diff --git a/packages/kernel-test/src/service.test.ts b/packages/kernel-test/src/service.test.ts new file mode 100644 index 000000000..393f6ea07 --- /dev/null +++ b/packages/kernel-test/src/service.test.ts @@ -0,0 +1,92 @@ +import { Far } from '@endo/marshal'; +import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; +import { waitUntilQuiescent } from '@metamask/kernel-utils'; +import { Kernel, krefOf } from '@metamask/ocap-kernel'; +import type { SlotValue } from '@metamask/ocap-kernel'; +import { describe, expect, it } from 'vitest'; + +import { + getBundleSpec, + makeKernel, + makeTestLogger, + runTestVats, + extractTestLogs, +} from './utils.ts'; + +const testSubcluster = { + bootstrap: 'main', + forceReset: true, + services: ['testService'], + vats: { + main: { + bundleSpec: getBundleSpec('service-vat'), + parameters: { + name: 'main', + }, + }, + }, +}; + +describe('Kernel service object invocation', () => { + let kernel: Kernel; + + const testService = Far('serviceObject', { + async getStuff(obj: SlotValue, tag: string): Promise { + return `${tag} -- ${krefOf(obj)}`; + }, + }); + + it('can invoke a kernel service and get an answer', async () => { + const kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + const { logger, entries } = makeTestLogger(); + kernel = await makeKernel(kernelDatabase, true, logger); + kernel.registerKernelServiceObject('testService', testService); + + await runTestVats(kernel, testSubcluster); + + // ko1 ::= the (test) service object + // ko2 ::= test vat root object + // ko3 ::= internal object generated inside test vat to have its kref extracted + + await kernel.queueMessage('ko2', 'go', []); + await waitUntilQuiescent(100); + const testLogs = extractTestLogs(entries); + expect(testLogs).toStrictEqual(['kernel service returns hello -- ko3']); + }); + + it('configure subcluster with unknown service throws', async () => { + const kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + const { logger } = makeTestLogger(); + kernel = await makeKernel(kernelDatabase, true, logger); + + await expect(runTestVats(kernel, testSubcluster)).rejects.toThrow( + `no registered kernel service 'testService'`, + ); + }); + + it('invoking unknown service method throws', async () => { + const kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + const { logger, entries } = makeTestLogger(); + kernel = await makeKernel(kernelDatabase, true, logger); + kernel.registerKernelServiceObject('testService', testService); + + await runTestVats(kernel, testSubcluster); + + // ko1 ::= the (test) service object + // ko2 ::= test vat root object + // ko3 ::= internal object generated inside test vat to have its kref extracted + + await kernel.queueMessage('ko2', 'goBadly', []); + await waitUntilQuiescent(100); + const testLogs = extractTestLogs(entries); + expect(testLogs).toStrictEqual([ + `kernel service threw: unknown service method 'nonexistentMethod'`, + ]); + }); +}); diff --git a/packages/kernel-test/src/vats/service-vat.js b/packages/kernel-test/src/vats/service-vat.js new file mode 100644 index 000000000..00fa93901 --- /dev/null +++ b/packages/kernel-test/src/vats/service-vat.js @@ -0,0 +1,47 @@ +import { E } from '@endo/eventual-send'; +import { Far } from '@endo/marshal'; + +/** + * Build function for running a test of kernel service objects. + * + * @param {unknown} vatPowers - Special powers granted to this vat. + * @param {unknown} parameters - Initialization parameters from the vat's config object. + * @returns {unknown} The root object for the new vat. + */ +export function buildRootObject(vatPowers, parameters) { + const name = parameters?.name ?? 'anonymous'; + const logger = vatPowers.logger.subLogger({ tags: ['test', name] }); + const tlog = (...args) => logger.log(...args); + console.log(`buildRootObject "${name}"`); + + const thing = Far('thing', {}); + let testService; + + const mainVatRoot = Far('root', { + async bootstrap(_vats, services) { + console.log(`vat ${name} is bootstrap`); + testService = services.testService; + }, + async go() { + const serviceResult = await E(testService).getStuff(thing, 'hello'); + tlog(`kernel service returns ${serviceResult}`); + await E(mainVatRoot).loopback(); + }, + async goBadly() { + try { + const serviceResult = await E(testService).nonexistentMethod( + thing, + 'hello', + ); + tlog(`kernel service returns ${serviceResult} and it shouldn't have`); + } catch (problem) { + tlog(`kernel service threw: ${problem.message}`); + } + await E(mainVatRoot).loopback(); + }, + loopback() { + return undefined; + }, + }); + return mainVatRoot; +} diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index 5d1582d9b..8bae451bf 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -20,7 +20,7 @@ import { KernelQueue } from './KernelQueue.ts'; import { KernelRouter } from './KernelRouter.ts'; import { kernelHandlers } from './rpc/index.ts'; import type { PingVatResult } from './rpc/index.ts'; -import { kslot } from './services/kernel-marshal.ts'; +import { kslot, kser, kunser } from './services/kernel-marshal.ts'; import type { SlotValue } from './services/kernel-marshal.ts'; import { makeKernelStore } from './store/index.ts'; import type { KernelStore } from './store/index.ts'; @@ -32,11 +32,18 @@ import type { VatConfig, KernelStatus, Subcluster, + Message, } from './types.ts'; import { ROOT_OBJECT_VREF, isClusterConfig } from './types.ts'; -import { Fail } from './utils/assert.ts'; +import { Fail, assert } from './utils/assert.ts'; import { VatHandle } from './VatHandle.ts'; +type KernelService = { + name: string; + kref: string; + service: object; +}; + export class Kernel { /** Command channel from the controlling console/browser extension/test driver */ readonly #commandStream: DuplexStream; @@ -61,6 +68,11 @@ export class Kernel { /** The kernel's router */ readonly #kernelRouter: KernelRouter; + /** Objects providing custom or kernel-privileged services to vats. */ + readonly #kernelServicesByName: Map = new Map(); + + readonly #kernelServicesByObject: Map = new Map(); + /** * Construct a new kernel instance. * @@ -98,6 +110,7 @@ export class Kernel { this.#kernelStore, this.#kernelQueue, this.#getVat.bind(this), + this.#invokeKernelService.bind(this), ); harden(this); } @@ -370,9 +383,21 @@ export class Kernel { rootIds[vatName] = rootRef; roots[vatName] = kslot(rootRef, 'vatRoot'); } + const services: Record = {}; + if (config.services) { + for (const name of config.services) { + const possibleService = this.#kernelServicesByName.get(name); + if (possibleService) { + const { kref } = possibleService; + services[name] = kslot(kref); + } else { + throw Error(`no registered kernel service '${name}'`); + } + } + } const bootstrapRoot = rootIds[config.bootstrap]; if (bootstrapRoot) { - return this.queueMessage(bootstrapRoot, 'bootstrap', [roots]); + return this.queueMessage(bootstrapRoot, 'bootstrap', [roots, services]); } return undefined; } @@ -632,5 +657,57 @@ export class Kernel { } this.#kernelStore.collectGarbage(); } + + registerKernelServiceObject(name: string, service: object): void { + const kref = this.#kernelStore.initKernelObject('kernel'); + const kernelService = { name, kref, service }; + this.#kernelServicesByName.set(name, kernelService); + this.#kernelServicesByObject.set(kref, kernelService); + } + + async #invokeKernelService(target: KRef, message: Message): Promise { + const kernelService = this.#kernelServicesByObject.get(target); + if (!kernelService) { + throw Error(`no registered service for ${target}`); + } + const { methargs, result } = message; + const [method, args] = kunser(methargs) as [string, unknown[]]; + assert.typeof(method, 'string'); + if (result) { + assert.typeof(result, 'string'); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + const service = kernelService.service as Record; + const methodFunction = service[method]; + if (methodFunction === undefined) { + if (result) { + this.#kernelQueue.resolvePromises('kernel', [ + [result, true, kser(Error(`unknown service method '${method}'`))], + ]); + } else { + this.#logger.error(`unknown service method '${method}'`); + } + return; + } + assert.typeof(methodFunction, 'function'); + assert(Array.isArray(args)); + try { + // eslint-disable-next-line prefer-spread + const resultValue = await methodFunction.apply(null, args); + if (result) { + this.#kernelQueue.resolvePromises('kernel', [ + [result, false, kser(resultValue)], + ]); + } + } catch (problem) { + if (result) { + this.#kernelQueue.resolvePromises('kernel', [ + [result, true, kser(problem)], + ]); + } else { + this.#logger.error('error in kernel service method:', problem); + } + } + } } harden(Kernel); diff --git a/packages/ocap-kernel/src/KernelQueue.ts b/packages/ocap-kernel/src/KernelQueue.ts index 280c2a4d6..45c4f914e 100644 --- a/packages/ocap-kernel/src/KernelQueue.ts +++ b/packages/ocap-kernel/src/KernelQueue.ts @@ -208,7 +208,7 @@ export class KernelQueue { vatId: VatId | undefined, resolutions: VatOneResolution[], ): void { - if (vatId) { + if (vatId && vatId !== 'kernel') { insistVatId(vatId); } for (const resolution of resolutions) { diff --git a/packages/ocap-kernel/src/KernelRouter.ts b/packages/ocap-kernel/src/KernelRouter.ts index 2d250d0c3..3b5895f48 100644 --- a/packages/ocap-kernel/src/KernelRouter.ts +++ b/packages/ocap-kernel/src/KernelRouter.ts @@ -10,6 +10,7 @@ import { isPromiseRef } from './store/utils/promise-ref.ts'; import type { VatId, KRef, + Message, RunQueueItem, RunQueueItemSend, RunQueueItemBringOutYourDead, @@ -42,21 +43,30 @@ export class KernelRouter { /** A function that returns a vat handle for a given vat id. */ readonly #getVat: (vatId: VatId) => VatHandle; + /** A function that invokes a method on a kernel service. */ + readonly #invokeKernelService: ( + target: KRef, + message: Message, + ) => Promise; + /** * Construct a new KernelRouter. * * @param kernelStore - The kernel's store. * @param kernelQueue - The kernel's queue. * @param getVat - A function that returns a vat handle for a given vat id. + * @param invokeKernelService - A function that calls a method on a kernel service object. */ constructor( kernelStore: KernelStore, kernelQueue: KernelQueue, getVat: (vatId: VatId) => VatHandle, + invokeKernelService: (target: KRef, message: Message) => Promise, ) { this.#kernelStore = kernelStore; this.#kernelQueue = kernelQueue; this.#getVat = getVat; + this.#invokeKernelService = invokeKernelService; } /** @@ -201,8 +211,9 @@ export class KernelRouter { `@@@@ deliver ${vatId} send ${target}<-${JSON.stringify(message)}`, ); if (vatId) { - const vat = this.#getVat(vatId); - if (vat) { + const isKernelServiceMessage = vatId === 'kernel'; + const vat = isKernelServiceMessage ? null : this.#getVat(vatId); + if (vat || isKernelServiceMessage) { if (message.result) { if (typeof message.result !== 'string') { throw TypeError('message result must be a string'); @@ -213,6 +224,8 @@ export class KernelRouter { 'deliver|send|result', ); } + } + if (vat) { const vatTarget = this.#kernelStore.translateRefKtoV( vatId, target, @@ -223,13 +236,15 @@ export class KernelRouter { message, ); crankResults = await vat.deliverMessage(vatTarget, vatMessage); - this.#kernelStore.decrementRefCount(target, 'deliver|send|target'); - for (const slot of message.methargs.slots) { - this.#kernelStore.decrementRefCount(slot, 'deliver|send|slot'); - } + } else if (isKernelServiceMessage) { + crankResults = await this.#deliverKernelServiceMessage(target, message); } else { Fail`no owner for kernel object ${target}`; } + this.#kernelStore.decrementRefCount(target, 'deliver|send|target'); + for (const slot of message.methargs.slots) { + this.#kernelStore.decrementRefCount(slot, 'deliver|send|slot'); + } } else { this.#kernelStore.enqueuePromiseMessage(target, message); } @@ -240,6 +255,14 @@ export class KernelRouter { return crankResults; } + async #deliverKernelServiceMessage( + target: KRef, + message: Message, + ): Promise { + await this.#invokeKernelService(target, message); + return { didDelivery: 'kernel' }; + } + /** * Deliver a 'notify' run queue item. * diff --git a/packages/ocap-kernel/src/index.test.ts b/packages/ocap-kernel/src/index.test.ts index bea3424bf..18ddbade3 100644 --- a/packages/ocap-kernel/src/index.test.ts +++ b/packages/ocap-kernel/src/index.test.ts @@ -16,7 +16,9 @@ describe('index', () => { 'VatSupervisor', 'isVatConfig', 'isVatId', + 'krefOf', 'kser', + 'kslot', 'kunser', 'makeKernelStore', 'parseRef', diff --git a/packages/ocap-kernel/src/index.ts b/packages/ocap-kernel/src/index.ts index 03348c7a8..d28b220ad 100644 --- a/packages/ocap-kernel/src/index.ts +++ b/packages/ocap-kernel/src/index.ts @@ -22,7 +22,8 @@ export { KernelStatusStruct, SubclusterStruct, } from './types.ts'; -export { kunser, kser } from './services/kernel-marshal.ts'; +export { kunser, kser, kslot, krefOf } from './services/kernel-marshal.ts'; +export type { SlotValue } from './services/kernel-marshal.ts'; export { makeKernelStore } from './store/index.ts'; export type { KernelStore } from './store/index.ts'; export { parseRef } from './store/utils/parse-ref.ts'; diff --git a/packages/ocap-kernel/src/store/methods/promise.ts b/packages/ocap-kernel/src/store/methods/promise.ts index 56ea63e6a..3f232a3e5 100644 --- a/packages/ocap-kernel/src/store/methods/promise.ts +++ b/packages/ocap-kernel/src/store/methods/promise.ts @@ -136,7 +136,9 @@ export function getPromiseMethods(ctx: StoreContext) { * @param vatId - The vat which will become the decider. */ function setPromiseDecider(kpid: KRef, vatId: VatId): void { - insistVatId(vatId); + if (vatId !== 'kernel') { + insistVatId(vatId); + } if (kpid) { ctx.kv.set(`${kpid}.decider`, vatId); } diff --git a/packages/ocap-kernel/src/types.ts b/packages/ocap-kernel/src/types.ts index 55f28dbec..b3907ee59 100644 --- a/packages/ocap-kernel/src/types.ts +++ b/packages/ocap-kernel/src/types.ts @@ -321,6 +321,7 @@ export type VatConfigTable = Record; export const ClusterConfigStruct = object({ bootstrap: string(), forceReset: exactOptional(boolean()), + services: exactOptional(array(string())), vats: record(string(), VatConfigStruct), bundles: exactOptional(record(string(), VatConfigStruct)), }); diff --git a/vitest.config.ts b/vitest.config.ts index 2f1f6d21f..c8de1a558 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -140,10 +140,10 @@ export default defineConfig({ lines: 73.58, }, 'packages/ocap-kernel/**': { - statements: 92.52, - functions: 95.27, - branches: 82.82, - lines: 92.49, + statements: 92.43, + functions: 95.28, + branches: 82.5, + lines: 92.4, }, 'packages/streams/**': { statements: 100,