Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
a310fe4
fix(ocap-kernel): make c-list import accounting symmetric (#1006)
sirtimid Aug 5, 2026
d921e9f
fix(ocap-kernel): format the changelog and cite this PR
sirtimid Aug 13, 2026
1f9c888
fix(ocap-kernel): don't audit an importer entry the collector is reti…
sirtimid Aug 13, 2026
e26c929
fix(ocap-kernel): retain the holders the accounting cannot see
sirtimid Aug 14, 2026
051d772
fix(ocap-kernel): release the ocap URL retention a failed mint took
sirtimid Aug 14, 2026
ee9c894
fix(ocap-kernel): take an ocap URL retention per issuance, not per kref
sirtimid Aug 17, 2026
e0573b7
test: pin the transaction invariants #1005 left broken
grypez Aug 6, 2026
c8ce039
fix: keep a crank's store work inside one transaction
sirtimid Aug 6, 2026
58b00b7
test(kernel-test): reap until the vat's GC is visible, not three times
sirtimid Aug 6, 2026
29e4dbf
test: fix rollback crank test
sirtimid Aug 6, 2026
f3b0d4a
fix(ocap-kernel): forget every savepoint when a crank rollback fails
sirtimid Aug 6, 2026
d6ab74c
fix(kernel-store): log an abort that fails while discarding a transac…
sirtimid Aug 6, 2026
dcf1db7
test(ocap-kernel): pin the flush's ordering against a failing enqueue
sirtimid Aug 6, 2026
97f161b
docs: correct the transaction claims review found wrong
sirtimid Aug 6, 2026
fe99803
docs: cut the padding from this branch's comments and changelogs
sirtimid Aug 6, 2026
f605ec6
fix(ocap-kernel): revert cached values and GC candidates on crank rol…
sirtimid Aug 10, 2026
5fadbf4
test(kernel-store): pin the failed COMMIT that wedges `_inTx`
grypez Aug 10, 2026
9d78120
test(ocap-kernel): pin the endCrank failure that buries the real error
grypez Aug 10, 2026
ad07e8a
test(kernel-node-runtime): pin the kernel store's missing logger
grypez Aug 10, 2026
997c10d
test(ocap-kernel): pin the release failure lost at the remote savepoint
grypez Aug 10, 2026
251e58f
test: tighten the four repros after review
grypez Aug 10, 2026
86d16dc
fix(kernel-store): clear `_inTx` before the COMMIT, not after
sirtimid Aug 13, 2026
8adaae5
fix(ocap-kernel): stop `endCrank` burying the error that killed the r…
sirtimid Aug 13, 2026
fdf9d6a
fix(ocap-kernel): report the remote release failure, not a missing sa…
sirtimid Aug 13, 2026
80cb871
test(ocap-kernel): pin the in-memory revert against a failed crank ro…
sirtimid Aug 13, 2026
90b42e9
fix(runtimes): give the kernel store a logger
sirtimid Aug 13, 2026
0892784
chore: cite this PR in the changelogs
sirtimid Aug 13, 2026
da40298
fix(ocap-kernel): free retired exports and harden GC delivery
sirtimid Aug 5, 2026
f6791a5
fix(ocap-kernel): guard disowning, and stop hiding GC delivery failures
sirtimid Aug 5, 2026
5c3a850
fix(ocap-kernel): don't starve the run loop retrying a remote GC deli…
sirtimid Aug 5, 2026
2cc1d85
test(ocap-kernel): pin each refcount audit credit source to a literal
sirtimid Aug 10, 2026
0e80f43
fix(ocap-kernel): don't commit a GC release a restarting vat disagree…
sirtimid Aug 10, 2026
2bcfb09
test(ocap-kernel): cover the export-ownership guard and the launch cl…
sirtimid Aug 13, 2026
1099770
docs(ocap-kernel): add changelog entries for GC delivery hardening
sirtimid Aug 13, 2026
4868848
fix(ocap-kernel): wait for a vat between workers instead of reading i…
sirtimid Aug 10, 2026
4e495cd
fix(ocap-kernel): let the run loop restart a vat, and drop work only …
sirtimid Aug 10, 2026
35ce937
fix(ocap-kernel): stop a send resolving a live endpoint as unreachable
sirtimid Aug 10, 2026
eaa71ac
fix(ocap-kernel): keep the run loop alive through restart, cleanup, a…
sirtimid Aug 10, 2026
e8b3961
fix(ocap-kernel): record a vat's death in one synchronous step
sirtimid Aug 12, 2026
52823d3
fix(ocap-kernel): restore the pre-crank GC candidates, and mark a par…
sirtimid Aug 13, 2026
6a66373
docs(ocap-kernel): add changelog entries for the vat lifecycle work
sirtimid Aug 13, 2026
bfcdbc4
fix(ocap-kernel): reject the delivery in flight when a vat's stream dies
sirtimid Aug 13, 2026
c9b917b
fix(ocap-kernel): tear down a vat whose stream dies before its handle…
sirtimid Aug 13, 2026
e54a39a
test(kernel-store): pin the crank commit point against a foreign save…
grypez Aug 31, 2026
b072c26
test(kernel-store): pin the transaction that survives a failed rollback
grypez Aug 31, 2026
489f299
test(kernel-store): pin the transaction a failed COMMIT leaves open
grypez Aug 31, 2026
d2edb2f
test(ocap-kernel): pin the audit that runs after the crank answers it…
grypez Aug 31, 2026
17ed8d9
test(ocap-kernel): pin the GC candidate a rollback must not discard
grypez Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/extension/test/e2e/control-panel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,6 @@ test.describe('Control Panel', () => {
`{"key":"v3.c.o+0","value":"${v3Root}"}`,
`{"key":"v3.c.${v3Promise}","value":"R p-1"}`,
`{"key":"v3.c.p-1","value":"${v3Promise}"}`,
`{"key":"${v3Root}.refCount","value":"1,1"}`,
`{"key":"${v3Promise}.refCount","value":"2"}`,
];
// Derived too: v1 imports the two roots as the bootstrap's calls are
Expand Down
1 change: 1 addition & 0 deletions packages/kernel-browser-runtime/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- The kernel worker gives the kernel store a logger, so the wasm SQLite driver's diagnostics — including the transaction aborts it reports on the kernel's dying path — reach the log rather than nowhere ([#1021](https://github.com/MetaMask/ocap-kernel/pull/1021))
- Process platform-services RPC request handlers in the background so a request handler that fires a reentrant outbound RPC (e.g. transport handshake calling back into the kernel) cannot deadlock waiting for its response ([#948](https://github.com/MetaMask/ocap-kernel/pull/948))

## [0.6.0]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ async function main(): Promise<void> {
isJsonRpcMessage,
),
PlatformServicesClient.make(globalThis as PostMessageTarget),
makeSQLKernelDatabase({ dbFilename: DB_FILENAME }),
makeSQLKernelDatabase({
dbFilename: DB_FILENAME,
logger: logger.subLogger({ tags: ['kernel-store'] }),
}),
]);

setupConsoleForwarding({
Expand Down
1 change: 1 addition & 0 deletions packages/kernel-node-runtime/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- `makeKernel` gives the kernel store a logger, so the SQLite driver's diagnostics — including the transaction aborts it reports on the kernel's dying path — reach the log rather than nowhere ([#1021](https://github.com/MetaMask/ocap-kernel/pull/1021))
- The RPC socket server refuses to bind a Unix socket that has a live listener, rather than unlinking it and orphaning the previous owner; stale socket files with no listener are still cleaned up automatically ([#952](https://github.com/MetaMask/ocap-kernel/pull/952))

## [0.1.0]
Expand Down
21 changes: 20 additions & 1 deletion packages/kernel-node-runtime/src/kernel/make-kernel.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs';
import { Logger } from '@metamask/logger';
import { Kernel } from '@metamask/ocap-kernel';
import { describe, expect, it, vi } from 'vitest';

Expand All @@ -8,7 +10,9 @@ vi.mock('@metamask/kernel-store/sqlite/nodejs', async () => {
'../../../ocap-kernel/test/storage.ts'
);
return {
makeSQLKernelDatabase: makeMapKernelDatabase,
// Wrapped so that a test can see what the database was constructed with,
// while still getting a real store back.
makeSQLKernelDatabase: vi.fn(makeMapKernelDatabase),
};
});

Expand All @@ -18,4 +22,19 @@ describe('makeKernel', () => {

expect(kernel).toBeInstanceOf(Kernel);
});

// FAILING REPRO.
//
// The kernel store is the only collaborator `makeKernel` builds without
// handing it a logger, so every `logger?.` call inside the SQLite driver is
// dead code in production — including the four abort failures #1012 added
// logging for. `kernel-worker.ts` omits it too, which keeps the wasm driver's
// pair dead even once this passes.
it('gives the kernel store a logger', async () => {
await makeKernel({});

expect(vi.mocked(makeSQLKernelDatabase)).toHaveBeenCalledWith(
expect.objectContaining({ logger: expect.any(Logger) }),
);
});
});
5 changes: 4 additions & 1 deletion packages/kernel-node-runtime/src/kernel/make-kernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ export async function makeKernel({
});

// Initialize kernel store.
const kernelDatabase = await makeSQLKernelDatabase({ dbFilename });
const kernelDatabase = await makeSQLKernelDatabase({
dbFilename,
logger: rootLogger.subLogger({ tags: ['kernel-store'] }),
});

// Create and start kernel.
const kernel = await Kernel.make(platformServicesClient, kernelDatabase, {
Expand Down
5 changes: 5 additions & 0 deletions packages/kernel-store/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `rollbackSavepoint` discards the enclosing transaction when `ROLLBACK TO` itself fails, instead of leaving the savepoint on its stack and the transaction open ([#1005](https://github.com/MetaMask/ocap-kernel/pull/1005))
- Nothing would ever commit or abort that transaction, so every later write on the connection silently joined it, reported success, and vanished on close. Discarding it is no wider than the caller asked for: the transaction begins with the outermost savepoint, so it holds only the work the rollback was abandoning
- The rollback failure is still what gets thrown, even if aborting the transaction fails too
- `releaseSavepoint` discards the enclosing transaction when `RELEASE` fails, as `rollbackSavepoint` already did for `ROLLBACK TO` ([#1021](https://github.com/MetaMask/ocap-kernel/pull/1021))
- Otherwise the savepoint stayed on the stack and the transaction open with nothing left to commit or abort it. The release failure is still what gets thrown
- The wasm driver clears `_inTx` when aborting or committing a transaction throws, instead of believing it is still in one ([#1021](https://github.com/MetaMask/ocap-kernel/pull/1021))
- Left true, `beginIfNeeded` became a permanent no-op and later writes autocommitted — including the next savepoint, which was then created bare, where its `RELEASE` commits and no rollback could undo the delivery. The nodejs driver reads `db.inTransaction` and was never affected
- An abort that fails while recovering from a failed savepoint operation is now logged in both drivers ([#1021](https://github.com/MetaMask/ocap-kernel/pull/1021))

## [0.6.0]

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest';

import { makeSQLKernelDatabase } from './nodejs.ts';

/**
* `KernelQueue.#runLoop` calls releasing its `crank` savepoint "this crank's one
* commit point", on the grounds that only `delivery` is ever rolled back.
*
* `releaseAllSavepoints` releases `t0`, which is the outermost savepoint only if
* the crank opened the first one. Two production paths open savepoints through
* `KernelStore.createSavepoint`, which bypasses `ctx.savepoints` and so is
* invisible to the ordinal numbering: `RemoteHandle.handleRemoteMessage` (held
* across its `await this.#handleRedeemURLRequest(...)`) and
* `RemoteManager.#handlePeerIncarnation`. Neither waits for the crank, and the
* remote message handler is installed in `Kernel.#init` as a bare async callback,
* so all three orderings below are reachable while the run loop sits in
* `await deliver(queueItem)`.
*
* Real SQLite through the real driver: these are the savepoint semantics, not a
* mock's idea of them.
*/
describe('a savepoint the crank does not know about', () => {
it('outside the crank, leaves the crank release with nothing to commit', async () => {
const kdb = await makeSQLKernelDatabase({ dbFilename: ':memory:' });
const kv = kdb.kernelKVStore;

// RemoteHandle.handleRemoteMessage, parked on its await.
kdb.createSavepoint('receive_r1_7');

// The run loop wakes: startCrank, then the two crank savepoints.
kdb.createSavepoint('t0');
kdb.createSavepoint('t1');
kv.set('crankWrite', 'durable');

// endCrank -> releaseAllSavepoints -> releaseSavepoint('t0'). `t0` is not the
// outermost savepoint, so this releases into the remote's, not to a commit.
kdb.releaseSavepoint('t0');

// The remote message then fails, so RemoteHandle rolls its savepoint back.
kdb.rollbackSavepoint('receive_r1_7');

// If releasing `crank` were a commit point, the crank would have survived.
expect(kv.get('crankWrite')).toBe('durable');
kdb.close();
});

it('inside the crank, is destroyed by the delivery rollback behind its owner', async () => {
const kdb = await makeSQLKernelDatabase({ dbFilename: ':memory:' });
const kv = kdb.kernelKVStore;

// startCrank
kdb.createSavepoint('t0');
kdb.createSavepoint('t1');

// A remote message arrives during `await deliver(queueItem)`.
kdb.createSavepoint('receive_r1_7');
kv.set('remoteSeq.r1.highestReceivedSeq', '7');

// The delivery aborts: rollbackCrank('delivery') issues ROLLBACK TO t1, and
// SQLite cancels every savepoint started after t1 -- including the remote's.
kdb.rollbackSavepoint('t1');

// The remote handler, still inside its own try, reaches its release.
expect(() => kdb.releaseSavepoint('receive_r1_7')).not.toThrow();
kdb.close();
});

it('inside a crank that succeeds, is committed under its owner', async () => {
const kdb = await makeSQLKernelDatabase({ dbFilename: ':memory:' });
const kv = kdb.kernelKVStore;

// startCrank
kdb.createSavepoint('t0');
kdb.createSavepoint('t1');

// A remote message arrives and gets as far as its await, so the seq row it
// writes "at the end, within the transaction" is not written yet.
kdb.createSavepoint('receive_r1_7');
kv.set('remoteHalfDone', 'yes');

// endCrank releases t0, and with it everything stacked above.
kdb.releaseSavepoint('t0');

// The remote handler resumes and tries to commit its own savepoint. Whatever
// it decides, its half-finished work is already durable -- and it will report
// failure, leaving the peer to retry an effect that has landed.
expect(() => kdb.releaseSavepoint('receive_r1_7')).not.toThrow();
expect(kv.get('remoteHalfDone')).toBe('yes');
kdb.close();
});
});
39 changes: 39 additions & 0 deletions packages/kernel-store/src/sqlite/nodejs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,45 @@ describe('makeSQLKernelDatabase', () => {
expect(mockDb._spStack).toStrictEqual([]);
});

// The hazard `rollbackSavepoint` guards against, by the other door.
it('releaseSavepoint discards the transaction when the release fails', async () => {
const db = await makeSQLKernelDatabase({});
mockDb.inTransaction = true;
mockDb._spStack = ['point1'];
mockStatement.run.mockClear();
mockDb.exec.mockImplementationOnce(() => {
throw new Error('disk I/O error');
});

expect(() => db.releaseSavepoint('point1')).toThrowError(
'disk I/O error',
);

expect(mockDb._spStack).toStrictEqual([]);
// The abort is the only prepared statement this path runs.
expect(mockStatement.run).toHaveBeenCalledOnce();
mockDb.inTransaction = false;
});

it('releaseSavepoint reports the release failure even if the abort fails too', async () => {
const db = await makeSQLKernelDatabase({});
mockDb.inTransaction = true;
mockDb._spStack = ['point1'];
mockDb.exec.mockImplementationOnce(() => {
throw new Error('disk I/O error');
});
mockStatement.run.mockImplementationOnce(() => {
throw new Error('cannot rollback');
});

expect(() => db.releaseSavepoint('point1')).toThrowError(
'disk I/O error',
);

expect(mockDb._spStack).toStrictEqual([]);
mockDb.inTransaction = false;
});

it('supports nested savepoints', async () => {
const db = await makeSQLKernelDatabase({});
db.createSavepoint('outer');
Expand Down
112 changes: 112 additions & 0 deletions packages/kernel-store/src/sqlite/nodejs.transaction-survival.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';

import { makeSQLKernelDatabase } from './nodejs.ts';

/**
* Two invariants the crank layer relies on:
*
* - a failed `ROLLBACK TO` discards the whole transaction, so truncating
* `ctx.savepoints` to zero still matches the database;
* - `commitIfNeeded` leaves no transaction behind.
*
* Both drivers catch and log an abort that fails while discarding a transaction,
* so "the whole transaction is discarded" holds only when that abort succeeds.
* This driver has no `_inTx` flag, reading `db.inTransaction` from SQLite
* instead, which prevents a wedged flag but does not end an ownerless
* transaction.
*/

/** Every statement and exec call, in order. */
let issued: string[] = [];
/** SQL that throws when next run. */
let failOnce: Set<string> = new Set();

const makeStatement = (text: string): Record<string, unknown> => ({
run: () => {
issued.push(text);
if (failOnce.delete(text)) {
throw new Error(`SQLITE_IOERR: ${text}`);
}
return undefined;
},
get: () => undefined,
all: () => [],
pluck: () => undefined,
iterate: () => [],
});

const mockDb = {
prepare: vi.fn((text: string) => makeStatement(text)),
transaction: vi.fn((fn: () => void) => fn),
exec: vi.fn((text: string) => {
issued.push(text);
if (failOnce.delete(text)) {
throw new Error(`SQLITE_IOERR: ${text}`);
}
}),
inTransaction: false,
_spStack: [] as string[],
close: vi.fn(),
};

vi.mock('better-sqlite3', () => ({
default: vi.fn(function () {
return mockDb;
}),
}));
vi.mock('node:fs/promises', () => ({ mkdir: vi.fn() }));
vi.mock('node:os', () => ({ tmpdir: vi.fn(() => '/mock-tmpdir') }));

describe('the nodejs driver after a failure it tolerates', () => {
beforeEach(() => {
issued = [];
failOnce = new Set();
mockDb.inTransaction = false;
mockDb._spStack = [];
});

it('discards the transaction when the rollback fails and the abort fails too', async () => {
const kdb = await makeSQLKernelDatabase({});
// A crank in progress: SAVEPOINT t0, SAVEPOINT t1.
mockDb.inTransaction = true;
mockDb._spStack = ['t0', 't1'];
issued = [];

// The disk fills. `ROLLBACK TO SAVEPOINT t1` fails, and so does the
// `ROLLBACK TRANSACTION` meant to discard the transaction instead. The driver
// logs that second failure and carries on, so SQLite is still in a
// transaction with t0 and t1 on its stack.
failOnce.add('ROLLBACK TO SAVEPOINT t1');
failOnce.add('ROLLBACK TRANSACTION');
expect(() => kdb.rollbackSavepoint('t1')).toThrow('SQLITE_IOERR');
expect(mockDb._spStack).toStrictEqual([]);

// `_spStack` now says "no savepoints, nothing to commit or abort" while
// SQLite says otherwise. Teardown still runs after the run loop dies --
// `reset`, a peer incarnation change, a remote message -- and takes a
// savepoint.
issued = [];
kdb.createSavepoint('teardown');
kdb.releaseSavepoint('teardown');

// `beginIfNeeded` saw `inTransaction` and skipped BEGIN, so `teardown` was
// created inside the transaction that was supposed to be gone, and releasing
// it committed that transaction whole -- the abandoned crank included.
expect(issued).not.toContain('COMMIT TRANSACTION');
});

it('discards the transaction when the commit fails', async () => {
const kdb = await makeSQLKernelDatabase({});
mockDb.inTransaction = true;
mockDb._spStack = ['t0'];
issued = [];

// endCrank: RELEASE SAVEPOINT t0 succeeds, the COMMIT it triggers does not.
failOnce.add('COMMIT TRANSACTION');
expect(() => kdb.releaseSavepoint('t0')).toThrow('SQLITE_IOERR');

// A failed COMMIT can leave the transaction open, and `_spStack` was already
// spliced empty. Nothing here ends it.
expect(issued).toContain('ROLLBACK TRANSACTION');
});
});
33 changes: 30 additions & 3 deletions packages/kernel-store/src/sqlite/nodejs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,8 +298,14 @@ export async function makeSQLKernelDatabase({
db._spStack.length = 0;
try {
rollbackIfNeeded();
} catch {
// The rollback failure below is the one worth reporting.
} catch (abortError) {
// The rollback failure below is the one worth reporting, but a failed
// abort leaves SQLite holding a transaction the next crank would
// silently write into. Nothing here can repair that.
logger?.error(
'failed to discard transaction after rollback',
abortError,
);
}
throw error;
}
Expand All @@ -321,7 +327,28 @@ export async function makeSQLKernelDatabase({
throw new Error(`No such savepoint: ${name}`);
}
const query = SQL_QUERIES.RELEASE_SAVEPOINT.replace('%NAME%', name);
db.exec(query);
try {
db.exec(query);
} catch (error) {
// The hazard `rollbackSavepoint` guards against, by the other door: left as
// it was, the savepoint stays on the stack and the transaction open with
// nothing to ever commit or abort it, so every later write on this
// connection joins it, reports success, and vanishes on close. There is no
// committing this transaction now, so discard it.
db._spStack.length = 0;
try {
rollbackIfNeeded();
} catch (abortError) {
// The release failure below is the one worth reporting, but a failed
// abort leaves SQLite holding a transaction the next crank would
// silently write into. Nothing here can repair that.
logger?.error(
'failed to discard transaction after release',
abortError,
);
}
throw error;
}
db._spStack.splice(idx);
if (db._spStack.length === 0) {
commitIfNeeded();
Expand Down
Loading
Loading