Skip to content

fix: keep a crank's store work inside one transaction - #1021

Closed
sirtimid wants to merge 40 commits into
mainfrom
sirtimid/crank-rollback-integrity
Closed

sirtimid wants to merge 40 commits into
mainfrom
sirtimid/crank-rollback-integrity

Conversation

@sirtimid

@sirtimid sirtimid commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

#1020 has merged; this is rebased onto main and stands alone. Replaces #1012, #1018 and #1011, which are closed.

Four PRs were editing the same rollbackCrank lines. This one owns all of the kernel's crank-rollback, savepoint and transaction-boundary semantics, so nothing else has to.

Closes #1016 — a throw out of deliver now rolls the delivery back instead of committing the partial crank.

Why these had to merge

#1012 rewrote rollbackCrank's finally into a try/catch that truncates the savepoint stack and rethrows. #1010 changed ctx.savepoints from string[] to {name, maybeFreeKrefs}[] and added a cache/GC-candidate restore further down the same function. Composed naively the rethrow fires before the restore, so a failed rollback leaves stale in-memory GC candidates and stale cached stored values behind. Neither PR could see that from inside itself.

A second one surfaced while assembling this branch, and is the sharper argument: #1020's audit and #1012's flush reorder are silently incompatible. The audit reads the run queue as ground truth, but a buffered item's refcounts are incremented at enqueueSend/enqueueNotify time, so auditing before the flush reports every buffered item as a leak. assertRefCountsIfAuditing now runs after the flush.

What's fixed

Carried from #1012:

  1. releaseSavepoint is hardened the way rollbackSavepoint was. A RELEASE that threw left the savepoint on the stack and the transaction open with nothing that would ever commit or abort it, so every later write on the connection joined it, reported success, and vanished on close(). Both drivers now discard the transaction; the release failure still propagates. releaseAllSavepoints gets the companion case.
  2. A crank's store work stays inside one transaction. A crank takes crank and delivery savepoints and the run loop rolls back only delivery. Rolling back the outermost one ended the transaction, so the work an aborted crank still owes — terminating the vat, collecting garbage — was autocommitting a statement at a time.
  3. The wasm driver can no longer believe it is in a transaction it isn't. _inTx is cleared before the abort is attempted, because the abort can throw.

Fixing the four defects #1018 pinned as failing repros — its tests are carried here unmodified, with Ryan's authorship, and the fixes land as later commits so the branch reads test-then-fix:

  1. RemoteHandle reports the release failure, not a missing savepoint. It released inside its try and rolled back in the catch; now that a failed RELEASE discards the stack, that rollback threw No such savepoint in place of the real error. RemoteManager had the identical shape at its peerIncarnation_* savepoint with zero coverage — test: failing repros for four defects found reviewing #1012 #1018 flagged it and it is fixed and covered here. The savepoint-stack model is shared (test/savepoint-stack.ts), so a fix applied to one and forgotten in the other can't leave a green suite.
  2. endCrank no longer buries the error that killed the run loop. #runLoop called it from a bare finally. The in-flight error is boxed rather than compared against undefined, so a crank that threw undefined stays distinguishable from one that didn't throw.
  3. The kernel store gets a logger. No production call site passed one to makeSQLKernelDatabase, making four logger?.error calls dead code. Fixed for both the nodejs path and kernel-browser-runtime's kernel-worker.ts, so both drivers' calls are live.
  4. commitIfNeeded clears _inTx before the COMMIT, the ordering rollbackIfNeeded was already corrected for. A throwing COMMIT wedged _inTx true and beginIfNeeded became a permanent no-op.

And the composition bug above:

  1. The in-memory revert runs on the failed-rollback path too. Extracted as revertStateBeneathRollback() and called from both sites. A failed ROLLBACK TO makes the driver discard the whole transaction, so the database has moved back at least as far as a successful rollback would have taken it — the caches are at least as stale, and that is precisely where a lost GC action does the most damage. If the revert itself throws while a rollback error is in flight, the rollback error is preserved as cause.

Note for the reviewer

refreshCachedValues() refreshes gcActions, and it now runs on both rollback paths. The GC-delivery hardening PR stacked after this one depends on that: a DB rollback restores the gcActions row but not the cached closure over it, so without this the audit builds its exemption set from a stale cache and kills the run loop. Verified against a real SQLite store.

Testing

yarn lint clean, yarn build 31/31, four changelog:validate runs clean. kernel-store, ocap-kernel, kernel-node-runtime, kernel-browser-runtime and kernel-test all green.

Every fix mutation-verified — revert the production hunk and the named test fails for the stated reason.

@ocap/kernel-test is intermittently flaky, on this branch and on its base. Roughly two failures in twelve runs, always garbage-collection › survives until both importers let go or supervisor › initializes vat with powers, each passing in isolation and in five consecutive clean runs after. Both are GC/timing-dependent and untouched by crank, savepoint or logger code. The first is what the vat-lifecycle PR further up this stack targets, via makeGCAndFinalize draining the queues before gc(). Not introduced here, and not claimed green.

Not done

  • No test pins the kernel-worker.ts logger wiring; kernel-browser-runtime has no test module for it and standing up the browser mock surface is disproportionate for a one-line change. The nodejs equivalent is pinned by test: failing repros for four defects found reviewing #1012 #1018's own repro.
  • kernel-node-runtime/test/helpers/remote-comms.ts and kernel-test-local/src/lms-chat.ts still call makeSQLKernelDatabase without a logger — test harnesses, not production call sites.

Follow-ups filed

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, README.md, CHANGELOG.md) as appropriate

Note

High Risk
Changes core persistence and crank commit/rollback paths plus remote message timing; bugs here could corrupt kernel state or lose GC work, though coverage is extensive.

Overview
Hardens SQLite kernel-store transaction semantics and aligns crank boundaries with how the run loop commits work, so partial failures no longer leave “successful” writes in orphaned transactions or interleave remote savepoints with cranks.

The nodejs and wasm drivers now discard transactions when RELEASE, ROLLBACK TO, or COMMIT fails, refuse further writes when an abort cannot end a wedged transaction, and (wasm) use SQLite’s autocommit state instead of a cached in-transaction flag. The node driver stops logging every SQL statement via verbose; browser and node runtimes pass a kernel-store sub-logger into makeSQLKernelDatabase.

Crank layering uses separate crank and delivery savepoints: only the delivery rolls back on abort, while post-delivery work (vat termination, GC) stays in the crank transaction. The crank buffer flushes after that fallible work (so callers aren’t answered from state that gets rolled back), and the reference-count audit runs after endCrank. rollbackCrank also refreshes cached stored values and restores GC candidate state the DB rollback can’t reach.

Concurrency: withStoreOutOfCrank / outOfCrankWorkPending block cranks while inbound remote handling and peer incarnation updates hold their own outer savepoints; RemoteHandle resolves redeemURL before opening a savepoint. Related savepoint rollback failures are logged without masking the original error. Kernel.stop records last-active time on a best-effort basis.

Extensive new tests cover transaction survival, savepoint/crank interleaving, audit ordering, and GC test stability (reap until settled).

Reviewed by Cursor Bugbot for commit 0304ddc. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 72.62%
⬆️ +0.08%
9736 / 13406
🔵 Statements 72.47%
⬆️ +0.07%
9899 / 13658
🔵 Functions 73.03%
⬆️ +0.04%
2281 / 3123
🔵 Branches 66.91%
⬆️ +0.02%
3994 / 5969
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts 0%
🟰 ±0%
0%
🟰 ±0%
0%
🟰 ±0%
0%
🟰 ±0%
26-128
packages/kernel-node-runtime/src/kernel/make-kernel.ts 100%
🟰 ±0%
88.88%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/kernel-store/src/sqlite/nodejs.ts 99.07%
⬆️ +0.07%
93.33%
🟰 ±0%
100%
🟰 ±0%
99.07%
⬆️ +0.07%
82
packages/kernel-store/src/sqlite/wasm.ts 98.19%
⬆️ +0.16%
89.47%
🟰 ±0%
100%
🟰 ±0%
98.18%
⬆️ +0.16%
252-255
packages/ocap-kernel/src/KernelQueue.ts 98%
⬇️ -0.56%
89.47%
⬇️ -0.80%
100%
🟰 ±0%
98%
⬇️ -0.56%
149, 196, 544
packages/ocap-kernel/src/remotes/kernel/RemoteHandle.ts 95.91%
⬆️ +0.03%
89.47%
🟰 ±0%
98.03%
🟰 ±0%
95.88%
⬆️ +0.03%
389, 396-401, 447, 526, 569, 579-581, 641-644, 993, 1070-1072, 1123
packages/ocap-kernel/src/remotes/kernel/RemoteManager.ts 99.07%
⬆️ +0.02%
100%
🟰 ±0%
95.65%
🟰 ±0%
99.07%
⬆️ +0.02%
442-444
packages/ocap-kernel/src/store/index.ts 98.83%
⬇️ -0.07%
95.23%
🟰 ±0%
100%
🟰 ±0%
98.82%
⬇️ -0.06%
396
packages/ocap-kernel/src/store/types.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ocap-kernel/src/store/methods/crank.ts 97.87%
⬇️ -2.13%
88.88%
⬇️ -4.87%
100%
🟰 ±0%
97.87%
⬇️ -2.13%
80
Generated in workflow #4699 for commit e75a20d by the Vitest Coverage Report Action

@sirtimid
sirtimid force-pushed the sirtimid/crank-rollback-integrity branch from 3311d5c to 0892784 Compare August 17, 2026 10:08
sirtimid added a commit that referenced this pull request Aug 17, 2026
The crank-transaction and rollback work landed here rather than in #1012,
which is closed and replaced. #1021 is a placeholder until the PR exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/kernel-store/src/sqlite/wasm.ts
Base automatically changed from sirtimid/clist-refcount-symmetry to main August 20, 2026 13:45
@sirtimid
sirtimid force-pushed the sirtimid/crank-rollback-integrity branch from 0892784 to 65bd467 Compare August 20, 2026 13:59
sirtimid added a commit that referenced this pull request Aug 20, 2026
The crank-transaction and rollback work landed here rather than in #1012,
which is closed and replaced. #1021 is a placeholder until the PR exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sirtimid
sirtimid force-pushed the sirtimid/crank-rollback-integrity branch from 913524a to 05b273d Compare August 27, 2026 11:27
sirtimid added a commit that referenced this pull request Aug 27, 2026
The crank-transaction and rollback work landed here rather than in #1012,
which is closed and replaced. #1021 is a placeholder until the PR exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sirtimid
sirtimid requested a review from a team September 7, 2026 23:06

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/ocap-kernel/src/KernelQueue.ts
Comment thread packages/ocap-kernel/src/remotes/kernel/RemoteHandle.ts Outdated
grypez and others added 10 commits September 8, 2026 01:17
Eight tests, all currently failing, for three defects that landed with
#1005. They change no production code: each one states the invariant the
fix has to restore, so the diff that repairs them is the specification
being met rather than a claim about it.

`releaseSavepoint` was never hardened the way `rollbackSavepoint` was in
that PR. A RELEASE that throws leaves the savepoint on the stack and the
transaction open with nothing that will ever commit or abort it, so every
later write on the connection joins it, reports success, and vanishes on
close — verbatim the failure mode #1005 documents for the other door. The
driver tests sit beside their rollback counterparts so the asymmetry is
visible in place. `endCrank` gets the companion case: it now settles its
waiters in a `finally`, which is right, but it also leaves the savepoint
listed, so the next crank numbers its savepoint `t1` against a database
that still has `t0`.

`#processCrankResult` does fallible work after the crank's transactional
boundary has already been crossed. On the success path `#flushCrankBuffer`
settles the promise `enqueueMessage` handed an external caller, and only
then can `#terminateVat` throw and have the new catch roll the crank back
— so the caller keeps an answer computed from state the store discarded,
and a restart delivers the message again. On the abort path the rollback
ends the transaction, so `#terminateVat` and `collectGarbage` autocommit
piecemeal and the second rollback the flag correctly suppresses would
have had nothing left to undo either way. The invariant is stated as "the
rollback is the last thing the crank asks of the store", which leaves the
choice of remedy open.

The wasm driver tracks `_inTx` itself rather than reading it from SQLite,
so a failed abort inside the new catch is the one case that can leave it
disagreeing with the database. Left true, `beginIfNeeded` is a no-op from
then on and the next `createSavepoint` runs in autocommit mode, where the
matching RELEASE commits (Agoric/agoric-sdk#8423, already cited two lines
above the code) and no rollback can undo the delivery. The second test
runs that next `createSavepoint` and asserts the BEGIN, so the corruption
path is observable instead of argued.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three transaction-integrity defects, all in the same family: a store call
fails, and the layer above goes on as though its bookkeeping still matched
the database.

- `releaseSavepoint` (both SQLite drivers) discards the enclosing
  transaction when `RELEASE` fails, as `rollbackSavepoint` already does
  when `ROLLBACK TO` fails. Left as it was, the savepoint stayed on the
  stack and the transaction open with nothing to ever commit or abort it,
  so every later write on the connection joined it, reported success, and
  vanished on `close()`.
- `releaseAllSavepoints` forgets its savepoints even if the release
  throws, as `rollbackCrank` already does. A savepoint left listed had the
  next crank number its savepoint `t1` while the database still had `t0`,
  from which point every release and rollback aimed one crank past the one
  it meant to end.
- The wasm driver stops believing it is in a transaction when an abort
  fails. `_inTx` is tracked in the driver rather than read from SQLite, and
  an abort usually fails because SQLite already rolled back on its own.
  Left true, `beginIfNeeded` was a no-op from then on and the next
  `createSavepoint` ran in autocommit mode, where its `RELEASE` commits
  (Agoric/agoric-sdk#8423) and no later rollback could undo the delivery.

And the crank boundary itself, in two parts:

- A crank now takes two savepoints. Rolling back to the outermost one
  discards the enclosing transaction, so the work an aborted crank still
  owes — terminating the vat whose delivery failed, collecting garbage —
  was autocommitting statement by statement, beyond the reach of any later
  rollback. That work has to follow the rollback, since the worker is gone
  and the store must not go on believing the vat is alive, so it is the
  rollback that spares the transaction. Releasing the outer savepoint in
  `endCrank` is now a crank's one commit point.
- `#flushCrankBuffer` runs last, after everything that can still fail.
  It settles the promise `enqueueMessage` handed an external caller,
  reading the result out of the store; rolling the crank back after that
  left the caller holding an answer computed from state the store had
  discarded, and a restart would deliver the message again.

Tests for the first three defects are Ryan's, from #1011. The two crank
tests there specify the remedy as "the rollback is the last thing the
crank asks of the store", which reordering the fallible work before it
would satisfy — but that rollback would then undo the vat termination.
They are restated here as the invariant the fix does hold.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`should trigger GC syscalls through bringOutYourDead` scheduled one reap
and then ran three cranks. `scheduleReap` dedupes, so that bought one
`bringOutYourDead`, not three — and an import is only reported as dropped
once the engine has collected the vat's presence and run its finalizer,
which the forced GC pass inside `bringOutYourDead` cannot guarantee on the
first attempt. When it hadn't, no further reap was ever scheduled and the
refcount stayed where it was: `expected 2 to be 1`, as on main in
31081630878.

Each attempt now schedules its own reap and stops as soon as the kernel's
bookkeeping catches up, so the common case is one crank rather than three.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
A failed `ROLLBACK TO` discards the whole transaction, taking every
savepoint with it — not just the one rolled back to. `rollbackCrank`
truncated `ctx.savepoints` to the rolled-back ordinal regardless, which
was correct while a crank took one savepoint at ordinal 0 and cleared the
list, but leaves `['crank']` listed now that the delivery sits at ordinal
1.

`endCrank` then releases a `t0` the database no longer has, and throws
"No such savepoint: t0" from the run loop's `finally` — replacing the
failure that actually killed the kernel, with no `cause`. That is the
masking this branch's own error-preservation exists to prevent.

Clear the list on the throwing path, truncate to the ordinal only on
success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tion

Both drivers recover from a failed savepoint operation by discarding the
enclosing transaction, and swallow any error from that abort so the
savepoint failure stays the one reported. That part is right, but it left
the abandoned transaction entirely silent: on the nodejs driver, where
`inTransaction` is read from SQLite, the next crank's `beginIfNeeded`
sees the transaction still open, skips its `BEGIN`, and commits the dead
crank's writes alongside the new crank's.

Nothing here can repair that, so at least record it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moving `#invokeKernelSubscription` out of the enqueue loop and after it
was the one production change on this branch with no test: reverting
`#flushCrankBuffer` to its interleaved form left all 2412 ocap-kernel
tests passing.

Same hazard as the crank-level ordering a few tests up, one level down —
`#enqueueRun` is store work and can fail part-way, so answering the first
caller while the second enqueue is still ahead hands out a result the
crank's rollback then discards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five comments on this branch asserted more than the code holds:

- `wasm.ts` claimed a stale `_inTx` meant "no later rollback can undo the
  delivery". False: a savepoint created in autocommit mode does open a
  transaction, and an inner savepoint still rolls back. The real cost is
  that writes outside a savepoint autocommit one statement at a time, and
  the outermost `RELEASE` commits. The "an abort typically fails because
  SQLite already rolled back" premise was unsupported and isn't the
  reason for the reorder — the reason is simply that the abort can throw.
- `#processCrankResult` said "the worker is already gone" ahead of the
  call that kills the worker.
- The flush was described as running "once nothing fallible remains".
  It doesn't: `#terminateVat` resolves the dying vat's promises through
  `resolvePromises`, which defaults to `immediate` and invokes their
  kernel subscriptions before `collectGarbage`. Reachable without an
  abort, via a clean `exitVat`. Recorded rather than fixed — closing it
  changes termination semantics, not crank ordering.
- "Only `delivery` is ever rolled back" is true of the run loop but not
  of the tests. Scoped, and the ordinal coupling it depends on is now
  stated: `endCrank` releases `t0` by position, so `crank` must stay
  first.
- `reapImporterUntil` credited `scheduleReap` deduping for the old
  one-BOYD behaviour; it was `nextReapAction` shifting the single entry
  off, leaving the later cranks nothing to do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment the non-obvious why, in the shortest form that carries it. The
two-savepoint rationale was re-argued in full in four places; the tests
now point at `#runLoop` and `#processCrankResult` instead of restating
them, and the hazard block duplicated across both driver test files is a
line. No reasoning removed, only the retelling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lback

A database rollback cannot reach two pieces of state, so `rollbackCrank`
now reverts both itself.

Every `provideCachedStoredValue` answers reads from a closure and only
writes through to kv. Reverting the database therefore left the closure
holding the abandoned crank's value, and the next `set` persisted it.
`processGCActionSet` takes an action out of the set before delivering it,
so an aborted delivery lost the action outright rather than retrying it.
`reapQueue` was exposed the same way.

`maybeFreeKrefs` lives in RAM, so nothing reverted it either. Its entries
are collection candidates only because of the decrements the rollback
undid, and a later `collectGarbage` threw outright on a promise the
rollback had deleted, killing the run loop.

No live bug either way: every `abort` `#deliverGCAction` returns is paired
with a `terminate`, which is what made losing the action harmless. The
comment there claimed the rollback restored the action, which is the thing
a future reader would trust when adding an abort path that isn't paired
with a termination; it now states the real causality.

The cached values are declared once so that initialization and the
refresher cannot disagree about which ones exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e the gate's own gaps

Three changes to the crank layer, all of which #1021 either introduced or
left open:

`rollbackCrank` restores `maybeFreeKrefs` to the savepoint's snapshot rather
than emptying it. That set is not per-crank — only `collectGarbage` empties
it — so a candidate produced outside a crank, as a peer restart abandoning a
remote's exports does, was dropped by an unrelated crank's rollback and the
objects leaked with nothing left to notice them. The reference count audit
cannot see it either: an orphan with no holders and a count of zero looks
consistent. Guard adopted from #1039.

`RemoteManager` snapshots the restarting peer's promises inside its turn at
the store rather than before waiting for one. The wait spans a whole crank,
so a promise that crank made the peer decider of was never rejected and the
sending vat waited on it forever. This one was #1021's own regression.

`beginOutOfCrank`/`endOutOfCrank` are replaced by `withStoreOutOfCrank`. A
turn that was never given back left the run loop waiting on a promise nothing
resolves — no failure, no log, no timeout — and the callback's type now says
the held section has to be synchronous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sirtimid added a commit that referenced this pull request Sep 14, 2026
`RefCountViolation` gained its `kind` discriminant here, so the expectations
#1021 wrote against the old shape needed it. The audit also moved out of the
crank and runs after it commits, and `utils.ts` grew hooks that fail a test
whose run loop died — so the case that kills the loop on purpose now claims
that death as its result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/ocap-kernel/src/store/methods/crank.ts
`withStoreOutOfCrank` gives the turn back from its `finally` the moment
`work()` returns. An async callback returns a promise there, so the store
went back to the run loop with the callback still between its savepoint and
the release — the interleaving the turn exists to prevent. `() => Result`
did not forbid it, though the JSDoc and changelog said the type did.

The type now refuses a callback whose return type is thenable, and a
thenable that reaches the call through inference is thrown on instead of
being handed the turn back mid-flight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sirtimid added a commit that referenced this pull request Sep 14, 2026
`RefCountViolation` gained its `kind` discriminant here, so the expectations
#1021 wrote against the old shape needed it. The audit also moved out of the
crank and runs after it commits, and `utils.ts` grew hooks that fail a test
whose run loop died — so the case that kills the loop on purpose now claims
that death as its result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/ocap-kernel/src/store/methods/crank.ts
…ned transaction out of the rollback

Three ways the crank's transaction discipline could still leave the store
disagreeing with what had already happened:

- `vatPowers.exitVat` terminates without aborting, so `#crankRollbackAttempted`
  stayed unset and a throw from `collectGarbage` or the crank flush sent the run
  loop's catch into `rollbackCrank('delivery')` — undoing `deleteVat` and
  `markVatAsTerminated` for a worker already killed, whose callers had already
  been rejected, and then committing that. The flag is now
  `#deliveryRollbackAllowed` and a recorded death withholds it.

- `withStoreOutOfCrank`'s synchrony type was distributive, so a union return
  (one promise-returning branch of several) and a forwarded generic parameter
  both got through; and the runtime check threw without settling the thenable it
  refused, making an abandoned rejection end the process. `Synchronous<Result>`
  judges the union whole, and the thenable gets a handler before the throw.

- `createSavepoint` was the one savepoint path with no discard guard: a
  `SAVEPOINT` that failed after this call's own `BEGIN` left a transaction with
  nothing on the savepoint stack to reach it and `txAbandoned` unset, so every
  later write joined it and an unrelated release committed the lot. It now
  validates the name ahead of the `BEGIN` and discards a transaction it began.

Each of the three is pinned by a test verified against the mutation.

Also: `createSavepoint`'s refusal and two changelog entries named
`beginOutOfCrank`, which is module-private; the BREAKING entry announced a
migration off two methods that never shipped, and is now an `### Added` entry
for `withStoreOutOfCrank`/`outOfCrankWorkPending`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sirtimid added a commit that referenced this pull request Sep 15, 2026
`RefCountViolation` gained its `kind` discriminant here, so the expectations
#1021 wrote against the old shape needed it. The audit also moved out of the
crank and runs after it commits, and `utils.ts` grew hooks that fail a test
whose run loop died — so the case that kills the loop on purpose now claims
that death as its result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 0304ddc. Configure here.

if (crankResult?.terminate) {
const { vatId, info } = crankResult.terminate;
await this.#terminateVat(vatId, info);
this.#deliveryRollbackAllowed = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Terminate commits crank without flushing

High Severity

Clearing #deliveryRollbackAllowed after #terminateVat means a later throw from collectGarbage skips both rollback and #flushCrankBuffer, then #endCrank commits the delivery. Promise resolutions and other vat outputs stay in the store while their notifies never reach the run queue, so waiters hang across restart. exitVat is the path that terminates without abort, and the new test throws from collectGarbage on exactly that path.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0304ddc. Configure here.

@sirtimid
sirtimid marked this pull request as draft September 15, 2026 14:50
@sirtimid

Copy link
Copy Markdown
Contributor Author

Converting to draft. This stack is being split into small, single-purpose PRs against main rather than reviewed as a whole; it stays open as the reference to cherry-pick from and will be closed once its last piece has landed. Please do not spend review time on it in this form.

@sirtimid

Copy link
Copy Markdown
Contributor Author

Closing: this is now fully re-landed as individually reviewed PRs against main.

The split is described in the plan agreed on 2026-09-15; the design question the concurrency work turned on is answered in the accompanying decision record, only the run loop writes the store, which is why the Tier 3 pieces below are rewritten rather than cherry-picked. withStoreOutOfCrank, outOfCrankWorkPending, the run loop's gate re-check and the savepoint-inside-crank refusals do not appear in any of them.

What this PR became:

piece PR
Kernel-store logging: no better-sqlite3 verbose, no per-kv debug lines, a store logger in both runtimes #1086
wasm driver reads inTransaction from sqlite3_get_autocommit #1089
One copy of the savepoint/transaction methods, shared by both drivers #1092
A failed RELEASE / COMMIT / SAVEPOINT discards the transaction; writes refused while it is abandoned #1094
Crank rollback reverts the in-memory caches #1087
Two crank savepoints, so termination and GC after an abort stay in the crank's transaction #1090
Audit vs flush ordering — by crediting the crank buffer, so the audit can run before the flush #1095
Post-commit hook; an outbound remote delivery is sent after its crank commits #1101
An inbound remote message is a run-queue item, taken in a crank of its own #1103
A peer's incarnation change is a run-queue item #1104
clearStorage / reset / stop stop the run loop before writing #1105
A savepoint taken while the run loop runs is refused #1106
makeGCAndFinalize drains the queues before gc() #1083

Each is motivated on its own, reproduces on main where it is a defect, and carries a test named for the behaviour. Nothing here is lost; the branch stays for reference.

@sirtimid sirtimid closed this Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A throw inside a crank commits the partial crank instead of rolling it back

2 participants