Files
node/test/parallel/test-runner-v8-deserializer.mjs
Joyee Cheung cfb6aa1749 test: do not hardcode V8 serialization header
The header changes when V8 is updated. Do not hardcode it to
avoid breaking V8 updates. The update should only surface in
test/parallel/test-v8-serdes.js

Signed-off-by: Joyee Cheung <joyeec9h3@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/66442
Refs: https://github.com/nodejs/node/pull/65161
Reviewed-By: Shelley Vohr <shelley.vohr@gmail.com>
Reviewed-By: Richard Lau <richard.lau@ibm.com>
2026-10-02 16:56:58 +00:00

314 lines
14 KiB
JavaScript

// Flags: --expose-internals --no-warnings
import * as common from '../common/index.mjs';
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert';
import { finished } from 'node:stream/promises';
import { DefaultSerializer } from 'node:v8';
import { resolve } from 'node:path';
import serializer from 'internal/test_runner/reporter/v8-serializer';
import runner from 'internal/test_runner/runner';
async function toArray(chunks) {
const arr = [];
for await (const i of chunks) arr.push(i);
return arr;
}
const entryFile = resolve('filetest');
const diagnosticEvent = {
type: 'test:diagnostic',
data: { nesting: 0, details: {}, message: 'diagnostic' },
};
const reportedDiagnosticEvent = {
type: 'test:diagnostic',
data: { ...diagnosticEvent.data, entryFile },
};
const chunks = await toArray(serializer([diagnosticEvent]));
const defaultSerializer = new DefaultSerializer();
defaultSerializer.writeHeader();
const headerOnly = Buffer.from(defaultSerializer.releaseBuffer());
const headerLength = headerOnly.length;
const oversizedLengthHeader = Buffer.concat([headerOnly, Buffer.from([0x7f, 0xff, 0xff, 0xff])]);
const unsignedOversizedLengthHeader = Buffer.concat([headerOnly, Buffer.from([0x80, 0x00, 0x00, 0x00])]);
const truncatedLengthHeader = Buffer.concat([headerOnly, Buffer.from([0x00, 0x01, 0x00, 0x00])]);
// Expected stdout for oversizedLengthHeader: first byte is emitted via
// String.fromCharCode (byte-by-byte fallback in #drainRawBuffer), remaining
// bytes go through the nonSerialized UTF-8 decode path in #processRawBuffer.
const oversizedLengthStdout = String.fromCharCode(oversizedLengthHeader[0]) +
Buffer.from(oversizedLengthHeader.subarray(1)).toString('utf-8');
const unsignedOversizedLengthStdout = String.fromCharCode(unsignedOversizedLengthHeader[0]) +
Buffer.from(unsignedOversizedLengthHeader.subarray(1)).toString('utf-8');
function payloadSize(size) {
const buffer = Buffer.alloc(4);
buffer.writeUInt32BE(size);
return buffer;
}
// The v8 header followed by a small, plausible size (8) and 8 payload bytes. Unlike the
// oversized headers above, this passes the size check, but its payload does not
// begin with the inner v8 header a real frame carries, so it is treated as
// stdout instead of reaching the deserializer.
// Regression fixture for https://github.com/nodejs/node/issues/66164
const plausibleSizeFalseHeader = Buffer.concat([
headerOnly,
payloadSize(8),
Buffer.from('ABCDEFGH'), // Not a real payload
]);
const plausibleSizeFalseHeaderStdout = String.fromCharCode(plausibleSizeFalseHeader[0]) +
Buffer.from(plausibleSizeFalseHeader.subarray(1)).toString('utf-8');
// The v8 header, a valid size, then the inner v8 header a real frame repeats, followed
// by a byte that is not a valid serialized value. This passes the inner header
// check and reaches the deserializer, which throws. This is what a genuine
// report-protocol regression looks like, so the parser must let the error
// surface instead of hiding it as stdout.
const headeredCorruptFrame = Buffer.concat([
headerOnly,
payloadSize(headerLength + 1),
headerOnly,
Buffer.from([0xee]), // Not a valid serialized value
]);
// The v8 header with a declared size of 1, then more header bytes. The payload is
// shorter than the inner v8 header a real frame carries, so it can never be a
// real frame. The length guard must reject it as stdout without reaching the
// deserializer.
const shortPayloadFalseHeader = Buffer.concat([
headerOnly,
payloadSize(1),
headerOnly, // Trailing bytes that also look like a header
]);
function collectStdout(reported) {
return reported
.filter((event) => event.type === 'test:stdout')
.map((event) => event.data.message)
.join('');
}
describe('v8 deserializer', common.mustCall(() => {
let fileTest;
let reported;
beforeEach(common.mustCallAtLeast(() => {
reported = [];
fileTest = new runner.FileTest({
name: 'filetest',
harness: { config: {} },
});
fileTest.reporter.on('data', (data) => reported.push(data));
assert(fileTest.isClearToSend());
}));
async function collectReported(chunks) {
chunks.forEach((chunk) => fileTest.parseMessage(chunk));
fileTest.drain();
fileTest.reporter.end();
await finished(fileTest.reporter);
return reported;
}
it('should do nothing when no chunks', async () => {
const reported = await collectReported([]);
assert.deepStrictEqual(reported, []);
});
it('should deserialize a chunk with no serialization', async () => {
const reported = await collectReported([Buffer.from('unknown')]);
assert.deepStrictEqual(reported, [
{ data: { __proto__: null, entryFile, file: 'filetest', message: 'unknown' }, type: 'test:stdout' },
]);
});
it('should deserialize a serialized chunk', async () => {
const reported = await collectReported(chunks);
assert.deepStrictEqual(reported, [reportedDiagnosticEvent]);
});
it('should serialize a repeated object as independent messages', async () => {
const repeatedChunks = await toArray(serializer([diagnosticEvent, diagnosticEvent]));
const reported = await collectReported(repeatedChunks);
assert.deepStrictEqual(reported, [
reportedDiagnosticEvent,
reportedDiagnosticEvent,
]);
});
it('should deserialize a serialized chunk after non-serialized chunk', async () => {
const reported = await collectReported([Buffer.concat([Buffer.from('unknown'), ...chunks])]);
assert.deepStrictEqual(reported, [
{ data: { __proto__: null, entryFile, file: 'filetest', message: 'unknown' }, type: 'test:stdout' },
reportedDiagnosticEvent,
]);
});
it('should deserialize a serialized chunk before non-serialized output', async () => {
const reported = await collectReported([Buffer.concat([ ...chunks, Buffer.from('unknown')])]);
assert.deepStrictEqual(reported, [
reportedDiagnosticEvent,
{ data: { __proto__: null, entryFile, file: 'filetest', message: 'unknown' }, type: 'test:stdout' },
]);
});
it('should not hang when buffer starts with v8Header followed by oversized length', async () => {
// Regression test for https://github.com/nodejs/node/issues/62693
// The v8 serializer header is followed by 4 bytes read as a big-endian
// message size. 0x7FFFFFFF far exceeds any actual buffer size, causing
// #processRawBuffer to make no progress and #drainRawBuffer to loop
// forever without the no-progress guard.
const reported = await collectReported([oversizedLengthHeader]);
assert.partialDeepStrictEqual(
reported,
Array.from({ length: reported.length }, () => ({ type: 'test:stdout' })),
);
assert.strictEqual(collectStdout(reported), oversizedLengthStdout);
});
it('should read an oversized length as an unsigned integer', async () => {
const reported = await collectReported([unsignedOversizedLengthHeader]);
assert(reported.every((event) => event.type === 'test:stdout'));
assert.strictEqual(collectStdout(reported), unsignedOversizedLengthStdout);
});
it('should flush incomplete v8 frame as stdout and keep prior valid data', async () => {
// A valid non-serialized message followed by bytes that look like
// a v8 header with a truncated/oversized length.
const reported = await collectReported([
Buffer.from('hello'),
truncatedLengthHeader,
]);
assert.strictEqual(collectStdout(reported), `hello${truncatedLengthHeader.toString('latin1')}`);
});
it('should flush v8Header-only bytes as stdout when stream ends', async () => {
// Just the v8 header bytes with no size field at all.
const reported = await collectReported([headerOnly]);
assert(reported.every((event) => event.type === 'test:stdout'));
assert.strictEqual(collectStdout(reported), headerOnly.toString('latin1'));
});
it('should resync and parse valid messages after false v8 header', async () => {
// A false v8 header (header bytes + oversized length) followed by a
// legitimate serialized message. The parser must skip the corrupt
// bytes and still deserialize the real message.
const reported = await collectReported([
oversizedLengthHeader,
...chunks,
]);
assert.deepStrictEqual(reported.at(-1), reportedDiagnosticEvent);
assert.strictEqual(reported.filter((event) => event.type === 'test:diagnostic').length, 1);
assert.strictEqual(collectStdout(reported), oversizedLengthStdout);
});
it('should preserve a false v8 header split across chunks', async () => {
const reported = await collectReported([
oversizedLengthHeader.subarray(0, 1),
oversizedLengthHeader.subarray(1),
]);
assert(reported.every((event) => event.type === 'test:stdout'));
assert.strictEqual(collectStdout(reported), oversizedLengthStdout);
});
it('should not crash when stdout mimics a v8 frame with a plausible size', async () => {
// Regression test for https://github.com/nodejs/node/issues/66164
// The payload does not start with the inner v8 header that a real frame
// carries, so the parser treats the bytes as stdout instead of handing
// them to the deserializer and aborting the whole run.
const reported = await collectReported([plausibleSizeFalseHeader]);
assert(reported.every((event) => event.type === 'test:stdout'));
assert.strictEqual(collectStdout(reported), plausibleSizeFalseHeaderStdout);
});
it('should resync live and report a real message after a false frame', async () => {
// Feed the poison bytes then a real message but never call drain(). Recovery
// must happen live, so the real event is reported right away. If resync only
// ran at shutdown, the diagnostic would still be buffered and missing here.
// The reporter is a stream, so flush it with end() and finished() before
// asserting, rather than reading it synchronously.
fileTest.parseMessage(plausibleSizeFalseHeader);
chunks.forEach((chunk) => fileTest.parseMessage(chunk));
fileTest.reporter.end();
await finished(fileTest.reporter);
assert.deepStrictEqual(reported.at(-1), reportedDiagnosticEvent);
assert.strictEqual(reported.filter((event) => event.type === 'test:diagnostic').length, 1);
assert.strictEqual(collectStdout(reported), plausibleSizeFalseHeaderStdout);
});
it('should preserve real messages on both sides of a plausible-size false frame', async () => {
// A real message, then the poison bytes, then another real message. Both
// real messages must survive and the poison bytes must become stdout.
const reported = await collectReported([
...chunks,
plausibleSizeFalseHeader,
...chunks,
]);
const diagnostics = reported.filter((event) => event.type === 'test:diagnostic');
assert.strictEqual(diagnostics.length, 2);
diagnostics.forEach((event) => assert.deepStrictEqual(event, reportedDiagnosticEvent));
assert.strictEqual(collectStdout(reported), plausibleSizeFalseHeaderStdout);
});
it('should recover from a plausible-size false frame split across chunks', async () => {
// The same poison bytes arriving in two chunks must still be treated as
// stdout without crashing.
const reported = await collectReported([
plausibleSizeFalseHeader.subarray(0, 3),
plausibleSizeFalseHeader.subarray(3),
]);
assert(reported.every((event) => event.type === 'test:stdout'));
assert.strictEqual(collectStdout(reported), plausibleSizeFalseHeaderStdout);
});
it('should resync through several stray frames in a row', async () => {
// Two false frames back to back in one read, then a real one. The parser
// must peel each stray frame off as stdout and still report the real event.
const reported = await collectReported([
Buffer.concat([plausibleSizeFalseHeader, plausibleSizeFalseHeader, ...chunks]),
]);
assert.deepStrictEqual(reported.at(-1), reportedDiagnosticEvent);
assert.strictEqual(reported.filter((event) => event.type === 'test:diagnostic').length, 1);
assert.strictEqual(collectStdout(reported),
plausibleSizeFalseHeaderStdout + plausibleSizeFalseHeaderStdout);
});
it('should surface a genuinely corrupt frame instead of hiding it', () => {
// A frame with both v8 headers and a valid size but an invalid value is
// what a real report-protocol regression looks like, not stray stdout.
// The parser must let the deserialize error surface instead of silently
// turning it into stdout.
assert.throws(() => fileTest.parseMessage(headeredCorruptFrame), /deserialize/);
});
it('should treat a frame whose payload is shorter than the header as stdout', async () => {
// The declared size is smaller than the inner v8 header, so the length
// guard must reject the bytes as stdout instead of reaching the
// deserializer.
const reported = await collectReported([shortPayloadFalseHeader]);
assert(reported.every((event) => event.type === 'test:stdout'));
});
const headerPosition = headerLength * 2 + 4;
for (let i = 0; i < headerPosition + 5; i++) {
const message = `should deserialize a serialized message split into two chunks {...${i},${i + 1}...}`;
it(message, async () => {
const data = chunks[0];
const reported = await collectReported([data.subarray(0, i), data.subarray(i)]);
assert.deepStrictEqual(reported, [
reportedDiagnosticEvent,
]);
});
it(`${message} wrapped by non-serialized data`, async () => {
const data = chunks[0];
const reported = await collectReported([
Buffer.concat([Buffer.from('unknown'), data.subarray(0, i)]),
Buffer.concat([data.subarray(i), Buffer.from('unknown')]),
]);
assert.deepStrictEqual(reported, [
{ data: { __proto__: null, entryFile, file: 'filetest', message: 'unknown' }, type: 'test:stdout' },
reportedDiagnosticEvent,
{ data: { __proto__: null, entryFile, file: 'filetest', message: 'unknown' }, type: 'test:stdout' },
]);
}
);
}
}));