Summary
WriteAhead.js#advanceTxId() can crash with TypeError: Cannot read properties of null (reading 'id') when the local WAL-file read fallback (#readTx()) returns null. The identical call is correctly guarded in #readAllTx() but not in #advanceTxId(). The crash happens inside jUnlock → rejoin(), gets caught by OPFSWriteAheadVFS's blanket per-method catch, and is reported to the application as SQLITE_IOERR_UNLOCK — which SQLite renders as the generic, undiagnostic string "disk I/O error".
Confirmed present in 2.0.3 (currently pinned) and still present, unchanged, in the latest published 2.0.4.
The bug
// #readAllTx() — correctly guarded
*#readAllTx() {
while (true) {
const tx = this.#readTx();
if (!tx) break; // null is treated as "nothing more yet"
yield tx;
}
}
// #advanceTxId() — same call, no guard
#advanceTxId(options = {}) {
let didAdvance = false;
while (this.#mapIdToPendingTx.size) {
const nextTxId = this.#txId + 1;
let tx;
if (this.#mapIdToPendingTx.has(nextTxId)) {
tx = this.#mapIdToPendingTx.get(nextTxId);
this.#mapIdToPendingTx.delete(tx.id);
this.#skipTx(tx);
} else {
tx = this.#readTx(); // can return null (see #readTx's own @returns {Transaction?})
}
this.#activateTx(tx); // <-- crashes here if tx is null
didAdvance = true;
}
...
#activateTx:
#activateTx(tx) {
this.#mapIdToTx.set(tx.id, tx); // TypeError: Cannot read properties of null (reading 'id')
...
How we believe this triggers
#advanceTxId is entered from #handleMessage's 'tx' branch (a peer connection broadcast a transaction) and from rejoin(). The while (this.#mapIdToPendingTx.size) guard only confirms some pending transaction exists — not that the specific next txId this connection needs is available. When it isn't in #mapIdToPendingTx, the code falls back to reading straight from the WAL file via #readTx().
#readFrame()'s own comments note two of its null-return paths are "not necessarily an error, could be from a restart without truncation" — specifically a salt/generation mismatch against #activeHeader. That's exactly what happens when another connection has called #swapActiveFile() (rotated the active WAL file) and this connection hasn't yet caught up via #followFileChange: it receives a broadcast for a transaction after the swap, tries to read the transaction at the swap boundary with its stale salt, #readFrame sees the mismatch and returns null, and #advanceTxId doesn't handle that — #readAllTx would have.
Observed impact
Once this fires, the connection's state isn't repaired (the pending-tx gap is never resolved), so every subsequent rejoin() (i.e. every transaction end) hits the identical crash. In one captured production session this repeated 46 times in the visible log plus 16,014 more collapsed by the browser console — i.e. it never recovers for the rest of the session, and each occurrence surfaces to the application as an opaque "disk I/O error" with no distinguishing detail (SQLITE_IOERR_UNLOCK, code 2058).
Suggested fix
Mirror #readAllTx()'s handling in #advanceTxId():
} else {
tx = this.#readTx();
if (!tx) break; // or: wait/retry, if a peer catch-up mechanism exists
}
this.#activateTx(tx);
Happy to open a PR if that's the preferred direction — wanted to raise it for input first since I'm not certain whether break (stop advancing, try again later) or an active wait/retry against the salt mismatch is the intended recovery here.
Environment
@journeyapps/wa-sqlite 2.0.3 (also confirmed present in 2.0.4)
OPFSWriteAheadVFS, enableMultiTabs: true, WAL mode
- Chrome on macOS, multi-tab usage
Summary
WriteAhead.js#advanceTxId()can crash withTypeError: Cannot read properties of null (reading 'id')when the local WAL-file read fallback (#readTx()) returnsnull. The identical call is correctly guarded in#readAllTx()but not in#advanceTxId(). The crash happens insidejUnlock→rejoin(), gets caught byOPFSWriteAheadVFS's blanket per-methodcatch, and is reported to the application asSQLITE_IOERR_UNLOCK— which SQLite renders as the generic, undiagnostic string"disk I/O error".Confirmed present in
2.0.3(currently pinned) and still present, unchanged, in the latest published2.0.4.The bug
#activateTx:How we believe this triggers
#advanceTxIdis entered from#handleMessage's'tx'branch (a peer connection broadcast a transaction) and fromrejoin(). Thewhile (this.#mapIdToPendingTx.size)guard only confirms some pending transaction exists — not that the specific nexttxIdthis connection needs is available. When it isn't in#mapIdToPendingTx, the code falls back to reading straight from the WAL file via#readTx().#readFrame()'s own comments note two of its null-return paths are "not necessarily an error, could be from a restart without truncation" — specifically a salt/generation mismatch against#activeHeader. That's exactly what happens when another connection has called#swapActiveFile()(rotated the active WAL file) and this connection hasn't yet caught up via#followFileChange: it receives a broadcast for a transaction after the swap, tries to read the transaction at the swap boundary with its stale salt,#readFramesees the mismatch and returnsnull, and#advanceTxIddoesn't handle that —#readAllTxwould have.Observed impact
Once this fires, the connection's state isn't repaired (the pending-tx gap is never resolved), so every subsequent
rejoin()(i.e. every transaction end) hits the identical crash. In one captured production session this repeated 46 times in the visible log plus 16,014 more collapsed by the browser console — i.e. it never recovers for the rest of the session, and each occurrence surfaces to the application as an opaque"disk I/O error"with no distinguishing detail (SQLITE_IOERR_UNLOCK, code 2058).Suggested fix
Mirror
#readAllTx()'s handling in#advanceTxId():Happy to open a PR if that's the preferred direction — wanted to raise it for input first since I'm not certain whether
break(stop advancing, try again later) or an active wait/retry against the salt mismatch is the intended recovery here.Environment
@journeyapps/wa-sqlite2.0.3 (also confirmed present in 2.0.4)OPFSWriteAheadVFS,enableMultiTabs: true, WAL mode