Skip to content

Commit 76dec71

Browse files
grypezclaude
authored andcommitted
test: pin the transaction invariants #1005 left broken
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>
1 parent f02677d commit 76dec71

4 files changed

Lines changed: 215 additions & 0 deletions

File tree

packages/kernel-store/src/sqlite/nodejs.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,29 @@ describe('makeSQLKernelDatabase', () => {
360360
expect(mockDb._spStack).toStrictEqual([]);
361361
});
362362

363+
// The same hazard `rollbackSavepoint` guards against, by the other door: a
364+
// RELEASE that throws leaves the savepoint on the stack and the transaction
365+
// open with nothing to ever commit or abort it, so every later write on this
366+
// connection joins it, reports success, and vanishes on close.
367+
it('releaseSavepoint discards the transaction when the release fails', async () => {
368+
const db = await makeSQLKernelDatabase({});
369+
mockDb.inTransaction = true;
370+
mockDb._spStack = ['point1'];
371+
mockStatement.run.mockClear();
372+
mockDb.exec.mockImplementationOnce(() => {
373+
throw new Error('disk I/O error');
374+
});
375+
376+
expect(() => db.releaseSavepoint('point1')).toThrowError(
377+
'disk I/O error',
378+
);
379+
380+
expect(mockDb._spStack).toStrictEqual([]);
381+
// The abort is the only prepared statement this path runs.
382+
expect(mockStatement.run).toHaveBeenCalledOnce();
383+
mockDb.inTransaction = false;
384+
});
385+
363386
it('supports nested savepoints', async () => {
364387
const db = await makeSQLKernelDatabase({});
365388
db.createSavepoint('outer');

packages/kernel-store/src/sqlite/wasm.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,75 @@ describe('makeSQLKernelDatabase', () => {
518518
expect(mockDb._inTx).toBe(false);
519519
});
520520

521+
// The same hazard `rollbackSavepoint` guards against, by the other door: a
522+
// RELEASE that throws leaves the savepoint on the stack and the transaction
523+
// open with nothing to ever commit or abort it, so every later write on this
524+
// connection joins it, reports success, and vanishes on close.
525+
it('releaseSavepoint discards the transaction when the release fails', async () => {
526+
const db = await makeSQLKernelDatabase({});
527+
mockDb._inTx = true;
528+
mockDb._spStack = ['point1'];
529+
mockDb.exec.mockImplementationOnce(() => {
530+
throw new Error('disk I/O error');
531+
});
532+
533+
expect(() => db.releaseSavepoint('point1')).toThrowError(
534+
'disk I/O error',
535+
);
536+
537+
expect(mockDb._spStack).toStrictEqual([]);
538+
expect(mockDb._inTx).toBe(false);
539+
});
540+
541+
// `_inTx` is tracked here rather than read from SQLite, so a failed abort is
542+
// the one case that can leave it disagreeing with the database. Left true,
543+
// `beginIfNeeded` becomes a no-op forever after.
544+
it('stops believing it is in a transaction when the abort fails too', async () => {
545+
const db = await makeSQLKernelDatabase({});
546+
mockDb._inTx = true;
547+
mockDb._spStack = ['point1'];
548+
mockDb.exec.mockImplementationOnce(() => {
549+
throw new Error('disk I/O error');
550+
});
551+
mockStatement.step.mockImplementationOnce(() => {
552+
throw new Error('cannot rollback');
553+
});
554+
555+
expect(() => db.rollbackSavepoint('point1')).toThrowError(
556+
'disk I/O error',
557+
);
558+
559+
expect(mockDb._inTx).toBe(false);
560+
});
561+
562+
// The consequence of the above, and the reason it is worth asserting: a
563+
// savepoint created outside a transaction autocommits when released
564+
// (Agoric/agoric-sdk#8423), so no later rollback can undo the delivery — an
565+
// aborted crank would silently keep its writes.
566+
it('begins a transaction for the next savepoint after a failed abort', async () => {
567+
const db = await makeSQLKernelDatabase({});
568+
mockDb._inTx = true;
569+
mockDb._spStack = ['point1'];
570+
mockDb.exec.mockImplementationOnce(() => {
571+
throw new Error('disk I/O error');
572+
});
573+
mockStatement.step.mockImplementationOnce(() => {
574+
throw new Error('cannot rollback');
575+
});
576+
expect(() => db.rollbackSavepoint('point1')).toThrowError(
577+
'disk I/O error',
578+
);
579+
580+
mockDb.exec.mockClear();
581+
mockStatement.step.mockClear();
582+
db.createSavepoint('next');
583+
584+
// BEGIN is the only prepared statement `createSavepoint` runs; the
585+
// SAVEPOINT itself goes through `exec`.
586+
expect(mockStatement.step).toHaveBeenCalledOnce();
587+
expect(mockDb.exec).toHaveBeenCalledWith('SAVEPOINT next');
588+
});
589+
521590
it('supports nested savepoints', async () => {
522591
const db = await makeSQLKernelDatabase({});
523592
db.createSavepoint('outer');

packages/ocap-kernel/src/KernelQueue.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,112 @@ describe('KernelQueue', () => {
195195
expect(kernelStore.collectGarbage).toHaveBeenCalled();
196196
expect(kernelStore.endCrank).toHaveBeenCalled();
197197
});
198+
199+
// `#flushCrankBuffer` settles the promise `enqueueMessage` handed an external
200+
// caller, reading the resolution out of the store on the way. Rolling the
201+
// crank back afterwards un-resolves that promise in the store and restores
202+
// the run queue item, so a restart delivers the message a second time and
203+
// notifies every other subscriber again — while the original caller has
204+
// already been told the first answer.
205+
it('does not roll back a crank whose result the caller already received', async () => {
206+
const mockItem: RunQueueItem = {
207+
type: 'send',
208+
target: 'ko123',
209+
message: { result: 'kp1' } as KernelMessage,
210+
};
211+
(kernelStore.runQueueLength as unknown as MockInstance)
212+
.mockReturnValueOnce(1)
213+
.mockReturnValue(0);
214+
(kernelStore.dequeueRun as unknown as MockInstance).mockReturnValueOnce(
215+
mockItem,
216+
);
217+
218+
// A caller is awaiting this message's result.
219+
const resolve = vi.fn();
220+
const reject = vi.fn();
221+
kernelQueue.subscriptions.set('kp1', { resolve, reject });
222+
223+
// The crank succeeds, so the flush hands that caller its answer...
224+
(
225+
kernelStore.flushCrankBuffer as unknown as MockInstance
226+
).mockReturnValueOnce([
227+
{ type: 'notify', endpointId: 'v1', kpid: 'kp1' },
228+
]);
229+
(kernelStore.getKernelPromise as unknown as MockInstance).mockReturnValue(
230+
{
231+
state: 'fulfilled',
232+
value: { body: '"answer"', slots: [] },
233+
},
234+
);
235+
236+
// ...and only then does the kernel die, in work that runs after the flush.
237+
const terminationError = new Error('vat worker already gone');
238+
(terminateVat as unknown as MockInstance).mockRejectedValueOnce(
239+
terminationError,
240+
);
241+
const deliver = vi.fn().mockResolvedValue({
242+
terminate: { vatId: 'v1', info: { body: '"exit"', slots: [] } },
243+
});
244+
245+
await expect(kernelQueue.run(deliver)).rejects.toBe(terminationError);
246+
247+
expect(resolve).toHaveBeenCalledWith({ body: '"answer"', slots: [] });
248+
expect(kernelStore.rollbackCrank).not.toHaveBeenCalled();
249+
});
250+
251+
// `rollbackCrank('start')` rolls back the crank's outermost savepoint, which
252+
// ends the transaction — so anything the crank does to the store afterwards
253+
// autocommits piecemeal and no rollback can reach it. Whatever the ordering,
254+
// the rollback has to be the last thing the crank asks of the store.
255+
it.each([
256+
{ label: 'an abort', crankResult: { abort: true } },
257+
{
258+
label: 'an abort that also terminates',
259+
crankResult: {
260+
abort: true,
261+
terminate: { vatId: 'v1', info: { body: '"exit"', slots: [] } },
262+
},
263+
},
264+
])(
265+
'does no store work after rolling back $label',
266+
async ({ crankResult }) => {
267+
const mockItem: RunQueueItem = {
268+
type: 'send',
269+
target: 'ko123',
270+
message: { result: 'kp99' } as KernelMessage,
271+
};
272+
(kernelStore.runQueueLength as unknown as MockInstance)
273+
.mockReturnValueOnce(1)
274+
.mockReturnValue(0);
275+
(kernelStore.dequeueRun as unknown as MockInstance).mockReturnValueOnce(
276+
mockItem,
277+
);
278+
279+
const storeCalls: string[] = [];
280+
(
281+
kernelStore.rollbackCrank as unknown as MockInstance
282+
).mockImplementation(() => {
283+
storeCalls.push('rollbackCrank');
284+
});
285+
(terminateVat as unknown as MockInstance).mockImplementation(
286+
async () => {
287+
storeCalls.push('terminateVat');
288+
},
289+
);
290+
(
291+
kernelStore.collectGarbage as unknown as MockInstance
292+
).mockImplementation(() => {
293+
storeCalls.push('collectGarbage');
294+
throw new Error(STOP_RUN_LOOP);
295+
});
296+
297+
const deliver = vi.fn().mockResolvedValue(crankResult);
298+
await expect(kernelQueue.run(deliver)).rejects.toThrow(STOP_RUN_LOOP);
299+
300+
expect(storeCalls).toContain('rollbackCrank');
301+
expect(storeCalls.at(-1)).toBe('rollbackCrank');
302+
},
303+
);
198304
});
199305

200306
describe('getRunLoopStatus', () => {

packages/ocap-kernel/src/store/methods/crank.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,23 @@ describe('crank methods', () => {
206206
expect(context.resolveCrank).toBeUndefined();
207207
expect(await waiter).toBeUndefined();
208208
});
209+
210+
// What `rollbackCrank` already does in its own `finally`. Settling the crank
211+
// regardless means callers proceed, so a savepoint left listed here has the
212+
// next crank number its savepoint `t1` while the database still has `t0`:
213+
// from then on `releaseAllSavepoints` releases the wrong one and every
214+
// rollback aims past the crank it meant to undo.
215+
it('forgets its savepoints even if releasing them fails', () => {
216+
crankMethods.startCrank();
217+
context.savepoints = ['test'];
218+
vi.mocked(kdb.releaseSavepoint).mockImplementationOnce(() => {
219+
throw new Error('database is gone');
220+
});
221+
222+
expect(() => crankMethods.endCrank()).toThrow('database is gone');
223+
224+
expect(context.savepoints).toStrictEqual([]);
225+
});
209226
});
210227

211228
describe('releaseAllSavepoints', () => {

0 commit comments

Comments
 (0)