Severity / Size
- Severity: MEDIUM —
SqliteJournal doesn't expose a timeout for OS-level filesystem locks. A co-tenant process holding flock() (or Windows file-locking) on the journal file stalls every write indefinitely. Bun's bun:sqlite and Node's better-sqlite3 both block on the underlying syscall by default; we never set busy_timeout.
- Size: S (~1d).
- Threat model: co-tenant process on the same filesystem (sidecar container holding flocks, malicious local process, backup tool that holds the file open). Not strictly external; matters in deployment scenarios where multiple processes can touch the same SQLite file (which is itself an anti-pattern but happens — shared volumes, dev/test setups, migration tooling).
Affected files
src/persistence/journals/SqliteJournal.ts:54-120 (approx) — constructor + driver setup. No busy_timeout pragma set; no Journal-level timeout for write operations.
src/runtime/sqlite/* — driver abstraction. Neither BunSqliteDriver nor NodeSqliteDriver exposes a busy-timeout option to the journal layer.
src/persistence/snapshot-stores/SqliteSnapshotStore.ts — same family; same issue applies.
src/persistence/query/SqliteQuery.ts — same.
Background
SQLite uses OS-level locks (flock() on POSIX, LockFileEx on Windows) to coordinate concurrent access. When two processes share the same database file, SQLite's BEGIN EXCLUSIVE (or BEGIN IMMEDIATE) acquires the lock; concurrent writers block until the holder releases.
By default, SQLite's busy_timeout is 0 — if a write can't acquire the lock immediately, it fails with SQLITE_BUSY instantly. Some drivers (better-sqlite3) interpret this as throwing synchronously; others (bun:sqlite) similarly raise. But the framework's higher-level journal code never sees these errors because we don't propagate them — the test setups all use in-memory or single-process SQLite, and the multi-process scenario isn't part of the test matrix.
Two failure modes worth distinguishing:
-
SQLITE_BUSY rejection: write fails immediately with no retry. Caller (the actor's persist) sees a JournalError. Application logic may not be prepared for transient-write-fail and may crash, retry-storm, or escalate.
-
Indefinite block: if busy_timeout is set high (e.g., PRAGMA busy_timeout = 30000), the syscall blocks the entire JS thread for up to 30 seconds. This is worse than rejection because it freezes the actor system — every persist() on every actor sharing the same dispatcher stalls until the lock clears or timeout fires.
Either failure mode is a denial-of-service for the actor that's trying to write. A co-tenant process can sustain the lock indefinitely (e.g., a backup tool that opens the file with an exclusive lock for the duration of a 1-hour backup).
Worth noting: WAL mode (which SqliteJournalOptions.wal: true enables) reduces lock contention significantly — readers don't block writers and vice versa. But writers still serialize against other writers via the WAL lock. WAL helps but doesn't eliminate the problem.
Exploit walkthrough
Setup: deployment with SqliteJournal at /data/journal.db. Backup tool runs hourly via cron; it acquires an exclusive flock() on the file for ~10 minutes to dump it consistently.
Step 1 — actor does persist(event): the SQLite driver tries to BEGIN IMMEDIATE (or starts a write txn). Lock acquisition blocks.
Step 2 — JS thread freezes: with busy_timeout = 0 (default), the driver throws SQLITE_BUSY instantly. Actor's persist rejects. Supervisor catches; depending on policy, the actor restarts, stops, or escalates. Repeated persist attempts all fail for 10 minutes.
Step 2' — alternative with busy_timeout = 30000: the driver blocks the syscall for 30 seconds. In Node, this blocks the event loop. Every actor on the same dispatcher is paused. Heartbeats stop. Cluster's failure detector marks this node unreachable. Eventually the cluster downs this node.
Either failure mode: business impact is real. An adversarial process (or a misconfigured ops tool) can take a cluster node offline for the duration of the lock.
How the 8 already-landed security fixes inform this
- Frame-size DoS cap (
d454079): bounded a malicious caller's ability to hang the framework. Same shape here: bound the duration of a SQLite lock-wait.
- Snapshot seq integrity (
99de741): made silent failures loud. Apply here: don't silently let a 30-second block happen — surface it as a clear error.
Fix design
Three coordinated changes.
Track 1 — set a sane busy_timeout by default.
In the SQLite driver init path:
// after opening the DB
db.exec(`PRAGMA busy_timeout = ${opts.busyTimeoutMs ?? 5_000}`);
5 seconds is the standard SQLite default in many ORMs. Long enough that transient contention (other process briefly writing) resolves without surfacing. Short enough that pathological holds surface as errors quickly.
Track 2 — expose the timeout as a journal option.
export interface SqliteJournalOptions {
// ... existing
readonly busyTimeoutMs?: number; // default: 5_000
}
Same for SqliteSnapshotStoreOptions. Operators can tune for their environment.
Track 3 — surface SQLITE_BUSY as a distinct error class.
Today a busy-lock failure surfaces as a generic JournalError. Add a dedicated subclass:
export class JournalBusyError extends JournalError {
constructor(pid: string, lockTimeoutMs: number, cause?: unknown) {
super(`SQLite journal busy on persistenceId="${pid}" — lock contention exceeded ${lockTimeoutMs}ms`, cause);
this.name = 'JournalBusyError';
}
}
In the append / delete paths, catch the driver's busy-error code and rethrow as JournalBusyError. Callers can distinguish transient lock-contention from permanent errors and retry with backoff.
Track 4 — document the multi-process scenario.
Add a "Multi-process SQLite" section to the persistence doc:
SQLite supports concurrent access from multiple processes via OS-level
file locks. In high-write multi-process scenarios this can cause
contention. Enable WAL mode (wal: true) to allow concurrent readers
- one writer, and tune
busyTimeoutMs for your contention profile.
For very-high-throughput deployments, use a multi-process-friendly
backend (Cassandra, Postgres) instead.
API surface
// src/persistence/journals/SqliteJournal.ts
export interface SqliteJournalOptions {
readonly path?: string;
readonly eventsTable?: string;
readonly wal?: boolean;
readonly driver?: SqliteDriver;
readonly busyTimeoutMs?: number; // new — default 5_000
}
// src/persistence/JournalTypes.ts
export class JournalBusyError extends JournalError { ... }
Backward compatibility
busyTimeoutMs defaults to 5_000. Today's default (no busy_timeout set, i.e. 0) causes immediate failure on contention. The change makes single-process tests slower-but-equivalent (no contention → no waiting), and multi-process scenarios actually work.
JournalBusyError is a JournalError subclass — existing catch (e) { if (e instanceof JournalError) ... } patterns still match.
Test plan
-
Multi-process lock-contention test: spawn a child process holding flock() on the journal file for 1 second; main process's persist() waits ~1s then succeeds (verifies busy_timeout works).
-
Hold-longer-than-timeout test: child holds lock for 10s; main's persist() fails after 5s with JournalBusyError (verifies the timeout surfaces correctly).
-
No-contention regression: single-process tests run with the new timeout default; performance unchanged (no contention → no waits).
-
WAL+contention test: same as Test 1 but with wal: true; readers proceed concurrently with the locked writer.
-
Error-class test: assert JournalBusyError is a JournalError (for back-compat catch-all handlers) and has the right .name.
-
Regression: existing SqliteJournal.test.ts + multi-node-tests pass.
Acceptance criteria
Severity / Size
SqliteJournaldoesn't expose a timeout for OS-level filesystem locks. A co-tenant process holdingflock()(or Windows file-locking) on the journal file stalls every write indefinitely. Bun'sbun:sqliteand Node'sbetter-sqlite3both block on the underlying syscall by default; we never setbusy_timeout.Affected files
src/persistence/journals/SqliteJournal.ts:54-120(approx) — constructor + driver setup. Nobusy_timeoutpragma set; no Journal-level timeout for write operations.src/runtime/sqlite/*— driver abstraction. NeitherBunSqliteDrivernorNodeSqliteDriverexposes a busy-timeout option to the journal layer.src/persistence/snapshot-stores/SqliteSnapshotStore.ts— same family; same issue applies.src/persistence/query/SqliteQuery.ts— same.Background
SQLite uses OS-level locks (
flock()on POSIX,LockFileExon Windows) to coordinate concurrent access. When two processes share the same database file, SQLite'sBEGIN EXCLUSIVE(orBEGIN IMMEDIATE) acquires the lock; concurrent writers block until the holder releases.By default, SQLite's
busy_timeoutis 0 — if a write can't acquire the lock immediately, it fails withSQLITE_BUSYinstantly. Some drivers (better-sqlite3) interpret this as throwing synchronously; others (bun:sqlite) similarly raise. But the framework's higher-level journal code never sees these errors because we don't propagate them — the test setups all use in-memory or single-process SQLite, and the multi-process scenario isn't part of the test matrix.Two failure modes worth distinguishing:
SQLITE_BUSY rejection: write fails immediately with no retry. Caller (the actor's
persist) sees aJournalError. Application logic may not be prepared for transient-write-fail and may crash, retry-storm, or escalate.Indefinite block: if
busy_timeoutis set high (e.g.,PRAGMA busy_timeout = 30000), the syscall blocks the entire JS thread for up to 30 seconds. This is worse than rejection because it freezes the actor system — everypersist()on every actor sharing the same dispatcher stalls until the lock clears or timeout fires.Either failure mode is a denial-of-service for the actor that's trying to write. A co-tenant process can sustain the lock indefinitely (e.g., a backup tool that opens the file with an exclusive lock for the duration of a 1-hour backup).
Worth noting:
WALmode (whichSqliteJournalOptions.wal: trueenables) reduces lock contention significantly — readers don't block writers and vice versa. But writers still serialize against other writers via the WAL lock. WAL helps but doesn't eliminate the problem.Exploit walkthrough
Setup: deployment with
SqliteJournalat/data/journal.db. Backup tool runs hourly via cron; it acquires an exclusiveflock()on the file for ~10 minutes to dump it consistently.Step 1 — actor does
persist(event): the SQLite driver tries toBEGIN IMMEDIATE(or starts a write txn). Lock acquisition blocks.Step 2 — JS thread freezes: with
busy_timeout = 0(default), the driver throwsSQLITE_BUSYinstantly. Actor'spersistrejects. Supervisor catches; depending on policy, the actor restarts, stops, or escalates. Repeatedpersistattempts all fail for 10 minutes.Step 2' — alternative with
busy_timeout = 30000: the driver blocks the syscall for 30 seconds. In Node, this blocks the event loop. Every actor on the same dispatcher is paused. Heartbeats stop. Cluster's failure detector marks this node unreachable. Eventually the cluster downs this node.Either failure mode: business impact is real. An adversarial process (or a misconfigured ops tool) can take a cluster node offline for the duration of the lock.
How the 8 already-landed security fixes inform this
d454079): bounded a malicious caller's ability to hang the framework. Same shape here: bound the duration of a SQLite lock-wait.99de741): made silent failures loud. Apply here: don't silently let a 30-second block happen — surface it as a clear error.Fix design
Three coordinated changes.
Track 1 — set a sane
busy_timeoutby default.In the SQLite driver init path:
5 seconds is the standard SQLite default in many ORMs. Long enough that transient contention (other process briefly writing) resolves without surfacing. Short enough that pathological holds surface as errors quickly.
Track 2 — expose the timeout as a journal option.
Same for
SqliteSnapshotStoreOptions. Operators can tune for their environment.Track 3 — surface SQLITE_BUSY as a distinct error class.
Today a busy-lock failure surfaces as a generic
JournalError. Add a dedicated subclass:In the
append/deletepaths, catch the driver's busy-error code and rethrow asJournalBusyError. Callers can distinguish transient lock-contention from permanent errors and retry with backoff.Track 4 — document the multi-process scenario.
Add a "Multi-process SQLite" section to the persistence doc:
API surface
Backward compatibility
busyTimeoutMsdefaults to 5_000. Today's default (nobusy_timeoutset, i.e. 0) causes immediate failure on contention. The change makes single-process tests slower-but-equivalent (no contention → no waiting), and multi-process scenarios actually work.JournalBusyErroris aJournalErrorsubclass — existingcatch (e) { if (e instanceof JournalError) ... }patterns still match.Test plan
Multi-process lock-contention test: spawn a child process holding
flock()on the journal file for 1 second; main process'spersist()waits ~1s then succeeds (verifiesbusy_timeoutworks).Hold-longer-than-timeout test: child holds lock for 10s; main's
persist()fails after 5s withJournalBusyError(verifies the timeout surfaces correctly).No-contention regression: single-process tests run with the new timeout default; performance unchanged (no contention → no waits).
WAL+contention test: same as Test 1 but with
wal: true; readers proceed concurrently with the locked writer.Error-class test: assert
JournalBusyErroris aJournalError(for back-compat catch-all handlers) and has the right.name.Regression: existing
SqliteJournal.test.ts+ multi-node-tests pass.Acceptance criteria
busy_timeoutPRAGMA set tobusyTimeoutMs(default 5_000) on DB open.SqliteJournalOptions.busyTimeoutMsexposed.SqliteSnapshotStoreOptions.JournalBusyErrorsubclass distinguishes lock-contention from other errors.