vercel/next.js · #99968

turbopack-node: linear IPC frame parsing with eager UTF-8 decoding

lukesandberg · merged Nov 3, 20263 files · 408 + / 73 −
test/unit/turbopack-node-packet-reader.test.tsadded217 + / 0 −
@@ -0,0 +1,217 @@+/* eslint-env jest */+import { PacketReader } from '../../turbopack/crates/turbopack-node/js/src/child_process/packet-reader'++function frame(payload: string): Buffer {+  const body = Buffer.from(payload, 'utf8')+  const header = Buffer.alloc(4)+  header.writeUInt32BE(body.length, 0)+  return Buffer.concat([header, body])+}++const messages: unknown[] = [+  { a: 1 },+  'é'.repeat(1000),+  [1, 2, 3],+  { big: 'x'.repeat(70000) },+  null,+  'z',+]+const stream = Buffer.concat(messages.map((m) => frame(JSON.stringify(m))))++function splitEvery(buffer: Buffer, size: number): Buffer[] {+  const chunks: Buffer[] = []+  for (let i = 0; i < buffer.length; i += size) {+    chunks.push(buffer.subarray(i, i + size))+  }+  return chunks+}++function splitRandomly(buffer: Buffer, seed: number): Buffer[] {+  const chunks: Buffer[] = []+  for (let i = 0; i < buffer.length; ) {+    seed = (seed * 1103515245 + 12345) & 0x7fffffff+    const size = 1 + (seed % 9000)+    chunks.push(buffer.subarray(i, i + size))+    i += size+  }+  return chunks+}++async function receiveAll(+  reader: PacketReader<unknown>,+  count: number+): Promise<unknown[]> {+  const received: unknown[] = []+  for (let i = 0; i < count; i++) {+    received.push(await reader.recv())+  }+  return received+}++describe('turbopack-node PacketReader', () => {+  it.each([+    ['in one chunk', () => [stream]],+    ['byte by byte', () => splitEvery(stream, 1)],+    ['in 3 byte chunks', () => splitEvery(stream, 3)],+    ['in random chunks', () => splitRandomly(stream, 42)],+  ])('decodes frames delivered %s', async (_name, split) => {+    const reader = new PacketReader<unknown>()+    for (const chunk of split()) {+      reader.push(chunk)+    }+    expect(await receiveAll(reader, messages.length)).toEqual(messages)+  })++  it('handles a chunk that ends one frame and starts the next header', async () => {+    const first = frame('"first"')+    const second = frame('"second"')+    const reader = new PacketReader<unknown>()+    reader.push(first.subarray(0, 5))+    reader.push(Buffer.concat([first.subarray(5), second.subarray(0, 2)]))+    reader.push(second.subarray(2))+    expect(await receiveAll(reader, 2)).toEqual(['first', 'second'])+  })++  it('resolves recv() calls that were pending before data arrived in order', async () => {+    const reader = new PacketReader<unknown>()+    const pending = messages.map(() => reader.recv())+    for (const chunk of splitRandomly(stream, 7)) {+      reader.push(chunk)+    }+    expect(await Promise.all(pending)).toEqual(messages)+  })++  it('keeps framing after a queued zero-length frame', async () => {+    const reader = new PacketReader<unknown>()+    reader.push(Buffer.concat([frame(''), frame('"after"')]))+    await expect(reader.recv()).rejects.toThrow(SyntaxError)+    expect(await reader.recv()).toBe('after')+  })++  it('throws from push() when a zero-length frame completes a pending recv()', () => {+    const reader = new PacketReader<unknown>()+    void reader.recv()+    expect(() => reader.push(frame(''))).toThrow(SyntaxError)+  })++  it('decodes chunks that are consumed whole without slicing them', async () => {+    const payload = Buffer.from('{"exact":true}', 'utf8')+    const header = Buffer.alloc(4)+    header.writeUInt32BE(payload.length, 0)+    const reader = new PacketReader<unknown>()+    const subarray = jest.spyOn(Buffer.prototype, 'subarray')+    // The payload chunk itself (not a copy or view of it) is what gets decoded.+    const toString = jest.spyOn(payload, 'toString')+    try {+      reader.push(header)+      reader.push(payload)+      expect(subarray).not.toHaveBeenCalled()+      expect(toString.mock.calls).toEqual([['utf8', 0, payload.length]])+    } finally {+      subarray.mockRestore()+      toString.mockRestore()+    }+    expect(await reader.recv()).toEqual({ exact: true })+  })++  describe('matches Buffer#toString decoding for every split point', () => {+    // Captures the decoded strings by bypassing JSON.parse, so invalid JSON+    // (e.g. a truncated sequence) can be compared too. All payloads are+    // sent as consecutive frames, so decoder state must not leak between them.+    async function check(...payloads: Buffer[]) {+      const stream = Buffer.concat(+        payloads.map((payload) => {+          const header = Buffer.alloc(4)+          header.writeUInt32BE(payload.length, 0)+          return Buffer.concat([header, payload])+        })+      )+      const expected = payloads.map((payload) => payload.toString('utf8'))+      for (let split = 1; split < stream.length; split++) {+        const reader = new PacketReader<unknown>()+        reader.push(stream.subarray(0, split))+        reader.push(stream.subarray(split))+        const parse = jest.spyOn(JSON, 'parse').mockImplementation((s) => s)+        let decoded: unknown[]+        try {+          decoded = await receiveAll(reader, payloads.length)+        } finally {+          parse.mockRestore()+        }+        expect({ split, decoded }).toEqual({ split, decoded: expected })+      }+    }++    it('multi-byte characters', async () => {+      await check(Buffer.from(JSON.stringify('aé€😀bé€😀'), 'utf8'))+    })++    it('invalid UTF-8 sequences', async () => {+      await check(+        Buffer.from([+          0x22, 0xff, 0x41, 0xe2, 0x82, 0x41, 0xf0, 0x9f, 0x98, 0x41, 0xc0,+          0xaf, 0xed, 0xa0, 0x80, 0xf4, 0x90, 0x80, 0x80, 0x22,+        ])+      )+    })++    it.each([+      ['2-byte', [0xc3]],+      ['3-byte', [0xe2, 0x82]],+      ['4-byte', [0xf0, 0x9f, 0x98]],+    ])(+      'a truncated %s sequence at the end of a frame',+      async (_name, truncated) => {+        await check(+          Buffer.from([0x22, 0x61, ...truncated]),+          Buffer.from('"b"', 'utf8')+        )+      }+    )+  })++  it('does not retain received chunks in queued packets', async () => {+    const first = frame('"first"')+    const second = frame('"second"')+    const reader = new PacketReader<unknown>()+    // One frame decoded from a single chunk, one spanning two chunks.+    const chunks = [first, second.subarray(0, 6), second.subarray(6)].map(+      (chunk) => Buffer.from(chunk)+    )+    for (const chunk of chunks) {+      reader.push(chunk)+    }+    for (const chunk of chunks) {+      chunk.fill(0x20)+    }+    expect(await receiveAll(reader, 2)).toEqual(['first', 'second'])+  })++  it('does not assemble fragmented payloads into a Buffer', async () => {+    const size = 4 * 1024 * 1024+    const payload = Buffer.alloc(size, 0x78)+    payload[0] = 0x22+    payload[size - 1] = 0x22+    const header = Buffer.alloc(4)+    header.writeUInt32BE(size, 0)+    const chunks = splitEvery(Buffer.concat([header, payload]), 64 * 1024)++    const copy = jest.spyOn(Buffer.prototype, 'copy')+    const concat = jest.spyOn(Buffer, 'concat')+    const allocUnsafe = jest.spyOn(Buffer, 'allocUnsafe')+    const reader = new PacketReader<string>()+    try {+      for (const chunk of chunks) {+        reader.push(chunk)+      }+      expect(copy).not.toHaveBeenCalled()+      expect(concat).not.toHaveBeenCalled()+      expect(allocUnsafe).not.toHaveBeenCalled()+    } finally {+      copy.mockRestore()+      concat.mockRestore()+      allocUnsafe.mockRestore()+    }+    expect(await reader.recv()).toBe('x'.repeat(size - 2))+  })+})
turbopack/crates/turbopack-node/js/src/child_process/index.ts5 + / 73 −
@@ -2,15 +2,7 @@ import { writeSync } from 'node:fs' import { createConnection } from 'node:net' import { Writable } from 'node:stream' import { structuredError } from '../error'--type State =-  | {-      type: 'waiting'-    }-  | {-      type: 'packet'-      length: number-    }+import { PacketReader } from './packet-reader'  export type Ipc<TIncoming, TOutgoing> = {   recv(): Promise<TIncoming>@@ -65,54 +57,12 @@ function createIpc<TIncoming, TOutgoing>(     },   }) -  const packetQueue: Buffer[] = []-  const recvPromiseResolveQueue: Array<(message: TIncoming) => void> = []+  const reader = new PacketReader<TIncoming>() -  function pushPacket(packet: Buffer) {-    const recvPromiseResolve = recvPromiseResolveQueue.shift()-    if (recvPromiseResolve != null) {-      recvPromiseResolve(JSON.parse(packet.toString('utf8')) as TIncoming)-    } else {-      packetQueue.push(packet)-    }-  }--  let state: State = { type: 'waiting' }-  let buffer: Buffer = Buffer.alloc(0)   socket.once('connect', () => {     socket.off('error', onConnectError)     socket.setNoDelay(true)-    socket.on('data', (chunk) => {-      buffer = Buffer.concat([buffer, chunk])--      loop: while (true) {-        switch (state.type) {-          case 'waiting': {-            if (buffer.length >= 4) {-              const length = buffer.readUInt32BE(0)-              buffer = buffer.subarray(4)-              state = { type: 'packet', length }-            } else {-              break loop-            }-            break-          }-          case 'packet': {-            if (buffer.length >= state.length) {-              const packet = buffer.subarray(0, state.length)-              buffer = buffer.subarray(state.length)-              state = { type: 'waiting' }-              pushPacket(packet)-            } else {-              break loop-            }-            break-          }-          default:-            invariant(state, (state) => `Unknown state type: ${state?.type}`)-        }-      }-    })+    socket.on('data', (chunk) => reader.push(chunk))   })   // When the socket is closed, this process is no longer needed.   // This might happen e. g. when parent process is killed or@@ -148,19 +98,8 @@ function createIpc<TIncoming, TOutgoing>(   }    return {-    async recv() {-      const packet = packetQueue.shift()-      if (packet != null) {-        return JSON.parse(packet.toString('utf8')) as TIncoming-      }--      const result = await new Promise<TIncoming>((resolve) => {-        recvPromiseResolveQueue.push((result) => {-          resolve(result)-        })-      })--      return result+    recv() {+      return reader.recv()     },      send(message: TOutgoing) {@@ -234,10 +173,3 @@ improveConsole('timeEnd', 'stdout', true) improveConsole('timeLog', 'stdout', true) improveConsole('timeStamp', 'stdout', true) improveConsole('assert', 'stderr', true)--/**- * Utility function to ensure all variants of an enum are handled.- */-function invariant(never: never, computeMessage: (arg: any) => string): never {-  throw new Error(`Invariant: ${computeMessage(never)}`)-}
turbopack/crates/turbopack-node/js/src/child_process/packet-reader.tsadded186 + / 0 −
@@ -0,0 +1,186 @@+import { TextDecoder } from 'node:util'++type State =+  | {+      type: 'waiting'+    }+  | {+      type: 'packet'+      length: number+    }++/**+ * Decodes length-prefixed packets (a u32 big-endian byte length followed by a+ * UTF-8 JSON payload) from a stream of chunks, and hands the decoded messages+ * to `recv()` callers in order.+ */+export class PacketReader<TIncoming> {+  private state: State = { type: 'waiting' }+  // Received chunks that have not been consumed yet. We keep them as a list+  // (instead of concatenating on every chunk) so that a packet that arrives in+  // many chunks can be decoded straight from them.+  private readonly chunks: Buffer[] = []+  private bufferedLength = 0+  // Decoded packets that no `recv()` call has consumed yet. Packets are+  // decoded as soon as they are complete so that queued packets don't retain+  // the received chunks (which may be views into larger Buffers).+  private readonly packetQueue: string[] = []+  // `recv()` calls waiting for the next packet.+  private readonly recvPromiseResolveQueue: Array<+    (message: TIncoming) => void+  > = []++  /**+   * Feeds a chunk of received bytes into the reader. Each packet it completes+   * is decoded for the oldest waiting `recv()` call, or queued until the next+   * `recv()` call if none is waiting.+   */+  push(chunk: Buffer): void {+    this.chunks.push(chunk)+    this.bufferedLength += chunk.length++    loop: while (true) {+      switch (this.state.type) {+        case 'waiting': {+          if (this.bufferedLength >= 4) {+            const length = this.takeBytes(4).readUInt32BE(0)+            this.state = { type: 'packet', length }+          } else {+            break loop+          }+          break+        }+        case 'packet': {+          if (this.bufferedLength >= this.state.length) {+            const packet = this.takeString(this.state.length)+            this.state = { type: 'waiting' }+            this.pushPacket(packet)+          } else {+            break loop+          }+          break+        }+        default:+          invariant(this.state, (state) => `Unknown state type: ${state?.type}`)+      }+    }+  }++  /**+   * Resolves with the next decoded message.+   */+  async recv(): Promise<TIncoming> {+    const packet = this.packetQueue.shift()+    if (packet != null) {+      return JSON.parse(packet) as TIncoming+    }++    return new Promise<TIncoming>((resolve) => {+      this.recvPromiseResolveQueue.push(resolve)+    })+  }++  private pushPacket(packet: string): void {+    const recvPromiseResolve = this.recvPromiseResolveQueue.shift()+    if (recvPromiseResolve != null) {+      recvPromiseResolve(JSON.parse(packet) as TIncoming)+    } else {+      this.packetQueue.push(packet)+    }+  }++  /**+   * Removes the first `length` bytes from `chunks` and returns them decoded as+   * UTF-8, without first assembling them into a single Buffer.+   * The caller must ensure that `bufferedLength >= length`.+   */+  private takeString(length: number): string {+    if (length === 0) {+      return ''+    }+    this.bufferedLength -= length+    const chunks = this.chunks+    const first = chunks[0]+    if (first.length >= length) {+      // Fast path: the bytes are contained in the first chunk, decode them in+      // place.+      const result = first.toString('utf8', 0, length)+      if (first.length === length) {+        chunks.shift()+      } else {+        chunks[0] = first.subarray(length)+      }+      return result+    }+    // The bytes span multiple chunks. A streaming decoder carries multi-byte+    // characters that are split across chunk boundaries.+    const decoder = new TextDecoder('utf-8')+    let result = ''+    let remaining = length+    let consumed = 0+    while (remaining > 0) {+      const chunk = chunks[consumed]+      if (chunk.length <= remaining) {+        result += decoder.decode(chunk, { stream: true })+        remaining -= chunk.length+        consumed+++      } else {+        result += decoder.decode(chunk.subarray(0, remaining), {+          stream: true,+        })+        chunks[consumed] = chunk.subarray(remaining)+        remaining = 0+      }+    }+    chunks.splice(0, consumed)+    return result + decoder.decode()+  }++  /**+   * Removes the first `length` bytes from `chunks` and returns them.+   * The caller must ensure that `bufferedLength >= length`.+   */+  private takeBytes(length: number): Buffer {+    if (length === 0) {+      return Buffer.alloc(0)+    }+    this.bufferedLength -= length+    const chunks = this.chunks+    const first = chunks[0]+    if (first.length === length) {+      // Fast path: the first chunk is exactly the requested bytes.+      chunks.shift()+      return first+    }+    if (first.length > length) {+      // The bytes are contained in the first chunk, no copy needed.+      chunks[0] = first.subarray(length)+      return first.subarray(0, length)+    }+    const result = Buffer.allocUnsafe(length)+    let offset = 0+    let consumed = 0+    while (offset < length) {+      const chunk = chunks[consumed]+      const remaining = length - offset+      if (chunk.length <= remaining) {+        chunk.copy(result, offset)+        offset += chunk.length+        consumed+++      } else {+        chunk.copy(result, offset, 0, remaining)+        chunks[consumed] = chunk.subarray(remaining)+        offset += remaining+      }+    }+    chunks.splice(0, consumed)+    return result+  }+}++/**+ * Utility function to ensure all variants of an enum are handled.+ */+function invariant(never: never, computeMessage: (arg: any) => string): never {+  throw new Error(`Invariant: ${computeMessage(never)}`)+}