diff --git a/eslint.config.mjs b/eslint.config.mjs index b79f8dc1a..cbe6efdab 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,6 +18,7 @@ const config = [ ...options, files: ['**/*.test.{ts,js}'], })), + { ignores: [ 'yarn.config.cjs', @@ -29,6 +30,7 @@ const config = [ '**/coverage', ], }, + { languageOptions: { parserOptions: { @@ -47,15 +49,15 @@ const config = [ }, ], - // This prevents using Node.js and/or browser specific globals. We - // currently use both in our codebase, so this rule is disabled. - 'no-restricted-globals': 'off', - 'import-x/extensions': 'off', 'import-x/no-unassigned-import': 'off', // This prevents pretty formatting of comments with multi-line lists entries. 'jsdoc/check-indentation': 'off', + + // This prevents using Node.js and/or browser specific globals. We + // currently use both in our codebase, so this rule is disabled. + 'no-restricted-globals': 'off', }, }, diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index b11b2991a..bfdbf3250 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -81,6 +81,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/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..ccecff356 100644 --- a/packages/extension/src/kernel-worker.ts +++ b/packages/extension/src/kernel-worker.ts @@ -1,103 +1,18 @@ import './kernel-worker-trusted-prelude.js'; -import type { KernelCommand, KernelCommandReply } from '@ocap/kernel'; -import { isKernelCommand, KernelCommandMethod } from '@ocap/kernel'; -import { PostMessageDuplexStream } from '@ocap/streams'; +import type { KernelCommand, KernelCommandReply, VatId } from '@ocap/kernel'; +import { isKernelCommand, Kernel, KernelCommandMethod } from '@ocap/kernel'; +import { PostMessageDuplexStream, receiveMessagePort } 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]'); -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; -}; +main({ defaultVatId: 'v0' }).catch(console.error); /** * Ensure that SQLite is initialized. @@ -115,8 +30,27 @@ 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 { + // Note we must setup the worker MessageChannel before initializing the stream, + // because the stream will close if it receives an unrecognized message. + const clientPort = await receiveMessagePort( + (listener) => globalThis.addEventListener('message', listener), + (listener) => globalThis.removeEventListener('message', listener), + ); + + const vatWorkerClient = new ExtensionVatWorkerClient( + (message) => clientPort.postMessage(message), + (listener) => { + clientPort.onmessage = listener; + }, + ); + + const startTime = performance.now(); + const kernelStream = new PostMessageDuplexStream< KernelCommand, KernelCommandReply @@ -126,29 +60,51 @@ async function main(): Promise { (listener) => globalThis.removeEventListener('message', listener), ); + // Initialize kernel store. + const { sqlKVGet, sqlKVSet } = await initDb(); + // Create kernel. + + const kernel = new Kernel(vatWorkerClient); + const vatReadyP = kernel.launchVat({ id: defaultVatId }); + + await reply({ + method: KernelCommandMethod.InitKernel, + params: { + defaultVat: defaultVatId, + initTime: performance.now() - startTime, + }, + }); + // Handle messages from the console service worker - for await (const message of kernelStream) { - if (isKernelWorkerCommand(message)) { - await handleKernelCommand(message); - } else { - console.error('Received unexpected message', message); - } - } + await kernelStream.drain(handleKernelCommand); /** * 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, - }: KernelWorkerCommand): Promise { + async function handleKernelCommand(command: KernelCommand): Promise { + if (!isKernelCommand(command)) { + logger.error('Received unexpected message', command); + return; + } + + const { method, params } = command; + 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({ @@ -181,6 +137,42 @@ 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 vatReadyP; + 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". + { method: method.valueOf(), params }, + ); + } + } + /** * Reply to the background script. * @@ -190,6 +182,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 7c18dfa59..8210b89c5 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -1,23 +1,24 @@ +import { makePromiseKit } from '@endo/promise-kit'; import { - Kernel, - KernelCommandMethod, isKernelCommand, isKernelCommandReply, + KernelCommandMethod, } from '@ocap/kernel'; -import type { KernelCommandReply, KernelCommand, VatId } from '@ocap/kernel'; +import type { KernelCommandReply, KernelCommand } from '@ocap/kernel'; import { ChromeRuntimeTarget, initializeMessageChannel, 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'; -main().catch(console.error); +const logger = makeLogger('[ocap glue]'); + +main().catch((error) => logger.error(error)); /** * The main function for the offscreen script. @@ -29,38 +30,8 @@ async function main(): Promise { ChromeRuntimeTarget.Background, ); - 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: 'v0' }); - - // Setup glue. + const kernelWorker = await makeKernelWorker(); + const kernelInitKit = makePromiseKit(); /** * Reply to a command from the background script. @@ -75,119 +46,48 @@ async function main(): Promise { // Handle messages from the background service worker and the kernel SQLite worker. await Promise.all([ - (async () => { + kernelWorker.receiveMessages(), + kernelInitKit.promise.then(async () => { for await (const message of backgroundStream) { if (!isKernelCommand(message)) { - console.error('Offscreen received unexpected message', message); + logger.error('Offscreen received unexpected message', message); continue; } - await handleKernelCommand(message); + await kernelWorker.sendMessage(message); } - })(), - kernelWorker.receiveMessages(), + return undefined; + }), ]); - /** - * 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.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". - { 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. * * @returns An object with methods to send and receive messages from the kernel worker. */ - function makeKernelWorker(): { + async function makeKernelWorker(): Promise<{ sendMessage: (message: KernelCommand) => Promise; receiveMessages: () => Promise; - } { + }> { const worker = new Worker('kernel-worker.js', { type: 'module' }); + + // Note we must setup the worker MessageChannel before initializing the stream, + // because the stream will close if it receives an unrecognized message. + const workerPort = await initializeMessageChannel((message, transfer) => + worker.postMessage(message, transfer), + ); + + const vatWorkerServer = new ExtensionVatWorkerServer( + (message, transfer?) => + transfer + ? workerPort.postMessage(message, transfer) + : workerPort.postMessage(message), + (listener) => workerPort.addEventListener('message', listener), + (vatId) => makeIframeVatWorker(vatId, initializeMessageChannel), + ); + + vatWorkerServer.start(); + const workerStream = new PostMessageDuplexStream< KernelCommandReply, KernelCommand @@ -201,37 +101,18 @@ 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; - } - 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 { - result = params; + logger.error('Kernel received unexpected message', message); + continue; } - const reply = { method, params: result ?? null }; - if (!isKernelCommandReply(reply)) { - // Internal error. - console.error('Malformed command reply', reply); - return; + + if (message.method === KernelCommandMethod.InitKernel) { + logger.info('Kernel initialized.'); + kernelInitKit.resolve(); } - await replyToBackground(reply); + await replyToBackground(message); } }; 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); 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, };