diff --git a/packages/extension/package.json b/packages/extension/package.json index bac0ca824..2a5e80917 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -49,6 +49,7 @@ "@metamask/kernel-ui": "workspace:^", "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", + "@metamask/ocap-kernel": "workspace:^", "@metamask/streams": "workspace:^", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index b1a11267c..df1d144ca 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -1,13 +1,7 @@ import { E } from '@endo/eventual-send'; -import { - makeBackgroundCapTP, - makeCapTPNotification, - isCapTPNotification, - getCapTPMessage, -} from '@metamask/kernel-browser-runtime'; -import type { CapTPMessage } from '@metamask/kernel-browser-runtime'; +import { makeBackgroundHostVat } from '@metamask/kernel-browser-runtime'; import defaultSubcluster from '@metamask/kernel-browser-runtime/default-cluster'; -import { delay, isJsonRpcMessage, stringify } from '@metamask/kernel-utils'; +import { delay, isJsonRpcMessage } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser'; @@ -21,7 +15,7 @@ let bootPromise: Promise | null = null; // With this we can click the extension action button to wake up the service worker. chrome.action.onClicked.addListener(() => { globalThis.kernel !== undefined && - E(globalThis.kernel).ping().catch(logger.error); + E(globalThis.kernel).getStatus().catch(logger.error); }); // Install/update @@ -87,65 +81,38 @@ async function main(): Promise { // Without this delay, sending messages via the chrome.runtime API can fail. await delay(50); - // Create stream for CapTP messages + // Create stream for JSON-RPC messages to kernel const offscreenStream = await ChromeRuntimeDuplexStream.make< JsonRpcMessage, JsonRpcMessage >(chrome.runtime, 'background', 'offscreen', isJsonRpcMessage); - // Set up CapTP for E() based communication with the kernel - const backgroundCapTP = makeBackgroundCapTP({ - send: (captpMessage: CapTPMessage) => { - const notification = makeCapTPNotification(captpMessage); - offscreenStream.write(notification).catch((error) => { - logger.error('Failed to send CapTP message:', error); - }); - }, - }); + // Create host vat - captures kernelFacet from bootstrap automatically + const hostVat = makeBackgroundHostVat({ logger }); - // Get the kernel remote presence - const kernelP = backgroundCapTP.getKernel(); - globalThis.kernel = kernelP; - - // Handle incoming CapTP messages from the kernel - const drainPromise = offscreenStream.drain((message) => { - if (isCapTPNotification(message)) { - const captpMessage = getCapTPMessage(message); - backgroundCapTP.dispatch(captpMessage); - } else { - throw new Error(`Unexpected message: ${stringify(message)}`); - } - }); - drainPromise.catch(logger.error); + // Connect to kernel via offscreen pipe + hostVat.connect(offscreenStream); - try { - await E(kernelP).ping(); - await startDefaultSubcluster(); - } catch (error) { - offscreenStream.throw(error as Error).catch(logger.error); - } + globalThis.kernel = hostVat.kernelFacetPromise; - try { - await drainPromise; - } catch (error) { - const finalError = new Error('Offscreen connection closed unexpectedly', { - cause: error, - }); - backgroundCapTP.abort(finalError); - throw finalError; - } + // Verify connectivity and start default subcluster + await E(kernel).getStatus(); + await startDefaultSubcluster(); } /** * Idempotently starts the default subcluster. + * Must be called after globalThis.kernel is set. */ async function startDefaultSubcluster(): Promise { - const status = await E(globalThis.kernel).getStatus(); + const { kernel } = globalThis; + if (kernel === undefined) { + throw new Error('Kernel not initialized'); + } + const status = await E(kernel).getStatus(); if (status.subclusters.length === 0) { - const result = await E(globalThis.kernel).launchSubcluster( - defaultSubcluster, - ); + const result = await E(kernel).launchSubcluster(defaultSubcluster); logger.info(`Default subcluster launched: ${JSON.stringify(result)}`); } else { logger.info('Subclusters already exist. Not launching default subcluster.'); diff --git a/packages/extension/src/global.d.ts b/packages/extension/src/global.d.ts index f63d2a3a6..5e62eac43 100644 --- a/packages/extension/src/global.d.ts +++ b/packages/extension/src/global.d.ts @@ -1,4 +1,4 @@ -import type { KernelFacade } from '@metamask/kernel-browser-runtime'; +import type { KernelFacet } from '@metamask/ocap-kernel'; // Type declarations for kernel dev console API. declare global { @@ -8,7 +8,6 @@ declare global { * * @example * ```typescript - * const kernel = await kernel.getKernel(); * const status = await E(kernel).getStatus(); * ``` */ @@ -16,7 +15,7 @@ declare global { var E: typeof import('@endo/eventual-send').E; // eslint-disable-next-line no-var - var kernel: KernelFacade | Promise; + var kernel: Promise | KernelFacet; } export {}; diff --git a/packages/extension/test/e2e/control-panel.test.ts b/packages/extension/test/e2e/control-panel.test.ts index 8677aeb5d..e7d18a586 100644 --- a/packages/extension/test/e2e/control-panel.test.ts +++ b/packages/extension/test/e2e/control-panel.test.ts @@ -191,19 +191,19 @@ test.describe('Control Panel', () => { const v3Values = [ '{"key":"e.nextPromiseId.v3","value":"2"}', '{"key":"e.nextObjectId.v3","value":"1"}', - '{"key":"ko5.owner","value":"v3"}', - '{"key":"v3.c.ko5","value":"R o+0"}', - '{"key":"v3.c.o+0","value":"ko5"}', - '{"key":"v3.c.kp4","value":"R p-1"}', - '{"key":"v3.c.p-1","value":"kp4"}', - '{"key":"ko5.refCount","value":"1,1"}', - '{"key":"kp4.refCount","value":"2"}', + '{"key":"ko7.owner","value":"v3"}', + '{"key":"v3.c.ko7","value":"R o+0"}', + '{"key":"v3.c.o+0","value":"ko7"}', + '{"key":"v3.c.kp7","value":"R p-1"}', + '{"key":"v3.c.p-1","value":"kp7"}', + '{"key":"ko7.refCount","value":"1,1"}', + '{"key":"kp7.refCount","value":"2"}', ]; const v1koValues = [ - '{"key":"v1.c.ko4","value":"R o-1"}', - '{"key":"v1.c.o-1","value":"ko4"}', - '{"key":"v1.c.ko5","value":"R o-2"}', - '{"key":"v1.c.o-2","value":"ko5"}', + '{"key":"v1.c.ko6","value":"R o-1"}', + '{"key":"v1.c.o-1","value":"ko6"}', + '{"key":"v1.c.ko7","value":"R o-2"}', + '{"key":"v1.c.o-2","value":"ko7"}', ]; await expect( popupPage.locator('[data-testid="message-output"]'), @@ -263,16 +263,16 @@ test.describe('Control Panel', () => { popupPage.locator('[data-testid="message-output"]'), ).not.toContainText(value); } - // ko3 (vat root) reference still exists for v1 + // ko6, ko7 (bob, carol vat roots) references still exist for v1 for (const value of v1koValues) { await expect( popupPage.locator('[data-testid="message-output"]'), ).toContainText(value); } - // kp4 reference dropped to 1 + // kp7 reference dropped to 1 await expect( popupPage.locator('[data-testid="message-output"]'), - ).toContainText('{"key":"kp4.refCount","value":"1"}'); + ).toContainText('{"key":"kp7.refCount","value":"1"}'); await popupPage.click('button:text("Control Panel")'); await popupPage.locator('[data-testid="accordion-header"]').first().click(); // delete v1 diff --git a/packages/extension/test/e2e/object-registry.test.ts b/packages/extension/test/e2e/object-registry.test.ts index f54038a4a..5a1cedfdc 100644 --- a/packages/extension/test/e2e/object-registry.test.ts +++ b/packages/extension/test/e2e/object-registry.test.ts @@ -108,7 +108,7 @@ test.describe('Object Registry', () => { test('should revoke an object', async () => { const owner = 'v1'; - const v1Root = 'ko3'; + const v1Root = 'ko5'; const [target, method, params] = [v1Root, 'hello', '["Bob"]']; // Before revoking, we should be able to send a message to the object diff --git a/packages/extension/test/e2e/remote-comms.test.ts b/packages/extension/test/e2e/remote-comms.test.ts index b07c3935c..5ed622b6b 100644 --- a/packages/extension/test/e2e/remote-comms.test.ts +++ b/packages/extension/test/e2e/remote-comms.test.ts @@ -118,8 +118,8 @@ test.describe('Remote Communications', () => { await expect(targetSelect).toBeVisible(); const options = await targetSelect.locator('option').all(); expect(options.length).toBeGreaterThan(1); - await targetSelect.selectOption({ value: 'ko3' }); - expect(await targetSelect.inputValue()).toBe('ko3'); + await targetSelect.selectOption({ value: 'ko5' }); + expect(await targetSelect.inputValue()).toBe('ko5'); // Set method to doRunRun (the remote communication method) const methodInput = popupPage1.locator('[data-testid="message-method"]'); diff --git a/packages/kernel-browser-runtime/package.json b/packages/kernel-browser-runtime/package.json index 89597fa7b..ecb7d4c22 100644 --- a/packages/kernel-browser-runtime/package.json +++ b/packages/kernel-browser-runtime/package.json @@ -59,14 +59,15 @@ "test:build": "tsx ./test/build-tests.ts", "test:clean": "yarn test --no-cache --coverage.clean", "test:dev": "yarn test --mode development", - "test:integration": "vitest run --config vitest.integration.config.ts", "test:verbose": "yarn test --reporter verbose", "test:watch": "vitest --config vitest.config.ts", "test:dev:quiet": "yarn test:dev --reporter @ocap/repo-tools/vitest-reporters/silent" }, "dependencies": { + "@agoric/swingset-liveslots": "0.10.3-u21.0.1", "@endo/captp": "^4.4.8", "@endo/marshal": "^1.8.0", + "@endo/promise-kit": "^1.1.13", "@metamask/json-rpc-engine": "^10.2.0", "@metamask/kernel-errors": "workspace:^", "@metamask/kernel-rpc-methods": "workspace:^", @@ -85,7 +86,6 @@ }, "devDependencies": { "@arethetypeswrong/cli": "^0.17.4", - "@endo/eventual-send": "^1.3.4", "@metamask/auto-changelog": "^5.3.0", "@metamask/eslint-config": "^15.0.0", "@metamask/eslint-config-nodejs": "^15.0.0", diff --git a/packages/kernel-browser-runtime/src/host-vat/index.ts b/packages/kernel-browser-runtime/src/host-vat/index.ts new file mode 100644 index 000000000..eea6203f4 --- /dev/null +++ b/packages/kernel-browser-runtime/src/host-vat/index.ts @@ -0,0 +1,14 @@ +/** + * Host vat utilities for cross-process system vat communication. + * + * The host vat enables a system vat supervisor to run in a different process + * than the kernel. The kernel runs in a Worker, and the supervisor runs in + * the background script. They communicate over a stream using JSON-RPC messages + * and the optimistic syscall model (fire-and-forget with ['ok', null]). + */ + +export { makeKernelHostVat } from './kernel-side.ts'; +export type { KernelHostVatResult } from './kernel-side.ts'; + +export { makeBackgroundHostVat } from './supervisor-side.ts'; +export type { BackgroundHostVatResult } from './supervisor-side.ts'; diff --git a/packages/kernel-browser-runtime/src/host-vat/kernel-side.test.ts b/packages/kernel-browser-runtime/src/host-vat/kernel-side.test.ts new file mode 100644 index 000000000..e62266df6 --- /dev/null +++ b/packages/kernel-browser-runtime/src/host-vat/kernel-side.test.ts @@ -0,0 +1,387 @@ +import type { DuplexStream } from '@metamask/streams'; +import type { + JsonRpcMessage, + JsonRpcNotification, + JsonRpcRequest, + JsonRpcResponse, +} from '@metamask/utils'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { makeKernelHostVat } from './kernel-side.ts'; + +type TestStream = DuplexStream; + +const makeMockStream = () => { + const written: JsonRpcMessage[] = []; + const messageHandlers: ((message: JsonRpcMessage) => void | Promise)[] = + []; + let drainResolver: (() => void) | null = null; + + const stream: TestStream = { + write: vi.fn(async (message: JsonRpcMessage) => { + written.push(message); + return { done: false, value: undefined }; + }), + drain: vi.fn( + async (handler: (message: JsonRpcMessage) => void | Promise) => { + messageHandlers.push(handler); + // Return a promise that resolves when test calls closeDrain() + return new Promise((resolve) => { + drainResolver = resolve; + }); + }, + ), + next: vi.fn(), + pipe: vi.fn(), + return: vi.fn(), + throw: vi.fn(), + end: vi.fn(), + [Symbol.asyncIterator]: vi.fn(() => stream), + }; + + return { + stream, + written, + // Simulate receiving a message from supervisor + receiveMessage: async (message: JsonRpcMessage) => { + for (const handler of messageHandlers) { + await handler(message); + } + }, + closeDrain: () => { + drainResolver?.(); + }, + }; +}; + +/** + * Helper to check if a message is a JSON-RPC request with the given method. + * + * @param message - The message to check. + * @param method - The expected method name. + * @returns True if the message is a request with the given method. + */ +const isRequestWithMethod = ( + message: JsonRpcMessage, + method: string, +): message is JsonRpcRequest => { + return 'method' in message && message.method === method && 'id' in message; +}; + +describe('makeKernelHostVat', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('config', () => { + it('returns config with default name', () => { + const result = makeKernelHostVat(); + expect(result.config.name).toBe('kernelHost'); + }); + + it('returns config with custom name', () => { + const result = makeKernelHostVat({ name: 'customHost' }); + expect(result.config.name).toBe('customHost'); + }); + + it('returns config with transport functions', () => { + const result = makeKernelHostVat(); + + expect(result.config.transport.deliver).toBeTypeOf('function'); + expect(result.config.transport.setSyscallHandler).toBeTypeOf('function'); + expect(result.config.transport.awaitConnection).toBeTypeOf('function'); + }); + }); + + describe('connect', () => { + it('starts draining the stream for messages', async () => { + const result = makeKernelHostVat(); + const { stream } = makeMockStream(); + + result.connect(stream); + + await vi.waitFor(() => { + expect(stream.drain).toHaveBeenCalled(); + }); + }); + }); + + describe('awaitConnection', () => { + it('resolves when ready notification is received', async () => { + const result = makeKernelHostVat(); + const { stream, receiveMessage } = makeMockStream(); + + result.connect(stream); + + // Wait for drain to be called before sending messages + await vi.waitFor(() => { + expect(stream.drain).toHaveBeenCalled(); + }); + + const connectionPromise = result.config.transport.awaitConnection(); + + // Simulate supervisor sending ready notification (JSON-RPC) + await receiveMessage({ + jsonrpc: '2.0', + method: 'ready', + }); + + expect(await connectionPromise).toBeUndefined(); + }); + + it('does not resolve before ready notification', async () => { + const result = makeKernelHostVat(); + const { stream } = makeMockStream(); + + result.connect(stream); + + const connectionPromise = result.config.transport.awaitConnection(); + + // Check that promise is still pending using Promise.race + const PENDING = Symbol('pending'); + const status = await Promise.race([ + connectionPromise.then(() => 'resolved'), + new Promise((resolve) => setTimeout(() => resolve(PENDING), 10)), + ]); + + expect(status).toBe(PENDING); + }); + }); + + describe('deliver', () => { + it('sends delivery request over stream as JSON-RPC', async () => { + const result = makeKernelHostVat(); + const { stream, written, receiveMessage } = makeMockStream(); + + result.connect(stream); + + const delivery = [ + 'message', + 'o+0', + { methargs: { body: '', slots: [] } }, + ]; + const deliverPromise = result.config.transport.deliver( + delivery as unknown as Parameters< + typeof result.config.transport.deliver + >[0], + ); + + await vi.waitFor(() => { + const deliveryMsg = written.find((item) => + isRequestWithMethod(item, 'deliver'), + ); + expect(deliveryMsg).toBeDefined(); + }); + + // Verify the delivery message format (JSON-RPC request) + const deliveryMsg = written.find((item) => + isRequestWithMethod(item, 'deliver'), + ) as JsonRpcRequest; + expect(deliveryMsg.jsonrpc).toBe('2.0'); + expect(deliveryMsg.method).toBe('deliver'); + expect(deliveryMsg.params).toStrictEqual(delivery); + expect(deliveryMsg.id).toMatch(/^kernel:\d+$/u); + + // Simulate supervisor responding with JSON-RPC response + // The deliver result is [checkpoint, deliveryError] + await receiveMessage({ + jsonrpc: '2.0', + id: deliveryMsg.id, + result: [[[], []], null], + } as JsonRpcResponse); + + expect(await deliverPromise).toBeNull(); + }); + + it('returns delivery error when supervisor reports error', async () => { + const result = makeKernelHostVat(); + const { stream, written, receiveMessage } = makeMockStream(); + + result.connect(stream); + + const delivery = [ + 'message', + 'o+0', + { methargs: { body: '', slots: [] } }, + ]; + const deliverPromise = result.config.transport.deliver( + delivery as unknown as Parameters< + typeof result.config.transport.deliver + >[0], + ); + + // Wait for delivery to be sent + await vi.waitFor(() => { + expect(stream.write).toHaveBeenCalled(); + }); + + // Get the request ID + const deliveryMsg = written.find((item) => + isRequestWithMethod(item, 'deliver'), + ) as JsonRpcRequest; + + // Simulate supervisor responding with error in result + await receiveMessage({ + jsonrpc: '2.0', + id: deliveryMsg.id, + result: [[[], []], 'Delivery failed'], + } as JsonRpcResponse); + + expect(await deliverPromise).toBe('Delivery failed'); + }); + + it('increments delivery IDs', async () => { + const result = makeKernelHostVat(); + const { stream, written, receiveMessage } = makeMockStream(); + + result.connect(stream); + + const delivery = [ + 'message', + 'o+0', + { methargs: { body: '', slots: [] } }, + ]; + + // Send first delivery + const deliver1 = result.config.transport.deliver( + delivery as unknown as Parameters< + typeof result.config.transport.deliver + >[0], + ); + + await vi.waitFor(() => { + expect( + written.filter((item) => isRequestWithMethod(item, 'deliver')), + ).toHaveLength(1); + }); + + // Send second delivery + const deliver2 = result.config.transport.deliver( + delivery as unknown as Parameters< + typeof result.config.transport.deliver + >[0], + ); + + await vi.waitFor(() => { + expect( + written.filter((item) => isRequestWithMethod(item, 'deliver')), + ).toHaveLength(2); + }); + + // Check IDs increment within a single host vat instance + const deliveryMsgs = written.filter((item) => + isRequestWithMethod(item, 'deliver'), + ); + const id1 = deliveryMsgs[0]?.id as string; + const id2 = deliveryMsgs[1]?.id as string; + // IDs should be different and follow the pattern + expect(id1).toMatch(/^kernel:\d+$/u); + expect(id2).toMatch(/^kernel:\d+$/u); + expect(id1).not.toBe(id2); + + // Resolve both + await receiveMessage({ + jsonrpc: '2.0', + id: id1, + result: [[[], []], null], + } as JsonRpcResponse); + await receiveMessage({ + jsonrpc: '2.0', + id: id2, + result: [[[], []], null], + } as JsonRpcResponse); + + await Promise.all([deliver1, deliver2]); + }); + + it('throws if stream is not connected', async () => { + const result = makeKernelHostVat(); + + const delivery = [ + 'message', + 'o+0', + { methargs: { body: '', slots: [] } }, + ]; + + await expect( + result.config.transport.deliver( + delivery as unknown as Parameters< + typeof result.config.transport.deliver + >[0], + ), + ).rejects.toThrow('Stream not connected'); + }); + }); + + describe('syscall handling', () => { + it('calls syscall handler when syscall notification is received', async () => { + const result = makeKernelHostVat(); + const { stream, receiveMessage } = makeMockStream(); + const syscallHandler = vi.fn().mockReturnValue(['ok', null]); + + result.config.transport.setSyscallHandler(syscallHandler); + result.connect(stream); + + const syscall = ['send', 'ko1', { methargs: { body: '', slots: [] } }]; + // Send as JSON-RPC notification + await receiveMessage({ + jsonrpc: '2.0', + method: 'syscall', + params: syscall, + } as JsonRpcNotification); + + await vi.waitFor(() => { + expect(syscallHandler).toHaveBeenCalledWith(syscall); + }); + }); + + it('ignores syscall if handler is not set', async () => { + const mockLogger = { warn: vi.fn() }; + const result = makeKernelHostVat({ logger: mockLogger as never }); + const { stream, receiveMessage } = makeMockStream(); + + // Don't set syscall handler + result.connect(stream); + + const syscall = ['send', 'ko1', { methargs: { body: '', slots: [] } }]; + + // Should not throw + await receiveMessage({ + jsonrpc: '2.0', + method: 'syscall', + params: syscall, + } as JsonRpcNotification); + + await vi.waitFor(() => { + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Received syscall before handler was set', + ); + }); + }); + + it('logs error if syscall handler throws', async () => { + const mockLogger = { error: vi.fn() }; + const result = makeKernelHostVat({ logger: mockLogger as never }); + const { stream, receiveMessage } = makeMockStream(); + + const error = new Error('Syscall failed'); + const syscallHandler = vi.fn().mockImplementation(() => { + throw error; + }); + + result.config.transport.setSyscallHandler(syscallHandler); + result.connect(stream); + + const syscall = ['send', 'ko1', { methargs: { body: '', slots: [] } }]; + await receiveMessage({ + jsonrpc: '2.0', + method: 'syscall', + params: syscall, + } as JsonRpcNotification); + + await vi.waitFor(() => { + expect(mockLogger.error).toHaveBeenCalledWith('Syscall error:', error); + }); + }); + }); +}); diff --git a/packages/kernel-browser-runtime/src/host-vat/kernel-side.ts b/packages/kernel-browser-runtime/src/host-vat/kernel-side.ts new file mode 100644 index 000000000..2c6d3a23c --- /dev/null +++ b/packages/kernel-browser-runtime/src/host-vat/kernel-side.ts @@ -0,0 +1,172 @@ +import type { VatSyscallObject } from '@agoric/swingset-liveslots'; +import { makePromiseKit } from '@endo/promise-kit'; +import { RpcClient, RpcService } from '@metamask/kernel-rpc-methods'; +import { stringify } from '@metamask/kernel-utils'; +import type { JsonRpcMessage } from '@metamask/kernel-utils'; +import type { Logger } from '@metamask/logger'; +import type { + DeliveryObject, + SystemVatConfig, + SystemVatSyscallHandler, + SystemVatTransport, +} from '@metamask/ocap-kernel'; +import type { DuplexStream } from '@metamask/streams'; +import { isJsonRpcNotification, isJsonRpcResponse } from '@metamask/utils'; + +import { kernelToSupervisorSpecs, kernelHandlers } from './rpc/index.ts'; + +/** + * Result of creating a kernel-side host vat. + */ +export type KernelHostVatResult = { + /** + * Configuration to pass to Kernel.make() hostVat option. + */ + config: SystemVatConfig; + + /** + * Connect the stream after kernel is created. + * Call this to wire up communication with the supervisor. + * + * @param stream - The duplex stream for JSON-RPC communication with the supervisor. + */ + connect: (stream: DuplexStream) => void; +}; + +/** + * Create a kernel-side host vat for use with Kernel.make(). + * + * This creates the transport configuration needed for the kernel to communicate + * with a system vat supervisor running in a different process (e.g., browser + * background script). + * + * The transport uses an optimistic syscall model where syscalls are fire-and-forget, + * returning ['ok', null] immediately. The kernel handles failures by terminating + * the vat and rolling back the crank. + * + * Usage in kernel Worker: + * ```typescript + * const hostVat = makeKernelHostVat({ logger }); + * const kernel = await Kernel.make(platformServices, db, { + * hostVat: hostVat.config, + * }); + * const stream = await createHostVatStream(); // e.g., BroadcastChannel + * hostVat.connect(stream); + * ``` + * + * @param options - Options for creating the host vat. + * @param options.name - Optional name for the host vat (default: 'kernelHost'). + * @param options.logger - Optional logger for debugging. + * @returns The host vat result with config and connect function. + */ +export function makeKernelHostVat(options?: { + name?: string; + logger?: Logger; +}): KernelHostVatResult { + const vatName = options?.name ?? 'kernelHost'; + const logger = options?.logger; + + // Syscall handler - set by kernel during registerSystemVat() + let syscallHandler: SystemVatSyscallHandler | null = null; + + // Promise kit to signal when supervisor is ready + const supervisorReady = makePromiseKit(); + + // RpcClient for sending deliveries to supervisor - set when connect() is called + let rpcClient: RpcClient | null = null; + + // RpcService for receiving syscalls from supervisor + const rpcService = new RpcService(kernelHandlers, { + handleSyscall: (params) => { + if (!syscallHandler) { + logger?.warn('Received syscall before handler was set'); + return; + } + // Process syscall synchronously - the result is ignored because + // the supervisor uses optimistic execution + try { + // Cast needed because the RPC spec uses slightly different types + syscallHandler(params as unknown as VatSyscallObject); + } catch (error) { + // Syscall errors are handled by the kernel (vat termination) + logger?.error('Syscall error:', error); + } + }, + }); + + /** + * Deliver a message to the supervisor via RPC. + * + * @param delivery - The delivery object to send. + * @returns A promise that resolves to the delivery error (null if success). + */ + const deliver = async (delivery: DeliveryObject): Promise => { + if (!rpcClient) { + throw new Error('Stream not connected'); + } + + // The deliver spec returns [checkpoint, deliveryError], we want just the error + const result = await rpcClient.call('deliver', delivery); + return result[1]; + }; + + const transport: SystemVatTransport = { + deliver, + setSyscallHandler: (handler: SystemVatSyscallHandler) => { + syscallHandler = handler; + }, + awaitConnection: async () => supervisorReady.promise, + }; + + const config: SystemVatConfig = { + name: vatName, + transport, + }; + + const connect = ( + stream: DuplexStream, + ): void => { + rpcClient = new RpcClient( + kernelToSupervisorSpecs, + async (message) => { + await stream.write(message); + }, + 'kernel:', + logger, + ); + + // Capture reference for use in drain callback + const client = rpcClient; + + // Start draining the stream for incoming messages + stream + .drain(async (message) => { + if (isJsonRpcResponse(message)) { + // Response to our deliver request + client.handleResponse(message.id as string, message); + } else if (isJsonRpcNotification(message)) { + if (message.method === 'ready') { + // Supervisor signals it's ready + supervisorReady.resolve(); + } else if (message.method === 'syscall') { + // Syscall notification from supervisor + await rpcService.execute('syscall', message.params); + } else { + throw new Error( + `Unexpected host vat message from supervisor: ${stringify(message)}`, + ); + } + } + }) + .catch((error) => { + logger?.error('Stream error:', error); + client.rejectAll(error as Error); + }); + }; + + return harden({ + config, + connect, + }); +} +harden(makeKernelHostVat); diff --git a/packages/kernel-browser-runtime/src/host-vat/rpc/index.ts b/packages/kernel-browser-runtime/src/host-vat/rpc/index.ts new file mode 100644 index 000000000..200719a6b --- /dev/null +++ b/packages/kernel-browser-runtime/src/host-vat/rpc/index.ts @@ -0,0 +1,42 @@ +import type { + HandlerRecord, + MethodSpecRecord, +} from '@metamask/kernel-rpc-methods'; +import { + vatHandlers, + vatMethodSpecs, + vatSyscallHandlers, + vatSyscallMethodSpecs, +} from '@metamask/ocap-kernel/rpc'; + +// Extract types +type DeliverSpec = (typeof vatMethodSpecs)['deliver']; +type DeliverHandler = (typeof vatHandlers)['deliver']; +type VatSyscallSpec = (typeof vatSyscallMethodSpecs)['syscall']; +type VatSyscallHandler = (typeof vatSyscallHandlers)['syscall']; + +/** + * Method specs for messages from kernel to supervisor (requests). + */ +export const kernelToSupervisorSpecs = { + deliver: vatMethodSpecs.deliver, +} as MethodSpecRecord; + +/** + * Handlers for the kernel to process notifications from the supervisor. + */ +export const kernelHandlers: HandlerRecord = + vatSyscallHandlers; + +/** + * Method specs for messages from supervisor to kernel (notifications). + */ +export const supervisorToKernelSpecs: MethodSpecRecord = + vatSyscallMethodSpecs; + +/** + * Handlers for the supervisor to process requests from the kernel. + */ +export const supervisorHandlers = { + deliver: vatHandlers.deliver, +} as HandlerRecord; diff --git a/packages/kernel-browser-runtime/src/host-vat/supervisor-side.test.ts b/packages/kernel-browser-runtime/src/host-vat/supervisor-side.test.ts new file mode 100644 index 000000000..02bf3b635 --- /dev/null +++ b/packages/kernel-browser-runtime/src/host-vat/supervisor-side.test.ts @@ -0,0 +1,383 @@ +import { SystemVatSupervisor } from '@metamask/ocap-kernel/vats'; +import type { DuplexStream } from '@metamask/streams'; +import type { + JsonRpcMessage, + JsonRpcNotification, + JsonRpcRequest, + JsonRpcResponse, +} from '@metamask/utils'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { makeBackgroundHostVat } from './supervisor-side.ts'; + +// Mock SystemVatSupervisor +vi.mock('@metamask/ocap-kernel/vats', () => ({ + SystemVatSupervisor: { + make: vi.fn(), + }, +})); + +type TestStream = DuplexStream; + +const makeMockStream = () => { + const written: JsonRpcMessage[] = []; + const messageHandlers: ((message: JsonRpcMessage) => void | Promise)[] = + []; + let drainResolver: (() => void) | null = null; + + const stream: TestStream = { + write: vi.fn(async (message: JsonRpcMessage) => { + written.push(message); + return { done: false, value: undefined }; + }), + drain: vi.fn( + async (handler: (message: JsonRpcMessage) => void | Promise) => { + messageHandlers.push(handler); + // Return a promise that resolves when test calls closeDrain() + return new Promise((resolve) => { + drainResolver = resolve; + }); + }, + ), + next: vi.fn(), + pipe: vi.fn(), + return: vi.fn(), + throw: vi.fn(), + end: vi.fn(), + [Symbol.asyncIterator]: vi.fn(() => stream), + }; + + return { + stream, + written, + // Simulate receiving a message from kernel + receiveMessage: async (message: JsonRpcMessage) => { + for (const handler of messageHandlers) { + await handler(message); + } + }, + closeDrain: () => { + drainResolver?.(); + }, + }; +}; + +const makeMockSupervisor = () => ({ + deliver: vi.fn().mockResolvedValue(null), + id: 'sv0' as const, +}); + +/** + * Helper to check if a message is a JSON-RPC notification with the given method. + * + * @param message - The message to check. + * @param method - The expected method name. + * @returns True if the message is a notification with the given method. + */ +const isNotificationWithMethod = ( + message: JsonRpcMessage, + method: string, +): message is JsonRpcNotification => { + return 'method' in message && message.method === method && !('id' in message); +}; + +/** + * Helper to check if a message is a JSON-RPC response. + * + * @param message - The message to check. + * @returns True if the message is a response. + */ +const isResponse = (message: JsonRpcMessage): message is JsonRpcResponse => { + return 'id' in message && ('result' in message || 'error' in message); +}; + +describe('makeBackgroundHostVat', () => { + let mockSupervisor: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockSupervisor = makeMockSupervisor(); + vi.mocked(SystemVatSupervisor.make).mockResolvedValue( + mockSupervisor as never, + ); + }); + + describe('connect', () => { + it('creates supervisor with internal buildRootObject', async () => { + const result = makeBackgroundHostVat(); + const { stream } = makeMockStream(); + + result.connect(stream); + + await vi.waitFor(() => { + expect(SystemVatSupervisor.make).toHaveBeenCalled(); + }); + + const makeCall = vi.mocked(SystemVatSupervisor.make).mock.calls[0]; + expect(makeCall?.[0]).toHaveProperty('buildRootObject'); + expect(makeCall?.[0]).toHaveProperty('executeSyscall'); + }); + + it('sends ready notification after supervisor is created', async () => { + const result = makeBackgroundHostVat(); + const { stream, written } = makeMockStream(); + + result.connect(stream); + + await vi.waitFor(() => { + const readyMsg = written.find((item) => + isNotificationWithMethod(item, 'ready'), + ); + expect(readyMsg).toBeDefined(); + }); + + // Verify the ready message format (JSON-RPC notification) + const readyMsg = written.find((item) => + isNotificationWithMethod(item, 'ready'), + ); + expect(readyMsg).toStrictEqual({ + jsonrpc: '2.0', + method: 'ready', + }); + }); + + it('starts draining stream after sending ready', async () => { + const result = makeBackgroundHostVat(); + const { stream } = makeMockStream(); + + result.connect(stream); + + await vi.waitFor(() => { + expect(stream.drain).toHaveBeenCalled(); + }); + }); + }); + + describe('kernelFacetPromise', () => { + it('resolves when bootstrap is called with kernelFacet in services', async () => { + const mockKernelFacet = { launchSubcluster: vi.fn() }; + let capturedBuildRootObject: (() => object) | null = null; + + vi.mocked(SystemVatSupervisor.make).mockImplementation( + async (options) => { + capturedBuildRootObject = + options.buildRootObject as typeof capturedBuildRootObject; + return mockSupervisor as never; + }, + ); + + const result = makeBackgroundHostVat(); + const { stream } = makeMockStream(); + + result.connect(stream); + + await vi.waitFor(() => { + expect(capturedBuildRootObject).not.toBeNull(); + }); + + // Build root object (liveslots calls this) + const rootObject = capturedBuildRootObject?.() as { + bootstrap: ( + roots: Record, + services: Record, + ) => void; + }; + + // Simulate kernel sending bootstrap message with kernelFacet in services + rootObject.bootstrap({}, { kernelFacet: mockKernelFacet }); + + expect(await result.kernelFacetPromise).toBe(mockKernelFacet); + }); + + it('rejects if kernelFacet is not provided in services', async () => { + let capturedBuildRootObject: (() => object) | null = null; + + vi.mocked(SystemVatSupervisor.make).mockImplementation( + async (options) => { + capturedBuildRootObject = + options.buildRootObject as typeof capturedBuildRootObject; + return mockSupervisor as never; + }, + ); + + const result = makeBackgroundHostVat(); + const { stream } = makeMockStream(); + + result.connect(stream); + + await vi.waitFor(() => { + expect(capturedBuildRootObject).not.toBeNull(); + }); + + const rootObject = capturedBuildRootObject?.() as { + bootstrap: ( + roots: Record, + services: Record, + ) => void; + }; + + // Bootstrap without kernelFacet + rootObject.bootstrap({}, {}); + + await expect(result.kernelFacetPromise).rejects.toThrow( + 'kernelFacet not provided in bootstrap services', + ); + }); + }); + + describe('delivery handling', () => { + it('delivers to supervisor and sends JSON-RPC response back', async () => { + const result = makeBackgroundHostVat(); + const { stream, written, receiveMessage } = makeMockStream(); + + result.connect(stream); + + // Wait for supervisor to be ready + await vi.waitFor(() => { + const readyMsg = written.find((item) => + isNotificationWithMethod(item, 'ready'), + ); + expect(readyMsg).toBeDefined(); + }); + + const delivery = [ + 'message', + 'o+0', + { methargs: { body: '', slots: [] } }, + ]; + + // Send JSON-RPC request for delivery + await receiveMessage({ + jsonrpc: '2.0', + id: 'kernel:123', + method: 'deliver', + params: delivery, + } as JsonRpcRequest); + + expect(mockSupervisor.deliver).toHaveBeenCalledWith(delivery); + + // Check the response - VatDeliveryResult is [checkpoint, error] + const responseMsg = written.find((item) => isResponse(item)); + expect(responseMsg).toStrictEqual({ + jsonrpc: '2.0', + id: 'kernel:123', + result: [[[], []], null], + }); + }); + + it('sends delivery error in response when supervisor.deliver returns error', async () => { + mockSupervisor.deliver.mockResolvedValue('Something went wrong'); + + const result = makeBackgroundHostVat(); + const { stream, written, receiveMessage } = makeMockStream(); + + result.connect(stream); + + await vi.waitFor(() => { + const readyMsg = written.find((item) => + isNotificationWithMethod(item, 'ready'), + ); + expect(readyMsg).toBeDefined(); + }); + + const delivery = [ + 'message', + 'o+0', + { methargs: { body: '', slots: [] } }, + ]; + + // Send JSON-RPC request for delivery + await receiveMessage({ + jsonrpc: '2.0', + id: 'kernel:456', + method: 'deliver', + params: delivery, + } as JsonRpcRequest); + + // VatDeliveryResult is [checkpoint, error] + const responseMsg = written.find((item) => isResponse(item)); + expect(responseMsg).toStrictEqual({ + jsonrpc: '2.0', + id: 'kernel:456', + result: [[[], []], 'Something went wrong'], + }); + }); + }); + + describe('syscall execution', () => { + it('sends syscall as JSON-RPC notification over stream', async () => { + let capturedExecuteSyscall: ((vso: unknown) => unknown) | null = null; + + vi.mocked(SystemVatSupervisor.make).mockImplementation( + async (options) => { + capturedExecuteSyscall = + options.executeSyscall as typeof capturedExecuteSyscall; + return mockSupervisor as never; + }, + ); + + const result = makeBackgroundHostVat(); + const { stream, written } = makeMockStream(); + + result.connect(stream); + + await vi.waitFor(() => { + expect(capturedExecuteSyscall).not.toBeNull(); + }); + + const syscall = [ + 'send', + 'ko1', + { methargs: { body: '#{}', slots: [] }, result: 'kp1' }, + ]; + const syscallResult = capturedExecuteSyscall?.(syscall); + + // Should return success immediately (optimistic) + expect(syscallResult).toStrictEqual(['ok', null]); + + await vi.waitFor(() => { + const syscallMsg = written.find((item) => + isNotificationWithMethod(item, 'syscall'), + ); + expect(syscallMsg).toBeDefined(); + }); + + // Verify the syscall message format (JSON-RPC notification) + const syscallMsg = written.find((item) => + isNotificationWithMethod(item, 'syscall'), + ); + expect(syscallMsg).toStrictEqual({ + jsonrpc: '2.0', + method: 'syscall', + params: syscall, + }); + }); + + it('returns ok immediately without waiting for response', async () => { + let capturedExecuteSyscall: ((vso: unknown) => unknown) | null = null; + + vi.mocked(SystemVatSupervisor.make).mockImplementation( + async (options) => { + capturedExecuteSyscall = + options.executeSyscall as typeof capturedExecuteSyscall; + return mockSupervisor as never; + }, + ); + + const result = makeBackgroundHostVat(); + const { stream } = makeMockStream(); + + result.connect(stream); + + await vi.waitFor(() => { + expect(capturedExecuteSyscall).not.toBeNull(); + }); + + const syscall = ['subscribe', 'kp1']; + const syscallResult = capturedExecuteSyscall?.(syscall); + + // Result is synchronous, not a promise + expect(syscallResult).toStrictEqual(['ok', null]); + }); + }); +}); diff --git a/packages/kernel-browser-runtime/src/host-vat/supervisor-side.ts b/packages/kernel-browser-runtime/src/host-vat/supervisor-side.ts new file mode 100644 index 000000000..5c8e813eb --- /dev/null +++ b/packages/kernel-browser-runtime/src/host-vat/supervisor-side.ts @@ -0,0 +1,206 @@ +import type { + VatSyscallObject, + VatSyscallResult, +} from '@agoric/swingset-liveslots'; +import { makePromiseKit } from '@endo/promise-kit'; +import { RpcClient, RpcService } from '@metamask/kernel-rpc-methods'; +import { makeDefaultExo, stringify } from '@metamask/kernel-utils'; +import type { JsonRpcMessage } from '@metamask/kernel-utils'; +import type { Logger } from '@metamask/logger'; +import type { DeliveryObject, KernelFacet } from '@metamask/ocap-kernel'; +import { SystemVatSupervisor } from '@metamask/ocap-kernel/vats'; +import type { DuplexStream } from '@metamask/streams'; +import { isJsonRpcRequest } from '@metamask/utils'; + +import { supervisorToKernelSpecs, supervisorHandlers } from './rpc/index.ts'; + +/** + * Result of creating a background-side host vat. + */ +export type BackgroundHostVatResult = { + /** + * Connect and start the supervisor. + * Call this with the stream to the kernel Worker. + * + * @param stream - The duplex stream for JSON-RPC communication with the kernel. + */ + connect: (stream: DuplexStream) => void; + + /** + * Promise that resolves to kernelFacet when bootstrap completes. + * No polling needed - just await this promise after calling connect(). + */ + kernelFacetPromise: Promise; +}; + +/** + * Create a background-side host vat for use in the browser background script. + * + * This creates a supervisor that communicates with the kernel over a stream. + * The supervisor uses an optimistic syscall model where syscalls are fire-and-forget, + * returning ['ok', null] immediately. + * + * The kernelFacet is automatically captured from the bootstrap message sent by the + * kernel. You can await `kernelFacetPromise` to get it after calling `connect()`. + * + * Usage in background script: + * ```typescript + * const hostVat = makeBackgroundHostVat({ logger }); + * hostVat.connect(stream); + * const kernelFacet = await hostVat.kernelFacetPromise; + * const result = await E(kernelFacet).launchSubcluster(config); + * ``` + * + * @param options - Options for creating the host vat. + * @param options.logger - Optional logger for debugging. + * @returns The host vat result with connect and kernelFacetPromise. + */ +export function makeBackgroundHostVat(options?: { + logger?: Logger; +}): BackgroundHostVatResult { + const logger = options?.logger; + + // Promise kit for kernel facet - resolves when bootstrap is called + const kernelFacetKit = makePromiseKit(); + + // RpcClient for sending syscalls to kernel - set when connect() is called + let rpcClient: RpcClient | null = null; + + /** + * Execute a syscall by sending it to the kernel via RPC notification. + * Uses optimistic execution - returns success immediately. + * + * @param vso - The syscall object to execute. + * @returns A syscall success result. + */ + const executeSyscall = (vso: VatSyscallObject): VatSyscallResult => { + if (!rpcClient) { + throw new Error('Stream not connected'); + } + + // Send syscall as notification (fire-and-forget) + // Cast needed because the RPC spec uses slightly different types + rpcClient.notify('syscall', vso as never).catch((error) => { + logger?.error('Failed to send syscall:', error); + }); + + // Return success immediately (optimistic execution) + return ['ok', null]; + }; + + /** + * Build the root object for this host vat. + * + * The root object only needs a bootstrap method to receive the kernelFacet + * from the kernel's bootstrap message. + * + * @returns The root object with a bootstrap method. + */ + const buildRootObject = (): object => { + return makeDefaultExo('BackgroundHostVat', { + /** + * Called by the kernel after connection with roots and services. + * Captures the kernelFacet from services. + * + * @param _roots - The roots object (unused). + * @param services - The services object containing kernelFacet. + */ + bootstrap: ( + _roots: Record, + services: Record, + ) => { + if (services.kernelFacet) { + kernelFacetKit.resolve(services.kernelFacet as KernelFacet); + } else { + kernelFacetKit.reject( + new Error('kernelFacet not provided in bootstrap services'), + ); + } + }, + }); + }; + + const connect = ( + stream: DuplexStream, + ): void => { + rpcClient = new RpcClient( + supervisorToKernelSpecs, + async (message) => { + await stream.write(message); + }, + 'supervisor:', + logger, + ); + + // Create and start the supervisor + const supervisorOptions = { + buildRootObject, + executeSyscall, + ...(logger && { logger: logger.subLogger({ tags: ['supervisor'] }) }), + }; + + SystemVatSupervisor.make(supervisorOptions) + .then(async (createdSupervisor) => { + // Create RpcService for handling delivery requests from kernel + const rpcService = new RpcService(supervisorHandlers, { + handleDelivery: async (params) => { + const deliveryError = await createdSupervisor.deliver( + params as DeliveryObject, + ); + // SystemVatSupervisor returns just the error, but the spec expects + // VatDeliveryResult which is [VatCheckpoint, error]. System vats + // don't checkpoint, so we return an empty checkpoint. + const emptyCheckpoint: [[string, string][], string[]] = [[], []]; + return [emptyCheckpoint, deliveryError]; + }, + }); + + // Signal to kernel that we're ready via notification + await stream.write({ + jsonrpc: '2.0' as const, + method: 'ready', + }); + + // Start draining the stream for incoming messages + return stream.drain(async (message) => { + if (isJsonRpcRequest(message) && message.method === 'deliver') { + // Request from kernel (deliver) + try { + const result = await rpcService.execute( + 'deliver', + message.params, + ); + await stream.write({ + jsonrpc: '2.0', + id: message.id, + result, + }); + } catch (error) { + await stream.write({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32603, + message: (error as Error).message, + }, + }); + } + } else { + throw new Error( + `Unexpected host vat message from kernel: ${stringify(message)}`, + ); + } + }); + }) + .catch((error) => { + logger?.error('Supervisor initialization error:', error); + kernelFacetKit.reject(error as Error); + }); + }; + + return harden({ + connect, + kernelFacetPromise: kernelFacetKit.promise, + }); +} +harden(makeBackgroundHostVat); diff --git a/packages/kernel-browser-runtime/src/index.test.ts b/packages/kernel-browser-runtime/src/index.test.ts index f52b98667..aa3e0b786 100644 --- a/packages/kernel-browser-runtime/src/index.test.ts +++ b/packages/kernel-browser-runtime/src/index.test.ts @@ -13,8 +13,10 @@ describe('index', () => { 'getRelaysFromCurrentLocation', 'isCapTPNotification', 'makeBackgroundCapTP', + 'makeBackgroundHostVat', 'makeCapTPNotification', 'makeIframeVatWorker', + 'makeKernelHostVat', 'parseRelayQueryString', 'receiveInternalConnections', 'rpcHandlers', diff --git a/packages/kernel-browser-runtime/src/index.ts b/packages/kernel-browser-runtime/src/index.ts index 4c10590e3..7d2eca44d 100644 --- a/packages/kernel-browser-runtime/src/index.ts +++ b/packages/kernel-browser-runtime/src/index.ts @@ -1,4 +1,5 @@ export * from './rpc-handlers/index.ts'; +export * from './host-vat/index.ts'; export { connectToKernel, receiveInternalConnections, diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts deleted file mode 100644 index 0e3fe0cf0..000000000 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { E } from '@endo/eventual-send'; -import type { ClusterConfig, Kernel } from '@metamask/ocap-kernel'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -import { makeKernelCapTP } from './kernel-captp.ts'; -import { makeBackgroundCapTP } from '../../background-captp.ts'; -import type { CapTPMessage } from '../../background-captp.ts'; - -/** - * Integration tests for CapTP communication between background and kernel endpoints. - * - * These tests validate that the two CapTP endpoints can communicate correctly - * and that E() works properly with the kernel facade remote presence. - */ -describe('CapTP Integration', () => { - let mockKernel: Kernel; - let kernelCapTP: ReturnType; - let backgroundCapTP: ReturnType; - - beforeEach(() => { - // Create mock kernel with method implementations - mockKernel = { - launchSubcluster: vi.fn().mockResolvedValue({ - subclusterId: 'sc1', - bootstrapRootKref: 'ko1', - bootstrapResult: { - body: '#{"result":"ok"}', - slots: [], - }, - }), - terminateSubcluster: vi.fn().mockResolvedValue(undefined), - queueMessage: vi.fn().mockResolvedValue({ - body: '#{"result":"message-sent"}', - slots: [], - }), - getStatus: vi.fn().mockResolvedValue({ - vats: [{ id: 'v1', name: 'test-vat' }], - subclusters: ['sc1'], - remoteComms: false, - }), - pingVat: vi.fn().mockResolvedValue('pong'), - } as unknown as Kernel; - - // Wire up CapTP endpoints to dispatch messages synchronously to each other - // This simulates direct message passing for testing - - // Kernel-side: exposes facade as bootstrap - kernelCapTP = makeKernelCapTP({ - kernel: mockKernel, - send: (message: CapTPMessage) => { - // Dispatch synchronously for testing - backgroundCapTP.dispatch(message); - }, - }); - - // Background-side: gets remote presence of kernel - backgroundCapTP = makeBackgroundCapTP({ - send: (message: CapTPMessage) => { - // Dispatch synchronously for testing - kernelCapTP.dispatch(message); - }, - }); - }); - - describe('bootstrap', () => { - it('background can get kernel remote presence via getKernel', async () => { - // Request the kernel facade - with synchronous dispatch, this resolves immediately - const kernel = await backgroundCapTP.getKernel(); - expect(kernel).toBeDefined(); - }); - }); - - describe('ping', () => { - it('e(kernel).ping() returns "pong"', async () => { - // Get kernel remote presence - const kernel = await backgroundCapTP.getKernel(); - - // Call ping via E() - const result = await E(kernel).ping(); - expect(result).toBe('pong'); - }); - }); - - describe('getStatus', () => { - it('e(kernel).getStatus() returns status from mock kernel', async () => { - // Get kernel remote presence - const kernel = await backgroundCapTP.getKernel(); - - // Call getStatus via E() - const result = await E(kernel).getStatus(); - expect(result).toStrictEqual({ - vats: [{ id: 'v1', name: 'test-vat' }], - subclusters: ['sc1'], - remoteComms: false, - }); - - expect(mockKernel.getStatus).toHaveBeenCalled(); - }); - }); - - describe('launchSubcluster', () => { - it('e(kernel).launchSubcluster() passes arguments correctly', async () => { - const config: ClusterConfig = { - bootstrap: 'v1', - vats: { - v1: { - bundleSpec: 'test-source', - }, - }, - }; - - // Get kernel remote presence - const kernel = await backgroundCapTP.getKernel(); - - // Call launchSubcluster via E() - const result = await E(kernel).launchSubcluster(config); - - // The kernel facade now returns LaunchResult instead of CapData - expect(result).toStrictEqual({ - subclusterId: 'sc1', - rootKref: 'ko1', - }); - - expect(mockKernel.launchSubcluster).toHaveBeenCalledWith(config); - }); - }); - - describe('terminateSubcluster', () => { - it('e(kernel).terminateSubcluster() delegates to kernel', async () => { - // Get kernel remote presence - const kernel = await backgroundCapTP.getKernel(); - - // Call terminateSubcluster via E() - await E(kernel).terminateSubcluster('sc1'); - expect(mockKernel.terminateSubcluster).toHaveBeenCalledWith('sc1'); - }); - }); - - describe('queueMessage', () => { - it('e(kernel).queueMessage() passes arguments correctly', async () => { - const target = 'ko1'; - const method = 'doSomething'; - const args = ['arg1', { nested: 'value' }]; - - // Get kernel remote presence - const kernel = await backgroundCapTP.getKernel(); - - // Call queueMessage via E() - const result = await E(kernel).queueMessage(target, method, args); - expect(result).toStrictEqual({ - body: '#{"result":"message-sent"}', - slots: [], - }); - - expect(mockKernel.queueMessage).toHaveBeenCalledWith( - target, - method, - args, - ); - }); - }); - - describe('pingVat', () => { - it('e(kernel).pingVat() delegates to kernel', async () => { - // Get kernel remote presence - const kernel = await backgroundCapTP.getKernel(); - - // Call pingVat via E() - const result = await E(kernel).pingVat('v1'); - expect(result).toBe('pong'); - - expect(mockKernel.pingVat).toHaveBeenCalledWith('v1'); - }); - }); - - describe('error propagation', () => { - it('errors from kernel methods propagate to background', async () => { - const error = new Error('Kernel operation failed'); - vi.mocked(mockKernel.getStatus).mockRejectedValueOnce(error); - - // Get kernel remote presence - const kernel = await backgroundCapTP.getKernel(); - - // Call getStatus which will fail - await expect(E(kernel).getStatus()).rejects.toThrow( - 'Kernel operation failed', - ); - }); - }); -}); diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/index.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/index.ts deleted file mode 100644 index 6e3ee7053..000000000 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - makeKernelCapTP, - type KernelCapTP, - type KernelCapTPOptions, -} from './kernel-captp.ts'; - -export { makeKernelFacade, type KernelFacade } from './kernel-facade.ts'; diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts deleted file mode 100644 index fbd1eb0d2..000000000 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { Kernel } from '@metamask/ocap-kernel'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -import { makeKernelCapTP } from './kernel-captp.ts'; -import type { CapTPMessage } from '../../types.ts'; - -describe('makeKernelCapTP', () => { - const mockKernel: Kernel = {} as unknown as Kernel; - let sendMock: (message: CapTPMessage) => void; - - beforeEach(() => { - sendMock = vi.fn(); - }); - - it('returns object with dispatch and abort', () => { - const capTP = makeKernelCapTP({ - kernel: mockKernel, - send: sendMock, - }); - - expect(capTP).toHaveProperty('dispatch'); - expect(capTP).toHaveProperty('abort'); - expect(typeof capTP.dispatch).toBe('function'); - expect(typeof capTP.abort).toBe('function'); - }); - - it('dispatch returns boolean', () => { - const capTP = makeKernelCapTP({ - kernel: mockKernel, - send: sendMock, - }); - - // Dispatch a dummy message - will return false since it's not valid - const result = capTP.dispatch({ type: 'unknown' }); - - expect(typeof result).toBe('boolean'); - }); - - it('processes valid CapTP messages without errors', () => { - const capTP = makeKernelCapTP({ - kernel: mockKernel, - send: sendMock, - }); - - // Dispatch a valid CapTP message format - // CapTP uses array-based message format internally - // A CTP_CALL message triggers method calls on the bootstrap object - const callMessage: CapTPMessage = { - type: 'CTP_CALL', - questionID: 1, - target: 0, // Bootstrap slot - method: 'ping', - args: { body: '[]', slots: [] }, - }; - - // Should not throw when processing a message - expect(() => capTP.dispatch(callMessage)).not.toThrow(); - }); - - it('abort does not throw', () => { - const capTP = makeKernelCapTP({ - kernel: mockKernel, - send: sendMock, - }); - - expect(() => capTP.abort()).not.toThrow(); - }); - - it('abort can be called with a reason', () => { - const capTP = makeKernelCapTP({ - kernel: mockKernel, - send: sendMock, - }); - - expect(() => capTP.abort({ reason: 'test shutdown' })).not.toThrow(); - }); -}); diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.ts deleted file mode 100644 index 16587a100..000000000 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { makeCapTP } from '@endo/captp'; -import type { Kernel } from '@metamask/ocap-kernel'; - -import { makeKernelFacade } from './kernel-facade.ts'; -import type { CapTPMessage } from '../../types.ts'; - -/** - * Options for creating a kernel CapTP endpoint. - */ -export type KernelCapTPOptions = { - /** - * The kernel instance to expose via CapTP. - */ - kernel: Kernel; - - /** - * Function to send CapTP messages to the background. - * - * @param message - The CapTP message to send. - */ - send: (message: CapTPMessage) => void; -}; - -/** - * The kernel's CapTP endpoint. - */ -export type KernelCapTP = { - /** - * Dispatch an incoming CapTP message from the background. - * - * @param message - The CapTP message to dispatch. - * @returns True if the message was handled. - */ - dispatch: (message: CapTPMessage) => boolean; - - /** - * Abort the CapTP connection. - * - * @param reason - The reason for aborting. - */ - abort: (reason?: unknown) => void; -}; - -/** - * Create a CapTP endpoint for the kernel. - * - * This sets up a CapTP connection that exposes the kernel facade as the - * bootstrap object. The background can then use `E(kernel).method()` to - * call kernel methods. - * - * @param options - The options for creating the CapTP endpoint. - * @returns The kernel CapTP endpoint. - */ -export function makeKernelCapTP(options: KernelCapTPOptions): KernelCapTP { - const { kernel, send } = options; - - // Create the kernel facade that will be exposed to the background - const kernelFacade = makeKernelFacade(kernel); - - // Create the CapTP endpoint - const { dispatch, abort } = makeCapTP('kernel', send, kernelFacade); - - return harden({ - dispatch, - abort, - }); -} -harden(makeKernelCapTP); diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.test.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.test.ts deleted file mode 100644 index cdaf77703..000000000 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import type { - ClusterConfig, - Kernel, - KernelStatus, - KRef, - VatId, -} from '@metamask/ocap-kernel'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -import { makeKernelFacade } from './kernel-facade.ts'; -import type { KernelFacade } from './kernel-facade.ts'; - -const makeClusterConfig = (): ClusterConfig => ({ - bootstrap: 'test-vat', - vats: { - 'test-vat': { bundleSpec: 'test' }, - }, -}); - -describe('makeKernelFacade', () => { - let mockKernel: Kernel; - let facade: KernelFacade; - - beforeEach(() => { - mockKernel = { - launchSubcluster: vi.fn().mockResolvedValue({ - subclusterId: 'sc1', - bootstrapRootKref: 'ko1', - }), - terminateSubcluster: vi.fn().mockResolvedValue(undefined), - queueMessage: vi.fn().mockResolvedValue({ - body: '#{"result":"success"}', - slots: [], - }), - getStatus: vi.fn().mockResolvedValue({ - vats: [], - subclusters: [], - remoteComms: false, - }), - pingVat: vi.fn().mockResolvedValue('pong'), - } as unknown as Kernel; - - facade = makeKernelFacade(mockKernel); - }); - - describe('ping', () => { - it('returns "pong"', async () => { - const result = await facade.ping(); - expect(result).toBe('pong'); - }); - }); - - describe('launchSubcluster', () => { - it('delegates to kernel with correct arguments', async () => { - const config: ClusterConfig = makeClusterConfig(); - - await facade.launchSubcluster(config); - - expect(mockKernel.launchSubcluster).toHaveBeenCalledWith(config); - expect(mockKernel.launchSubcluster).toHaveBeenCalledTimes(1); - }); - - it('returns result with subclusterId and rootKref from kernel', async () => { - const kernelResult = { - subclusterId: 's1', - bootstrapRootKref: 'ko1', - bootstrapResult: { body: '#null', slots: [] }, - }; - vi.mocked(mockKernel.launchSubcluster).mockResolvedValueOnce( - kernelResult, - ); - - const config: ClusterConfig = makeClusterConfig(); - - const result = await facade.launchSubcluster(config); - - expect(result).toStrictEqual({ - subclusterId: 's1', - rootKref: 'ko1', - }); - }); - - it('propagates errors from kernel', async () => { - const error = new Error('Launch failed'); - vi.mocked(mockKernel.launchSubcluster).mockRejectedValueOnce(error); - const config: ClusterConfig = makeClusterConfig(); - - await expect(facade.launchSubcluster(config)).rejects.toThrow(error); - }); - }); - - describe('terminateSubcluster', () => { - it('delegates to kernel with correct arguments', async () => { - const subclusterId = 'sc1'; - - await facade.terminateSubcluster(subclusterId); - - expect(mockKernel.terminateSubcluster).toHaveBeenCalledWith(subclusterId); - expect(mockKernel.terminateSubcluster).toHaveBeenCalledTimes(1); - }); - - it('propagates errors from kernel', async () => { - const error = new Error('Terminate failed'); - vi.mocked(mockKernel.terminateSubcluster).mockRejectedValueOnce(error); - - await expect(facade.terminateSubcluster('sc1')).rejects.toThrow(error); - }); - }); - - describe('queueMessage', () => { - it('delegates to kernel with correct arguments', async () => { - const target: KRef = 'ko1'; - const method = 'doSomething'; - const args = ['arg1', { nested: 'value' }]; - - await facade.queueMessage(target, method, args); - - expect(mockKernel.queueMessage).toHaveBeenCalledWith( - target, - method, - args, - ); - expect(mockKernel.queueMessage).toHaveBeenCalledTimes(1); - }); - - it('returns result from kernel', async () => { - const expectedResult = { body: '#{"answer":42}', slots: [] }; - vi.mocked(mockKernel.queueMessage).mockResolvedValueOnce(expectedResult); - - const result = await facade.queueMessage('ko1', 'compute', []); - expect(result).toStrictEqual(expectedResult); - }); - - it('propagates errors from kernel', async () => { - const error = new Error('Queue message failed'); - vi.mocked(mockKernel.queueMessage).mockRejectedValueOnce(error); - - await expect(facade.queueMessage('ko1', 'method', [])).rejects.toThrow( - error, - ); - }); - }); - - describe('getStatus', () => { - it('delegates to kernel', async () => { - await facade.getStatus(); - - expect(mockKernel.getStatus).toHaveBeenCalled(); - expect(mockKernel.getStatus).toHaveBeenCalledTimes(1); - }); - - it('returns status from kernel', async () => { - const expectedStatus: KernelStatus = { - vats: [], - subclusters: [], - remoteComms: { isInitialized: false }, - }; - vi.mocked(mockKernel.getStatus).mockResolvedValueOnce(expectedStatus); - - const result = await facade.getStatus(); - expect(result).toStrictEqual(expectedStatus); - }); - - it('propagates errors from kernel', async () => { - const error = new Error('Get status failed'); - vi.mocked(mockKernel.getStatus).mockRejectedValueOnce(error); - - await expect(facade.getStatus()).rejects.toThrow(error); - }); - }); - - describe('pingVat', () => { - it('delegates to kernel with correct vatId', async () => { - const vatId: VatId = 'v1'; - - await facade.pingVat(vatId); - - expect(mockKernel.pingVat).toHaveBeenCalledWith(vatId); - expect(mockKernel.pingVat).toHaveBeenCalledTimes(1); - }); - - it('returns result from kernel', async () => { - vi.mocked(mockKernel.pingVat).mockResolvedValueOnce('pong'); - - const result = await facade.pingVat('v1'); - expect(result).toBe('pong'); - }); - - it('propagates errors from kernel', async () => { - const error = new Error('Ping vat failed'); - vi.mocked(mockKernel.pingVat).mockRejectedValueOnce(error); - - await expect(facade.pingVat('v1')).rejects.toThrow(error); - }); - }); -}); diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts deleted file mode 100644 index 51d3cc9a4..000000000 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { makeDefaultExo } from '@metamask/kernel-utils/exo'; -import type { Kernel, ClusterConfig, KRef, VatId } from '@metamask/ocap-kernel'; - -import type { KernelFacade, LaunchResult } from '../../types.ts'; - -export type { KernelFacade } from '../../types.ts'; - -/** - * Create the kernel facade exo that exposes kernel methods via CapTP. - * - * @param kernel - The kernel instance to wrap. - * @returns The kernel facade exo. - */ -export function makeKernelFacade(kernel: Kernel): KernelFacade { - return makeDefaultExo('KernelFacade', { - ping: async () => 'pong' as const, - - launchSubcluster: async (config: ClusterConfig): Promise => { - const { subclusterId, bootstrapRootKref } = - await kernel.launchSubcluster(config); - return { subclusterId, rootKref: bootstrapRootKref }; - }, - - terminateSubcluster: async (subclusterId: string) => { - return kernel.terminateSubcluster(subclusterId); - }, - - queueMessage: async (target: KRef, method: string, args: unknown[]) => { - return kernel.queueMessage(target, method, args); - }, - - getStatus: async () => { - return kernel.getStatus(); - }, - - pingVat: async (vatId: VatId) => { - return kernel.pingVat(vatId); - }, - - getVatRoot: async (krefString: string) => { - // Return wrapped kref for future CapTP marshalling to presence - // TODO: Enable custom CapTP marshalling tables to convert this to a presence - return { kref: krefString }; - }, - }); -} -harden(makeKernelFacade); diff --git a/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts b/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts index ecc9b8ad8..35a15e45e 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts @@ -1,6 +1,7 @@ +import { makePromiseKit } from '@endo/promise-kit'; import { JsonRpcServer } from '@metamask/json-rpc-engine/v2'; import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/wasm'; -import { isJsonRpcMessage, stringify } from '@metamask/kernel-utils'; +import { isJsonRpcMessage } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import { Kernel } from '@metamask/ocap-kernel'; @@ -9,19 +10,19 @@ import { MessagePortDuplexStream, receiveMessagePort, } from '@metamask/streams/browser'; +import type { JsonRpcResponse } from '@metamask/utils'; -import { - isCapTPNotification, - makeCapTPNotification, -} from '../background-captp.ts'; -import type { CapTPMessage } from '../background-captp.ts'; +import { makeKernelHostVat } from '../host-vat/kernel-side.ts'; import { receiveInternalConnections } from '../internal-comms/internal-connections.ts'; import { PlatformServicesClient } from '../PlatformServicesClient.ts'; -import { makeKernelCapTP } from './captp/index.ts'; import { makeLoggingMiddleware } from './middleware/logging.ts'; import { makePanelMessageMiddleware } from './middleware/panel-message.ts'; import { getRelaysFromCurrentLocation } from '../utils/relay-query-string.ts'; +type HandleInternalMessage = ( + request: JsonRpcMessage, +) => Promise; + const logger = new Logger('kernel-worker'); const DB_FILENAME = 'store.db'; @@ -31,6 +32,13 @@ main().catch(logger.error); * Run the kernel. */ async function main(): Promise { + // Synchronously start listening for internal connections + const panelHandlerKit = makePromiseKit(); + receiveInternalConnections({ + handlerPromise: panelHandlerKit.promise, + logger, + }); + const port = await receiveMessagePort( (listener) => globalThis.addEventListener('message', listener), (listener) => globalThis.removeEventListener('message', listener), @@ -50,50 +58,34 @@ async function main(): Promise { new URLSearchParams(globalThis.location.search).get('reset-storage') === 'true'; - const kernelP = Kernel.make(platformServicesClient, kernelDatabase, { - resetStorage, + // Create host vat first to get config + const hostVat = makeKernelHostVat({ + name: 'kernelHost', + logger: logger.subLogger({ tags: ['host-vat'] }), }); - const handlerP = kernelP.then((kernel) => { - const server = new JsonRpcServer({ - middleware: [ - makeLoggingMiddleware(logger.subLogger('internal-rpc')), - makePanelMessageMiddleware(kernel, kernelDatabase), - ], - }); - return async (request: JsonRpcMessage) => server.handle(request); + // Pass host vat config to kernel + const kernel = await Kernel.make(platformServicesClient, kernelDatabase, { + resetStorage, + hostVat: hostVat.config, }); - receiveInternalConnections({ - handlerPromise: handlerP, - logger, + const panelRpcServer = new JsonRpcServer({ + middleware: [ + makeLoggingMiddleware(logger.subLogger('internal-rpc')), + makePanelMessageMiddleware(kernel, kernelDatabase), + ], }); + panelHandlerKit.resolve(panelRpcServer.handle.bind(panelRpcServer)); - const kernel = await kernelP; - - const kernelCapTP = makeKernelCapTP({ - kernel, - send: (captpMessage: CapTPMessage) => { - const notification = makeCapTPNotification(captpMessage); - messageStream.write(notification).catch((error) => { - logger.error('Failed to send CapTP message:', error); - }); - }, - }); + // Connect host vat to the background via the message stream after kernel is created + // The background will use makeBackgroundHostVat to create the supervisor side + const hostVatStream = messageStream as unknown as Parameters< + typeof hostVat.connect + >[0]; + hostVat.connect(hostVatStream); - messageStream - .drain((message) => { - if (isCapTPNotification(message)) { - const captpMessage = message.params[0]; - kernelCapTP.dispatch(captpMessage); - } else { - throw new Error(`Unexpected message: ${stringify(message)}`); - } - }) - .catch((error) => { - kernelCapTP.abort(error); - logger.error('Message stream error:', error); - }); + logger.log('Kernel started with host vat transport'); const relays = getRelaysFromCurrentLocation(); await kernel.initRemoteComms({ relays }); diff --git a/packages/kernel-browser-runtime/src/types.ts b/packages/kernel-browser-runtime/src/types.ts index 02d014d2b..74bb5bdb0 100644 --- a/packages/kernel-browser-runtime/src/types.ts +++ b/packages/kernel-browser-runtime/src/types.ts @@ -1,4 +1,9 @@ -import type { Kernel, ClusterConfig } from '@metamask/ocap-kernel'; +import type { + ClusterConfig, + Subcluster, + KernelStatus, + KernelFacetLaunchResult, +} from '@metamask/ocap-kernel'; import type { Json } from '@metamask/utils'; /** @@ -7,9 +12,9 @@ import type { Json } from '@metamask/utils'; export type CapTPMessage = Record; /** - * Result of launching a subcluster. + * Result of launching a subcluster (legacy format with kref string). * - * The rootKref contains the kref string for the bootstrap vat's root object. + * @deprecated Use KernelFacetLaunchResult instead, which returns a presence. */ export type LaunchResult = { subclusterId: string; @@ -19,14 +24,73 @@ export type LaunchResult = { /** * The kernel facade interface - methods exposed to userspace via CapTP. * - * This is the remote presence type that the background receives from the kernel. + * This is the remote presence type that the background receives from the kernel + * via the kernel host vat. The kernel host vat runs as a system vat inside + * the kernel worker and serves as the CapTP bootstrap object. + * + * Note: launchSubcluster now returns a presence in the `root` field instead of + * a kref string. When received via CapTP, this becomes an E()-callable presence. */ export type KernelFacade = { + /** + * Ping the kernel host. + * + * @returns 'pong' to confirm the host is responsive. + */ ping: () => Promise<'pong'>; - launchSubcluster: (config: ClusterConfig) => Promise; - terminateSubcluster: Kernel['terminateSubcluster']; - queueMessage: Kernel['queueMessage']; - getStatus: Kernel['getStatus']; - pingVat: Kernel['pingVat']; - getVatRoot: (krefString: string) => Promise; + + /** + * Launch a dynamic subcluster. + * + * @param config - Configuration for the subcluster. + * @returns The launch result with subcluster ID and root presence. + */ + launchSubcluster: (config: ClusterConfig) => Promise; + + /** + * Terminate a subcluster. + * + * @param subclusterId - The ID of the subcluster to terminate. + */ + terminateSubcluster: (subclusterId: string) => Promise; + + /** + * Get kernel status. + * + * @returns The current kernel status. + */ + getStatus: () => Promise; + + /** + * Reload a subcluster. + * + * @param subclusterId - The ID of the subcluster to reload. + * @returns The reloaded subcluster. + */ + reloadSubcluster: (subclusterId: string) => Promise; + + /** + * Get a subcluster by ID. + * + * @param subclusterId - The ID of the subcluster. + * @returns The subcluster or undefined if not found. + */ + getSubcluster: (subclusterId: string) => Subcluster | undefined; + + /** + * Get all subclusters. + * + * @returns Array of all subclusters. + */ + getSubclusters: () => Subcluster[]; + + /** + * Convert a kref string to a presence. + * + * Use this to restore a presence from a stored kref string after restart. + * + * @param kref - The kref string to convert. + * @returns The presence for the given kref. + */ + getVatRoot: (kref: string) => unknown; }; diff --git a/packages/nodejs/src/host-vat/index.test.ts b/packages/nodejs/src/host-vat/index.test.ts new file mode 100644 index 000000000..d9918dd7a --- /dev/null +++ b/packages/nodejs/src/host-vat/index.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { makeHostVat } from './index.ts'; + +// Mock SystemVatSupervisor +const mockDeliverFn = vi.fn(); +const mockMakeFn = vi.fn(); + +vi.mock('@metamask/ocap-kernel/vats', () => ({ + SystemVatSupervisor: { + make: (...args: unknown[]) => mockMakeFn(...args), + }, +})); + +describe('makeHostVat', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDeliverFn.mockResolvedValue(null); + mockMakeFn.mockResolvedValue({ deliver: mockDeliverFn }); + }); + + it('returns config, connect, and kernelFacetPromise', () => { + const result = makeHostVat(); + + expect(result.config).toBeDefined(); + expect(result.connect).toBeTypeOf('function'); + expect(result.kernelFacetPromise).toBeInstanceOf(Promise); + }); + + describe('config', () => { + it('has kernelHost as default name', () => { + const { config } = makeHostVat(); + + expect(config.name).toBe('kernelHost'); + }); + + it('uses custom name when provided', () => { + const { config } = makeHostVat({ name: 'customHost' }); + + expect(config.name).toBe('customHost'); + }); + + it('has transport with deliver and setSyscallHandler', () => { + const { config } = makeHostVat(); + + expect(config.transport).toBeDefined(); + expect(config.transport.deliver).toBeTypeOf('function'); + expect(config.transport.setSyscallHandler).toBeTypeOf('function'); + expect(config.transport.awaitConnection).toBeTypeOf('function'); + }); + }); + + describe('connect', () => { + it('throws if syscall handler not set', () => { + const { connect } = makeHostVat(); + + expect(() => connect()).toThrow( + 'Cannot connect: syscall handler not set. Was Kernel.make() called with this config?', + ); + }); + }); + + describe('transport', () => { + it('deliver waits for supervisor then calls it', async () => { + const { config, connect } = makeHostVat(); + + // Set the syscall handler (simulating kernel setup) + config.transport.setSyscallHandler(vi.fn()); + + const delivery = { + type: 'message' as const, + methargs: { body: '[]', slots: [] }, + result: 'p-1', + target: 'o+0', + }; + + // Start the delivery (it will wait for supervisor) + const deliverPromise = config.transport.deliver(delivery); + + // Start the supervisor - this should unblock the delivery + connect(); + + // Now the delivery should complete + await deliverPromise; + + expect(mockDeliverFn).toHaveBeenCalledWith(delivery); + }); + }); +}); diff --git a/packages/nodejs/src/host-vat/index.ts b/packages/nodejs/src/host-vat/index.ts new file mode 100644 index 000000000..bf5446feb --- /dev/null +++ b/packages/nodejs/src/host-vat/index.ts @@ -0,0 +1,140 @@ +import { makePromiseKit } from '@endo/promise-kit'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import { Logger } from '@metamask/logger'; +import type { + SystemVatBuildRootObject, + KernelFacet, + SystemVatConfig, + SystemVatTransport, + SystemVatSyscallHandler, + SystemVatDeliverFn, +} from '@metamask/ocap-kernel'; +import { SystemVatSupervisor } from '@metamask/ocap-kernel/vats'; + +/** + * Result of creating a host vat. + */ +export type HostVatResult = { + /** + * Configuration to pass to Kernel.make() hostVat option. + */ + config: SystemVatConfig; + + /** + * Call after Kernel.make() returns to initiate connection from supervisor side. + * This creates and starts the supervisor, then signals the kernel that + * the connection is ready. The kernel will then send the bootstrap message. + */ + connect: () => void; + + /** + * Promise that resolves to kernelFacet when bootstrap completes. + * No polling needed - just await this promise after calling connect(). + */ + kernelFacetPromise: Promise; +}; + +/** + * Create a host vat for use with Kernel.make(). + * + * This creates the supervisor and transport configuration needed to connect + * a host vat to the kernel. The supervisor is created when `connect()` + * is called (after Kernel.make() returns). + * + * Usage: + * ```typescript + * const hostVat = makeHostVat({ logger }); + * const kernel = await Kernel.make(platformServices, db, { + * hostVat: hostVat.config, + * }); + * hostVat.connect(); // Supervisor pushes connection to kernel + * const kernelFacet = await hostVat.kernelFacetPromise; + * const result = await E(kernelFacet).launchSubcluster(config); + * ``` + * + * @param options - Options for creating the host vat. + * @param options.name - Optional name for the host vat (default: 'kernelHost'). + * @param options.logger - Optional logger for the supervisor. + * @returns The host vat result with config, connect, and kernelFacetPromise. + */ +export function makeHostVat( + options: { + name?: string; + logger?: Logger; + } = {}, +): HostVatResult { + const logger = options.logger ?? new Logger('host-vat'); + const vatName = options.name ?? 'kernelHost'; + + // Promise kit for kernel facet - resolves when bootstrap is called + const kernelFacetKit = makePromiseKit(); + + // Syscall handler - set by kernel during registerSystemVat() + let syscallHandler: SystemVatSyscallHandler | null = null; + + const supervisor: SystemVatSupervisor | null = null; + + // Build root object that captures kernelFacet from bootstrap + const buildRootObject: SystemVatBuildRootObject = () => { + return makeDefaultExo('KernelHostRoot', { + // Bootstrap is called by the kernel with roots and services. + // kernelFacet is always included in services. + bootstrap: ( + _roots: Record, + services: { kernelFacet: KernelFacet }, + ) => { + kernelFacetKit.resolve(services.kernelFacet); + }, + }); + }; + + // Promise kit to signal when supervisor is ready to receive deliveries + const supervisorReady = makePromiseKit(); + + // Create the transport with a deliver function that waits for the supervisor + const deliver: SystemVatDeliverFn = async (delivery) => { + return (supervisor ?? (await supervisorReady.promise)).deliver(delivery); + }; + + const transport: SystemVatTransport = { + deliver, + setSyscallHandler: (handler: SystemVatSyscallHandler) => { + syscallHandler = handler; + }, + // Kernel calls this to wait for connection from supervisor side + awaitConnection: async () => supervisorReady.promise.then(() => undefined), + }; + + /** + * Called after Kernel.make() returns to initiate connection from supervisor side. + * Creates and starts the supervisor, then resolves the connection promise. + */ + const connect = (): void => { + if (!syscallHandler) { + throw new Error( + 'Cannot connect: syscall handler not set. Was Kernel.make() called with this config?', + ); + } + // Create and start the supervisor + SystemVatSupervisor.make({ + buildRootObject, + executeSyscall: syscallHandler, + logger: logger.subLogger({ tags: ['supervisor'] }), + }) + .then((result) => supervisorReady.resolve(result)) + .catch((error) => kernelFacetKit.reject(error as Error)); + }; + + // Config for Kernel.make() + const config: SystemVatConfig = { + name: vatName, + transport, + }; + + return harden({ + config, + connect, + kernelFacetPromise: kernelFacetKit.promise, + }); +} +harden(makeHostVat); diff --git a/packages/nodejs/src/index.ts b/packages/nodejs/src/index.ts index 6af1ec51b..5a6c4041e 100644 --- a/packages/nodejs/src/index.ts +++ b/packages/nodejs/src/index.ts @@ -1,3 +1,5 @@ export { NodejsPlatformServices } from './kernel/PlatformServices.ts'; export { makeKernel } from './kernel/make-kernel.ts'; export { makeNodeJsVatSupervisor } from './vat/make-supervisor.ts'; +export { makeHostVat } from './host-vat/index.ts'; +export type { HostVatResult } from './host-vat/index.ts'; diff --git a/packages/nodejs/test/e2e/system-vat.test.ts b/packages/nodejs/test/e2e/system-vat.test.ts new file mode 100644 index 000000000..b0cbd299d --- /dev/null +++ b/packages/nodejs/test/e2e/system-vat.test.ts @@ -0,0 +1,314 @@ +import { E } from '@endo/eventual-send'; +import { makePromiseKit } from '@endo/promise-kit'; +import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; +import { Logger } from '@metamask/logger'; +import type { ClusterConfig, KernelFacet } from '@metamask/ocap-kernel'; +import { Kernel } from '@metamask/ocap-kernel'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { makeHostVat } from '../../src/host-vat/index.ts'; +import { NodejsPlatformServices } from '../../src/kernel/PlatformServices.ts'; + +type Bob = { + makeGreeter: (greeting: string) => Promise; +}; +type Carol = { + receiveAndGreet: (greeter: Greeter, name: string) => Promise; +}; +type Greeter = { + greet: (name: string) => Promise; +}; + +type PromiseVat = { + makeGreeter: (greeting: string) => Promise; + makeDeferredPromise: () => Promise; + resolveDeferredPromise: (value: unknown) => void; + rejectDeferredPromise: (reason: string) => void; + getRejectingPromise: (reason: string) => Promise; + awaitPromiseArg: (promiseArg: Promise) => Promise; +}; + +describe('system vat e2e tests', { timeout: 30_000 }, () => { + let kernel: Kernel; + let kernelFacet: KernelFacet | Promise; + + beforeEach(async () => { + const logger = new Logger('test'); + + // Create host vat first + const hostVat = makeHostVat({ logger }); + + // Create kernel with system vat config + const platformServices = new NodejsPlatformServices({ + logger: logger.subLogger({ tags: ['platform-services'] }), + }); + const kernelDatabase = await makeSQLKernelDatabase({}); + + // Import Kernel dynamically to avoid circular deps + kernel = await Kernel.make(platformServices, kernelDatabase, { + resetStorage: true, + logger: logger.subLogger({ tags: ['kernel'] }), + hostVat: hostVat.config, + }); + + // Supervisor-side initiates connection AFTER kernel exists + hostVat.connect(); + + // Wait for kernel facet - resolves after bootstrap message is delivered + kernelFacet = await hostVat.kernelFacetPromise; + }); + + afterEach(async () => { + await kernel.clearStorage(); + }); + + describe('basic operations', () => { + it('gets kernel status', async () => { + const status = await E(kernelFacet).getStatus(); + expect(status).toBeDefined(); + expect(status.vats).toBeDefined(); + expect(status.subclusters).toBeDefined(); + }); + + it('launches a subcluster and receives E()-callable presence', async () => { + const config: ClusterConfig = { + bootstrap: 'bob', + vats: { + bob: { + bundleSpec: 'http://localhost:3000/bob-vat.bundle', + }, + }, + }; + + const result = await E(kernelFacet).launchSubcluster(config); + + expect(result.subclusterId).toBeDefined(); + expect(result.root).toBeDefined(); + expect(result.rootKref).toBeDefined(); + + // The root should be E()-callable + const bob = result.root as unknown as Bob; + const greeter = await E(bob).makeGreeter('Hello'); + expect(greeter).toBeDefined(); + }); + + it('terminates a subcluster', async () => { + const config: ClusterConfig = { + bootstrap: 'bob', + vats: { + bob: { + bundleSpec: 'http://localhost:3000/bob-vat.bundle', + }, + }, + }; + + const result = await E(kernelFacet).launchSubcluster(config); + const subcluster = await E(kernelFacet).getSubcluster( + result.subclusterId, + ); + expect(subcluster).toBeDefined(); + + await E(kernelFacet).terminateSubcluster(result.subclusterId); + + const terminatedSubcluster = await E(kernelFacet).getSubcluster( + result.subclusterId, + ); + expect(terminatedSubcluster).toBeUndefined(); + }); + }); + + describe('third-party handoff', () => { + it('host orchestrates handoff between two vats', async () => { + // Launch Bob and Carol in the same subcluster + const config: ClusterConfig = { + bootstrap: 'bob', + vats: { + bob: { + bundleSpec: 'http://localhost:3000/bob-vat.bundle', + }, + carol: { + bundleSpec: 'http://localhost:3000/carol-vat.bundle', + }, + }, + }; + + const launchResult = await E(kernelFacet).launchSubcluster(config); + const bob = launchResult.root as unknown as Bob; + + // Get Carol's root object + const subcluster = await E(kernelFacet).getSubcluster( + launchResult.subclusterId, + ); + expect(subcluster).toBeDefined(); + const carolVatId = subcluster?.vats.find( + (vatId) => vatId !== subcluster.vats[0], + ); + expect(carolVatId).toBeDefined(); + const carolKref = kernel.pinVatRoot(carolVatId!); + const carol = (await E(kernelFacet).getVatRoot(carolKref)) as Carol; + + // Host orchestrates: get exo from Bob, pass to Carol + const greeter = await E(bob).makeGreeter('Greetings'); + const result = await E(carol).receiveAndGreet(greeter, 'Universe'); + + expect(result).toBe('Greetings, Universe!'); + }); + + it('host passes presence between two separate subclusters', async () => { + // Launch Bob in one subcluster + const bobConfig: ClusterConfig = { + bootstrap: 'bob', + vats: { + bob: { + bundleSpec: 'http://localhost:3000/bob-vat.bundle', + }, + }, + }; + const bobResult = await E(kernelFacet).launchSubcluster(bobConfig); + const bob = bobResult.root as unknown as Bob; + + // Launch Carol in another subcluster + const carolConfig: ClusterConfig = { + bootstrap: 'carol', + vats: { + carol: { + bundleSpec: 'http://localhost:3000/carol-vat.bundle', + }, + }, + }; + const carolResult = await E(kernelFacet).launchSubcluster(carolConfig); + const carol = carolResult.root as unknown as Carol; + + // Host orchestrates cross-subcluster handoff + const greeter = await E(bob).makeGreeter('Cross-cluster'); + const result = await E(carol).receiveAndGreet(greeter, 'Test'); + + expect(result).toBe('Cross-cluster, Test!'); + }); + }); + + describe('promise handling', () => { + it('supports promise pipelining (E() on unresolved promise)', async () => { + const config: ClusterConfig = { + bootstrap: 'promiseVat', + vats: { + promiseVat: { + bundleSpec: 'http://localhost:3000/promise-vat.bundle', + }, + }, + }; + + const result = await E(kernelFacet).launchSubcluster(config); + const promiseVat = result.root as unknown as PromiseVat; + + // Get a promise for an exo (without awaiting) + const exoPromise = E(promiseVat).makeGreeter('Hi'); + + // Pipeline: call method on the unresolved promise + const greetingPromise = E(exoPromise).greet('World'); + + // Both should resolve correctly + const greeting = await greetingPromise; + expect(greeting).toBe('Hi, World!'); + }); + + it('handles deferred promise resolution', async () => { + const config: ClusterConfig = { + bootstrap: 'promiseVat', + vats: { + promiseVat: { + bundleSpec: 'http://localhost:3000/promise-vat.bundle', + }, + }, + }; + + const result = await E(kernelFacet).launchSubcluster(config); + const promiseVat = result.root as unknown as PromiseVat; + + // Get a deferred promise (unresolved) + const deferredPromise = E(promiseVat).makeDeferredPromise(); + + // Resolve it + await E(promiseVat).resolveDeferredPromise('resolved value'); + + // The deferred promise should now resolve + const resolvedValue = await deferredPromise; + expect(resolvedValue).toBe('resolved value'); + }); + + it('handles deferred promise rejection', async () => { + const config: ClusterConfig = { + bootstrap: 'promiseVat', + vats: { + promiseVat: { + bundleSpec: 'http://localhost:3000/promise-vat.bundle', + }, + }, + }; + + const result = await E(kernelFacet).launchSubcluster(config); + const promiseVat = result.root as unknown as PromiseVat; + + // Get a deferred promise (unresolved) + const deferredPromise = E(promiseVat).makeDeferredPromise(); + + // Reject it + await E(promiseVat).rejectDeferredPromise('error reason'); + + // Rejections from vats throw errors + await expect(deferredPromise).rejects.toThrow('error reason'); + }); + + it('vat awaits promise created in host vat', async () => { + const config: ClusterConfig = { + bootstrap: 'promiseVat', + vats: { + promiseVat: { + bundleSpec: 'http://localhost:3000/promise-vat.bundle', + }, + }, + }; + + const result = await E(kernelFacet).launchSubcluster(config); + const promiseVat = result.root as unknown as PromiseVat; + + // Create a deferred promise in the host vat (this test) + const { promise, resolve } = makePromiseKit(); + + // Pass the unresolved promise to the vat + const resultPromise = E(promiseVat).awaitPromiseArg(promise); + + // Resolve the deferred promise from the host vat + resolve('host-resolved-value'); + + // The vat should receive the resolved value + const vatResult = await resultPromise; + expect(vatResult).toBe('received: host-resolved-value'); + }); + }); + + describe('kref to presence restoration', () => { + it('converts stored kref back to E()-callable presence', async () => { + const config: ClusterConfig = { + bootstrap: 'bob', + vats: { + bob: { + bundleSpec: 'http://localhost:3000/bob-vat.bundle', + }, + }, + }; + + // Launch and get rootKref for storage + const result = await E(kernelFacet).launchSubcluster(config); + const storedKref = result.rootKref; + + // Later: restore presence from kref + const restoredBob = (await E(kernelFacet).getVatRoot(storedKref)) as Bob; + + // The restored presence should be E()-callable + const greeter = await E(restoredBob).makeGreeter('Restored'); + const greeting = await E(greeter).greet('World'); + expect(greeting).toBe('Restored, World!'); + }); + }); +}); diff --git a/packages/nodejs/test/vats/alice-vat.js b/packages/nodejs/test/vats/alice-vat.js new file mode 100644 index 000000000..343cdefcf --- /dev/null +++ b/packages/nodejs/test/vats/alice-vat.js @@ -0,0 +1,42 @@ +import { E } from '@endo/eventual-send'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +/** + * Build function for Alice's vat. + * Alice orchestrates the third-party handoff between Bob and Carol. + * + * @param {object} vatPowers - Special powers granted to this vat. + * @param {object} vatPowers.logger - The logger object. + * @returns {object} The root object for the new vat. + */ +export function buildRootObject({ logger }) { + return makeDefaultExo('aliceRoot', { + bootstrap() { + logger.log('Alice vat bootstrap'); + }, + + /** + * Orchestrates a third-party handoff by getting an exo from Bob, + * passing it to Carol, and having Carol use it. + * + * @param {object} bob - Reference to Bob's vat root. + * @param {object} carol - Reference to Carol's vat root. + * @param {string} greeting - The greeting for Bob to use. + * @param {string} name - The name for Carol to greet. + * @returns {Promise} The greeting result. + */ + async performHandoff(bob, carol, greeting, name) { + logger.log('Alice starting handoff'); + + // Get exo from Bob + const greeter = await E(bob).makeGreeter(greeting); + logger.log('Alice received greeter from Bob'); + + // Pass to Carol and have her use it + const result = await E(carol).receiveAndGreet(greeter, name); + logger.log(`Alice got result: ${result}`); + + return result; + }, + }); +} diff --git a/packages/nodejs/test/vats/bob-vat.js b/packages/nodejs/test/vats/bob-vat.js new file mode 100644 index 000000000..25dc085c0 --- /dev/null +++ b/packages/nodejs/test/vats/bob-vat.js @@ -0,0 +1,34 @@ +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +/** + * Build function for Bob's vat. + * Bob can create greeter exos that can be passed to other vats. + * + * @param {object} vatPowers - Special powers granted to this vat. + * @param {object} vatPowers.logger - The logger object. + * @returns {object} The root object for the new vat. + */ +export function buildRootObject({ logger }) { + return makeDefaultExo('bobRoot', { + bootstrap() { + logger.log('Bob vat bootstrap'); + }, + + /** + * Create a greeter exo that can greet with a custom message. + * This exo can be passed to other vats (third-party handoff). + * + * @param {string} greeting - The greeting prefix to use. + * @returns {object} A greeter exo with a greet method. + */ + makeGreeter(greeting) { + return makeDefaultExo('greeter', { + greet(name) { + const message = `${greeting}, ${name}!`; + logger.log(`Greeter says: ${message}`); + return message; + }, + }); + }, + }); +} diff --git a/packages/nodejs/test/vats/carol-vat.js b/packages/nodejs/test/vats/carol-vat.js new file mode 100644 index 000000000..b97244be7 --- /dev/null +++ b/packages/nodejs/test/vats/carol-vat.js @@ -0,0 +1,60 @@ +import { E } from '@endo/eventual-send'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +/** + * Build function for Carol's vat. + * Carol can receive exos from other vats and call methods on them. + * + * @param {object} vatPowers - Special powers granted to this vat. + * @param {object} vatPowers.logger - The logger object. + * @returns {object} The root object for the new vat. + */ +export function buildRootObject({ logger }) { + /** @type {object | null} */ + let storedExo = null; + + return makeDefaultExo('carolRoot', { + bootstrap() { + logger.log('Carol vat bootstrap'); + }, + + /** + * Receive an exo and immediately call a method on it. + * This proves the third-party handoff worked. + * + * @param {object} exo - An exo received from another vat. + * @param {string} name - The name to greet. + * @returns {Promise} The greeting from the exo. + */ + receiveAndGreet(exo, name) { + logger.log(`Carol received exo and will greet "${name}"`); + return E(exo).greet(name); + }, + + /** + * Store an exo for later use. + * + * @param {object} exo - An exo to store. + * @returns {string} Confirmation message. + */ + storeExo(exo) { + storedExo = exo; + logger.log('Carol stored exo'); + return 'stored'; + }, + + /** + * Use a previously stored exo to greet. + * + * @param {string} name - The name to greet. + * @returns {Promise} The greeting from the stored exo. + */ + useStoredExo(name) { + if (!storedExo) { + throw new Error('No exo stored'); + } + logger.log(`Carol using stored exo to greet "${name}"`); + return E(storedExo).greet(name); + }, + }); +} diff --git a/packages/nodejs/test/vats/promise-vat.js b/packages/nodejs/test/vats/promise-vat.js new file mode 100644 index 000000000..223f376a4 --- /dev/null +++ b/packages/nodejs/test/vats/promise-vat.js @@ -0,0 +1,124 @@ +import { E } from '@endo/eventual-send'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +/** + * Build function for a vat that tests promise behaviors. + * This vat provides methods to test kernel promise (kp kref) handling. + * + * @param {object} vatPowers - Special powers granted to this vat. + * @param {object} vatPowers.logger - The logger object. + * @returns {object} The root object for the new vat. + */ +export function buildRootObject({ logger }) { + let deferredResolver = null; + let deferredRejecter = null; + + return makeDefaultExo('promiseRoot', { + bootstrap() { + logger.log('Promise vat bootstrap'); + }, + + /** + * Returns a promise that resolves to a greeter exo. + * + * @param {string} greeting - The greeting prefix to use. + * @returns {Promise} A promise resolving to a greeter exo. + */ + makeGreeter(greeting) { + logger.log(`makeGreeter called with greeting: ${greeting}`); + return makeDefaultExo('greeter', { + greet(name) { + const message = `${greeting}, ${name}!`; + logger.log(`Greeter says: ${message}`); + return message; + }, + }); + }, + + /** + * Makes a deferred promise that can be resolved or rejected + * via separate method calls. + * + * @returns {Promise} An unresolved promise. + */ + makeDeferredPromise() { + logger.log('makeDeferredPromise called'); + return new Promise((resolve, reject) => { + deferredResolver = resolve; + deferredRejecter = reject; + }); + }, + + /** + * Resolves the deferred promise created by makeDeferredPromise. + * + * @param {unknown} value - The value to resolve with. + */ + resolveDeferredPromise(value) { + logger.log(`resolveDeferredPromise called with: ${value}`); + if (deferredResolver) { + deferredResolver(value); + deferredResolver = null; + deferredRejecter = null; + } else { + logger.log('No deferred promise to resolve'); + } + }, + + /** + * Rejects the deferred promise created by makeDeferredPromise. + * + * @param {string} reason - The rejection reason. + */ + rejectDeferredPromise(reason) { + logger.log(`rejectDeferredPromise called with reason: ${reason}`); + if (deferredRejecter) { + deferredRejecter(new Error(reason)); + deferredResolver = null; + deferredRejecter = null; + } else { + logger.log('No deferred promise to reject'); + } + }, + + /** + * Returns a promise that immediately rejects with the given reason. + * + * @param {string} reason - The rejection reason. + * @returns {Promise} A rejecting promise. + */ + getRejectingPromise(reason) { + logger.log(`getRejectingPromise called with reason: ${reason}`); + return Promise.reject(new Error(reason)); + }, + + /** + * Accepts a promise argument and awaits it before returning. + * + * @param {Promise} promiseArg - A promise to await. + * @returns {Promise} A message containing the resolved value. + */ + async awaitPromiseArg(promiseArg) { + logger.log('awaitPromiseArg called, awaiting promise...'); + const result = await promiseArg; + logger.log(`awaitPromiseArg resolved to: ${result}`); + return `received: ${result}`; + }, + + /** + * Gets a deferred promise from another vat and awaits it. + * This tests cross-vat kernel promise handling. + * + * @param {object} promiserVat - A reference to another promise-vat. + * @returns {Promise} A message containing the resolved value. + */ + async awaitDeferredFromVat(promiserVat) { + logger.log('awaitDeferredFromVat called, getting deferred promise...'); + const deferredPromise = E(promiserVat).makeDeferredPromise(); + logger.log('Got deferred promise, awaiting...'); + const result = await deferredPromise; + logger.log(`Deferred promise resolved to: ${result}`); + return `received: ${result}`; + }, + }); +} diff --git a/packages/ocap-kernel/package.json b/packages/ocap-kernel/package.json index 0e12041ae..e00a40631 100644 --- a/packages/ocap-kernel/package.json +++ b/packages/ocap-kernel/package.json @@ -39,6 +39,16 @@ "default": "./dist/rpc/index.cjs" } }, + "./vats": { + "import": { + "types": "./dist/vats/index.d.mts", + "default": "./dist/vats/index.mjs" + }, + "require": { + "types": "./dist/vats/index.d.cts", + "default": "./dist/vats/index.cjs" + } + }, "./package.json": "./package.json" }, "main": "./dist/index.cjs", @@ -63,9 +73,10 @@ "test": "vitest run --config vitest.config.ts", "test:clean": "yarn test --no-cache --coverage.clean", "test:dev": "yarn test --mode development", + "test:dev:quiet": "yarn test:dev --reporter @ocap/repo-tools/vitest-reporters/silent", + "test:integration": "vitest run --config vitest.integration.config.ts", "test:verbose": "yarn test --reporter verbose", - "test:watch": "vitest --config vitest.config.ts", - "test:dev:quiet": "yarn test:dev --reporter @ocap/repo-tools/vitest-reporters/silent" + "test:watch": "vitest --config vitest.config.ts" }, "dependencies": { "@agoric/swingset-liveslots": "0.10.3-u21.0.1", @@ -105,6 +116,7 @@ "uint8arrays": "^5.1.0" }, "devDependencies": { + "@endo/eventual-send": "^1.3.4", "@metamask/auto-changelog": "^5.3.0", "@metamask/eslint-config": "^15.0.0", "@metamask/eslint-config-nodejs": "^15.0.0", diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index e759f5001..eec0ceb84 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -14,18 +14,21 @@ import { makeKernelStore } from './store/index.ts'; import type { KernelStore } from './store/index.ts'; import type { VatId, + SystemVatId, EndpointId, KRef, PlatformServices, ClusterConfig, + SystemVatConfig, VatConfig, KernelStatus, Subcluster, SubclusterLaunchResult, EndpointHandle, } from './types.ts'; -import { isVatId, isRemoteId } from './types.ts'; +import { isVatId, isRemoteId, isSystemVatId } from './types.ts'; import { SubclusterManager } from './vats/SubclusterManager.ts'; +import { SystemVatManager } from './vats/SystemVatManager.ts'; import type { VatHandle } from './vats/VatHandle.ts'; import { VatManager } from './vats/VatManager.ts'; @@ -49,6 +52,9 @@ export class Kernel { /** Manages subcluster operations */ readonly #subclusterManager: SubclusterManager; + /** Manages system vat operations */ + readonly #systemVatManager: SystemVatManager; + /** Manages remote kernel connections */ readonly #remoteManager: RemoteManager; @@ -76,6 +82,12 @@ export class Kernel { /** The kernel's router */ readonly #kernelRouter: KernelRouter; + /** + * Host vat configuration passed to Kernel.make(). + * Stored for connection after initialization. + */ + readonly #hostVatConfig: SystemVatConfig | undefined; + /** * Construct a new kernel instance. * @@ -86,6 +98,7 @@ export class Kernel { * @param options.logger - Optional logger for error and diagnostic output. * @param options.keySeed - Optional seed for libp2p key generation. * @param options.mnemonic - Optional BIP39 mnemonic for deriving the kernel identity. + * @param options.hostVat - Optional host vat configuration to connect at kernel creation. */ // eslint-disable-next-line no-restricted-syntax private constructor( @@ -96,11 +109,13 @@ export class Kernel { logger?: Logger; keySeed?: string | undefined; mnemonic?: string | undefined; + hostVat?: SystemVatConfig; } = {}, ) { this.#platformServices = platformServices; this.#logger = options.logger ?? new Logger('ocap-kernel'); this.#kernelStore = makeKernelStore(kernelDatabase, this.#logger); + this.#hostVatConfig = options.hostVat; if (!this.#kernelStore.kv.get('initialized')) { this.#kernelStore.kv.set('initialized', 'true'); } @@ -155,6 +170,22 @@ export class Kernel { queueMessage: this.queueMessage.bind(this), }); + this.#systemVatManager = new SystemVatManager({ + kernelStore: this.#kernelStore, + kernelQueue: this.#kernelQueue, + kernelFacetDeps: { + launchSubcluster: this.launchSubcluster.bind(this), + terminateSubcluster: this.terminateSubcluster.bind(this), + reloadSubcluster: this.reloadSubcluster.bind(this), + getSubcluster: this.getSubcluster.bind(this), + getSubclusters: this.getSubclusters.bind(this), + getStatus: this.getStatus.bind(this), + }, + registerKernelService: (name, service) => + this.#kernelServiceManager.registerKernelServiceObject(name, service), + logger: this.#logger.subLogger({ tags: ['SystemVatManager'] }), + }); + this.#kernelRouter = new KernelRouter( this.#kernelStore, this.#kernelQueue, @@ -190,6 +221,7 @@ export class Kernel { * @param options.logger - Optional logger for error and diagnostic output. * @param options.keySeed - Optional seed for libp2p key generation. * @param options.mnemonic - Optional BIP39 mnemonic for deriving the kernel identity. + * @param options.hostVat - Optional host vat configuration to connect at kernel creation. * @returns A promise for the new kernel instance. */ static async make( @@ -200,6 +232,7 @@ export class Kernel { logger?: Logger; keySeed?: string | undefined; mnemonic?: string | undefined; + hostVat?: SystemVatConfig; } = {}, ): Promise { const kernel = new Kernel(platformServices, kernelDatabase, options); @@ -217,10 +250,28 @@ export class Kernel { this.#remoteManager.handleRemoteMessage(from, message), ); + // Clean up any orphaned system vat state from a previous session. + // This handles crash recovery where disconnect was never called. + this.#cleanupOrphanedSystemVats(); + // Start all vats that were previously running before starting the queue // This ensures that any messages in the queue have their target vats ready await this.#vatManager.initializeAllVats(); + // Register host vat if configured. + // This runs asynchronously - the registration completes when the supervisor + // side calls connect() via the transport's awaitConnection(). + if (this.#hostVatConfig) { + this.#systemVatManager + .registerSystemVat(this.#hostVatConfig) + .catch((error) => { + this.#logger.error( + `Failed to register host vat ${this.#hostVatConfig?.name}:`, + error, + ); + }); + } + // Start the kernel queue processing (non-blocking) // This runs for the entire lifetime of the kernel this.#kernelQueue @@ -234,6 +285,69 @@ export class Kernel { }); } + /** + * Clean up orphaned system vat state from a previous session. + * + * System vats are ephemeral - they don't persist across restarts. However, + * their krefs (for owned objects) are persisted to the database. If the + * kernel restarts without properly disconnecting system vats (e.g., crash, + * browser refresh), orphaned state can remain. + * + * This method scans for system vat state and cleans it up before new + * system vats are registered, ensuring a clean slate. + */ + #cleanupOrphanedSystemVats(): void { + // Scan for system vat c-list keys (sv*.c.*) to find orphaned system vats + const orphanedSystemVatIds = new Set(); + const { kv } = this.#kernelStore; + + // Look for c-list entries with system vat prefixes (sv0, sv1, etc.) + // C-list keys use the format: {endpointId}.c.{slot} + let key: string | undefined = 'sv'; + while ((key = kv.getNextKey(key)) !== undefined) { + if (!key.startsWith('sv')) { + break; + } + // Extract the system vat ID from keys like "sv0.c.o+0" + const parts = key.split('.'); + if (parts.length >= 2 && parts[1] === 'c') { + const endpointId = parts[0]; + if (isSystemVatId(endpointId)) { + orphanedSystemVatIds.add(endpointId); + } + } + } + + for (const systemVatId of orphanedSystemVatIds) { + this.#logger.log( + `Cleaning up orphaned system vat state for ${systemVatId}`, + ); + + // Reject pending promises where this vat was the decider + const failure = { body: '"System vat disconnected (orphan cleanup)"' }; + for (const kpid of this.#kernelStore.getPromisesByDecider(systemVatId)) { + // Since there's no active vat, we directly resolve the promise + // in the kernel store rather than going through the queue + this.#kernelStore.resolveKernelPromise(kpid, true, { + ...failure, + slots: [], + }); + } + + // Clean up kernel state: exports, imports, promises, c-list entries + const work = this.#kernelStore.cleanupTerminatedVat(systemVatId); + this.#logger.debug( + `Orphaned system vat ${systemVatId} cleanup: ${work.exports} exports, ${work.imports} imports, ${work.promises} promises`, + ); + } + + if (orphanedSystemVatIds.size > 0) { + this.#logger.log( + `Cleaned up ${orphanedSystemVatIds.size} orphaned system vat(s)`, + ); + } + } + /** * Initialize the remote comms object. * @@ -401,7 +515,7 @@ export class Kernel { * * @param endpointId - The ID of the endpoint to retrieve. * @returns The endpoint handle for the given ID. - * @throws If the endpoint ID is invalid (neither a vat ID nor a remote ID). + * @throws If the endpoint ID is invalid (neither a vat ID, remote ID, nor system vat ID). */ #getEndpoint(endpointId: EndpointId): EndpointHandle { if (isVatId(endpointId)) { @@ -410,6 +524,14 @@ export class Kernel { if (isRemoteId(endpointId)) { return this.#remoteManager.getRemote(endpointId); } + if (isSystemVatId(endpointId)) { + const systemVatId = endpointId as SystemVatId; + const handle = this.#systemVatManager.getSystemVatHandle(systemVatId); + if (!handle) { + throw Error(`system vat ${systemVatId} not found`); + } + return handle; + } // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw Error(`invalid endpoint ID ${endpointId}`); } @@ -460,6 +582,9 @@ export class Kernel { * Get the current kernel status, defined as the current cluster configuration * and a list of all running vats. * + * Returns a promise that resolves in a future crank to avoid deadlock when + * called from within a crank (e.g., via E(kernelFacet).getStatus()). + * * @returns A promise for the current kernel status containing vats, subclusters, and remote comms information. */ async getStatus(): Promise { diff --git a/packages/ocap-kernel/src/KernelRouter.ts b/packages/ocap-kernel/src/KernelRouter.ts index d26b82ea1..2e7c2a8ad 100644 --- a/packages/ocap-kernel/src/KernelRouter.ts +++ b/packages/ocap-kernel/src/KernelRouter.ts @@ -45,10 +45,7 @@ export class KernelRouter { readonly #getEndpoint: (endpointId: EndpointId) => EndpointHandle; /** A function that invokes a method on a kernel service. */ - readonly #invokeKernelService: ( - target: KRef, - message: Message, - ) => Promise; + readonly #invokeKernelService: (target: KRef, message: Message) => void; /** The logger, if any. */ readonly #logger: Logger | undefined; @@ -66,7 +63,7 @@ export class KernelRouter { kernelStore: KernelStore, kernelQueue: KernelQueue, getEndpoint: (endpointId: EndpointId) => EndpointHandle, - invokeKernelService: (target: KRef, message: Message) => Promise, + invokeKernelService: (target: KRef, message: Message) => void, logger?: Logger, ) { this.#kernelStore = kernelStore; @@ -266,7 +263,7 @@ export class KernelRouter { // Continue processing other messages - don't let one failure crash the queue } } else if (isKernelServiceMessage) { - crankResults = await this.#deliverKernelServiceMessage(target, message); + crankResults = this.#deliverKernelServiceMessage(target, message); } else { Fail`no owner for kernel object ${target}`; } @@ -288,11 +285,8 @@ export class KernelRouter { * @param message - The message to deliver to the service. * @returns A promise that resolves to the crank results indicating the delivery was to the kernel. */ - async #deliverKernelServiceMessage( - target: KRef, - message: Message, - ): Promise { - await this.#invokeKernelService(target, message); + #deliverKernelServiceMessage(target: KRef, message: Message): CrankResults { + this.#invokeKernelService(target, message); return { didDelivery: 'kernel' }; } diff --git a/packages/ocap-kernel/src/KernelServiceManager.test.ts b/packages/ocap-kernel/src/KernelServiceManager.test.ts index 0334437b2..f24cc1bd4 100644 --- a/packages/ocap-kernel/src/KernelServiceManager.test.ts +++ b/packages/ocap-kernel/src/KernelServiceManager.test.ts @@ -1,3 +1,4 @@ +import { delay } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import { describe, it, expect, beforeEach, vi } from 'vitest'; @@ -173,7 +174,8 @@ describe('KernelServiceManager', () => { methargs: kser(['testMethod', ['arg1', 'arg2']]), }; - await serviceManager.invokeKernelService(registered.kref, message); + serviceManager.invokeKernelService(registered.kref, message); + await delay(); expect(testMethod).toHaveBeenCalledWith('arg1', 'arg2'); expect(mockKernelQueue.resolvePromises).not.toHaveBeenCalled(); @@ -195,7 +197,8 @@ describe('KernelServiceManager', () => { result: 'kp123', }; - await serviceManager.invokeKernelService(registered.kref, message); + serviceManager.invokeKernelService(registered.kref, message); + await delay(); expect(testMethod).toHaveBeenCalledWith('arg1'); expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [ @@ -220,7 +223,8 @@ describe('KernelServiceManager', () => { result: 'kp123', }; - await serviceManager.invokeKernelService(registered.kref, message); + serviceManager.invokeKernelService(registered.kref, message); + await delay(); expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [ ['kp123', true, kser(testError)], @@ -244,7 +248,8 @@ describe('KernelServiceManager', () => { methargs: kser(['testMethod', []]), }; - await serviceManager.invokeKernelService(registered.kref, message); + serviceManager.invokeKernelService(registered.kref, message); + await delay(); expect(loggerErrorSpy).toHaveBeenCalledWith( 'Error in kernel service method:', @@ -253,17 +258,17 @@ describe('KernelServiceManager', () => { expect(mockKernelQueue.resolvePromises).not.toHaveBeenCalled(); }); - it('throws error for non-existent service', async () => { + it('throws error for non-existent service', () => { const message: Message = { methargs: kser(['testMethod', []]), }; - await expect( + expect(() => serviceManager.invokeKernelService('ko999', message), - ).rejects.toThrow('No registered service for ko999'); + ).toThrow('No registered service for ko999'); }); - it('handles unknown method with result', async () => { + it('handles unknown method with result', () => { const testService = { existingMethod: () => 'test', }; @@ -278,14 +283,14 @@ describe('KernelServiceManager', () => { result: 'kp123', }; - await serviceManager.invokeKernelService(registered.kref, message); + serviceManager.invokeKernelService(registered.kref, message); expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [ ['kp123', true, kser(Error("unknown service method 'unknownMethod'"))], ]); }); - it('handles unknown method without result', async () => { + it('handles unknown method without result', () => { const loggerErrorSpy = vi.spyOn(logger, 'error'); const testService = { existingMethod: () => 'test', @@ -300,7 +305,7 @@ describe('KernelServiceManager', () => { methargs: kser(['unknownMethod', []]), }; - await serviceManager.invokeKernelService(registered.kref, message); + serviceManager.invokeKernelService(registered.kref, message); expect(loggerErrorSpy).toHaveBeenCalledWith( "unknown service method 'unknownMethod'", @@ -308,7 +313,7 @@ describe('KernelServiceManager', () => { expect(mockKernelQueue.resolvePromises).not.toHaveBeenCalled(); }); - it('handles service with no methods', async () => { + it('handles service with no methods', () => { const emptyService = {}; const registered = serviceManager.registerKernelServiceObject( @@ -321,7 +326,7 @@ describe('KernelServiceManager', () => { result: 'kp123', }; - await serviceManager.invokeKernelService(registered.kref, message); + serviceManager.invokeKernelService(registered.kref, message); expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [ ['kp123', true, kser(Error("unknown service method 'anyMethod'"))], @@ -343,11 +348,37 @@ describe('KernelServiceManager', () => { result: 'kp123', }; - await serviceManager.invokeKernelService(registered.kref, message); + serviceManager.invokeKernelService(registered.kref, message); + await delay(); expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [ ['kp123', false, kser(undefined)], ]); }); + + it('handles synchronous errors thrown by service method', () => { + const testError = new Error('Sync error'); + const testService = { + throwingMethod: () => { + throw testError; + }, + }; + + const registered = serviceManager.registerKernelServiceObject( + 'testService', + testService, + ); + + const message: Message = { + methargs: kser(['throwingMethod', []]), + result: 'kp123', + }; + + serviceManager.invokeKernelService(registered.kref, message); + + expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [ + ['kp123', true, kser(testError)], + ]); + }); }); }); diff --git a/packages/ocap-kernel/src/KernelServiceManager.ts b/packages/ocap-kernel/src/KernelServiceManager.ts index 5fcb74fb8..ccfb1f904 100644 --- a/packages/ocap-kernel/src/KernelServiceManager.ts +++ b/packages/ocap-kernel/src/KernelServiceManager.ts @@ -109,10 +109,16 @@ export class KernelServiceManager { /** * Invoke a kernel service. * + * This method does NOT await the service method result. Instead, it uses + * promise chaining to resolve the kernel promise when the method eventually + * completes. This allows service methods to use `waitForCrank()` without + * causing deadlock - the crank can complete, and the resolution happens + * in a future turn of the event loop. + * * @param target - The target kref of the service. * @param message - The message to invoke the service with. */ - async invokeKernelService(target: KRef, message: Message): Promise { + invokeKernelService(target: KRef, message: Message): void { const kernelService = this.#kernelServicesByObject.get(target); if (!kernelService) { throw Error(`No registered service for ${target}`); @@ -138,20 +144,40 @@ export class KernelServiceManager { } assert.typeof(methodFunction, 'function'); assert(Array.isArray(args)); + + // Call the method without awaiting. This allows the crank to complete + // even if the method internally waits for the crank to end. try { - const resultValue = await methodFunction.apply(service, args); - if (result) { - this.#kernelQueue.resolvePromises('kernel', [ - [result, false, kser(resultValue)], - ]); - } - } catch (problem) { + const maybePromise = methodFunction.apply(service, args); + // Use Promise.resolve to normalize: if maybePromise is a Promise, it + // returns that Promise; if it's a value, it returns an immediately- + // resolved Promise. + Promise.resolve(maybePromise) + .then((resultValue) => { + if (result) { + this.#kernelQueue.resolvePromises('kernel', [ + [result, false, kser(resultValue)], + ]); + } + return undefined; + }) + .catch((problem: unknown) => { + if (result) { + this.#kernelQueue.resolvePromises('kernel', [ + [result, true, kser(problem)], + ]); + } else { + this.#logger?.error('Error in kernel service method:', problem); + } + }); + } catch (syncError) { + // Handle synchronous errors thrown before returning a Promise if (result) { this.#kernelQueue.resolvePromises('kernel', [ - [result, true, kser(problem)], + [result, true, kser(syncError)], ]); } else { - this.#logger?.error('Error in kernel service method:', problem); + this.#logger?.error('Error in kernel service method:', syncError); } } } diff --git a/packages/ocap-kernel/src/index.test.ts b/packages/ocap-kernel/src/index.test.ts index 6f862268b..62d8d9d0d 100644 --- a/packages/ocap-kernel/src/index.test.ts +++ b/packages/ocap-kernel/src/index.test.ts @@ -10,12 +10,14 @@ describe('index', () => { 'Kernel', 'KernelStatusStruct', 'SubclusterStruct', + 'SystemVatIdStruct', 'VatConfigStruct', 'VatHandle', 'VatIdStruct', 'VatSupervisor', 'generateMnemonic', 'initTransport', + 'isSystemVatId', 'isValidMnemonic', 'isVatConfig', 'isVatId', diff --git a/packages/ocap-kernel/src/index.ts b/packages/ocap-kernel/src/index.ts index 2e4aa3532..29b20aba3 100644 --- a/packages/ocap-kernel/src/index.ts +++ b/packages/ocap-kernel/src/index.ts @@ -4,6 +4,7 @@ export { VatSupervisor } from './vats/VatSupervisor.ts'; export { initTransport } from './remotes/platform/transport.ts'; export type { ClusterConfig, + DeliveryObject, KRef, Message, VatId, @@ -13,6 +14,15 @@ export type { Subcluster, SubclusterId, SubclusterLaunchResult, + // System vat types + SystemVatId, + SystemVatBuildRootObject, + // System vat transport types (for Kernel.make()) + SystemVatTransport, + SystemVatSyscallHandler, + SystemVatDeliverFn, + SystemVatConfig, + SystemVatRegistrationResult, } from './types.ts'; export type { RemoteMessageHandler, @@ -30,9 +40,17 @@ export { CapDataStruct, KernelStatusStruct, SubclusterStruct, + // System vat exports + isSystemVatId, + SystemVatIdStruct, } from './types.ts'; export { kunser, kser, kslot, krefOf } from './liveslots/kernel-marshal.ts'; export type { SlotValue } from './liveslots/kernel-marshal.ts'; +export type { + KernelFacet, + KernelFacetLaunchResult, + KernelFacetRegisterSystemVatResult, +} from './kernel-facet.ts'; export { makeKernelStore } from './store/index.ts'; export type { KernelStore } from './store/index.ts'; export { parseRef } from './store/utils/parse-ref.ts'; diff --git a/packages/ocap-kernel/src/kernel-facet.test.ts b/packages/ocap-kernel/src/kernel-facet.test.ts new file mode 100644 index 000000000..45c8c685d --- /dev/null +++ b/packages/ocap-kernel/src/kernel-facet.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { KernelFacetDependencies } from './kernel-facet.ts'; +import { makeKernelFacet } from './kernel-facet.ts'; +import type { SlotValue } from './liveslots/kernel-marshal.ts'; +import { krefOf } from './liveslots/kernel-marshal.ts'; +import type { ClusterConfig, KernelStatus, Subcluster } from './types.ts'; + +describe('makeKernelFacet', () => { + let deps: KernelFacetDependencies; + + beforeEach(() => { + deps = { + launchSubcluster: vi.fn().mockResolvedValue({ + subclusterId: 's1', + bootstrapRootKref: 'ko1', + }), + terminateSubcluster: vi.fn().mockResolvedValue(undefined), + reloadSubcluster: vi.fn().mockResolvedValue({ + id: 's2', + config: { bootstrap: 'test', vats: {} }, + vats: {}, + }), + getSubcluster: vi.fn().mockReturnValue({ + id: 's1', + config: { bootstrap: 'test', vats: {} }, + vats: {}, + }), + getSubclusters: vi + .fn() + .mockReturnValue([ + { id: 's1', config: { bootstrap: 'test', vats: {} }, vats: {} }, + ]), + getStatus: vi.fn().mockResolvedValue({ + initialized: true, + cranksExecuted: 10, + cranksPending: 0, + vatCount: 2, + endpointCount: 3, + }), + }; + }); + + it('creates a kernel facet object', () => { + const facet = makeKernelFacet(deps); + expect(facet).toBeDefined(); + expect(typeof facet).toBe('object'); + }); + + describe('launchSubcluster', () => { + it('calls the launchSubcluster dependency', async () => { + const facet = makeKernelFacet(deps) as { + launchSubcluster: (config: ClusterConfig) => Promise; + }; + const config: ClusterConfig = { + bootstrap: 'myVat', + vats: { myVat: { sourceSpec: 'test.js' } }, + }; + + await facet.launchSubcluster(config); + + expect(deps.launchSubcluster).toHaveBeenCalledWith(config); + }); + + it('returns subclusterId and root as slot value', async () => { + const facet = makeKernelFacet(deps) as { + launchSubcluster: ( + config: ClusterConfig, + ) => Promise<{ subclusterId: string; root: SlotValue }>; + }; + const config: ClusterConfig = { + bootstrap: 'myVat', + vats: { myVat: { sourceSpec: 'test.js' } }, + }; + + const result = await facet.launchSubcluster(config); + + expect(result.subclusterId).toBe('s1'); + // The root is a slot value (remotable) that carries the kref + expect(krefOf(result.root)).toBe('ko1'); + }); + }); + + describe('terminateSubcluster', () => { + it('calls the terminateSubcluster dependency', async () => { + const facet = makeKernelFacet(deps) as { + terminateSubcluster: (id: string) => Promise; + }; + + await facet.terminateSubcluster('s1'); + + expect(deps.terminateSubcluster).toHaveBeenCalledWith('s1'); + }); + }); + + describe('reloadSubcluster', () => { + it('calls the reloadSubcluster dependency', async () => { + const facet = makeKernelFacet(deps) as { + reloadSubcluster: (id: string) => Promise; + }; + + await facet.reloadSubcluster('s1'); + + expect(deps.reloadSubcluster).toHaveBeenCalledWith('s1'); + }); + + it('returns the reloaded subcluster', async () => { + const facet = makeKernelFacet(deps) as { + reloadSubcluster: (id: string) => Promise; + }; + + const result = await facet.reloadSubcluster('s1'); + + expect(result.id).toBe('s2'); + }); + }); + + describe('getSubcluster', () => { + it('calls the getSubcluster dependency', () => { + const facet = makeKernelFacet(deps) as { + getSubcluster: (id: string) => Subcluster | undefined; + }; + + facet.getSubcluster('s1'); + + expect(deps.getSubcluster).toHaveBeenCalledWith('s1'); + }); + + it('returns the subcluster', () => { + const facet = makeKernelFacet(deps) as { + getSubcluster: (id: string) => Subcluster | undefined; + }; + + const result = facet.getSubcluster('s1'); + + expect(result?.id).toBe('s1'); + }); + + it('returns undefined for unknown subcluster', () => { + vi.spyOn(deps, 'getSubcluster') + .mockImplementation() + .mockReturnValue(undefined); + const facet = makeKernelFacet(deps) as { + getSubcluster: (id: string) => Subcluster | undefined; + }; + + const result = facet.getSubcluster('unknown'); + + expect(result).toBeUndefined(); + }); + }); + + describe('getSubclusters', () => { + it('calls the getSubclusters dependency', () => { + const facet = makeKernelFacet(deps) as { + getSubclusters: () => Subcluster[]; + }; + + facet.getSubclusters(); + + expect(deps.getSubclusters).toHaveBeenCalled(); + }); + + it('returns all subclusters', () => { + const facet = makeKernelFacet(deps) as { + getSubclusters: () => Subcluster[]; + }; + + const result = facet.getSubclusters(); + + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe('s1'); + }); + }); + + describe('getStatus', () => { + it('calls the getStatus dependency', async () => { + const facet = makeKernelFacet(deps) as { + getStatus: () => Promise; + }; + + await facet.getStatus(); + + expect(deps.getStatus).toHaveBeenCalled(); + }); + + it('returns kernel status', async () => { + const facet = makeKernelFacet(deps) as { + getStatus: () => Promise; + }; + + const result = await facet.getStatus(); + + expect(result.initialized).toBe(true); + expect(result.cranksExecuted).toBe(10); + expect(result.cranksPending).toBe(0); + expect(result.vatCount).toBe(2); + expect(result.endpointCount).toBe(3); + }); + }); +}); diff --git a/packages/ocap-kernel/src/kernel-facet.ts b/packages/ocap-kernel/src/kernel-facet.ts new file mode 100644 index 000000000..eed9865d6 --- /dev/null +++ b/packages/ocap-kernel/src/kernel-facet.ts @@ -0,0 +1,244 @@ +import { makeDefaultExo } from '@metamask/kernel-utils'; + +import type { Kernel } from './Kernel.ts'; +import { kslot } from './liveslots/kernel-marshal.ts'; +import type { SlotValue } from './liveslots/kernel-marshal.ts'; +import type { + ClusterConfig, + Subcluster, + KernelStatus, + SystemVatConfig, + SystemVatId, +} from './types.ts'; +import type { SystemVatManager } from './vats/SystemVatManager.ts'; + +/** + * Dependencies required to create a kernel facet. + */ +export type KernelFacetDependencies = Pick< + Kernel, + | 'launchSubcluster' + | 'terminateSubcluster' + | 'reloadSubcluster' + | 'getSubcluster' + | 'getSubclusters' + | 'getStatus' +> & { + /** Optional system vat manager for dynamic registration. */ + systemVatManager?: Pick; +}; + +/** + * Result of launching a subcluster via the kernel facet. + * Contains the root object as a slot value (which will become a presence) + * and the root kref string for storage purposes. + */ +export type KernelFacetLaunchResult = { + /** The ID of the launched subcluster. */ + subclusterId: string; + /** + * The root object as a slot value (becomes a presence when marshalled). + * Use this directly with E() for immediate operations. + */ + root: SlotValue; + /** + * The root kref string for storage purposes. + * Store this value to restore the presence after restart using getSubclusterRoot(). + */ + rootKref: string; +}; + +/** + * Result of registering a dynamic system vat via the kernel facet. + */ +export type KernelFacetRegisterSystemVatResult = { + /** The allocated system vat ID. */ + systemVatId: SystemVatId; + /** + * The root object as a slot value (becomes a presence when marshalled). + */ + root: SlotValue; + /** + * The root kref string for storage purposes. + */ + rootKref: string; + /** + * Function to disconnect and clean up the vat. + */ + disconnect: () => Promise; +}; + +/** + * The kernel facet interface. + * + * This is the interface provided as a vatpower to the bootstrap vat of a + * system vat. It enables privileged kernel operations. + * + * Derived from KernelFacetDependencies but with launchSubcluster overridden + * to return KernelFacetLaunchResult (root as SlotValue) instead of + * SubclusterLaunchResult (bootstrapRootKref as string). + */ +export type KernelFacet = Omit< + KernelFacetDependencies, + 'logger' | 'launchSubcluster' | 'systemVatManager' +> & { + /** + * Launch a dynamic subcluster. + * Returns root as a SlotValue (which becomes a presence when delivered). + * + * @param config - Configuration for the subcluster. + * @returns A promise for the launch result containing subclusterId and root presence. + */ + launchSubcluster: (config: ClusterConfig) => Promise; + + /** + * Register a system vat at runtime. + * Used by UIs and other components that connect after kernel initialization. + * + * @param config - Configuration for the system vat. + * @returns A promise for the registration result. + */ + registerSystemVat: ( + config: SystemVatConfig, + ) => Promise; + + /** + * Convert a kref string to a slot value (presence). + * + * Use this to restore a presence from a stored kref string after restart. + * + * @param kref - The kref string to convert. + * @returns The slot value that will become a presence when marshalled. + */ + getVatRoot: (kref: string) => SlotValue; +}; + +/** + * Creates a kernel facet object that provides privileged kernel operations. + * + * The kernel facet is provided as a vatpower to the bootstrap vat of a + * system vat. It enables the bootstrap vat to: + * - Launch dynamic subclusters (and receive E()-callable presences) + * - Register dynamic system vats at runtime + * - Terminate subclusters + * - Reload subclusters + * - Query kernel status + * + * @param deps - Dependencies for creating the kernel facet. + * @returns The kernel facet object. + */ +export function makeKernelFacet(deps: KernelFacetDependencies): KernelFacet { + const { + launchSubcluster, + terminateSubcluster, + reloadSubcluster, + getSubcluster, + getSubclusters, + getStatus, + systemVatManager, + } = deps; + + const kernelFacet = makeDefaultExo('kernelFacet', { + /** + * Launch a dynamic subcluster. + * + * @param config - Configuration for the subcluster. + * @returns A promise for the launch result containing subclusterId and root presence. + */ + async launchSubcluster( + config: ClusterConfig, + ): Promise { + const result = await launchSubcluster(config); + return { + subclusterId: result.subclusterId, + root: kslot(result.bootstrapRootKref, 'vatRoot'), + rootKref: result.bootstrapRootKref, + }; + }, + + /** + * Terminate a subcluster. + * + * @param subclusterId - ID of the subcluster to terminate. + */ + async terminateSubcluster(subclusterId: string): Promise { + await terminateSubcluster(subclusterId); + }, + + /** + * Reload a subcluster by terminating and relaunching all its vats. + * + * @param subclusterId - ID of the subcluster to reload. + * @returns The reloaded subcluster information. + */ + async reloadSubcluster(subclusterId: string): Promise { + return reloadSubcluster(subclusterId); + }, + + /** + * Get information about a specific subcluster. + * + * @param subclusterId - ID of the subcluster to query. + * @returns The subcluster information or undefined if not found. + */ + getSubcluster(subclusterId: string): Subcluster | undefined { + return getSubcluster(subclusterId); + }, + + /** + * Get information about all subclusters. + * + * @returns Array of all subcluster information records. + */ + getSubclusters(): Subcluster[] { + return getSubclusters(); + }, + + /** + * Get the current kernel status. + * + * @returns A promise for the kernel status. + */ + async getStatus(): Promise { + return getStatus(); + }, + + /** + * Register a system vat at runtime. + * Used by UIs and other components that connect after kernel initialization. + * + * @param config - Configuration for the system vat. + * @returns A promise for the registration result. + */ + async registerSystemVat( + config: SystemVatConfig, + ): Promise { + if (!systemVatManager) { + throw new Error( + 'Cannot register system vat: systemVatManager not provided to kernel facet', + ); + } + const result = await systemVatManager.registerSystemVat(config); + return { + systemVatId: result.systemVatId, + root: kslot(result.rootKref, 'vatRoot'), + rootKref: result.rootKref, + disconnect: result.disconnect, + }; + }, + + /** + * Convert a kref string to a slot value (presence). + * + * Use this to restore a presence from a stored kref string after restart. + * + * @param kref - The kref string to convert. + * @returns The slot value that will become a presence when marshalled. + */ + getVatRoot(kref: string): SlotValue { + return kslot(kref, 'vatRoot'); + }, + }); + + return kernelFacet; +} diff --git a/packages/ocap-kernel/src/store/methods/clist.ts b/packages/ocap-kernel/src/store/methods/clist.ts index 95661562b..aa15aa7f2 100644 --- a/packages/ocap-kernel/src/store/methods/clist.ts +++ b/packages/ocap-kernel/src/store/methods/clist.ts @@ -86,7 +86,10 @@ export function getCListMethods(ctx: StoreContext) { */ function allocateErefForKref(endpointId: EndpointId, kref: KRef): ERef { let id; - const refTag = endpointId.startsWith('v') ? '' : endpointId[0]; + // System vats (sv0, sv1) use the same vref format as regular vats (v0, v1) + const isVatOrSystemVat = + endpointId.startsWith('v') || endpointId.startsWith('sv'); + const refTag = isVatOrSystemVat ? '' : endpointId[0]; let refType; if (isPromiseRef(kref)) { id = ctx.kv.get(`e.nextPromiseId.${endpointId}`); diff --git a/packages/ocap-kernel/src/store/methods/queue.ts b/packages/ocap-kernel/src/store/methods/queue.ts index 54693580e..2b7839164 100644 --- a/packages/ocap-kernel/src/store/methods/queue.ts +++ b/packages/ocap-kernel/src/store/methods/queue.ts @@ -33,6 +33,10 @@ export function getQueueMethods(ctx: StoreContext) { * @param message - The message to enqueue. */ function enqueueRun(message: RunQueueItem): void { + // Ensure cache is initialized before incrementing + if (ctx.runQueueLengthCache < 0) { + ctx.runQueueLengthCache = getQueueLength('run'); + } ctx.runQueueLengthCache += 1; ctx.runQueue.enqueue(message); } @@ -44,6 +48,10 @@ export function getQueueMethods(ctx: StoreContext) { * empty. */ function dequeueRun(): RunQueueItem | undefined { + // Ensure cache is initialized before decrementing + if (ctx.runQueueLengthCache < 0) { + ctx.runQueueLengthCache = getQueueLength('run'); + } ctx.runQueueLengthCache -= 1; return ctx.runQueue.dequeue() as RunQueueItem | undefined; } diff --git a/packages/ocap-kernel/src/store/methods/vat.test.ts b/packages/ocap-kernel/src/store/methods/vat.test.ts index 527a0a0f1..50253b57b 100644 --- a/packages/ocap-kernel/src/store/methods/vat.test.ts +++ b/packages/ocap-kernel/src/store/methods/vat.test.ts @@ -261,35 +261,35 @@ describe('vat store methods', () => { describe('deleteEndpoint', () => { it('deletes all keys related to the endpoint', () => { - const endpointId = 'e1'; + const endpointId = 'v1'; - // Setup mock data - mockKV.set(`cle.${endpointId}.obj1`, 'data1'); - mockKV.set(`cle.${endpointId}.obj2`, 'data2'); - mockKV.set(`clk.${endpointId}.prom1`, 'data3'); + // Setup mock data - c-list keys use the format {endpointId}.c.{slot} + mockKV.set(`${endpointId}.c.o+0`, 'ko1'); + mockKV.set(`${endpointId}.c.ko1`, 'R o+0'); + mockKV.set(`${endpointId}.c.p+1`, 'kp1'); mockKV.set(`e.nextObjectId.${endpointId}`, '10'); mockKV.set(`e.nextPromiseId.${endpointId}`, '5'); mockGetPrefixedKeys.mockImplementation((prefix: string) => { - if (prefix === `cle.${endpointId}.`) { - return [`cle.${endpointId}.obj1`, `cle.${endpointId}.obj2`]; - } - if (prefix === `clk.${endpointId}.`) { - return [`clk.${endpointId}.prom1`]; + if (prefix === `${endpointId}.c.`) { + return [ + `${endpointId}.c.o+0`, + `${endpointId}.c.ko1`, + `${endpointId}.c.p+1`, + ]; } return []; }); vatMethods.deleteEndpoint(endpointId); - expect(mockKV.has(`cle.${endpointId}.obj1`)).toBe(false); - expect(mockKV.has(`cle.${endpointId}.obj2`)).toBe(false); - expect(mockKV.has(`clk.${endpointId}.prom1`)).toBe(false); + expect(mockKV.has(`${endpointId}.c.o+0`)).toBe(false); + expect(mockKV.has(`${endpointId}.c.ko1`)).toBe(false); + expect(mockKV.has(`${endpointId}.c.p+1`)).toBe(false); expect(mockKV.has(`e.nextObjectId.${endpointId}`)).toBe(false); expect(mockKV.has(`e.nextPromiseId.${endpointId}`)).toBe(false); - expect(mockGetPrefixedKeys).toHaveBeenCalledWith(`cle.${endpointId}.`); - expect(mockGetPrefixedKeys).toHaveBeenCalledWith(`clk.${endpointId}.`); + expect(mockGetPrefixedKeys).toHaveBeenCalledWith(`${endpointId}.c.`); }); it('does nothing if endpoint has no associated keys', () => { @@ -299,8 +299,7 @@ describe('vat store methods', () => { expect(() => vatMethods.deleteEndpoint(endpointId)).not.toThrow(); - expect(mockGetPrefixedKeys).toHaveBeenCalledWith(`cle.${endpointId}.`); - expect(mockGetPrefixedKeys).toHaveBeenCalledWith(`clk.${endpointId}.`); + expect(mockGetPrefixedKeys).toHaveBeenCalledWith(`${endpointId}.c.`); }); }); @@ -535,4 +534,219 @@ describe('vat store methods', () => { expect(result).toBe(false); }); }); + + describe('cleanupTerminatedVat', () => { + describe('regular vats', () => { + it('returns zero counts when vat is not terminated', () => { + mockTerminatedVats.get.mockReturnValue('[]'); + mockGetPrefixedKeys.mockReturnValue([]); + + const work = vatMethods.cleanupTerminatedVat(vatID1); + + expect(work).toStrictEqual({ + exports: 0, + imports: 0, + promises: 0, + kv: 0, + }); + }); + + it('cleans up vat KV entries for regular vats', () => { + mockTerminatedVats.get.mockReturnValue(JSON.stringify([vatID1])); + + // Set up some vat KV entries + mockKV.set(`${vatID1}.someKey`, 'value'); + + mockGetPrefixedKeys.mockImplementation((prefix: string) => { + if (prefix === `${vatID1}.`) { + return [`${vatID1}.someKey`]; + } + return []; + }); + + const work = vatMethods.cleanupTerminatedVat(vatID1); + + expect(work.kv).toBe(1); + expect(mockKV.has(`${vatID1}.someKey`)).toBe(false); + }); + + it('removes vat from terminated list for regular vats', () => { + mockTerminatedVats.get.mockReturnValue(JSON.stringify([vatID1])); + mockGetPrefixedKeys.mockReturnValue([]); + + vatMethods.cleanupTerminatedVat(vatID1); + + expect(mockTerminatedVats.set).toHaveBeenCalledWith('[]'); + }); + }); + + describe('system vats', () => { + it('cleans up exported objects and removes owner entries', () => { + const systemVatId = 'sv0'; + + // Set up exported object c-list entry (eref->kref) + mockKV.set(`${systemVatId}.c.o+0`, 'ko1'); + // Set up owner entry - use the mock owner key format + mockKV.set('owner.ko1', systemVatId); + + mockGetPrefixedKeys.mockImplementation((prefix: string) => { + if (prefix === `${systemVatId}.c.o+`) { + return [`${systemVatId}.c.o+0`]; + } + return []; + }); + + mockGetReachableAndVatSlot.mockReturnValue({ vatSlot: 'o+0' }); + + const work = vatMethods.cleanupTerminatedVat(systemVatId); + + expect(work.exports).toBe(1); + expect(mockKV.has('owner.ko1')).toBe(false); + expect(mockDecrementRefCount).toHaveBeenCalledWith( + 'ko1', + 'cleanup|export|baseline', + ); + expect(mockMaybeFreeKrefs.add).toHaveBeenCalledWith('ko1'); + }); + + it('cleans up imported objects', () => { + const systemVatId = 'sv0'; + + // Set up imported object c-list entry (eref->kref) + mockKV.set(`${systemVatId}.c.o-1`, 'ko2'); + + mockGetPrefixedKeys.mockImplementation((prefix: string) => { + if (prefix === `${systemVatId}.c.o-`) { + return [`${systemVatId}.c.o-1`]; + } + return []; + }); + + const work = vatMethods.cleanupTerminatedVat(systemVatId); + + expect(work.imports).toBe(1); + expect(mockDeleteCListEntry).toHaveBeenCalledWith( + systemVatId, + 'ko2', + 'o-1', + ); + }); + + it('cleans up promises and decrements decider refcount', () => { + const systemVatId = 'sv0'; + + // Set up promise c-list entry (eref->kref) + mockKV.set(`${systemVatId}.c.p+1`, 'kp1'); + + mockGetPrefixedKeys.mockImplementation((prefix: string) => { + if (prefix === `${systemVatId}.c.p`) { + return [`${systemVatId}.c.p+1`]; + } + return []; + }); + + mockGetKernelPromise.mockReturnValue({ decider: systemVatId }); + + const work = vatMethods.cleanupTerminatedVat(systemVatId); + + expect(work.promises).toBe(1); + expect(mockDeleteCListEntry).toHaveBeenCalledWith( + systemVatId, + 'kp1', + 'p+1', + ); + expect(mockDecrementRefCount).toHaveBeenCalledWith( + 'kp1', + 'cleanup|promise|decider', + ); + }); + + it('does not decrement promise refcount if endpoint is not the decider', () => { + const systemVatId = 'sv0'; + + mockKV.set(`${systemVatId}.c.p+1`, 'kp1'); + + mockGetPrefixedKeys.mockImplementation((prefix: string) => { + if (prefix === `${systemVatId}.c.p`) { + return [`${systemVatId}.c.p+1`]; + } + return []; + }); + + // Different decider + mockGetKernelPromise.mockReturnValue({ decider: 'v1' }); + + const work = vatMethods.cleanupTerminatedVat(systemVatId); + + expect(work.promises).toBe(1); + expect(mockDeleteCListEntry).toHaveBeenCalledWith( + systemVatId, + 'kp1', + 'p+1', + ); + // Should not decrement decider refcount since this endpoint is not the decider + expect(mockDecrementRefCount).not.toHaveBeenCalledWith( + 'kp1', + 'cleanup|promise|decider', + ); + }); + + it('returns zero counts when system vat has no state', () => { + const systemVatId = 'sv99'; + + mockGetPrefixedKeys.mockReturnValue([]); + + const work = vatMethods.cleanupTerminatedVat(systemVatId); + + expect(work).toStrictEqual({ + exports: 0, + imports: 0, + promises: 0, + kv: 0, + }); + }); + + it('skips vat KV cleanup for system vats', () => { + const systemVatId = 'sv0'; + + mockGetPrefixedKeys.mockReturnValue([]); + + const work = vatMethods.cleanupTerminatedVat(systemVatId); + + expect(work.kv).toBe(0); + }); + + it('does not remove from terminated list for system vats', () => { + const systemVatId = 'sv0'; + mockGetPrefixedKeys.mockReturnValue([]); + + vatMethods.cleanupTerminatedVat(systemVatId); + + expect(mockTerminatedVats.set).not.toHaveBeenCalled(); + }); + + it('skips terminated check for system vats', () => { + const systemVatId = 'sv0'; + // System vat is not in the terminated list, but cleanup should still proceed + mockTerminatedVats.get.mockReturnValue('[]'); + + mockKV.set(`${systemVatId}.c.o+0`, 'ko1'); + mockKV.set('owner.ko1', systemVatId); + + mockGetPrefixedKeys.mockImplementation((prefix: string) => { + if (prefix === `${systemVatId}.c.o+`) { + return [`${systemVatId}.c.o+0`]; + } + return []; + }); + + mockGetReachableAndVatSlot.mockReturnValue({ vatSlot: 'o+0' }); + + const work = vatMethods.cleanupTerminatedVat(systemVatId); + + // Should still clean up even though not in terminated list + expect(work.exports).toBe(1); + }); + }); + }); }); diff --git a/packages/ocap-kernel/src/store/methods/vat.ts b/packages/ocap-kernel/src/store/methods/vat.ts index 7e6482cd9..5e690f789 100644 --- a/packages/ocap-kernel/src/store/methods/vat.ts +++ b/packages/ocap-kernel/src/store/methods/vat.ts @@ -6,7 +6,7 @@ import { getObjectMethods } from './object.ts'; import { getPromiseMethods } from './promise.ts'; import { getReachableMethods } from './reachable.ts'; import { getRefCountMethods } from './refcount.ts'; -import { insistEndpointId } from '../../types.ts'; +import { insistEndpointId, isSystemVatId } from '../../types.ts'; import type { EndpointId, KRef, VatConfig, VatId, ERef } from '../../types.ts'; import type { StoreContext, VatCleanupWork } from '../types.ts'; import { parseRef } from '../utils/parse-ref.ts'; @@ -45,13 +45,15 @@ export function getVatMethods(ctx: StoreContext) { /** * Delete all persistent state associated with an endpoint. * + * This deletes c-list entries and endpoint counters. It does NOT handle + * cleaning up kernel objects, refcounts, or other state - use + * cleanupTerminatedVat for full cleanup. + * * @param endpointId - The endpoint whose state is to be deleted. */ function deleteEndpoint(endpointId: EndpointId): void { - for (const key of getPrefixedKeys(`cle.${endpointId}.`)) { - kv.delete(key); - } - for (const key of getPrefixedKeys(`clk.${endpointId}.`)) { + // C-list keys use the format: {endpointId}.c.{slot} + for (const key of getPrefixedKeys(`${endpointId}.c.`)) { kv.delete(key); } kv.delete(`e.nextObjectId.${endpointId}`); @@ -201,24 +203,37 @@ export function getVatMethods(ctx: StoreContext) { } /** - * Cleanup a terminated vat. + * Cleanup a terminated vat or system vat endpoint. + * + * This handles cleanup of: + * - Exported objects: deletes owner, decrements refcounts, marks for GC + * - Imported objects: decrements refcounts via deleteCListEntry + * - Promises: cleans up c-list entries and decrements decider refcounts + * - Vat KV entries (for regular vats only, system vats have no vatstore) + * - Terminated vats list (for regular vats only) * - * @param vatID - The ID of the vat to cleanup. + * For system vats (IDs starting with 'sv'), the terminated check, vat KV cleanup, + * and terminated list removal are automatically skipped. + * + * @param endpointId - The ID of the endpoint to cleanup. * @returns The work done during the cleanup. */ - function cleanupTerminatedVat(vatID: VatId): VatCleanupWork { - const work = { + function cleanupTerminatedVat(endpointId: EndpointId): VatCleanupWork { + const isSystemVat = isSystemVatId(endpointId); + + const work: VatCleanupWork = { exports: 0, imports: 0, promises: 0, kv: 0, }; - if (!isVatTerminated(vatID)) { + // Check if vat is terminated (skip for system vats which aren't in the list) + if (!isSystemVat && !isVatTerminated(endpointId)) { return work; } - const clistPrefix = `${vatID}.c.`; + const clistPrefix = `${endpointId}.c.`; const exportPrefix = `${clistPrefix}o+`; const importPrefix = `${clistPrefix}o-`; const promisePrefix = `${clistPrefix}p`; @@ -243,19 +258,19 @@ export function getVatMethods(ctx: StoreContext) { // must also delete the corresponding kernel owner entry for the object, // since the object will no longer be accessible. assert(key.startsWith(clistPrefix), key); - const vref = key.slice(clistPrefix.length); - assert(vref.startsWith('o+'), vref); + const eref = key.slice(clistPrefix.length); + assert(eref.startsWith('o+'), eref); const kref = ctx.kv.get(key); assert(kref, key); // deletes c-list and .owner, adds to maybeFreeKrefs const ownerKey = getOwnerKey(kref); - const ownerVat = ctx.kv.get(ownerKey); - ownerVat === vatID || Fail`export ${kref} not owned by old vat`; + const owner = ctx.kv.get(ownerKey); + owner === endpointId || Fail`export ${kref} not owned by ${endpointId}`; ctx.kv.delete(ownerKey); - const { vatSlot } = getReachableAndVatSlot(vatID, kref); - ctx.kv.delete(getSlotKey(vatID, kref)); - ctx.kv.delete(getSlotKey(vatID, vatSlot)); - // Decrease refcounts that belonged to the terminating vat + const { vatSlot } = getReachableAndVatSlot(endpointId, kref); + ctx.kv.delete(getSlotKey(endpointId, kref)); + ctx.kv.delete(getSlotKey(endpointId, vatSlot)); + // Decrease refcounts that belonged to the terminating endpoint decrementRefCount(kref, 'cleanup|export|baseline'); ctx.maybeFreeKrefs.add(kref); work.exports += 1; @@ -267,8 +282,8 @@ export function getVatMethods(ctx: StoreContext) { // drop+retire const kref = ctx.kv.get(key) ?? Fail`getNextKey ensures get`; assert(key.startsWith(clistPrefix), key); - const vref = key.slice(clistPrefix.length); - deleteCListEntry(vatID, kref, vref); + const eref = key.slice(clistPrefix.length); + deleteCListEntry(endpointId, kref, eref); // that will also delete both db keys work.imports += 1; } @@ -279,31 +294,35 @@ export function getVatMethods(ctx: StoreContext) { for (const key of getPrefixedKeys(promisePrefix)) { const kref = ctx.kv.get(key) ?? Fail`getNextKey ensures get`; assert(key.startsWith(clistPrefix), key); - const vref = key.slice(clistPrefix.length); + const eref = key.slice(clistPrefix.length); // the following will also delete both db keys - deleteCListEntry(vatID, kref, vref); - // If the dead vat was still the decider, drop the decider’s refcount, too. + deleteCListEntry(endpointId, kref, eref); + // If the dead endpoint was still the decider, drop the decider's refcount, too. const kp = getKernelPromise(kref); - if (kp.decider === vatID) { + if (kp.decider === endpointId) { decrementRefCount(kref, 'cleanup|promise|decider'); } work.promises += 1; } - // Finally, clean up any remaining KV entries for this vat - for (const key of getPrefixedKeys(`${vatID}.`)) { - ctx.kv.delete(key); - work.kv += 1; + // Clean up vat KV entries (skip for system vats which have no vatstore) + if (!isSystemVat) { + for (const key of getPrefixedKeys(`${endpointId}.`)) { + ctx.kv.delete(key); + work.kv += 1; + } } - // Clean up any remaining c-list entries and vat-specific counters - deleteEndpoint(vatID); + // Clean up any remaining c-list entries and endpoint-specific counters + deleteEndpoint(endpointId); - // Remove the vat from the terminated vats list - forgetTerminatedVat(vatID); + // Remove from terminated vats list (skip for system vats) + if (!isSystemVat) { + forgetTerminatedVat(endpointId); + } // Log the cleanup work done - ctx.logger?.debug(`Cleaned up terminated vat ${vatID}:`, work); + ctx.logger?.debug(`Cleaned up endpoint ${endpointId}:`, work); return work; } diff --git a/packages/ocap-kernel/src/types.ts b/packages/ocap-kernel/src/types.ts index e5ad35dbe..fbb040565 100644 --- a/packages/ocap-kernel/src/types.ts +++ b/packages/ocap-kernel/src/types.ts @@ -2,6 +2,7 @@ import type { SwingSetCapData, Message as SwingsetMessage, VatSyscallObject, + VatSyscallResult, VatSyscallSend, VatOneResolution, } from '@agoric/swingset-liveslots'; @@ -40,7 +41,8 @@ import { Fail } from './utils/assert.ts'; export type VatId = string; export type RemoteId = string; -export type EndpointId = VatId | RemoteId; +export type SystemVatId = `sv${number}`; +export type EndpointId = VatId | RemoteId | SystemVatId; export type SubclusterId = string; export type KRef = string; @@ -85,6 +87,21 @@ export function coerceMessage(message: SwingsetMessage): Message { return message as Message; } +/** + * Delivery object using our Message type. + * + * This is equivalent to VatDeliveryObject from swingset-liveslots for JSON + * serialization purposes. The only difference is Message.result typing: + * ours is `result?: string | null`, theirs is `result: string | null | undefined`. + */ +export type DeliveryObject = + | ['message', VRef, Message] + | ['notify', VatOneResolution[]] + | ['dropExports', VRef[]] + | ['retireExports', VRef[]] + | ['retireImports', VRef[]] + | ['bringOutYourDead']; + type JsonVatSyscallObject = | Exclude | ['send', string, Message]; @@ -221,10 +238,13 @@ export const isRemoteId = (value: unknown): value is RemoteId => value.at(0) === 'r' && value.slice(1) === String(Number(value.slice(1))); -export const isEndpointId = (value: unknown): value is EndpointId => +export const isSystemVatId = (value: unknown): value is SystemVatId => typeof value === 'string' && - (value.at(0) === 'v' || value.at(0) === 'r') && - value.slice(1) === String(Number(value.slice(1))); + value.startsWith('sv') && + value.slice(2) === String(Number(value.slice(2))); + +export const isEndpointId = (value: unknown): value is EndpointId => + isVatId(value) || isRemoteId(value) || isSystemVatId(value); /** * Assert that a value is a valid vat id. @@ -247,6 +267,10 @@ export function insistEndpointId(value: unknown): asserts value is EndpointId { } export const VatIdStruct = define('VatId', isVatId); +export const SystemVatIdStruct = define( + 'SystemVatId', + isSystemVatId, +); export const isSubclusterId = (value: unknown): value is SubclusterId => typeof value === 'string' && @@ -422,6 +446,115 @@ export type ClusterConfig = Infer; export const isClusterConfig = (value: unknown): value is ClusterConfig => is(value, ClusterConfigStruct); +/** + * Function signature for building the root object of a system vat. + * System vats don't load bundles; they provide this function directly. + */ +export type SystemVatBuildRootObject = ( + vatPowers: Record, + parameters: Record | undefined, +) => object; + +/** + * Configuration for a single system vat within a system subcluster. + * Used when launching system subclusters via Kernel.launchSystemSubcluster(). + */ +export type SystemSubclusterVatConfig = { + buildRootObject: SystemVatBuildRootObject; + parameters?: Record; +}; + +/** + * Configuration for launching a system subcluster. + * System subclusters contain vats that run without compartment isolation + * directly in the host process. + * + * Used when launching system subclusters via Kernel.launchSystemSubcluster(). + */ +export type SystemSubclusterConfig = { + /** The name of the bootstrap vat within the subcluster. */ + bootstrap: string; + /** Map of vat names to their configurations. */ + vats: Record; + /** Optional list of kernel service names to provide to the bootstrap vat. */ + services?: string[]; +}; + +// ============================================================================ +// System Vat Transport Types +// ============================================================================ + +/** + * Syscall handler from system vat to kernel. + * The kernel provides this to the transport so syscalls can be routed correctly. + */ +export type SystemVatSyscallHandler = ( + syscall: VatSyscallObject, +) => VatSyscallResult; + +/** + * Deliver function from kernel to system vat. + * The runtime provides this to the kernel so deliveries can be routed correctly. + */ +export type SystemVatDeliverFn = ( + delivery: DeliveryObject, +) => Promise; + +/** + * Transport interface bridging kernel and system vat processes. + * + * The transport abstracts the communication channel between the kernel (which + * creates SystemVatHandle) and the system vat supervisor (which runs in the + * runtime's process). This allows: + * - Node.js: direct function calls (same process) + * - Extension: MessagePort IPC (cross-process) + * + * The kernel is passive - it sets up to receive connections via the transport. + * The supervisor side initiates the connection by resolving `awaitConnection()`. + * This push-based model allows: + * - Same-process: supervisor calls `connect()` which resolves the promise + * - Cross-process: supervisor sends "connect" message over IPC + */ +export type SystemVatTransport = { + /** Send deliveries from kernel to system vat. */ + deliver: SystemVatDeliverFn; + /** Register syscall handler (kernel calls this to wire up). */ + setSyscallHandler: (handler: SystemVatSyscallHandler) => void; + /** + * Returns a promise that resolves when the supervisor-side initiates + * connection. The kernel waits for this before sending bootstrap messages. + * For same-process transports, this resolves when `connect()` is called. + * For cross-process transports, this resolves when "connect" IPC message arrives. + */ + awaitConnection: () => Promise; +}; + +/** + * Configuration for a system vat using transport-based communication. + * Used for the host vat (configured at kernel construction) and dynamic + * system vats (registered at runtime via kernel facet). + */ +export type SystemVatConfig = { + /** Vat name (used in bootstrap message). */ + name: string; + /** Transport callbacks for communication. */ + transport: SystemVatTransport; + /** Optional kernel services to provide to the vat. */ + services?: string[]; +}; + +/** + * Result of registering a system vat. + */ +export type SystemVatRegistrationResult = { + /** The allocated system vat ID. */ + systemVatId: SystemVatId; + /** The kref of the vat's root object. */ + rootKref: KRef; + /** Function to disconnect and clean up the vat. */ + disconnect: () => Promise; +}; + export const SubclusterStruct = object({ id: SubclusterIdStruct, config: ClusterConfigStruct, @@ -513,7 +646,7 @@ export const isGCAction = (value: unknown): value is GCAction => export type CrankResults = { didDelivery?: EndpointId; // the endpoint to which we made a delivery abort?: boolean; // changes should be discarded, not committed - terminate?: { vatId: VatId; reject: boolean; info: SwingSetCapData }; + terminate?: { vatId: EndpointId; reject: boolean; info: SwingSetCapData }; }; export type VatDeliveryResult = [VatCheckpoint, string | null]; diff --git a/packages/ocap-kernel/src/vats/BaseVatHandle.ts b/packages/ocap-kernel/src/vats/BaseVatHandle.ts new file mode 100644 index 000000000..7dc208a3f --- /dev/null +++ b/packages/ocap-kernel/src/vats/BaseVatHandle.ts @@ -0,0 +1,124 @@ +import type { VatOneResolution } from '@agoric/swingset-liveslots'; + +import type { + CrankResults, + DeliveryObject, + EndpointHandle, + Message, + VRef, +} from '../types.ts'; +import type { VatSyscall } from './VatSyscall.ts'; + +/** + * Function type for delivering messages to a vat. + * + * @param delivery - The delivery object to send to the vat. + * @returns A promise that resolves to the delivery error (or null if no error). + */ +export type DeliverFn = (delivery: DeliveryObject) => Promise; + +/** + * Abstract base class for vat handles. + * + * Implements the delivery methods shared between VatHandle and SystemVatHandle. + * Subclasses provide a deliver function that handles transport-specific logic. + */ +export abstract class BaseVatHandle implements EndpointHandle { + readonly #vatSyscall: VatSyscall; + + protected deliver: DeliverFn | undefined; + + /** + * Construct a new BaseVatHandle instance. + * + * @param vatSyscall - The vat's syscall handler. + */ + protected constructor(vatSyscall: VatSyscall) { + this.#vatSyscall = vatSyscall; + } + + /** + * Get the vat syscall handler. + * + * @returns The vat syscall handler. + */ + protected get vatSyscall(): VatSyscall { + return this.#vatSyscall; + } + + /** + * Perform a delivery and get crank results. + * + * @param delivery - The delivery object. + * @returns The crank results. + */ + async #doDeliver(delivery: DeliveryObject): Promise { + if (!this.deliver) { + throw new Error( + 'deliver function not set - subclass must call setDeliver()', + ); + } + const deliveryError = await this.deliver(delivery); + return this.#vatSyscall.getCrankResults(deliveryError); + } + + /** + * Make a 'message' delivery to the vat. + * + * @param target - The VRef of the object to which the message is addressed. + * @param message - The message to deliver. + * @returns The crank results. + */ + async deliverMessage(target: VRef, message: Message): Promise { + return await this.#doDeliver(['message', target, message]); + } + + /** + * Make a 'notify' delivery to the vat. + * + * @param resolutions - One or more promise resolutions to deliver. + * @returns The crank results. + */ + async deliverNotify(resolutions: VatOneResolution[]): Promise { + return await this.#doDeliver(['notify', resolutions]); + } + + /** + * Make a 'dropExports' delivery to the vat. + * + * @param vrefs - The VRefs of the exports to be dropped. + * @returns The crank results. + */ + async deliverDropExports(vrefs: VRef[]): Promise { + return await this.#doDeliver(['dropExports', vrefs]); + } + + /** + * Make a 'retireExports' delivery to the vat. + * + * @param vrefs - The VRefs of the exports to be retired. + * @returns The crank results. + */ + async deliverRetireExports(vrefs: VRef[]): Promise { + return await this.#doDeliver(['retireExports', vrefs]); + } + + /** + * Make a 'retireImports' delivery to the vat. + * + * @param vrefs - The VRefs of the imports to be retired. + * @returns The crank results. + */ + async deliverRetireImports(vrefs: VRef[]): Promise { + return await this.#doDeliver(['retireImports', vrefs]); + } + + /** + * Make a 'bringOutYourDead' delivery to the vat. + * + * @returns The crank results. + */ + async deliverBringOutYourDead(): Promise { + return await this.#doDeliver(['bringOutYourDead']); + } +} diff --git a/packages/ocap-kernel/src/vats/SystemVatHandle.test.ts b/packages/ocap-kernel/src/vats/SystemVatHandle.test.ts new file mode 100644 index 000000000..7c14c2948 --- /dev/null +++ b/packages/ocap-kernel/src/vats/SystemVatHandle.test.ts @@ -0,0 +1,278 @@ +import type { VatOneResolution } from '@agoric/swingset-liveslots'; +import type { Logger } from '@metamask/logger'; +import type { MockInstance } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { KernelQueue } from '../KernelQueue.ts'; +import type { KernelStore } from '../store/index.ts'; +import type { DeliveryObject, Message, SystemVatId, VRef } from '../types.ts'; +import type { SystemVatDeliverFn } from './SystemVatHandle.ts'; +import { SystemVatHandle } from './SystemVatHandle.ts'; + +describe('SystemVatHandle', () => { + let kernelStore: KernelStore; + let kernelQueue: KernelQueue; + let logger: Logger; + let deliver: SystemVatDeliverFn; + let systemVatHandle: SystemVatHandle; + const systemVatId: SystemVatId = 'sv0'; + + beforeEach(() => { + kernelStore = { + translateSyscallVtoK: vi.fn((_, vso) => vso), + getKernelPromise: vi.fn(() => ({ state: 'unresolved' })), + addPromiseSubscriber: vi.fn(), + clearReachableFlag: vi.fn(), + getReachableFlag: vi.fn(), + forgetKref: vi.fn(), + getPromisesByDecider: vi.fn(() => []), + deleteEndpoint: vi.fn(), + } as unknown as KernelStore; + kernelQueue = { + enqueueSend: vi.fn(), + resolvePromises: vi.fn(), + enqueueNotify: vi.fn(), + } as unknown as KernelQueue; + logger = { + debug: vi.fn(), + error: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + subLogger: vi.fn(() => logger), + } as unknown as Logger; + deliver = vi.fn().mockResolvedValue(null); + systemVatHandle = new SystemVatHandle({ + systemVatId, + kernelStore, + kernelQueue, + deliver, + logger, + }); + }); + + describe('constructor', () => { + it('exposes the system vat ID', () => { + expect(systemVatHandle.systemVatId).toBe(systemVatId); + }); + }); + + describe('getSyscallHandler', () => { + it('returns a function that handles syscalls', () => { + const handler = systemVatHandle.getSyscallHandler(); + expect(typeof handler).toBe('function'); + + // Test that it can handle a syscall + handler([ + 'send', + 'o+1', + { methargs: { body: '[]', slots: [] }, result: 'p-1' }, + ]); + expect(kernelQueue.enqueueSend).toHaveBeenCalled(); + }); + }); + + describe('deliverMessage', () => { + it('calls deliver with message delivery', async () => { + const target: VRef = 'o+0'; + const message: Message = { + methargs: { body: '["test"]', slots: [] }, + result: 'p-1', + }; + + await systemVatHandle.deliverMessage(target, message); + + expect(deliver).toHaveBeenCalledWith([ + 'message', + target, + { methargs: message.methargs, result: message.result }, + ]); + }); + + it('passes message without result property as-is', async () => { + const target: VRef = 'o+0'; + const message: Message = { + methargs: { body: '["test"]', slots: [] }, + }; + + await systemVatHandle.deliverMessage(target, message); + + expect(deliver).toHaveBeenCalledWith([ + 'message', + target, + { methargs: message.methargs }, + ]); + }); + + it('returns crank results with didDelivery', async () => { + const target: VRef = 'o+0'; + const message: Message = { + methargs: { body: '[]', slots: [] }, + }; + + const result = await systemVatHandle.deliverMessage(target, message); + + expect(result.didDelivery).toBe(systemVatId); + }); + }); + + describe('deliverNotify', () => { + it('calls deliver with notify delivery', async () => { + const resolutions: VatOneResolution[] = [ + ['p-1', false, { body: '"resolved"', slots: [] }], + ]; + + await systemVatHandle.deliverNotify(resolutions); + + expect(deliver).toHaveBeenCalledWith(['notify', resolutions]); + }); + + it('returns crank results with didDelivery', async () => { + const resolutions: VatOneResolution[] = [ + ['p-1', false, { body: '"resolved"', slots: [] }], + ]; + + const result = await systemVatHandle.deliverNotify(resolutions); + + expect(result.didDelivery).toBe(systemVatId); + }); + }); + + describe('deliverDropExports', () => { + it('calls deliver with dropExports delivery', async () => { + const vrefs: VRef[] = ['o+1', 'o+2']; + + await systemVatHandle.deliverDropExports(vrefs); + + expect(deliver).toHaveBeenCalledWith(['dropExports', vrefs]); + }); + }); + + describe('deliverRetireExports', () => { + it('calls deliver with retireExports delivery', async () => { + const vrefs: VRef[] = ['o+1', 'o+2']; + + await systemVatHandle.deliverRetireExports(vrefs); + + expect(deliver).toHaveBeenCalledWith(['retireExports', vrefs]); + }); + }); + + describe('deliverRetireImports', () => { + it('calls deliver with retireImports delivery', async () => { + const vrefs: VRef[] = ['o-1', 'o-2']; + + await systemVatHandle.deliverRetireImports(vrefs); + + expect(deliver).toHaveBeenCalledWith(['retireImports', vrefs]); + }); + }); + + describe('deliverBringOutYourDead', () => { + it('calls deliver with bringOutYourDead delivery', async () => { + await systemVatHandle.deliverBringOutYourDead(); + + expect(deliver).toHaveBeenCalledWith(['bringOutYourDead']); + }); + }); + + describe('crank results', () => { + it('returns abort and terminate on delivery error', async () => { + (deliver as unknown as MockInstance).mockResolvedValueOnce( + 'delivery failed', + ); + + const result = await systemVatHandle.deliverMessage('o+0', { + methargs: { body: '[]', slots: [] }, + }); + + expect(result.abort).toBe(true); + expect(result.terminate).toStrictEqual({ + vatId: systemVatId, + reject: true, + info: expect.objectContaining({ + body: expect.stringContaining('delivery failed'), + }), + }); + }); + + it('returns abort and terminate on illegal syscall', async () => { + // Create a new handle with a syscall that triggers illegal syscall + const illegalSyscallKernelStore = { + ...kernelStore, + translateSyscallVtoK: vi.fn(() => { + throw new Error('illegal'); + }), + } as unknown as KernelStore; + + const handle = new SystemVatHandle({ + systemVatId, + kernelStore: illegalSyscallKernelStore, + kernelQueue, + deliver: vi.fn().mockImplementation(async (del: DeliveryObject) => { + // Simulate the vat making a syscall during delivery + if (del[0] === 'message') { + handle.getSyscallHandler()([ + 'send', + 'o+1', + { methargs: { body: '[]', slots: [] }, result: 'p-1' }, + ]); + } + return null; + }), + logger, + }); + + const result = await handle.deliverMessage('o+0', { + methargs: { body: '[]', slots: [] }, + }); + + expect(result.abort).toBe(true); + expect(result.terminate).toBeDefined(); + expect(result.terminate?.reject).toBe(true); + }); + + it('returns terminate without abort on graceful exit', async () => { + // Create a handle that will request termination gracefully + const handle = new SystemVatHandle({ + systemVatId, + kernelStore, + kernelQueue, + deliver: vi.fn().mockImplementation(async () => { + // Simulate the vat calling syscall.exit(false, info) + handle.getSyscallHandler()([ + 'exit', + false, + { body: '"goodbye"', slots: [] }, + ]); + return null; + }), + logger, + }); + + const result = await handle.deliverMessage('o+0', { + methargs: { body: '[]', slots: [] }, + }); + + expect(result.abort).toBeUndefined(); + expect(result.terminate).toStrictEqual({ + vatId: systemVatId, + reject: false, + info: { body: '"goodbye"', slots: [] }, + }); + }); + }); + + describe('logging', () => { + it('creates handle without logger', () => { + const handleWithoutLogger = new SystemVatHandle({ + systemVatId, + kernelStore, + kernelQueue, + deliver, + }); + + // Handle is created successfully + expect(handleWithoutLogger.systemVatId).toBe(systemVatId); + }); + }); +}); diff --git a/packages/ocap-kernel/src/vats/SystemVatHandle.ts b/packages/ocap-kernel/src/vats/SystemVatHandle.ts new file mode 100644 index 000000000..a4cfdc1c0 --- /dev/null +++ b/packages/ocap-kernel/src/vats/SystemVatHandle.ts @@ -0,0 +1,95 @@ +import type { + VatSyscallObject, + VatSyscallResult, +} from '@agoric/swingset-liveslots'; +import type { Logger } from '@metamask/logger'; + +import type { KernelQueue } from '../KernelQueue.ts'; +import type { KernelStore } from '../store/index.ts'; +import type { DeliveryObject, SystemVatId } from '../types.ts'; +import { BaseVatHandle } from './BaseVatHandle.ts'; +import { VatSyscall } from './VatSyscall.ts'; + +/** + * Delivery callback type - called by kernel to deliver messages to the system vat. + */ +export type SystemVatDeliverFn = ( + delivery: DeliveryObject, +) => Promise; + +/** + * Syscall callback type - called by system vat to send syscalls to kernel. + */ +export type SystemVatSyscallFn = ( + syscall: VatSyscallObject, +) => VatSyscallResult; + +type SystemVatHandleProps = { + systemVatId: SystemVatId; + kernelStore: KernelStore; + kernelQueue: KernelQueue; + deliver: SystemVatDeliverFn; + logger?: Logger | undefined; +}; + +/** + * Handles communication with and lifecycle management of a system vat. + * + * System vats run without compartment isolation directly in the host process. + * They don't participate in kernel persistence machinery (no vatstore). + */ +export class SystemVatHandle extends BaseVatHandle { + /** The ID of the system vat this handles */ + readonly systemVatId: SystemVatId; + + /** Flag indicating if this handle is active */ + readonly #isActive: boolean = true; + + /** + * Construct a new SystemVatHandle instance. + * + * @param props - Named constructor parameters. + * @param props.systemVatId - The system vat ID. + * @param props.kernelStore - The kernel's persistent state store. + * @param props.kernelQueue - The kernel's queue. + * @param props.deliver - Callback function to deliver messages to the system vat. + * @param props.logger - Optional logger for error and diagnostic output. + */ + constructor({ + systemVatId, + kernelStore, + kernelQueue, + deliver, + logger, + }: SystemVatHandleProps) { + const vatSyscall = new VatSyscall({ + vatId: systemVatId, + kernelQueue, + kernelStore, + isActive: () => this.#isActive, + vatLabel: 'system vat', + logger: logger?.subLogger({ tags: ['syscall'] }), + }); + + super(vatSyscall); + + this.systemVatId = systemVatId; + + this.deliver = async (delivery): Promise => { + return deliver(harden(delivery)); + }; + + harden(this); + } + + /** + * Get a syscall handler function to pass to the system vat supervisor. + * + * @returns A function that handles syscalls from the system vat and returns the result. + */ + getSyscallHandler(): (syscall: VatSyscallObject) => VatSyscallResult { + return (syscall: VatSyscallObject) => { + return this.vatSyscall.handleSyscall(syscall); + }; + } +} diff --git a/packages/ocap-kernel/src/vats/SystemVatManager.test.ts b/packages/ocap-kernel/src/vats/SystemVatManager.test.ts new file mode 100644 index 000000000..9bfd4f03a --- /dev/null +++ b/packages/ocap-kernel/src/vats/SystemVatManager.test.ts @@ -0,0 +1,300 @@ +import { Logger } from '@metamask/logger'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import type { KernelFacetDependencies } from '../kernel-facet.ts'; +import type { KernelQueue } from '../KernelQueue.ts'; +import type { KernelStore } from '../store/index.ts'; +import type { SystemVatConfig, SystemVatTransport } from '../types.ts'; +import { SystemVatManager } from './SystemVatManager.ts'; + +describe('SystemVatManager', () => { + let mockKernelStore: KernelStore; + let mockKernelQueue: KernelQueue; + let mockKernelFacetDeps: KernelFacetDependencies; + let manager: SystemVatManager; + let mockTransport: SystemVatTransport; + + const makeTransport = (): SystemVatTransport => { + return { + deliver: vi.fn().mockResolvedValue(null), + setSyscallHandler: vi.fn(), + awaitConnection: vi.fn().mockResolvedValue(undefined), + }; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockKernelStore = { + initEndpoint: vi.fn(), + erefToKref: vi.fn().mockReturnValue(null), + initKernelObject: vi.fn().mockReturnValue('ko1'), + addCListEntry: vi.fn(), + getPromisesByDecider: vi.fn().mockReturnValue([]), + deleteEndpoint: vi.fn(), + cleanupTerminatedVat: vi + .fn() + .mockReturnValue({ exports: 0, imports: 0, promises: 0, kv: 0 }), + kv: { + get: vi.fn().mockReturnValue(undefined), + set: vi.fn(), + }, + } as unknown as KernelStore; + + mockKernelQueue = { + enqueueSend: vi.fn(), + resolvePromises: vi.fn(), + } as unknown as KernelQueue; + + mockKernelFacetDeps = { + launchSubcluster: vi.fn(), + terminateSubcluster: vi.fn(), + reloadSubcluster: vi.fn(), + getSubcluster: vi.fn(), + getSubclusters: vi.fn(), + getStatus: vi.fn(), + logger: new Logger('test'), + }; + + mockTransport = makeTransport(); + + manager = new SystemVatManager({ + kernelStore: mockKernelStore, + kernelQueue: mockKernelQueue, + kernelFacetDeps: mockKernelFacetDeps, + registerKernelService: vi.fn().mockReturnValue({ kref: 'ko0' }), + logger: new Logger('test'), + }); + }); + + describe('registerSystemVat', () => { + it('allocates system vat ID starting from sv0', async () => { + const config: SystemVatConfig = { + name: 'testVat', + transport: mockTransport, + }; + + const result = await manager.registerSystemVat(config); + + expect(result.systemVatId).toBe('sv0'); + }); + + it('allocates sequential system vat IDs', async () => { + const config1: SystemVatConfig = { + name: 'vat1', + transport: makeTransport(), + }; + const config2: SystemVatConfig = { + name: 'vat2', + transport: makeTransport(), + }; + + const result1 = await manager.registerSystemVat(config1); + const result2 = await manager.registerSystemVat(config2); + + expect(result1.systemVatId).toBe('sv0'); + expect(result2.systemVatId).toBe('sv1'); + }); + + it('initializes endpoint in kernel store', async () => { + const config: SystemVatConfig = { + name: 'testVat', + transport: mockTransport, + }; + + await manager.registerSystemVat(config); + + expect(mockKernelStore.initEndpoint).toHaveBeenCalledWith('sv0'); + }); + + it('creates root kernel object if not exists', async () => { + const config: SystemVatConfig = { + name: 'testVat', + transport: mockTransport, + }; + + await manager.registerSystemVat(config); + + expect(mockKernelStore.initKernelObject).toHaveBeenCalledWith('sv0'); + expect(mockKernelStore.addCListEntry).toHaveBeenCalledWith( + 'sv0', + 'ko1', + 'o+0', + ); + }); + + it('uses existing root kref if already exists', async () => { + (mockKernelStore.erefToKref as ReturnType).mockReturnValue( + 'ko99', + ); + const config: SystemVatConfig = { + name: 'testVat', + transport: mockTransport, + }; + + await manager.registerSystemVat(config); + + expect(mockKernelStore.initKernelObject).not.toHaveBeenCalled(); + expect(mockKernelStore.addCListEntry).not.toHaveBeenCalled(); + }); + + it('sets syscall handler on transport', async () => { + const config: SystemVatConfig = { + name: 'testVat', + transport: mockTransport, + }; + + await manager.registerSystemVat(config); + + expect(mockTransport.setSyscallHandler).toHaveBeenCalled(); + }); + + it('awaits connection before sending bootstrap', async () => { + let resolveConnection: () => void; + const connectionPromise = new Promise((resolve) => { + resolveConnection = resolve; + }); + const transport: SystemVatTransport = { + deliver: vi.fn().mockResolvedValue(null), + setSyscallHandler: vi.fn(), + awaitConnection: vi.fn().mockReturnValue(connectionPromise), + }; + + const config: SystemVatConfig = { + name: 'testVat', + transport, + }; + + // Start registration (will await connection) + const registrationPromise = manager.registerSystemVat(config); + + // Bootstrap should not be sent yet + expect(mockKernelQueue.enqueueSend).not.toHaveBeenCalled(); + + // Resolve connection + resolveConnection!(); + + // Wait for registration to complete + await registrationPromise; + + // Now bootstrap should be sent + expect(mockKernelQueue.enqueueSend).toHaveBeenCalled(); + }); + + it('returns root kref and disconnect function', async () => { + const config: SystemVatConfig = { + name: 'testVat', + transport: mockTransport, + }; + + const result = await manager.registerSystemVat(config); + + expect(result.rootKref).toBe('ko1'); + expect(typeof result.disconnect).toBe('function'); + }); + + it('disconnect function removes vat', async () => { + const config: SystemVatConfig = { + name: 'testVat', + transport: mockTransport, + }; + + const result = await manager.registerSystemVat(config); + + expect(manager.getSystemVatHandle(result.systemVatId)).toBeDefined(); + await result.disconnect(); + expect(manager.getSystemVatHandle(result.systemVatId)).toBeUndefined(); + }); + }); + + describe('getSystemVatHandle', () => { + it('returns handle for registered system vat', async () => { + const config: SystemVatConfig = { + name: 'testVat', + transport: mockTransport, + }; + + await manager.registerSystemVat(config); + const handle = manager.getSystemVatHandle('sv0'); + + expect(handle).toBeDefined(); + }); + + it('returns undefined for non-existent system vat', () => { + const handle = manager.getSystemVatHandle('sv999'); + + expect(handle).toBeUndefined(); + }); + + it('returns correct handle for multiple system vats', async () => { + const config1: SystemVatConfig = { + name: 'vat1', + transport: makeTransport(), + }; + const config2: SystemVatConfig = { + name: 'vat2', + transport: makeTransport(), + }; + + await manager.registerSystemVat(config1); + await manager.registerSystemVat(config2); + + const handle1 = manager.getSystemVatHandle('sv0'); + const handle2 = manager.getSystemVatHandle('sv1'); + + expect(handle1).toBeDefined(); + expect(handle2).toBeDefined(); + expect(handle1).not.toBe(handle2); + }); + }); + + describe('disconnectSystemVat', () => { + it('removes system vat from tracking', async () => { + const config: SystemVatConfig = { + name: 'testVat', + transport: mockTransport, + }; + + await manager.registerSystemVat(config); + expect(manager.getSystemVatHandle('sv0')).toBeDefined(); + + await manager.disconnectSystemVat('sv0'); + expect(manager.getSystemVatHandle('sv0')).toBeUndefined(); + }); + + it('handles disconnect of non-existent vat gracefully', async () => { + // Should not throw + const result = await manager.disconnectSystemVat('sv999'); + expect(result).toBeUndefined(); + }); + + it('rejects pending promises where vat is decider', async () => { + ( + mockKernelStore.getPromisesByDecider as ReturnType + ).mockReturnValue(['kp1', 'kp2']); + + const config: SystemVatConfig = { + name: 'testVat', + transport: mockTransport, + }; + + await manager.registerSystemVat(config); + await manager.disconnectSystemVat('sv0'); + + expect(mockKernelStore.getPromisesByDecider).toHaveBeenCalledWith('sv0'); + expect(mockKernelQueue.resolvePromises).toHaveBeenCalledTimes(2); + }); + + it('cleans up endpoint in kernel store', async () => { + const config: SystemVatConfig = { + name: 'testVat', + transport: mockTransport, + }; + + await manager.registerSystemVat(config); + await manager.disconnectSystemVat('sv0'); + + expect(mockKernelStore.cleanupTerminatedVat).toHaveBeenCalledWith('sv0'); + }); + }); +}); diff --git a/packages/ocap-kernel/src/vats/SystemVatManager.ts b/packages/ocap-kernel/src/vats/SystemVatManager.ts new file mode 100644 index 000000000..5b109aeb8 --- /dev/null +++ b/packages/ocap-kernel/src/vats/SystemVatManager.ts @@ -0,0 +1,295 @@ +import type { Logger } from '@metamask/logger'; + +import { makeKernelFacet } from '../kernel-facet.ts'; +import type { KernelFacetDependencies } from '../kernel-facet.ts'; +import type { KernelQueue } from '../KernelQueue.ts'; +import { kser, kslot } from '../liveslots/kernel-marshal.ts'; +import type { SlotValue } from '../liveslots/kernel-marshal.ts'; +import type { KernelStore } from '../store/index.ts'; +import type { + SystemVatId, + SystemVatConfig, + SystemVatRegistrationResult, + KRef, +} from '../types.ts'; +import { ROOT_OBJECT_VREF } from '../types.ts'; +import { SystemVatHandle } from './SystemVatHandle.ts'; + +type SystemVatManagerOptions = { + kernelStore: KernelStore; + kernelQueue: KernelQueue; + kernelFacetDeps: KernelFacetDependencies; + registerKernelService: (name: string, service: object) => { kref: string }; + logger: Logger; +}; + +/** + * Internal record for a system vat. + */ +type SystemVatRecord = { + id: SystemVatId; + name: string; + handle: SystemVatHandle; + rootKref: KRef; +}; + +/** + * Manages system vats - vats that run without compartment isolation + * directly in the runtime process. + * + * System vats: + * - Are created by the runtime (not the kernel) + * - Connect to the kernel via transports + * - Receive a kernel facet in the bootstrap message + * - Don't participate in kernel persistence machinery + * + * The host vat (background/main vat) is configured at kernel construction time. + * Additional system vats can be registered dynamically via the kernel facet. + */ +export class SystemVatManager { + /** Storage holding the kernel's persistent state */ + readonly #kernelStore: KernelStore; + + /** The kernel's run queue */ + readonly #kernelQueue: KernelQueue; + + /** Dependencies for creating kernel facet services */ + readonly #kernelFacetDeps: KernelFacetDependencies; + + /** Logger for outputting messages to the console */ + readonly #logger: Logger; + + /** Counter for allocating system vat IDs */ + #nextSystemVatId: number = 0; + + /** Active system vats indexed by ID */ + readonly #systemVats: Map = new Map(); + + /** Singleton kernel facet (created lazily, kept alive for GC purposes) */ + #kernelFacet: object | null = null; + + /** Kref of the singleton kernel facet */ + #kernelFacetKref: KRef | null = null; + + /** Function to register a kernel service */ + readonly #registerKernelService: ( + name: string, + service: object, + ) => { kref: string }; + + /** + * Creates a new SystemVatManager instance. + * + * @param options - Constructor options. + * @param options.kernelStore - The kernel's persistent state store. + * @param options.kernelQueue - The kernel's message queue. + * @param options.kernelFacetDeps - Dependencies for the kernel facet service. + * @param options.registerKernelService - Function to register kernel services. + * @param options.logger - Logger instance for debugging and diagnostics. + */ + constructor({ + kernelStore, + kernelQueue, + kernelFacetDeps, + registerKernelService, + logger, + }: SystemVatManagerOptions) { + this.#kernelStore = kernelStore; + this.#kernelQueue = kernelQueue; + this.#kernelFacetDeps = kernelFacetDeps; + this.#registerKernelService = registerKernelService; + this.#logger = logger; + harden(this); + } + + /** + * Allocate a new system vat ID. + * + * @returns A new system vat ID. + */ + #allocateSystemVatId(): SystemVatId { + const id: SystemVatId = `sv${this.#nextSystemVatId}`; + this.#nextSystemVatId += 1; + return id; + } + + /** + * Get the singleton kernel facet kref, creating and registering it if necessary. + * + * @returns The kref for the kernel facet. + */ + #getKernelFacetKref(): KRef { + if (!this.#kernelFacetKref) { + // Pass `this` as systemVatManager for dynamic registration + const depsWithManager = { + ...this.#kernelFacetDeps, + systemVatManager: this, + }; + this.#kernelFacet = makeKernelFacet(depsWithManager); + // Register the kernel facet as a kernel service so it can receive messages + const { kref } = this.#registerKernelService( + 'kernelFacet', + this.#kernelFacet, + ); + this.#kernelFacetKref = kref; + } + return this.#kernelFacetKref; + } + + /** + * Set up a system vat from a transport config. + * + * @param config - The system vat config with transport. + * @returns The system vat ID and root kref. + */ + #setupSystemVat(config: SystemVatConfig): { + systemVatId: SystemVatId; + rootKref: KRef; + } { + const { name, transport } = config; + const systemVatId = this.#allocateSystemVatId(); + + // Initialize the endpoint in the kernel store + this.#kernelStore.initEndpoint(systemVatId); + + // Create the system vat handle (kernel-side) with the transport's deliver function + const handle = new SystemVatHandle({ + systemVatId, + kernelStore: this.#kernelStore, + kernelQueue: this.#kernelQueue, + deliver: transport.deliver, + logger: this.#logger.subLogger({ tags: [systemVatId] }), + }); + + // Wire the syscall handler to the transport + transport.setSyscallHandler(handle.getSyscallHandler()); + + // Get or create the root kref (the root object is exported at o+0) + let rootKref = this.#kernelStore.erefToKref(systemVatId, ROOT_OBJECT_VREF); + if (!rootKref) { + // Initialize the root object in the clist + rootKref = this.#kernelStore.initKernelObject(systemVatId); + this.#kernelStore.addCListEntry(systemVatId, rootKref, ROOT_OBJECT_VREF); + } + + // Store the vat record + const record: SystemVatRecord = { + id: systemVatId, + name, + handle, + rootKref, + }; + this.#systemVats.set(systemVatId, record); + + return { systemVatId, rootKref }; + } + + /** + * Register a system vat using a provided transport. + * + * The runtime creates the supervisor externally and provides the transport for + * communication. This method sets up the kernel side and awaits connection + * before sending the bootstrap message. + * + * For the host vat (configured at kernel construction), call this during kernel + * init. For dynamic vats (UI instances, etc.), call via the kernel facet. + * + * @param config - Configuration for the system vat with transport. + * @returns A promise for the registration result with system vat ID and disconnect function. + */ + async registerSystemVat( + config: SystemVatConfig, + ): Promise { + const { systemVatId, rootKref } = this.#setupSystemVat(config); + + // Get the singleton kernel facet kref + const kernelFacetKref = this.#getKernelFacetKref(); + + // Build roots object for bootstrap (just this vat's root) + const roots: Record = { + [config.name]: kslot(rootKref, 'vatRoot'), + }; + + // Build services object - always include kernelFacet + const services: Record = { + kernelFacet: kslot(kernelFacetKref, 'KernelFacet'), + }; + if (config.services) { + for (const serviceName of config.services) { + const serviceKref = this.#kernelStore.kv.get( + `kernelService.${serviceName}`, + ); + if (serviceKref) { + services[serviceName] = kslot(serviceKref); + } else { + this.#logger.warn(`Kernel service '${serviceName}' not found`); + } + } + } + + // Wait for connection then send bootstrap + await config.transport.awaitConnection(); + this.#kernelQueue.enqueueSend(rootKref, { + methargs: kser(['bootstrap', [roots, services]]), + }); + + // Return disconnect function for cleanup + const disconnect = async (): Promise => { + await this.disconnectSystemVat(systemVatId); + }; + + return { + systemVatId, + rootKref, + disconnect, + }; + } + + /** + * Disconnect and clean up a system vat. + * + * This performs full cleanup equivalent to vat termination: + * - Rejects pending promises where this vat is the decider + * - Deletes owned kernel objects (removes owner entries) + * - Decrements reference counts for imported objects + * - Cleans up c-list entries and adds orphaned krefs to GC + * + * @param systemVatId - The system vat ID to disconnect. + */ + async disconnectSystemVat(systemVatId: SystemVatId): Promise { + const record = this.#systemVats.get(systemVatId); + if (!record) { + this.#logger.warn(`System vat ${systemVatId} not found for disconnect`); + return; + } + + // Reject pending promises where this vat is the decider + const failure = kser(`System vat ${systemVatId} disconnected`); + for (const kpid of this.#kernelStore.getPromisesByDecider(systemVatId)) { + this.#kernelQueue.resolvePromises(systemVatId, [[kpid, true, failure]]); + } + + // Clean up kernel state: exports, imports, promises, c-list entries + const work = this.#kernelStore.cleanupTerminatedVat(systemVatId); + this.#logger.debug( + `System vat ${systemVatId} cleanup: ${work.exports} exports, ${work.imports} imports, ${work.promises} promises`, + ); + + // Remove the vat record from in-memory tracking + this.#systemVats.delete(systemVatId); + + this.#logger.log(`Disconnected system vat ${systemVatId} (${record.name})`); + } + + /** + * Get a system vat handle by ID. + * + * @param systemVatId - The system vat ID. + * @returns The system vat handle or undefined if not found. + */ + getSystemVatHandle(systemVatId: SystemVatId): SystemVatHandle | undefined { + const record = this.#systemVats.get(systemVatId); + return record?.handle; + } +} +harden(SystemVatManager); diff --git a/packages/ocap-kernel/src/vats/SystemVatSupervisor.test.ts b/packages/ocap-kernel/src/vats/SystemVatSupervisor.test.ts new file mode 100644 index 000000000..47ad6f042 --- /dev/null +++ b/packages/ocap-kernel/src/vats/SystemVatSupervisor.test.ts @@ -0,0 +1,374 @@ +import type { VatDeliveryObject } from '@agoric/swingset-liveslots'; +import { Logger } from '@metamask/logger'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { SystemVatBuildRootObject, SystemVatId } from '../types.ts'; +import type { SystemVatExecuteSyscall } from './SystemVatSupervisor.ts'; +import { SystemVatSupervisor } from './SystemVatSupervisor.ts'; + +// Mock liveslots +const mockDispatch = vi.fn(); +vi.mock('@agoric/swingset-liveslots', () => ({ + makeLiveSlots: vi.fn(() => ({ + dispatch: mockDispatch, + })), +})); + +describe('SystemVatSupervisor', () => { + let buildRootObject: SystemVatBuildRootObject; + let vatPowers: Record; + let executeSyscall: SystemVatExecuteSyscall; + let logger: Logger; + const systemVatId: SystemVatId = 'sv0'; + + beforeEach(() => { + vi.clearAllMocks(); + mockDispatch.mockResolvedValue(undefined); + + buildRootObject = vi.fn(() => ({ + test: () => 'test result', + })); + vatPowers = { testPower: 'power' }; + executeSyscall = vi.fn().mockReturnValue(['ok', null]); + logger = { + debug: vi.fn(), + error: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + subLogger: vi.fn(() => logger), + } as unknown as Logger; + }); + + describe('constructor', () => { + it('creates a SystemVatSupervisor with the given ID', () => { + const supervisor = new SystemVatSupervisor({ + id: systemVatId, + buildRootObject, + vatPowers, + parameters: {}, + executeSyscall, + logger, + }); + + expect(supervisor.id).toBe(systemVatId); + }); + + it('initializes liveslots during construction', async () => { + const { makeLiveSlots } = await import('@agoric/swingset-liveslots'); + + const supervisor = new SystemVatSupervisor({ + id: systemVatId, + buildRootObject, + vatPowers, + parameters: {}, + executeSyscall, + logger, + }); + + expect(supervisor.id).toBe(systemVatId); + expect(makeLiveSlots).toHaveBeenCalled(); + }); + + it('passes vatPowers to liveslots', async () => { + const { makeLiveSlots } = await import('@agoric/swingset-liveslots'); + + const supervisor = new SystemVatSupervisor({ + id: systemVatId, + buildRootObject, + vatPowers: { customPower: 'custom' }, + parameters: {}, + executeSyscall, + logger, + }); + + expect(supervisor.id).toBe(systemVatId); + expect(makeLiveSlots).toHaveBeenCalledWith( + expect.anything(), + systemVatId, + { customPower: 'custom' }, + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + ); + }); + }); + + describe('start', () => { + it('dispatches startVat delivery', async () => { + const supervisor = new SystemVatSupervisor({ + id: systemVatId, + buildRootObject, + vatPowers, + parameters: {}, + executeSyscall, + logger, + }); + + await supervisor.start(); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.arrayContaining(['startVat', expect.anything()]), + ); + }); + + it('throws on failed start', async () => { + mockDispatch.mockRejectedValueOnce(new Error('start failed')); + + const supervisor = new SystemVatSupervisor({ + id: systemVatId, + buildRootObject, + vatPowers, + parameters: {}, + executeSyscall, + logger, + }); + + await expect(supervisor.start()).rejects.toThrow('start failed'); + }); + }); + + describe('deliver', () => { + it('dispatches message deliveries', async () => { + const supervisor = new SystemVatSupervisor({ + id: systemVatId, + buildRootObject, + vatPowers, + parameters: {}, + executeSyscall, + logger, + }); + + const delivery: VatDeliveryObject = [ + 'message', + 'o+0', + { methargs: { body: '[]', slots: [] }, result: null }, + ]; + await supervisor.deliver(delivery); + + expect(mockDispatch).toHaveBeenCalledWith(delivery); + }); + + it('dispatches notify deliveries', async () => { + const supervisor = new SystemVatSupervisor({ + id: systemVatId, + buildRootObject, + vatPowers, + parameters: {}, + executeSyscall, + logger, + }); + + const delivery: VatDeliveryObject = [ + 'notify', + [['p-1', false, { body: '"resolved"', slots: [] }]], + ]; + await supervisor.deliver(delivery); + + expect(mockDispatch).toHaveBeenCalledWith(delivery); + }); + + it('returns null on successful delivery', async () => { + const supervisor = new SystemVatSupervisor({ + id: systemVatId, + buildRootObject, + vatPowers, + parameters: {}, + executeSyscall, + logger, + }); + + const delivery: VatDeliveryObject = [ + 'message', + 'o+0', + { methargs: { body: '[]', slots: [] }, result: null }, + ]; + const result = await supervisor.deliver(delivery); + + expect(result).toBeNull(); + }); + + it('returns error message on failed delivery', async () => { + mockDispatch.mockRejectedValueOnce(new Error('delivery failed')); + + const supervisor = new SystemVatSupervisor({ + id: systemVatId, + buildRootObject, + vatPowers, + parameters: {}, + executeSyscall, + logger, + }); + + const delivery: VatDeliveryObject = [ + 'message', + 'o+0', + { methargs: { body: '[]', slots: [] }, result: null }, + ]; + const result = await supervisor.deliver(delivery); + + expect(result).toBe('delivery failed'); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Delivery error'), + 'delivery failed', + ); + }); + }); + + describe('syscall handling', () => { + it('passes syscalls to executeSyscall callback', async () => { + const { makeLiveSlots } = await import('@agoric/swingset-liveslots'); + + const supervisor = new SystemVatSupervisor({ + id: systemVatId, + buildRootObject, + vatPowers, + parameters: {}, + executeSyscall, + logger, + }); + expect(supervisor.id).toBe(systemVatId); + + // Get the syscall object passed to makeLiveSlots + const syscall = vi.mocked(makeLiveSlots).mock.calls[0]?.[0]; + + // Test the send syscall + syscall.send('o+1', { body: '[]', slots: [] }, 'p-1'); + + expect(executeSyscall).toHaveBeenCalledWith([ + 'send', + 'o+1', + { methargs: { body: '[]', slots: [] }, result: 'p-1' }, + ]); + }); + + it('throws on syscall error', async () => { + const { makeLiveSlots } = await import('@agoric/swingset-liveslots'); + + const failingExecuteSyscall = vi + .fn() + .mockReturnValue(['error', 'syscall failed']); + + const supervisor = new SystemVatSupervisor({ + id: systemVatId, + buildRootObject, + vatPowers, + parameters: {}, + executeSyscall: failingExecuteSyscall, + logger, + }); + expect(supervisor.id).toBe(systemVatId); + + // Get the syscall object passed to makeLiveSlots + const syscall = vi.mocked(makeLiveSlots).mock.calls[0]?.[0]; + + expect(() => + syscall.send('o+1', { body: '[]', slots: [] }, 'p-1'), + ).toThrow('syscall.send failed: syscall failed'); + }); + + it('throws for callNow syscall', async () => { + const { makeLiveSlots } = await import('@agoric/swingset-liveslots'); + + const supervisor = new SystemVatSupervisor({ + id: systemVatId, + buildRootObject, + vatPowers, + parameters: {}, + executeSyscall, + logger, + }); + expect(supervisor.id).toBe(systemVatId); + + // Get the syscall object passed to makeLiveSlots + const syscall = vi.mocked(makeLiveSlots).mock.calls[0]?.[0]; + + expect(() => syscall.callNow()).toThrow( + 'callNow not supported for system vats', + ); + }); + }); + + describe('ephemeral vatstore', () => { + it('provides ephemeral vatstore operations', async () => { + const { makeLiveSlots } = await import('@agoric/swingset-liveslots'); + + // eslint-disable-next-line no-new + new SystemVatSupervisor({ + id: systemVatId, + buildRootObject, + vatPowers, + parameters: {}, + executeSyscall, + logger, + }); + + // Get the syscall object passed to makeLiveSlots + const syscall = vi.mocked(makeLiveSlots).mock.calls[0]?.[0]; + + // Test vatstore operations + expect(syscall.vatstoreGet('key')).toBeUndefined(); + + syscall.vatstoreSet('key', 'value'); + expect(syscall.vatstoreGet('key')).toBe('value'); + + syscall.vatstoreDelete('key'); + expect(syscall.vatstoreGet('key')).toBeUndefined(); + }); + + it('provides getNextKey for ephemeral vatstore', async () => { + const { makeLiveSlots } = await import('@agoric/swingset-liveslots'); + + // eslint-disable-next-line no-new + new SystemVatSupervisor({ + id: systemVatId, + buildRootObject, + vatPowers, + parameters: {}, + executeSyscall, + logger, + }); + + // Get the syscall object passed to makeLiveSlots + const syscall = vi.mocked(makeLiveSlots).mock.calls[0]?.[0]; + + syscall.vatstoreSet('a', '1'); + syscall.vatstoreSet('b', '2'); + syscall.vatstoreSet('c', '3'); + + expect(syscall.vatstoreGetNextKey('a')).toBe('b'); + expect(syscall.vatstoreGetNextKey('b')).toBe('c'); + expect(syscall.vatstoreGetNextKey('c')).toBeUndefined(); + }); + }); + + describe('parameters', () => { + it('passes parameters to buildRootObject', async () => { + const { makeLiveSlots } = await import('@agoric/swingset-liveslots'); + + // eslint-disable-next-line no-new + new SystemVatSupervisor({ + id: systemVatId, + buildRootObject, + vatPowers, + parameters: { testParam: 'testValue' }, + executeSyscall, + logger, + }); + + // Get the buildVatNamespace function passed to makeLiveSlots + const buildVatNamespace = vi.mocked(makeLiveSlots).mock.calls[0]?.[6]; + + // Call buildVatNamespace to get the namespace + const namespace = await buildVatNamespace({}, {}); + + // Call buildRootObject from the namespace + (namespace.buildRootObject as CallableFunction)({}); + + // Verify buildRootObject was called with parameters + expect(buildRootObject).toHaveBeenCalledWith(expect.anything(), { + testParam: 'testValue', + }); + }); + }); +}); diff --git a/packages/ocap-kernel/src/vats/SystemVatSupervisor.ts b/packages/ocap-kernel/src/vats/SystemVatSupervisor.ts new file mode 100644 index 000000000..7453f6a50 --- /dev/null +++ b/packages/ocap-kernel/src/vats/SystemVatSupervisor.ts @@ -0,0 +1,325 @@ +import { makeLiveSlots as localMakeLiveSlots } from '@agoric/swingset-liveslots'; +import type { + VatDeliveryObject, + VatSyscallObject, + VatSyscallResult, +} from '@agoric/swingset-liveslots'; +import { makeMarshal } from '@endo/marshal'; +import type { CapData } from '@endo/marshal'; +import type { KVStore } from '@metamask/kernel-store'; +import { waitUntilQuiescent } from '@metamask/kernel-utils'; +import { Logger } from '@metamask/logger'; +import type { Json } from '@metamask/utils'; + +import { makeGCAndFinalize } from '../garbage-collection/gc-finalize.ts'; +import { makeDummyMeterControl } from '../liveslots/meter-control.ts'; +import type { + DispatchFn, + MakeLiveSlotsFn, + GCTools, + Syscall, + SyscallResult, +} from '../liveslots/types.ts'; +import type { + SystemVatId, + SystemVatBuildRootObject, + DeliveryObject, +} from '../types.ts'; + +const makeLiveSlots: MakeLiveSlotsFn = localMakeLiveSlots; + +const marshal = makeMarshal(undefined, undefined, { + serializeBodyFormat: 'smallcaps', +}); + +/** + * Syscall executor type - synchronously handles syscalls from the system vat. + */ +export type SystemVatExecuteSyscall = ( + vso: VatSyscallObject, +) => VatSyscallResult; + +type SystemVatSupervisorProps = { + buildRootObject: SystemVatBuildRootObject; + executeSyscall: SystemVatExecuteSyscall; + /** ID for the system vat. Defaults to 'sv-pending'. */ + id?: SystemVatId; + vatPowers?: Record; + parameters?: Record; + logger?: Logger; +}; + +/** + * A non-persistent KV store for system vats. + * + * System vats don't participate in kernel persistence machinery, so their + * vatstore is ephemeral (Map-based). This store is still required because + * liveslots uses the vatstore internally for: + * - Virtual object tracking and lifecycle management + * - Promise resolution bookkeeping + * - Reference counting and garbage collection coordination + * + * The data in this store is lost when the system vat terminates, which is + * acceptable since system vats are not designed to persist across restarts. + * + * @returns An ephemeral KVStore implementation. + */ +function makeEphemeralVatKVStore(): KVStore { + const data = new Map(); + + return harden({ + has(key: string): boolean { + return data.has(key); + }, + get(key: string): string | undefined { + return data.get(key); + }, + getRequired(key: string): string { + const value = data.get(key); + if (value === undefined) { + throw Error(`key "${key}" not found`); + } + return value; + }, + set(key: string, value: string): void { + data.set(key, value); + }, + delete(key: string): void { + data.delete(key); + }, + getNextKey(previousKey: string): string | undefined { + const keys = [...data.keys()].sort(); + const index = keys.indexOf(previousKey); + if (index === -1) { + // If key not found, find first key greater than previousKey + return keys.find((k) => k > previousKey); + } + return keys[index + 1]; + }, + *getKeys(start: string, end: string): Iterable { + const keys = [...data.keys()].sort(); + for (const key of keys) { + if (key >= start && key < end) { + yield key; + } + } + }, + *getPrefixedKeys(prefix: string): Iterable { + const keys = [...data.keys()].sort(); + for (const key of keys) { + if (key.startsWith(prefix)) { + yield key; + } + } + }, + }); +} + +/** + * Options for creating a system vat supervisor via the static factory method. + */ +export type SystemVatSupervisorMakeOptions = { + buildRootObject: SystemVatBuildRootObject; + executeSyscall: SystemVatExecuteSyscall; + vatPowers?: Record; + parameters?: Record; + logger?: Logger; +}; + +/** + * Supervises a system vat's execution. + * + * System vats run without compartment isolation directly in the host process. + * They don't load bundles via importBundle; instead, they receive a + * buildRootObject function directly. They use an ephemeral vatstore since + * they don't participate in kernel persistence machinery. + */ +export class SystemVatSupervisor { + /** The ID of the system vat being supervised */ + readonly id: SystemVatId; + + /** The logger for this system vat */ + readonly #logger: Logger; + + /** Function to dispatch deliveries into liveslots */ + readonly #dispatch: DispatchFn | null = null; + + /** + * Create and start a system vat supervisor. + * + * @param options - Options for creating the supervisor. + * @returns A promise that resolves to the started supervisor. + */ + static async make( + options: SystemVatSupervisorMakeOptions, + ): Promise { + const supervisor = new SystemVatSupervisor(options); + await supervisor.start(); + return supervisor; + } + + /** + * Construct a new SystemVatSupervisor instance. + * + * @param props - Named constructor parameters. + * @param props.buildRootObject - Function to build the vat's root object. + * @param props.executeSyscall - Function to execute syscalls. + * @param props.id - ID for the system vat. Defaults to 'sv-pending'. + * @param props.vatPowers - External capabilities for this system vat. + * @param props.parameters - Parameters to pass to buildRootObject. + * @param props.logger - The logger for this system vat. + */ + constructor(props: SystemVatSupervisorProps) { + const { + buildRootObject, + executeSyscall, + id = 'sv-pending' as SystemVatId, + vatPowers = {}, + parameters, + logger = new Logger('system-vat'), + } = props; + this.id = id; + this.#logger = logger; + + const kvStore = makeEphemeralVatKVStore(); + const syscall = this.#makeSyscall(executeSyscall, kvStore); + const liveSlotsOptions = {}; + + const gcTools: GCTools = harden({ + WeakRef, + FinalizationRegistry, + waitUntilQuiescent, + gcAndFinalize: makeGCAndFinalize( + this.#logger.subLogger({ tags: ['gc'] }), + ), + meterControl: makeDummyMeterControl(), + }); + + // For system vats, buildVatNamespace returns the buildRootObject directly + // without loading a bundle via importBundle. + // + // Liveslots invokes buildVatNamespace, then calls the returned buildRootObject. + // VatPowers are merged in three stages: + // 1. External vatPowers (e.g., kernelFacet for bootstrap vat) + // 2. lsEndowments from liveslots (D, etc.) provided to buildVatNamespace + // 3. innerVatPowers from liveslots (exitVat, etc.) provided to buildRootObject + // Later sources override earlier ones. + const buildVatNamespace = async ( + lsEndowments: Record, + _inescapableGlobalProperties: object, + ): Promise> => { + return { + buildRootObject: (innerVatPowers: Record) => { + const finalVatPowers = { + ...vatPowers, + ...lsEndowments, + ...innerVatPowers, + }; + return buildRootObject(finalVatPowers, parameters); + }, + }; + }; + + const liveslots = makeLiveSlots( + syscall, + this.id, + vatPowers, + liveSlotsOptions, + gcTools, + this.#logger.subLogger({ tags: ['liveslots'] }), + buildVatNamespace, + ); + + this.#dispatch = liveslots.dispatch; + } + + /** + * Create a syscall interface for the system vat. + * + * @param executeSyscall - Function to execute syscalls to the kernel. + * @param kv - The ephemeral KV store for this system vat. + * @returns A syscall object for liveslots. + */ + #makeSyscall(executeSyscall: SystemVatExecuteSyscall, kv: KVStore): Syscall { + const doSyscall = (vso: VatSyscallObject): SyscallResult => { + let syscallResult; + try { + syscallResult = executeSyscall(vso); + } catch (problem) { + this.#logger.warn(`system vat got error during syscall:`, problem); + throw problem; + } + const [type, ...rest] = syscallResult; + switch (type) { + case 'ok': { + const [data] = rest; + return data; + } + case 'error': { + const [problem] = rest; + throw Error(`syscall.${vso[0]} failed: ${problem as string}`); + } + default: + throw Error(`unknown result type ${type as string}`); + } + }; + + return harden({ + send: (target: string, methargs: CapData, result?: string) => + doSyscall(['send', target, { methargs, result }]), + subscribe: (vpid: string) => doSyscall(['subscribe', vpid]), + resolve: (resolutions) => doSyscall(['resolve', resolutions]), + exit: (isFailure: boolean, info: CapData) => + doSyscall(['exit', isFailure, info]), + dropImports: (vrefs: string[]) => doSyscall(['dropImports', vrefs]), + retireImports: (vrefs: string[]) => doSyscall(['retireImports', vrefs]), + retireExports: (vrefs: string[]) => doSyscall(['retireExports', vrefs]), + abandonExports: (vrefs: string[]) => doSyscall(['abandonExports', vrefs]), + callNow: () => { + throw Error(`callNow not supported for system vats`); + }, + // System vats use an ephemeral vatstore (non-persistent) + vatstoreGet: (key: string) => kv.get(key), + vatstoreGetNextKey: (priorKey: string) => kv.getNextKey(priorKey), + vatstoreSet: (key: string, value: string) => kv.set(key, value), + vatstoreDelete: (key: string) => kv.delete(key), + }); + } + + /** + * Start the system vat by dispatching the startVat delivery. + */ + async start(): Promise { + if (!this.#dispatch) { + throw new Error('SystemVatSupervisor not initialized'); + } + + const serParam = marshal.toCapData(harden({})) as CapData; + await this.#dispatch(harden(['startVat', serParam])); + } + + /** + * Deliver a message to the system vat. + * + * @param delivery - The delivery object to dispatch. + * @returns A promise that resolves to the delivery error (null if success). + */ + async deliver(delivery: DeliveryObject): Promise { + if (!this.#dispatch) { + throw new Error('SystemVatSupervisor not initialized'); + } + + let deliveryError: string | null = null; + try { + // Cast needed because DeliveryObject and VatDeliveryObject have minor type differences + await this.#dispatch(harden(delivery as VatDeliveryObject)); + } catch (error) { + deliveryError = error instanceof Error ? error.message : String(error); + this.#logger.error( + `Delivery error in system vat ${this.id}:`, + deliveryError, + ); + } + return deliveryError; + } +} diff --git a/packages/ocap-kernel/src/vats/VatHandle.test.ts b/packages/ocap-kernel/src/vats/VatHandle.test.ts index 62dacc143..ccba469f7 100644 --- a/packages/ocap-kernel/src/vats/VatHandle.test.ts +++ b/packages/ocap-kernel/src/vats/VatHandle.test.ts @@ -2,7 +2,6 @@ import type { VatOneResolution } from '@agoric/swingset-liveslots'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { isJsonRpcMessage } from '@metamask/kernel-utils'; import type { Logger } from '@metamask/logger'; -import type { Json } from '@metamask/utils'; import { delay } from '@ocap/repo-tools/test-utils'; import { TestDuplexStream } from '@ocap/repo-tools/test-utils/streams'; import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -242,31 +241,6 @@ describe('VatHandle', () => { }); }); - describe('sendVatCommand', () => { - it('sends a message and resolves the promise', async () => { - const dispatch = vi.fn(); - const { vat, stream } = await makeVat({ dispatch }); - const mockMessage = { - method: 'ping' as const, - params: [] as Json[], - }; - - const sendVatCommandPromise = vat.sendVatCommand(mockMessage); - await delay(10); - expect(dispatch).toHaveBeenCalledWith( - expect.objectContaining(mockMessage), - ); - - await stream.receiveInput({ - id: 'v0:1', - result: 'test-response', - jsonrpc: '2.0', - }); - - expect(await sendVatCommandPromise).toBe('test-response'); - }); - }); - describe('terminate', () => { it('terminates the vat and rejects unresolved messages', async () => { const { vat, stream } = await makeVat(); @@ -276,10 +250,7 @@ describe('VatHandle', () => { mockKernelStore.addSubclusterVat('s1', 'v0'); // Create a pending message that should be rejected on terminate - const messagePromise = vat.sendVatCommand({ - method: 'ping' as const, - params: [], - }); + const messagePromise = vat.ping(); await vat.terminate(true); diff --git a/packages/ocap-kernel/src/vats/VatHandle.ts b/packages/ocap-kernel/src/vats/VatHandle.ts index 2b3c97635..2dad89c6d 100644 --- a/packages/ocap-kernel/src/vats/VatHandle.ts +++ b/packages/ocap-kernel/src/vats/VatHandle.ts @@ -1,7 +1,4 @@ -import type { - VatOneResolution, - VatSyscallObject, -} from '@agoric/swingset-liveslots'; +import type { VatSyscallObject } from '@agoric/swingset-liveslots'; import { VatDeletedError, StreamReadError } from '@metamask/kernel-errors'; import { RpcClient, RpcService } from '@metamask/kernel-rpc-methods'; import type { @@ -16,19 +13,17 @@ import { isJsonRpcNotification, isJsonRpcResponse } from '@metamask/utils'; import type { JsonRpcNotification, JsonRpcResponse } from '@metamask/utils'; import type { KernelQueue } from '../KernelQueue.ts'; -import { kser, makeError } from '../liveslots/kernel-marshal.ts'; +import { kser } from '../liveslots/kernel-marshal.ts'; import { vatMethodSpecs, vatSyscallHandlers } from '../rpc/index.ts'; import type { PingVatResult, VatMethod } from '../rpc/index.ts'; import type { KernelStore } from '../store/index.ts'; import type { - Message, - VatId, + DeliveryObject, VatConfig, - VRef, - CrankResults, VatDeliveryResult, - EndpointHandle, + VatId, } from '../types.ts'; +import { BaseVatHandle } from './BaseVatHandle.ts'; import { VatSyscall } from './VatSyscall.ts'; type MessageFromVat = JsonRpcResponse | JsonRpcNotification; @@ -47,7 +42,7 @@ type VatConstructorProps = { /** * Handles communication with and lifecycle management of a vat. */ -export class VatHandle implements EndpointHandle { +export class VatHandle extends BaseVatHandle { /** The ID of the vat this is the VatHandle for */ readonly vatId: VatId; @@ -66,9 +61,6 @@ export class VatHandle implements EndpointHandle { /** Storage holding this vat's persistent state */ readonly #vatStore: VatStore; - /** The vat's syscall */ - readonly #vatSyscall: VatSyscall; - /** The kernel's queue */ readonly #kernelQueue: KernelQueue; @@ -96,32 +88,44 @@ export class VatHandle implements EndpointHandle { kernelQueue, logger, }: VatConstructorProps) { + const vatSyscall = new VatSyscall({ + vatId, + kernelQueue, + kernelStore, + isActive: () => kernelStore.isVatActive(vatId), + logger: logger?.subLogger({ tags: ['syscall'] }), + }); + + super(vatSyscall); + this.vatId = vatId; this.config = vatConfig; this.#logger = logger; this.#vatStream = vatStream; this.#kernelStore = kernelStore; - this.#vatStore = kernelStore.makeVatStore(vatId); this.#kernelQueue = kernelQueue; - this.#vatSyscall = new VatSyscall({ - vatId, - kernelQueue, - kernelStore, - logger: this.#logger?.subLogger({ tags: ['syscall'] }), - }); - + this.#vatStore = kernelStore.makeVatStore(vatId); this.#rpcClient = new RpcClient( vatMethodSpecs, async (request) => { - await this.#vatStream.write(request); + await vatStream.write(request); }, - `${this.vatId}:`, + `${vatId}:`, ); this.#rpcService = new RpcService(vatSyscallHandlers, { handleSyscall: (params) => { - this.#vatSyscall.handleSyscall(params as VatSyscallObject); + this.vatSyscall.handleSyscall(params as VatSyscallObject); }, }); + + // Set up deliver function for BaseVatHandle + this.deliver = async (delivery: DeliveryObject): Promise => { + const [, deliveryError] = await this.sendVatCommand({ + method: 'deliver', + params: delivery, + }); + return deliveryError; + }; } /** @@ -204,90 +208,6 @@ export class VatHandle implements EndpointHandle { } } - /** - * Make a 'message' delivery to the vat. - * - * @param target - The VRef of the object to which the message is addressed. - * @param message - The message to deliver. - * @returns The crank results. - */ - async deliverMessage(target: VRef, message: Message): Promise { - await this.sendVatCommand({ - method: 'deliver', - params: ['message', target, message], - }); - return this.#getDeliveryCrankResults(); - } - - /** - * Make a 'notify' delivery to the vat. - * - * @param resolutions - One or more promise resolutions to deliver. - * @returns The crank results. - */ - async deliverNotify(resolutions: VatOneResolution[]): Promise { - await this.sendVatCommand({ - method: 'deliver', - params: ['notify', resolutions], - }); - return this.#getDeliveryCrankResults(); - } - - /** - * Make a 'dropExports' delivery to the vat. - * - * @param vrefs - The VRefs of the exports to be dropped. - * @returns The crank results. - */ - async deliverDropExports(vrefs: VRef[]): Promise { - await this.sendVatCommand({ - method: 'deliver', - params: ['dropExports', vrefs], - }); - return this.#getDeliveryCrankResults(); - } - - /** - * Make a 'retireExports' delivery to the vat. - * - * @param vrefs - The VRefs of the exports to be retired. - * @returns The crank results. - */ - async deliverRetireExports(vrefs: VRef[]): Promise { - await this.sendVatCommand({ - method: 'deliver', - params: ['retireExports', vrefs], - }); - return this.#getDeliveryCrankResults(); - } - - /** - * Make a 'retireImports' delivery to the vat. - * - * @param vrefs - The VRefs of the imports to be retired. - * @returns The crank results. - */ - async deliverRetireImports(vrefs: VRef[]): Promise { - await this.sendVatCommand({ - method: 'deliver', - params: ['retireImports', vrefs], - }); - return this.#getDeliveryCrankResults(); - } - - /** - * Make a 'bringOutYourDead' delivery to the vat. - * - * @returns The crank results. - */ - async deliverBringOutYourDead(): Promise { - await this.sendVatCommand({ - method: 'deliver', - params: ['bringOutYourDead'], - }); - return this.#getDeliveryCrankResults(); - } - /** * Terminates the vat. * @@ -325,10 +245,9 @@ export class VatHandle implements EndpointHandle { params: ExtractParams; }): Promise> { const result = await this.#rpcClient.call(method, params); - if (method === 'initVat' || method === 'deliver') { + if (method === 'deliver' || method === 'initVat') { const [[sets, deletes], deliveryError] = result as VatDeliveryResult; - this.#vatSyscall.deliveryError = deliveryError ?? undefined; - const noErrors = !deliveryError && !this.#vatSyscall.illegalSyscall; + const noErrors = !deliveryError && !this.vatSyscall.illegalSyscall; // On errors, we neither update this vat's KV data nor rollback previous changes. // This is safe because vats are always terminated when errors occur // and they have their own databases, which are deleted when the vat is terminated. @@ -339,40 +258,4 @@ export class VatHandle implements EndpointHandle { } return result; } - - /** - * Get the crank outcome for a given checkpoint result. - * - * @returns The crank outcome. - */ - async #getDeliveryCrankResults(): Promise { - const results: CrankResults = { - didDelivery: this.vatId, - }; - - // These conditionals express a priority order: the consequences of an - // illegal syscall take precedence over a vat requesting termination, etc. - if (this.#vatSyscall.illegalSyscall) { - results.abort = true; - const { info } = this.#vatSyscall.illegalSyscall; - // TODO: For now, vat errors both rewind changes and terminate the vat. - // Some day, they might rewind changes and retry the syscall. - // We should terminate the vat only after a certain # of failed retries. - results.terminate = { vatId: this.vatId, reject: true, info }; - } else if (this.#vatSyscall.deliveryError) { - results.abort = true; - const info = makeError(this.#vatSyscall.deliveryError); - results.terminate = { vatId: this.vatId, reject: true, info }; - } else if (this.#vatSyscall.vatRequestedTermination) { - if (this.#vatSyscall.vatRequestedTermination.reject) { - results.abort = true; // vatPowers.exitWithFailure wants rewind - } - results.terminate = { - vatId: this.vatId, - ...this.#vatSyscall.vatRequestedTermination, - }; - } - - return harden(results); - } } diff --git a/packages/ocap-kernel/src/vats/VatSyscall.test.ts b/packages/ocap-kernel/src/vats/VatSyscall.test.ts index 06f0fb02c..442aa5ac5 100644 --- a/packages/ocap-kernel/src/vats/VatSyscall.test.ts +++ b/packages/ocap-kernel/src/vats/VatSyscall.test.ts @@ -15,6 +15,7 @@ describe('VatSyscall', () => { let kernelQueue: KernelQueue; let kernelStore: KernelStore; let logger: Logger; + let isActive: () => boolean; let vatSys: VatSyscall; beforeEach(() => { @@ -31,7 +32,6 @@ describe('VatSyscall', () => { getReachableFlag: vi.fn(), forgetKref: vi.fn(), getVatConfig: vi.fn(() => ({})), - isVatActive: vi.fn(() => true), } as unknown as KernelStore; logger = { debug: vi.fn(), @@ -40,10 +40,17 @@ describe('VatSyscall', () => { warn: vi.fn(), subLogger: vi.fn(() => logger), } as unknown as Logger; - vatSys = new VatSyscall({ vatId: 'v1', kernelQueue, kernelStore, logger }); + isActive = vi.fn(() => true); + vatSys = new VatSyscall({ + vatId: 'v1', + kernelQueue, + kernelStore, + isActive, + logger, + }); }); - it('enqueues run for send syscall', async () => { + it('enqueues run for send syscall', () => { const target = 'o+1'; const message = {} as unknown as Message; const vso = ['send', target, message] as unknown as VatSyscallObject; @@ -51,7 +58,7 @@ describe('VatSyscall', () => { expect(kernelQueue.enqueueSend).toHaveBeenCalledWith(target, message); }); - it('calls resolvePromises for resolve syscall', async () => { + it('calls resolvePromises for resolve syscall', () => { const resolution = ['kp1', false, {}] as unknown as VatOneResolution; const vso = ['resolve', [resolution]] as unknown as VatSyscallObject; vatSys.handleSyscall(vso); @@ -61,7 +68,7 @@ describe('VatSyscall', () => { }); describe('subscribe syscall', () => { - it('subscribes to unresolved promise', async () => { + it('subscribes to unresolved promise', () => { ( kernelStore.getKernelPromise as unknown as MockInstance ).mockReturnValueOnce({ @@ -75,7 +82,7 @@ describe('VatSyscall', () => { ); }); - it('notifies for resolved promise', async () => { + it('notifies for resolved promise', () => { ( kernelStore.getKernelPromise as unknown as MockInstance ).mockReturnValueOnce({ @@ -88,7 +95,7 @@ describe('VatSyscall', () => { }); describe('dropImports syscall', () => { - it('clears reachable flags for valid imports', async () => { + it('clears reachable flags for valid imports', () => { const vso = [ 'dropImports', ['o-1', 'o-2'], @@ -101,7 +108,7 @@ describe('VatSyscall', () => { it.each([ ['o+1', 'vat v1 issued invalid syscall dropImports for o+1'], ['p-1', 'vat v1 issued invalid syscall dropImports for p-1'], - ])('returns error for invalid ref %s', async (ref, errMsg) => { + ])('returns error for invalid ref %s', (ref, errMsg) => { ( kernelStore.translateSyscallVtoK as unknown as MockInstance ).mockImplementationOnce(() => { @@ -114,7 +121,7 @@ describe('VatSyscall', () => { }); describe('retireImports syscall', () => { - it('forgets kref when not reachable', async () => { + it('forgets kref when not reachable', () => { ( kernelStore.getReachableFlag as unknown as MockInstance ).mockReturnValueOnce(false); @@ -123,7 +130,7 @@ describe('VatSyscall', () => { expect(kernelStore.forgetKref).toHaveBeenCalledWith('v1', 'o-1'); }); - it('returns error if still reachable', async () => { + it('returns error if still reachable', () => { ( kernelStore.translateSyscallVtoK as unknown as MockInstance ).mockImplementationOnce(() => { @@ -142,7 +149,7 @@ describe('VatSyscall', () => { }); describe('exportCleanup syscalls', () => { - it('retires exports when not reachable', async () => { + it('retires exports when not reachable', () => { ( kernelStore.getReachableFlag as unknown as MockInstance ).mockReturnValueOnce(false); @@ -154,7 +161,7 @@ describe('VatSyscall', () => { ); }); - it('returns error for reachable exports', async () => { + it('returns error for reachable exports', () => { ( kernelStore.translateSyscallVtoK as unknown as MockInstance ).mockImplementationOnce(() => { @@ -171,7 +178,7 @@ describe('VatSyscall', () => { ]); }); - it('abandons exports without reachability check', async () => { + it('abandons exports without reachability check', () => { const vso = ['abandonExports', ['o+1']] as unknown as VatSyscallObject; vatSys.handleSyscall(vso); expect(kernelStore.forgetKref).toHaveBeenCalledWith('v1', 'o+1'); @@ -180,7 +187,7 @@ describe('VatSyscall', () => { ); }); - it('returns error for invalid abandonExports refs', async () => { + it('returns error for invalid abandonExports refs', () => { ( kernelStore.translateSyscallVtoK as unknown as MockInstance ).mockImplementationOnce(() => { @@ -196,7 +203,7 @@ describe('VatSyscall', () => { }); describe('exit syscall', () => { - it('records vat termination request', async () => { + it('records vat termination request', () => { const vso = [ 'exit', true, @@ -211,10 +218,8 @@ describe('VatSyscall', () => { }); describe('error handling', () => { - it('handles vat not found error', async () => { - (kernelStore.isVatActive as unknown as MockInstance).mockReturnValueOnce( - false, - ); + it('handles vat not found error', () => { + vi.mocked(isActive).mockReturnValueOnce(false); const vso = ['send', 'o+1', {}] as unknown as VatSyscallObject; const result = vatSys.handleSyscall(vso); @@ -222,7 +227,7 @@ describe('VatSyscall', () => { expect(vatSys.illegalSyscall).toBeDefined(); }); - it('handles general syscall errors', async () => { + it('handles general syscall errors', () => { const error = new Error('test error'); ( kernelStore.translateSyscallVtoK as unknown as MockInstance @@ -249,7 +254,7 @@ describe('VatSyscall', () => { ['vatstoreDelete', 'invalid syscall vatstoreDelete'], ['callNow', 'invalid syscall callNow'], ['unknownOp', 'unknown syscall unknownOp'], - ])('%s should warn', async (op, message) => { + ])('%s warns', (op, message) => { const spy = vi.spyOn(logger, 'warn'); const vso = [op, []] as unknown as VatSyscallObject; vatSys.handleSyscall(vso); @@ -259,16 +264,143 @@ describe('VatSyscall', () => { }); describe('logging', () => { - it('is disabled if logger is undefined', async () => { + it('is disabled if logger is undefined', () => { const logSpy = vi.spyOn(console, 'log'); const vatSyscall = new VatSyscall({ vatId: 'v1', kernelQueue, kernelStore, + isActive, }); vatSyscall.handleSyscall(['send', 'o+1', {}] as VatSyscallObject); expect(logSpy).not.toHaveBeenCalled(); expect(logger.log).not.toHaveBeenCalled(); }); }); + + describe('vatLabel', () => { + it('uses custom label in error messages', () => { + const systemVatSyscall = new VatSyscall({ + vatId: 'sv0', + kernelQueue, + kernelStore, + isActive: () => false, + vatLabel: 'system vat', + logger, + }); + const vso = ['send', 'o+1', {}] as unknown as VatSyscallObject; + const result = systemVatSyscall.handleSyscall(vso); + + expect(result).toStrictEqual(['error', 'system vat not found']); + }); + }); + + describe('getCrankResults', () => { + it('returns basic result when no errors or termination', () => { + const results = vatSys.getCrankResults(null); + expect(results).toStrictEqual({ + didDelivery: 'v1', + }); + }); + + it('returns termination result for illegalSyscall', () => { + vi.mocked(isActive).mockReturnValueOnce(false); + vatSys.handleSyscall(['send', 'o+1', {}] as unknown as VatSyscallObject); + + const results = vatSys.getCrankResults(null); + expect(results).toStrictEqual({ + didDelivery: 'v1', + abort: true, + terminate: { + vatId: 'v1', + reject: true, + info: expect.objectContaining({ + body: expect.stringContaining('vat not found'), + }), + }, + }); + }); + + it('returns termination result for deliveryError', () => { + const results = vatSys.getCrankResults('delivery error'); + expect(results).toStrictEqual({ + didDelivery: 'v1', + abort: true, + terminate: { + vatId: 'v1', + reject: true, + info: expect.objectContaining({ + body: expect.stringContaining('delivery error'), + }), + }, + }); + }); + + it('returns termination result for vatRequestedTermination with reject=true', () => { + vatSys.handleSyscall([ + 'exit', + true, + { body: '"error message"', slots: [] }, + ] as unknown as VatSyscallObject); + + const results = vatSys.getCrankResults(null); + expect(results).toStrictEqual({ + didDelivery: 'v1', + abort: true, + terminate: { + vatId: 'v1', + reject: true, + info: { body: '"error message"', slots: [] }, + }, + }); + }); + + it('returns termination result for vatRequestedTermination with reject=false', () => { + vatSys.handleSyscall([ + 'exit', + false, + { body: '"graceful exit"', slots: [] }, + ] as unknown as VatSyscallObject); + + const results = vatSys.getCrankResults(null); + expect(results).toStrictEqual({ + didDelivery: 'v1', + terminate: { + vatId: 'v1', + reject: false, + info: { body: '"graceful exit"', slots: [] }, + }, + }); + }); + + it('prioritizes illegalSyscall over deliveryError', () => { + vi.mocked(isActive).mockReturnValueOnce(false); + vatSys.handleSyscall(['send', 'o+1', {}] as unknown as VatSyscallObject); + + const results = vatSys.getCrankResults('delivery error'); + expect(results.terminate?.info.body).toContain('vat not found'); + }); + + it('prioritizes illegalSyscall over vatRequestedTermination', () => { + vi.mocked(isActive).mockReturnValueOnce(false); + vatSys.handleSyscall(['send', 'o+1', {}] as unknown as VatSyscallObject); + vatSys.vatRequestedTermination = { + reject: false, + info: { body: '"graceful"', slots: [] }, + }; + + const results = vatSys.getCrankResults(null); + expect(results.terminate?.info.body).toContain('vat not found'); + }); + + it('prioritizes deliveryError over vatRequestedTermination', () => { + vatSys.vatRequestedTermination = { + reject: false, + info: { body: '"graceful"', slots: [] }, + }; + + const results = vatSys.getCrankResults('delivery error'); + expect(results.terminate?.info.body).toContain('delivery error'); + }); + }); }); diff --git a/packages/ocap-kernel/src/vats/VatSyscall.ts b/packages/ocap-kernel/src/vats/VatSyscall.ts index e77cbc87d..5091c0c58 100644 --- a/packages/ocap-kernel/src/vats/VatSyscall.ts +++ b/packages/ocap-kernel/src/vats/VatSyscall.ts @@ -15,24 +15,26 @@ import type { KernelQueue } from '../KernelQueue.ts'; import { makeError } from '../liveslots/kernel-marshal.ts'; import type { KernelStore } from '../store/index.ts'; import { coerceMessage } from '../types.ts'; -import type { Message, VatId, KRef } from '../types.ts'; +import type { Message, EndpointId, KRef, CrankResults } from '../types.ts'; type VatSyscallProps = { - vatId: VatId; + vatId: EndpointId; kernelQueue: KernelQueue; kernelStore: KernelStore; + isActive: () => boolean; + vatLabel?: string; logger?: Logger | undefined; }; /** - * A VatSyscall is a class that handles syscalls from a vat. + * Handles syscalls from a vat (regular or system). * * This class is responsible for handling syscalls from a vat, including * sending messages, resolving promises, and dropping imports. */ export class VatSyscall { /** The ID of the vat */ - readonly vatId: VatId; + readonly vatId: EndpointId; /** The kernel's run queue */ readonly #kernelQueue: KernelQueue; @@ -40,14 +42,17 @@ export class VatSyscall { /** The kernel's store */ readonly #kernelStore: KernelStore; + /** Function to check if the vat is still active */ + readonly #isActive: () => boolean; + + /** Label for this vat type (for error messages) */ + readonly #vatLabel: string; + /** Logger for outputting messages (such as errors) to the console */ readonly #logger: Logger | undefined; /** The illegal syscall that was received */ - illegalSyscall: { vatId: VatId; info: SwingSetCapData } | undefined; - - /** The error when delivery failed */ - deliveryError: string | undefined; + illegalSyscall: { vatId: EndpointId; info: SwingSetCapData } | undefined; /** The termination request that was received from the vat with syscall.exit() */ vatRequestedTermination: @@ -61,12 +66,23 @@ export class VatSyscall { * @param props.vatId - The ID of the vat. * @param props.kernelQueue - The kernel's run queue. * @param props.kernelStore - The kernel's store. + * @param props.isActive - Function to check if the vat is still active. + * @param props.vatLabel - Label for this vat type (for error messages). * @param props.logger - The logger for the VatSyscall. */ - constructor({ vatId, kernelQueue, kernelStore, logger }: VatSyscallProps) { + constructor({ + vatId, + kernelQueue, + kernelStore, + isActive, + vatLabel = 'vat', + logger, + }: VatSyscallProps) { this.vatId = vatId; this.#kernelQueue = kernelQueue; this.#kernelStore = kernelStore; + this.#isActive = isActive; + this.#vatLabel = vatLabel; this.#logger = logger; } @@ -151,9 +167,9 @@ export class VatSyscall { this.vatRequestedTermination = undefined; // This is a safety check - this case should never happen - if (!this.#kernelStore.isVatActive(this.vatId)) { - this.#recordVatFatalSyscall('vat not found'); - return harden(['error', 'vat not found']); + if (!this.#isActive()) { + this.#recordVatFatalSyscall(`${this.#vatLabel} not found`); + return harden(['error', `${this.#vatLabel} not found`]); } const kso: VatSyscallObject = this.#kernelStore.translateSyscallVtoK( @@ -238,18 +254,27 @@ export class VatSyscall { case 'vatstoreGetNextKey': case 'vatstoreSet': case 'vatstoreDelete': { - this.#logger?.warn(`vat ${vatId} issued invalid syscall ${op} `, vso); + this.#logger?.warn( + `${this.#vatLabel} ${vatId} issued invalid syscall ${op} `, + vso, + ); break; } default: // Compile-time exhaustiveness check - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - this.#logger?.warn(`vat ${vatId} issued unknown syscall ${op} `, vso); + this.#logger?.warn( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `${this.#vatLabel} ${vatId} issued unknown syscall ${op} `, + vso, + ); break; } return harden(['ok', null]); } catch (error) { - this.#logger?.error(`Fatal syscall error in vat ${this.vatId}`, error); + this.#logger?.error( + `Fatal syscall error in ${this.#vatLabel} ${this.vatId}`, + error, + ); this.#recordVatFatalSyscall('syscall translation error: prepare to die'); return harden([ 'error', @@ -266,4 +291,37 @@ export class VatSyscall { #recordVatFatalSyscall(error: string): void { this.illegalSyscall = { vatId: this.vatId, info: makeError(error) }; } + + /** + * Build crank results after a delivery. + * + * @param deliveryError - Error from delivery, if any. + * @returns The crank results. + */ + getCrankResults(deliveryError: string | null): CrankResults { + const results: CrankResults = { + didDelivery: this.vatId, + }; + + // Priority order: illegalSyscall > deliveryError > vatRequestedTermination + if (this.illegalSyscall) { + results.abort = true; + const { info } = this.illegalSyscall; + results.terminate = { vatId: this.vatId, reject: true, info }; + } else if (deliveryError) { + results.abort = true; + const info = makeError(deliveryError); + results.terminate = { vatId: this.vatId, reject: true, info }; + } else if (this.vatRequestedTermination) { + if (this.vatRequestedTermination.reject) { + results.abort = true; + } + results.terminate = { + vatId: this.vatId, + ...this.vatRequestedTermination, + }; + } + + return harden(results); + } } diff --git a/packages/ocap-kernel/src/vats/index.ts b/packages/ocap-kernel/src/vats/index.ts new file mode 100644 index 000000000..f34386dc5 --- /dev/null +++ b/packages/ocap-kernel/src/vats/index.ts @@ -0,0 +1,12 @@ +export { BaseVatHandle, type DeliverFn } from './BaseVatHandle.ts'; +export { SystemVatSupervisor } from './SystemVatSupervisor.ts'; +export type { + SystemVatExecuteSyscall, + SystemVatSupervisorMakeOptions, +} from './SystemVatSupervisor.ts'; +export { SystemVatHandle } from './SystemVatHandle.ts'; +export type { + SystemVatDeliverFn, + SystemVatSyscallFn, +} from './SystemVatHandle.ts'; +export { SystemVatManager } from './SystemVatManager.ts'; diff --git a/packages/ocap-kernel/test/integration/system-vat.test.ts b/packages/ocap-kernel/test/integration/system-vat.test.ts new file mode 100644 index 000000000..8822cfd99 --- /dev/null +++ b/packages/ocap-kernel/test/integration/system-vat.test.ts @@ -0,0 +1,175 @@ +import { E } from '@endo/eventual-send'; +import { makePromiseKit } from '@endo/promise-kit'; +import type { JsonRpcMessage } from '@metamask/kernel-utils'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import { Logger } from '@metamask/logger'; +import type { DuplexStream } from '@metamask/streams'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +import type { KernelFacet } from '../../src/kernel-facet.ts'; +import { Kernel } from '../../src/Kernel.ts'; +import type { + PlatformServices, + SystemVatBuildRootObject, + SystemVatTransport, + SystemVatSyscallHandler, + SystemVatDeliverFn, + SystemVatConfig, +} from '../../src/types.ts'; +import { SystemVatSupervisor } from '../../src/vats/SystemVatSupervisor.ts'; +import { makeMapKernelDatabase } from '../storage.ts'; + +/** + * Result of creating a test system vat. + */ +type TestSystemVatResult = { + /** Config for kernel. */ + config: SystemVatConfig; + /** Call after Kernel.make() to initiate connection from supervisor side. */ + connect: () => void; + /** Promise that resolves to kernelFacet when bootstrap completes. */ + kernelFacetPromise: Promise; +}; + +/** + * Create a system vat transport and supervisor pair for testing. + * Uses the push-based connection pattern where the supervisor initiates + * connection after the kernel is created. + * + * @param options - Options for creating the transport. + * @param options.logger - Logger instance. + * @param options.name - Name for the system vat. + * @returns The config, connect function, and kernelFacetPromise. + */ +function makeTestSystemVat(options: { + logger: Logger; + name?: string; +}): TestSystemVatResult { + const { logger, name = 'testVat' } = options; + + // Promise kit for kernel facet - resolves when bootstrap is called + const kernelFacetKit = makePromiseKit(); + + // Syscall handler - set by kernel during registerSystemVat() + let syscallHandler: SystemVatSyscallHandler | null = null; + + // Build root object that captures kernelFacet from bootstrap + const buildRootObject: SystemVatBuildRootObject = () => { + return makeDefaultExo('TestRoot', { + bootstrap: ( + _roots: Record, + services: { kernelFacet: KernelFacet }, + ) => { + kernelFacetKit.resolve(services.kernelFacet); + }, + }); + }; + + // Promise kit to signal when supervisor is ready + const supervisorReady = makePromiseKit(); + + // Promise kit for connection - resolved when connect() is called + const connectionKit = makePromiseKit(); + + // Create the transport with a deliver function that waits for the supervisor + const deliver: SystemVatDeliverFn = async (delivery) => { + const supervisor = await supervisorReady.promise; + return supervisor.deliver(delivery); + }; + + const transport: SystemVatTransport = { + deliver, + setSyscallHandler: (handler: SystemVatSyscallHandler) => { + syscallHandler = handler; + }, + awaitConnection: async () => connectionKit.promise, + }; + + // Called after Kernel.make() to initiate connection from supervisor side + const connect = (): void => { + if (!syscallHandler) { + throw new Error('Syscall handler not set'); + } + SystemVatSupervisor.make({ + buildRootObject, + executeSyscall: syscallHandler, + logger: logger.subLogger({ tags: ['supervisor'] }), + }) + .then((supervisor) => { + supervisorReady.resolve(supervisor); + connectionKit.resolve(); + return undefined; + }) + .catch((error) => { + connectionKit.reject(error as Error); + kernelFacetKit.reject(error as Error); + }); + }; + + const config: SystemVatConfig = { + name, + transport, + }; + + return { config, connect, kernelFacetPromise: kernelFacetKit.promise }; +} + +describe('system vat integration', { timeout: 30_000 }, () => { + let kernel: Kernel; + let kernelFacet: KernelFacet | Promise; + + beforeEach(async () => { + const logger = new Logger('test'); + + // Create the system vat transport + const systemVat = makeTestSystemVat({ + logger: logger.subLogger({ tags: ['system-vat'] }), + }); + + // Create mock platform services + const mockPlatformServices: PlatformServices = { + launch: vi.fn().mockResolvedValue({ + end: vi.fn(), + } as unknown as DuplexStream), + terminate: vi.fn().mockResolvedValue(undefined), + terminateAll: vi.fn().mockResolvedValue(undefined), + stopRemoteComms: vi.fn().mockResolvedValue(undefined), + } as unknown as PlatformServices; + + // Create kernel with system vat config + const kernelDatabase = makeMapKernelDatabase(); + kernel = await Kernel.make(mockPlatformServices, kernelDatabase, { + resetStorage: true, + logger: logger.subLogger({ tags: ['kernel'] }), + hostVat: systemVat.config, + }); + + // Supervisor-side initiates connection AFTER kernel exists + systemVat.connect(); + + // Wait for kernel facet + kernelFacet = await systemVat.kernelFacetPromise; + }); + + afterEach(async () => { + await kernel.clearStorage(); + }); + + describe('kernel facet', () => { + it('gets kernel status via E()', async () => { + const status = await E(kernelFacet).getStatus(); + + expect(status).toBeDefined(); + expect(status.vats).toBeDefined(); + expect(status.subclusters).toBeDefined(); + expect(status.remoteComms).toBeDefined(); + }); + + it('gets subclusters via E()', async () => { + const subclusters = await E(kernelFacet).getSubclusters(); + + expect(subclusters).toBeDefined(); + expect(Array.isArray(subclusters)).toBe(true); + }); + }); +}); diff --git a/packages/ocap-kernel/tsconfig.json b/packages/ocap-kernel/tsconfig.json index d1a3ce3c9..994dded08 100644 --- a/packages/ocap-kernel/tsconfig.json +++ b/packages/ocap-kernel/tsconfig.json @@ -18,6 +18,7 @@ "./src", "./test", "./vite.config.ts", - "./vitest.config.ts" + "./vitest.config.ts", + "./vitest.integration.config.ts" ] } diff --git a/packages/kernel-browser-runtime/vitest.integration.config.ts b/packages/ocap-kernel/vitest.integration.config.ts similarity index 62% rename from packages/kernel-browser-runtime/vitest.integration.config.ts rename to packages/ocap-kernel/vitest.integration.config.ts index 6c20f76c6..22520bf64 100644 --- a/packages/kernel-browser-runtime/vitest.integration.config.ts +++ b/packages/ocap-kernel/vitest.integration.config.ts @@ -12,14 +12,9 @@ export default defineConfig((args) => { defaultConfig, defineProject({ test: { - name: 'kernel-browser-runtime:integration', - include: ['src/**/*.integration.test.ts'], + name: 'kernel-integration', + include: ['**/test/integration/**'], setupFiles: [ - fileURLToPath( - import.meta.resolve('@ocap/repo-tools/test-utils/fetch-mock'), - ), - // Use endoify-node which imports @libp2p/webrtc before lockdown - // (webrtc imports reflect-metadata which modifies globalThis.Reflect) fileURLToPath( import.meta.resolve('@metamask/kernel-shims/endoify-node'), ), diff --git a/packages/omnium-gatherum/README.md b/packages/omnium-gatherum/README.md index 688955bae..9f0b62555 100644 --- a/packages/omnium-gatherum/README.md +++ b/packages/omnium-gatherum/README.md @@ -10,6 +10,27 @@ or `npm install @ocap/omnium-gatherum` +## Usage + +### Installing and using the `echo` caplet + +After loading the extension, open the background console (chrome://extensions → Omnium → "Inspect views: service worker") and run the following: + +```javascript +// 1. Load the echo caplet manifest +const { manifest } = await omnium.caplet.load('echo'); + +// 2. Install the caplet +const { capletId } = await omnium.caplet.install(manifest); + +// 3. Get the caplet's root as an E()-usable presence +const echoRoot = await omnium.caplet.getRoot(capletId); + +// 4. Call the echo method +const result = await E(echoRoot).echo('Hello, world!'); +console.log(result); // "echo: Hello, world!" +``` + ## Contributing This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/ocap-kernel#readme). diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index 191160ec6..8b513d8c5 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -1,12 +1,6 @@ import { E } from '@endo/eventual-send'; -import { - makeBackgroundCapTP, - makeCapTPNotification, - isCapTPNotification, - getCapTPMessage, -} from '@metamask/kernel-browser-runtime'; -import type { CapTPMessage } from '@metamask/kernel-browser-runtime'; -import { delay, isJsonRpcMessage, stringify } from '@metamask/kernel-utils'; +import { makeBackgroundHostVat } from '@metamask/kernel-browser-runtime'; +import { delay, isJsonRpcMessage } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser'; @@ -25,7 +19,7 @@ let bootPromise: Promise | null = null; // With this we can click the extension action button to wake up the service worker. chrome.action.onClicked.addListener(() => { globalThis.kernel !== undefined && - E(globalThis.kernel).ping().catch(logger.error); + E(globalThis.kernel).getStatus().catch(logger.error); }); // Install/update @@ -91,49 +85,28 @@ async function main(): Promise { // Without this delay, sending messages via the chrome.runtime API can fail. await delay(50); + // Create stream for JSON-RPC messages to kernel const offscreenStream = await ChromeRuntimeDuplexStream.make< JsonRpcMessage, JsonRpcMessage >(chrome.runtime, 'background', 'offscreen', isJsonRpcMessage); - const backgroundCapTP = makeBackgroundCapTP({ - send: (captpMessage: CapTPMessage) => { - const notification = makeCapTPNotification(captpMessage); - offscreenStream.write(notification).catch((error) => { - logger.error('Failed to send CapTP message:', error); - }); - }, - }); + // Create host vat - captures kernelFacet from bootstrap automatically + const hostVat = makeBackgroundHostVat({ logger }); - const kernelP = backgroundCapTP.getKernel(); - globalThis.kernel = kernelP; + // Connect to kernel via offscreen pipe + hostVat.connect(offscreenStream); - try { - const controllers = await initializeControllers({ - logger, - kernel: kernelP, - }); - globals.setCapletController(controllers.caplet); - } catch (error) { - offscreenStream.throw(error as Error).catch(logger.error); - } + // Wait for kernel facet (resolves after bootstrap) + const kernelFacet = await hostVat.kernelFacetPromise; + globalThis.kernel = kernelFacet; - try { - await offscreenStream.drain((message) => { - if (isCapTPNotification(message)) { - const captpMessage = getCapTPMessage(message); - backgroundCapTP.dispatch(captpMessage); - } else { - throw new Error(`Unexpected message: ${stringify(message)}`); - } - }); - } catch (error) { - const finalError = new Error('Offscreen connection closed unexpectedly', { - cause: error, - }); - backgroundCapTP.abort(finalError); - throw finalError; - } + // Initialize controllers with kernel facet + const controllers = await initializeControllers({ + logger, + kernel: kernelFacet, + }); + globals.setCapletController(controllers.caplet); } type GlobalSetters = { @@ -216,7 +189,7 @@ function defineGlobals(): GlobalSetters { list: async () => E(capletController).list(), load: loadCaplet, get: async (capletId: string) => E(capletController).get(capletId), - getCapletRoot: async (capletId: string) => + getRoot: async (capletId: string) => E(capletController).getCapletRoot(capletId), }), }, diff --git a/packages/omnium-gatherum/src/controllers/index.ts b/packages/omnium-gatherum/src/controllers/index.ts index e31664d41..7f1b70164 100644 --- a/packages/omnium-gatherum/src/controllers/index.ts +++ b/packages/omnium-gatherum/src/controllers/index.ts @@ -1,7 +1,6 @@ import { E } from '@endo/eventual-send'; -import type { KernelFacade } from '@metamask/kernel-browser-runtime'; import type { Logger } from '@metamask/logger'; -import type { ClusterConfig } from '@metamask/ocap-kernel'; +import type { ClusterConfig, KernelFacet } from '@metamask/ocap-kernel'; import { CapletController } from './caplet/caplet-controller.ts'; import type { CapletControllerFacet, LaunchResult } from './caplet/index.ts'; @@ -46,7 +45,7 @@ export { type InitializeControllersOptions = { logger: Logger; - kernel: KernelFacade | Promise; + kernel: KernelFacet; }; /** diff --git a/packages/omnium-gatherum/src/global.d.ts b/packages/omnium-gatherum/src/global.d.ts index 545ed5d14..8a0b89ae7 100644 --- a/packages/omnium-gatherum/src/global.d.ts +++ b/packages/omnium-gatherum/src/global.d.ts @@ -1,5 +1,5 @@ -import type { KernelFacade } from '@metamask/kernel-browser-runtime'; import type { Promisified } from '@metamask/kernel-utils'; +import type { KernelFacet } from '@metamask/ocap-kernel'; import type { CapletControllerFacet, @@ -14,7 +14,6 @@ declare global { * * @example * ```typescript - * const kernel = await omnium.getKernel(); * const status = await E(kernel).getStatus(); * ``` */ @@ -22,7 +21,7 @@ declare global { var E: typeof import('@endo/eventual-send').E; // eslint-disable-next-line no-var - var kernel: KernelFacade | Promise; + var kernel: KernelFacet | Promise; // eslint-disable-next-line no-var var omnium: { diff --git a/tsconfig.base.json b/tsconfig.base.json index 397922e88..c3edaf4be 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -13,7 +13,7 @@ "resolveJsonModule": true, "rewriteRelativeImportExtensions": true, "strict": true, - "target": "ES2020", + "target": "ES2022", "verbatimModuleSyntax": true, "useUnknownInCatchVariables": true } diff --git a/yarn.lock b/yarn.lock index 084a341ef..1c320f7de 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2290,10 +2290,11 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/kernel-browser-runtime@workspace:packages/kernel-browser-runtime" dependencies: + "@agoric/swingset-liveslots": "npm:0.10.3-u21.0.1" "@arethetypeswrong/cli": "npm:^0.17.4" "@endo/captp": "npm:^4.4.8" - "@endo/eventual-send": "npm:^1.3.4" "@endo/marshal": "npm:^1.8.0" + "@endo/promise-kit": "npm:^1.1.13" "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/eslint-config": "npm:^15.0.0" "@metamask/eslint-config-nodejs": "npm:^15.0.0" @@ -2704,6 +2705,7 @@ __metadata: "@chainsafe/libp2p-noise": "npm:^16.1.3" "@chainsafe/libp2p-yamux": "patch:@chainsafe/libp2p-yamux@npm%3A7.0.4#~/.yarn/patches/@chainsafe-libp2p-yamux-npm-7.0.4-284c2f6812.patch" "@endo/errors": "npm:^1.2.13" + "@endo/eventual-send": "npm:^1.3.4" "@endo/marshal": "npm:^1.8.0" "@endo/pass-style": "npm:^1.6.3" "@endo/promise-kit": "npm:^1.1.13" @@ -3473,6 +3475,7 @@ __metadata: "@metamask/kernel-ui": "workspace:^" "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" + "@metamask/ocap-kernel": "workspace:^" "@metamask/streams": "workspace:^" "@ocap/cli": "workspace:^" "@ocap/kernel-test": "workspace:^"