Skip to content

fix(driver-sqlite-wasm): handle a failing background flush instead of rejecting unhandled - #23643

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-23635-background-flush-rejection
Oct 15, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-23635-background-flush-rejection

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #23635
Clause-②: no

A flush that fails in the background in driver-sqlite-wasm is now handled at the producer: reported once, at error, 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 awaited flush() / close() still throws to its caller.

What changed

packages/drivers/driver-sqlite-wasm/src/wasm-connection.ts, one file:

  • The three fire-and-forget flushes (markDirty under on-write, the debounced:N timer, and the flush deferred past a transaction's close in noteTransactionControl) now call one private helper, flushInBackground(), in place of three bare void this.flush().
  • On rejection the helper calls reportBackgroundFlushFailure(): one line at error (at warn for a logger that has no error) 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, so on-write does 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-marks dirty and rethrows, so the next flush retries and an awaited caller still gets the error. close() is untouched.
  • The exported type surface is unchanged: error is read off the injected logger when it has one (console does). Declaring it on the exported logger option type would be a Clause-② widening and a minor; 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 own process.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.

  • One test per call site (on-write, debounce timer, transaction close). Each makes the flush fail by removing the database's directory, as the dogfood teardown did, and asserts: no unhandled rejection, exactly one error line naming the file, dirty still set.
  • Retry: once the directory is back, the next write's flush lands, and a second connection reads back every row written during the failure.
  • Once per episode: three failed writes give one line; after a flush lands, a new failure reports again.
  • Report robustness: a warn-only logger still hears it, at warn; a logger that throws produces no unhandled rejection.
  • CONTROL: an awaited flush() rejects with ENOENT and does not also log, dirty stays set and a later flush() resolves; close() rejects with ENOENT.
  • Through the driver: the dogfood race itself (a live SqliteWasmDriver on on-write, its directory removed, the next insert) is reported, and the public driver.flush() still rejects.

Ablation

Each leg mutated wasm-connection.ts through scripts/ablation-replace.mjs (anchor hit once, blob changed on disk, restore proven: blob equal to HEAD and git diff HEAD empty). The test imports src directly, so no dist/ 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.

Mutation Result
on-write site back to bare void this.flush() the on-write test and the 5 tests that drive on-write fail (6 of 10); debounce and transaction tests stay green
debounce site back to bare exactly the debounce test fails (1 of 10)
transaction-close site back to bare exactly the transaction test fails (1 of 10)
helper body back to bare void this.flush() (all three sites) 8 of 10 fail; the two CONTROL tests stay green
once-guard removed the once-per-episode test fails (1 of 10)
latch reset after a landed write removed the once-per-episode test fails (1 of 10)
logger try/catch removed the throwing-logger test fails (1 of 10)
warn fallback removed the warn-only test fails (1 of 10)
this.dirty = true on failure removed (existing line) 8 of 10 fail, including the retry test

On the unmutated tree the file passes 10 of 10.

Verification

At aa42889b36. The patch round changed one thing: the private logger field's type is now one literal, with warn required and error optional. Every run below was repeated at this head.

  • pnpm check:optional-error-sink (a wide-population family that --commands does not print) was red at e3b5224c85, on the old intersection type. It exits 0 at this head: 37 sinks declare an optional error beside a required warn, and 0 are no-fallback. Both blocks dispatch-gates.mjs prints 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 --commands derived 62 commands from this tree; all 62 ran at this commit and exited 0, and --ran reconciles 62 derived, 62 run, 0 NOT-MEASURED. One of them, check:logger-receiver-detach, was red on my first spelling (a logger channel read off this.logger into a local) and is fixed in the last commit. pnpm check:durability-log-level is also green.

  • eslint with --no-inline-config on the two changed source files, --format json: 2 files, 0 errors, 0 warnings. The repo config never enables type-aware linting (no parserOptions.project, no typed rules; see the note near line 328 of eslint.config.mjs), so this diff cannot move the verdict of an untouched file. The repo-wide pnpm lint is 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.

  • Connect: ObjectQLPlugin.start (packages/objectql/src/plugin.ts, await this.ql?.init()) reaches driver.connect() in ObjectQLEngine.init (packages/objectql/src/engine.ts, the for (const [name, driver] of this.drivers) loop).
  • Failure: the ownership guard throws at kernel:ready, Phase 3 of ObjectKernel.bootstrap() (packages/core/src/kernel.ts, await this.context.trigger('kernel:ready')).
  • Why nothing closes it: rollbackStartedPlugins() is called only inside the Phase 2 branch for a plugin whose start() failed. The outer catch (error) of bootstrap() sets the state to stopped, releases the signal listeners and rethrows; the comment there states that shutdown() on such a kernel is a no-op. So no plugin destroy() runs, including DefaultDatasourcePlugin.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) and bootStack (packages/verify/src/harness.ts, await kernel.bootstrap()), add no teardown of their own.
  • Probe (core dist, scratch script outside the repo): plugin A's start() records a connect and its destroy() records a disconnect; plugin B hooks kernel:ready and throws. Result: ["driver.connect()", "bootstrap rejected: refused at kernel:ready", "shutdown() returned"], with no disconnect. Positive control, same kernel but plugin B's start() throws (Phase 2 rollback): ["driver.connect()", "driver.disconnect() via destroy()", "bootstrap rejected: ..."], so the probe can see a destroy when one happens.

Acceptance notes

  • Typing error on the exported WasmConnectionOptions['logger'] would let a typed caller pass a logger with error without 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 declared no, so it is not done here. The seat can take that as its own card.
  • SqliteWasmDriver.connect() registers a beforeExit handler (packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver.ts) that calls void 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

… 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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 15, 2026
@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

7 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
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7dc7d2213fbf4321070a33cd4a085dcb1f4c0fea → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4920007ee5a6f936e604e644f14478ed3653ec1a — the merge of head aa42889b366c1b37fca0341408dbf5d1de037562 into base 7dc7d2213fbf4321070a33cd4a085dcb1f4c0fea, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

…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>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 15, 2026 09:46
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 15, 2026 09:46
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 15, 2026
Merged via the queue into main with commit c7dd48a Oct 15, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23635-background-flush-rejection branch October 15, 2026 10:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants