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.
Describe the bug
If the protocol parser throws while parsing a malformed response (e.g. a
RangeErrorfrom an out-of-bounds read), the PGlite instance never recovers: the parser's internal buffer state is left poisoned, and every subsequentexecProtocolcall — for any query — rethrows the identical error forever. The only way out is discarding the whole instance.The parser (
Parserinpg-protocol) keeps leftover bytes between calls (#bufferView/#bufferOffset/#bufferRemainingLength), and#mergeBufferappends each new response behind any retained bytes.execProtocolresets the parser in exactly one case — when a well-formedDatabaseErrormessage is parsed: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 thesync()sent from#runQuery'sfinally. A single bad response is amplified into permanent instance failure. Still present on currentmain.To Reproduce
No wasm needed — reproduces against
packages/pg-protocoldirectly (Node 20, any runtime):Output:
Nothing is ever delivered to the callback again.
Logs
Production stack (all queries, repeated for 27 minutes):
Details
main)idb://VFS; repro runs in Node 20Additional 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
#protocolParserwhenparse()throws a non-DatabaseError, so a corrupt response costs one query instead of the instance. Happy to PR this if you'd take it.