Skip to content

[BUG]: Protocol parser is never reset when parse() throws — one corrupt/truncated response permanently wedges the instance #1052

Description

@paisanoluciano

Describe the bug

If the protocol parser throws while parsing a malformed response (e.g. a RangeError from an out-of-bounds read), the PGlite instance never recovers: the parser's internal buffer state is left poisoned, and every subsequent execProtocol call — for any query — rethrows the identical error forever. The only way out is discarding the whole instance.

The parser (Parser in pg-protocol) keeps leftover bytes between calls (#bufferView / #bufferOffset / #bufferRemainingLength), and #mergeBuffer appends each new response behind any retained bytes. execProtocol resets the parser in exactly one case — when a well-formed DatabaseError message is parsed:

if (msg instanceof DatabaseError) {
  this.#protocolParser = new ProtocolParser() // Reset the parser
  ...

There is no reset when parse() itself throws. So one corrupt response stays at the front of the buffer, every later (perfectly valid) response queues behind it, and the parser re-reads the same corrupt packet and throws again on every call — including the sync() sent from #runQuery's finally. A single bad response is amplified into permanent instance failure. Still present on current main.

To Reproduce

No wasm needed — reproduces against packages/pg-protocol directly (Node 20, any runtime):

import { Parser } from '@electric-sql/pg-protocol'

// DataRow ('D') with an honest declared message length but a lying body:
// fieldCount=2, field #1 claims a 1GB value. Parsing field #1 advances the
// reader ~1GB; reading field #2's length does getInt32 past the end -> RangeError.
function corruptDataRow(): Uint8Array {
  const buf = new Uint8Array(11)
  const view = new DataView(buf.buffer)
  buf[0] = 0x44                        // 'D' DataRow
  view.setUint32(1, 10, false)         // declared length: 4 (self) + 6 (body) — honest
  view.setInt16(5, 2, false)           // fieldCount = 2
  view.setInt32(7, 0x40000000, false)  // field #1 length = 1GB — the lie
  return buf
}

// A perfectly valid ReadyForQuery ('Z') message.
function readyForQuery(): Uint8Array {
  const buf = new Uint8Array(6)
  const view = new DataView(buf.buffer)
  buf[0] = 0x5a; view.setUint32(1, 5, false); buf[5] = 0x49
  return buf
}

const parser = new Parser()
try { parser.parse(corruptDataRow(), () => {}) } catch (e) { console.log('call 1:', (e as Error).message) }

// Every subsequent VALID response now fails identically, forever:
for (let i = 2; i <= 6; i++) {
  try { parser.parse(readyForQuery(), (m) => console.log('delivered:', m.name)) }
  catch (e) { console.log(`call ${i}:`, (e as Error).message) }
}
// A fresh parser handles readyForQuery() fine (control).

Output:

call 1: Offset is outside the bounds of the DataView
call 2: Offset is outside the bounds of the DataView
call 3: Offset is outside the bounds of the DataView
call 4: Offset is outside the bounds of the DataView
call 5: Offset is outside the bounds of the DataView
call 6: Offset is outside the bounds of the DataView

Nothing is ever delivered to the callback again.

Logs

Production stack (all queries, repeated for 27 minutes):

RangeError: Offset is outside the bounds of the DataView
    at DataView.prototype.getInt32 (<anonymous>)
    at BufferReader.int32
    at Parser.#handlePacket
    at Parser.parse
    at PGlite.execProtocol

Details

  • PGlite version: 0.3.8 (bug confirmed still present reading current main)
  • using any extensions? none
  • OS version: incident on Windows 10; repro on macOS/Node
  • node, bun, deno or browser version: incident in Chrome 150, PGlite in a SharedWorker with idb:// VFS; repro runs in Node 20

Additional context

We hit this in production: one malformed response (origin still under investigation on our side — the app was under memory-heavy load) took down the shared PGlite instance for all tabs for 27+ minutes; every query failed with the identical RangeError until the worker was manually recycled. The parser-never-resets behavior is what turned one bad response into a full outage.

Suggested fix: also reset #protocolParser when parse() throws a non-DatabaseError, so a corrupt response costs one query instead of the instance. Happy to PR this if you'd take it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions