diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 03d3272dd..ddfa2170c 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -149,7 +149,6 @@ jobs: yarn build-release cd - - run: yarn build - - run: yarn build:vats - run: yarn run test - name: Require clean working directory shell: bash diff --git a/package.json b/package.json index 9166f80c5..98954e716 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "build:clean": "yarn clean && yarn build", "build:docs": "yarn workspaces foreach --all --exclude @ocap/monorepo --exclude @ocap/extension --parallel --interlaced --verbose run build:docs", "build:source": "ts-bridge --project tsconfig.build.json --verbose && yarn build:special", - "build:special": "yarn workspace @ocap/shims run build && yarn workspace @ocap/extension run build", + "build:special": "yarn workspace @ocap/shims run build && yarn workspace @ocap/extension run build && yarn workspace @ocap/kernel-test run build:vats", "bundle": "node ./scripts/bundle-vat.js", "changelog:update": "yarn workspaces foreach --all --no-private --parallel --interlaced --verbose run changelog:update", "changelog:validate": "yarn workspaces foreach --all --no-private --parallel --interlaced --verbose run changelog:validate", diff --git a/packages/kernel-test/src/liveslots.test.ts b/packages/kernel-test/src/liveslots.test.ts index 0b0803386..8af65311e 100644 --- a/packages/kernel-test/src/liveslots.test.ts +++ b/packages/kernel-test/src/liveslots.test.ts @@ -8,7 +8,7 @@ import { } from 'node:worker_threads'; import { beforeEach, describe, expect, it } from 'vitest'; -import { kunser } from '../../kernel/src/kernel-marshal.ts'; +import { kunser } from '../../kernel/src/services/kernel-marshal.ts'; import { makeKernel } from '../../nodejs/src/kernel/make-kernel.ts'; const origStdoutWrite = process.stdout.write.bind(process.stdout); diff --git a/packages/kernel-test/src/supervisor.test.ts b/packages/kernel-test/src/supervisor.test.ts index 5d3e94c88..89bd18506 100644 --- a/packages/kernel-test/src/supervisor.test.ts +++ b/packages/kernel-test/src/supervisor.test.ts @@ -5,7 +5,7 @@ import { readFile } from 'fs/promises'; import { join } from 'path'; import { describe, it, expect } from 'vitest'; -import { kser } from '../../kernel/src/kernel-marshal.ts'; +import { kser } from '../../kernel/src/services/kernel-marshal.ts'; import { TestDuplexStream } from '../../streams/test/stream-mocks.ts'; const makeVatSupervisor = async ({ diff --git a/packages/kernel-test/src/vatstore.test.ts b/packages/kernel-test/src/vatstore.test.ts index b4fba56d6..d1baac376 100644 --- a/packages/kernel-test/src/vatstore.test.ts +++ b/packages/kernel-test/src/vatstore.test.ts @@ -17,7 +17,7 @@ import { } from 'node:worker_threads'; import { describe, vi, expect, it } from 'vitest'; -import { kunser } from '../../kernel/src/kernel-marshal.ts'; +import { kunser } from '../../kernel/src/services/kernel-marshal.ts'; import { NodejsVatWorkerService } from '../../nodejs/src/kernel/VatWorkerService.ts'; /** diff --git a/packages/kernel/src/Kernel.ts b/packages/kernel/src/Kernel.ts index 570483142..eed95cd5b 100644 --- a/packages/kernel/src/Kernel.ts +++ b/packages/kernel/src/Kernel.ts @@ -12,9 +12,6 @@ import type { DuplexStream } from '@ocap/streams'; import type { Logger } from '@ocap/utils'; import { makeLogger } from '@ocap/utils'; -import { assert, Fail } from './assert.ts'; -import { kser, kunser, krefOf, kslot } from './kernel-marshal.ts'; -import type { SlotValue } from './kernel-marshal.ts'; import { isKernelCommand, KernelCommandMethod } from './messages/index.ts'; import type { KernelCommand, @@ -22,12 +19,13 @@ import type { VatCommand, VatCommandReturnType, } from './messages/index.ts'; -import { - parseRef, - isPromiseRef, - makeKernelStore, -} from './store/kernel-store.ts'; +import { processGCActionSet } from './services/garbage-collection.ts'; +import type { SlotValue } from './services/kernel-marshal.ts'; +import { kser, kunser, krefOf, kslot } from './services/kernel-marshal.ts'; +import { makeKernelStore } from './store/kernel-store.ts'; import type { KernelStore } from './store/kernel-store.ts'; +import { parseRef } from './store/utils/parse-ref.ts'; +import { isPromiseRef } from './store/utils/promise-ref.ts'; import type { VatId, VRef, @@ -45,6 +43,7 @@ import { insistMessage, isClusterConfig, } from './types.ts'; +import { assert, Fail } from './utils/assert.ts'; import { VatHandle } from './VatHandle.ts'; /** @@ -193,6 +192,18 @@ export class Kernel { */ async *#runQueueItems(): AsyncGenerator { for (;;) { + const gcAction = processGCActionSet(this.#kernelStore); + if (gcAction) { + yield gcAction; + continue; + } + + const reapAction = this.#kernelStore.nextReapAction(); + if (reapAction) { + yield reapAction; + continue; + } + while (this.#runQueueLength > 0) { const item = this.#dequeueRun(); if (item) { @@ -201,6 +212,7 @@ export class Kernel { break; } } + if (this.#runQueueLength === 0) { const { promise, resolve } = makePromiseKit(); if (this.#wakeUpTheRunQueue !== null) { @@ -582,6 +594,38 @@ export class Kernel { log(`@@@@ done ${vatId} notify ${kpid}`); break; } + case 'dropExports': { + const { vatId, krefs } = item; + log(`@@@@ deliver ${vatId} dropExports`, krefs); + const vat = this.#getVat(vatId); + await vat.deliverDropExports(krefs); + log(`@@@@ done ${vatId} dropExports`, krefs); + break; + } + case 'retireExports': { + const { vatId, krefs } = item; + log(`@@@@ deliver ${vatId} retireExports`, krefs); + const vat = this.#getVat(vatId); + await vat.deliverRetireExports(krefs); + log(`@@@@ done ${vatId} retireExports`, krefs); + break; + } + case 'retireImports': { + const { vatId, krefs } = item; + log(`@@@@ deliver ${vatId} retireImports`, krefs); + const vat = this.#getVat(vatId); + await vat.deliverRetireImports(krefs); + log(`@@@@ done ${vatId} retireImports`, krefs); + break; + } + case 'bringOutYourDead': { + const { vatId } = item; + log(`@@@@ deliver ${vatId} bringOutYourDead`); + const vat = this.#getVat(vatId); + await vat.deliverBringOutYourDead(); + log(`@@@@ done ${vatId} bringOutYourDead`); + break; + } default: // @ts-expect-error Runtime does not respect "never". Fail`unsupported or unknown run queue item type ${item.type}`; diff --git a/packages/kernel/src/VatHandle.test.ts b/packages/kernel/src/VatHandle.test.ts index 92eef54fb..9a62e6847 100644 --- a/packages/kernel/src/VatHandle.test.ts +++ b/packages/kernel/src/VatHandle.test.ts @@ -8,8 +8,8 @@ import type { MockInstance } from 'vitest'; import { Kernel } from './Kernel.ts'; import { isVatCommandReply, VatCommandMethod } from './messages/index.ts'; import type { VatCommand, VatCommandReply } from './messages/index.ts'; -import type { KernelStore } from './store/kernel-store.ts'; import { makeKernelStore } from './store/kernel-store.ts'; +import type { KernelStore } from './store/kernel-store.ts'; import { VatHandle } from './VatHandle.ts'; import { makeMapKernelDatabase } from '../test/storage.ts'; diff --git a/packages/kernel/src/VatHandle.ts b/packages/kernel/src/VatHandle.ts index 32e111414..bbc54597f 100644 --- a/packages/kernel/src/VatHandle.ts +++ b/packages/kernel/src/VatHandle.ts @@ -19,6 +19,7 @@ import type { VatCommandReturnType, } from './messages/index.ts'; import type { KernelStore } from './store/kernel-store.ts'; +import { parseRef } from './store/utils/parse-ref.ts'; import type { PromiseCallbacks, VatId, @@ -312,6 +313,76 @@ export class VatHandle { } } + /** + * Handle a 'dropImports' syscall from the vat. + * + * @param krefs - The KRefs of the imports to be dropped. + */ + #handleSyscallDropImports(krefs: KRef[]): void { + for (const kref of krefs) { + const { direction, isPromise } = parseRef(kref); + // We validate it's an import - meaning this vat received this object from somewhere else + if (direction === 'export' || isPromise) { + throw Error( + `vat ${this.vatId} issued invalid syscall dropImports for ${kref}`, + ); + } + this.#kernelStore.clearReachableFlag(this.vatId, kref); + } + } + + /** + * Handle a 'retireImports' syscall from the vat. + * + * @param krefs - The KRefs of the imports to be retired. + */ + #handleSyscallRetireImports(krefs: KRef[]): void { + for (const kref of krefs) { + const { direction, isPromise } = parseRef(kref); + // We validate it's an import - meaning this vat received this object from somewhere else + if (direction === 'export' || isPromise) { + throw Error( + `vat ${this.vatId} issued invalid syscall retireImports for ${kref}`, + ); + } + if (this.#kernelStore.getReachableFlag(this.vatId, kref)) { + throw Error(`syscall.retireImports but ${kref} is still reachable`); + } + // deleting the clist entry will decrement the recognizable count, but + // not the reachable count (because it was unreachable, as we asserted) + this.#kernelStore.forgetKref(this.vatId, kref); + } + } + + /** + * Handle retiring or abandoning exports syscall from the vat. + * + * @param krefs - The KRefs of the exports to be retired/abandoned. + * @param checkReachable - If true, verify the object is not reachable (retire). If false, ignore reachability (abandon). + */ + #handleSyscallExportCleanup(krefs: KRef[], checkReachable: boolean): void { + const action = checkReachable ? 'retire' : 'abandon'; + + for (const kref of krefs) { + const { direction, isPromise } = parseRef(kref); + // We validate it's an export - meaning this vat created/owns this object + if (direction === 'import' || isPromise) { + throw Error( + `vat ${this.vatId} issued invalid syscall ${action}Exports for ${kref}`, + ); + } + if (checkReachable) { + if (this.#kernelStore.getReachableFlag(this.vatId, kref)) { + throw Error( + `syscall.${action}Exports but ${kref} is still reachable`, + ); + } + } + this.#kernelStore.forgetKref(this.vatId, kref); + this.#logger.debug(`${action}Exports: deleted object ${kref}`); + } + } + /** * Handle a syscall from the vat. * @@ -354,24 +425,28 @@ export class VatHandle { // [KRef[]]; const [, refs] = kso; log(`@@@@ ${vatId} syscall dropImports ${JSON.stringify(refs)}`); + this.#handleSyscallDropImports(refs); break; } case 'retireImports': { // [KRef[]]; const [, refs] = kso; log(`@@@@ ${vatId} syscall retireImports ${JSON.stringify(refs)}`); + this.#handleSyscallRetireImports(refs); break; } case 'retireExports': { // [KRef[]]; const [, refs] = kso; log(`@@@@ ${vatId} syscall retireExports ${JSON.stringify(refs)}`); + this.#handleSyscallExportCleanup(refs, true); break; } case 'abandonExports': { // [KRef[]]; const [, refs] = kso; log(`@@@@ ${vatId} syscall abandonExports ${JSON.stringify(refs)}`); + this.#handleSyscallExportCleanup(refs, false); break; } case 'callNow': @@ -450,6 +525,52 @@ export class VatHandle { }); } + /** + * Make a 'dropExports' delivery to the vat. + * + * @param krefs - The KRefs of the exports to be dropped. + */ + async deliverDropExports(krefs: KRef[]): Promise { + await this.sendVatCommand({ + method: VatCommandMethod.deliver, + params: ['dropExports', krefs], + }); + } + + /** + * Make a 'retireExports' delivery to the vat. + * + * @param krefs - The KRefs of the exports to be retired. + */ + async deliverRetireExports(krefs: KRef[]): Promise { + await this.sendVatCommand({ + method: VatCommandMethod.deliver, + params: ['retireExports', krefs], + }); + } + + /** + * Make a 'retireImports' delivery to the vat. + * + * @param krefs - The KRefs of the imports to be retired. + */ + async deliverRetireImports(krefs: KRef[]): Promise { + await this.sendVatCommand({ + method: VatCommandMethod.deliver, + params: ['retireImports', krefs], + }); + } + + /** + * Make a 'bringOutYourDead' delivery to the vat. + */ + async deliverBringOutYourDead(): Promise { + await this.sendVatCommand({ + method: VatCommandMethod.deliver, + params: ['bringOutYourDead'], + }); + } + /** * Terminates the vat. * diff --git a/packages/kernel/src/VatSupervisor.ts b/packages/kernel/src/VatSupervisor.ts index db42507f4..e226b0e39 100644 --- a/packages/kernel/src/VatSupervisor.ts +++ b/packages/kernel/src/VatSupervisor.ts @@ -10,20 +10,16 @@ import type { CapData } from '@endo/marshal'; import { StreamReadError } from '@ocap/errors'; import type { DuplexStream } from '@ocap/streams'; -import type { - DispatchFn, - MakeLiveSlotsFn, - GCTools, -} from './ag-liveslots-types.ts'; -import { makeDummyMeterControl } from './dummyMeterControl.ts'; import type { VatCommand, VatCommandReply } from './messages/index.ts'; import { VatCommandMethod } from './messages/index.ts'; -import { makeSupervisorSyscall } from './syscall.ts'; +import { makeDummyMeterControl } from './services/meter-control.ts'; +import { makeSupervisorSyscall } from './services/syscall.ts'; +import type { DispatchFn, MakeLiveSlotsFn, GCTools } from './services/types.ts'; import type { VatConfig, VatId, VRef } from './types.ts'; import { ROOT_OBJECT_VREF, isVatConfig } from './types.ts'; +import { waitUntilQuiescent } from './utils/wait-quiescent.ts'; import type { VatKVStore } from './VatKVStore.ts'; import { makeVatKVStore } from './VatKVStore.ts'; -import { waitUntilQuiescent } from './waitUntilQuiescent.ts'; const makeLiveSlots: MakeLiveSlotsFn = localMakeLiveSlots; diff --git a/packages/kernel/src/services/garbage-collection.test.ts b/packages/kernel/src/services/garbage-collection.test.ts new file mode 100644 index 000000000..2f4a4de56 --- /dev/null +++ b/packages/kernel/src/services/garbage-collection.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +import { processGCActionSet } from './garbage-collection.ts'; +import { makeMapKernelDatabase } from '../../test/storage.ts'; +import { makeKernelStore } from '../store/kernel-store.ts'; +import { RunQueueItemType } from '../types.ts'; + +describe('garbage-collection', () => { + describe('processGCActionSet', () => { + let kernelStore: ReturnType; + + beforeEach(() => { + kernelStore = makeKernelStore(makeMapKernelDatabase()); + }); + + it('processes dropExport actions', () => { + // Setup: Create object and add GC action + const ko1 = kernelStore.initKernelObject('v1'); + kernelStore.addClistEntry('v1', ko1, 'o+1'); // Export reference + + // Set reachable count to 0 but keep recognizable count + kernelStore.setObjectRefCount(ko1, { reachable: 0, recognizable: 1 }); + + kernelStore.addGCActions([`v1 dropExport ${ko1}`]); + + // Initial state checks + expect(kernelStore.getReachableFlag('v1', ko1)).toBe(true); + expect(kernelStore.getObjectRefCount(ko1)).toStrictEqual({ + reachable: 0, + recognizable: 1, + }); + + // Process GC actions + const result = processGCActionSet(kernelStore); + + // Verify result + expect(result).toStrictEqual({ + type: RunQueueItemType.dropExports, + vatId: 'v1', + krefs: [ko1], + }); + + // Verify actions were removed + expect(kernelStore.getGCActions().size).toBe(0); + }); + + it('processes retireExport actions', () => { + // Setup: Create object with zero refcounts + const ko1 = kernelStore.initKernelObject('v1'); + kernelStore.addClistEntry('v1', ko1, 'o+1'); + kernelStore.setObjectRefCount(ko1, { reachable: 0, recognizable: 0 }); + kernelStore.addGCActions([`v1 retireExport ${ko1}`]); + + // Process GC actions + const result = processGCActionSet(kernelStore); + + // Verify result + expect(result).toStrictEqual({ + type: RunQueueItemType.retireExports, + vatId: 'v1', + krefs: [ko1], + }); + + // Verify actions were removed + expect(kernelStore.getGCActions().size).toBe(0); + }); + + 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.addGCActions([`v2 retireImport ${ko1}`]); + + // Process GC actions + const result = processGCActionSet(kernelStore); + + // Verify result + expect(result).toStrictEqual({ + type: RunQueueItemType.retireImports, + vatId: 'v2', + krefs: [ko1], + }); + + // Verify actions were removed + expect(kernelStore.getGCActions().size).toBe(0); + }); + + it('processes actions in priority order', () => { + // Setup: Create objects and add multiple GC actions + const ko1 = kernelStore.initKernelObject('v1'); + const ko2 = kernelStore.initKernelObject('v1'); + + 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 }); + kernelStore.setObjectRefCount(ko2, { reachable: 0, recognizable: 0 }); + + // Add actions in reverse priority order + kernelStore.addGCActions([ + `v1 retireExport ${ko2}`, + `v1 dropExport ${ko1}`, + ]); + + // Process first action - should be dropExport + let result = processGCActionSet(kernelStore); + expect(result).toStrictEqual({ + type: RunQueueItemType.dropExports, + vatId: 'v1', + krefs: [ko1], + }); + + // Process second action - should be retireExport + result = processGCActionSet(kernelStore); + expect(result).toStrictEqual({ + type: RunQueueItemType.retireExports, + vatId: 'v1', + krefs: [ko2], + }); + }); + + it('processes actions by vat ID order', () => { + // Setup: Create objects in different vats + const ko1 = kernelStore.initKernelObject('v2'); + const ko2 = kernelStore.initKernelObject('v1'); + + kernelStore.addClistEntry('v2', ko1, 'o+1'); + kernelStore.addClistEntry('v1', ko2, 'o+1'); + + // Set up conditions for dropExport + kernelStore.setObjectRefCount(ko1, { reachable: 0, recognizable: 1 }); + kernelStore.setObjectRefCount(ko2, { reachable: 0, recognizable: 1 }); + + // Add actions in reverse vat order + kernelStore.addGCActions([ + `v2 dropExport ${ko1}`, + `v1 dropExport ${ko2}`, + ]); + + // Process first action - should be v1 + let result = processGCActionSet(kernelStore); + expect(result).toStrictEqual({ + type: RunQueueItemType.dropExports, + vatId: 'v1', + krefs: [ko2], + }); + + // Process second action - should be v2 + result = processGCActionSet(kernelStore); + expect(result).toStrictEqual({ + type: RunQueueItemType.dropExports, + vatId: 'v2', + krefs: [ko1], + }); + }); + + it('skips actions that should not be processed', () => { + const ko1 = kernelStore.initKernelObject('v1'); + kernelStore.addClistEntry('v1', ko1, 'o+1'); + + // Add dropExport action but set reachable to false (should skip) + kernelStore.clearReachableFlag('v1', ko1); + kernelStore.addGCActions([`v1 dropExport ${ko1}`]); + + // Process actions - should return undefined since action should be skipped + const result = processGCActionSet(kernelStore); + expect(result).toBeUndefined(); + + // Verify action was removed from set + expect(kernelStore.getGCActions().size).toBe(0); + }); + + it('returns undefined when no actions to process', () => { + const result = processGCActionSet(kernelStore); + expect(result).toBeUndefined(); + }); + + it('skips dropExport when object does not exist', () => { + const ko1 = kernelStore.initKernelObject('v1'); + kernelStore.addClistEntry('v1', ko1, 'o+1'); + + // Delete the object to simulate non-existence + kernelStore.deleteKernelObject(ko1); + + kernelStore.addGCActions([`v1 dropExport ${ko1}`]); + + const result = processGCActionSet(kernelStore); + expect(result).toBeUndefined(); + expect(kernelStore.getGCActions().size).toBe(0); + }); + + it('skips retireExport when object has non-zero refcounts', () => { + const ko1 = kernelStore.initKernelObject('v1'); + kernelStore.addClistEntry('v1', ko1, 'o+1'); + + // Set non-zero refcounts + kernelStore.setObjectRefCount(ko1, { reachable: 1, recognizable: 1 }); + + kernelStore.addGCActions([`v1 retireExport ${ko1}`]); + + const result = processGCActionSet(kernelStore); + expect(result).toBeUndefined(); + expect(kernelStore.getGCActions().size).toBe(0); + }); + + it('skips retireExport when object does not exist', () => { + const ko1 = kernelStore.initKernelObject('v1'); + kernelStore.addClistEntry('v1', ko1, 'o+1'); + + // Delete the object + kernelStore.deleteKernelObject(ko1); + + kernelStore.addGCActions([`v1 retireExport ${ko1}`]); + + const result = processGCActionSet(kernelStore); + expect(result).toBeUndefined(); + expect(kernelStore.getGCActions().size).toBe(0); + }); + + it('skips retireExport when clist entry does not exist', () => { + const ko1 = kernelStore.initKernelObject('v1'); + kernelStore.setObjectRefCount(ko1, { reachable: 0, recognizable: 0 }); + + kernelStore.addGCActions([`v1 retireExport ${ko1}`]); + + const result = processGCActionSet(kernelStore); + expect(result).toBeUndefined(); + expect(kernelStore.getGCActions().size).toBe(0); + }); + + it('skips retireImport when clist entry does not exist', () => { + const ko1 = kernelStore.initKernelObject('v1'); + + kernelStore.addGCActions([`v2 retireImport ${ko1}`]); + + const result = processGCActionSet(kernelStore); + expect(result).toBeUndefined(); + expect(kernelStore.getGCActions().size).toBe(0); + }); + + it('skips retireExport when object is recognizable', () => { + const ko1 = kernelStore.initKernelObject('v1'); + kernelStore.addClistEntry('v1', ko1, 'o+1'); + + // Set only recognizable count to non-zero + kernelStore.setObjectRefCount(ko1, { reachable: 0, recognizable: 1 }); + + kernelStore.addGCActions([`v1 retireExport ${ko1}`]); + + const result = processGCActionSet(kernelStore); + expect(result).toBeUndefined(); + expect(kernelStore.getGCActions().size).toBe(0); + }); + }); +}); diff --git a/packages/kernel/src/services/garbage-collection.ts b/packages/kernel/src/services/garbage-collection.ts new file mode 100644 index 000000000..be599967e --- /dev/null +++ b/packages/kernel/src/services/garbage-collection.ts @@ -0,0 +1,190 @@ +import type { KernelStore } from '../store/kernel-store.ts'; +import { insistKernelType } from '../store/utils/kernel-slots.ts'; +import type { + GCAction, + GCActionType, + KRef, + RunQueueItem, + VatId, +} from '../types.ts'; +import { + actionTypePriorities, + insistGCActionType, + insistVatId, + queueTypeFromActionType, +} from '../types.ts'; +import { assert } from '../utils/assert.ts'; + +/** + * Parsed representation of a GC action. + */ +type ParsedGCAction = Readonly<{ + vatId: VatId; + type: GCActionType; + kref: KRef; +}>; + +/** + * Parse a GC action string into a vat id, type, and kref. + * + * @param action - The GC action string to parse. + * @returns The parsed GC action. + */ +function parseAction(action: GCAction): ParsedGCAction { + const [vatId, type, kref] = action.split(' '); + insistVatId(vatId); + insistGCActionType(type); + insistKernelType('object', kref); + return harden({ vatId, type, kref }); +} + +/** + * Determines if a GC action should be processed based on current system state. + * + * @param storage - The kernel storage. + * @param vatId - The vat id of the vat that owns the kref. + * @param type - The type of GC action. + * @param kref - The kref of the object in question. + * @returns True if the action should be processed, false otherwise. + */ +function shouldProcessAction( + storage: KernelStore, + vatId: VatId, + type: GCActionType, + kref: KRef, +): boolean { + const hasCList = storage.hasCListEntry(vatId, kref); + const isReachable = hasCList + ? storage.getReachableFlag(vatId, kref) + : undefined; + const exists = storage.kernelRefExists(kref); + const { reachable, recognizable } = exists + ? storage.getObjectRefCount(kref) + : { reachable: 0, recognizable: 0 }; + + switch (type) { + case 'dropExport': + return exists && reachable === 0 && hasCList && isReachable === true; + + case 'retireExport': + return exists && reachable === 0 && recognizable === 0 && hasCList; + + case 'retireImport': + return hasCList; + + default: + return false; + } +} + +/** + * Filters and processes a group of GC actions for a specific vat and action type. + * + * @param storage - The kernel storage. + * @param vatId - The vat id of the vat that owns the krefs. + * @param actions - The set of GC actions to process. + * @param allActionsSet - The complete set of GC actions. + * @returns Object containing the krefs to process and whether the action set was updated. + */ +function filterActionsForProcessing( + storage: KernelStore, + vatId: VatId, + actions: Set, + allActionsSet: Set, +): { krefs: KRef[]; actionSetUpdated: boolean } { + const krefs: KRef[] = []; + let actionSetUpdated = false; + + for (const action of actions) { + const { type, kref } = parseAction(action); + if (shouldProcessAction(storage, vatId, type, kref)) { + krefs.push(kref); + } + allActionsSet.delete(action); + actionSetUpdated = true; + } + + return harden({ krefs, actionSetUpdated }); +} + +/** + * Process the set of GC actions. + * + * @param storage - The kernel storage. + * @returns The next action to process, or undefined if there are no actions to process. + */ +export function processGCActionSet( + storage: KernelStore, +): RunQueueItem | undefined { + const allActionsSet = storage.getGCActions(); + let actionSetUpdated = false; + + // Group actions by vat and type + const actionsByVat = new Map>>(); + + for (const action of allActionsSet) { + const { vatId, type } = parseAction(action); + + if (!actionsByVat.has(vatId)) { + actionsByVat.set(vatId, new Map()); + } + + const actionsForVatByType = actionsByVat.get(vatId); + assert(actionsForVatByType !== undefined, `No actions for vat: ${vatId}`); + + if (!actionsForVatByType.has(type)) { + actionsForVatByType.set(type, new Set()); + } + + const actions = actionsForVatByType.get(type); + assert(actions !== undefined, `No actions for type: ${type}`); + actions.add(action); + } + + // Process actions in priority order + const vatIds = Array.from(actionsByVat.keys()).sort(); + + for (const vatId of vatIds) { + const actionsForVatByType = actionsByVat.get(vatId); + assert(actionsForVatByType !== undefined, `No actions for vat: ${vatId}`); + + // Find the highest-priority type of work to do within this vat + for (const type of actionTypePriorities) { + if (actionsForVatByType.has(type)) { + const actions = actionsForVatByType.get(type); + assert(actions !== undefined, `No actions for type: ${type}`); + const { krefs, actionSetUpdated: updated } = filterActionsForProcessing( + storage, + vatId, + actions, + allActionsSet, + ); + + actionSetUpdated = actionSetUpdated || updated; + + if (krefs.length > 0) { + // We found actions to process + krefs.sort(); + + // Update the durable set before returning + storage.setGCActions(allActionsSet); + + const queueType = queueTypeFromActionType.get(type); + assert(queueType !== undefined, `Unknown action type: ${type}`); + + return harden({ type: queueType, vatId, krefs }); + } + } + } + } + + if (actionSetUpdated) { + // Remove negated items from the durable set + storage.setGCActions(allActionsSet); + } + + // No GC work to do + return undefined; +} + +harden(processGCActionSet); diff --git a/packages/kernel/src/services/kernel-marshal.test.ts b/packages/kernel/src/services/kernel-marshal.test.ts new file mode 100644 index 000000000..3d5ee6d8e --- /dev/null +++ b/packages/kernel/src/services/kernel-marshal.test.ts @@ -0,0 +1,146 @@ +import { passStyleOf } from '@endo/far'; +import { describe, it, expect } from 'vitest'; + +import { kslot, krefOf, kser, kunser, makeError } from './kernel-marshal.ts'; +import type { SlotValue } from './kernel-marshal.ts'; + +describe('kernel-marshal', () => { + describe('kslot', () => { + it('creates promise standin for promise refs', () => { + const promiseRefs = ['p1', 'kp1', 'rp1']; + + for (const ref of promiseRefs) { + const standin = kslot(ref); + expect(passStyleOf(standin)).toBe('promise'); + expect(krefOf(standin)).toBe(ref); + } + }); + + it('creates remotable standin for object refs', () => { + const ref = 'ko1'; + const iface = 'TestInterface'; + const standin = kslot(ref, iface); + + expect(passStyleOf(standin)).toBe('remotable'); + expect(krefOf(standin)).toBe(ref); + expect((standin as SlotValue & { iface(): string }).iface()).toBe(iface); + }); + + it('strips Alleged: prefix from interface', () => { + const ref = 'ko1'; + const iface = 'Alleged: TestInterface'; + const standin = kslot(ref, iface); + + expect((standin as SlotValue & { iface(): string }).iface()).toBe( + 'TestInterface', + ); + }); + }); + + describe('krefOf', () => { + it('extracts kref from promise standin', () => { + const ref = 'kp1'; + const standin = kslot(ref); + expect(krefOf(standin)).toBe(ref); + }); + + it('extracts kref from remotable standin', () => { + const ref = 'ko1'; + const standin = kslot(ref); + expect(krefOf(standin)).toBe(ref); + }); + + it('throws for invalid input', () => { + expect(() => krefOf(harden({}) as SlotValue)).toThrow( + 'krefOf requires a promise or remotable', + ); + expect(() => krefOf(null as unknown as SlotValue)).toThrow( + 'krefOf requires a promise or remotable', + ); + }); + }); + + describe('kser/kunser', () => { + it('serializes and deserializes primitive values', () => { + const values = [ + 42, + 'hello', + true, + null, + undefined, + ['array', 123], + { key: 'value' }, + ]; + + for (const value of values) { + const serialized = kser(value); + const deserialized = kunser(serialized); + expect(deserialized).toStrictEqual(value); + } + }); + + it('serializes and deserializes objects with krefs', () => { + const ko1 = kslot('ko1', 'TestInterface'); + const kp1 = kslot('kp1'); + + const value = { + obj: ko1, + promise: kp1, + data: 'test', + }; + + const serialized = kser(value); + expect(serialized).toHaveProperty('body'); + expect(serialized).toHaveProperty('slots'); + + const deserialized = kunser(serialized) as { + obj: SlotValue; + promise: SlotValue; + data: string; + }; + expect(deserialized).toHaveProperty('obj'); + expect(deserialized).toHaveProperty('promise'); + expect(deserialized).toHaveProperty('data', 'test'); + + expect(krefOf(deserialized.obj)).toBe('ko1'); + expect(krefOf(deserialized.promise)).toBe('kp1'); + }); + + it('preserves pass-style of serialized values', () => { + const ko1 = kslot('ko1', 'TestInterface'); + const kp1 = kslot('kp1'); + + const serialized = kser({ obj: ko1, promise: kp1 }); + const deserialized = kunser(serialized) as { + obj: SlotValue; + promise: SlotValue; + }; + + expect(passStyleOf(deserialized.obj)).toBe('remotable'); + expect(passStyleOf(deserialized.promise)).toBe('promise'); + }); + }); + + describe('makeError', () => { + it('creates serialized error with message', () => { + const message = 'Test error message'; + const serialized = makeError(message); + const deserialized = kunser(serialized); + + expect(deserialized).toBeInstanceOf(Error); + expect((deserialized as Error).message).toBe(message); + }); + + it('throws for non-string message', () => { + expect(() => makeError(123 as unknown as string)).toThrow( + '123 must be a string', + ); + expect(() => makeError(null as unknown as string)).toThrow( + 'null must be a string', + ); + expect(() => makeError(undefined as unknown as string)).toThrow( + '"[undefined]" must be a string', + ); + }); + }); +}); diff --git a/packages/kernel/src/kernel-marshal.ts b/packages/kernel/src/services/kernel-marshal.ts similarity index 99% rename from packages/kernel/src/kernel-marshal.ts rename to packages/kernel/src/services/kernel-marshal.ts index cfe779eab..146a0d72c 100644 --- a/packages/kernel/src/kernel-marshal.ts +++ b/packages/kernel/src/services/kernel-marshal.ts @@ -4,7 +4,7 @@ import { makeMarshal } from '@endo/marshal'; import type { CapData } from '@endo/marshal'; import type { Passable } from '@endo/pass-style'; -import type { KRef } from './types.ts'; +import type { KRef } from '../types.ts'; // Simple wrapper for serializing and unserializing marshalled values inside the // kernel, where we don't actually want to use clists nor actually allocate real diff --git a/packages/kernel/src/services/meter-control.test.ts b/packages/kernel/src/services/meter-control.test.ts new file mode 100644 index 000000000..e93305518 --- /dev/null +++ b/packages/kernel/src/services/meter-control.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect } from 'vitest'; + +import { makeDummyMeterControl } from './meter-control.ts'; + +describe('meter-control', () => { + describe('makeDummyMeterControl', () => { + it('creates a meter control object with expected methods', () => { + const meterControl = makeDummyMeterControl(); + + expect(meterControl).toHaveProperty('isMeteringDisabled'); + expect(meterControl).toHaveProperty('assertIsMetered'); + expect(meterControl).toHaveProperty('assertNotMetered'); + expect(meterControl).toHaveProperty('runWithoutMetering'); + expect(meterControl).toHaveProperty('runWithoutMeteringAsync'); + expect(meterControl).toHaveProperty('unmetered'); + }); + + it('starts with metering enabled', () => { + const meterControl = makeDummyMeterControl(); + expect(meterControl.isMeteringDisabled()).toBe(false); + }); + + describe('assertIsMetered', () => { + it('succeeds when metering is enabled', () => { + const meterControl = makeDummyMeterControl(); + expect(() => meterControl.assertIsMetered('test')).not.toThrow(); + }); + + it('throws when metering is disabled', () => { + const meterControl = makeDummyMeterControl(); + meterControl.runWithoutMetering(() => { + expect(() => meterControl.assertIsMetered('test message')).toThrow( + 'test message', + ); + }); + }); + }); + + describe('assertNotMetered', () => { + it('succeeds when metering is disabled', () => { + const meterControl = makeDummyMeterControl(); + meterControl.runWithoutMetering(() => { + expect(() => meterControl.assertNotMetered('test')).not.toThrow(); + }); + }); + + it('throws when metering is enabled', () => { + const meterControl = makeDummyMeterControl(); + expect(() => meterControl.assertNotMetered('test message')).toThrow( + 'test message', + ); + }); + }); + + describe('runWithoutMetering', () => { + it('disables metering during execution', () => { + const meterControl = makeDummyMeterControl(); + + meterControl.runWithoutMetering(() => { + expect(meterControl.isMeteringDisabled()).toBe(true); + }); + }); + + it('restores metering after execution', () => { + const meterControl = makeDummyMeterControl(); + + meterControl.runWithoutMetering(() => { + // do nothing + }); + expect(meterControl.isMeteringDisabled()).toBe(false); + }); + + it('restores metering even if thunk throws', () => { + const meterControl = makeDummyMeterControl(); + + expect(() => + meterControl.runWithoutMetering(() => { + throw new Error('test error'); + }), + ).toThrow('test error'); + + expect(meterControl.isMeteringDisabled()).toBe(false); + }); + + it('returns thunk result', () => { + const meterControl = makeDummyMeterControl(); + const result = meterControl.runWithoutMetering(() => 'test result'); + expect(result).toBe('test result'); + }); + + it('supports nested calls', () => { + const meterControl = makeDummyMeterControl(); + + meterControl.runWithoutMetering(() => { + expect(meterControl.isMeteringDisabled()).toBe(true); + + meterControl.runWithoutMetering(() => { + expect(meterControl.isMeteringDisabled()).toBe(true); + }); + + expect(meterControl.isMeteringDisabled()).toBe(true); + }); + + expect(meterControl.isMeteringDisabled()).toBe(false); + }); + }); + + describe('runWithoutMeteringAsync', () => { + it('disables metering during execution', async () => { + const meterControl = makeDummyMeterControl(); + + await meterControl.runWithoutMeteringAsync(async () => { + expect(meterControl.isMeteringDisabled()).toBe(true); + }); + }); + + it('restores metering after execution', async () => { + const meterControl = makeDummyMeterControl(); + + await meterControl.runWithoutMeteringAsync(async () => { + // do nothing + }); + expect(meterControl.isMeteringDisabled()).toBe(false); + }); + + it('restores metering even if thunk rejects', async () => { + const meterControl = makeDummyMeterControl(); + + await expect( + meterControl.runWithoutMeteringAsync(async () => { + throw new Error('test error'); + }), + ).rejects.toThrow('test error'); + + expect(meterControl.isMeteringDisabled()).toBe(false); + }); + + it('returns thunk result', async () => { + const meterControl = makeDummyMeterControl(); + const result = await meterControl.runWithoutMeteringAsync( + async () => 'test result', + ); + expect(result).toBe('test result'); + }); + + it('supports nested calls', async () => { + const meterControl = makeDummyMeterControl(); + + await meterControl.runWithoutMeteringAsync(async () => { + expect(meterControl.isMeteringDisabled()).toBe(true); + + await meterControl.runWithoutMeteringAsync(async () => { + expect(meterControl.isMeteringDisabled()).toBe(true); + }); + + expect(meterControl.isMeteringDisabled()).toBe(true); + }); + + expect(meterControl.isMeteringDisabled()).toBe(false); + }); + }); + + describe('unmetered', () => { + it('wraps function to run without metering', () => { + const meterControl = makeDummyMeterControl(); + const fn = meterControl.unmetered(() => { + expect(meterControl.isMeteringDisabled()).toBe(true); + return 'test result'; + }); + + expect(meterControl.isMeteringDisabled()).toBe(false); + const result = fn(); + expect(result).toBe('test result'); + expect(meterControl.isMeteringDisabled()).toBe(false); + }); + + it('preserves function arguments', () => { + const meterControl = makeDummyMeterControl(); + const fn = meterControl.unmetered((a: number, b: string) => { + expect(meterControl.isMeteringDisabled()).toBe(true); + return `${a}-${b}`; + }); + + const result = fn(42, 'test'); + expect(result).toBe('42-test'); + }); + + it('restores metering if function throws', () => { + const meterControl = makeDummyMeterControl(); + const fn = meterControl.unmetered(() => { + throw new Error('test error'); + }); + + expect(() => fn()).toThrow('test error'); + expect(meterControl.isMeteringDisabled()).toBe(false); + }); + }); + }); +}); diff --git a/packages/kernel/src/dummyMeterControl.ts b/packages/kernel/src/services/meter-control.ts similarity index 100% rename from packages/kernel/src/dummyMeterControl.ts rename to packages/kernel/src/services/meter-control.ts diff --git a/packages/kernel/src/services/syscall.test.ts b/packages/kernel/src/services/syscall.test.ts new file mode 100644 index 000000000..cd773e9b6 --- /dev/null +++ b/packages/kernel/src/services/syscall.test.ts @@ -0,0 +1,293 @@ +import type { + VatSyscallObject, + VatSyscallResult, + VatOneResolution, +} from '@agoric/swingset-liveslots'; +import type { CapData } from '@endo/marshal'; +import type { KVStore } from '@ocap/store'; +import { describe, it, expect, vi } from 'vitest'; + +import { makeSupervisorSyscall } from './syscall.ts'; +import type { VatSupervisor } from '../VatSupervisor.ts'; + +describe('syscall', () => { + // Mock supervisor that records syscalls and returns predefined results + const createMockSupervisor = (): VatSupervisor => { + const mockSupervisor = { + executeSyscall: vi.fn( + (_vso: VatSyscallObject): VatSyscallResult => ['ok', null], + ), + } as unknown as VatSupervisor; + return mockSupervisor; + }; + + // Mock KV store for testing vatstore operations + const createMockKVStore = (): KVStore => { + const store = new Map(); + const mockKVStore = { + get: vi.fn((key: string) => store.get(key)), + getNextKey: vi.fn((priorKey: string) => { + const keys = Array.from(store.keys()).sort(); + const index = keys.indexOf(priorKey); + return index >= 0 && index < keys.length - 1 + ? keys[index + 1] + : undefined; + }), + set: vi.fn((key: string, value: string) => { + store.set(key, value); + }), + delete: vi.fn((key: string) => { + store.delete(key); + }), + } as unknown as KVStore; + return mockKVStore; + }; + + describe('makeSupervisorSyscall', () => { + it('creates a syscall object with all required methods', () => { + const supervisor = createMockSupervisor(); + const kv = createMockKVStore(); + const syscall = makeSupervisorSyscall(supervisor, kv); + + expect(syscall).toHaveProperty('send'); + expect(syscall).toHaveProperty('subscribe'); + expect(syscall).toHaveProperty('resolve'); + expect(syscall).toHaveProperty('exit'); + expect(syscall).toHaveProperty('dropImports'); + expect(syscall).toHaveProperty('retireImports'); + expect(syscall).toHaveProperty('retireExports'); + expect(syscall).toHaveProperty('abandonExports'); + expect(syscall).toHaveProperty('callNow'); + expect(syscall).toHaveProperty('vatstoreGet'); + expect(syscall).toHaveProperty('vatstoreGetNextKey'); + expect(syscall).toHaveProperty('vatstoreSet'); + expect(syscall).toHaveProperty('vatstoreDelete'); + }); + + describe('syscall methods', () => { + it('handles send syscall', () => { + const supervisor = createMockSupervisor(); + const kv = createMockKVStore(); + const syscall = makeSupervisorSyscall(supervisor, kv); + + const target = 'ko1'; + const methargs: CapData = { body: '[]', slots: [] }; + const result = 'kp1'; + + syscall.send(target, methargs, result); + + expect(supervisor.executeSyscall).toHaveBeenCalledWith([ + 'send', + target, + { methargs, result }, + ]); + }); + + it('handles subscribe syscall', () => { + const supervisor = createMockSupervisor(); + const kv = createMockKVStore(); + const syscall = makeSupervisorSyscall(supervisor, kv); + + const vpid = 'kp1'; + syscall.subscribe(vpid); + + expect(supervisor.executeSyscall).toHaveBeenCalledWith([ + 'subscribe', + vpid, + ]); + }); + + it('handles resolve syscall', () => { + const supervisor = createMockSupervisor(); + const kv = createMockKVStore(); + const syscall = makeSupervisorSyscall(supervisor, kv); + + const resolutions: VatOneResolution[] = [ + ['kp1', false, { body: '[]', slots: [] }], + ]; + syscall.resolve(resolutions); + + expect(supervisor.executeSyscall).toHaveBeenCalledWith([ + 'resolve', + resolutions, + ]); + }); + + it('handles exit syscall', () => { + const supervisor = createMockSupervisor(); + const kv = createMockKVStore(); + const syscall = makeSupervisorSyscall(supervisor, kv); + + const isFailure = true; + const info: CapData = { body: '[]', slots: [] }; + syscall.exit(isFailure, info); + + expect(supervisor.executeSyscall).toHaveBeenCalledWith([ + 'exit', + isFailure, + info, + ]); + }); + + it('handles dropImports syscall', () => { + const supervisor = createMockSupervisor(); + const kv = createMockKVStore(); + const syscall = makeSupervisorSyscall(supervisor, kv); + + const vrefs = ['ko1', 'ko2']; + syscall.dropImports(vrefs); + + expect(supervisor.executeSyscall).toHaveBeenCalledWith([ + 'dropImports', + vrefs, + ]); + }); + + it('handles retireImports syscall', () => { + const supervisor = createMockSupervisor(); + const kv = createMockKVStore(); + const syscall = makeSupervisorSyscall(supervisor, kv); + + const vrefs = ['ko1', 'ko2']; + syscall.retireImports(vrefs); + + expect(supervisor.executeSyscall).toHaveBeenCalledWith([ + 'retireImports', + vrefs, + ]); + }); + + it('handles retireExports syscall', () => { + const supervisor = createMockSupervisor(); + const kv = createMockKVStore(); + const syscall = makeSupervisorSyscall(supervisor, kv); + + const vrefs = ['ko1', 'ko2']; + syscall.retireExports(vrefs); + + expect(supervisor.executeSyscall).toHaveBeenCalledWith([ + 'retireExports', + vrefs, + ]); + }); + + it('handles abandonExports syscall', () => { + const supervisor = createMockSupervisor(); + const kv = createMockKVStore(); + const syscall = makeSupervisorSyscall(supervisor, kv); + + const vrefs = ['ko1', 'ko2']; + syscall.abandonExports(vrefs); + + expect(supervisor.executeSyscall).toHaveBeenCalledWith([ + 'abandonExports', + vrefs, + ]); + }); + + it('throws on callNow syscall', () => { + const supervisor = createMockSupervisor(); + const kv = createMockKVStore(); + const syscall = makeSupervisorSyscall(supervisor, kv); + + expect(() => syscall.callNow('ko1', 'method', [])).toThrow( + 'callNow not supported (we have no devices)', + ); + }); + }); + + describe('vatstore methods', () => { + it('handles vatstoreGet', () => { + const supervisor = createMockSupervisor(); + const kv = createMockKVStore(); + const syscall = makeSupervisorSyscall(supervisor, kv); + + kv.set('test-key', 'test-value'); + const result = syscall.vatstoreGet('test-key'); + + expect(result).toBe('test-value'); + expect(kv.get).toHaveBeenCalledWith('test-key'); + }); + + it('handles vatstoreGetNextKey', () => { + const supervisor = createMockSupervisor(); + const kv = createMockKVStore(); + const syscall = makeSupervisorSyscall(supervisor, kv); + + kv.set('key1', 'value1'); + kv.set('key2', 'value2'); + const result = syscall.vatstoreGetNextKey('key1'); + + expect(result).toBe('key2'); + expect(kv.getNextKey).toHaveBeenCalledWith('key1'); + }); + + it('handles vatstoreSet', () => { + const supervisor = createMockSupervisor(); + const kv = createMockKVStore(); + const syscall = makeSupervisorSyscall(supervisor, kv); + + syscall.vatstoreSet('test-key', 'test-value'); + + expect(kv.set).toHaveBeenCalledWith('test-key', 'test-value'); + expect(kv.get('test-key')).toBe('test-value'); + }); + + it('handles vatstoreDelete', () => { + const supervisor = createMockSupervisor(); + const kv = createMockKVStore(); + const syscall = makeSupervisorSyscall(supervisor, kv); + + kv.set('test-key', 'test-value'); + syscall.vatstoreDelete('test-key'); + + expect(kv.delete).toHaveBeenCalledWith('test-key'); + expect(kv.get('test-key')).toBeUndefined(); + }); + }); + + describe('error handling', () => { + it('throws on supervisor error', () => { + const supervisor = createMockSupervisor(); + const kv = createMockKVStore(); + const syscall = makeSupervisorSyscall(supervisor, kv); + + vi.spyOn(supervisor, 'executeSyscall').mockImplementationOnce(() => { + throw new Error('supervisor error'); + }); + + expect(() => syscall.send('ko1', { body: '[]', slots: [] })).toThrow( + 'supervisor error', + ); + }); + + it('throws on syscall error result', () => { + const supervisor = createMockSupervisor(); + const kv = createMockKVStore(); + const syscall = makeSupervisorSyscall(supervisor, kv); + + vi.spyOn(supervisor, 'executeSyscall').mockImplementationOnce(() => { + return ['error', 'syscall failed']; + }); + + expect(() => syscall.send('ko1', { body: '[]', slots: [] })).toThrow( + 'syscall.send failed: syscall failed', + ); + }); + + it('throws on unknown result type', () => { + const supervisor = createMockSupervisor(); + const kv = createMockKVStore(); + const syscall = makeSupervisorSyscall(supervisor, kv); + + vi.spyOn(supervisor, 'executeSyscall').mockImplementationOnce(() => { + return ['unknown' as 'ok', null]; + }); + + expect(() => syscall.send('ko1', { body: '[]', slots: [] })).toThrow( + 'unknown syscall result type "unknown"', + ); + }); + }); + }); +}); diff --git a/packages/kernel/src/syscall.ts b/packages/kernel/src/services/syscall.ts similarity index 96% rename from packages/kernel/src/syscall.ts rename to packages/kernel/src/services/syscall.ts index f36e177f6..ede62bcf8 100644 --- a/packages/kernel/src/syscall.ts +++ b/packages/kernel/src/services/syscall.ts @@ -9,8 +9,8 @@ import type { import type { CapData } from '@endo/marshal'; import type { KVStore } from '@ocap/store'; -import type { Syscall, SyscallResult } from './ag-liveslots-types.ts'; -import type { VatSupervisor } from './VatSupervisor.ts'; +import type { Syscall, SyscallResult } from './types.ts'; +import type { VatSupervisor } from '../VatSupervisor.ts'; /** * This returns a function that is provided to liveslots as the 'syscall' @@ -76,7 +76,6 @@ function makeSupervisorSyscall( retireImports: (vrefs: string[]) => doSyscall(['retireImports', vrefs]), retireExports: (vrefs: string[]) => doSyscall(['retireExports', vrefs]), abandonExports: (vrefs: string[]) => doSyscall(['abandonExports', vrefs]), - callNow: (_target: string, _method: string, _args: unknown[]) => { throw Error(`callNow not supported (we have no devices)`); }, diff --git a/packages/kernel/src/ag-liveslots-types.ts b/packages/kernel/src/services/types.ts similarity index 99% rename from packages/kernel/src/ag-liveslots-types.ts rename to packages/kernel/src/services/types.ts index 1200cdbfb..cf5c89ded 100644 --- a/packages/kernel/src/ag-liveslots-types.ts +++ b/packages/kernel/src/services/types.ts @@ -12,6 +12,7 @@ export type DispatchFn = (vdo: VatDeliveryObject) => Promise; export type LiveSlots = { dispatch: DispatchFn; }; + export type Syscall = { send: ( target: string, @@ -31,6 +32,7 @@ export type Syscall = { vatstoreSet: (key: string, value: string) => void; vatstoreDelete: (key: string) => void; }; + export type GCTools = { // eslint-disable-next-line @typescript-eslint/naming-convention WeakRef: WeakRefConstructor; @@ -40,6 +42,7 @@ export type GCTools = { gcAndFinalize: () => Promise; meterControl: MeterControl; }; + export type MakeLiveSlotsFn = ( syscall: Syscall, forVatId: string, diff --git a/packages/kernel/src/store/kernel-store.test.ts b/packages/kernel/src/store/kernel-store.test.ts index 4b407faf7..83799c1ed 100644 --- a/packages/kernel/src/store/kernel-store.test.ts +++ b/packages/kernel/src/store/kernel-store.test.ts @@ -54,10 +54,15 @@ describe('kernel store', () => { const ks = makeKernelStore(mockKernelDatabase); expect(Object.keys(ks).sort()).toStrictEqual([ 'addClistEntry', + 'addGCActions', 'addPromiseSubscriber', 'allocateErefForKref', 'clear', + 'clearReachableFlag', + 'createStoredQueue', 'decRefCount', + 'decrementRefCount', + 'deleteClistEntry', 'deleteKernelObject', 'deleteKernelPromise', 'dequeueRun', @@ -66,22 +71,33 @@ describe('kernel store', () => { 'erefToKref', 'forgetEref', 'forgetKref', + 'getGCActions', 'getKernelPromise', 'getKernelPromiseMessageQueue', 'getNextRemoteId', 'getNextVatId', + 'getObjectRefCount', 'getOwner', + 'getQueueLength', + 'getReachableFlag', 'getRefCount', + 'hasCListEntry', 'incRefCount', + 'incrementRefCount', 'initEndpoint', 'initKernelObject', 'initKernelPromise', + 'kernelRefExists', 'krefToEref', 'kv', 'makeVatStore', + 'nextReapAction', 'reset', 'resolveKernelPromise', 'runQueueLength', + 'scheduleReap', + 'setGCActions', + 'setObjectRefCount', 'setPromiseDecider', ]); }); @@ -102,17 +118,41 @@ describe('kernel store', () => { const ko1Owner = 'v47'; const ko2Owner = 'r23'; expect(ks.initKernelObject(ko1Owner)).toBe('ko1'); - expect(ks.getRefCount('ko1')).toBe(1); - expect(ks.incRefCount('ko1')).toBe(2); - ks.incRefCount('ko1'); - expect(ks.getRefCount('ko1')).toBe(3); - expect(ks.decRefCount('ko1')).toBe(2); - ks.decRefCount('ko1'); - ks.decRefCount('ko1'); - expect(ks.getRefCount('ko1')).toBe(0); + + // Check that the object is initialized with reachable=1, recognizable=1 + const refCounts = ks.getObjectRefCount('ko1'); + expect(refCounts.reachable).toBe(1); + expect(refCounts.recognizable).toBe(1); + + // Increment the reference count + ks.incrementRefCount('ko1', {}); + expect(ks.getObjectRefCount('ko1').reachable).toBe(2); + expect(ks.getObjectRefCount('ko1').recognizable).toBe(2); + + // Increment again + ks.incrementRefCount('ko1', {}); + expect(ks.getObjectRefCount('ko1').reachable).toBe(3); + expect(ks.getObjectRefCount('ko1').recognizable).toBe(3); + + // Decrement + ks.decrementRefCount('ko1', {}); + 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', {}); + expect(ks.getObjectRefCount('ko1').reachable).toBe(0); + expect(ks.getObjectRefCount('ko1').recognizable).toBe(0); + + // Create another object expect(ks.initKernelObject(ko2Owner)).toBe('ko2'); + + // Check owners expect(ks.getOwner('ko1')).toBe(ko1Owner); expect(ks.getOwner('ko2')).toBe(ko2Owner); + + // Delete an object ks.deleteKernelObject('ko1'); expect(() => ks.getOwner('ko1')).toThrow('unknown kernel object ko1'); expect(() => ks.getOwner('ko99')).toThrow('unknown kernel object ko99'); @@ -176,27 +216,44 @@ describe('kernel store', () => { }); it('manages clists', () => { const ks = makeKernelStore(mockKernelDatabase); - 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'); - expect(ks.krefToEref('v2', 'ko42')).toBe('o-63'); - expect(ks.erefToKref('v2', 'o-63')).toBe('ko42'); - expect(ks.krefToEref('v2', 'ko51')).toBe('o-74'); - expect(ks.erefToKref('v2', 'o-74')).toBe('ko51'); - expect(ks.krefToEref('v2', 'kp60')).toBe('p+85'); - expect(ks.erefToKref('v2', 'p+85')).toBe('kp60'); - expect(ks.krefToEref('r7', 'ko42')).toBe('ro+11'); - expect(ks.erefToKref('r7', 'ro+11')).toBe('ko42'); - expect(ks.krefToEref('r7', 'kp61')).toBe('rp-99'); - expect(ks.erefToKref('r7', 'rp-99')).toBe('kp61'); - ks.forgetKref('v2', 'ko42'); - expect(ks.krefToEref('v2', 'ko42')).toBeUndefined(); + + // Create objects first to ensure they exist in the kernel + const ko42 = ks.initKernelObject('v2'); + const ko51 = ks.initKernelObject('v2'); + const [kp60] = ks.initKernelPromise(); + 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'); + + // Verify mappings + expect(ks.krefToEref('v2', ko42)).toBe('o-63'); + expect(ks.erefToKref('v2', 'o-63')).toBe(ko42); + expect(ks.krefToEref('v2', ko51)).toBe('o-74'); + expect(ks.erefToKref('v2', 'o-74')).toBe(ko51); + expect(ks.krefToEref('v2', kp60)).toBe('p+85'); + expect(ks.erefToKref('v2', 'p+85')).toBe(kp60); + expect(ks.krefToEref('r7', ko42)).toBe('ro+11'); + expect(ks.erefToKref('r7', 'ro+11')).toBe(ko42); + expect(ks.krefToEref('r7', kp61)).toBe('rp-99'); + expect(ks.erefToKref('r7', 'rp-99')).toBe(kp61); + + // Test forgetting entries + ks.forgetKref('v2', ko42); + expect(ks.krefToEref('v2', ko42)).toBeUndefined(); expect(ks.erefToKref('v2', 'o-63')).toBeUndefined(); + ks.forgetEref('r7', 'rp-99'); - expect(ks.krefToEref('r7', 'kp61')).toBeUndefined(); + expect(ks.krefToEref('r7', kp61)).toBeUndefined(); expect(ks.erefToKref('r7', 'rp-99')).toBeUndefined(); + + // Verify C-list entry existence + expect(ks.hasCListEntry('r7', ko42)).toBe(true); + expect(ks.hasCListEntry('v2', ko42)).toBe(false); // We forgot this one }); }); diff --git a/packages/kernel/src/store/kernel-store.ts b/packages/kernel/src/store/kernel-store.ts index 106ecf7e6..2d059fe3c 100644 --- a/packages/kernel/src/store/kernel-store.ts +++ b/packages/kernel/src/store/kernel-store.ts @@ -58,6 +58,12 @@ import { Fail } from '@endo/errors'; import type { CapData } from '@endo/marshal'; import type { KVStore, VatStore, KernelDatabase } from '@ocap/store'; +import { insistKernelType } from './utils/kernel-slots.ts'; +import { parseRef } from './utils/parse-ref.ts'; +import { + buildReachableAndVatSlot, + parseReachableAndVatSlot, +} from './utils/reachable.ts'; import type { VatId, RemoteId, @@ -67,8 +73,10 @@ import type { RunQueueItem, PromiseState, KernelPromise, + RunQueueItemBringOutYourDead, + GCAction, } from '../types.ts'; -import { insistVatId } from '../types.ts'; +import { insistGCActionType, insistVatId, RunQueueItemType } from '../types.ts'; type StoredValue = { get(): string | undefined; @@ -82,13 +90,6 @@ type StoredQueue = { delete(): void; }; -type RefParts = { - context: 'kernel' | 'vat' | 'remote'; - direction?: 'export' | 'import'; - isPromise: boolean; - index: string; -}; - /** * Test if a KRef designates a promise. * @@ -100,57 +101,6 @@ export function isPromiseRef(kref: KRef): boolean { return kref[1] === 'p'; } -/** - * Parse an alleged ref string into its components. - * - * @param ref - The string to be parsed. - * - * @returns an object with all of the ref string components as individual properties. - */ -export function parseRef(ref: string): RefParts { - let context; - let typeIdx = 1; - - switch (ref[0]) { - case 'k': - context = 'kernel'; - break; - case 'o': - case 'p': - typeIdx = 0; - context = 'vat'; - break; - case 'r': - context = 'remote'; - break; - case undefined: - default: - Fail`invalid reference context ${ref[0]}`; - } - if (ref[typeIdx] !== 'p' && ref[typeIdx] !== 'o') { - Fail`invalid reference type ${ref[typeIdx]}`; - } - const isPromise = ref[typeIdx] === 'p'; - let direction; - let index; - if (context === 'kernel') { - index = ref.slice(2); - } else { - const dirIdx = typeIdx + 1; - if (ref[dirIdx] !== '+' && ref[dirIdx] !== '-') { - Fail`invalid reference direction ${ref[dirIdx]}`; - } - direction = ref[dirIdx] === '+' ? 'export' : 'import'; - index = ref.slice(dirIdx + 1); - } - return { - context, - direction, - isPromise, - index, - } as RefParts; -} - /** * Create a new KernelStore object wrapped around a raw kernel database. The * resulting object provides a variety of operations for accessing various @@ -183,6 +133,21 @@ export function makeKernelStore(kdb: KernelDatabase) { /** Counter for allocating kernel promise IDs */ let nextPromiseId = provideCachedStoredValue('nextPromiseId', '1'); + // As refcounts are decremented, we accumulate a set of krefs for which + // action might need to be taken: + // * promises which are now resolved and unreferenced can be deleted + // * objects which are no longer reachable: export can be dropped + // * objects which are no longer recognizable: export can be retired + // This set is ephemeral: it lives in RAM, grows as deliveries and syscalls + // cause decrefs, and will be harvested by processRefcounts(). This needs to be + // called in the same transaction window as the syscalls/etc which prompted + // the change, else removals might be lost (not performed during the next + // replay). + const maybeFreeKrefs = new Set(); + // Garbage collection + let gcActions = provideCachedStoredValue('gcActions', '[]'); + let reapQueue = provideCachedStoredValue('reapQueue', '[]'); + /** * Provide a stored value object for which we keep an in-memory cache. We only * touch persistent storage if the value hasn't ever been read of if it is @@ -497,7 +462,7 @@ export function makeKernelStore(kdb: KernelDatabase) { function initKernelObject(owner: EndpointId): KRef { const koId = getNextObjectId(); kv.set(`${koId}.owner`, owner); - kv.set(refCountKey(koId), '1'); + setObjectRefCount(koId, { reachable: 1, recognizable: 1 }); return koId; } @@ -695,7 +660,7 @@ export function makeKernelStore(kdb: KernelDatabase) { * if there is no such mapping. */ function erefToKref(endpointId: EndpointId, eref: ERef): KRef | undefined { - return kv.get(`cle.${endpointId}.${eref}`); + return kv.get(getSlotKey(endpointId, eref)); } /** @@ -707,7 +672,13 @@ export function makeKernelStore(kdb: KernelDatabase) { * there is no such mapping. */ function krefToEref(endpointId: EndpointId, kref: KRef): ERef | undefined { - return kv.get(`clk.${endpointId}.${kref}`); + const key = getSlotKey(endpointId, kref); + const data = kv.get(key); + if (!data) { + return undefined; + } + const { vatSlot } = parseReachableAndVatSlot(data); + return vatSlot; } /** @@ -720,8 +691,8 @@ export function makeKernelStore(kdb: KernelDatabase) { * @param eref - The ERef. */ function addClistEntry(endpointId: EndpointId, kref: KRef, eref: ERef): void { - kv.set(`clk.${endpointId}.${kref}`, eref); - kv.set(`cle.${endpointId}.${eref}`, kref); + kv.set(getSlotKey(endpointId, kref), buildReachableAndVatSlot(true, eref)); + kv.set(getSlotKey(endpointId, eref), kref); } /** @@ -736,8 +707,17 @@ export function makeKernelStore(kdb: KernelDatabase) { kref: KRef, eref: ERef, ): void { - kv.delete(`clk.${endpointId}.${kref}`); - kv.delete(`cle.${endpointId}.${eref}`); + const kernelKey = getSlotKey(endpointId, kref); + const vatKey = getSlotKey(endpointId, eref); + assert(kv.get(kernelKey)); + clearReachableFlag(endpointId, kref); + const { direction } = parseRef(eref); + decrementRefCount(kref, { + isExport: direction === 'export', + onlyRecognizable: true, + }); + kv.delete(kernelKey); + kv.delete(vatKey); } /** @@ -794,6 +774,282 @@ export function makeKernelStore(kdb: KernelDatabase) { nextRemoteId = provideCachedStoredValue('nextRemoteId', '1'); nextObjectId = provideCachedStoredValue('nextObjectId', '1'); nextPromiseId = provideCachedStoredValue('nextPromiseId', '1'); + maybeFreeKrefs.clear(); + gcActions = provideCachedStoredValue('gcActions', '[]'); + reapQueue = provideCachedStoredValue('reapQueue', '[]'); + } + + /** + * Check if a kernel object exists in the kernel's persistent state. + * + * @param kref - The KRef of the kernel object in question. + * @returns True if the kernel object exists, false otherwise. + */ + function kernelRefExists(kref: KRef): boolean { + return Boolean(kv.get(refCountKey(kref))); + } + + /** + * Get the reference counts for a kernel object + * + * @param kref - The KRef of the object of interest. + * @returns The reference counts for the object. + */ + function getObjectRefCount(kref: KRef): { + reachable: number; + recognizable: number; + } { + const data = kv.get(refCountKey(kref)); + if (!data) { + return { reachable: 0, recognizable: 0 }; + } + const [reachable = 0, recognizable = 0] = data.split(',').map(Number); + reachable <= recognizable || + Fail`refMismatch(get) ${kref} ${reachable},${recognizable}`; + return { reachable, recognizable }; + } + + /** + * Set the reference counts for a kernel object + * + * @param kref - The KRef of the object of interest. + * @param counts - The reference counts to set. + * @param counts.reachable - The reachable reference count. + * @param counts.recognizable - The recognizable reference count. + */ + function setObjectRefCount( + kref: KRef, + counts: { reachable: number; recognizable: number }, + ): void { + const { reachable, recognizable } = counts; + assert.typeof(reachable, 'number'); + assert.typeof(recognizable, 'number'); + (reachable >= 0 && recognizable >= 0) || + Fail`${kref} underflow ${reachable},${recognizable}`; + reachable <= recognizable || + Fail`refMismatch(set) ${kref} ${reachable},${recognizable}`; + kv.set(refCountKey(kref), `${reachable},${recognizable}`); + } + + /** + * Get the set of GC actions to perform. + * + * @returns The set of GC actions to perform. + */ + function getGCActions(): Set { + return new Set(JSON.parse(gcActions.get() ?? '[]')); + } + + /** + * Set the set of GC actions to perform. + * + * @param actions - The set of GC actions to perform. + */ + function setGCActions(actions: Set): void { + const a = Array.from(actions); + a.sort(); + gcActions.set(JSON.stringify(a)); + } + + /** + * Add a new GC action to the set of GC actions to perform. + * + * @param newActions - The new GC action to add. + */ + function addGCActions(newActions: GCAction[]): void { + const actions = getGCActions(); + for (const action of newActions) { + assert.typeof(action, 'string', 'addGCActions given bad action'); + const [vatId, type, kref] = action.split(' '); + insistVatId(vatId); + insistGCActionType(type); + insistKernelType('object', kref); + actions.add(action); + } + setGCActions(actions); + } + + /** + * Check if a kernel object is reachable. + * + * @param endpointId - The endpoint for which the reachable flag is being checked. + * @param kref - The kref. + * @returns True if the kernel object is reachable, false otherwise. + */ + function getReachableFlag(endpointId: EndpointId, kref: KRef): boolean { + const key = getSlotKey(endpointId, kref); + const data = kv.getRequired(key); + const { isReachable } = parseReachableAndVatSlot(data); + return isReachable; + } + + /** + * Clear the reachable flag for a given endpoint and kref. + * + * @param endpointId - The endpoint for which the reachable flag is being cleared. + * @param kref - The kref. + */ + function clearReachableFlag(endpointId: EndpointId, kref: KRef): void { + const key = getSlotKey(endpointId, kref); + const { isReachable, vatSlot } = parseReachableAndVatSlot( + kv.getRequired(key), + ); + kv.set(key, buildReachableAndVatSlot(false, vatSlot)); + const { direction, isPromise } = parseRef(vatSlot); + // decrement 'reachable' part of refcount, but only for object imports + if ( + isReachable && + !isPromise && + direction === 'import' && + kernelRefExists(kref) + ) { + const counts = getObjectRefCount(kref); + counts.reachable -= 1; + setObjectRefCount(kref, counts); + if (counts.reachable === 0) { + maybeFreeKrefs.add(kref); + } + } + } + + /** + * Schedule a vat for reaping. + * + * @param vatId - The vat to schedule for reaping. + */ + function scheduleReap(vatId: VatId): void { + const queue = JSON.parse(reapQueue.get() ?? '[]'); + if (!queue.includes(vatId)) { + queue.push(vatId); + reapQueue.set(JSON.stringify(queue)); + } + } + + /** + * Get the next reap action. + * + * @returns The next reap action, or undefined if the queue is empty. + */ + function nextReapAction(): RunQueueItemBringOutYourDead | undefined { + const queue = JSON.parse(reapQueue.get() ?? '[]'); + if (queue.length > 0) { + const vatId = queue.shift(); + reapQueue.set(JSON.stringify(queue)); + return harden({ type: RunQueueItemType.bringOutYourDead, vatId }); + } + return undefined; + } + + /** + * Get the key for the reachable flag and vatSlot for a given endpoint and kref. + * + * @param endpointId - The endpoint for which the reachable flag is being set. + * @param kref - The kref. + * @returns The key for the reachable flag and vatSlot. + */ + function getSlotKey(endpointId: EndpointId, kref: KRef): string { + return `${endpointId}.c.${kref}`; + } + + /** + * Test if there's a c-list entry for some slot. + * + * @param endpointId - The endpoint of interest + * @param slot - The slot of interest + * @returns true iff this vat has a c-list entry mapping for `slot`. + */ + function hasCListEntry(endpointId: EndpointId, slot: string): boolean { + return kv.get(getSlotKey(endpointId, slot)) !== undefined; + } + + /** + * Increment the reference count associated with some kernel object. + * + * We track references to promises and objects, but not devices. Promises + * have only a "reachable" count, whereas objects track both "reachable" + * and "recognizable" counts. + * + * @param kref - The kernel slot whose refcount is to be incremented. + * @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, + { + isExport = false, + onlyRecognizable = false, + }: { isExport?: boolean; onlyRecognizable?: boolean }, + ): void { + kref || Fail`incrementRefCount called with empty kref`; + + const { isPromise } = parseRef(kref); + if (isPromise) { + const refCount = Number(kv.get(refCountKey(kref))) + 1; + kv.set(refCountKey(kref), `${refCount}`); + return; + } + + // If `isExport` the reference comes from a clist export, which counts for promises but not objects + if (isExport) { + return; + } + + const counts = getObjectRefCount(kref); + if (!onlyRecognizable) { + counts.reachable += 1; + } + counts.recognizable += 1; + setObjectRefCount(kref, counts); + } + + /** + * Decrement the reference count associated with some kernel object. + * + * @param kref - The kernel slot whose refcount is to be decremented. + * @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. + * @returns True if the reference count has been decremented to zero, false if it is still non-zero. + * @throws if this tries to decrement the reference count below zero. + */ + function decrementRefCount( + kref: KRef, + { + isExport = false, + onlyRecognizable = false, + }: { isExport?: boolean; onlyRecognizable?: boolean }, + ): boolean { + kref || Fail`decrementRefCount called with empty kref`; + + const { isPromise } = parseRef(kref); + if (isPromise) { + let refCount = Number(kv.get(refCountKey(kref))); + refCount > 0 || Fail`refCount underflow ${kref}`; + refCount -= 1; + kv.set(refCountKey(kref), `${refCount}`); + if (refCount === 0) { + maybeFreeKrefs.add(kref); + return true; + } + return false; + } + + if (isExport || !kernelRefExists(kref)) { + return false; + } + + const counts = getObjectRefCount(kref); + if (!onlyRecognizable) { + counts.reachable -= 1; + } + counts.recognizable -= 1; + if (!counts.reachable || !counts.recognizable) { + maybeFreeKrefs.add(kref); + } + setObjectRefCount(kref, counts); + + return false; } return harden({ @@ -827,6 +1083,22 @@ export function makeKernelStore(kdb: KernelDatabase) { makeVatStore, reset, kv, + kernelRefExists, + hasCListEntry, + getReachableFlag, + clearReachableFlag, + getObjectRefCount, + setObjectRefCount, + getGCActions, + setGCActions, + addGCActions, + nextReapAction, + scheduleReap, + incrementRefCount, + decrementRefCount, + createStoredQueue, + deleteClistEntry, + getQueueLength, }); } diff --git a/packages/kernel/src/store/utils/kernel-slots.test.ts b/packages/kernel/src/store/utils/kernel-slots.test.ts new file mode 100644 index 000000000..1d37aab67 --- /dev/null +++ b/packages/kernel/src/store/utils/kernel-slots.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'vitest'; + +import { + parseKernelSlot, + makeKernelSlot, + insistKernelType, +} from './kernel-slots.ts'; + +describe('kernel-slots', () => { + describe('parseKernelSlot', () => { + it('parses object slots correctly', () => { + const result = parseKernelSlot('ko123'); + expect(result).toStrictEqual({ + type: 'object', + id: '123', + }); + }); + + it('parses promise slots correctly', () => { + const result = parseKernelSlot('kp456'); + expect(result).toStrictEqual({ + type: 'promise', + id: '456', + }); + }); + + it('throws for invalid slot format', () => { + expect(() => parseKernelSlot('invalid')).toThrow( + 'invalid kernelSlot "invalid"', + ); + expect(() => parseKernelSlot('k123')).toThrow( + 'invalid kernelSlot "k123"', + ); + expect(() => parseKernelSlot('kx123')).toThrow( + 'invalid kernelSlot "kx123"', + ); + }); + + it('throws for non-string input', () => { + // @ts-expect-error Testing invalid input + expect(() => parseKernelSlot(123)).toThrow('123 must be a string'); + // @ts-expect-error Testing invalid input + expect(() => parseKernelSlot(null)).toThrow('null must be a string'); + // @ts-expect-error Testing invalid input + expect(() => parseKernelSlot(undefined)).toThrow( + '"[undefined]" must be a string', + ); + // @ts-expect-error Testing invalid input + expect(() => parseKernelSlot({})).toThrow('{} must be a string'); + }); + }); + + describe('makeKernelSlot', () => { + it('creates object slots correctly', () => { + expect(makeKernelSlot('object', '123')).toBe('ko123'); + }); + + it('creates promise slots correctly', () => { + expect(makeKernelSlot('promise', '456')).toBe('kp456'); + }); + + it('throws for invalid type', () => { + // @ts-expect-error Testing invalid input + expect(() => makeKernelSlot('invalid', '123')).toThrow( + 'unknown type "invalid"', + ); + }); + }); + + describe('insistKernelType', () => { + it('passes for correct object type', () => { + expect(() => insistKernelType('object', 'ko123')).not.toThrow(); + }); + + it('passes for correct promise type', () => { + expect(() => insistKernelType('promise', 'kp456')).not.toThrow(); + }); + + it('throws for mismatched type', () => { + expect(() => insistKernelType('object', 'kp123')).toThrow( + 'kernelSlot "kp123" is not of type "object"', + ); + expect(() => insistKernelType('promise', 'ko456')).toThrow( + 'kernelSlot "ko456" is not of type "promise"', + ); + }); + + it('throws for invalid slot format', () => { + expect(() => insistKernelType('object', 'invalid')).toThrow( + 'invalid kernelSlot "invalid"', + ); + expect(() => insistKernelType('promise', 'k123')).toThrow( + 'invalid kernelSlot "k123"', + ); + }); + + it('throws for undefined input', () => { + expect(() => insistKernelType('object', undefined)).toThrow( + 'kernelSlot is undefined', + ); + }); + }); + + describe('integration', () => { + it('can round-trip object slots', () => { + const slot = makeKernelSlot('object', '123'); + const parsed = parseKernelSlot(slot); + expect(parsed.type).toBe('object'); + expect(parsed.id).toBe('123'); + expect(makeKernelSlot(parsed.type, parsed.id)).toBe(slot); + }); + + it('can round-trip promise slots', () => { + const slot = makeKernelSlot('promise', '456'); + const parsed = parseKernelSlot(slot); + expect(parsed.type).toBe('promise'); + expect(parsed.id).toBe('456'); + expect(makeKernelSlot(parsed.type, parsed.id)).toBe(slot); + }); + + it('insistKernelType works with makeKernelSlot', () => { + const objectSlot = makeKernelSlot('object', '123'); + const promiseSlot = makeKernelSlot('promise', '456'); + + expect(() => insistKernelType('object', objectSlot)).not.toThrow(); + expect(() => insistKernelType('promise', promiseSlot)).not.toThrow(); + expect(() => insistKernelType('object', promiseSlot)).toThrow( + 'kernelSlot "kp456" is not of type "object"', + ); + expect(() => insistKernelType('promise', objectSlot)).toThrow( + 'kernelSlot "ko123" is not of type "promise"', + ); + }); + }); +}); diff --git a/packages/kernel/src/store/utils/kernel-slots.ts b/packages/kernel/src/store/utils/kernel-slots.ts new file mode 100644 index 000000000..341288b88 --- /dev/null +++ b/packages/kernel/src/store/utils/kernel-slots.ts @@ -0,0 +1,76 @@ +import { assert, Fail } from '../../utils/assert.ts'; + +// Object/promise references (in the kernel) contain a two-tuple of (type, +// index). All object references point to entries in the kernel Object +// Table, which records the vat that owns the actual object. In that vat, +// the object reference will be expressed as 'o+NN', and the NN was +// allocated by that vat when they first exported the reference into the +// kernel. In all other vats, if/when they are given a reference to this +// object, they will receive 'o-NN', with the NN allocated by the kernel +// clist for the recipient vat. + +type KernelSlotType = 'object' | 'promise'; + +/** + * Parse a kernel slot reference string into a kernel slot object. + * + * @param slot The string to be parsed, as described above. + * @returns kernel slot object corresponding to the parameter. + * @throws if the given string is syntactically incorrect. + */ +export function parseKernelSlot(slot: string): { + type: KernelSlotType; + id: string; +} { + assert.typeof(slot, 'string'); + let type: KernelSlotType; + let idSuffix: string; + if (slot.startsWith('ko')) { + type = 'object'; + idSuffix = slot.slice(2); + } else if (slot.startsWith('kp')) { + type = 'promise'; + idSuffix = slot.slice(2); + } else { + throw Fail`invalid kernelSlot ${slot}`; + } + const id = idSuffix; + return { type, id }; +} + +/** + * Generate a kernel slot reference string given a type and id. + * + * @param type - The kernel slot type desired, a string. + * @param id - The id, a number. + * @returns the corresponding kernel slot reference string. + * @throws if type is not one of the above known types. + */ +export function makeKernelSlot(type: KernelSlotType, id: string): string { + if (type === 'object') { + return `ko${id}`; + } + if (type === 'promise') { + return `kp${id}`; + } + throw Fail`unknown type ${type}`; +} + +/** + * Assert function to ensure that a kernel slot reference string refers to a + * slot of a given type. + * + * @param type - The kernel slot type desired, a string. + * @param kernelSlot - The kernel slot reference string being tested + * @throws if kernelSlot is not of the given type or is malformed. + */ +export function insistKernelType( + type: KernelSlotType, + kernelSlot: string | undefined, +): asserts kernelSlot is KernelSlotType { + if (kernelSlot === undefined) { + throw Fail`kernelSlot is undefined`; + } + type === parseKernelSlot(kernelSlot).type || + Fail`kernelSlot ${kernelSlot} is not of type ${type}`; +} diff --git a/packages/kernel/src/store/utils/parse-ref.test.ts b/packages/kernel/src/store/utils/parse-ref.test.ts new file mode 100644 index 000000000..7e4745154 --- /dev/null +++ b/packages/kernel/src/store/utils/parse-ref.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect } from 'vitest'; + +import { parseRef } from './parse-ref.ts'; + +describe('parse-ref', () => { + describe('parseRef', () => { + describe('valid references', () => { + it.each([ + // Kernel references + [ + 'ko123', + { + context: 'kernel', + isPromise: false, + index: '123', + direction: undefined, + }, + ], + [ + 'kp456', + { + context: 'kernel', + isPromise: true, + index: '456', + direction: undefined, + }, + ], + + // Vat references + [ + 'o+789', + { + context: 'vat', + direction: 'export', + isPromise: false, + index: '789', + }, + ], + [ + 'o-321', + { + context: 'vat', + direction: 'import', + isPromise: false, + index: '321', + }, + ], + [ + 'p+654', + { + context: 'vat', + direction: 'export', + isPromise: true, + index: '654', + }, + ], + [ + 'p-987', + { + context: 'vat', + direction: 'import', + isPromise: true, + index: '987', + }, + ], + + // Remote references + [ + 'ro+111', + { + context: 'remote', + direction: 'export', + isPromise: false, + index: '111', + }, + ], + [ + 'ro-222', + { + context: 'remote', + direction: 'import', + isPromise: false, + index: '222', + }, + ], + [ + 'rp+333', + { + context: 'remote', + direction: 'export', + isPromise: true, + index: '333', + }, + ], + [ + 'rp-444', + { + context: 'remote', + direction: 'import', + isPromise: true, + index: '444', + }, + ], + + // Edge cases + [ + 'o+', + { context: 'vat', direction: 'export', isPromise: false, index: '' }, + ], + [ + 'kpabc', + { + context: 'kernel', + isPromise: true, + index: 'abc', + direction: undefined, + }, + ], + [ + 'o+', + { context: 'vat', direction: 'export', isPromise: false, index: '' }, + ], + ])('parses %s correctly', (ref, expected) => { + expect(parseRef(ref)).toStrictEqual(expected); + }); + }); + + describe('error cases', () => { + it.each([ + ['xo+123', 'invalid reference context "x"'], + ['zo-456', 'invalid reference context "z"'], + ['kx123', 'invalid reference type "x"'], + ['rx+123', 'invalid reference type "x"'], + ['o*123', 'invalid reference direction "*"'], + ['p=456', 'invalid reference direction "="'], + ['ro?789', 'invalid reference direction "?"'], + ['rp!321', 'invalid reference direction "!"'], + ['', 'invalid reference context "[undefined]"'], + ])('throws for invalid reference %s', (ref, errorMessage) => { + expect(() => parseRef(ref)).toThrow(errorMessage); + }); + }); + }); + + describe('reference patterns', () => { + describe('context identification', () => { + it.each([ + ['ko1', 'kernel'], + ['kp2', 'kernel'], + ['o+3', 'vat'], + ['o-4', 'vat'], + ['p+5', 'vat'], + ['p-6', 'vat'], + ['ro+7', 'remote'], + ['ro-8', 'remote'], + ['rp+9', 'remote'], + ['rp-10', 'remote'], + ])('identifies %s as %s context', (ref, expectedContext) => { + expect(parseRef(ref).context).toBe(expectedContext); + }); + }); + + describe('promise vs object identification', () => { + it.each([ + // Object references + ['ko1', false], + ['o+3', false], + ['o-4', false], + ['ro+7', false], + ['ro-8', false], + + // Promise references + ['kp2', true], + ['p+5', true], + ['p-6', true], + ['rp+9', true], + ['rp-10', true], + ])('identifies %s as %s for isPromise', (ref, isPromise) => { + expect(parseRef(ref).isPromise).toBe(isPromise); + }); + }); + + describe('direction identification', () => { + it.each([ + // Export references + ['o+3', 'export'], + ['p+5', 'export'], + ['ro+7', 'export'], + ['rp+9', 'export'], + + // Import references + ['o-4', 'import'], + ['p-6', 'import'], + ['ro-8', 'import'], + ['rp-10', 'import'], + ])('identifies %s as %s direction', (ref, expectedDirection) => { + expect(parseRef(ref).direction).toBe(expectedDirection); + }); + + it.each(['ko1', 'kp2'])('kernel reference %s has no direction', (ref) => { + expect(parseRef(ref).direction).toBeUndefined(); + }); + }); + }); +}); diff --git a/packages/kernel/src/store/utils/parse-ref.ts b/packages/kernel/src/store/utils/parse-ref.ts new file mode 100644 index 000000000..691fb96fc --- /dev/null +++ b/packages/kernel/src/store/utils/parse-ref.ts @@ -0,0 +1,59 @@ +import { Fail } from '@endo/errors'; + +type RefParts = { + context: 'kernel' | 'vat' | 'remote'; + direction?: 'export' | 'import'; + isPromise: boolean; + index: string; +}; + +/** + * Parse an alleged ref string into its components. + * + * @param ref - The string to be parsed. + * + * @returns an object with all of the ref string components as individual properties. + */ +export function parseRef(ref: string): RefParts { + let context; + let typeIdx = 1; + + switch (ref[0]) { + case 'k': + context = 'kernel'; + break; + case 'o': + case 'p': + typeIdx = 0; + context = 'vat'; + break; + case 'r': + context = 'remote'; + break; + case undefined: + default: + Fail`invalid reference context ${ref[0]}`; + } + if (ref[typeIdx] !== 'p' && ref[typeIdx] !== 'o') { + Fail`invalid reference type ${ref[typeIdx]}`; + } + const isPromise = ref[typeIdx] === 'p'; + let direction; + let index; + if (context === 'kernel') { + index = ref.slice(2); + } else { + const dirIdx = typeIdx + 1; + if (ref[dirIdx] !== '+' && ref[dirIdx] !== '-') { + Fail`invalid reference direction ${ref[dirIdx]}`; + } + direction = ref[dirIdx] === '+' ? 'export' : 'import'; + index = ref.slice(dirIdx + 1); + } + return { + context, + direction, + isPromise, + index, + } as RefParts; +} diff --git a/packages/kernel/src/store/utils/promise-ref.test.ts b/packages/kernel/src/store/utils/promise-ref.test.ts new file mode 100644 index 000000000..5dda9e82b --- /dev/null +++ b/packages/kernel/src/store/utils/promise-ref.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; + +import { isPromiseRef } from './promise-ref.ts'; + +describe('promise-ref', () => { + describe('isPromiseRef', () => { + it.each([ + // References with 'p' as the second character (should return true) + ['kp1', true, 'kernel promise'], + ['rp+7', true, 'remote promise'], + ['xpyz', true, 'any string with p as second character'], + // References without 'p' as the second character (should return false) + ['ko2', false, 'kernel object'], + ['p+42', false, 'vat promise (p in first position)'], + ['o+3', false, 'vat object'], + ['ro+5', false, 'remote object'], + ['abc', false, 'string without p as second character'], + ['a', false, 'single character string'], + ])('returns %s for %s', (ref, expected) => { + expect(isPromiseRef(ref)).toBe(expected); + }); + }); +}); diff --git a/packages/kernel/src/store/utils/promise-ref.ts b/packages/kernel/src/store/utils/promise-ref.ts new file mode 100644 index 000000000..cae418daa --- /dev/null +++ b/packages/kernel/src/store/utils/promise-ref.ts @@ -0,0 +1,12 @@ +import type { KRef } from '../../types.ts'; + +/** + * Test if a KRef designates a promise. + * + * @param kref - The KRef to test. + * + * @returns true iff the given KRef references a promise. + */ +export function isPromiseRef(kref: KRef): boolean { + return kref[1] === 'p'; +} diff --git a/packages/kernel/src/store/utils/reachable.test.ts b/packages/kernel/src/store/utils/reachable.test.ts new file mode 100644 index 000000000..15fb91426 --- /dev/null +++ b/packages/kernel/src/store/utils/reachable.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest'; + +import { + parseReachableAndVatSlot, + buildReachableAndVatSlot, +} from './reachable.ts'; + +describe('reachable', () => { + describe('parseReachableAndVatSlot', () => { + it('parses reachable flag correctly', () => { + expect(parseReachableAndVatSlot('R o+123')).toStrictEqual({ + isReachable: true, + vatSlot: 'o+123', + }); + + expect(parseReachableAndVatSlot('_ o+123')).toStrictEqual({ + isReachable: false, + vatSlot: 'o+123', + }); + }); + + it('works with different vat slot types', () => { + // Object slots + expect(parseReachableAndVatSlot('R o+123')).toStrictEqual({ + isReachable: true, + vatSlot: 'o+123', + }); + + expect(parseReachableAndVatSlot('_ o-456')).toStrictEqual({ + isReachable: false, + vatSlot: 'o-456', + }); + + // Promise slots + expect(parseReachableAndVatSlot('R p+789')).toStrictEqual({ + isReachable: true, + vatSlot: 'p+789', + }); + + expect(parseReachableAndVatSlot('_ p-012')).toStrictEqual({ + isReachable: false, + vatSlot: 'p-012', + }); + }); + + it('throws for invalid flag format', () => { + expect(() => parseReachableAndVatSlot('X o+123')).toThrow( + `flag ("X") must be 'R' or '_'`, + ); + expect(() => parseReachableAndVatSlot('Ro+123')).toThrow( + 'Expected "o" is same as " "', + ); + expect(() => parseReachableAndVatSlot('R')).toThrow( + 'Expected "" is same as " "', + ); + }); + + it('throws for non-string input', () => { + // @ts-expect-error Testing invalid input + expect(() => parseReachableAndVatSlot(123)).toThrow( + 'non-string value: 123', + ); + // @ts-expect-error Testing invalid input + expect(() => parseReachableAndVatSlot(null)).toThrow( + 'non-string value: null', + ); + // @ts-expect-error Testing invalid input + expect(() => parseReachableAndVatSlot(undefined)).toThrow( + 'non-string value: "[undefined]"', + ); + }); + }); + + describe('buildReachableAndVatSlot', () => { + it('builds string with reachable flag correctly', () => { + expect(buildReachableAndVatSlot(true, 'o+123')).toBe('R o+123'); + expect(buildReachableAndVatSlot(false, 'o+123')).toBe('_ o+123'); + }); + + it('works with different vat slot types', () => { + // Object slots + expect(buildReachableAndVatSlot(true, 'o+123')).toBe('R o+123'); + expect(buildReachableAndVatSlot(false, 'o-456')).toBe('_ o-456'); + + // Promise slots + expect(buildReachableAndVatSlot(true, 'p+789')).toBe('R p+789'); + expect(buildReachableAndVatSlot(false, 'p-012')).toBe('_ p-012'); + }); + }); + + describe('round-trip conversion', () => { + it('can round-trip reachable values', () => { + const testCases = [ + { isReachable: true, vatSlot: 'o+123' }, + { isReachable: false, vatSlot: 'o-456' }, + { isReachable: true, vatSlot: 'p+789' }, + { isReachable: false, vatSlot: 'p-012' }, + ]; + + for (const testCase of testCases) { + const { isReachable, vatSlot } = testCase; + const built = buildReachableAndVatSlot(isReachable, vatSlot); + const parsed = parseReachableAndVatSlot(built); + + expect(parsed).toStrictEqual(testCase); + } + }); + }); +}); diff --git a/packages/kernel/src/store/utils/reachable.ts b/packages/kernel/src/store/utils/reachable.ts new file mode 100644 index 000000000..9de02dee3 --- /dev/null +++ b/packages/kernel/src/store/utils/reachable.ts @@ -0,0 +1,42 @@ +import { assert, Fail } from '../../utils/assert.ts'; + +/** + * Parse a string into an object with `isReachable` and `vatSlot` properties. + * + * @param value - The string to parse. + * @returns An object with `isReachable` and `vatSlot` properties. + */ +export function parseReachableAndVatSlot(value: string): { + isReachable: boolean; + vatSlot: string; +} { + typeof value === 'string' || Fail`non-string value: ${value}`; + const flag = value.slice(0, 1); + assert.equal(value.slice(1, 2), ' '); + const vatSlot = value.slice(2); + let isReachable; + if (flag === 'R') { + isReachable = true; + } else if (flag === '_') { + isReachable = false; + } else { + throw Fail`flag (${flag}) must be 'R' or '_'`; + } + return { isReachable, vatSlot }; +} +harden(parseReachableAndVatSlot); + +/** + * Build a string from an object with `isReachable` and `vatSlot` properties. + * + * @param isReachable - The `isReachable` property of the object. + * @param vatSlot - The `vatSlot` property of the object. + * @returns A string with the `isReachable` and `vatSlot` properties. + */ +export function buildReachableAndVatSlot( + isReachable: boolean, + vatSlot: string, +): string { + return `${isReachable ? 'R' : '_'} ${vatSlot}`; +} +harden(buildReachableAndVatSlot); diff --git a/packages/kernel/src/types.ts b/packages/kernel/src/types.ts index 019876812..ae7a10df5 100644 --- a/packages/kernel/src/types.ts +++ b/packages/kernel/src/types.ts @@ -1,5 +1,4 @@ import type { Message } from '@agoric/swingset-liveslots'; -import { Fail } from '@endo/errors'; import type { CapData } from '@endo/marshal'; import type { PromiseKit } from '@endo/promise-kit'; import { @@ -25,6 +24,7 @@ import { UnsafeJsonStruct } from '@metamask/utils'; import type { DuplexStream } from '@ocap/streams'; import type { VatCommandReply, VatCommand } from './messages/vat.ts'; +import { Fail } from './utils/assert.ts'; export type VatId = string; export type RemoteId = string; @@ -55,17 +55,39 @@ export type RunQueueItemNotify = { kpid: KRef; }; -export type RunQueueItem = RunQueueItemSend | RunQueueItemNotify; +export type RunQueueItemGCAction = { + type: GCRunQueueType; + vatId: VatId; + krefs: KRef[]; +}; + +export type RunQueueItemBringOutYourDead = { + type: 'bringOutYourDead'; + vatId: VatId; +}; + +export type RunQueueItem = + | RunQueueItemSend + | RunQueueItemNotify + | RunQueueItemGCAction + | RunQueueItemBringOutYourDead; export const MessageStruct = object({ methargs: CapDataStruct, result: union([string(), literal(undefined), literal(null)]), }); -export const insistMessage = (value: unknown): boolean => +/** + * Assert that a value is a valid message. + * + * @param value - The value to check. + * @throws if the value is not a valid message. + */ +export function insistMessage(value: unknown): asserts value is Message { is(value, MessageStruct) || Fail`not a valid message`; +} -const RunQueueItemType = { +export const RunQueueItemType = { send: 'send', notify: 'notify', dropExports: 'dropExports', @@ -96,6 +118,7 @@ const RunQueueItemStructs = { }), [RunQueueItemType.bringOutYourDead]: object({ type: literal(RunQueueItemType.bringOutYourDead), + vatId: string(), }), }; @@ -153,8 +176,15 @@ export const isVatId = (value: unknown): value is VatId => value.at(0) === 'v' && value.slice(1) === String(Number(value.slice(1))); -export const insistVatId = (value: unknown): boolean => +/** + * Assert that a value is a valid vat id. + * + * @param value - The value to check. + * @throws if the value is not a valid vat id. + */ +export function insistVatId(value: unknown): asserts value is VatId { isVatId(value) || Fail`not a valid VatId`; +} export const VatIdStruct = define('VatId', isVatId); @@ -290,3 +320,57 @@ export const VatCheckpointStruct = tuple([ map(string(), string()), set(string()), ]); + +export type GCRunQueueType = 'dropExports' | 'retireExports' | 'retireImports'; +export type GCActionType = 'dropExport' | 'retireExport' | 'retireImport'; +export const actionTypePriorities: GCActionType[] = [ + 'dropExport', + 'retireExport', + 'retireImport', +]; + +/** + * A mapping of GC action type to queue event type. + */ +export const queueTypeFromActionType = new Map([ + ['dropExport', RunQueueItemType.dropExports], + ['retireExport', RunQueueItemType.retireExports], + ['retireImport', RunQueueItemType.retireImports], +]); + +export const isGCActionType = (value: unknown): value is GCActionType => + actionTypePriorities.includes(value as GCActionType); + +/** + * Assert that a value is a valid GC action type. + * + * @param value - The value to check. + * @throws if the value is not a valid GC action type. + */ +export function insistGCActionType( + value: unknown, +): asserts value is GCActionType { + isGCActionType(value) || Fail`not a valid GCActionType ${value}`; +} + +export type GCAction = `${VatId} ${GCActionType} ${KRef}`; + +export const GCActionStruct = define('GCAction', (value: unknown) => { + if (typeof value !== 'string') { + return false; + } + const [vatId, actionType, kref] = value.split(' '); + if (!isVatId(vatId)) { + return false; + } + if (!isGCActionType(actionType)) { + return false; + } + if (typeof kref !== 'string' || !kref.startsWith('ko')) { + return false; + } + return true; +}); + +export const isGCAction = (value: unknown): value is GCAction => + is(value, GCActionStruct); diff --git a/packages/kernel/src/assert.ts b/packages/kernel/src/utils/assert.ts similarity index 100% rename from packages/kernel/src/assert.ts rename to packages/kernel/src/utils/assert.ts diff --git a/packages/kernel/src/utils/wait-quiescent.test.ts b/packages/kernel/src/utils/wait-quiescent.test.ts new file mode 100644 index 000000000..1b2a2ebab --- /dev/null +++ b/packages/kernel/src/utils/wait-quiescent.test.ts @@ -0,0 +1,129 @@ +import { makePromiseKit } from '@endo/promise-kit'; +import { describe, it, expect } from 'vitest'; + +import { waitUntilQuiescent } from './wait-quiescent.ts'; + +describe('wait-quiescent', () => { + describe('waitUntilQuiescent', () => { + it('resolves after microtask queue is empty', async () => { + const { promise: p1, resolve: r1 } = makePromiseKit(); + const { promise: p2, resolve: r2 } = makePromiseKit(); + + // Start waiting for quiescence first + const quiescentPromise = waitUntilQuiescent(); + + // Create microtasks + Promise.resolve() + .then(() => { + r1(); + return undefined; + }) + .catch(() => undefined); + + Promise.resolve() + .then(() => { + r2(); + return undefined; + }) + .catch(() => undefined); + + // Wait for all promises + await p1; + await p2; + await quiescentPromise; + + expect(true).toBe(true); // If we got here, the test passed + }); + + it('waits for nested promise chains', async () => { + const results: number[] = []; + + // Create nested promise chains + await Promise.resolve().then(async () => { + results.push(1); + await Promise.resolve().then(async () => { + results.push(2); + await Promise.resolve().then(() => { + results.push(3); + return results; + }); + return results; + }); + return results; + }); + + await waitUntilQuiescent(); + + expect(results).toStrictEqual([1, 2, 3]); + }); + + it('waits for concurrent promises', async () => { + const results: number[] = []; + const promises = [ + Promise.resolve().then(() => results.push(1)), + Promise.resolve().then(() => results.push(2)), + Promise.resolve().then(() => results.push(3)), + ]; + + await Promise.all(promises); + await waitUntilQuiescent(); + + expect(results).toHaveLength(3); + expect(results).toContain(1); + expect(results).toContain(2); + expect(results).toContain(3); + }); + + it('handles rejected promises in the queue', async () => { + const results: string[] = []; + + // Create a mix of resolved and rejected promises + await Promise.resolve() + .then(() => { + results.push('success1'); + return results; + }) + .catch(() => { + results.push('caught1'); + return results; + }); + + await Promise.reject(new Error('test error')) + .then(() => { + results.push('success2'); + return results; + }) + .catch(() => { + results.push('caught2'); + return results; + }); + + await waitUntilQuiescent(); + + expect(results).toContain('success1'); + expect(results).toContain('caught2'); + }); + + it('resolves even with setImmediate callbacks', async () => { + const results: string[] = []; + + setImmediate(() => { + results.push('immediate1'); + setImmediate(() => { + results.push('immediate2'); + }); + }); + + await Promise.resolve().then(() => { + results.push('promise'); + return results; + }); + + await waitUntilQuiescent(); + + expect(results).toContain('promise'); + // Note: We don't check for immediate1/2 as they may execute after + // waitUntilQuiescent resolves, since they're lower priority + }); + }); +}); diff --git a/packages/kernel/src/waitUntilQuiescent.ts b/packages/kernel/src/utils/wait-quiescent.ts similarity index 100% rename from packages/kernel/src/waitUntilQuiescent.ts rename to packages/kernel/src/utils/wait-quiescent.ts diff --git a/packages/nodejs/src/index.ts b/packages/nodejs/src/index.ts new file mode 100644 index 000000000..f20a4d8bc --- /dev/null +++ b/packages/nodejs/src/index.ts @@ -0,0 +1,2 @@ +export { NodejsVatWorkerService } from './kernel/VatWorkerService.ts'; +export { makeKernel } from './kernel/make-kernel.ts'; diff --git a/vitest.config.ts b/vitest.config.ts index 244c19f1d..ec1c2402b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -88,10 +88,10 @@ export default defineConfig({ lines: 81.78, }, 'packages/kernel/**': { - statements: 80.9, - functions: 77.91, - branches: 62.12, - lines: 81.05, + statements: 82.4, + functions: 88.71, + branches: 64.15, + lines: 82.51, }, 'packages/nodejs/**': { statements: 72.91,