test: pin the transaction invariants #1005 left broken - #1011
Draft
grypez wants to merge 1 commit into
Draft
Conversation
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>
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Explanation
Eight tests, all currently failing, for three defects that landed with #1005. No production code changes: each test states the invariant a fix has to restore, so the diff that repairs them is the specification being met rather than a claim about it.
CI is expected to be red until the fixes land. That is the point of the branch — please don't read the failures as flakes.
Draft because the remedies are the author's call, not mine. Happy for these to be cherry-picked into a fix PR and this one closed, or for me to add the fixes here.
The three defects
1.
releaseSavepointwas never hardened the wayrollbackSavepointwas#1005 fixed
rollbackSavepointto discard the enclosing transaction whenROLLBACK TOfails, with a comment explaining why: otherwise the savepoint stays on the stack and the transaction stays open with nothing that will ever commit or abort it, so every later write on that connection joins it, reports success, and vanishes onclose().releaseSavepointhas the identical shape and was left alone. The new tests sit directly beside their rollback counterparts so the asymmetry is visible in place.endCrankgets the companion case. Settling itswaitForCrankwaiters in afinallyis right — but it also leavesctx.savepointslisting a savepoint the release failed to remove, so the next crank numbers its savepointt1while the database still hast0. From then onreleaseAllSavepointsreleases the wrong one and every rollback aims past the crank it meant to undo.rollbackCrankalready does the right thing in its ownfinally; this mirrors it.Before #1005 this wedged safely —
endCrankthrew before clearinginCrank, so every waiter hung and nothing proceeded. Now the kernel walks past it. In the daemonprocess.exit(1)follows, but the browser worker deliberately stays up, so panel-drivenreset()/terminateAllVats()writes proceed into the orphaned transaction.releaseSavepoint discards the transaction when the release failskernel-store/src/sqlite/nodejs.test.tsreleaseSavepoint discards the transaction when the release failskernel-store/src/sqlite/wasm.test.tsforgets its savepoints even if releasing them failsocap-kernel/src/store/methods/crank.test.ts2.
#processCrankResultdoes fallible work after the crank's transactional boundaryThe PR's headline claim is that the crank the loop died in is rolled back. It isn't, in two ways:
#flushCrankBuffer()calls#invokeKernelSubscription, which settles the promiseenqueueMessagehanded an externalKernel.queueMessagecaller. Only then canawait #terminateVat(...)orcollectGarbage()throw and have the new inner catch roll the crank back. The caller keeps an answer computed from state the store discarded; a restart re-delivers the message and notifies every other subscriber again. The abort path already guards this hazard by clearing#resolvedWithKernelSubscription— the success path doesn't.rollbackCrank('start')empties the savepoint stack, which ends the transaction.#terminateVatandcollectGarbagethen run with no savepoint and no transaction, so their writes autocommit piecemeal.#crankRollbackAttemptedcorrectly suppresses a second rollback, but there was nothing left to undo either way.The first test proves both halves of the inconsistency at once: its
expect(resolve).toHaveBeenCalledWith(...)assertion passes — the caller really does get its answer — and then the rollback assertion fails. It only goes green when those two stop co-occurring.The parameterized pair states the invariant as the rollback is the last thing the crank asks of the store, rather than prescribing where
#terminateVatlands relative tocollectGarbage. Reordering and moving the fallible work inside the savepoint both satisfy it.does not roll back a crank whose result the caller already receivedocap-kernel/src/KernelQueue.test.tsdoes no store work after rolling back 'an abort'ocap-kernel/src/KernelQueue.test.tsdoes no store work after rolling back 'an abort that also terminates'ocap-kernel/src/KernelQueue.test.ts3. The wasm driver can be left believing it is in a transaction
wasm.tstracks_inTxitself rather than reading it from SQLite, so a failed abort inside #1005's newrollbackSavepointcatch is the one case that can leave it disagreeing with the database. The nodejs driver is immune —db.inTransactioncomes from better-sqlite3.Left true,
beginIfNeededis a no-op from then on and the nextcreateSavepointrunsSAVEPOINTin autocommit mode, where the matchingRELEASEcommits (Agoric/agoric-sdk#8423, already cited two lines above the code) and no later rollback can undo the delivery — an{ abort: true }crank silently keeps its writes. Reachable whenever SQLite has already auto-rolled-back as part of the error that madeROLLBACK TOfail in the first place.The second test runs that next
createSavepointand asserts theBEGIN, so the corruption path is observable rather than argued.stops believing it is in a transaction when the abort fails tookernel-store/src/sqlite/wasm.test.tsbegins a transaction for the next savepoint after a failed abortkernel-store/src/sqlite/wasm.test.tsTesting
Every new test fails for the mechanism it describes, not incidentally:
All pre-existing tests in the touched files still pass — 78/78 in
kernel-store, 61/61 acrossKernelQueue.test.tsandcrank.test.ts.yarn lintis clean.Incidental
wasm.test.ts's existingrollbackSavepoint reports the rollback failure even if the abort fails tooends in a baremockDb._inTx = false;with no assertion. It is already redundant given thedescribe'sbeforeEach, and it exists only because production leaves_inTxtrue — it quietly documents defect 3 instead of asserting it. Left in place rather than folding unrelated edits into this branch; worth deleting alongside the fix.Checklist
🤖 Generated with Claude Code