@@ -39,6 +39,38 @@ const oversizedLengthStdout = String.fromCharCode(oversizedLengthHeader[0]) +
3939 Buffer . from ( oversizedLengthHeader . subarray ( 1 ) ) . toString ( 'utf-8' ) ;
4040const 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
4375function 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 ) , / d e s e r i a l i z e / ) ;
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