Skip to content

Commit 8b35125

Browse files
Copilotkobenguyent
andauthored
fix: RangeError: Maximum call stack size exceeded in Socket.IO serialization (#563)
* Initial plan * Fix RangeError: Maximum call stack size exceeded in Socket.IO serialization Co-authored-by: kobenguyent <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: kobenguyent <[email protected]>
1 parent 578c075 commit 8b35125

File tree

4 files changed

+287
-26
lines changed

4 files changed

+287
-26
lines changed

lib/model/ws-events.js

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const { getUrl } = require('../config/url');
2+
const safeSerialize = require('../utils/safe-serialize');
23

34
// Only create socket connection when not in test environment
45
let socket;
@@ -63,61 +64,61 @@ module.exports = {
6364
],
6465
console: {
6566
jsError(err) {
66-
socket.emit('console.error', {
67+
socket.emit('console.error', safeSerialize({
6768
type: 'js',
6869
error: err,
69-
});
70+
}));
7071
},
7172
error(err) {
72-
socket.emit('console.error', {
73+
socket.emit('console.error', safeSerialize({
7374
type: 'error',
7475
error: err,
75-
});
76+
}));
7677
},
7778
log(type, url, lineno, args) {
78-
socket.emit('console.log', {
79+
socket.emit('console.log', safeSerialize({
7980
type,
8081
url,
8182
lineno,
8283
args
83-
});
84+
}));
8485
}
8586
},
8687
network: {
8788
failedRequest(data) {
88-
socket.emit('network.failed_request', data);
89+
socket.emit('network.failed_request', safeSerialize(data));
8990
}
9091
},
9192
rtr: {
9293
suiteBefore(data) {
93-
socket.emit('suite.before', data);
94+
socket.emit('suite.before', safeSerialize(data));
9495
},
9596
testBefore(data) {
96-
socket.emit('test.before', data);
97+
socket.emit('test.before', safeSerialize(data));
9798
},
9899
testAfter(data) {
99-
socket.emit('test.after', data);
100+
socket.emit('test.after', safeSerialize(data));
100101
},
101102
stepBefore(data) {
102-
socket.emit('step.before', data);
103+
socket.emit('step.before', safeSerialize(data));
103104
},
104105
stepAfter(data) {
105-
socket.emit('step.after', data);
106+
socket.emit('step.after', safeSerialize(data));
106107
},
107108
stepComment(comment) {
108-
socket.emit('step.comment', comment);
109+
socket.emit('step.comment', safeSerialize(comment));
109110
},
110111
stepPassed(data) {
111-
socket.emit('step.passed', data);
112+
socket.emit('step.passed', safeSerialize(data));
112113
},
113114
metaStepChanged(data) {
114-
socket.emit('metastep.changed', data);
115+
socket.emit('metastep.changed', safeSerialize(data));
115116
},
116117
testPassed(data) {
117-
socket.emit('test.passed', data);
118+
socket.emit('test.passed', safeSerialize(data));
118119
},
119120
testFailed(data) {
120-
socket.emit('test.failed', data);
121+
socket.emit('test.failed', safeSerialize(data));
121122
},
122123
testRunFinished() {
123124
socket.emit('testrun.finish');
@@ -131,32 +132,32 @@ module.exports = {
131132
socket.emit('codeceptjs:scenarios.updated');
132133
},
133134
scenariosParseError(err) {
134-
socket.emit('codeceptjs:scenarios.parseerror', {
135+
socket.emit('codeceptjs:scenarios.parseerror', safeSerialize({
135136
message: err.message,
136137
stack: err.stack,
137-
});
138+
}));
138139
},
139140
configUpdated(configFile) {
140-
socket.emit('codeceptjs:config.updated', {
141+
socket.emit('codeceptjs:config.updated', safeSerialize({
141142
file: configFile,
142143
timestamp: new Date().toISOString()
143-
});
144+
}));
144145
},
145146
fileChanged(filePath, changeType) {
146-
socket.emit('codeceptjs:file.changed', {
147+
socket.emit('codeceptjs:file.changed', safeSerialize({
147148
file: filePath,
148149
changeType: changeType, // 'add', 'change', 'unlink'
149150
timestamp: new Date().toISOString()
150-
});
151+
}));
151152
},
152153
started(data) {
153-
socket.emit('codeceptjs.started', data);
154+
socket.emit('codeceptjs.started', safeSerialize(data));
154155
},
155156
exit(data) {
156-
socket.emit('codeceptjs.exit', data);
157+
socket.emit('codeceptjs.exit', safeSerialize(data));
157158
},
158159
error(err) {
159-
socket.emit('codeceptjs.error', err);
160+
socket.emit('codeceptjs.error', safeSerialize(err));
160161
}
161162
}
162163
};

lib/utils/safe-serialize.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* Safely serializes objects by removing circular references and limiting depth
3+
* This prevents "Maximum call stack size exceeded" errors in Socket.IO serialization
4+
*/
5+
6+
/**
7+
* Creates a safe copy of an object with circular references resolved
8+
* @param {*} obj - The object to serialize safely
9+
* @param {number} maxDepth - Maximum recursion depth (default: 50)
10+
* @param {WeakSet} seen - Internally used to track visited objects
11+
* @returns {*} Safe copy of the object
12+
*/
13+
function safeSerialize(obj, maxDepth = 50, seen = new WeakSet()) {
14+
// Handle primitive types and null
15+
if (obj === null || typeof obj !== 'object') {
16+
return obj;
17+
}
18+
19+
// Check depth limit
20+
if (maxDepth <= 0) {
21+
return '[Object: max depth reached]';
22+
}
23+
24+
// Check for circular references
25+
if (seen.has(obj)) {
26+
return '[Circular Reference]';
27+
}
28+
29+
// Add to seen set
30+
seen.add(obj);
31+
32+
try {
33+
// Handle arrays
34+
if (Array.isArray(obj)) {
35+
return obj.map(item => safeSerialize(item, maxDepth - 1, seen));
36+
}
37+
38+
// Handle Error objects specially
39+
if (obj instanceof Error) {
40+
return {
41+
name: obj.name,
42+
message: obj.message,
43+
stack: obj.stack,
44+
code: obj.code,
45+
type: 'Error'
46+
};
47+
}
48+
49+
// Handle Date objects
50+
if (obj instanceof Date) {
51+
return obj.toISOString();
52+
}
53+
54+
// Handle regular expressions
55+
if (obj instanceof RegExp) {
56+
return obj.toString();
57+
}
58+
59+
// Handle Buffer objects
60+
if (typeof Buffer !== 'undefined' && Buffer.isBuffer && Buffer.isBuffer(obj)) {
61+
return '[Buffer]';
62+
}
63+
64+
// Handle plain objects
65+
const result = {};
66+
for (const key in obj) {
67+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
68+
try {
69+
result[key] = safeSerialize(obj[key], maxDepth - 1, seen);
70+
} catch (err) {
71+
result[key] = '[Serialization Error: ' + err.message + ']';
72+
}
73+
}
74+
}
75+
76+
return result;
77+
} catch (err) {
78+
return '[Serialization Error: ' + err.message + ']';
79+
} finally {
80+
// Remove from seen set when done with this branch
81+
seen.delete(obj);
82+
}
83+
}
84+
85+
module.exports = safeSerialize;

test/safe-serialize.spec.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
const test = require('ava');
2+
const safeSerialize = require('../lib/utils/safe-serialize');
3+
4+
test('safeSerialize handles circular references', (t) => {
5+
const obj = { name: 'test', id: 123 };
6+
obj.self = obj;
7+
8+
const result = safeSerialize(obj);
9+
10+
t.is(result.name, 'test');
11+
t.is(result.id, 123);
12+
t.is(result.self, '[Circular Reference]');
13+
});
14+
15+
test('safeSerialize handles Error objects with circular references', (t) => {
16+
const error = new Error('Test error');
17+
error.code = 'TEST_CODE';
18+
error.cause = error; // Create circular reference
19+
20+
const result = safeSerialize(error);
21+
22+
t.is(result.name, 'Error');
23+
t.is(result.message, 'Test error');
24+
t.is(result.code, 'TEST_CODE');
25+
t.is(result.type, 'Error');
26+
t.truthy(result.stack);
27+
});
28+
29+
test('safeSerialize limits recursion depth', (t) => {
30+
const deep = { level: 1 };
31+
let current = deep;
32+
for (let i = 2; i <= 60; i++) {
33+
current.next = { level: i };
34+
current = current.next;
35+
}
36+
37+
const result = safeSerialize(deep);
38+
const serialized = JSON.stringify(result);
39+
40+
t.true(serialized.includes('[Object: max depth reached]'));
41+
});
42+
43+
test('safeSerialize preserves normal objects', (t) => {
44+
const obj = {
45+
name: 'test',
46+
count: 42,
47+
tags: ['a', 'b'],
48+
nested: {
49+
value: 'nested'
50+
}
51+
};
52+
53+
const result = safeSerialize(obj);
54+
55+
t.deepEqual(result, obj);
56+
});
57+
58+
test('safeSerialize handles arrays', (t) => {
59+
const arr = [1, 2, { name: 'test' }];
60+
const result = safeSerialize(arr);
61+
62+
t.deepEqual(result, arr);
63+
});
64+
65+
test('safeSerialize handles Date objects', (t) => {
66+
const date = new Date('2023-01-01T00:00:00.000Z');
67+
const result = safeSerialize(date);
68+
69+
t.is(result, '2023-01-01T00:00:00.000Z');
70+
});
71+
72+
test('safeSerialize handles RegExp objects', (t) => {
73+
const regex = /test/gi;
74+
const result = safeSerialize(regex);
75+
76+
t.is(result, '/test/gi');
77+
});
78+
79+
test('safeSerialize handles null and undefined', (t) => {
80+
t.is(safeSerialize(null), null);
81+
t.is(safeSerialize(undefined), undefined);
82+
});
83+
84+
test('safeSerialize handles primitive types', (t) => {
85+
t.is(safeSerialize('string'), 'string');
86+
t.is(safeSerialize(123), 123);
87+
t.is(safeSerialize(true), true);
88+
t.is(safeSerialize(false), false);
89+
});

test/ws-events-circular-fix.spec.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
const test = require('ava');
2+
3+
// Mock the Socket.IO environment to test ws-events without actually connecting
4+
process.env.NODE_ENV = 'test';
5+
6+
const wsEvents = require('../lib/model/ws-events');
7+
8+
test('ws-events can emit error objects with circular references without crashing', (t) => {
9+
// Create an error object with a circular reference
10+
const error = new Error('Test error with circular reference');
11+
error.code = 'CIRCULAR_ERROR';
12+
error.cause = error; // Create circular reference
13+
14+
// This should not throw "Maximum call stack size exceeded"
15+
t.notThrows(() => {
16+
wsEvents.console.jsError(error);
17+
});
18+
19+
t.notThrows(() => {
20+
wsEvents.console.error(error);
21+
});
22+
23+
t.notThrows(() => {
24+
wsEvents.codeceptjs.error(error);
25+
});
26+
});
27+
28+
test('ws-events can emit objects with circular references without crashing', (t) => {
29+
// Create an object with circular reference
30+
const testData = {
31+
name: 'test',
32+
id: 123,
33+
steps: []
34+
};
35+
testData.parent = testData; // Create circular reference
36+
37+
// This should not throw "Maximum call stack size exceeded"
38+
t.notThrows(() => {
39+
wsEvents.rtr.testBefore(testData);
40+
});
41+
42+
t.notThrows(() => {
43+
wsEvents.rtr.testAfter(testData);
44+
});
45+
46+
t.notThrows(() => {
47+
wsEvents.rtr.stepBefore(testData);
48+
});
49+
50+
t.notThrows(() => {
51+
wsEvents.rtr.stepAfter(testData);
52+
});
53+
});
54+
55+
test('ws-events can handle complex nested objects', (t) => {
56+
// Create a deeply nested object that could potentially cause issues
57+
const complexData = {
58+
test: {
59+
suite: {
60+
title: 'Complex Test',
61+
tests: [
62+
{ title: 'Test 1', steps: [] },
63+
{ title: 'Test 2', steps: [] }
64+
]
65+
}
66+
},
67+
error: new Error('Complex error'),
68+
args: [1, 2, 3, { nested: { deep: { object: true } } }]
69+
};
70+
71+
// Add circular reference
72+
complexData.test.suite.parent = complexData;
73+
complexData.error.context = complexData;
74+
75+
t.notThrows(() => {
76+
wsEvents.network.failedRequest(complexData);
77+
});
78+
79+
t.notThrows(() => {
80+
wsEvents.codeceptjs.started(complexData);
81+
});
82+
83+
t.notThrows(() => {
84+
wsEvents.codeceptjs.exit(complexData);
85+
});
86+
});

0 commit comments

Comments
 (0)