Repository navigation
fix(driver-sqlite-wasm): handle a failing background flush instead of rejecting unhandled - #23643
Conversation
… rejecting unhandled The three fire-and-forget flushes (on-write markDirty, the debounce timer, a transaction closing) left flush()'s rejection to the process. Route them through one helper that reports a failed flush once per failure episode, at error with the database file named, and leaves dirty set so the next flush retries. An awaited flush()/close() still throws to its caller. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…rted, retried and never unhandled Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…he changeset Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check7 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4920007ee5a6f936e604e644f14478ed3653ec1a && git checkout 4920007ee5a6f936e604e644f14478ed3653ec1a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7dc7d2213fbf4321070a33cd4a085dcb1f4c0fea aa42889b366c1b37fca0341408dbf5d1de037562 && git checkout -B drift-repro 7dc7d2213fbf4321070a33cd4a085dcb1f4c0fea && git merge --no-ff aa42889b366c1b37fca0341408dbf5d1de037562
node scripts/docs-audit/affected-docs.mjs --json 7dc7d2213fbf4321070a33cd4a085dcb1f4c0fea |
…literal with warn required The intersection form hid warn from check:optional-error-sink, which read the inline literal as an optional error with no fallback channel. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
Fixes #23635
Clause-②: no
A flush that fails in the background in
driver-sqlite-wasmis now handled at the producer: reported once, aterror, with the database file named, and the file stays dirty so a later flush retries. It no longer reaches the process as an unhandled rejection. An awaitedflush()/close()still throws to its caller.What changed
packages/drivers/driver-sqlite-wasm/src/wasm-connection.ts, one file:markDirtyunderon-write, thedebounced:Ntimer, and the flush deferred past a transaction's close innoteTransactionControl) now call one private helper,flushInBackground(), in place of three barevoid this.flush().reportBackgroundFlushFailure(): one line aterror(atwarnfor a logger that has noerror) naming the file, the consequence (writes since the last good flush are not on disk while the database keeps answering from memory and looks healthy) and the fix. It reports once per failure episode: a latch is set by the first report and cleared when a flush actually writes, soon-writedoes not log once per write (AGENTS.md, "Degradation log levels": say it once, at the first degradation). The reporter never throws, because a throwing logger inside a rejection handler would recreate the unhandled rejection.flush()is unchanged except that it clears the latch after a landed write. It still re-marksdirtyand rethrows, so the next flush retries and an awaited caller still gets the error.close()is untouched.erroris read off the injected logger when it has one (consoledoes). Declaring it on the exportedloggeroption type would be a Clause-② widening and aminor; see Acceptance notes.One patch changeset:
.changeset/23635-sqlite-wasm-background-flush-failure.md.The pin, and how it asserts "no unhandled rejection"
src/sqlite-wasm-background-flush-failure.test.ts, 10 tests. The assertion is direct: every test attaches its ownprocess.on('unhandledRejection')listener and requires that it saw nothing once the flush chain has settled. It awaits the chain tail and then 20 ms of event-loop turn, so no fixed sleep races the flush, and it does not lean on Vitest's run-level unhandled-error report.errorline naming the file,dirtystill set.warn-only logger still hears it, atwarn; a logger that throws produces no unhandled rejection.flush()rejects withENOENTand does not also log,dirtystays set and a laterflush()resolves;close()rejects withENOENT.SqliteWasmDriveronon-write, its directory removed, the next insert) is reported, and the publicdriver.flush()still rejects.Ablation
Each leg mutated
wasm-connection.tsthroughscripts/ablation-replace.mjs(anchor hit once, blob changed on disk, restore proven: blob equal to HEAD andgit diff HEADempty). The test importssrcdirectly, so nodist/rebuild leg applies. The first attempt of the once-guard leg was refused by the tool as a no-op (its replacement was a substring of its anchor) and redone with a distinct replacement.void this.flush()void this.flush()(all three sites)warnfallback removedwarn-only test fails (1 of 10)this.dirty = trueon failure removed (existing line)On the unmutated tree the file passes 10 of 10.
Verification
At
aa42889b36. The patch round changed one thing: the privateloggerfield's type is now one literal, withwarnrequired anderroroptional. Every run below was repeated at this head.pnpm check:optional-error-sink(a wide-population family that--commandsdoes not print) was red ate3b5224c85, on the old intersection type. It exits 0 at this head: 37 sinks declare an optionalerrorbeside a requiredwarn, and 0 areno-fallback. Both blocksdispatch-gates.mjsprints on every run were also run, each with its own command: the artifact-roster block, 53 of 53 exit 0, and the wide-population block, 11 of 11 exit 0.pnpm --filter @objectstack/driver-sqlite-wasm typecheck: exit 0. Whole package suite: 37 files, 685 tests, all pass.node scripts/pm/dispatch-gates.mjs --commandsderived 62 commands from this tree; all 62 ran at this commit and exited 0, and--ranreconciles 62 derived, 62 run, 0 NOT-MEASURED. One of them,check:logger-receiver-detach, was red on my first spelling (a logger channel read offthis.loggerinto a local) and is fixed in the last commit.pnpm check:durability-log-levelis also green.eslint with
--no-inline-configon the two changed source files,--format json: 2 files, 0 errors, 0 warnings. The repo config never enables type-aware linting (noparserOptions.project, no typed rules; see the note near line 328 ofeslint.config.mjs), so this diff cannot move the verdict of an untouched file. The repo-widepnpm lintis left to CI.NOT MEASURED: the Dogfood Regression Gate (2/3). It is CI-owned and the race is intermittent, so a local run could not prove it; the pin reproduces the mechanism deterministically instead.
Measured, out of scope and not fixed here: where the refused boot's driver is left open
The seat filed this as #23644.
A boot that fails after a plugin's
start()connected a driver never tears that driver down.ObjectQLPlugin.start(packages/objectql/src/plugin.ts,await this.ql?.init()) reachesdriver.connect()inObjectQLEngine.init(packages/objectql/src/engine.ts, thefor (const [name, driver] of this.drivers)loop).kernel:ready, Phase 3 ofObjectKernel.bootstrap()(packages/core/src/kernel.ts,await this.context.trigger('kernel:ready')).rollbackStartedPlugins()is called only inside the Phase 2 branch for a plugin whosestart()failed. The outercatch (error)ofbootstrap()sets the state tostopped, releases the signal listeners and rethrows; the comment there states thatshutdown()on such a kernel is a no-op. So no plugindestroy()runs, includingDefaultDatasourcePlugin.destroy(packages/runtime/src/default-datasource-plugin.ts), the path that disconnects the default datasource. The two in-process callers,Runtime.start()(packages/runtime/src/runtime.ts) andbootStack(packages/verify/src/harness.ts,await kernel.bootstrap()), add no teardown of their own.dist, scratch script outside the repo): plugin A'sstart()records a connect and itsdestroy()records a disconnect; plugin B hookskernel:readyand throws. Result:["driver.connect()", "bootstrap rejected: refused at kernel:ready", "shutdown() returned"], with no disconnect. Positive control, same kernel but plugin B'sstart()throws (Phase 2 rollback):["driver.connect()", "driver.disconnect() via destroy()", "bootstrap rejected: ..."], so the probe can see a destroy when one happens.Acceptance notes
erroron the exportedWasmConnectionOptions['logger']would let a typed caller pass a logger witherrorwithout a cast, and it is the declared home of this behaviour. It widens a published surface (Clause-②yes (widening),minor), which the card's claim declaredno, so it is not done here. The seat can take that as its own card.SqliteWasmDriver.connect()registers abeforeExithandler (packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver.ts) that callsvoid this.flush().catch(() => { /* ignore */ }), so a flush that fails at process exit is swallowed with no log. Read from source, not run. It sits outside this card's file surface and is not filed.Generated by Claude Code