diff --git a/packages/extension/src/kernel-integration/handlers/collect-garbage.ts b/packages/extension/src/kernel-integration/handlers/collect-garbage.ts new file mode 100644 index 000000000..458ddb19a --- /dev/null +++ b/packages/extension/src/kernel-integration/handlers/collect-garbage.ts @@ -0,0 +1,30 @@ +import { literal } from '@metamask/superstruct'; +import type { Kernel } from '@ocap/kernel'; +import type { MethodSpec, Handler } from '@ocap/rpc-methods'; +import { EmptyJsonArray } from '@ocap/utils'; + +export const collectGarbageSpec: MethodSpec< + 'collectGarbage', + EmptyJsonArray, + null +> = { + method: 'collectGarbage', + params: EmptyJsonArray, + result: literal(null), +}; + +export type CollectGarbageHooks = { kernel: Pick }; + +export const collectGarbageHandler: Handler< + 'collectGarbage', + EmptyJsonArray, + null, + CollectGarbageHooks +> = { + ...collectGarbageSpec, + hooks: { kernel: true }, + implementation: async ({ kernel }): Promise => { + kernel.collectGarbage(); + return null; + }, +}; diff --git a/packages/extension/src/kernel-integration/handlers/index.ts b/packages/extension/src/kernel-integration/handlers/index.ts index 221e185c4..69256f4d0 100644 --- a/packages/extension/src/kernel-integration/handlers/index.ts +++ b/packages/extension/src/kernel-integration/handlers/index.ts @@ -1,4 +1,8 @@ import { clearStateHandler, clearStateSpec } from './clear-state.ts'; +import { + collectGarbageHandler, + collectGarbageSpec, +} from './collect-garbage.ts'; import { executeDBQueryHandler, executeDBQuerySpec, @@ -33,6 +37,7 @@ export const handlers = { restartVat: restartVatHandler, sendVatCommand: sendVatCommandHandler, terminateAllVats: terminateAllVatsHandler, + collectGarbage: collectGarbageHandler, terminateVat: terminateVatHandler, updateClusterConfig: updateClusterConfigHandler, } as const; @@ -49,6 +54,7 @@ export const methodSpecs = { restartVat: restartVatSpec, sendVatCommand: sendVatCommandSpec, terminateAllVats: terminateAllVatsSpec, + collectGarbage: collectGarbageSpec, terminateVat: terminateVatSpec, updateClusterConfig: updateClusterConfigSpec, } as const; diff --git a/packages/extension/src/ui/App.module.css b/packages/extension/src/ui/App.module.css index 386eb23fd..a9e5e251b 100644 --- a/packages/extension/src/ui/App.module.css +++ b/packages/extension/src/ui/App.module.css @@ -151,10 +151,18 @@ h6 { border: none; } +.buttonGray { + composes: button; + background-color: var(--color-gray-300); + color: var(--color-black); + border: none; +} + .button:hover:not(:disabled) { background-color: var(--color-gray-800); color: var(--color-white); } + .textButton { padding: 0; border: 0; diff --git a/packages/extension/src/ui/components/KernelControls.tsx b/packages/extension/src/ui/components/KernelControls.tsx index bd9fb197d..e574b5a6a 100644 --- a/packages/extension/src/ui/components/KernelControls.tsx +++ b/packages/extension/src/ui/components/KernelControls.tsx @@ -6,7 +6,8 @@ import { useVats } from '../hooks/useVats.ts'; * @returns A panel for controlling the kernel. */ export const KernelControls: React.FC = () => { - const { terminateAllVats, clearState, reload } = useKernelActions(); + const { terminateAllVats, collectGarbage, clearState, reload } = + useKernelActions(); const { vats } = useVats(); return ( @@ -16,6 +17,9 @@ export const KernelControls: React.FC = () => { Terminate All Vats )} + diff --git a/packages/extension/src/ui/hooks/useKernelActions.ts b/packages/extension/src/ui/hooks/useKernelActions.ts index f4e3a8c22..652e6bf9e 100644 --- a/packages/extension/src/ui/hooks/useKernelActions.ts +++ b/packages/extension/src/ui/hooks/useKernelActions.ts @@ -11,6 +11,7 @@ import { usePanelContext } from '../context/PanelContext.tsx'; export function useKernelActions(): { sendKernelCommand: () => void; terminateAllVats: () => void; + collectGarbage: () => void; clearState: () => void; reload: () => void; launchVat: (bundleUrl: string, vatName: string) => void; @@ -42,6 +43,18 @@ export function useKernelActions(): { .catch(() => logMessage('Failed to terminate all vats', 'error')); }, [callKernelMethod, logMessage]); + /** + * Collects garbage. + */ + const collectGarbage = useCallback(() => { + callKernelMethod({ + method: 'collectGarbage', + params: [], + }) + .then(() => logMessage('Garbage collected', 'success')) + .catch(() => logMessage('Failed to collect garbage', 'error')); + }, [callKernelMethod, logMessage]); + /** * Clears the kernel state. */ @@ -104,6 +117,7 @@ export function useKernelActions(): { return { sendKernelCommand, terminateAllVats, + collectGarbage, clearState, reload, launchVat, diff --git a/packages/extension/test/e2e/vat-manager.test.ts b/packages/extension/test/e2e/vat-manager.test.ts index fda2e2373..aac6e5685 100644 --- a/packages/extension/test/e2e/vat-manager.test.ts +++ b/packages/extension/test/e2e/vat-manager.test.ts @@ -263,4 +263,85 @@ test.describe('Vat Manager', () => { minimalClusterConfig.vats.main.parameters.name, ); }); + + test('should collect garbage', async () => { + await popupPage.click('button:text("Database Inspector")'); + await expect(messageOutput).toContainText( + '{"key":"vats.terminated","value":"[]"}', + ); + const v3Values = [ + '{"key":"e.nextPromiseId.v3","value":"2"}', + '{"key":"e.nextObjectId.v3","value":"1"}', + '{"key":"ko3.owner","value":"v3"}', + '{"key":"v3.c.ko3","value":"R o+0"}', + '{"key":"v3.c.o+0","value":"ko3"}', + '{"key":"v3.c.kp3","value":"R p-1"}', + '{"key":"v3.c.p-1","value":"kp3"}', + ]; + const v1ko3Values = [ + '{"key":"v1.c.ko3","value":"R o-2"}', + '{"key":"v1.c.o-2","value":"ko3"}', + '{"key":"ko3.refCount","value":"2,2"}', + '{"key":"kp3.state","value":"fulfilled"}', + '{"key":"kp3.value","value"', + ]; + await expect(messageOutput).toContainText( + '{"key":"kp3.refCount","value":"2"}', + ); + await expect(messageOutput).toContainText('{"key":"vatConfig.v3","value"'); + for (const value of v3Values) { + await expect(messageOutput).toContainText(value); + } + for (const value of v1ko3Values) { + await expect(messageOutput).toContainText(value); + } + await popupPage.click('button:text("Vat Manager")'); + await popupPage.locator('td button:text("Terminate")').last().click(); + await expect(messageOutput).toContainText('Terminated vat "v3"'); + await popupPage.locator('[data-testid="clear-logs-button"]').click(); + await expect(messageOutput).toContainText(''); + await popupPage.click('button:text("Database Inspector")'); + await expect(messageOutput).toContainText( + '{"key":"vats.terminated","value":"[\\"v3\\"]"}', + ); + await expect(messageOutput).not.toContainText( + '{"key":"vatConfig.v3","value"', + ); + for (const value of v3Values) { + await expect(messageOutput).toContainText(value); + } + await popupPage.click('button:text("Vat Manager")'); + await popupPage.click('button:text("Collect Garbage")'); + await expect(messageOutput).toContainText('Garbage collected'); + await popupPage.locator('[data-testid="clear-logs-button"]').click(); + await expect(messageOutput).toContainText(''); + await popupPage.click('button:text("Database Inspector")'); + // v3 is gone + for (const value of v3Values) { + await expect(messageOutput).not.toContainText(value); + } + // ko3 reference still exists for v1 + for (const value of v1ko3Values) { + await expect(messageOutput).toContainText(value); + } + // kp3 reference dropped to 1 + await expect(messageOutput).toContainText( + '{"key":"kp3.refCount","value":"1"}', + ); + await popupPage.click('button:text("Vat Manager")'); + // delete v1 + await popupPage.locator('td button:text("Terminate")').first().click(); + await expect(messageOutput).toContainText('Terminated vat "v1"'); + await popupPage.click('button:text("Collect Garbage")'); + await expect(messageOutput).toContainText('Garbage collected'); + await popupPage.locator('[data-testid="clear-logs-button"]').click(); + await expect(messageOutput).toContainText(''); + await popupPage.click('button:text("Database Inspector")'); + await expect(messageOutput).toContainText( + '{"key":"vats.terminated","value":"[]"}', + ); + for (const value of v1ko3Values) { + await expect(messageOutput).not.toContainText(value); + } + }); }); diff --git a/packages/kernel-test/src/garbage-collection.test.ts b/packages/kernel-test/src/garbage-collection.test.ts index 923987ff3..3d25128fd 100644 --- a/packages/kernel-test/src/garbage-collection.test.ts +++ b/packages/kernel-test/src/garbage-collection.test.ts @@ -78,8 +78,8 @@ describe('Garbage Collection', () => { const createObjectRef = createObjectData.slots[0] as KRef; // Verify initial reference counts from database const initialRefCounts = kernelStore.getObjectRefCount(createObjectRef); - expect(initialRefCounts.reachable).toBe(1); - expect(initialRefCounts.recognizable).toBe(1); + expect(initialRefCounts.reachable).toBe(3); + expect(initialRefCounts.recognizable).toBe(3); // Send the object to the importer vat const objectRef = kunser(createObjectData); await kernel.queueMessageFromKernel(importerKRef, 'storeImport', [ @@ -95,7 +95,7 @@ describe('Garbage Collection', () => { // Check that the object is reachable as a promise from the importer vat const importerKref = kernelStore.erefToKref(importerVatId, 'p-1') as KRef; expect(kernelStore.hasCListEntry(importerVatId, importerKref)).toBe(true); - expect(kernelStore.getRefCount(importerKref)).toBe(1); + expect(kernelStore.getRefCount(importerKref)).toBe(2); // Use the object const useResult = await kernel.queueMessageFromKernel( importerKRef, @@ -118,8 +118,8 @@ describe('Garbage Collection', () => { // Store initial reference count information const initialRefCounts = kernelStore.getObjectRefCount(createObjectRef); - expect(initialRefCounts.reachable).toBe(1); - expect(initialRefCounts.recognizable).toBe(1); + expect(initialRefCounts.reachable).toBe(3); + expect(initialRefCounts.recognizable).toBe(3); // Store the reference in the importer vat const objectRef = kunser(createObjectData); @@ -160,8 +160,8 @@ describe('Garbage Collection', () => { // Check reference counts after dropImports const afterWeakRefCounts = kernelStore.getObjectRefCount(createObjectRef); - expect(afterWeakRefCounts.reachable).toBe(0); - expect(afterWeakRefCounts.recognizable).toBe(1); + expect(afterWeakRefCounts.reachable).toBe(2); + expect(afterWeakRefCounts.recognizable).toBe(3); // Now completely forget the import in the importer vat // This should trigger retireImports when GC runs @@ -171,16 +171,15 @@ describe('Garbage Collection', () => { // Schedule another reap kernel.reapVats((vatId) => vatId === importerVatId); - // Run 3 cranks to allow bringOutYourDead to be processed for (let i = 0; i < 3; i++) { await kernel.queueMessageFromKernel(importerKRef, 'noop', []); await waitUntilQuiescent(500); } - // Check reference counts after retireImports (both should be decreased) + // Check reference counts after retireImports const afterForgetRefCounts = kernelStore.getObjectRefCount(createObjectRef); - expect(afterForgetRefCounts.reachable).toBe(0); - expect(afterForgetRefCounts.recognizable).toBe(0); + expect(afterForgetRefCounts.reachable).toBe(2); + expect(afterForgetRefCounts.recognizable).toBe(2); // Now forget the object in the exporter vat // This should trigger retireExports when GC runs diff --git a/packages/kernel-test/src/utils.ts b/packages/kernel-test/src/utils.ts index 97d85f809..72538b75d 100644 --- a/packages/kernel-test/src/utils.ts +++ b/packages/kernel-test/src/utils.ts @@ -150,3 +150,13 @@ export function parseReplyBody(body: string): unknown { return body; } } + +/** + * Debug the database. + * + * @param kernelDatabase - The database to debug. + */ +export function logDatabase(kernelDatabase: KernelDatabase): void { + const result = kernelDatabase.executeQuery('SELECT * FROM kv'); + console.log(result); +} diff --git a/packages/kernel/src/Kernel.ts b/packages/kernel/src/Kernel.ts index 4bbae90e0..6048296a8 100644 --- a/packages/kernel/src/Kernel.ts +++ b/packages/kernel/src/Kernel.ts @@ -181,6 +181,7 @@ export class Kernel { */ async #run(): Promise { for await (const item of this.#runQueueItems()) { + this.#kernelStore.nextTerminatedVatCleanup(); await this.#deliver(item); this.#kernelStore.collectGarbage(); } @@ -286,7 +287,11 @@ export class Kernel { } else { kref = this.#kernelStore.initKernelObject(vatId); } - this.#kernelStore.addClistEntry(vatId, kref, vref); + this.#kernelStore.addCListEntry(vatId, kref, vref); + this.#kernelStore.incrementRefCount(kref, 'export', { + isExport: true, + onlyRecognizable: true, + }); return kref; } @@ -328,6 +333,7 @@ export class Kernel { await this.#runVat(vatId, vatConfig); this.#kernelStore.initEndpoint(vatId); const rootRef = this.exportFromVat(vatId, ROOT_OBJECT_VREF); + this.#kernelStore.incrementRefCount(rootRef, 'root'); this.#kernelStore.setVatConfig(vatId, vatConfig); return rootRef; } @@ -534,10 +540,25 @@ export class Kernel { throw TypeError('message result must be a string'); } this.#kernelStore.setPromiseDecider(message.result, vatId); + this.#kernelStore.decrementRefCount( + message.result, + 'deliver|send|result', + ); } const vatTarget = this.#translateRefKtoV(vatId, target, false); const vatMessage = this.#translateMessageKtoV(vatId, message); await vat.deliverMessage(vatTarget, vatMessage); + // decrement refcount for processed 'send' items except for the root object + const vatKref = this.#kernelStore.erefToKref(vatId, 'o+0'); + if (vatKref !== target) { + this.#kernelStore.decrementRefCount( + target, + 'deliver|send|target', + ); + } + for (const slot of message.methargs.slots) { + this.#kernelStore.decrementRefCount(slot, 'deliver|send|slot'); + } } else { Fail`no owner for kernel object ${target}`; } @@ -545,6 +566,21 @@ export class Kernel { this.#kernelStore.enqueuePromiseMessage(target, message); } log(`@@@@ done ${vatId} send ${target}<-${JSON.stringify(message)}`); + } else { + // Message went splat + this.#kernelStore.decrementRefCount( + item.target, + 'deliver|splat|target', + ); + if (item.message.result) { + this.#kernelStore.decrementRefCount( + item.message.result, + 'deliver|splat|result', + ); + } + for (const slot of item.message.methargs.slots) { + this.#kernelStore.decrementRefCount(slot, 'deliver|splat|slot'); + } } break; } @@ -586,9 +622,18 @@ export class Kernel { false, this.#translateCapDataKtoV(vatId, tPromise.value), ]); + // decrement refcount for the promise being notified + if (toResolve !== kpid) { + this.#kernelStore.decrementRefCount( + toResolve, + 'deliver|notify|slot', + ); + } } const vat = this.#getVat(vatId); await vat.deliverNotify(resolutions); + // Decrement reference count for processed 'notify' item + this.#kernelStore.decrementRefCount(kpid, 'deliver|notify'); log(`@@@@ done ${vatId} notify ${vatId} ${kpid}`); break; } @@ -686,6 +731,8 @@ export class Kernel { notify(vatId: VatId, kpid: KRef): void { const notifyItem: RunQueueItemNotify = { type: 'notify', vatId, kpid }; this.enqueueRun(notifyItem); + // Increment reference count for the promise being notified about + this.#kernelStore.incrementRefCount(kpid, 'notify'); } /** @@ -701,6 +748,12 @@ export class Kernel { for (const resolution of resolutions) { const [kpid, rejected, dataRaw] = resolution; const data = dataRaw as CapData; + + this.#kernelStore.incrementRefCount(kpid, 'resolve|kpid'); + for (const slot of data.slots || []) { + this.#kernelStore.incrementRefCount(slot, 'resolve|slot'); + } + const promise = this.#kernelStore.getKernelPromise(kpid); const { state, decider, subscribers } = promise; if (state !== 'unresolved') { @@ -744,6 +797,13 @@ export class Kernel { methargs: kser([method, args]), result, }; + + this.#kernelStore.incrementRefCount(target, 'queue|target'); + this.#kernelStore.incrementRefCount(result, 'queue|result'); + for (const slot of message.methargs.slots || []) { + this.#kernelStore.incrementRefCount(slot, 'queue|slot'); + } + const queueItem: RunQueueItemSend = { type: 'send', target, @@ -832,6 +892,8 @@ export class Kernel { async terminateVat(vatId: VatId): Promise { await this.#stopVat(vatId, true); this.#kernelStore.deleteVatConfig(vatId); + // Mark for deletion (which will happen later, in vat-cleanup events) + this.#kernelStore.markVatAsTerminated(vatId); } /** @@ -853,9 +915,8 @@ export class Kernel { if (!this.#mostRecentSubcluster) { throw Error('no subcluster to reload'); } - await this.terminateAllVats(); - + this.collectGarbage(); await this.launchSubcluster(this.#mostRecentSubcluster); } @@ -942,5 +1003,16 @@ export class Kernel { } } } + + /** + * Collect garbage. + * This is for debugging purposes only. + */ + collectGarbage(): void { + while (this.#kernelStore.nextTerminatedVatCleanup()) { + // wait for all vats to be cleaned up + } + this.#kernelStore.collectGarbage(); + } } // harden(Kernel); // XXX restore this once vitest is able to cope diff --git a/packages/kernel/src/services/garbage-collection.test.ts b/packages/kernel/src/services/garbage-collection.test.ts index b739efe6f..8e0150789 100644 --- a/packages/kernel/src/services/garbage-collection.test.ts +++ b/packages/kernel/src/services/garbage-collection.test.ts @@ -16,7 +16,7 @@ describe('garbage-collection', () => { it('processes dropExport actions', () => { // Setup: Create object and add GC action const ko1 = kernelStore.initKernelObject('v1'); - kernelStore.addClistEntry('v1', ko1, 'o+1'); // Export reference + kernelStore.addCListEntry('v1', ko1, 'o+1'); // Export reference // Set reachable count to 0 but keep recognizable count kernelStore.setObjectRefCount(ko1, { reachable: 0, recognizable: 1 }); @@ -47,7 +47,7 @@ describe('garbage-collection', () => { it('processes retireExport actions', () => { // Setup: Create object with zero refcounts const ko1 = kernelStore.initKernelObject('v1'); - kernelStore.addClistEntry('v1', ko1, 'o+1'); + kernelStore.addCListEntry('v1', ko1, 'o+1'); kernelStore.setObjectRefCount(ko1, { reachable: 0, recognizable: 0 }); kernelStore.addGCActions([`v1 retireExport ${ko1}`]); @@ -68,7 +68,7 @@ describe('garbage-collection', () => { it('processes retireImport actions', () => { // Setup: Create object and add GC action const ko1 = kernelStore.initKernelObject('v1'); - kernelStore.addClistEntry('v2', ko1, 'o-1'); // Import reference + kernelStore.addCListEntry('v2', ko1, 'o-1'); // Import reference kernelStore.addGCActions([`v2 retireImport ${ko1}`]); // Process GC actions @@ -90,8 +90,8 @@ describe('garbage-collection', () => { const ko1 = kernelStore.initKernelObject('v1'); const ko2 = kernelStore.initKernelObject('v1'); - kernelStore.addClistEntry('v1', ko1, 'o+1'); - kernelStore.addClistEntry('v1', ko2, 'o+2'); + kernelStore.addCListEntry('v1', ko1, 'o+1'); + kernelStore.addCListEntry('v1', ko2, 'o+2'); // Set up conditions for dropExport and retireExport kernelStore.setObjectRefCount(ko1, { reachable: 0, recognizable: 1 }); @@ -125,8 +125,8 @@ describe('garbage-collection', () => { const ko1 = kernelStore.initKernelObject('v2'); const ko2 = kernelStore.initKernelObject('v1'); - kernelStore.addClistEntry('v2', ko1, 'o+1'); - kernelStore.addClistEntry('v1', ko2, 'o+1'); + kernelStore.addCListEntry('v2', ko1, 'o+1'); + kernelStore.addCListEntry('v1', ko2, 'o+1'); // Set up conditions for dropExport kernelStore.setObjectRefCount(ko1, { reachable: 0, recognizable: 1 }); @@ -157,7 +157,7 @@ describe('garbage-collection', () => { it('skips actions that should not be processed', () => { const ko1 = kernelStore.initKernelObject('v1'); - kernelStore.addClistEntry('v1', ko1, 'o+1'); + kernelStore.addCListEntry('v1', ko1, 'o+1'); // Add dropExport action but set reachable to false (should skip) kernelStore.clearReachableFlag('v1', ko1); @@ -178,7 +178,7 @@ describe('garbage-collection', () => { it('skips dropExport when object does not exist', () => { const ko1 = kernelStore.initKernelObject('v1'); - kernelStore.addClistEntry('v1', ko1, 'o+1'); + kernelStore.addCListEntry('v1', ko1, 'o+1'); // Delete the object to simulate non-existence kernelStore.deleteKernelObject(ko1); @@ -192,7 +192,7 @@ describe('garbage-collection', () => { it('skips retireExport when object has non-zero refcounts', () => { const ko1 = kernelStore.initKernelObject('v1'); - kernelStore.addClistEntry('v1', ko1, 'o+1'); + kernelStore.addCListEntry('v1', ko1, 'o+1'); // Set non-zero refcounts kernelStore.setObjectRefCount(ko1, { reachable: 1, recognizable: 1 }); @@ -206,7 +206,7 @@ describe('garbage-collection', () => { it('skips retireExport when object does not exist', () => { const ko1 = kernelStore.initKernelObject('v1'); - kernelStore.addClistEntry('v1', ko1, 'o+1'); + kernelStore.addCListEntry('v1', ko1, 'o+1'); // Delete the object kernelStore.deleteKernelObject(ko1); @@ -241,7 +241,7 @@ describe('garbage-collection', () => { it('skips retireExport when object is recognizable', () => { const ko1 = kernelStore.initKernelObject('v1'); - kernelStore.addClistEntry('v1', ko1, 'o+1'); + kernelStore.addCListEntry('v1', ko1, 'o+1'); // Set only recognizable count to non-zero kernelStore.setObjectRefCount(ko1, { reachable: 0, recognizable: 1 }); diff --git a/packages/kernel/src/store/index.test.ts b/packages/kernel/src/store/index.test.ts index bde06b622..eb4e8a5a9 100644 --- a/packages/kernel/src/store/index.test.ts +++ b/packages/kernel/src/store/index.test.ts @@ -53,16 +53,17 @@ describe('kernel store', () => { it('has all the expected parts', () => { const ks = makeKernelStore(mockKernelDatabase); expect(Object.keys(ks).sort()).toStrictEqual([ - 'addClistEntry', + 'addCListEntry', 'addGCActions', 'addPromiseSubscriber', 'allocateErefForKref', + 'cleanupTerminatedVat', 'clear', 'clearReachableFlag', 'collectGarbage', 'decRefCount', 'decrementRefCount', - 'deleteClistEntry', + 'deleteCListEntry', 'deleteEndpoint', 'deleteKernelObject', 'deleteKernelPromise', @@ -74,6 +75,7 @@ describe('kernel store', () => { 'erefToKref', 'forgetEref', 'forgetKref', + 'forgetTerminatedVat', 'getAllVatRecords', 'getGCActions', 'getImporters', @@ -90,6 +92,7 @@ describe('kernel store', () => { 'getReachableAndVatSlot', 'getReachableFlag', 'getRefCount', + 'getTerminatedVats', 'getVatConfig', 'getVatIDs', 'hasCListEntry', @@ -99,12 +102,15 @@ describe('kernel store', () => { 'initEndpoint', 'initKernelObject', 'initKernelPromise', + 'isVatTerminated', 'kernelRefExists', 'krefToEref', 'krefsToExistingErefs', 'kv', 'makeVatStore', + 'markVatAsTerminated', 'nextReapAction', + 'nextTerminatedVatCleanup', 'refCountKey', 'reset', 'resolveKernelPromise', @@ -141,23 +147,23 @@ describe('kernel store', () => { expect(refCounts.recognizable).toBe(1); // Increment the reference count - ks.incrementRefCount('ko1', {}); + ks.incrementRefCount('ko1', 'test'); expect(ks.getObjectRefCount('ko1').reachable).toBe(2); expect(ks.getObjectRefCount('ko1').recognizable).toBe(2); // Increment again - ks.incrementRefCount('ko1', {}); + ks.incrementRefCount('ko1', 'test'); expect(ks.getObjectRefCount('ko1').reachable).toBe(3); expect(ks.getObjectRefCount('ko1').recognizable).toBe(3); // Decrement - ks.decrementRefCount('ko1', {}); + ks.decrementRefCount('ko1', 'tess'); expect(ks.getObjectRefCount('ko1').reachable).toBe(2); expect(ks.getObjectRefCount('ko1').recognizable).toBe(2); // Decrement twice more to reach 0 - ks.decrementRefCount('ko1', {}); - ks.decrementRefCount('ko1', {}); + ks.decrementRefCount('ko1', 'test'); + ks.decrementRefCount('ko1', 'test'); expect(ks.getObjectRefCount('ko1').reachable).toBe(0); expect(ks.getObjectRefCount('ko1').recognizable).toBe(0); @@ -234,11 +240,11 @@ describe('kernel store', () => { const [kp61] = ks.initKernelPromise(); // Add C-list entries - ks.addClistEntry('v2', ko42, 'o-63'); - ks.addClistEntry('v2', ko51, 'o-74'); - ks.addClistEntry('v2', kp60, 'p+85'); - ks.addClistEntry('r7', ko42, 'ro+11'); - ks.addClistEntry('r7', kp61, 'rp-99'); + ks.addCListEntry('v2', ko42, 'o-63'); + ks.addCListEntry('v2', ko51, 'o-74'); + ks.addCListEntry('v2', kp60, 'p+85'); + ks.addCListEntry('r7', ko42, 'ro+11'); + ks.addCListEntry('r7', kp61, 'rp-99'); // Verify mappings expect(ks.krefToEref('v2', ko42)).toBe('o-63'); @@ -275,7 +281,7 @@ describe('kernel store', () => { ks.getNextRemoteId(); const koId = ks.initKernelObject('v1'); const [kpId] = ks.initKernelPromise(); - ks.addClistEntry('v1', koId, 'o-1'); + ks.addCListEntry('v1', koId, 'o-1'); ks.enqueueRun(tm('test message')); ks.reset(); expect(ks.getNextVatId()).toBe('v1'); diff --git a/packages/kernel/src/store/index.ts b/packages/kernel/src/store/index.ts index e4ead1652..6a094d248 100644 --- a/packages/kernel/src/store/index.ts +++ b/packages/kernel/src/store/index.ts @@ -121,8 +121,7 @@ export function makeKernelStore(kdb: KernelDatabase) { // Garbage collection gcActions: provideCachedStoredValue('gcActions', '[]'), reapQueue: provideCachedStoredValue('reapQueue', '[]'), - // TODO: Store terminated vats in DB and fetch from there - terminatedVats: [], + terminatedVats: provideCachedStoredValue('vats.terminated', '[]'), }; const id = getIdMethods(context); @@ -152,7 +151,6 @@ export function makeKernelStore(kdb: KernelDatabase) { * @param vatId - The vat whose state is to be deleted. */ function deleteVat(vatId: VatId): void { - vat.deleteEndpoint(vatId); vat.deleteVatConfig(vatId); kdb.deleteVatStore(vatId); } @@ -163,10 +161,10 @@ export function makeKernelStore(kdb: KernelDatabase) { function reset(): void { kdb.clear(); context.maybeFreeKrefs.clear(); - context.terminatedVats = []; context.runQueue = provideStoredQueue('run', true); context.gcActions = provideCachedStoredValue('gcActions', '[]'); context.reapQueue = provideCachedStoredValue('reapQueue', '[]'); + context.terminatedVats = provideCachedStoredValue('vats.terminated', '[]'); context.nextObjectId = provideCachedStoredValue('nextObjectId', '1'); context.nextPromiseId = provideCachedStoredValue('nextPromiseId', '1'); context.nextVatId = provideCachedStoredValue('nextVatId', '1'); diff --git a/packages/kernel/src/store/methods/clist.test.ts b/packages/kernel/src/store/methods/clist.test.ts index a6e49a6f1..ade04f102 100644 --- a/packages/kernel/src/store/methods/clist.test.ts +++ b/packages/kernel/src/store/methods/clist.test.ts @@ -44,13 +44,13 @@ describe('clist-methods', () => { } as StoreContext); }); - describe('addClistEntry', () => { + describe('addCListEntry', () => { it('adds a bidirectional mapping between KRef and ERef', () => { const endpointId: EndpointId = 'v1'; const kref: KRef = 'ko1'; const eref: ERef = 'o-1'; - clistMethods.addClistEntry(endpointId, kref, eref); + clistMethods.addCListEntry(endpointId, kref, eref); // Check that both mappings are stored expect(kv.get(`${endpointId}.c.${kref}`)).toBe(`R ${eref}`); @@ -62,7 +62,7 @@ describe('clist-methods', () => { const kref: KRef = 'kp1'; const eref: ERef = 'p+2'; - clistMethods.addClistEntry(endpointId, kref, eref); + clistMethods.addCListEntry(endpointId, kref, eref); expect(kv.get(`${endpointId}.c.${kref}`)).toBe(`R ${eref}`); expect(kv.get(`${endpointId}.c.${eref}`)).toBe(kref); @@ -73,7 +73,7 @@ describe('clist-methods', () => { const kref: KRef = 'ko2'; const eref: ERef = 'ro+3'; - clistMethods.addClistEntry(endpointId, kref, eref); + clistMethods.addCListEntry(endpointId, kref, eref); expect(kv.get(`${endpointId}.c.${kref}`)).toBe(`R ${eref}`); expect(kv.get(`${endpointId}.c.${eref}`)).toBe(kref); @@ -86,7 +86,7 @@ describe('clist-methods', () => { const kref: KRef = 'ko1'; const eref: ERef = 'o-1'; - clistMethods.addClistEntry(endpointId, kref, eref); + clistMethods.addCListEntry(endpointId, kref, eref); expect(clistMethods.hasCListEntry(endpointId, kref)).toBe(true); expect(clistMethods.hasCListEntry(endpointId, eref)).toBe(true); @@ -155,7 +155,7 @@ describe('clist-methods', () => { const kref: KRef = 'ko1'; const eref: ERef = 'o-1'; - clistMethods.addClistEntry(endpointId, kref, eref); + clistMethods.addCListEntry(endpointId, kref, eref); expect(clistMethods.erefToKref(endpointId, eref)).toBe(kref); expect(clistMethods.krefToEref(endpointId, kref)).toBe(eref); @@ -177,8 +177,8 @@ describe('clist-methods', () => { const eref1: ERef = 'o-1'; const eref2: ERef = 'o-2'; - clistMethods.addClistEntry(endpointId, kref1, eref1); - clistMethods.addClistEntry(endpointId, kref2, eref2); + clistMethods.addCListEntry(endpointId, kref1, eref1); + clistMethods.addCListEntry(endpointId, kref2, eref2); expect( clistMethods.krefsToExistingErefs(endpointId, [kref1, kref2]), @@ -210,7 +210,7 @@ describe('clist-methods', () => { // Set up initial refCount kv.set(`${kref}.refCount`, '1'); - clistMethods.incrementRefCount(kref, {}); + clistMethods.incrementRefCount(kref, 'test'); // Check that the refCount was incremented expect(kv.get(`${kref}.refCount`)).toBe('2'); @@ -219,7 +219,7 @@ describe('clist-methods', () => { it('does not increment object counts for exports', () => { const kref: KRef = 'ko1'; - clistMethods.incrementRefCount(kref, { isExport: true }); + clistMethods.incrementRefCount(kref, 'test', { isExport: true }); // Should not call getObjectRefCount or setObjectRefCount expect(mockGetObjectRefCount).not.toHaveBeenCalled(); @@ -227,9 +227,9 @@ describe('clist-methods', () => { }); it('throws for empty kref', () => { - expect(() => clistMethods.incrementRefCount('' as KRef, {})).toThrow( - 'incrementRefCount called with empty kref', - ); + expect(() => + clistMethods.incrementRefCount('' as KRef, 'test', {}), + ).toThrow('incrementRefCount called with empty kref'); }); }); @@ -240,7 +240,7 @@ describe('clist-methods', () => { // Set up initial refCount kv.set(`${kref}.refCount`, '2'); - const result = clistMethods.decrementRefCount(kref, {}); + const result = clistMethods.decrementRefCount(kref, 'test'); // Check that the refCount was decremented expect(kv.get(`${kref}.refCount`)).toBe('1'); @@ -254,7 +254,7 @@ describe('clist-methods', () => { // Set up initial refCount kv.set(`${kref}.refCount`, '1'); - const result = clistMethods.decrementRefCount(kref, {}); + const result = clistMethods.decrementRefCount(kref, 'test'); // Check that the refCount was decremented to zero expect(kv.get(`${kref}.refCount`)).toBe('0'); @@ -265,7 +265,9 @@ describe('clist-methods', () => { it('does not decrement object counts for exports', () => { const kref: KRef = 'ko1'; - const result = clistMethods.decrementRefCount(kref, { isExport: true }); + const result = clistMethods.decrementRefCount(kref, 'test', { + isExport: true, + }); // Should not call getObjectRefCount or setObjectRefCount expect(mockGetObjectRefCount).not.toHaveBeenCalled(); @@ -274,9 +276,9 @@ describe('clist-methods', () => { }); it('throws for empty kref', () => { - expect(() => clistMethods.decrementRefCount('' as KRef, {})).toThrow( - 'decrementRefCount called with empty kref', - ); + expect(() => + clistMethods.decrementRefCount('' as KRef, 'test', {}), + ).toThrow('decrementRefCount called with empty kref'); }); it('throws for underflow on promise refCount', () => { @@ -285,7 +287,7 @@ describe('clist-methods', () => { // Set up initial refCount at 0 kv.set(`${kref}.refCount`, '0'); - expect(() => clistMethods.decrementRefCount(kref, {})).toThrow( + expect(() => clistMethods.decrementRefCount(kref, 'test')).toThrow( /refCount underflow/u, ); }); diff --git a/packages/kernel/src/store/methods/clist.ts b/packages/kernel/src/store/methods/clist.ts index cf23ad12b..dc3e52098 100644 --- a/packages/kernel/src/store/methods/clist.ts +++ b/packages/kernel/src/store/methods/clist.ts @@ -35,7 +35,7 @@ export function getCListMethods(ctx: StoreContext) { * @param kref - The KRef. * @param eref - The ERef. */ - function addClistEntry(endpointId: EndpointId, kref: KRef, eref: ERef): void { + function addCListEntry(endpointId: EndpointId, kref: KRef, eref: ERef): void { ctx.kv.set( getSlotKey(endpointId, kref), buildReachableAndVatSlot(true, eref), @@ -61,7 +61,7 @@ export function getCListMethods(ctx: StoreContext) { * @param kref - The KRef. * @param eref - The ERef. */ - function deleteClistEntry( + function deleteCListEntry( endpointId: EndpointId, kref: KRef, eref: ERef, @@ -71,7 +71,7 @@ export function getCListMethods(ctx: StoreContext) { assert(ctx.kv.get(kernelKey)); clearReachableFlag(endpointId, kref); const { direction } = parseRef(eref); - decrementRefCount(kref, { + decrementRefCount(kref, 'delete|kref', { isExport: direction === 'export', onlyRecognizable: true, }); @@ -102,7 +102,7 @@ export function getCListMethods(ctx: StoreContext) { refType = 'o'; } const eref = `${refTag}${refType}-${id}`; - addClistEntry(endpointId, kref, eref); + addCListEntry(endpointId, kref, eref); return eref; } @@ -158,7 +158,7 @@ export function getCListMethods(ctx: StoreContext) { function forgetEref(endpointId: EndpointId, eref: ERef): void { const kref = erefToKref(endpointId, eref); if (kref) { - deleteClistEntry(endpointId, kref, eref); + deleteCListEntry(endpointId, kref, eref); } } @@ -171,7 +171,7 @@ export function getCListMethods(ctx: StoreContext) { function forgetKref(endpointId: EndpointId, kref: KRef): void { const eref = krefToEref(endpointId, kref); if (eref) { - deleteClistEntry(endpointId, kref, eref); + deleteCListEntry(endpointId, kref, eref); } } @@ -183,22 +183,25 @@ export function getCListMethods(ctx: StoreContext) { * and "recognizable" counts. * * @param kref - The kernel slot whose refcount is to be incremented. + * @param tag - The tag of the kernel slot. * @param options - Options for the increment. * @param options.isExport - True if the reference comes from a clist export, which counts for promises but not objects. * @param options.onlyRecognizable - True if the reference provides only recognition, not reachability. */ function incrementRefCount( kref: KRef, + tag: string, { isExport = false, onlyRecognizable = false, - }: { isExport?: boolean; onlyRecognizable?: boolean }, + }: { isExport?: boolean; onlyRecognizable?: boolean } = {}, ): void { kref || Fail`incrementRefCount called with empty kref`; const { isPromise } = parseRef(kref); if (isPromise) { const refCount = Number(ctx.kv.get(refCountKey(kref))) + 1; + console.debug('++', refCountKey(kref), refCount, tag); ctx.kv.set(refCountKey(kref), `${refCount}`); return; } @@ -213,6 +216,7 @@ export function getCListMethods(ctx: StoreContext) { counts.reachable += 1; } counts.recognizable += 1; + console.debug('++', refCountKey(kref), JSON.stringify(counts), tag); setObjectRefCount(kref, counts); } @@ -220,6 +224,7 @@ export function getCListMethods(ctx: StoreContext) { * Decrement the reference count associated with some kernel object. * * @param kref - The kernel slot whose refcount is to be decremented. + * @param tag - The tag of the kernel slot. * @param options - Options for the decrement. * @param options.isExport - True if the reference comes from a clist export, which counts for promises but not objects. * @param options.onlyRecognizable - True if the reference provides only recognition, not reachability. @@ -228,6 +233,7 @@ export function getCListMethods(ctx: StoreContext) { */ function decrementRefCount( kref: KRef, + tag: string, { isExport = false, onlyRecognizable = false, @@ -238,6 +244,7 @@ export function getCListMethods(ctx: StoreContext) { const { isPromise } = parseRef(kref); if (isPromise) { let refCount = Number(ctx.kv.get(refCountKey(kref))); + console.debug('--', refCountKey(kref), refCount - 1, tag); refCount > 0 || Fail`refCount underflow ${kref}`; refCount -= 1; ctx.kv.set(refCountKey(kref), `${refCount}`); @@ -260,6 +267,7 @@ export function getCListMethods(ctx: StoreContext) { if (!counts.reachable || !counts.recognizable) { ctx.maybeFreeKrefs.add(kref); } + console.debug('--', refCountKey(kref), JSON.stringify(counts), tag); setObjectRefCount(kref, counts); ctx.kv.set('initialized', 'true'); return false; @@ -267,9 +275,9 @@ export function getCListMethods(ctx: StoreContext) { return { // C-List entries - addClistEntry, + addCListEntry, hasCListEntry, - deleteClistEntry, + deleteCListEntry, // Eref allocation allocateErefForKref, erefToKref, diff --git a/packages/kernel/src/store/methods/gc.test.ts b/packages/kernel/src/store/methods/gc.test.ts index 4f5f395e1..dc61999cd 100644 --- a/packages/kernel/src/store/methods/gc.test.ts +++ b/packages/kernel/src/store/methods/gc.test.ts @@ -81,7 +81,7 @@ describe('GC methods', () => { describe('reachability tracking', () => { it('manages reachable flags', () => { const ko1 = kernelStore.initKernelObject('v1'); - kernelStore.addClistEntry('v1', ko1, 'o-1'); + kernelStore.addCListEntry('v1', ko1, 'o-1'); expect(kernelStore.getReachableFlag('v1', ko1)).toBe(true); diff --git a/packages/kernel/src/store/methods/gc.ts b/packages/kernel/src/store/methods/gc.ts index efa7d85a4..2c31a46e0 100644 --- a/packages/kernel/src/store/methods/gc.ts +++ b/packages/kernel/src/store/methods/gc.ts @@ -34,7 +34,7 @@ export function getGCMethods(ctx: StoreContext) { const { getObjectRefCount, deleteKernelObject } = getObjectMethods(ctx); const { getKernelPromise, deleteKernelPromise } = getPromiseMethods(ctx); const { decrementRefCount } = getCListMethods(ctx); - const { getImporters } = getVatMethods(ctx); + const { getImporters, isVatTerminated } = getVatMethods(ctx); const { getReachableFlag, getReachableAndVatSlot } = getReachableMethods(ctx); /** * Get the set of GC actions to perform. @@ -139,7 +139,7 @@ export function getGCMethods(ctx: StoreContext) { // Note: the following decrement can result in an addition to the // maybeFreeKrefs set, which we are in the midst of iterating. // TC39 went to a lot of trouble to ensure that this is kosher. - decrementRefCount(slot); + decrementRefCount(slot, 'gc|promise|slot'); } } deleteKernelPromise(kpid); @@ -154,7 +154,7 @@ export function getGCMethods(ctx: StoreContext) { // deleted). Message delivery should use that, but not us. const ownerKey = `${kref}.owner`; let ownerVatID = ctx.kv.get(ownerKey); - const terminated = ctx.terminatedVats.includes(ownerVatID as VatId); + const terminated = isVatTerminated(ownerVatID as VatId); // Some objects that are still owned, but the owning vat // might still alive, or might be terminated and in the diff --git a/packages/kernel/src/store/methods/promise.ts b/packages/kernel/src/store/methods/promise.ts index 29b53bc52..5286081c2 100644 --- a/packages/kernel/src/store/methods/promise.ts +++ b/packages/kernel/src/store/methods/promise.ts @@ -3,6 +3,7 @@ import { Fail } from '@endo/errors'; import type { CapData } from '@endo/marshal'; import { getBaseMethods } from './base.ts'; +import { getCListMethods } from './clist.ts'; import { getQueueMethods } from './queue.ts'; import { getRefCountMethods } from './refcount.ts'; import type { @@ -31,6 +32,7 @@ export function getPromiseMethods(ctx: StoreContext) { ); const { enqueueRun } = getQueueMethods(ctx); const { refCountKey } = getRefCountMethods(ctx); + const { incrementRefCount } = getCListMethods(ctx); /** * Create a new, unresolved kernel promise. The new promise will be born with @@ -154,6 +156,12 @@ export function getPromiseMethods(ctx: StoreContext) { rejected: boolean, value: CapData, ): void { + let idx = 0; + for (const dataSlot of value.slots) { + incrementRefCount(dataSlot, `resolve|${kpid}|s${idx}`); + idx += 1; + } + const queue = provideStoredQueue(kpid, false); for (const message of getKernelPromiseMessageQueue(kpid)) { const messageItem: RunQueueItemSend = { diff --git a/packages/kernel/src/store/methods/reachable.test.ts b/packages/kernel/src/store/methods/reachable.test.ts index 80ebb69c2..fdaab477e 100644 --- a/packages/kernel/src/store/methods/reachable.test.ts +++ b/packages/kernel/src/store/methods/reachable.test.ts @@ -13,7 +13,7 @@ describe('GC methods', () => { describe('reachability tracking', () => { it('manages reachable flags', () => { const ko1 = kernelStore.initKernelObject('v1'); - kernelStore.addClistEntry('v1', ko1, 'o-1'); + kernelStore.addCListEntry('v1', ko1, 'o-1'); expect(kernelStore.getReachableFlag('v1', ko1)).toBe(true); diff --git a/packages/kernel/src/store/methods/vat.test.ts b/packages/kernel/src/store/methods/vat.test.ts index 40f16cce6..7ec44e215 100644 --- a/packages/kernel/src/store/methods/vat.test.ts +++ b/packages/kernel/src/store/methods/vat.test.ts @@ -12,6 +12,14 @@ vi.mock('./base.ts', () => ({ describe('vat store methods', () => { let mockKV: Map; let mockGetPrefixedKeys = vi.fn(); + let mockGetSlotKey = vi.fn(); + let mockTerminatedVats = { + get: vi.fn(), + set: vi.fn(), + }; + let mockMaybeFreeKrefs = { + add: vi.fn(), + }; let context: StoreContext; let vatMethods: ReturnType; const vatID1 = 'v1' as VatId; @@ -30,9 +38,18 @@ describe('vat store methods', () => { beforeEach(() => { mockKV = new Map(); mockGetPrefixedKeys = vi.fn(); + mockGetSlotKey = vi.fn((vatId, ref) => `slot.${vatId}.${ref}`); + mockTerminatedVats = { + get: vi.fn().mockReturnValue('[]'), + set: vi.fn(), + }; + mockMaybeFreeKrefs = { + add: vi.fn(), + }; (getBaseMethods as ReturnType).mockReturnValue({ getPrefixedKeys: mockGetPrefixedKeys, + getSlotKey: mockGetSlotKey, }); context = { @@ -52,7 +69,9 @@ describe('vat store methods', () => { mockKV.delete(key); }, }, - } as StoreContext; + terminatedVats: mockTerminatedVats, + maybeFreeKrefs: mockMaybeFreeKrefs, + } as unknown as StoreContext; vatMethods = getVatMethods(context); }); @@ -211,35 +230,125 @@ describe('vat store methods', () => { }); }); - describe('getImporters', () => { - it('handles case with no vats', () => { - const kernelObject = 'ko123'; + describe('getTerminatedVats', () => { + it('returns empty array when no vats are terminated', () => { + mockTerminatedVats.get.mockReturnValue('[]'); - // Mock empty array of vat IDs - mockGetPrefixedKeys.mockImplementation((prefix) => { - if (prefix === 'vatConfig.') { - return []; - } - return []; - }); + const result = vatMethods.getTerminatedVats(); + + expect(result).toStrictEqual([]); + expect(mockTerminatedVats.get).toHaveBeenCalled(); + }); + + it('returns array of terminated vat IDs', () => { + mockTerminatedVats.get.mockReturnValue(JSON.stringify([vatID1, vatID2])); + + const result = vatMethods.getTerminatedVats(); + + expect(result).toStrictEqual([vatID1, vatID2]); + expect(mockTerminatedVats.get).toHaveBeenCalled(); + }); + + it('returns empty array when no terminated vats data exists', () => { + mockTerminatedVats.get.mockReturnValue(null); + + const result = vatMethods.getTerminatedVats(); + + expect(result).toStrictEqual([]); + expect(mockTerminatedVats.get).toHaveBeenCalled(); + }); + }); + + describe('markVatAsTerminated', () => { + it('adds a vat to the terminated vats list', () => { + mockTerminatedVats.get.mockReturnValue('[]'); + + vatMethods.markVatAsTerminated(vatID1); + + expect(mockTerminatedVats.set).toHaveBeenCalledWith( + JSON.stringify([vatID1]), + ); + expect(mockTerminatedVats.get).toHaveBeenCalled(); + }); + + it('does not add a vat that is already in the terminated list', () => { + mockTerminatedVats.get.mockReturnValue(JSON.stringify([vatID1])); + + vatMethods.markVatAsTerminated(vatID1); + + expect(mockTerminatedVats.set).not.toHaveBeenCalled(); + expect(mockTerminatedVats.get).toHaveBeenCalled(); + }); + + it('appends to existing terminated vats list', () => { + mockTerminatedVats.get.mockReturnValue(JSON.stringify([vatID1])); - // This shouldn't be called - const mockImportsKernelSlot = vi.fn(); + vatMethods.markVatAsTerminated(vatID2); + + expect(mockTerminatedVats.set).toHaveBeenCalledWith( + JSON.stringify([vatID1, vatID2]), + ); + expect(mockTerminatedVats.get).toHaveBeenCalled(); + }); + }); + + describe('forgetTerminatedVat', () => { + it('removes a vat from the terminated vats list', () => { + mockTerminatedVats.get.mockReturnValue(JSON.stringify([vatID1, vatID2])); + + vatMethods.forgetTerminatedVat(vatID1); + + expect(mockTerminatedVats.set).toHaveBeenCalledWith( + JSON.stringify([vatID2]), + ); + expect(mockTerminatedVats.get).toHaveBeenCalled(); + }); + + it('does nothing if vat is not in the terminated list', () => { + mockTerminatedVats.get.mockReturnValue(JSON.stringify([vatID2])); + + vatMethods.forgetTerminatedVat(vatID1); + + expect(mockTerminatedVats.set).toHaveBeenCalledWith( + JSON.stringify([vatID2]), + ); + expect(mockTerminatedVats.get).toHaveBeenCalled(); + }); + + it('handles empty terminated vats list', () => { + mockTerminatedVats.get.mockReturnValue('[]'); + + vatMethods.forgetTerminatedVat(vatID1); + + expect(mockTerminatedVats.set).toHaveBeenCalledWith('[]'); + expect(mockTerminatedVats.get).toHaveBeenCalled(); + }); + }); + + describe('isVatTerminated', () => { + it('returns true if vat is in terminated list', () => { + mockTerminatedVats.get.mockReturnValue(JSON.stringify([vatID1, vatID2])); + const result = vatMethods.isVatTerminated(vatID1); + expect(result).toBe(true); + expect(mockTerminatedVats.get).toHaveBeenCalled(); + }); + + it('returns false if vat is not in terminated list', () => { + mockTerminatedVats.get.mockReturnValue(JSON.stringify([vatID2])); + + const result = vatMethods.isVatTerminated(vatID1); + + expect(result).toBe(false); + expect(mockTerminatedVats.get).toHaveBeenCalled(); + }); - // Replace the importsKernelSlot method for this test - const originalImportsKernelSlot = vatMethods.importsKernelSlot; - vatMethods.importsKernelSlot = mockImportsKernelSlot; + it('returns false if terminated list is empty', () => { + mockTerminatedVats.get.mockReturnValue('[]'); - try { - const result = vatMethods.getImporters(kernelObject); + const result = vatMethods.isVatTerminated(vatID1); - expect(result).toStrictEqual([]); - expect(mockGetPrefixedKeys).toHaveBeenCalledWith('vatConfig.'); - expect(mockImportsKernelSlot).not.toHaveBeenCalled(); - } finally { - // Restore original method - vatMethods.importsKernelSlot = originalImportsKernelSlot; - } + expect(result).toBe(false); + expect(mockTerminatedVats.get).toHaveBeenCalled(); }); }); }); diff --git a/packages/kernel/src/store/methods/vat.ts b/packages/kernel/src/store/methods/vat.ts index 5db3e5c8c..c8052cc9f 100644 --- a/packages/kernel/src/store/methods/vat.ts +++ b/packages/kernel/src/store/methods/vat.ts @@ -1,6 +1,10 @@ +import { Fail } from '@endo/errors'; + import { getBaseMethods } from './base.ts'; +import { getCListMethods } from './clist.ts'; +import { getReachableMethods } from './reachable.ts'; import type { EndpointId, KRef, VatConfig, VatId } from '../../types.ts'; -import type { StoreContext } from '../types.ts'; +import type { StoreContext, VatCleanupWork } from '../types.ts'; import { parseRef } from '../utils/parse-ref.ts'; import { parseReachableAndVatSlot } from '../utils/reachable.ts'; @@ -22,6 +26,8 @@ const VAT_CONFIG_BASE_LEN = VAT_CONFIG_BASE.length; export function getVatMethods(ctx: StoreContext) { const { kv } = ctx; const { getPrefixedKeys, getSlotKey } = getBaseMethods(ctx.kv); + const { deleteCListEntry } = getCListMethods(ctx); + const { getReachableAndVatSlot } = getReachableMethods(ctx); /** * Delete all persistent state associated with an endpoint. @@ -129,6 +135,160 @@ export function getVatMethods(ctx: StoreContext) { return importers; } + /** + * Get the list of terminated vats. + * + * @returns an array of terminated vat IDs. + */ + function getTerminatedVats(): VatId[] { + return JSON.parse(ctx.terminatedVats.get() ?? '[]'); + } + + /** + * Check if a vat is terminated. + * + * @param vatID - The ID of the vat to check. + * @returns True if the vat is terminated, false otherwise. + */ + function isVatTerminated(vatID: VatId): boolean { + return getTerminatedVats().includes(vatID); + } + + /** + * Add a vat to the list of terminated vats. + * + * @param vatID - The ID of the vat to add. + */ + function markVatAsTerminated(vatID: VatId): void { + const terminatedVats = getTerminatedVats(); + if (!terminatedVats.includes(vatID)) { + terminatedVats.push(vatID); + ctx.terminatedVats.set(JSON.stringify(terminatedVats)); + } + } + + /** + * Remove a vat from the list of terminated vats. + * + * @param vatID - The ID of the vat to remove. + */ + function forgetTerminatedVat(vatID: VatId): void { + const terminatedVats = getTerminatedVats().filter((id) => id !== vatID); + ctx.terminatedVats.set(JSON.stringify(terminatedVats)); + } + + /** + * Cleanup a terminated vat. + * + * @param vatID - The ID of the vat to cleanup. + * @returns The work done during the cleanup. + */ + function cleanupTerminatedVat(vatID: VatId): VatCleanupWork { + const work = { + exports: 0, + imports: 0, + promises: 0, + kv: 0, + }; + + if (!isVatTerminated(vatID)) { + return work; + } + + const clistPrefix = `${vatID}.c.`; + const exportPrefix = `${clistPrefix}o+`; + const importPrefix = `${clistPrefix}o-`; + const promisePrefix = `${clistPrefix}p`; + + // Note: ASCII order is "+,-./", and we rely upon this to split the + // keyspace into the various o+NN/o-NN/etc spaces. If we were using a + // more sophisticated database, we'd keep each section in a separate + // table. + + // The current store semantics ensure this iteration is lexicographic. + // Any changes to the creation of the list of promises to be rejected (and + // thus to the order in which they *get* rejected) need to preserve this + // ordering in order to preserve determinism. + + // first, scan for exported objects, which must be orphaned + for (const key of getPrefixedKeys(exportPrefix)) { + // The void for an object exported by a vat will always be of the form + // `o+NN`. The '+' means that the vat exported the object (rather than + // importing it) and therefore the object is owned by (i.e., within) the + // vat. The corresponding void->koid c-list entry will thus always + // begin with `vMM.c.o+`. In addition to deleting the c-list entry, we + // must also delete the corresponding kernel owner entry for the object, + // since the object will no longer be accessible. + assert(key.startsWith(clistPrefix), key); + const vref = key.slice(clistPrefix.length); + assert(vref.startsWith('o+'), vref); + const kref = ctx.kv.get(key); + assert(kref, key); + // deletes c-list and .owner, adds to maybeFreeKrefs + const ownerKey = `${kref}.owner`; + const ownerVat = ctx.kv.get(ownerKey); + ownerVat === vatID || Fail`export ${kref} not owned by old vat`; + ctx.kv.delete(ownerKey); + const { vatSlot } = getReachableAndVatSlot(vatID, kref); + ctx.kv.delete(getSlotKey(vatID, kref)); + ctx.kv.delete(getSlotKey(vatID, vatSlot)); + ctx.maybeFreeKrefs.add(kref); + work.exports += 1; + } + + // then scan for imported objects, which must be decrefed + for (const key of getPrefixedKeys(importPrefix)) { + // abandoned imports: delete the clist entry as if the vat did a + // drop+retire + const kref = ctx.kv.get(key) ?? Fail`getNextKey ensures get`; + assert(key.startsWith(clistPrefix), key); + const vref = key.slice(clistPrefix.length); + deleteCListEntry(vatID, kref, vref); + // that will also delete both db keys + work.imports += 1; + } + + // The caller used enumeratePromisesByDecider() before calling us, + // so they have already rejected the orphan promises, but those + // kpids are still present in the dead vat's c-list. Clean those up now. + for (const key of getPrefixedKeys(promisePrefix)) { + const kref = ctx.kv.get(key) ?? Fail`getNextKey ensures get`; + assert(key.startsWith(clistPrefix), key); + const vref = key.slice(clistPrefix.length); + deleteCListEntry(vatID, kref, vref); + // that will also delete both db keys + work.promises += 1; + } + + // Finally, clean up any remaining KV entries for this vat + for (const key of getPrefixedKeys(`${vatID}.`)) { + ctx.kv.delete(key); + work.kv += 1; + } + + // Clean up any remaining c-list entries and vat-specific counters + deleteEndpoint(vatID); + + // Remove the vat from the terminated vats list + forgetTerminatedVat(vatID); + + // Log the cleanup work done + console.log(`Cleaned up terminated vat ${vatID}:`, work); + + return work; + } + + /** + * Get the next terminated vat to cleanup. + * + * @returns The work done during the cleanup. + */ + function nextTerminatedVatCleanup(): boolean { + const vatID = getTerminatedVats()?.[0]; + vatID && cleanupTerminatedVat(vatID); + return getTerminatedVats().length > 0; + } + return { deleteEndpoint, getAllVatRecords, @@ -138,5 +298,11 @@ export function getVatMethods(ctx: StoreContext) { getVatIDs, importsKernelSlot, getImporters, + getTerminatedVats, + markVatAsTerminated, + forgetTerminatedVat, + isVatTerminated, + cleanupTerminatedVat, + nextTerminatedVatCleanup, }; } diff --git a/packages/kernel/src/store/types.ts b/packages/kernel/src/store/types.ts index 673130a92..b42352698 100644 --- a/packages/kernel/src/store/types.ts +++ b/packages/kernel/src/store/types.ts @@ -1,6 +1,6 @@ import type { KVStore } from '@ocap/store'; -import type { KRef, VatId } from '../types.ts'; +import type { KRef } from '../types.ts'; export type StoreContext = { kv: KVStore; @@ -13,7 +13,7 @@ export type StoreContext = { maybeFreeKrefs: Set; gcActions: StoredValue; reapQueue: StoredValue; - terminatedVats: VatId[]; + terminatedVats: StoredValue; }; export type StoredValue = { @@ -27,3 +27,10 @@ export type StoredQueue = { dequeue(): object | undefined; delete(): void; }; + +export type VatCleanupWork = { + exports: number; + imports: number; + promises: number; + kv: number; +}; diff --git a/vitest.config.ts b/vitest.config.ts index 70d950435..ba2653b43 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -82,16 +82,16 @@ export default defineConfig({ lines: 98.63, }, 'packages/extension/**': { - statements: 79.21, - functions: 81.96, + statements: 78.22, + functions: 80.21, branches: 75, - lines: 79.24, + lines: 78.25, }, 'packages/kernel/**': { - statements: 86.33, - functions: 92.27, - branches: 70.62, - lines: 86.3, + statements: 83.51, + functions: 91.22, + branches: 68.7, + lines: 83.46, }, 'packages/nodejs/**': { statements: 72.91,