diff --git a/packages/extension/package.json b/packages/extension/package.json index 74b414df5..418dfdd7d 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -40,6 +40,7 @@ "@endo/promise-kit": "^1.1.6", "@metamask/snaps-utils": "^8.3.0", "@metamask/utils": "^9.3.0", + "@ocap/errors": "workspace:^", "@ocap/kernel": "workspace:^", "@ocap/shims": "workspace:^", "@ocap/streams": "workspace:^", diff --git a/packages/extension/src/VatWorkerClient.test.ts b/packages/extension/src/VatWorkerClient.test.ts index 1751be368..431e63301 100644 --- a/packages/extension/src/VatWorkerClient.test.ts +++ b/packages/extension/src/VatWorkerClient.test.ts @@ -1,11 +1,11 @@ import '@ocap/shims/endoify'; -import type { VatId } from '@ocap/kernel'; +import type { VatId, VatWorkerServiceCommandReply } from '@ocap/kernel'; +import { VatWorkerServiceCommandMethod } from '@ocap/kernel'; import { delay } from '@ocap/test-utils'; import type { Logger } from '@ocap/utils'; import { makeLogger } from '@ocap/utils'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { VatWorkerServiceMethod } from './vat-worker-service.js'; import type { ExtensionVatWorkerClient } from './VatWorkerClient.js'; import { makeTestClient } from '../test/vat-worker-service.js'; @@ -40,16 +40,18 @@ describe('ExtensionVatWorkerClient', () => { it.each` method - ${VatWorkerServiceMethod.Init} - ${VatWorkerServiceMethod.Delete} + ${VatWorkerServiceCommandMethod.Launch} + ${VatWorkerServiceCommandMethod.Terminate} `( "calls logger.error when receiving a $method reply it wasn't waiting for", async ({ method }) => { const errorSpy = vi.spyOn(clientLogger, 'error'); - const unexpectedReply = { - method, - id: 9, - vatId: 'v0', + const unexpectedReply: VatWorkerServiceCommandReply = { + id: 'm9', + payload: { + method, + params: { vatId: 'v0' }, + }, }; serverPort.postMessage(unexpectedReply); await delay(100); @@ -61,15 +63,17 @@ describe('ExtensionVatWorkerClient', () => { }, ); - it(`calls logger.error when receiving a ${VatWorkerServiceMethod.Init} reply without a port`, async () => { + it(`calls logger.error when receiving a ${VatWorkerServiceCommandMethod.Launch} reply without a port`, async () => { const errorSpy = vi.spyOn(clientLogger, 'error'); const vatId: VatId = 'v0'; // eslint-disable-next-line @typescript-eslint/no-floating-promises - client.initWorker(vatId); + client.launch(vatId); const reply = { - method: VatWorkerServiceMethod.Init, - id: 1, - vatId: 'v0', + id: 'm1', + payload: { + method: VatWorkerServiceCommandMethod.Launch, + params: { vatId: 'v0' }, + }, }; serverPort.postMessage(reply); await delay(100); diff --git a/packages/extension/src/VatWorkerClient.ts b/packages/extension/src/VatWorkerClient.ts index a534385dc..5ad43edde 100644 --- a/packages/extension/src/VatWorkerClient.ts +++ b/packages/extension/src/VatWorkerClient.ts @@ -1,21 +1,24 @@ import { makePromiseKit } from '@endo/promise-kit'; import type { PromiseKit } from '@endo/promise-kit'; +import { isObject } from '@metamask/utils'; +import { unmarshalError } from '@ocap/errors'; +import { + VatWorkerServiceCommandMethod, + isVatWorkerServiceCommandReply, +} from '@ocap/kernel'; import type { StreamEnvelope, StreamEnvelopeReply, VatWorkerService, VatId, + VatWorkerServiceCommand, } from '@ocap/kernel'; import type { DuplexStream } from '@ocap/streams'; import { MessagePortDuplexStream } from '@ocap/streams'; import type { Logger } from '@ocap/utils'; import { makeCounter, makeHandledCallback, makeLogger } from '@ocap/utils'; -import type { AddListener } from './vat-worker-service.js'; -import { - isVatWorkerServiceMessage, - VatWorkerServiceMethod, -} from './vat-worker-service.js'; +import type { AddListener, PostMessage } from './vat-worker-service.js'; // Appears in the docs. // eslint-disable-next-line @typescript-eslint/no-unused-vars import type { ExtensionVatWorkerServer } from './VatWorkerServer.js'; @@ -25,16 +28,19 @@ type PromiseCallbacks = Omit, 'promise'>; export class ExtensionVatWorkerClient implements VatWorkerService { readonly #logger: Logger; - readonly #unresolvedMessages: Map = new Map(); + readonly #unresolvedMessages: Map< + VatWorkerServiceCommand['id'], + PromiseCallbacks + > = new Map(); readonly #messageCounter = makeCounter(); - readonly #postMessage: (message: unknown) => void; + readonly #postMessage: PostMessage; /** * The client end of the vat worker service, intended to be constructed in - * the kernel worker. Sends initWorker and deleteWorker requests to the - * server and wraps the initWorker response in a DuplexStream for consumption + * the kernel worker. Sends launch and terminate worker requests to the + * server and wraps the launch response in a DuplexStream for consumption * by the kernel. * * @see {@link ExtensionVatWorkerServer} for the other end of the service. @@ -44,7 +50,7 @@ export class ExtensionVatWorkerClient implements VatWorkerService { * @param logger - An optional {@link Logger}. Defaults to a new logger labeled '[vat worker client]'. */ constructor( - postMessage: (message: unknown) => void, + postMessage: PostMessage, addListener: AddListener, logger?: Logger, ) { @@ -54,15 +60,11 @@ export class ExtensionVatWorkerClient implements VatWorkerService { } async #sendMessage( - method: - | typeof VatWorkerServiceMethod.Init - | typeof VatWorkerServiceMethod.Delete, - vatId: VatId, + payload: VatWorkerServiceCommand['payload'], ): Promise { - const message = { - id: this.#messageCounter(), - method, - vatId, + const message: VatWorkerServiceCommand = { + id: `m${this.#messageCounter()}`, + payload, }; const { promise, resolve, reject } = makePromiseKit(); this.#unresolvedMessages.set(message.id, { @@ -73,24 +75,38 @@ export class ExtensionVatWorkerClient implements VatWorkerService { return promise; } - async initWorker( + async launch( vatId: VatId, ): Promise> { - return this.#sendMessage(VatWorkerServiceMethod.Init, vatId); + return this.#sendMessage({ + method: VatWorkerServiceCommandMethod.Launch, + params: { vatId }, + }); } - async deleteWorker(vatId: VatId): Promise { - return this.#sendMessage(VatWorkerServiceMethod.Delete, vatId); + async terminate(vatId: VatId): Promise { + return this.#sendMessage({ + method: VatWorkerServiceCommandMethod.Terminate, + params: { vatId }, + }); + } + + async terminateAll(): Promise { + return this.#sendMessage({ + method: VatWorkerServiceCommandMethod.TerminateAll, + params: null, + }); } async #handleMessage(event: MessageEvent): Promise { - if (!isVatWorkerServiceMessage(event.data)) { + if (!isVatWorkerServiceCommandReply(event.data)) { // This happens when other messages pass through the same channel. this.#logger.debug('Received unexpected message', event.data); return; } - const { id, method, error } = event.data; + const { id, payload } = event.data; + const { method } = payload; const port = event.ports.at(0); const promise = this.#unresolvedMessages.get(id); @@ -100,13 +116,13 @@ export class ExtensionVatWorkerClient implements VatWorkerService { return; } - if (error) { - promise.reject(error); + if (isObject(payload.params) && payload.params.error) { + promise.reject(unmarshalError(payload.params.error)); return; } switch (method) { - case VatWorkerServiceMethod.Init: + case VatWorkerServiceCommandMethod.Launch: if (!port) { this.#logger.error('Expected a port with message reply', event); return; @@ -117,7 +133,8 @@ export class ExtensionVatWorkerClient implements VatWorkerService { ), ); break; - case VatWorkerServiceMethod.Delete: + case VatWorkerServiceCommandMethod.Terminate: + case VatWorkerServiceCommandMethod.TerminateAll: // If we were caching streams on the client this would be a good place // to remove them. promise.resolve(undefined); diff --git a/packages/extension/src/VatWorkerServer.test.ts b/packages/extension/src/VatWorkerServer.test.ts index 24fd14748..c5390b25a 100644 --- a/packages/extension/src/VatWorkerServer.test.ts +++ b/packages/extension/src/VatWorkerServer.test.ts @@ -1,13 +1,17 @@ import '@ocap/shims/endoify'; +import type { NonEmptyArray } from '@metamask/utils'; +import { VatNotFoundError } from '@ocap/errors'; +import { VatWorkerServiceCommandMethod } from '@ocap/kernel'; import { delay } from '@ocap/test-utils'; import type { Logger } from '@ocap/utils'; import { makeLogger } from '@ocap/utils'; import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { VatWorker } from './vat-worker-service.js'; import type { ExtensionVatWorkerServer } from './VatWorkerServer.js'; import { makeTestServer } from '../test/vat-worker-service.js'; -describe('VatWorker', () => { +describe('ExtensionVatWorkerServer', () => { let serverPort: MessagePort; let clientPort: MessagePort; @@ -15,43 +19,82 @@ describe('VatWorker', () => { let server: ExtensionVatWorkerServer; - // let vatPort: MessagePort; - let kernelPort: MessagePort; - beforeEach(() => { const serviceMessageChannel = new MessageChannel(); serverPort = serviceMessageChannel.port1; clientPort = serviceMessageChannel.port2; logger = makeLogger('[test server]'); + }); - const deliveredMessageChannel = new MessageChannel(); - // vatPort = deliveredMessageChannel.port1; - kernelPort = deliveredMessageChannel.port2; + describe('Misc', () => { + beforeEach(() => { + [server] = makeTestServer({ serverPort, logger }); + }); - server = makeTestServer({ serverPort, logger, kernelPort }); - }); + it('starts', () => { + server.start(); + expect(serverPort.onmessage).toBeDefined(); + }); - it('starts', () => { - server.start(); - expect(serverPort.onmessage).toBeDefined(); - }); + it('throws if started twice', () => { + server.start(); + expect(() => server.start()).toThrow(/already running/u); + }); - it('throws if started twice', () => { - server.start(); - expect(() => server.start()).toThrow(/already running/u); + it('calls logger.debug when receiving an unexpected message', async () => { + const debugSpy = vi.spyOn(logger, 'debug'); + const unexpectedMessage = 'foobar'; + server.start(); + clientPort.postMessage(unexpectedMessage); + await delay(100); + expect(debugSpy).toHaveBeenCalledOnce(); + expect(debugSpy).toHaveBeenLastCalledWith( + 'Received unexpected message', + unexpectedMessage, + ); + }); }); - it('calls logger.debug when receiving an unexpected message', async () => { - const debugSpy = vi.spyOn(logger, 'debug'); - const unexpectedMessage = 'foobar'; - server.start(); - clientPort.postMessage(unexpectedMessage); - await delay(100); - expect(debugSpy).toHaveBeenCalledOnce(); - expect(debugSpy).toHaveBeenLastCalledWith( - 'Received unexpected message', - unexpectedMessage, - ); + describe('terminateAll', () => { + let workers: NonEmptyArray; + + beforeEach(() => { + [server, ...workers] = makeTestServer({ + serverPort, + logger, + nWorkers: 3, + }); + }); + + it('calls logger.error when a vat fails to terminate', async () => { + const errorSpy = vi.spyOn(logger, 'error'); + const vatId = 'v0'; + const vatNotFoundError = new VatNotFoundError(vatId); + vi.spyOn(workers[0], 'terminate').mockRejectedValue(vatNotFoundError); + server.start(); + clientPort.postMessage({ + id: 'm0', + payload: { + method: VatWorkerServiceCommandMethod.Launch, + params: { vatId }, + }, + }); + clientPort.postMessage({ + id: 'm1', + payload: { + method: VatWorkerServiceCommandMethod.TerminateAll, + params: null, + }, + }); + + await delay(100); + + expect(errorSpy).toHaveBeenCalledOnce(); + expect(errorSpy.mock.lastCall?.[0]).toBe( + `Error handling ${VatWorkerServiceCommandMethod.TerminateAll} for vatId ${vatId}`, + ); + expect(errorSpy.mock.lastCall?.[1]).toBe(vatNotFoundError); + }); }); }); diff --git a/packages/extension/src/VatWorkerServer.ts b/packages/extension/src/VatWorkerServer.ts index 4809c6ab4..4abaa31dd 100644 --- a/packages/extension/src/VatWorkerServer.ts +++ b/packages/extension/src/VatWorkerServer.ts @@ -1,11 +1,16 @@ -import type { VatId } from '@ocap/kernel'; +import { + VatAlreadyExistsError, + VatDeletedError, + marshalError, +} from '@ocap/errors'; +import { + isVatWorkerServiceCommand, + VatWorkerServiceCommandMethod, +} from '@ocap/kernel'; +import type { VatWorkerServiceCommandReply, VatId } from '@ocap/kernel'; import type { Logger } from '@ocap/utils'; import { makeHandledCallback, makeLogger } from '@ocap/utils'; -import { - isVatWorkerServiceMessage, - VatWorkerServiceMethod, -} from './vat-worker-service.js'; import type { AddListener, PostMessage, @@ -20,7 +25,7 @@ export class ExtensionVatWorkerServer { readonly #vatWorkers: Map = new Map(); - readonly #postMessage: PostMessage; + readonly #postMessage: PostMessage; readonly #addListener: AddListener; @@ -30,7 +35,7 @@ export class ExtensionVatWorkerServer { /** * The server end of the vat worker service, intended to be constructed in - * the offscreen document. Listens for initWorker and deleteWorker requests + * the offscreen document. Listens for launch and terminate worker requests * from the client and uses the {@link VatWorker} methods to effect those * requests. * @@ -42,7 +47,7 @@ export class ExtensionVatWorkerServer { * @param logger - An optional {@link Logger}. Defaults to a new logger labeled '[vat worker server]'. */ constructor( - postMessage: PostMessage, + postMessage: PostMessage, addListener: (listener: (event: MessageEvent) => void) => void, makeWorker: (vatId: VatId) => VatWorker, logger?: Logger, @@ -62,32 +67,42 @@ export class ExtensionVatWorkerServer { } async #handleMessage(event: MessageEvent): Promise { - if (!isVatWorkerServiceMessage(event.data)) { + if (!isVatWorkerServiceCommand(event.data)) { // This happens when other messages pass through the same channel. this.#logger.debug('Received unexpected message', event.data); return; } - const { method, id, vatId } = event.data; + const { id, payload } = event.data; + const { method, params } = payload; - const handleProblem = async (problem: Error): Promise => { - this.#logger.error( - `Error handling ${method} for vatId ${vatId}`, - problem, - ); - this.#postMessage({ method, id, vatId, error: problem }); + const handleError = (error: Error, vatId: VatId): void => { + this.#logger.error(`Error handling ${method} for vatId ${vatId}`, error); + this.#postMessage({ + id, + payload: { method, params: { vatId, error: marshalError(error) } }, + }); + throw error; }; switch (method) { - case VatWorkerServiceMethod.Init: - await this.#initVatWorker(vatId) - .then((port) => this.#postMessage({ method, id, vatId }, [port])) - .catch(handleProblem); + case VatWorkerServiceCommandMethod.Launch: + await this.#launch(params.vatId) + .then((port) => this.#postMessage({ id, payload }, [port])) + .catch(async (error) => handleError(error, params.vatId)); + break; + case VatWorkerServiceCommandMethod.Terminate: + await this.#terminate(params.vatId) + .then(() => this.#postMessage({ id, payload })) + .catch(async (error) => handleError(error, params.vatId)); break; - case VatWorkerServiceMethod.Delete: - await this.#deleteVatWorker(vatId) - .then(() => this.#postMessage({ method, id, vatId })) - .catch(handleProblem); + case VatWorkerServiceCommandMethod.TerminateAll: + await Promise.all( + Array.from(this.#vatWorkers.keys()).map(async (vatId) => + this.#terminate(vatId).catch((error) => handleError(error, vatId)), + ), + ); + this.#postMessage({ id, payload }); break; /* v8 ignore next 6: Not known to be possible. */ default: @@ -99,22 +114,22 @@ export class ExtensionVatWorkerServer { } } - async #initVatWorker(vatId: VatId): Promise { + async #launch(vatId: VatId): Promise { if (this.#vatWorkers.has(vatId)) { - throw new Error(`Worker for vat ${vatId} already exists.`); + throw new VatAlreadyExistsError(vatId); } const vatWorker = this.#makeWorker(vatId); - const [port] = await vatWorker.init(); + const [port] = await vatWorker.launch(); this.#vatWorkers.set(vatId, vatWorker); return port; } - async #deleteVatWorker(vatId: VatId): Promise { + async #terminate(vatId: VatId): Promise { const vatWorker = this.#vatWorkers.get(vatId); if (!vatWorker) { - throw new Error(`Worker for vat ${vatId} does not exist.`); + throw new VatDeletedError(vatId); } - await vatWorker.delete(); + await vatWorker.terminate(); this.#vatWorkers.delete(vatId); } } diff --git a/packages/extension/src/iframe-vat-worker.ts b/packages/extension/src/iframe-vat-worker.ts index 400e149e6..ad1278631 100644 --- a/packages/extension/src/iframe-vat-worker.ts +++ b/packages/extension/src/iframe-vat-worker.ts @@ -12,7 +12,7 @@ export const makeIframeVatWorker = ( ): VatWorker => { const vatHtmlId = `ocap-iframe-${id}`; return { - init: async () => { + launch: async () => { const newWindow = await createWindow({ uri: IFRAME_URI, id: vatHtmlId, @@ -24,7 +24,7 @@ export const makeIframeVatWorker = ( return [port, newWindow]; }, - delete: async (): Promise => { + terminate: async (): Promise => { const iframe = document.getElementById(vatHtmlId); /* v8 ignore next 6: Not known to be possible. */ if (iframe === null) { diff --git a/packages/extension/src/vat-worker-service.test.ts b/packages/extension/src/vat-worker-service.test.ts index 9cc15ea57..413f85bf5 100644 --- a/packages/extension/src/vat-worker-service.test.ts +++ b/packages/extension/src/vat-worker-service.test.ts @@ -1,4 +1,6 @@ import '@ocap/shims/endoify'; +import type { NonEmptyArray } from '@metamask/utils'; +import { VatAlreadyExistsError, VatDeletedError } from '@ocap/errors'; import type { VatId } from '@ocap/kernel'; import { delay } from '@ocap/test-utils'; import type { MockInstance } from 'vitest'; @@ -7,81 +9,99 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import type { VatWorker } from './vat-worker-service.js'; import type { ExtensionVatWorkerClient } from './VatWorkerClient.js'; import type { ExtensionVatWorkerServer } from './VatWorkerServer.js'; -import { - getMockMakeWorker, - makeTestClient, - makeTestServer, -} from '../test/vat-worker-service.js'; +import { makeTestClient, makeTestServer } from '../test/vat-worker-service.js'; -describe('VatWorker', () => { +// low key integration test +describe('VatWorkerService', () => { let serverPort: MessagePort; let clientPort: MessagePort; let server: ExtensionVatWorkerServer; let client: ExtensionVatWorkerClient; - let kernelPort: MessagePort; - let mockWorker: VatWorker; + let mockWorkers: NonEmptyArray; - let mockMakeWorker: (vatId: VatId) => VatWorker; - let mockInitWorker: MockInstance; - let mockDeleteWorker: MockInstance; + let mockLaunchWorker: MockInstance; + let mockTerminateWorker: MockInstance; beforeEach(() => { const serviceMessageChannel = new MessageChannel(); serverPort = serviceMessageChannel.port1; clientPort = serviceMessageChannel.port2; - const deliveredMessageChannel = new MessageChannel(); + client = makeTestClient(clientPort); + [server, ...mockWorkers] = makeTestServer({ serverPort, nWorkers: 3 }); + server.start(); + }); + + it('launches and terminates a worker', async () => { + mockWorker = mockWorkers[0]; + mockLaunchWorker = vi.spyOn(mockWorker, 'launch'); + mockTerminateWorker = vi.spyOn(mockWorker, 'terminate'); + + const vatId: VatId = 'v0'; - kernelPort = deliveredMessageChannel.port2; + // eslint-disable-next-line @typescript-eslint/no-floating-promises + client.launch(vatId); + await delay(10); + expect(mockLaunchWorker).toHaveBeenCalledOnce(); + expect(mockTerminateWorker).not.toHaveBeenCalled(); - [mockWorker, mockMakeWorker] = getMockMakeWorker(kernelPort); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + client.terminate(vatId); + await delay(10); + expect(mockLaunchWorker).toHaveBeenCalledOnce(); + expect(mockTerminateWorker).toHaveBeenCalledOnce(); + }); + + it('terminates all workers', async () => { + const mockLaunches = mockWorkers.map((worker) => + vi.spyOn(worker, 'launch'), + ); + const mockTerminates = mockWorkers.map((worker) => + vi.spyOn(worker, 'terminate'), + ); + + // launch many workers + for (let i = 0; i < mockWorkers.length; i++) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + client.launch(`v${i}`); + } + + await delay(10); + + // each worker had its launch method called + for (let i = 0; i < mockWorkers.length; i++) { + expect(mockLaunches[i]).toHaveBeenCalledOnce(); + expect(mockTerminates[i]).not.toHaveBeenCalled(); + } + + // terminate all workers + // eslint-disable-next-line @typescript-eslint/no-floating-promises + client.terminateAll(); + await delay(10); + + // each worker had its terminate method called + for (let i = 0; i < mockWorkers.length; i++) { + expect(mockLaunches[i]).toHaveBeenCalledOnce(); + expect(mockTerminates[i]).toHaveBeenCalledOnce(); + } + }); - mockInitWorker = vi.spyOn(mockWorker, 'init'); - mockDeleteWorker = vi.spyOn(mockWorker, 'delete'); + it('throws when terminating a nonexistent worker', async () => { + await expect(async () => await client.terminate('v0')).rejects.toThrow( + VatDeletedError, + ); }); - // low key integration test - describe('Service', () => { - beforeEach(() => { - client = makeTestClient(clientPort); - server = makeTestServer({ serverPort, makeWorker: mockMakeWorker }); - server.start(); - }); - - it('initializes and deletes a worker', async () => { - const vatId: VatId = 'v0'; - // Due to mock-related issues, this promise never resolves. - client.initWorker(vatId).catch((error) => { - throw error; - }); - await delay(10); - expect(mockInitWorker).toHaveBeenCalledOnce(); - expect(mockDeleteWorker).not.toHaveBeenCalled(); - - await client.deleteWorker(vatId); - expect(mockInitWorker).toHaveBeenCalledOnce(); - expect(mockDeleteWorker).toHaveBeenCalledOnce(); - }); - - it('throws when deleting a nonexistent worker', async () => { - await expect(async () => await client.deleteWorker('v0')).rejects.toThrow( - /vat v0 does not exist/u, - ); - }); - - it('throws when initializing the same worker twice', async () => { - const vatId: VatId = 'v0'; - // Due to mock-related issues, this promise never resolves. - client.initWorker(vatId).catch((error) => { - throw error; - }); - await delay(10); - await expect(async () => await client.initWorker(vatId)).rejects.toThrow( - /vat v0 already exists/u, - ); - }); + it('throws when launching the same worker twice', async () => { + const vatId: VatId = 'v0'; + // eslint-disable-next-line @typescript-eslint/no-floating-promises + client.launch(vatId); + await delay(10); + await expect(async () => await client.launch(vatId)).rejects.toThrow( + VatAlreadyExistsError, + ); }); }); diff --git a/packages/extension/src/vat-worker-service.ts b/packages/extension/src/vat-worker-service.ts index 6e5a6c329..c042c296a 100644 --- a/packages/extension/src/vat-worker-service.ts +++ b/packages/extension/src/vat-worker-service.ts @@ -1,38 +1,12 @@ -import { isObject } from '@metamask/utils'; -import type { VatId } from '@ocap/kernel'; - -export enum VatWorkerServiceMethod { - Init = 'iframe-vat-worker-init', - Delete = 'iframe-vat-worker-delete', -} - -type MessageId = number; - export type VatWorker = { - init: () => Promise<[MessagePort, unknown]>; - delete: () => Promise; + launch: () => Promise<[MessagePort, unknown]>; + terminate: () => Promise; }; -export type VatWorkerServiceMessage = { - method: - | typeof VatWorkerServiceMethod.Init - | typeof VatWorkerServiceMethod.Delete; - id: MessageId; - vatId: VatId; - error?: Error; -}; - -export const isVatWorkerServiceMessage = ( - value: unknown, -): value is VatWorkerServiceMessage => - isObject(value) && - typeof value.id === 'number' && - Object.values(VatWorkerServiceMethod).includes( - value.method as VatWorkerServiceMethod, - ) && - typeof value.vatId === 'string'; - -export type PostMessage = (message: unknown, transfer?: Transferable[]) => void; +export type PostMessage = ( + message: Message, + transfer?: Transferable[], +) => void; export type AddListener = ( listener: (event: MessageEvent) => void, ) => void; diff --git a/packages/extension/test/vat-worker-service.ts b/packages/extension/test/vat-worker-service.ts index 434244957..541bffb1f 100644 --- a/packages/extension/test/vat-worker-service.ts +++ b/packages/extension/test/vat-worker-service.ts @@ -1,4 +1,6 @@ +import type { NonEmptyArray } from '@metamask/utils'; import type { VatId } from '@ocap/kernel'; +import { makeCounter } from '@ocap/utils'; import type { Logger } from '@ocap/utils'; import { vi } from 'vitest'; @@ -6,17 +8,35 @@ import type { VatWorker } from '../src/vat-worker-service.js'; import { ExtensionVatWorkerClient } from '../src/VatWorkerClient.js'; import { ExtensionVatWorkerServer } from '../src/VatWorkerServer.js'; -type MakeVatWorker = (vatId: VatId) => VatWorker; +const getMockMakeWorker = ( + nWorkers: number = 1, +): [ + (vatId: VatId) => VatWorker & { kernelPort: MessagePort }, + ...NonEmptyArray, +] => { + if (typeof nWorkers !== 'number' || nWorkers < 1) { + throw new Error('invalid argument: nWorkers must be > 0'); + } + const counter = makeCounter(-1); + const mockWorkers = Array(nWorkers) + .fill(0) + .map(() => { + const { + // port1: vatPort, + port2: kernelPort, + } = new MessageChannel(); + return { + launch: vi.fn().mockResolvedValue([kernelPort, {}]), + terminate: vi.fn().mockResolvedValue(undefined), + // vatPort, + kernelPort, + }; + }) as unknown as NonEmptyArray; -export const getMockMakeWorker = ( - kernelPort: MessagePort, -): [VatWorker, MakeVatWorker] => { - const mockWorker = { - init: vi.fn().mockResolvedValue([kernelPort, {}]), - delete: vi.fn().mockResolvedValue(undefined), - }; - - return [mockWorker, vi.fn().mockReturnValue(mockWorker)]; + return [ + vi.fn().mockImplementation(() => mockWorkers[counter()]), + ...mockWorkers, + ]; }; export const makeTestClient = ( @@ -31,31 +51,24 @@ export const makeTestClient = ( logger, ); -type MakeTestServerArgs = { +export const makeTestServer = (args: { serverPort: MessagePort; logger?: Logger; -} & ( - | { - makeWorker: MakeVatWorker; - kernelPort?: never; - } - | { - makeWorker?: never; - kernelPort: MessagePort; - } -); - -export const makeTestServer = ( - args: MakeTestServerArgs, -): ExtensionVatWorkerServer => - new ExtensionVatWorkerServer( - (message: unknown, transfer?: Transferable[]) => - transfer - ? args.serverPort.postMessage(message, transfer) - : args.serverPort.postMessage(message), - (listener) => { - args.serverPort.onmessage = listener; - }, - args.makeWorker ?? getMockMakeWorker(args.kernelPort)[1], - args.logger, - ); + nWorkers?: number; +}): [ExtensionVatWorkerServer, ...NonEmptyArray] => { + const [makeWorker, ...workers] = getMockMakeWorker(args.nWorkers); + return [ + new ExtensionVatWorkerServer( + (message: unknown, transfer?: Transferable[]) => + transfer + ? args.serverPort.postMessage(message, transfer) + : args.serverPort.postMessage(message), + (listener) => { + args.serverPort.onmessage = listener; + }, + makeWorker, + args.logger, + ), + ...workers, + ]; +}; diff --git a/packages/kernel/src/Kernel.test.ts b/packages/kernel/src/Kernel.test.ts index 9c905e9e3..a0d026749 100644 --- a/packages/kernel/src/Kernel.test.ts +++ b/packages/kernel/src/Kernel.test.ts @@ -20,8 +20,8 @@ import { makeMapKernelStore } from '../test/storage.js'; describe('Kernel', () => { let mockStream: DuplexStream; let mockWorkerService: VatWorkerService; - let mockGetWorkerStreams: MockInstance; - let mockDeleteWorker: MockInstance; + let launchWorkerMock: MockInstance; + let terminateWorkerMock: MockInstance; let initMock: MockInstance; let terminateMock: MockInstance; @@ -38,17 +38,17 @@ describe('Kernel', () => { } as unknown as MessagePortDuplexStream; mockWorkerService = { - initWorker: async () => ({}), - deleteWorker: async () => undefined, + launch: async () => ({}), + terminate: async () => undefined, } as unknown as VatWorkerService; - mockGetWorkerStreams = vi - .spyOn(mockWorkerService, 'initWorker') + launchWorkerMock = vi + .spyOn(mockWorkerService, 'launch') .mockResolvedValue( {} as DuplexStream, ); - mockDeleteWorker = vi - .spyOn(mockWorkerService, 'deleteWorker') + terminateWorkerMock = vi + .spyOn(mockWorkerService, 'terminate') .mockResolvedValue(undefined); initMock = vi.spyOn(Vat.prototype, 'init').mockImplementation(vi.fn()); @@ -84,7 +84,7 @@ describe('Kernel', () => { const kernel = new Kernel(mockStream, mockWorkerService, mockKernelStore); await kernel.launchVat({ id: 'v0' }); expect(initMock).toHaveBeenCalledOnce(); - expect(mockGetWorkerStreams).toHaveBeenCalled(); + expect(launchWorkerMock).toHaveBeenCalled(); expect(kernel.getVatIds()).toStrictEqual(['v0']); }); @@ -108,7 +108,7 @@ describe('Kernel', () => { expect(kernel.getVatIds()).toStrictEqual(['v0']); await kernel.deleteVat('v0'); expect(terminateMock).toHaveBeenCalledOnce(); - expect(mockDeleteWorker).toHaveBeenCalledOnce(); + expect(terminateWorkerMock).toHaveBeenCalledOnce(); expect(kernel.getVatIds()).toStrictEqual([]); }); diff --git a/packages/kernel/src/Kernel.ts b/packages/kernel/src/Kernel.ts index abd9850ce..6e778b7de 100644 --- a/packages/kernel/src/Kernel.ts +++ b/packages/kernel/src/Kernel.ts @@ -172,7 +172,7 @@ export class Kernel { if (this.#vats.has(id)) { throw new VatAlreadyExistsError(id); } - const stream = await this.#vatWorkerService.initWorker(id); + const stream = await this.#vatWorkerService.launch(id); const vat = new Vat({ id, stream }); this.#vats.set(vat.id, vat); await vat.init(); @@ -187,7 +187,7 @@ export class Kernel { async deleteVat(id: VatId): Promise { const vat = this.#getVat(id); await vat.terminate(); - await this.#vatWorkerService.deleteWorker(id).catch(console.error); + await this.#vatWorkerService.terminate(id).catch(console.error); this.#vats.delete(id); } diff --git a/packages/kernel/src/Supervisor.ts b/packages/kernel/src/Supervisor.ts index ef51af031..4ee979ffa 100644 --- a/packages/kernel/src/Supervisor.ts +++ b/packages/kernel/src/Supervisor.ts @@ -7,7 +7,6 @@ import type { CapTpMessage, VatCommand, VatCommandReply, - VatMessageId, } from './messages/index.js'; import { VatCommandMethod } from './messages/index.js'; import type { StreamEnvelope, StreamEnvelopeReply } from './stream-envelope.js'; @@ -127,7 +126,7 @@ export class Supervisor { * @param payload - The payload to reply with. */ async replyToMessage( - id: VatMessageId, + id: VatCommand['id'], payload: VatCommandReply['payload'], ): Promise { await this.#stream.write(wrapStreamCommandReply({ id, payload })); diff --git a/packages/kernel/src/Vat.ts b/packages/kernel/src/Vat.ts index 2855d765b..49b88fdf5 100644 --- a/packages/kernel/src/Vat.ts +++ b/packages/kernel/src/Vat.ts @@ -17,7 +17,6 @@ import type { CapTpPayload, VatCommandReply, VatCommand, - VatMessageId, } from './messages/index.js'; import type { StreamEnvelope, @@ -45,7 +44,8 @@ export class Vat { readonly #messageCounter: () => number; - readonly unresolvedMessages: Map = new Map(); + readonly unresolvedMessages: Map = + new Map(); readonly streamEnvelopeReplyHandler: StreamEnvelopeReplyHandler; @@ -185,7 +185,7 @@ export class Vat { * * @returns The message ID. */ - readonly #nextMessageId = (): VatMessageId => { + readonly #nextMessageId = (): VatCommand['id'] => { return `${this.id}:${this.#messageCounter()}`; }; } diff --git a/packages/kernel/src/index.test.ts b/packages/kernel/src/index.test.ts index 5e7a1d2ca..c11c0bd83 100644 --- a/packages/kernel/src/index.test.ts +++ b/packages/kernel/src/index.test.ts @@ -1,3 +1,4 @@ +import '@ocap/shims/endoify'; import { describe, it, expect } from 'vitest'; import * as indexModule from './index.js'; @@ -7,7 +8,8 @@ describe('index', () => { expect(Object.keys(indexModule)).toStrictEqual( expect.arrayContaining( ['Kernel', 'Vat'].concat( - ['Cluster', 'Kernel', 'Vat'].flatMap((value) => [ + ['Cluster', 'Kernel', 'Vat', 'VatWorkerService'].flatMap((value) => [ + `${value}CommandMethod`, `is${value}Command`, `is${value}CommandReply`, ]), diff --git a/packages/kernel/src/messages/index.ts b/packages/kernel/src/messages/index.ts index f66a02a24..8d2e9d511 100644 --- a/packages/kernel/src/messages/index.ts +++ b/packages/kernel/src/messages/index.ts @@ -1,7 +1,3 @@ -// Base messages. - -export type { VatMessageId } from './vat-message.js'; - // CapTP. export { isCapTpPayload, isCapTpMessage } from './captp.js'; @@ -30,4 +26,16 @@ export type { KernelCommand, KernelCommandReply } from './kernel.js'; export { VatCommandMethod, isVatCommand, isVatCommandReply } from './vat.js'; export type { VatCommand, VatCommandReply } from './vat.js'; +// Vat worker service commands. + +export { + VatWorkerServiceCommandMethod, + isVatWorkerServiceCommand, + isVatWorkerServiceCommandReply, +} from './vat-worker-service.js'; +export type { + VatWorkerServiceCommand, + VatWorkerServiceCommandReply, +} from './vat-worker-service.js'; + // Syscalls. diff --git a/packages/kernel/src/messages/message-kit.ts b/packages/kernel/src/messages/message-kit.ts index 1e631d408..841b843f3 100644 --- a/packages/kernel/src/messages/message-kit.ts +++ b/packages/kernel/src/messages/message-kit.ts @@ -1,5 +1,6 @@ import '@ocap/shims/endoify'; +import { isObject } from '@metamask/utils'; import type { Json } from '@metamask/utils'; import type { ExtractGuardType, TypeGuard } from '@ocap/utils'; @@ -8,9 +9,9 @@ import { uncapitalize } from './utils.js'; // Message kit. -type BoolExpr = (value: unknown) => boolean; +export type BoolExpr = (value: unknown) => boolean; -type SourceLike = Record; +export type SourceLike = Record; type MessageUnion = { [Key in keyof Source]: Key extends string @@ -95,3 +96,45 @@ export const makeMessageKit = ( replyGuard: makeGuard(source, methods, 1), } as MessageKit; }; + +/** + * An object type encapsulating all of the schematics that define a functional + * group of messages as a payload wrapped with a message id. + */ +type IdentifiedMessageKit< + Source extends SourceLike, + MessageId extends string, +> = { + source: Source; + methods: Methods; + send: { id: MessageId; payload: Send }; + sendGuard: TypeGuard<{ id: MessageId; payload: Send }>; + reply: { id: MessageId; payload: Reply }; + replyGuard: TypeGuard<{ id: MessageId; payload: Reply }>; +}; + +export const makeIdentifiedMessageKit = < + Source extends SourceLike, + MessageId extends string, +>({ + source, + isMessageId, +}: { + source: Source; + isMessageId: TypeGuard; +}): IdentifiedMessageKit => { + const messageKit = makeMessageKit(source); + + return { + source: messageKit.source, + methods: messageKit.methods, + sendGuard: (value: unknown) => + isObject(value) && + isMessageId(value.id) && + messageKit.sendGuard(value.payload), + replyGuard: (value: unknown) => + isObject(value) && + isMessageId(value.id) && + messageKit.replyGuard(value.payload), + } as IdentifiedMessageKit; +}; diff --git a/packages/kernel/src/messages/vat-message.test.ts b/packages/kernel/src/messages/vat-message.test.ts deleted file mode 100644 index 4c3d85810..000000000 --- a/packages/kernel/src/messages/vat-message.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { isVatMessage } from './vat-message.js'; -import { VatCommandMethod } from './vat.js'; - -describe('isVatMessage', () => { - const validPayload = { method: VatCommandMethod.Evaluate, params: '3 + 3' }; - - it.each` - value | expectedResult | description - ${{ id: 'v0:1', payload: validPayload }} | ${true} | ${'valid message id with valid payload'} - ${{ id: 'vat-message-id', payload: validPayload }} | ${false} | ${'invalid id'} - ${{ id: 1, payload: validPayload }} | ${false} | ${'numerical id'} - ${{ id: 'v0:1' }} | ${false} | ${'missing payload'} - `('returns $expectedResult for $description', ({ value, expectedResult }) => { - expect(isVatMessage(value)).toBe(expectedResult); - }); -}); diff --git a/packages/kernel/src/messages/vat-message.ts b/packages/kernel/src/messages/vat-message.ts deleted file mode 100644 index 6e3562ecc..000000000 --- a/packages/kernel/src/messages/vat-message.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { hasProperty, isObject } from '@metamask/utils'; - -import type { VatId } from '../types.js'; -import { isVatId } from '../types.js'; - -export type VatMessageId = `${VatId}:${number}`; - -export const isVatMessageId = (value: unknown): value is VatMessageId => { - if (typeof value !== 'string') { - return false; - } - const parts = value.split(':'); - return ( - parts.length === 2 && - isVatId(parts[0]) && - parts[1] === String(Number(parts[1])) - ); -}; - -export type VatMessage = { id: VatMessageId; payload: Payload }; - -export const isVatMessage = (value: unknown): value is VatMessage => - isObject(value) && isVatMessageId(value.id) && hasProperty(value, 'payload'); diff --git a/packages/kernel/src/messages/vat-worker-service.test.ts b/packages/kernel/src/messages/vat-worker-service.test.ts new file mode 100644 index 000000000..0989dd5b2 --- /dev/null +++ b/packages/kernel/src/messages/vat-worker-service.test.ts @@ -0,0 +1,166 @@ +import '@ocap/shims/endoify'; +import { + marshalError, + VatAlreadyExistsError, + VatDeletedError, +} from '@ocap/errors'; +import { describe, expect, it } from 'vitest'; + +import type { VatWorkerServiceCommandReply } from './vat-worker-service.js'; +import { + isVatWorkerServiceCommand, + isVatWorkerServiceCommandReply, + VatWorkerServiceCommandMethod, +} from './vat-worker-service.js'; +import type { VatId } from '../types.js'; + +const launchPayload: VatWorkerServiceCommandReply['payload'] = harden({ + method: VatWorkerServiceCommandMethod.Launch, + params: { vatId: 'v0' }, +}); +const terminatePayload: VatWorkerServiceCommandReply['payload'] = harden({ + method: VatWorkerServiceCommandMethod.Terminate, + params: { vatId: 'v0' }, +}); +const terminateAllPayload: VatWorkerServiceCommandReply['payload'] = harden({ + method: VatWorkerServiceCommandMethod.TerminateAll, + params: null, +}); + +describe('isVatWorkerServiceCommand', () => { + describe.each` + payload + ${launchPayload} + ${terminatePayload} + ${terminateAllPayload} + `('$payload.method', ({ payload }) => { + it.each([ + [true, 'valid message id with valid payload', { id: 'm0', payload }], + [false, 'invalid id', { id: 'vat-message-id', payload }], + [false, 'numerical id', { id: 1, payload }], + [false, 'missing payload', { id: 'm0' }], + ])('returns %j for %j', (expectedResult, _, value) => { + expect(isVatWorkerServiceCommand(value)).toBe(expectedResult); + }); + }); +}); + +describe('isVatWorkerServiceCommandReply', () => { + const withError = ( + payload: VatWorkerServiceCommandReply['payload'], + problem: unknown, + ): unknown => ({ + method: payload.method, + params: { ...payload.params, error: problem }, + }); + + describe('launch', () => { + const withMarshaledError = (vatId: VatId): unknown => ({ + method: launchPayload.method, + params: { + ...launchPayload.params, + error: marshalError(new VatAlreadyExistsError(vatId)), + }, + }); + it.each([ + [ + true, + 'valid message id with valid payload', + { id: 'm0', payload: launchPayload }, + ], + [false, 'invalid id', { id: 'vat-message-id', payload: launchPayload }], + [false, 'numerical id', { id: 1, payload: launchPayload }], + [false, 'missing payload', { id: 'm0' }], + [ + true, + 'valid message id with valid error', + { id: 'm0', payload: withMarshaledError('v0') }, + ], + [ + false, + 'valid message id with invalid error', + { id: 'm0', payload: withError(launchPayload, 404) }, + ], + ])('returns %j for %j', (expectedResult, _, value) => { + expect(isVatWorkerServiceCommandReply(value)).toBe(expectedResult); + }); + }); + + describe('terminate', () => { + const withMarshaledError = (vatId: VatId): unknown => ({ + method: terminatePayload.method, + params: { + ...terminatePayload.params, + error: marshalError(new VatDeletedError(vatId)), + }, + }); + it.each([ + [ + true, + 'valid message id with valid payload', + { id: 'm0', payload: terminatePayload }, + ], + [ + false, + 'invalid id', + { id: 'vat-message-id', payload: terminatePayload }, + ], + [false, 'numerical id', { id: 1, payload: terminatePayload }], + [false, 'missing payload', { id: 'm0' }], + [ + true, + 'valid message id with valid error', + { id: 'm0', payload: withMarshaledError('v0') }, + ], + [ + false, + 'valid message id with invalid error', + { id: 'm0', payload: withError(terminatePayload, 404) }, + ], + ])('returns %j for %j', (expectedResult, _, value) => { + expect(isVatWorkerServiceCommandReply(value)).toBe(expectedResult); + }); + }); + + describe('terminateAll', () => { + const withValidVatError = (vatId: VatId): unknown => ({ + method: terminateAllPayload.method, + params: { vatId, error: marshalError(new VatDeletedError(vatId)) }, + }); + const withMarshaledError = (): unknown => ({ + method: terminateAllPayload.method, + params: { error: marshalError(new Error('code: foobar')) }, + }); + it.each([ + [ + true, + 'valid message id with valid payload', + { id: 'm0', payload: terminateAllPayload }, + ], + [ + false, + 'invalid id', + { id: 'vat-message-id', payload: terminateAllPayload }, + ], + [false, 'numerical id', { id: 1, payload: terminateAllPayload }], + [false, 'missing payload', { id: 'm0' }], + [ + true, + 'valid message id with valid vat error', + { id: 'm0', payload: withValidVatError('v0') }, + ], + [ + true, + 'valid message id with valid error', + { id: 'm0', payload: withMarshaledError() }, + ], + [ + false, + 'valid message id with invalid error', + { id: 'm0', payload: withError(terminateAllPayload, 404) }, + ], + ])('returns %j for %j', (expectedResult, _, value) => { + expect(isVatWorkerServiceCommandReply(value)).toBe(expectedResult); + }); + }); +}); diff --git a/packages/kernel/src/messages/vat-worker-service.ts b/packages/kernel/src/messages/vat-worker-service.ts new file mode 100644 index 000000000..1d9f4355f --- /dev/null +++ b/packages/kernel/src/messages/vat-worker-service.ts @@ -0,0 +1,65 @@ +import { hasProperty, isObject } from '@metamask/utils'; +import { isMarshaledError } from '@ocap/errors'; +import type { MarshaledError } from '@ocap/errors'; +import type { TypeGuard } from '@ocap/utils'; + +import { makeIdentifiedMessageKit, messageType } from './message-kit.js'; +import type { VatId } from '../types.js'; +import { isVatId } from '../types.js'; + +const hasOptionalMarshaledError = (value: object): boolean => + !hasProperty(value, 'error') || isMarshaledError(value.error); + +export const vatWorkerServiceCommand = { + Launch: messageType< + { vatId: VatId }, + { vatId: VatId; error?: MarshaledError } + >( + (send) => isObject(send) && isVatId(send.vatId), + (reply) => + isObject(reply) && + isVatId(reply.vatId) && + hasOptionalMarshaledError(reply), + ), + + Terminate: messageType< + { vatId: VatId }, + { vatId: VatId; error?: MarshaledError } + >( + (send) => isObject(send) && isVatId(send.vatId), + (reply) => + isObject(reply) && + isVatId(reply.vatId) && + hasOptionalMarshaledError(reply), + ), + + TerminateAll: messageType< + null, + null | { vatId?: VatId; error: MarshaledError } + >( + (send) => send === null, + (reply) => + reply === null || + (isObject(reply) && + isMarshaledError(reply.error) && + (!hasProperty(reply, 'vatId') || isVatId(reply.vatId))), + ), +}; + +const messageKit = makeIdentifiedMessageKit({ + source: vatWorkerServiceCommand, + isMessageId: (value: unknown): value is `m${number}` => + typeof value === 'string' && + value.at(0) === 'm' && + value.slice(1) === String(Number(value.slice(1))), +}); + +export const VatWorkerServiceCommandMethod = messageKit.methods; + +export type VatWorkerServiceCommand = typeof messageKit.send; +export const isVatWorkerServiceCommand: TypeGuard = + messageKit.sendGuard; + +export type VatWorkerServiceCommandReply = typeof messageKit.reply; +export const isVatWorkerServiceCommandReply: TypeGuard = + messageKit.replyGuard; diff --git a/packages/kernel/src/messages/vat.test.ts b/packages/kernel/src/messages/vat.test.ts new file mode 100644 index 000000000..8b28c63df --- /dev/null +++ b/packages/kernel/src/messages/vat.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { isVatCommand, VatCommandMethod } from './vat.js'; + +describe('isVatCommand', () => { + const payload = { method: VatCommandMethod.Evaluate, params: '3 + 3' }; + + it.each` + value | expectedResult | description + ${{ id: 'v0:1', payload }} | ${true} | ${'valid message id with valid payload'} + ${{ id: 'vat-message-id', payload }} | ${false} | ${'invalid id'} + ${{ id: 1, payload }} | ${false} | ${'numerical id'} + ${{ id: 'v0:1' }} | ${false} | ${'missing payload'} + `('returns $expectedResult for $description', ({ value, expectedResult }) => { + expect(isVatCommand(value)).toBe(expectedResult); + }); +}); diff --git a/packages/kernel/src/messages/vat.ts b/packages/kernel/src/messages/vat.ts index 77eb99866..2ba698102 100644 --- a/packages/kernel/src/messages/vat.ts +++ b/packages/kernel/src/messages/vat.ts @@ -1,7 +1,7 @@ -import { makeMessageKit, messageType } from './message-kit.js'; -import type { VatMessage } from './vat-message.js'; -import { isVatMessage } from './vat-message.js'; +import { makeIdentifiedMessageKit, messageType } from './message-kit.js'; import { vatTestCommand } from './vat-test.js'; +import { isVatId } from '../types.js'; +import type { VatId } from '../types.js'; export const vatCommand = { CapTpInit: messageType( @@ -12,14 +12,18 @@ export const vatCommand = { ...vatTestCommand, }; -const vatMessageKit = makeMessageKit(vatCommand); +const vatMessageKit = makeIdentifiedMessageKit({ + source: vatCommand, + isMessageId: (value: unknown): value is `${VatId}:${number}` => + typeof value === 'string' && + /^\w+:\d+$/u.test(value) && + isVatId(value.split(':')[0]), +}); export const VatCommandMethod = vatMessageKit.methods; -export type VatCommand = VatMessage; -export const isVatCommand = (value: unknown): value is VatCommand => - isVatMessage(value) && vatMessageKit.sendGuard(value.payload); +export type VatCommand = typeof vatMessageKit.send; +export const isVatCommand = vatMessageKit.sendGuard; -export type VatCommandReply = VatMessage; -export const isVatCommandReply = (value: unknown): value is VatCommandReply => - isVatMessage(value) && vatMessageKit.replyGuard(value.payload); +export type VatCommandReply = typeof vatMessageKit.reply; +export const isVatCommandReply = vatMessageKit.replyGuard; diff --git a/packages/kernel/src/types.ts b/packages/kernel/src/types.ts index e25a8e894..bdf120041 100644 --- a/packages/kernel/src/types.ts +++ b/packages/kernel/src/types.ts @@ -16,8 +16,29 @@ export type PromiseCallbacks = Omit< >; export type VatWorkerService = { - initWorker: ( + /** + * Launch a new worker with a specific vat id. + * + * @param vatId - The vat id of the worker to launch. + * @returns A promise for a duplex stream connected to the worker + * which rejects if a worker with the given vat id already exists. + */ + launch: ( vatId: VatId, ) => Promise>; - deleteWorker: (vatId: VatId) => Promise; + /** + * Terminate a worker identified by its vat id. + * + * @param vatId - The vat id of the worker to terminate. + * @returns A promise that resolves when the worker has terminated + * or rejects if that worker does not exist. + */ + terminate: (vatId: VatId) => Promise; + /** + * Terminate all workers managed by the service. + * + * @returns A promise that resolves after all workers have terminated + * or rejects if there was an error during termination. + */ + terminateAll: () => Promise; }; diff --git a/yarn.lock b/yarn.lock index 8312a704f..08b901b01 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1514,6 +1514,7 @@ __metadata: "@metamask/eslint-config-typescript": "npm:^14.0.0" "@metamask/snaps-utils": "npm:^8.3.0" "@metamask/utils": "npm:^9.3.0" + "@ocap/errors": "workspace:^" "@ocap/kernel": "workspace:^" "@ocap/shims": "workspace:^" "@ocap/streams": "workspace:^"