From 73a1d4461415c4db7029a8c15b3033fcd443cb63 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Fri, 4 Oct 2024 15:12:10 -0500 Subject: [PATCH 1/8] feat(kernel): Add InitKernel command. --- packages/kernel/src/messages/kernel.test.ts | 32 +++++++++++++++++++++ packages/kernel/src/messages/kernel.ts | 13 ++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 packages/kernel/src/messages/kernel.test.ts diff --git a/packages/kernel/src/messages/kernel.test.ts b/packages/kernel/src/messages/kernel.test.ts new file mode 100644 index 000000000..259bae1e3 --- /dev/null +++ b/packages/kernel/src/messages/kernel.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { + isKernelCommand, + isKernelCommandReply, + KernelCommandMethod, +} from './kernel.js'; + +describe('isKernelCommand', () => { + it.each` + value | expectedResult | description + ${{ method: KernelCommandMethod.InitKernel, params: null }} | ${true} | ${'valid command with null data'} + ${123} | ${false} | ${'invalid command: primitive number'} + ${{ method: true, params: 'data' }} | ${false} | ${'invalid command: invalid type'} + ${{ method: 123, params: null }} | ${false} | ${'invalid command: invalid type and valid data'} + ${{ method: 'some-type', params: true }} | ${false} | ${'invalid command: valid type and invalid data'} + `('returns $expectedResult for $description', ({ value, expectedResult }) => { + expect(isKernelCommand(value)).toBe(expectedResult); + }); +}); + +describe('isKernelCommandReply', () => { + it.each` + value | expectedResult | description + ${{ method: KernelCommandMethod.InitKernel, params: { initTime: 22, defaultVat: 'v0' } }} | ${true} | ${'valid command with object data'} + ${{ method: true, params: 'data' }} | ${false} | ${'invalid command reply: invalid type'} + ${{ method: 123, params: null }} | ${false} | ${'invalid command reply: invalid type and valid data'} + ${{ method: 'some-type', params: true }} | ${false} | ${'invalid command reply: valid type and invalid data'} + `('returns $expectedResult for $description', ({ value, expectedResult }) => { + expect(isKernelCommandReply(value)).toBe(expectedResult); + }); +}); diff --git a/packages/kernel/src/messages/kernel.ts b/packages/kernel/src/messages/kernel.ts index a04924364..2a05d1254 100644 --- a/packages/kernel/src/messages/kernel.ts +++ b/packages/kernel/src/messages/kernel.ts @@ -1,9 +1,20 @@ +import { isObject } from '@metamask/utils'; import type { TypeGuard } from '@ocap/utils'; import { kernelTestCommand } from './kernel-test.js'; -import { makeMessageKit } from './message-kit.js'; +import { makeMessageKit, messageType } from './message-kit.js'; +import type { VatId } from '../types.js'; +import { isVatId } from '../types.js'; export const kernelCommand = { + InitKernel: messageType( + (send) => send === null, + (reply) => + isObject(reply) && + typeof reply.initTime === 'number' && + isVatId(reply.defaultVat), + ), + ...kernelTestCommand, }; From ebf04134077b2c2e665524cf89fadfcf88a5c358 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Fri, 4 Oct 2024 16:22:37 -0500 Subject: [PATCH 2/8] feat(extension): Use init kernel command. --- packages/extension/src/background.ts | 1 + packages/extension/src/offscreen.ts | 17 +++++++++++++++-- packages/kernel/src/messages/cluster.ts | 4 ++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index b48664664..e67d37c72 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -90,6 +90,7 @@ async function main(): Promise { } switch (message.method) { + case ClusterCommandMethod.InitKernel: case ClusterCommandMethod.Evaluate: case ClusterCommandMethod.CapTpCall: case ClusterCommandMethod.Ping: diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 990cb7cd4..5c76f5227 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -29,6 +29,9 @@ async function main(): Promise { ChromeRuntimeTarget.Background, ); + const startTime = performance.now(); + const defaultVatId = 'v0'; + const kernelWorker = makeKernelWorker(); // Setup mock VatWorker service. @@ -58,7 +61,7 @@ async function main(): Promise { // Create kernel. const kernel = new Kernel(vatWorkerClient); - const iframeReadyP = kernel.launchVat({ id: 'v0' }); + const iframeReadyP = kernel.launchVat({ id: defaultVatId }); // Setup glue. @@ -96,6 +99,8 @@ async function main(): Promise { async function handleKernelCommand(command: KernelCommand): Promise { const { method, params } = command; switch (method) { + case KernelCommandMethod.InitKernel: + throw new Error('background should not call init kernel'); case KernelCommandMethod.Ping: await replyToBackground({ method, params: 'pong' }); break; @@ -224,7 +229,8 @@ async function main(): Promise { // the sole eventual recipient is a human eyeball, and even then it's questionable. result = `ERROR: ${possibleError.message}`; } else { - result = params; + // The InitKernel reply has an object params. + result = String(params); } const reply = { method, params: result ?? null }; if (!isKernelCommandReply(reply)) { @@ -240,6 +246,13 @@ async function main(): Promise { await workerStream.write(message); }; + iframeReadyP.then(async () => { + await replyToBackground({ + method: KernelCommandMethod.InitKernel, + params: { initTime: performance.now() - startTime, defaultVat: defaultVatId }, + }) + }); + return { sendMessage, receiveMessages, diff --git a/packages/kernel/src/messages/cluster.ts b/packages/kernel/src/messages/cluster.ts index ec76f633b..c7afd5d5f 100644 --- a/packages/kernel/src/messages/cluster.ts +++ b/packages/kernel/src/messages/cluster.ts @@ -1,10 +1,10 @@ import type { TypeGuard } from '@ocap/utils'; -import { kernelTestCommand } from './kernel-test.js'; +import { kernelCommand } from './kernel.js'; import { makeMessageKit } from './message-kit.js'; const clusterCommand = { - ...kernelTestCommand, + ...kernelCommand, }; const clusterCommandKit = makeMessageKit(clusterCommand); From f76f58a1b1271d0c64fc41f93004ce89de0709ab Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Fri, 4 Oct 2024 14:52:27 -0500 Subject: [PATCH 3/8] refactor(extension): Extract kernel types from kernel-worker. --- packages/extension/src/kernel-types.ts | 82 +++++++++++++++++++++++++ packages/extension/src/kernel-worker.ts | 80 ------------------------ 2 files changed, 82 insertions(+), 80 deletions(-) create mode 100644 packages/extension/src/kernel-types.ts diff --git a/packages/extension/src/kernel-types.ts b/packages/extension/src/kernel-types.ts new file mode 100644 index 000000000..aed415d68 --- /dev/null +++ b/packages/extension/src/kernel-types.ts @@ -0,0 +1,82 @@ +/** + * A structured representation of an ocap kernel. + */ +type Queue = Type[]; + +type VatId = `v${number}`; +type RemoteId = `r${number}`; +type EndpointId = VatId | RemoteId; + +type RefTypeTag = 'o' | 'p'; +type RefDirectionTag = '+' | '-'; +type InnerKRef = `${RefTypeTag}${number}`; +type InnerERef = `${RefTypeTag}${RefDirectionTag}${number}`; + +type KRef = `k${InnerKRef}`; +type VRef = `v${InnerERef}`; +type RRef = `r${InnerERef}`; +type ERef = VRef | RRef; + +type CapData = { + body: string; + slots: string[]; +}; + +type Message = { + target: ERef | KRef; + method: string; + params: CapData; +}; + +// Per-endpoint persistent state +type EndpointState = { + name: string; + id: IdType; + nextExportObjectIdCounter: number; + nextExportPromiseIdCounter: number; + eRefToKRef: Map; + kRefToERef: Map; +}; + +type VatState = { + messagePort: MessagePort; + state: EndpointState; + source: string; + kvTable: Map; +}; + +type RemoteState = { + state: EndpointState; + connectToURL: string; + // more here about maintaining connection... +}; + +// Kernel persistent state +type KernelObject = { + owner: EndpointId; + reachableCount: number; + recognizableCount: number; +}; + +type PromiseState = 'unresolved' | 'fulfilled' | 'rejected'; + +type KernelPromise = { + decider: EndpointId; + state: PromiseState; + referenceCount: number; + messageQueue: Queue; + value: undefined | CapData; +}; + +// export temporarily to shut up lint whinges about unusedness +export type KernelState = { + runQueue: Queue; + nextVatIdCounter: number; + vats: Map; + nextRemoteIdCounter: number; + remotes: Map; + nextKernelObjectIdCounter: number; + kernelObjects: Map; + nextKernePromiseIdCounter: number; + kernelPromises: Map; +}; diff --git a/packages/extension/src/kernel-worker.ts b/packages/extension/src/kernel-worker.ts index a64c89bf7..896410a1f 100644 --- a/packages/extension/src/kernel-worker.ts +++ b/packages/extension/src/kernel-worker.ts @@ -19,86 +19,6 @@ const isKernelWorkerCommand = (value: unknown): value is KernelWorkerCommand => (value.method === KernelCommandMethod.KVSet || value.method === KernelCommandMethod.KVGet); -type Queue = Type[]; - -type VatId = `v${number}`; -type RemoteId = `r${number}`; -type EndpointId = VatId | RemoteId; - -type RefTypeTag = 'o' | 'p'; -type RefDirectionTag = '+' | '-'; -type InnerKRef = `${RefTypeTag}${number}`; -type InnerERef = `${RefTypeTag}${RefDirectionTag}${number}`; - -type KRef = `k${InnerKRef}`; -type VRef = `v${InnerERef}`; -type RRef = `r${InnerERef}`; -type ERef = VRef | RRef; - -type CapData = { - body: string; - slots: string[]; -}; - -type Message = { - target: ERef | KRef; - method: string; - params: CapData; -}; - -// Per-endpoint persistent state -type EndpointState = { - name: string; - id: IdType; - nextExportObjectIdCounter: number; - nextExportPromiseIdCounter: number; - eRefToKRef: Map; - kRefToERef: Map; -}; - -type VatState = { - messagePort: MessagePort; - state: EndpointState; - source: string; - kvTable: Map; -}; - -type RemoteState = { - state: EndpointState; - connectToURL: string; - // more here about maintaining connection... -}; - -// Kernel persistent state -type KernelObject = { - owner: EndpointId; - reachableCount: number; - recognizableCount: number; -}; - -type PromiseState = 'unresolved' | 'fulfilled' | 'rejected'; - -type KernelPromise = { - decider: EndpointId; - state: PromiseState; - referenceCount: number; - messageQueue: Queue; - value: undefined | CapData; -}; - -// export temporarily to shut up lint whinges about unusedness -export type KernelState = { - runQueue: Queue; - nextVatIdCounter: number; - vats: Map; - nextRemoteIdCounter: number; - remotes: Map; - nextKernelObjectIdCounter: number; - kernelObjects: Map; - nextKernePromiseIdCounter: number; - kernelPromises: Map; -}; - /** * Ensure that SQLite is initialized. * From ac7097c8597971189f49ab501d717b70bc4ab311 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 9 Oct 2024 19:29:54 -0500 Subject: [PATCH 4/8] refactor(extension): Move kernel into kernel-worker. --- packages/extension/src/kernel-worker.ts | 125 +++++++++++++-- packages/extension/src/offscreen.ts | 202 ++++-------------------- 2 files changed, 139 insertions(+), 188 deletions(-) diff --git a/packages/extension/src/kernel-worker.ts b/packages/extension/src/kernel-worker.ts index 896410a1f..45b3221ee 100644 --- a/packages/extension/src/kernel-worker.ts +++ b/packages/extension/src/kernel-worker.ts @@ -1,23 +1,18 @@ import './kernel-worker-trusted-prelude.js'; -import type { KernelCommand, KernelCommandReply } from '@ocap/kernel'; -import { isKernelCommand, KernelCommandMethod } from '@ocap/kernel'; +import type { KernelCommand, KernelCommandReply, VatId } from '@ocap/kernel'; +import { isKernelCommand, Kernel, KernelCommandMethod } from '@ocap/kernel'; import { PostMessageDuplexStream } from '@ocap/streams'; +import { makeLogger, stringify } from '@ocap/utils'; import type { Database } from '@sqlite.org/sqlite-wasm'; import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; -main().catch(console.error); +import { ExtensionVatWorkerClient } from './VatWorkerClient.js'; -// We temporarily have the kernel commands split between offscreen and kernel-worker -type KernelWorkerCommand = Extract< - KernelCommand, - | { method: typeof KernelCommandMethod.KVSet } - | { method: typeof KernelCommandMethod.KVGet } ->; +type MainArgs = { defaultVatId: VatId }; -const isKernelWorkerCommand = (value: unknown): value is KernelWorkerCommand => - isKernelCommand(value) && - (value.method === KernelCommandMethod.KVSet || - value.method === KernelCommandMethod.KVGet); +const logger = makeLogger('[kernel worker]'); + +main({ defaultVatId: 'v0' }).catch(console.error); /** * Ensure that SQLite is initialized. @@ -35,8 +30,13 @@ async function initDB(): Promise { /** * The main function for the offscreen script. + * + * @param options - The options bag. + * @param options.defaultVatId - The id to give the default vat. */ -async function main(): Promise { +async function main({ defaultVatId }: MainArgs): Promise { + const start = performance.now(); + const kernelStream = new PostMessageDuplexStream< KernelCommand, KernelCommandReply @@ -46,14 +46,35 @@ async function main(): Promise { (listener) => globalThis.removeEventListener('message', listener), ); + // Initialize vat worker service. + + const vatWorkerClient = new ExtensionVatWorkerClient( + (message: unknown) => globalThis.postMessage(message), + (listener) => { + globalThis.onmessage = listener; + }, + ); + + // Initialize kernel store. + const { sqlKVGet, sqlKVSet } = await initDb(); + // Create kernel. + + const kernel = new Kernel(vatWorkerClient); + const iframeReadyP = kernel.launchVat({ id: defaultVatId }); + + await reply({ + method: KernelCommandMethod.InitKernel, + params: { defaultVat: defaultVatId, initTime: performance.now() - start }, + }); + // Handle messages from the console service worker for await (const message of kernelStream) { - if (isKernelWorkerCommand(message)) { + if (isKernelCommand(message)) { await handleKernelCommand(message); } else { - console.error('Received unexpected message', message); + logger.debug(`Received unexpected message ${stringify(message)}`); } } @@ -67,8 +88,19 @@ async function main(): Promise { async function handleKernelCommand({ method, params, - }: KernelWorkerCommand): Promise { + }: KernelCommand): Promise { switch (method) { + case KernelCommandMethod.InitKernel: + throw new Error('The kernel starts itself.'); + case KernelCommandMethod.Ping: + await reply({ method, params: 'pong' }); + break; + case KernelCommandMethod.Evaluate: + await handleVatTestCommand({ method, params }); + break; + case KernelCommandMethod.CapTpCall: + await handleVatTestCommand({ method, params }); + break; case KernelCommandMethod.KVSet: kvSet(params.key, params.value); await reply({ @@ -101,6 +133,43 @@ async function main(): Promise { } } + /** + * Handle a command implemented by the test vat. + * + * @param command - The command to handle. + */ + async function handleVatTestCommand( + command: Extract< + KernelCommand, + | { method: typeof KernelCommandMethod.Evaluate } + | { method: typeof KernelCommandMethod.CapTpCall } + >, + ): Promise { + const { method, params } = command; + const vat = await iframeReadyP; + switch (method) { + case KernelCommandMethod.Evaluate: + await reply({ + method, + params: await evaluate(vat.id, params), + }); + break; + case KernelCommandMethod.CapTpCall: + await reply({ + method, + params: stringify(await vat.callCapTp(params)), + }); + break; + default: + console.error( + 'Offscreen received unexpected vat command', + // @ts-expect-error Runtime does not respect "never". + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + { method: method.valueOf(), params }, + ); + } + } + /** * Reply to the background script. * @@ -110,6 +179,28 @@ async function main(): Promise { await kernelStream.write(payload); } + /** + * Evaluate a string in the default iframe. + * + * @param vatId - The ID of the vat to send the message to. + * @param source - The source string to evaluate. + * @returns The result of the evaluation, or an error message. + */ + async function evaluate(vatId: VatId, source: string): Promise { + try { + const result = await kernel.sendMessage(vatId, { + method: KernelCommandMethod.Evaluate, + params: source, + }); + return String(result); + } catch (error) { + if (error instanceof Error) { + return `Error: ${error.message}`; + } + return `Error: Unknown error during evaluation.`; + } + } + /** * Coerce an unknown problem into an Error object. * diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 5c76f5227..68c2f61d3 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -1,8 +1,8 @@ +import { makePromiseKit } from '@endo/promise-kit'; import { - Kernel, - KernelCommandMethod, isKernelCommand, isKernelCommandReply, + KernelCommandMethod, } from '@ocap/kernel'; import type { KernelCommandReply, KernelCommand, VatId } from '@ocap/kernel'; import { @@ -11,12 +11,13 @@ import { ChromeRuntimeDuplexStream, PostMessageDuplexStream, } from '@ocap/streams'; -import { stringify } from '@ocap/utils'; +import { makeLogger } from '@ocap/utils'; import { makeIframeVatWorker } from './iframe-vat-worker.js'; -import { ExtensionVatWorkerClient } from './VatWorkerClient.js'; import { ExtensionVatWorkerServer } from './VatWorkerServer.js'; +const logger = makeLogger('[ocap glue]'); + main().catch(console.error); /** @@ -29,41 +30,14 @@ async function main(): Promise { ChromeRuntimeTarget.Background, ); - const startTime = performance.now(); - const defaultVatId = 'v0'; - const kernelWorker = makeKernelWorker(); - - // Setup mock VatWorker service. - - const { port1: serverPort, port2: clientPort } = new MessageChannel(); - - const vatWorkerServer = new ExtensionVatWorkerServer( - (message: unknown, transfer?: Transferable[]) => - transfer - ? serverPort.postMessage(message, transfer) - : serverPort.postMessage(message), - (listener) => { - serverPort.onmessage = listener; - }, - (vatId: VatId) => makeIframeVatWorker(vatId, initializeMessageChannel), - ); - - vatWorkerServer.start(); - - const vatWorkerClient = new ExtensionVatWorkerClient( - (message: unknown) => clientPort.postMessage(message), - (listener) => { - clientPort.onmessage = listener; - }, - ); - - // Create kernel. - - const kernel = new Kernel(vatWorkerClient); - const iframeReadyP = kernel.launchVat({ id: defaultVatId }); - - // Setup glue. + const kernelInit = + makePromiseKit< + Extract< + KernelCommandReply, + { method: typeof KernelCommandMethod.InitKernel } + >['params'] + >(); /** * Reply to a command from the background script. @@ -80,110 +54,15 @@ async function main(): Promise { await Promise.all([ (async () => { for await (const message of backgroundStream) { - if (!isKernelCommand(message)) { - console.error('Offscreen received unexpected message', message); - continue; - } - - await handleKernelCommand(message); + await kernelInit.promise; + isKernelCommand(message) + ? await kernelWorker.sendMessage(message) + : logger.debug('Received unexpected message', message); } })(), kernelWorker.receiveMessages(), ]); - /** - * Handle a KernelCommand received from the background script. - * - * @param command - The command to handle. - */ - async function handleKernelCommand(command: KernelCommand): Promise { - const { method, params } = command; - switch (method) { - case KernelCommandMethod.InitKernel: - throw new Error('background should not call init kernel'); - case KernelCommandMethod.Ping: - await replyToBackground({ method, params: 'pong' }); - break; - case KernelCommandMethod.Evaluate: - await handleVatTestCommand({ method, params }); - break; - case KernelCommandMethod.CapTpCall: - await handleVatTestCommand({ method, params }); - break; - case KernelCommandMethod.KVGet: - await kernelWorker.sendMessage({ method, params }); - break; - case KernelCommandMethod.KVSet: - await kernelWorker.sendMessage({ method, params }); - break; - default: - console.error( - 'Offscreen received unexpected kernel command', - // @ts-expect-error Runtime does not respect "never". - { method: method.valueOf(), params }, - ); - } - } - - /** - * Handle a command implemented by the test vat. - * - * @param command - The command to handle. - */ - async function handleVatTestCommand( - command: Extract< - KernelCommand, - | { method: typeof KernelCommandMethod.Evaluate } - | { method: typeof KernelCommandMethod.CapTpCall } - >, - ): Promise { - const { method, params } = command; - const vat = await iframeReadyP; - switch (method) { - case KernelCommandMethod.Evaluate: - await replyToBackground({ - method, - params: await evaluate(vat.id, params), - }); - break; - case KernelCommandMethod.CapTpCall: - await replyToBackground({ - method, - params: stringify(await vat.callCapTp(params)), - }); - break; - default: - console.error( - 'Offscreen received unexpected vat command', - // @ts-expect-error Runtime does not respect "never". - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - { method: method.valueOf(), params }, - ); - } - } - - /** - * Evaluate a string in the default iframe. - * - * @param vatId - The ID of the vat to send the message to. - * @param source - The source string to evaluate. - * @returns The result of the evaluation, or an error message. - */ - async function evaluate(vatId: VatId, source: string): Promise { - try { - const result = await kernel.sendMessage(vatId, { - method: KernelCommandMethod.Evaluate, - params: source, - }); - return String(result); - } catch (error) { - if (error instanceof Error) { - return `Error: ${error.message}`; - } - return `Error: Unknown error during evaluation.`; - } - } - /** * Make the SQLite kernel worker. * @@ -207,38 +86,15 @@ async function main(): Promise { // For the time being, the only messages that come from the kernel worker are replies to actions // initiated from the console, so just forward these replies to the console. This will need to // change once this offscreen script is providing services to the kernel worker that don't - // involve the user (e.g., for things the worker can't do for itself, such as create an - // offscreen iframe). - - // XXX TODO: Using the IframeMessage type here assumes that the set of response messages is the - // same as (and aligns perfectly with) the set of command messages, which is horribly, terribly, - // awfully wrong. Need to add types to account for the replies. + // involve the user. for await (const message of workerStream) { if (!isKernelCommandReply(message)) { - console.error('kernel received unexpected message', message); - return; + logger.debug('Received unexpected reply', message); } - const { method, params } = message; - let result: string; - const possibleError = params as unknown as Error; - if (possibleError?.message && possibleError?.stack) { - // XXX TODO: The following is an egregious hack which is barely good enough for manual testing - // but not acceptable for serious use. We should be passing some kind of proper error - // indication back so that the recipient will experience a thrown exception or rejected - // promise, instead of having to look for a magic string. This is tolerable only so long as - // the sole eventual recipient is a human eyeball, and even then it's questionable. - result = `ERROR: ${possibleError.message}`; - } else { - // The InitKernel reply has an object params. - result = String(params); + if (message.method === KernelCommandMethod.InitKernel) { + kernelInit.resolve(message.params); } - const reply = { method, params: result ?? null }; - if (!isKernelCommandReply(reply)) { - // Internal error. - console.error('Malformed command reply', reply); - return; - } - await replyToBackground(reply); + await replyToBackground(message); } }; @@ -246,12 +102,16 @@ async function main(): Promise { await workerStream.write(message); }; - iframeReadyP.then(async () => { - await replyToBackground({ - method: KernelCommandMethod.InitKernel, - params: { initTime: performance.now() - startTime, defaultVat: defaultVatId }, - }) - }); + const vatWorkerServer = new ExtensionVatWorkerServer( + (message: unknown, transfer?: Transferable[]) => + transfer + ? worker.postMessage(message, transfer) + : worker.postMessage(message), + (listener) => worker.addEventListener('message', listener), + (vatId: VatId) => makeIframeVatWorker(vatId, initializeMessageChannel), + ); + + vatWorkerServer.start(); return { sendMessage, From ad7e5e78799ebeddb0680030f92f63f3f1724610 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 9 Oct 2024 20:06:07 -0500 Subject: [PATCH 5/8] feat(streams): Make base stream ignore unexpected messages instead of throwing. --- packages/kernel/src/Supervisor.test.ts | 2 +- packages/kernel/src/Vat.test.ts | 2 +- packages/streams/src/BaseStream.test.ts | 50 ++++++++++++++----------- packages/streams/src/BaseStream.ts | 5 +++ 4 files changed, 35 insertions(+), 24 deletions(-) diff --git a/packages/kernel/src/Supervisor.test.ts b/packages/kernel/src/Supervisor.test.ts index 191c2a6a1..18c5a4f16 100644 --- a/packages/kernel/src/Supervisor.test.ts +++ b/packages/kernel/src/Supervisor.test.ts @@ -28,7 +28,7 @@ describe('Supervisor', () => { expect(supervisor.stream).toBeDefined(); }); - it('throws if the stream throws', async () => { + it.todo('throws if the stream throws', async () => { const consoleErrorSpy = vi.spyOn(console, 'error'); messageChannel.port2.postMessage('foobar'); await delay(10); diff --git a/packages/kernel/src/Vat.test.ts b/packages/kernel/src/Vat.test.ts index 9f9e702bc..9fc6ad40e 100644 --- a/packages/kernel/src/Vat.test.ts +++ b/packages/kernel/src/Vat.test.ts @@ -55,7 +55,7 @@ describe('Vat', () => { expect(capTpMock).toHaveBeenCalled(); }); - it('throws an error if the stream is invalid', async () => { + it.todo('throws an error if the stream is invalid', async () => { vi.spyOn(vat, 'sendMessage').mockResolvedValueOnce(undefined); vi.spyOn(vat, 'makeCapTp').mockResolvedValueOnce(undefined); await vat.init(); diff --git a/packages/streams/src/BaseStream.test.ts b/packages/streams/src/BaseStream.test.ts index 38df8d69a..b8f1fb6d2 100644 --- a/packages/streams/src/BaseStream.test.ts +++ b/packages/streams/src/BaseStream.test.ts @@ -75,28 +75,34 @@ describe('BaseReader', () => { } }); - it('throws after receiving unexpected message, before read is enqueued', async () => { - const reader = new TestReader(); - - const unexpectedMessage = { foo: 'bar' }; - reader.receiveInput(unexpectedMessage); - - await expect(reader.next()).rejects.toThrow( - 'Received unexpected message from transport', - ); - }); - - it('throws after receiving unexpected message, after read is enqueued', async () => { - const reader = new TestReader(); - - const nextP = reader.next(); - const unexpectedMessage = { foo: 'bar' }; - reader.receiveInput(unexpectedMessage); - - await expect(nextP).rejects.toThrow( - 'Received unexpected message from transport', - ); - }); + it.todo( + 'calls console.debug after receiving unexpected message, before read is enqueued', + async () => { + const reader = new TestReader(); + + const unexpectedMessage = { foo: 'bar' }; + reader.receiveInput(unexpectedMessage); + + await expect(reader.next()).rejects.toThrow( + 'Received unexpected message from transport', + ); + }, + ); + + it.todo( + 'calls console.debug after receiving unexpected message, after read is enqueued', + async () => { + const reader = new TestReader(); + + const nextP = reader.next(); + const unexpectedMessage = { foo: 'bar' }; + reader.receiveInput(unexpectedMessage); + + await expect(nextP).rejects.toThrow( + 'Received unexpected message from transport', + ); + }, + ); it('throws after receiving marshaled error, before read is enqueued', async () => { const reader = new TestReader(); diff --git a/packages/streams/src/BaseStream.ts b/packages/streams/src/BaseStream.ts index 989f3deeb..b378e1920 100644 --- a/packages/streams/src/BaseStream.ts +++ b/packages/streams/src/BaseStream.ts @@ -145,6 +145,10 @@ export class BaseReader implements Reader { readonly #receiveInput: ReceiveInput = async (input) => { if (!isDispatchable(input)) { + console.debug( + `Received unexpected message from transport:\n${stringify(input)}`, + ); + /* const error = new Error( `Received unexpected message from transport:\n${stringify(input)}`, ); @@ -152,6 +156,7 @@ export class BaseReader implements Reader { this.#buffer.put(error); } await this.#end(error); + */ return; } From 618db8d664ddc0dbabfc1163c508c2fa8622a106 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Fri, 4 Oct 2024 14:06:41 -0500 Subject: [PATCH 6/8] refactor(extension): Extract kernel store functionality from kernel-worker. --- packages/extension/src/kernel-worker.ts | 84 +---------------- packages/extension/src/offscreen.ts | 6 +- packages/extension/src/sqlite-kernel-store.ts | 93 +++++++++++++++++++ 3 files changed, 98 insertions(+), 85 deletions(-) create mode 100644 packages/extension/src/sqlite-kernel-store.ts diff --git a/packages/extension/src/kernel-worker.ts b/packages/extension/src/kernel-worker.ts index 45b3221ee..91ed19b35 100644 --- a/packages/extension/src/kernel-worker.ts +++ b/packages/extension/src/kernel-worker.ts @@ -3,9 +3,8 @@ import type { KernelCommand, KernelCommandReply, VatId } from '@ocap/kernel'; import { isKernelCommand, Kernel, KernelCommandMethod } from '@ocap/kernel'; import { PostMessageDuplexStream } from '@ocap/streams'; import { makeLogger, stringify } from '@ocap/utils'; -import type { Database } from '@sqlite.org/sqlite-wasm'; -import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; +import { makeKernelStore } from './sqlite-kernel-store.js'; import { ExtensionVatWorkerClient } from './VatWorkerClient.js'; type MainArgs = { defaultVatId: VatId }; @@ -14,20 +13,6 @@ const logger = makeLogger('[kernel worker]'); main({ defaultVatId: 'v0' }).catch(console.error); -/** - * Ensure that SQLite is initialized. - * - * @returns The SQLite database object. - */ -async function initDB(): Promise { - const sqlite3 = await sqlite3InitModule(); - if (sqlite3.oo1.OpfsDb) { - return new sqlite3.oo1.OpfsDb('/testdb.sqlite', 'cwt'); - } - console.warn(`OPFS not enabled, database will be ephemeral`); - return new sqlite3.oo1.DB('/testdb.sqlite', 'cwt'); -} - /** * The main function for the offscreen script. * @@ -57,7 +42,7 @@ async function main({ defaultVatId }: MainArgs): Promise { // Initialize kernel store. - const { sqlKVGet, sqlKVSet } = await initDb(); + const { kvGet, kvSet } = await makeKernelStore(); // Create kernel. @@ -212,69 +197,4 @@ async function main({ defaultVatId }: MainArgs): Promise { ? problem : new Error('Unknown', { cause: problem }); } - - /** - * Initialize the database and some prepared statements. - * - * @returns The prepared database statements. - */ - async function initDb(): Promise<{ - sqlKVGet: ReturnType; - sqlKVSet: ReturnType; - }> { - const db = await initDB(); - db.exec(` - CREATE TABLE IF NOT EXISTS kv ( - key TEXT, - value TEXT, - PRIMARY KEY(key) - ) - `); - - return { - sqlKVGet: db.prepare(` - SELECT value - FROM kv - WHERE key = ? - `), - sqlKVSet: db.prepare(` - INSERT INTO kv (key, value) - VALUES (?, ?) - ON CONFLICT DO UPDATE SET value = excluded.value - `), - }; - } - - /** - * Exercise reading from the database. - * - * @param key - A key to fetch. - * @returns The value at that key. - */ - function kvGet(key: string): string { - sqlKVGet.bind([key]); - if (sqlKVGet.step()) { - const result = sqlKVGet.getString(0); - if (result) { - sqlKVGet.reset(); - console.log(`kernel get '${key}' as '${result}'`); - return result; - } - } - sqlKVGet.reset(); - throw Error(`no record matching key '${key}'`); - } - - /** - * Exercise writing to the database. - * - * @param key - A key to assign. - * @param value - The value to assign to it. - */ - function kvSet(key: string, value: string): void { - console.log(`kernel set '${key}' to '${value}'`); - sqlKVSet.bind([key, value]); - sqlKVSet.step(); - sqlKVSet.reset(); - } } diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 68c2f61d3..bd8925138 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -1,8 +1,8 @@ import { makePromiseKit } from '@endo/promise-kit'; import { - isKernelCommand, - isKernelCommandReply, KernelCommandMethod, + isKernelCommandReply, + isClusterCommand, } from '@ocap/kernel'; import type { KernelCommandReply, KernelCommand, VatId } from '@ocap/kernel'; import { @@ -55,7 +55,7 @@ async function main(): Promise { (async () => { for await (const message of backgroundStream) { await kernelInit.promise; - isKernelCommand(message) + isClusterCommand(message) ? await kernelWorker.sendMessage(message) : logger.debug('Received unexpected message', message); } diff --git a/packages/extension/src/sqlite-kernel-store.ts b/packages/extension/src/sqlite-kernel-store.ts new file mode 100644 index 000000000..5e589a2b3 --- /dev/null +++ b/packages/extension/src/sqlite-kernel-store.ts @@ -0,0 +1,93 @@ +import { makeLogger } from '@ocap/utils'; +import type { Database } from '@sqlite.org/sqlite-wasm'; +import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; + +/** + * Ensure that SQLite is initialized. + * + * @returns The SQLite database object. + */ +async function initDB(): Promise { + const sqlite3 = await sqlite3InitModule(); + if (sqlite3.oo1.OpfsDb) { + return new sqlite3.oo1.OpfsDb('/testdb.sqlite', 'cwt'); + } + console.warn(`OPFS not enabled, database will be ephemeral`); + return new sqlite3.oo1.DB('/testdb.sqlite', 'cwt'); +} + +export type KernelStore = { + kvGet: (key: string) => string; + kvSet: (key: string, value: string) => void; +}; + +/** + * Makes a {@link KernelStore} for persistent storage. + * + * @param label - A logger prefix label. Defaults to '[sqlite]'. + * @returns The kernel store. + */ +export async function makeKernelStore( + label: string = '[sqlite]', +): Promise { + const logger = makeLogger(label); + const db = await initDB(); + + db.exec(` + CREATE TABLE IF NOT EXISTS kv ( + key TEXT, + value TEXT, + PRIMARY KEY(key) + ) + `); + + const sqlKVGet = db.prepare(` + SELECT value + FROM kv + WHERE key = ? + `); + + /** + * Exercise reading from the database. + * + * @param key - A key to fetch. + * @returns The value at that key. + */ + function kvGet(key: string): string { + sqlKVGet.bind([key]); + if (sqlKVGet.step()) { + const result = sqlKVGet.getString(0); + if (result) { + sqlKVGet.reset(); + logger.debug(`kernel get '${key}' as '${result}'`); + return result; + } + } + sqlKVGet.reset(); + throw Error(`no record matching key '${key}'`); + } + + const sqlKVSet = db.prepare(` + INSERT INTO kv (key, value) + VALUES (?, ?) + ON CONFLICT DO UPDATE SET value = excluded.value + `); + + /** + * Exercise writing to the database. + * + * @param key - A key to assign. + * @param value - The value to assign to it. + */ + function kvSet(key: string, value: string): void { + logger.debug(`kernel set '${key}' to '${value}'`); + sqlKVSet.bind([key, value]); + sqlKVSet.step(); + sqlKVSet.reset(); + } + + return { + kvGet, + kvSet, + }; +} From d216295cb99d2e31bebbec6e4a2bf423309c2d4c Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Fri, 4 Oct 2024 14:58:31 -0500 Subject: [PATCH 7/8] feat(kernel): Add a KernelStore interface. --- packages/extension/src/kernel-worker.ts | 8 ++--- packages/extension/src/sqlite-kernel-store.ts | 6 +--- packages/kernel/src/Kernel.test.ts | 32 ++++++++++++------- packages/kernel/src/Kernel.ts | 15 ++++++++- packages/kernel/src/index.ts | 1 + packages/kernel/src/kernel-store.ts | 4 +++ packages/kernel/test/storage.ts | 20 ++++++++++++ packages/kernel/tsconfig.json | 2 +- 8 files changed, 65 insertions(+), 23 deletions(-) create mode 100644 packages/kernel/src/kernel-store.ts create mode 100644 packages/kernel/test/storage.ts diff --git a/packages/extension/src/kernel-worker.ts b/packages/extension/src/kernel-worker.ts index 91ed19b35..94f1000e2 100644 --- a/packages/extension/src/kernel-worker.ts +++ b/packages/extension/src/kernel-worker.ts @@ -42,11 +42,11 @@ async function main({ defaultVatId }: MainArgs): Promise { // Initialize kernel store. - const { kvGet, kvSet } = await makeKernelStore(); + const kernelStore = await makeKernelStore(); // Create kernel. - const kernel = new Kernel(vatWorkerClient); + const kernel = new Kernel(vatWorkerClient, kernelStore); const iframeReadyP = kernel.launchVat({ id: defaultVatId }); await reply({ @@ -87,7 +87,7 @@ async function main({ defaultVatId }: MainArgs): Promise { await handleVatTestCommand({ method, params }); break; case KernelCommandMethod.KVSet: - kvSet(params.key, params.value); + kernel.kvSet(params.key, params.value); await reply({ method, params: `~~~ set "${params.key}" to "${params.value}" ~~~`, @@ -95,7 +95,7 @@ async function main({ defaultVatId }: MainArgs): Promise { break; case KernelCommandMethod.KVGet: { try { - const result = kvGet(params); + const result = kernel.kvGet(params); await reply({ method, params: result, diff --git a/packages/extension/src/sqlite-kernel-store.ts b/packages/extension/src/sqlite-kernel-store.ts index 5e589a2b3..ddfb69f16 100644 --- a/packages/extension/src/sqlite-kernel-store.ts +++ b/packages/extension/src/sqlite-kernel-store.ts @@ -1,3 +1,4 @@ +import type { KernelStore } from '@ocap/kernel'; import { makeLogger } from '@ocap/utils'; import type { Database } from '@sqlite.org/sqlite-wasm'; import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; @@ -16,11 +17,6 @@ async function initDB(): Promise { return new sqlite3.oo1.DB('/testdb.sqlite', 'cwt'); } -export type KernelStore = { - kvGet: (key: string) => string; - kvSet: (key: string, value: string) => void; -}; - /** * Makes a {@link KernelStore} for persistent storage. * diff --git a/packages/kernel/src/Kernel.test.ts b/packages/kernel/src/Kernel.test.ts index 22f08d7bd..8ad841405 100644 --- a/packages/kernel/src/Kernel.test.ts +++ b/packages/kernel/src/Kernel.test.ts @@ -2,12 +2,14 @@ import type { DuplexStream } from '@ocap/streams'; import type { MockInstance } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { KernelStore } from './kernel-store.js'; import { Kernel } from './Kernel.js'; import type { VatCommand } from './messages.js'; import type { StreamEnvelope, StreamEnvelopeReply } from './stream-envelope.js'; import type { VatId } from './types.js'; import { Vat } from './Vat.js'; import type { VatWorkerClient } from './VatWorkerClient.js'; +import { makeMapKernelStore } from '../test/storage.js'; describe('Kernel', () => { let mockWorkerService: VatWorkerClient; @@ -16,6 +18,8 @@ describe('Kernel', () => { let initMock: MockInstance; let terminateMock: MockInstance; + let mockKernelStore: KernelStore; + beforeEach(() => { mockWorkerService = { initWorker: async () => ({}), @@ -35,22 +39,24 @@ describe('Kernel', () => { terminateMock = vi .spyOn(Vat.prototype, 'terminate') .mockImplementation(vi.fn()); + + mockKernelStore = makeMapKernelStore(); }); describe('getVatIds()', () => { it('returns an empty array when no vats are added', () => { - const kernel = new Kernel(mockWorkerService); + const kernel = new Kernel(mockWorkerService, mockKernelStore); expect(kernel.getVatIds()).toStrictEqual([]); }); it('returns the vat IDs after adding a vat', async () => { - const kernel = new Kernel(mockWorkerService); + const kernel = new Kernel(mockWorkerService, mockKernelStore); await kernel.launchVat({ id: 'v0' }); expect(kernel.getVatIds()).toStrictEqual(['v0']); }); it('returns multiple vat IDs after adding multiple vats', async () => { - const kernel = new Kernel(mockWorkerService); + const kernel = new Kernel(mockWorkerService, mockKernelStore); await kernel.launchVat({ id: 'v0' }); await kernel.launchVat({ id: 'v1' }); expect(kernel.getVatIds()).toStrictEqual(['v0', 'v1']); @@ -59,7 +65,7 @@ describe('Kernel', () => { describe('launchVat()', () => { it('adds a vat to the kernel without errors when no vat with the same ID exists', async () => { - const kernel = new Kernel(mockWorkerService); + const kernel = new Kernel(mockWorkerService, mockKernelStore); await kernel.launchVat({ id: 'v0' }); expect(initMock).toHaveBeenCalledOnce(); expect(mockGetWorkerStreams).toHaveBeenCalled(); @@ -67,7 +73,7 @@ describe('Kernel', () => { }); it('throws an error when launching a vat that already exists in the kernel', async () => { - const kernel = new Kernel(mockWorkerService); + const kernel = new Kernel(mockWorkerService, mockKernelStore); await kernel.launchVat({ id: 'v0' }); expect(kernel.getVatIds()).toStrictEqual(['v0']); await expect( @@ -81,7 +87,7 @@ describe('Kernel', () => { describe('deleteVat()', () => { it('deletes a vat from the kernel without errors when the vat exists', async () => { - const kernel = new Kernel(mockWorkerService); + const kernel = new Kernel(mockWorkerService, mockKernelStore); await kernel.launchVat({ id: 'v0' }); expect(kernel.getVatIds()).toStrictEqual(['v0']); await kernel.deleteVat('v0'); @@ -91,7 +97,7 @@ describe('Kernel', () => { }); it('throws an error when deleting a vat that does not exist in the kernel', async () => { - const kernel = new Kernel(mockWorkerService); + const kernel = new Kernel(mockWorkerService, mockKernelStore); const nonExistentVatId: VatId = 'v9'; await expect(async () => kernel.deleteVat(nonExistentVatId), @@ -100,7 +106,7 @@ describe('Kernel', () => { }); it('throws an error when a vat terminate method throws', async () => { - const kernel = new Kernel(mockWorkerService); + const kernel = new Kernel(mockWorkerService, mockKernelStore); await kernel.launchVat({ id: 'v0' }); vi.spyOn(Vat.prototype, 'terminate').mockRejectedValueOnce('Test error'); await expect(async () => kernel.deleteVat('v0')).rejects.toThrow( @@ -111,7 +117,7 @@ describe('Kernel', () => { describe('sendMessage()', () => { it('sends a message to the vat without errors when the vat exists', async () => { - const kernel = new Kernel(mockWorkerService); + const kernel = new Kernel(mockWorkerService, mockKernelStore); await kernel.launchVat({ id: 'v0' }); vi.spyOn(Vat.prototype, 'sendMessage').mockResolvedValueOnce('test'); expect( @@ -123,7 +129,7 @@ describe('Kernel', () => { }); it('throws an error when sending a message to the vat that does not exist in the kernel', async () => { - const kernel = new Kernel(mockWorkerService); + const kernel = new Kernel(mockWorkerService, mockKernelStore); const nonExistentVatId: VatId = 'v9'; await expect(async () => kernel.sendMessage(nonExistentVatId, {} as VatCommand['payload']), @@ -131,7 +137,7 @@ describe('Kernel', () => { }); it('throws an error when sending a message to the vat throws', async () => { - const kernel = new Kernel(mockWorkerService); + const kernel = new Kernel(mockWorkerService, mockKernelStore); await kernel.launchVat({ id: 'v0' }); vi.spyOn(Vat.prototype, 'sendMessage').mockRejectedValueOnce('error'); await expect(async () => @@ -142,7 +148,9 @@ describe('Kernel', () => { describe('constructor()', () => { it('initializes the kernel without errors', () => { - expect(async () => new Kernel(mockWorkerService)).not.toThrow(); + expect( + async () => new Kernel(mockWorkerService, mockKernelStore), + ).not.toThrow(); }); }); }); diff --git a/packages/kernel/src/Kernel.ts b/packages/kernel/src/Kernel.ts index 7fe14486c..5d499bd09 100644 --- a/packages/kernel/src/Kernel.ts +++ b/packages/kernel/src/Kernel.ts @@ -1,4 +1,5 @@ import '@ocap/shims/endoify'; +import type { KernelStore } from './kernel-store.js'; import type { VatCommand } from './messages.js'; import type { VatId, VatWorkerService } from './types.js'; import { Vat } from './Vat.js'; @@ -8,9 +9,20 @@ export class Kernel { readonly #vatWorkerService: VatWorkerService; - constructor(vatWorkerService: VatWorkerService) { + readonly #storage: KernelStore; + + constructor(vatWorkerService: VatWorkerService, storage: KernelStore) { this.#vats = new Map(); this.#vatWorkerService = vatWorkerService; + this.#storage = storage; + } + + kvGet(key: string): string { + return this.#storage.kvGet(key); + } + + kvSet(key: string, value: string): void { + this.#storage.kvSet(key, value); } /** @@ -81,3 +93,4 @@ export class Kernel { return vat; } } +harden(Kernel); diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts index 25b8a1c61..75a5da8ca 100644 --- a/packages/kernel/src/index.ts +++ b/packages/kernel/src/index.ts @@ -1,5 +1,6 @@ export * from './messages.js'; export { Kernel } from './Kernel.js'; +export type { KernelStore } from './kernel-store.js'; export { Vat } from './Vat.js'; export { Supervisor } from './Supervisor.js'; export type * from './messages.js'; diff --git a/packages/kernel/src/kernel-store.ts b/packages/kernel/src/kernel-store.ts new file mode 100644 index 000000000..af4da6b64 --- /dev/null +++ b/packages/kernel/src/kernel-store.ts @@ -0,0 +1,4 @@ +export type KernelStore = { + kvGet: (key: string) => string; + kvSet: (key: string, value: string) => void; +}; diff --git a/packages/kernel/test/storage.ts b/packages/kernel/test/storage.ts new file mode 100644 index 000000000..46c38e5da --- /dev/null +++ b/packages/kernel/test/storage.ts @@ -0,0 +1,20 @@ +import type { KernelStore } from '../src/kernel-store.js'; + +/** + * A mock kernel store realized as a Map. + * + * @returns The mock {@link KernelStore}. + */ +export function makeMapKernelStore(): KernelStore { + const map = new Map(); + return { + kvGet: (key) => { + const value = map.get(key); + if (value === undefined) { + throw new Error(`No value found for key ${key}.`); + } + return value; + }, + kvSet: map.set.bind(map), + }; +} diff --git a/packages/kernel/tsconfig.json b/packages/kernel/tsconfig.json index 2e5a27d22..be7af40bd 100644 --- a/packages/kernel/tsconfig.json +++ b/packages/kernel/tsconfig.json @@ -4,5 +4,5 @@ "baseUrl": "./" }, "references": [{ "path": "../streams" }, { "path": "../utils" }], - "include": ["./src"] + "include": ["./src", "./test"] } From cb2391c4f661f4779588763191bf2a226a57ba2f Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 9 Oct 2024 21:24:10 -0500 Subject: [PATCH 8/8] refactor(extension,kernel): Move kernel message handling to Kernel. --- packages/extension/src/kernel-worker.ts | 179 ++---------------------- packages/kernel/src/Kernel.test.ts | 47 ++++--- packages/kernel/src/Kernel.ts | 150 +++++++++++++++++++- 3 files changed, 187 insertions(+), 189 deletions(-) diff --git a/packages/extension/src/kernel-worker.ts b/packages/extension/src/kernel-worker.ts index 94f1000e2..660dc941b 100644 --- a/packages/extension/src/kernel-worker.ts +++ b/packages/extension/src/kernel-worker.ts @@ -1,27 +1,19 @@ import './kernel-worker-trusted-prelude.js'; import type { KernelCommand, KernelCommandReply, VatId } from '@ocap/kernel'; -import { isKernelCommand, Kernel, KernelCommandMethod } from '@ocap/kernel'; +import { Kernel } from '@ocap/kernel'; import { PostMessageDuplexStream } from '@ocap/streams'; -import { makeLogger, stringify } from '@ocap/utils'; import { makeKernelStore } from './sqlite-kernel-store.js'; import { ExtensionVatWorkerClient } from './VatWorkerClient.js'; -type MainArgs = { defaultVatId: VatId }; - -const logger = makeLogger('[kernel worker]'); - -main({ defaultVatId: 'v0' }).catch(console.error); +main('v0').catch(console.error); /** - * The main function for the offscreen script. + * The main function for the kernel worker. * - * @param options - The options bag. - * @param options.defaultVatId - The id to give the default vat. + * @param defaultVatId - The id to give the default vat. */ -async function main({ defaultVatId }: MainArgs): Promise { - const start = performance.now(); - +async function main(defaultVatId: VatId): Promise { const kernelStream = new PostMessageDuplexStream< KernelCommand, KernelCommandReply @@ -35,166 +27,15 @@ async function main({ defaultVatId }: MainArgs): Promise { const vatWorkerClient = new ExtensionVatWorkerClient( (message: unknown) => globalThis.postMessage(message), - (listener) => { - globalThis.onmessage = listener; - }, + (listener) => globalThis.addEventListener('message', listener), ); - // Initialize kernel store. + // Initialize kernel store. (to be initialized in kernel.init in the future) const kernelStore = await makeKernelStore(); - // Create kernel. - - const kernel = new Kernel(vatWorkerClient, kernelStore); - const iframeReadyP = kernel.launchVat({ id: defaultVatId }); - - await reply({ - method: KernelCommandMethod.InitKernel, - params: { defaultVat: defaultVatId, initTime: performance.now() - start }, - }); - - // Handle messages from the console service worker - for await (const message of kernelStream) { - if (isKernelCommand(message)) { - await handleKernelCommand(message); - } else { - logger.debug(`Received unexpected message ${stringify(message)}`); - } - } - - /** - * Handle a KernelCommand sent from the offscreen. - * - * @param command - The KernelCommand to handle. - * @param command.method - The command method. - * @param command.params - The command params. - */ - async function handleKernelCommand({ - method, - params, - }: KernelCommand): Promise { - switch (method) { - case KernelCommandMethod.InitKernel: - throw new Error('The kernel starts itself.'); - case KernelCommandMethod.Ping: - await reply({ method, params: 'pong' }); - break; - case KernelCommandMethod.Evaluate: - await handleVatTestCommand({ method, params }); - break; - case KernelCommandMethod.CapTpCall: - await handleVatTestCommand({ method, params }); - break; - case KernelCommandMethod.KVSet: - kernel.kvSet(params.key, params.value); - await reply({ - method, - params: `~~~ set "${params.key}" to "${params.value}" ~~~`, - }); - break; - case KernelCommandMethod.KVGet: { - try { - const result = kernel.kvGet(params); - await reply({ - method, - params: result, - }); - } catch (problem) { - // TODO: marshal - await reply({ - method, - params: String(asError(problem)), - }); - } - break; - } - default: - console.error( - 'kernel worker received unexpected command', - // @ts-expect-error Runtime does not respect "never". - { method: method.valueOf(), params }, - ); - } - } - - /** - * Handle a command implemented by the test vat. - * - * @param command - The command to handle. - */ - async function handleVatTestCommand( - command: Extract< - KernelCommand, - | { method: typeof KernelCommandMethod.Evaluate } - | { method: typeof KernelCommandMethod.CapTpCall } - >, - ): Promise { - const { method, params } = command; - const vat = await iframeReadyP; - switch (method) { - case KernelCommandMethod.Evaluate: - await reply({ - method, - params: await evaluate(vat.id, params), - }); - break; - case KernelCommandMethod.CapTpCall: - await reply({ - method, - params: stringify(await vat.callCapTp(params)), - }); - break; - default: - console.error( - 'Offscreen received unexpected vat command', - // @ts-expect-error Runtime does not respect "never". - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - { method: method.valueOf(), params }, - ); - } - } - - /** - * Reply to the background script. - * - * @param payload - The payload to reply with. - */ - async function reply(payload: KernelCommandReply): Promise { - await kernelStream.write(payload); - } - - /** - * Evaluate a string in the default iframe. - * - * @param vatId - The ID of the vat to send the message to. - * @param source - The source string to evaluate. - * @returns The result of the evaluation, or an error message. - */ - async function evaluate(vatId: VatId, source: string): Promise { - try { - const result = await kernel.sendMessage(vatId, { - method: KernelCommandMethod.Evaluate, - params: source, - }); - return String(result); - } catch (error) { - if (error instanceof Error) { - return `Error: ${error.message}`; - } - return `Error: Unknown error during evaluation.`; - } - } + // Create and start kernel. - /** - * Coerce an unknown problem into an Error object. - * - * @param problem - Whatever was caught. - * @returns The problem if it is an Error, or a new Error with the problem as the cause. - */ - function asError(problem: unknown): Error { - return problem instanceof Error - ? problem - : new Error('Unknown', { cause: problem }); - } + const kernel = new Kernel(kernelStream, vatWorkerClient, kernelStore); + await kernel.init({ defaultVatId }); } diff --git a/packages/kernel/src/Kernel.test.ts b/packages/kernel/src/Kernel.test.ts index 8ad841405..2fc687544 100644 --- a/packages/kernel/src/Kernel.test.ts +++ b/packages/kernel/src/Kernel.test.ts @@ -1,18 +1,24 @@ -import type { DuplexStream } from '@ocap/streams'; +import '@ocap/shims/endoify'; + +import { MessagePortDuplexStream, type DuplexStream } from '@ocap/streams'; import type { MockInstance } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { KernelStore } from './kernel-store.js'; import { Kernel } from './Kernel.js'; -import type { VatCommand } from './messages.js'; +import type { + KernelCommand, + KernelCommandReply, + VatCommand, +} from './messages.js'; import type { StreamEnvelope, StreamEnvelopeReply } from './stream-envelope.js'; -import type { VatId } from './types.js'; +import type { VatId, VatWorkerService } from './types.js'; import { Vat } from './Vat.js'; -import type { VatWorkerClient } from './VatWorkerClient.js'; import { makeMapKernelStore } from '../test/storage.js'; describe('Kernel', () => { - let mockWorkerService: VatWorkerClient; + let mockStream: DuplexStream; + let mockWorkerService: VatWorkerService; let mockGetWorkerStreams: MockInstance; let mockDeleteWorker: MockInstance; let initMock: MockInstance; @@ -21,10 +27,15 @@ describe('Kernel', () => { let mockKernelStore: KernelStore; beforeEach(() => { + const consoleConnection = new MessageChannel(); + mockStream = new MessagePortDuplexStream( + consoleConnection.port1, + ); + mockWorkerService = { initWorker: async () => ({}), deleteWorker: async () => undefined, - } as unknown as VatWorkerClient; + } as unknown as VatWorkerService; mockGetWorkerStreams = vi .spyOn(mockWorkerService, 'initWorker') @@ -45,18 +56,18 @@ describe('Kernel', () => { describe('getVatIds()', () => { it('returns an empty array when no vats are added', () => { - const kernel = new Kernel(mockWorkerService, mockKernelStore); + const kernel = new Kernel(mockStream, mockWorkerService, mockKernelStore); expect(kernel.getVatIds()).toStrictEqual([]); }); it('returns the vat IDs after adding a vat', async () => { - const kernel = new Kernel(mockWorkerService, mockKernelStore); + const kernel = new Kernel(mockStream, mockWorkerService, mockKernelStore); await kernel.launchVat({ id: 'v0' }); expect(kernel.getVatIds()).toStrictEqual(['v0']); }); it('returns multiple vat IDs after adding multiple vats', async () => { - const kernel = new Kernel(mockWorkerService, mockKernelStore); + const kernel = new Kernel(mockStream, mockWorkerService, mockKernelStore); await kernel.launchVat({ id: 'v0' }); await kernel.launchVat({ id: 'v1' }); expect(kernel.getVatIds()).toStrictEqual(['v0', 'v1']); @@ -65,7 +76,7 @@ describe('Kernel', () => { describe('launchVat()', () => { it('adds a vat to the kernel without errors when no vat with the same ID exists', async () => { - const kernel = new Kernel(mockWorkerService, mockKernelStore); + const kernel = new Kernel(mockStream, mockWorkerService, mockKernelStore); await kernel.launchVat({ id: 'v0' }); expect(initMock).toHaveBeenCalledOnce(); expect(mockGetWorkerStreams).toHaveBeenCalled(); @@ -73,7 +84,7 @@ describe('Kernel', () => { }); it('throws an error when launching a vat that already exists in the kernel', async () => { - const kernel = new Kernel(mockWorkerService, mockKernelStore); + const kernel = new Kernel(mockStream, mockWorkerService, mockKernelStore); await kernel.launchVat({ id: 'v0' }); expect(kernel.getVatIds()).toStrictEqual(['v0']); await expect( @@ -87,7 +98,7 @@ describe('Kernel', () => { describe('deleteVat()', () => { it('deletes a vat from the kernel without errors when the vat exists', async () => { - const kernel = new Kernel(mockWorkerService, mockKernelStore); + const kernel = new Kernel(mockStream, mockWorkerService, mockKernelStore); await kernel.launchVat({ id: 'v0' }); expect(kernel.getVatIds()).toStrictEqual(['v0']); await kernel.deleteVat('v0'); @@ -97,7 +108,7 @@ describe('Kernel', () => { }); it('throws an error when deleting a vat that does not exist in the kernel', async () => { - const kernel = new Kernel(mockWorkerService, mockKernelStore); + const kernel = new Kernel(mockStream, mockWorkerService, mockKernelStore); const nonExistentVatId: VatId = 'v9'; await expect(async () => kernel.deleteVat(nonExistentVatId), @@ -106,7 +117,7 @@ describe('Kernel', () => { }); it('throws an error when a vat terminate method throws', async () => { - const kernel = new Kernel(mockWorkerService, mockKernelStore); + const kernel = new Kernel(mockStream, mockWorkerService, mockKernelStore); await kernel.launchVat({ id: 'v0' }); vi.spyOn(Vat.prototype, 'terminate').mockRejectedValueOnce('Test error'); await expect(async () => kernel.deleteVat('v0')).rejects.toThrow( @@ -117,7 +128,7 @@ describe('Kernel', () => { describe('sendMessage()', () => { it('sends a message to the vat without errors when the vat exists', async () => { - const kernel = new Kernel(mockWorkerService, mockKernelStore); + const kernel = new Kernel(mockStream, mockWorkerService, mockKernelStore); await kernel.launchVat({ id: 'v0' }); vi.spyOn(Vat.prototype, 'sendMessage').mockResolvedValueOnce('test'); expect( @@ -129,7 +140,7 @@ describe('Kernel', () => { }); it('throws an error when sending a message to the vat that does not exist in the kernel', async () => { - const kernel = new Kernel(mockWorkerService, mockKernelStore); + const kernel = new Kernel(mockStream, mockWorkerService, mockKernelStore); const nonExistentVatId: VatId = 'v9'; await expect(async () => kernel.sendMessage(nonExistentVatId, {} as VatCommand['payload']), @@ -137,7 +148,7 @@ describe('Kernel', () => { }); it('throws an error when sending a message to the vat throws', async () => { - const kernel = new Kernel(mockWorkerService, mockKernelStore); + const kernel = new Kernel(mockStream, mockWorkerService, mockKernelStore); await kernel.launchVat({ id: 'v0' }); vi.spyOn(Vat.prototype, 'sendMessage').mockRejectedValueOnce('error'); await expect(async () => @@ -149,7 +160,7 @@ describe('Kernel', () => { describe('constructor()', () => { it('initializes the kernel without errors', () => { expect( - async () => new Kernel(mockWorkerService, mockKernelStore), + async () => new Kernel(mockStream, mockWorkerService, mockKernelStore), ).not.toThrow(); }); }); diff --git a/packages/kernel/src/Kernel.ts b/packages/kernel/src/Kernel.ts index 5d499bd09..d50cdf634 100644 --- a/packages/kernel/src/Kernel.ts +++ b/packages/kernel/src/Kernel.ts @@ -1,20 +1,154 @@ import '@ocap/shims/endoify'; +import type { PromiseKit } from '@endo/promise-kit'; +import { makePromiseKit } from '@endo/promise-kit'; +import type { DuplexStream } from '@ocap/streams'; +import type { Logger } from '@ocap/utils'; +import { makeLogger, stringify } from '@ocap/utils'; + import type { KernelStore } from './kernel-store.js'; -import type { VatCommand } from './messages.js'; +import { + isKernelCommand, + KernelCommandMethod, + VatCommandMethod, + type KernelCommand, + type KernelCommandReply, + type VatCommand, +} from './messages.js'; import type { VatId, VatWorkerService } from './types.js'; import { Vat } from './Vat.js'; export class Kernel { + readonly #stream: DuplexStream; + readonly #vats: Map; readonly #vatWorkerService: VatWorkerService; readonly #storage: KernelStore; - constructor(vatWorkerService: VatWorkerService, storage: KernelStore) { + // Hopefully removed when we get to n+1 vats. + readonly #defaultVat: PromiseKit; + + readonly #logger: Logger; + + constructor( + stream: DuplexStream, + vatWorkerService: VatWorkerService, + storage: KernelStore, + logger?: Logger, + ) { + this.#stream = stream; this.#vats = new Map(); this.#vatWorkerService = vatWorkerService; this.#storage = storage; + this.#defaultVat = makePromiseKit(); + this.#logger = logger ?? makeLogger('[ocap kernel]'); + } + + async init({ defaultVatId }: { defaultVatId: VatId }): Promise { + const start = performance.now(); + + await this.launchVat({ id: defaultVatId }) + .then(this.#defaultVat.resolve) + .catch(this.#defaultVat.reject); + + await this.#stream.write({ + method: KernelCommandMethod.InitKernel, + params: { defaultVat: defaultVatId, initTime: performance.now() - start }, + }); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#receiveMessages().catch(this.#logger.error); + } + + async #receiveMessages(): Promise { + for await (const message of this.#stream) { + if (!isKernelCommand(message)) { + this.#logger.debug('Received unexpected message', message); + continue; + } + + const { method, params } = message; + + let vat: Vat; + + switch (method) { + case KernelCommandMethod.InitKernel: + throw new Error('The kernel initializes itself.'); + case KernelCommandMethod.Ping: + await this.#reply({ method, params: 'pong' }); + break; + case KernelCommandMethod.Evaluate: + vat = await this.#defaultVat.promise; + await this.#reply({ + method, + params: await this.evaluate(vat.id, params), + }); + break; + case KernelCommandMethod.CapTpCall: + vat = await this.#defaultVat.promise; + await this.#reply({ + method, + params: stringify(await vat.callCapTp(params)), + }); + break; + case KernelCommandMethod.KVSet: + this.kvSet(params.key, params.value); + await this.#reply({ + method, + params: `~~~ set "${params.key}" to "${params.value}" ~~~`, + }); + break; + case KernelCommandMethod.KVGet: { + try { + const result = this.kvGet(params); + await this.#reply({ + method, + params: result, + }); + } catch (problem) { + // TODO: marshal + await this.#reply({ + method, + params: String(asError(problem)), + }); + } + break; + } + default: + console.error( + 'kernel worker received unexpected command', + // @ts-expect-error Runtime does not respect "never". + { method: method.valueOf(), params }, + ); + } + } + } + + async #reply(message: KernelCommandReply): Promise { + await this.#stream.write(message); + } + + /** + * Evaluate a string in the default iframe. + * + * @param vatId - The ID of the vat to send the message to. + * @param source - The source string to evaluate. + * @returns The result of the evaluation, or an error message. + */ + async evaluate(vatId: VatId, source: string): Promise { + try { + const result = await this.sendMessage(vatId, { + method: VatCommandMethod.Evaluate, + params: source, + }); + return String(result); + } catch (error) { + if (error instanceof Error) { + return `Error: ${error.message}`; + } + return `Error: Unknown error during evaluation.`; + } } kvGet(key: string): string { @@ -94,3 +228,15 @@ export class Kernel { } } harden(Kernel); + +/** + * Coerce an unknown problem into an Error object. + * + * @param problem - Whatever was caught. + * @returns The problem if it is an Error, or a new Error with the problem as the cause. + */ +function asError(problem: unknown): Error { + return problem instanceof Error + ? problem + : new Error('Unknown', { cause: problem }); +}