Skip to content

Commit 1fc657d

Browse files
Copilotkobenguyent
andauthored
fix: JavaScript heap out of memory errors in browser tests (#569)
* Initial plan * Initial analysis of memory heap issue in browser tests Co-authored-by: kobenguyent <[email protected]> * Fix memory heap overflow issues in browser tests - Replace synchronous fs.readFileSync with streaming in get-file.js - Add 10MB file size limit to prevent memory overload - Add memory cleanup for worker processes with bounded arrays - Enhance safe-serialize with memory limits for arrays and objects - Add comprehensive test coverage for memory fixes - Maintain backward compatibility with existing functionality Co-authored-by: kobenguyent <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: kobenguyent <[email protected]>
1 parent be4c59e commit 1fc657d

File tree

7 files changed

+30487
-25364
lines changed

7 files changed

+30487
-25364
lines changed

lib/api/get-file.js

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,56 @@
1-
const fs = require('fs').promises;
1+
const fs = require('fs');
2+
const path = require('path');
23

3-
module.exports = (req, res) => {
4-
if (!req.file) return;
5-
if (!req.file.startsWith(global.codecept_dir)) return; // not a codecept file
6-
res.send(fs.readFileSync(req.file));
4+
// Maximum file size to prevent memory issues (10MB)
5+
const MAX_FILE_SIZE = 10 * 1024 * 1024;
6+
7+
module.exports = async (req, res) => {
8+
if (!req.file) {
9+
return res.status(400).json({ error: 'File parameter is required' });
10+
}
11+
12+
if (!req.file.startsWith(global.codecept_dir)) {
13+
return res.status(403).json({ error: 'Access denied. File must be within codecept directory' });
14+
}
15+
16+
try {
17+
// Check if file exists and get stats
18+
const stats = await fs.promises.stat(req.file);
19+
20+
// Check file size to prevent memory issues
21+
if (stats.size > MAX_FILE_SIZE) {
22+
return res.status(413).json({
23+
error: 'File too large',
24+
message: `File size (${stats.size} bytes) exceeds maximum allowed size (${MAX_FILE_SIZE} bytes)`
25+
});
26+
}
27+
28+
// Use streaming for better memory management
29+
const readStream = fs.createReadStream(req.file);
30+
31+
// Set appropriate headers
32+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
33+
res.setHeader('Content-Length', stats.size);
34+
35+
// Handle stream errors
36+
readStream.on('error', (error) => {
37+
console.error('Error reading file:', error);
38+
if (!res.headersSent) {
39+
res.status(500).json({
40+
error: 'Failed to read file',
41+
message: error.message
42+
});
43+
}
44+
});
45+
46+
// Pipe the file directly to response to avoid loading into memory
47+
readStream.pipe(res);
48+
49+
} catch (error) {
50+
console.error('Error accessing file:', error);
51+
res.status(404).json({
52+
error: 'File not found',
53+
message: error.message
54+
});
55+
}
756
};

lib/model/codeceptjs-run-workers/index.js

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ const stats = {
1717
tests: 0,
1818
};
1919

20+
// Memory management constants
21+
const MAX_ERRORS = 1000; // Maximum errors to keep in memory
22+
const MAX_FINISHED_TESTS = 500; // Maximum finished tests to keep in memory
2023

2124
let numberOfWorkersClosed = 0;
2225
const hasFailures = () => stats.failures || errors.length;
@@ -74,8 +77,15 @@ module.exports = function (workers, config, options, codecept, container) {
7477

7578
switch (message.event) {
7679
case event.test.failed:
77-
finishedTests.push(repackTest(message.data));
78-
output.test.failed(repackTest(message.data));
80+
const failedTest = repackTest(message.data);
81+
finishedTests.push(failedTest);
82+
83+
// Prevent memory buildup by limiting finished tests
84+
if (finishedTests.length > MAX_FINISHED_TESTS) {
85+
finishedTests.shift(); // Remove oldest test
86+
}
87+
88+
output.test.failed(failedTest);
7989
break;
8090
case event.test.passed: output.test.passed(repackTest(message.data)); break;
8191
case event.suite.before: output.suite.started(message.data); break;
@@ -86,6 +96,12 @@ module.exports = function (workers, config, options, codecept, container) {
8696

8797
worker.on('error', (err) => {
8898
errors.push(err);
99+
100+
// Prevent memory buildup by limiting error array size
101+
if (errors.length > MAX_ERRORS) {
102+
errors.shift(); // Remove oldest error
103+
}
104+
89105
// eslint-disable-next-line no-console
90106
console.error(err);
91107
});
@@ -102,6 +118,9 @@ module.exports = function (workers, config, options, codecept, container) {
102118
}
103119
// eslint-disable-next-line no-console
104120
console.log('FInished Successfully');
121+
122+
// Clean up memory after test run completion
123+
cleanupWorkerMemory();
105124
// process.exit(0);
106125
});
107126
}
@@ -162,6 +181,23 @@ function repackTest(test) {
162181
// return Object.assign(new Suite(suite.title), suite);
163182
// }
164183

184+
/**
185+
* Clean up memory after test run completion to prevent memory leaks
186+
*/
187+
function cleanupWorkerMemory() {
188+
// Clear arrays
189+
finishedTests.length = 0;
190+
errors.length = 0;
191+
192+
// Reset counters
193+
numberOfWorkersClosed = 0;
194+
195+
// Force garbage collection if available (for debugging/testing)
196+
if (global.gc) {
197+
global.gc();
198+
}
199+
}
200+
165201
function simplifyObject(object, remove = {}) {
166202
const defaultRemove = {
167203
objects: true,

lib/utils/safe-serialize.js

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,20 @@
88
* @param {*} obj - The object to serialize safely
99
* @param {number} maxDepth - Maximum recursion depth (default: 50)
1010
* @param {WeakSet} seen - Internally used to track visited objects
11+
* @param {number} maxProperties - Maximum properties per object (default: 100)
1112
* @returns {*} Safe copy of the object
1213
*/
13-
function safeSerialize(obj, maxDepth = 50, seen = new WeakSet()) {
14-
// Handle primitive types and null
15-
if (obj === null || typeof obj !== 'object') {
14+
function safeSerialize(obj, maxDepth = 50, seen = new WeakSet(), maxProperties = 100) {
15+
// Handle primitive types and null (but not functions)
16+
if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) {
1617
return obj;
1718
}
1819

20+
// Handle Functions explicitly (they are not objects but need special handling)
21+
if (typeof obj === 'function') {
22+
return '[Function: ' + (obj.name || 'anonymous') + ']';
23+
}
24+
1925
// Check depth limit
2026
if (maxDepth <= 0) {
2127
return '[Object: max depth reached]';
@@ -32,15 +38,21 @@ function safeSerialize(obj, maxDepth = 50, seen = new WeakSet()) {
3238
try {
3339
// Handle arrays
3440
if (Array.isArray(obj)) {
35-
return obj.map(item => safeSerialize(item, maxDepth - 1, seen));
41+
// Limit array size to prevent memory issues
42+
if (obj.length > 1000) {
43+
const truncated = obj.slice(0, 1000).map(item => safeSerialize(item, maxDepth - 1, seen, maxProperties));
44+
truncated.push(`[Array truncated - original length: ${obj.length}]`);
45+
return truncated;
46+
}
47+
return obj.map(item => safeSerialize(item, maxDepth - 1, seen, maxProperties));
3648
}
3749

3850
// Handle Error objects specially
3951
if (obj instanceof Error) {
4052
return {
4153
name: obj.name,
4254
message: obj.message,
43-
stack: obj.stack,
55+
stack: obj.stack ? obj.stack.substring(0, 2000) : undefined, // Limit stack trace length
4456
code: obj.code,
4557
type: 'Error'
4658
};
@@ -58,17 +70,27 @@ function safeSerialize(obj, maxDepth = 50, seen = new WeakSet()) {
5870

5971
// Handle Buffer objects
6072
if (typeof Buffer !== 'undefined' && Buffer.isBuffer && Buffer.isBuffer(obj)) {
61-
return '[Buffer]';
73+
return `[Buffer: ${obj.length} bytes]`;
6274
}
6375

6476
// Handle plain objects
6577
const result = {};
78+
let propertyCount = 0;
79+
6680
for (const key in obj) {
6781
if (Object.prototype.hasOwnProperty.call(obj, key)) {
82+
// Limit properties to prevent memory issues
83+
if (propertyCount >= maxProperties) {
84+
result['[...truncated]'] = `Object had ${Object.keys(obj).length} properties, showing first ${maxProperties}`;
85+
break;
86+
}
87+
6888
try {
69-
result[key] = safeSerialize(obj[key], maxDepth - 1, seen);
89+
result[key] = safeSerialize(obj[key], maxDepth - 1, seen, maxProperties);
90+
propertyCount++;
7091
} catch (err) {
7192
result[key] = '[Serialization Error: ' + err.message + ']';
93+
propertyCount++;
7294
}
7395
}
7496
}

0 commit comments

Comments
 (0)