Skip to content

Commit d573726

Browse files
faizanu94aduh95
authored andcommitted
test_runner: do not crash on stdout that mimics a v8 frame
The child test process sends framed report messages and raw user stdout over one pipe, using the bytes FF 0F to mark the start of a frame. User output can contain those same bytes, so #processRawBuffer could read a plausible size from stray stdout and hand the bytes to the v8 deserializer. The deserializer then threw. Because the call had no error handling, the exception aborted the whole test run. Read the frame before advancing the buffer and wrap the deserialize in a try/catch. When the read fails, leave the buffer untouched and stop parsing frames so #drainRawBuffer emits the stray byte as stdout and rescans for the next real header. This turns a fatal crash into recoverable stdout and preserves any real frames that follow the stray bytes. Fixes: #66164 Signed-off-by: Muhammad Faizan Uddin <faizan.uddin94@gmail.com> PR-URL: #66273 Reviewed-By: Moshe Atlow <moshe@atlow.co.il> Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
1 parent c65a333 commit d573726

2 files changed

Lines changed: 175 additions & 39 deletions

File tree

‎lib/internal/test_runner/runner.js‎

Lines changed: 65 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,6 @@ const {
8484
kTestTimeoutFailure,
8585
Test,
8686
} = require('internal/test_runner/test');
87-
const { FastBuffer } = require('internal/buffer');
88-
8987
const {
9088
createRandomSeed,
9189
convertStringToRegExp,
@@ -466,59 +464,87 @@ class FileTest extends Test {
466464
}
467465
}
468466
#processRawBuffer() {
469-
// This method is called when it is known that there is at least one message
470-
let bufferHead = this.#rawBuffer[0];
471-
let headerIndex = bufferHead.indexOf(v8Header);
472-
let nonSerialized = new FastBuffer();
473-
474-
while (bufferHead && headerIndex !== 0) {
475-
const nonSerializedData = headerIndex === -1 ?
476-
bufferHead :
477-
bufferHead.slice(0, headerIndex);
478-
nonSerialized = Buffer.concat([nonSerialized, nonSerializedData]);
479-
this.#rawBufferSize -= TypedArrayPrototypeGetLength(nonSerializedData);
480-
if (headerIndex === -1) {
481-
ArrayPrototypeShift(this.#rawBuffer);
482-
} else {
483-
this.#rawBuffer[0] = TypedArrayPrototypeSubarray(bufferHead, headerIndex);
467+
// This method is called when it is known that there is at least one message.
468+
// Each pass looks at the head of the buffer and handles exactly one of four
469+
// cases, then re-checks the new head. Recovery from stray stdout that only
470+
// mimics a frame happens inline here, so no separate resync pass is needed.
471+
while (this.#rawBuffer.length > 0) {
472+
const bufferHead = this.#rawBuffer[0];
473+
const headerIndex = bufferHead.indexOf(v8Header);
474+
475+
// 1. The head does not start with a frame header. Emit the bytes before
476+
// the next header, or the whole head when there is none, as stdout and
477+
// advance to the next header.
478+
if (headerIndex !== 0) {
479+
const nonSerialized = headerIndex === -1 ?
480+
bufferHead : TypedArrayPrototypeSubarray(bufferHead, 0, headerIndex);
481+
this.addToReport({
482+
__proto__: null,
483+
type: 'test:stdout',
484+
data: { __proto__: null, file: this.name, message: nonSerialized.toString('utf-8') },
485+
});
486+
this.#rawBufferSize -= TypedArrayPrototypeGetLength(nonSerialized);
487+
if (headerIndex === -1) {
488+
ArrayPrototypeShift(this.#rawBuffer);
489+
} else {
490+
this.#rawBuffer[0] = TypedArrayPrototypeSubarray(bufferHead, headerIndex);
491+
}
492+
continue;
484493
}
485-
bufferHead = this.#rawBuffer[0];
486-
headerIndex = bufferHead?.indexOf(v8Header);
487-
}
488-
489-
if (TypedArrayPrototypeGetLength(nonSerialized) > 0) {
490-
this.addToReport({
491-
__proto__: null,
492-
type: 'test:stdout',
493-
data: { __proto__: null, file: this.name, message: nonSerialized.toString('utf-8') },
494-
});
495-
}
496494

497-
while (bufferHead?.length >= kSerializedSizeHeader) {
498-
// We call `readUInt32BE` manually here, because this is faster than first converting
499-
// it to a buffer and using `readUInt32BE` on that.
495+
// 2. The head starts with a header but the whole frame has not arrived
496+
// yet. Stop and wait for more data.
497+
if (TypedArrayPrototypeGetLength(bufferHead) < kSerializedSizeHeader) {
498+
break;
499+
}
500+
// We call `readUInt32BE` manually here, because this is faster than first
501+
// converting it to a buffer and using `readUInt32BE` on that.
500502
const fullMessageSize = ((
501503
bufferHead[kV8HeaderLength] << 24 |
502504
bufferHead[kV8HeaderLength + 1] << 16 |
503505
bufferHead[kV8HeaderLength + 2] << 8 |
504506
bufferHead[kV8HeaderLength + 3]
505507
) >>> 0) + kSerializedSizeHeader;
506-
507-
if (this.#rawBufferSize < fullMessageSize) break;
508+
if (this.#rawBufferSize < fullMessageSize) {
509+
break;
510+
}
508511

509512
const concatenatedBuffer = this.#rawBuffer.length === 1 ?
510-
this.#rawBuffer[0] : Buffer.concat(this.#rawBuffer, this.#rawBufferSize);
513+
bufferHead : Buffer.concat(this.#rawBuffer, this.#rawBufferSize);
514+
515+
// 3. The head only mimics a frame. A real frame repeats the v8 header at
516+
// the start of its payload, right before the serialized value, so a
517+
// payload too short for that header, or one that does not start with
518+
// it, is stray stdout. Emit one byte and let the next pass resync on
519+
// the following header. A genuine frame that fails to deserialize is
520+
// left to throw, so real report-protocol regressions are not hidden.
521+
if (fullMessageSize - kSerializedSizeHeader < kV8HeaderLength ||
522+
concatenatedBuffer.indexOf(v8Header, kSerializedSizeHeader) !== kSerializedSizeHeader) {
523+
this.addToReport({
524+
__proto__: null,
525+
type: 'test:stdout',
526+
data: { __proto__: null, file: this.name, message: StringFromCharCode(bufferHead[0]) },
527+
});
528+
if (TypedArrayPrototypeGetLength(bufferHead) === 1) {
529+
ArrayPrototypeShift(this.#rawBuffer);
530+
} else {
531+
this.#rawBuffer[0] = TypedArrayPrototypeSubarray(bufferHead, 1);
532+
}
533+
this.#rawBufferSize--;
534+
continue;
535+
}
511536

537+
// 4. A real frame. Deserialize it and continue from the remaining bytes.
512538
const deserializer = new DefaultDeserializer(
513539
TypedArrayPrototypeSubarray(concatenatedBuffer, kSerializedSizeHeader, fullMessageSize),
514540
);
515-
516-
bufferHead = TypedArrayPrototypeSubarray(concatenatedBuffer, fullMessageSize);
517-
this.#rawBufferSize = TypedArrayPrototypeGetLength(bufferHead);
518-
this.#rawBuffer = this.#rawBufferSize !== 0 ? [bufferHead] : [];
519-
520541
deserializer.readHeader();
521542
const item = deserializer.readValue();
543+
544+
const remaining = TypedArrayPrototypeSubarray(concatenatedBuffer, fullMessageSize);
545+
this.#rawBufferSize = TypedArrayPrototypeGetLength(remaining);
546+
this.#rawBuffer = this.#rawBufferSize !== 0 ? [remaining] : [];
547+
522548
this.addToReport(item);
523549
}
524550
}

‎test/parallel/test-runner-v8-deserializer.mjs‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,38 @@ const oversizedLengthStdout = String.fromCharCode(oversizedLengthHeader[0]) +
3939
Buffer.from(oversizedLengthHeader.subarray(1)).toString('utf-8');
4040
const unsignedOversizedLengthStdout = String.fromCharCode(unsignedOversizedLengthHeader[0]) +
4141
Buffer.from(unsignedOversizedLengthHeader.subarray(1)).toString('utf-8');
42+
// FF 0F followed by a small, plausible size (8) and 8 payload bytes. Unlike the
43+
// oversized headers above, this passes the size check, but its payload does not
44+
// begin with the inner v8 header a real frame carries, so it is treated as
45+
// stdout instead of reaching the deserializer.
46+
// Regression fixture for https://github.com/nodejs/node/issues/66164
47+
const plausibleSizeFalseHeader = Buffer.from([
48+
0xff, 0x0f, // V8 serializer header magic
49+
0x00, 0x00, 0x00, 0x08, // Payload size of 8 bytes
50+
0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, // "ABCDEFGH", not a real payload
51+
]);
52+
const plausibleSizeFalseHeaderStdout = String.fromCharCode(plausibleSizeFalseHeader[0]) +
53+
Buffer.from(plausibleSizeFalseHeader.subarray(1)).toString('utf-8');
54+
// FF 0F, a valid size, then the inner v8 header a real frame repeats, followed
55+
// by a byte that is not a valid serialized value. This passes the inner header
56+
// check and reaches the deserializer, which throws. This is what a genuine
57+
// report-protocol regression looks like, so the parser must let the error
58+
// surface instead of hiding it as stdout.
59+
const headeredCorruptFrame = Buffer.from([
60+
0xff, 0x0f, // Outer v8 serializer header magic
61+
0x00, 0x00, 0x00, 0x03, // Payload size of 3 bytes
62+
0xff, 0x0f, // Inner v8 header that a real frame repeats
63+
0xee, // Not a valid serialized value
64+
]);
65+
// FF 0F with a declared size of 1, then more header bytes. The payload is
66+
// shorter than the inner v8 header a real frame carries, so it can never be a
67+
// real frame. The length guard must reject it as stdout without reaching the
68+
// deserializer.
69+
const shortPayloadFalseHeader = Buffer.from([
70+
0xff, 0x0f, // Outer v8 serializer header magic
71+
0x00, 0x00, 0x00, 0x01, // Payload size of 1 byte, too short for a header
72+
0xff, 0x0f, // Trailing bytes that also look like a header
73+
]);
4274

4375
function collectStdout(reported) {
4476
return reported
@@ -169,6 +201,84 @@ describe('v8 deserializer', common.mustCall(() => {
169201
assert.strictEqual(collectStdout(reported), oversizedLengthStdout);
170202
});
171203

204+
it('should not crash when stdout mimics a v8 frame with a plausible size', async () => {
205+
// Regression test for https://github.com/nodejs/node/issues/66164
206+
// The payload does not start with the inner v8 header that a real frame
207+
// carries, so the parser treats the bytes as stdout instead of handing
208+
// them to the deserializer and aborting the whole run.
209+
const reported = await collectReported([plausibleSizeFalseHeader]);
210+
assert(reported.every((event) => event.type === 'test:stdout'));
211+
assert.strictEqual(collectStdout(reported), plausibleSizeFalseHeaderStdout);
212+
});
213+
214+
it('should resync live and report a real message after a false frame', async () => {
215+
// Feed the poison bytes then a real message but never call drain(). Recovery
216+
// must happen live, so the real event is reported right away. If resync only
217+
// ran at shutdown, the diagnostic would still be buffered and missing here.
218+
// The reporter is a stream, so flush it with end() and finished() before
219+
// asserting, rather than reading it synchronously.
220+
fileTest.parseMessage(plausibleSizeFalseHeader);
221+
chunks.forEach((chunk) => fileTest.parseMessage(chunk));
222+
fileTest.reporter.end();
223+
await finished(fileTest.reporter);
224+
assert.deepStrictEqual(reported.at(-1), reportedDiagnosticEvent);
225+
assert.strictEqual(reported.filter((event) => event.type === 'test:diagnostic').length, 1);
226+
assert.strictEqual(collectStdout(reported), plausibleSizeFalseHeaderStdout);
227+
});
228+
229+
it('should preserve real messages on both sides of a plausible-size false frame', async () => {
230+
// A real message, then the poison bytes, then another real message. Both
231+
// real messages must survive and the poison bytes must become stdout.
232+
const reported = await collectReported([
233+
...chunks,
234+
plausibleSizeFalseHeader,
235+
...chunks,
236+
]);
237+
const diagnostics = reported.filter((event) => event.type === 'test:diagnostic');
238+
assert.strictEqual(diagnostics.length, 2);
239+
diagnostics.forEach((event) => assert.deepStrictEqual(event, reportedDiagnosticEvent));
240+
assert.strictEqual(collectStdout(reported), plausibleSizeFalseHeaderStdout);
241+
});
242+
243+
it('should recover from a plausible-size false frame split across chunks', async () => {
244+
// The same poison bytes arriving in two chunks must still be treated as
245+
// stdout without crashing.
246+
const reported = await collectReported([
247+
plausibleSizeFalseHeader.subarray(0, 3),
248+
plausibleSizeFalseHeader.subarray(3),
249+
]);
250+
assert(reported.every((event) => event.type === 'test:stdout'));
251+
assert.strictEqual(collectStdout(reported), plausibleSizeFalseHeaderStdout);
252+
});
253+
254+
it('should resync through several stray frames in a row', async () => {
255+
// Two false frames back to back in one read, then a real one. The parser
256+
// must peel each stray frame off as stdout and still report the real event.
257+
const reported = await collectReported([
258+
Buffer.concat([plausibleSizeFalseHeader, plausibleSizeFalseHeader, ...chunks]),
259+
]);
260+
assert.deepStrictEqual(reported.at(-1), reportedDiagnosticEvent);
261+
assert.strictEqual(reported.filter((event) => event.type === 'test:diagnostic').length, 1);
262+
assert.strictEqual(collectStdout(reported),
263+
plausibleSizeFalseHeaderStdout + plausibleSizeFalseHeaderStdout);
264+
});
265+
266+
it('should surface a genuinely corrupt frame instead of hiding it', () => {
267+
// A frame with both v8 headers and a valid size but an invalid value is
268+
// what a real report-protocol regression looks like, not stray stdout.
269+
// The parser must let the deserialize error surface instead of silently
270+
// turning it into stdout.
271+
assert.throws(() => fileTest.parseMessage(headeredCorruptFrame), /deserialize/);
272+
});
273+
274+
it('should treat a frame whose payload is shorter than the header as stdout', async () => {
275+
// The declared size is smaller than the inner v8 header, so the length
276+
// guard must reject the bytes as stdout instead of reaching the
277+
// deserializer.
278+
const reported = await collectReported([shortPayloadFalseHeader]);
279+
assert(reported.every((event) => event.type === 'test:stdout'));
280+
});
281+
172282
const headerPosition = headerLength * 2 + 4;
173283
for (let i = 0; i < headerPosition + 5; i++) {
174284
const message = `should deserialize a serialized message split into two chunks {...${i},${i + 1}...}`;

0 commit comments

Comments
 (0)