diff --git a/eslint.config.mjs b/eslint.config.mjs index 7e58b70f9..a083731a9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -183,6 +183,24 @@ const config = createConfig([ globals: { lockdown: 'readonly' }, }, }, + + { + files: [ + 'packages/nodejs/**/*-worker.ts', + 'packages/nodejs/test/workers/**/*', + ], + rules: { + // Node workers have reasonable cause to read from process.env + 'n/no-process-env': 'off', + }, + }, + { + files: ['packages/nodejs/test/workers/**/*'], + rules: { + // Test node worker files can resolve these imports, even if eslint cannot. + 'import-x/no-unresolved': 'off', + }, + }, ]); export default config; diff --git a/package.json b/package.json index 11130ce6f..453b67418 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "prepack": "./scripts/prepack.sh", "test": "vitest run", "test:clean": "yarn test --no-cache --coverage.clean", - "test:dev": "yarn test --coverage false ", + "test:dev": "yarn test --coverage false", + "test:e2e": "yarn workspaces foreach --all run test:e2e", "test:e2e:ci": "yarn workspaces foreach --all run test:e2e:ci", "test:verbose": "yarn test --reporter verbose", "test:watch": "vitest", diff --git a/packages/nodejs/package.json b/packages/nodejs/package.json index e020148f0..48d130ce1 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -34,7 +34,7 @@ "publish:preview": "yarn npm publish --tag preview", "test": "vitest run --config vitest.config.ts", "test:e2e": "vitest run --config vitest.config.e2e.ts", - "test:e2e:ci": "echo 'skipped tests' || ./scripts/test-e2e-ci.sh", + "test:e2e:ci": "./scripts/test-e2e-ci.sh", "test:clean": "yarn test --no-cache --coverage.clean", "test:dev": "yarn test --coverage false", "test:verbose": "yarn test --reporter verbose", @@ -47,6 +47,7 @@ "@metamask/eslint-config-nodejs": "^14.0.0", "@metamask/eslint-config-typescript": "^14.0.0", "@ocap/cli": "workspace:^", + "@ocap/test-utils": "workspace:^", "@ts-bridge/cli": "^0.6.2", "@ts-bridge/shims": "^0.1.1", "@types/better-sqlite3": "^7.6.12", @@ -77,7 +78,6 @@ }, "dependencies": { "@endo/promise-kit": "^1.1.6", - "@metamask/utils": "^11.0.1", "@ocap/kernel": "workspace:^", "@ocap/shims": "workspace:^", "@ocap/streams": "workspace:^", diff --git a/packages/nodejs/src/env/kernel-worker-trusted-prelude.js b/packages/nodejs/src/env/kernel-worker-trusted-prelude.js deleted file mode 100644 index dad2cb379..000000000 --- a/packages/nodejs/src/env/kernel-worker-trusted-prelude.js +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line import-x/no-unresolved -import './endoify.js'; diff --git a/packages/nodejs/src/kernel/VatWorkerService.test.ts b/packages/nodejs/src/kernel/VatWorkerService.test.ts index d665cecfb..e786b5855 100644 --- a/packages/nodejs/src/kernel/VatWorkerService.test.ts +++ b/packages/nodejs/src/kernel/VatWorkerService.test.ts @@ -1,12 +1,106 @@ import '@ocap/shims/endoify'; -import { describe, expect, it } from 'vitest'; +import type { VatId } from '@ocap/kernel'; +import { makeCounter } from '@ocap/utils'; +import { describe, expect, it, vi } from 'vitest'; import { NodejsVatWorkerService } from './VatWorkerService.js'; +const mocks = vi.hoisted(() => ({ + worker: { + once: (_: string, callback: () => unknown) => { + callback(); + }, + terminate: vi.fn(async () => undefined), + }, + stream: { + synchronize: vi.fn(async () => undefined).mockResolvedValue(undefined), + return: vi.fn(async () => ({})), + }, +})); + +vi.mock('@ocap/streams', () => ({ + NodeWorkerDuplexStream: vi.fn(() => mocks.stream), +})); + +vi.mock('node:worker_threads', () => ({ + Worker: vi.fn(() => mocks.worker), +})); + describe('NodejsVatWorkerService', () => { it('constructs an instance without any arguments', () => { - const instance = new NodejsVatWorkerService(); + const instance = new NodejsVatWorkerService({}); expect(instance).toBeInstanceOf(NodejsVatWorkerService); }); + + const workerFilePath = 'unused'; + const vatIdCounter = makeCounter(); + const getTestVatId = (): VatId => `v${vatIdCounter()}`; + + describe('launch', () => { + it('creates a NodeWorker and returns a NodeWorkerDuplexStream', async () => { + const service = new NodejsVatWorkerService({ + workerFilePath, + }); + const testVatId: VatId = getTestVatId(); + const stream = await service.launch(testVatId); + + expect(stream).toStrictEqual(mocks.stream); + }); + + it('rejects if synchronize fails', async () => { + const rejected = 'test-reject-value'; + mocks.stream.synchronize.mockRejectedValue(rejected); + const service = new NodejsVatWorkerService({ workerFilePath }); + const testVatId: VatId = getTestVatId(); + await expect(async () => await service.launch(testVatId)).rejects.toThrow( + rejected, + ); + }); + }); + + describe('terminate', () => { + it('terminates the target vat', async () => { + const service = new NodejsVatWorkerService({ + workerFilePath, + }); + const testVatId: VatId = getTestVatId(); + + await service.launch(testVatId); + expect(service.workers.has(testVatId)).toBe(true); + + await service.terminate(testVatId); + expect(service.workers.has(testVatId)).toBe(false); + }); + + it('throws when terminating an unknown vat', async () => { + const service = new NodejsVatWorkerService({ + workerFilePath, + }); + const testVatId: VatId = getTestVatId(); + + await expect( + async () => await service.terminate(testVatId), + ).rejects.toThrow(/No worker found/u); + }); + }); + + describe('terminateAll', () => { + it('terminates all vats', async () => { + const service = new NodejsVatWorkerService({ + workerFilePath, + }); + const vatIds: VatId[] = [getTestVatId(), getTestVatId(), getTestVatId()]; + + await Promise.all( + vatIds.map(async (vatId) => await service.launch(vatId)), + ); + + expect(Array.from(service.workers.values())).toHaveLength(vatIds.length); + + await service.terminateAll(); + + expect(Array.from(service.workers.values())).toHaveLength(0); + }); + }); }); diff --git a/packages/nodejs/src/kernel/VatWorkerService.ts b/packages/nodejs/src/kernel/VatWorkerService.ts index d77b50aec..6ddcecc7c 100644 --- a/packages/nodejs/src/kernel/VatWorkerService.ts +++ b/packages/nodejs/src/kernel/VatWorkerService.ts @@ -14,12 +14,16 @@ import { Worker as NodeWorker } from 'node:worker_threads'; // Worker file loads from the built dist directory, requires rebuild after change // Note: Worker runs in same process and may be subject to spectre-style attacks -const workerFileURL = new URL('../../dist/vat/vat-worker.mjs', import.meta.url) - .pathname; +const DEFAULT_WORKER_FILE = new URL( + '../../dist/vat/vat-worker.mjs', + import.meta.url, +).pathname; export class NodejsVatWorkerService implements VatWorkerService { readonly #logger: Logger; + readonly #workerFilePath: string; + workers = new Map< VatId, { worker: NodeWorker; stream: DuplexStream } @@ -29,24 +33,29 @@ export class NodejsVatWorkerService implements VatWorkerService { * The vat worker service, intended to be constructed in * the kernel worker. * - * @param logger - An optional {@link Logger}. Defaults to a new logger labeled '[vat worker client]'. + * @param args - A bag of optional arguments. + * @param args.workerFilePath - An optional path to a file defining the worker's routine. Defaults to 'vat-worker.mjs'. + * @param args.logger - An optional {@link Logger}. Defaults to a new logger labeled '[vat worker client]'. */ - constructor(logger?: Logger) { - this.#logger = logger ?? makeLogger('[vat worker service]'); + constructor(args: { + workerFilePath?: string | undefined; + logger?: Logger | undefined; + }) { + this.#workerFilePath = args.workerFilePath ?? DEFAULT_WORKER_FILE; + this.#logger = args.logger ?? makeLogger('[vat worker service]'); } async launch( vatId: VatId, ): Promise> { + this.#logger.debug('launching vat', vatId); const { promise, resolve, reject } = makePromiseKit>(); - this.#logger.debug('launching', vatId); - const worker = new NodeWorker(workerFileURL, { + const worker = new NodeWorker(this.#workerFilePath, { env: { NODE_VAT_ID: vatId, }, }); - this.#logger.debug('launched', vatId); worker.once('online', () => { const stream = new NodeWorkerDuplexStream( worker, @@ -57,7 +66,7 @@ export class NodejsVatWorkerService implements VatWorkerService { .synchronize() .then(() => { resolve(stream); - this.#logger.debug('connected', vatId); + this.#logger.debug('connected to kernel'); return undefined; }) .catch((error) => { diff --git a/packages/nodejs/src/kernel/kernel-worker.ts b/packages/nodejs/src/kernel/kernel-worker.ts deleted file mode 100644 index 302bcb80f..000000000 --- a/packages/nodejs/src/kernel/kernel-worker.ts +++ /dev/null @@ -1,87 +0,0 @@ -import '@ocap/shims/endoify'; -import type { NonEmptyArray } from '@metamask/utils'; -import type { KernelCommand, KernelCommandReply, VatId } from '@ocap/kernel'; -import { Kernel, VatCommandMethod } from '@ocap/kernel'; -import { NodeWorkerDuplexStream } from '@ocap/streams'; -import { MessagePort as NodeMessagePort } from 'worker_threads'; - -import { makeSQLKVStore } from './sqlite-kv-store.js'; -import { NodejsVatWorkerService } from './VatWorkerService.js'; - -/** - * The main function for the kernel worker. - * - * @param port - The kernel's end of a node:worker_threads MessageChannel - * @returns The kernel, initialized. - */ -export async function makeKernel(port: NodeMessagePort): Promise { - const nodeStream = new NodeWorkerDuplexStream< - KernelCommand, - KernelCommandReply - >(port); - const vatWorkerClient = new NodejsVatWorkerService(); - - // Initialize kernel store. - const kvStore = await makeSQLKVStore(); - - // Create and start kernel. - const kernel = new Kernel(nodeStream, vatWorkerClient, kvStore); - await kernel.init(); - - return kernel; -} - -/** - * Runs the full lifecycle of an array of vats, including their creation, - * restart, message passing, and termination. - * - * @param kernel The kernel instance. - * @param vats An array of VatIds to be managed. - */ -export async function runVatLifecycle( - kernel: Kernel, - vats: NonEmptyArray, -): Promise { - console.log('runVatLifecycle Start...'); - const vatLabel = vats.join(', '); - console.time(`Created vats: ${vatLabel}`); - await Promise.all( - vats.map( - async () => - await kernel.launchVat({ - bundleSpec: 'http://localhost:3000/sample-vat.bundle', - parameters: { name: 'Nodeen' }, - }), - ), - ); - console.timeEnd(`Created vats: ${vatLabel}`); - const knownVats = kernel.getVatIds() as NonEmptyArray; - const knownVatsLabel = knownVats.join(', '); - console.log('Kernel vats:', knownVatsLabel); - - // Restart a randomly selected vat from the array. - console.time(`Restart vats: ${knownVatsLabel}`); - await Promise.all( - knownVats.map(async (vatId: VatId) => await kernel.restartVat(vatId)), - ); - console.timeEnd(`Restart vats: ${knownVatsLabel}`); - - // Send a "Ping" message to a randomly selected vat. - console.time(`Ping vats: ${knownVatsLabel}`); - await Promise.all( - knownVats.map( - async (vatId: VatId) => - await kernel.sendMessage(vatId, { - method: VatCommandMethod.ping, - params: null, - }), - ), - ); - console.timeEnd(`Ping vats "${knownVatsLabel}"`); - - console.time(`Terminated vats: ${knownVatsLabel}`); - await kernel.terminateAllVats(); - console.timeEnd(`Terminated vats: ${knownVatsLabel}`); - - console.log(`Kernel has ${kernel.getVatIds().length} vats`); -} diff --git a/packages/nodejs/src/kernel/make-kernel.test.ts b/packages/nodejs/src/kernel/make-kernel.test.ts new file mode 100644 index 000000000..0a304077a --- /dev/null +++ b/packages/nodejs/src/kernel/make-kernel.test.ts @@ -0,0 +1,31 @@ +import '@ocap/shims/endoify'; + +import { Kernel } from '@ocap/kernel'; +import { + MessagePort as NodeMessagePort, + MessageChannel as NodeMessageChannel, +} from 'node:worker_threads'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { makeKernel } from './make-kernel.js'; + +vi.mock('./sqlite-kv-store.js', async () => { + const { makeMapKVStore } = await import('../../../kernel/test/storage.js'); + return { + makeSQLKVStore: makeMapKVStore, + }; +}); + +describe('makeKernel', () => { + let kernelPort: NodeMessagePort; + + beforeEach(() => { + kernelPort = new NodeMessageChannel().port1; + }); + + it('should return a Kernel', async () => { + const kernel = await makeKernel(kernelPort); + + expect(kernel).toBeInstanceOf(Kernel); + }); +}); diff --git a/packages/nodejs/src/kernel/make-kernel.ts b/packages/nodejs/src/kernel/make-kernel.ts new file mode 100644 index 000000000..aff23f67a --- /dev/null +++ b/packages/nodejs/src/kernel/make-kernel.ts @@ -0,0 +1,34 @@ +import type { KernelCommand, KernelCommandReply } from '@ocap/kernel'; +import { Kernel } from '@ocap/kernel'; +import { NodeWorkerDuplexStream } from '@ocap/streams'; +import { MessagePort as NodeMessagePort } from 'node:worker_threads'; + +import { makeSQLKVStore } from './sqlite-kv-store.js'; +import { NodejsVatWorkerService } from './VatWorkerService.js'; + +/** + * The main function for the kernel worker. + * + * @param port - The kernel's end of a node:worker_threads MessageChannel + * @param workerFilePath - The path to a file defining each vat worker's routine. + * @returns The kernel, initialized. + */ +export async function makeKernel( + port: NodeMessagePort, + workerFilePath?: string, +): Promise { + const nodeStream = new NodeWorkerDuplexStream< + KernelCommand, + KernelCommandReply + >(port); + const vatWorkerClient = new NodejsVatWorkerService({ workerFilePath }); + + // Initialize kernel store. + const kvStore = await makeSQLKVStore(); + + // Create and start kernel. + const kernel = new Kernel(nodeStream, vatWorkerClient, kvStore); + await kernel.init(); + + return kernel; +} diff --git a/packages/nodejs/src/kernel/sqlite-kv-store.ts b/packages/nodejs/src/kernel/sqlite-kv-store.ts index d8f2db7b0..0cf698bb0 100644 --- a/packages/nodejs/src/kernel/sqlite-kv-store.ts +++ b/packages/nodejs/src/kernel/sqlite-kv-store.ts @@ -1,12 +1,14 @@ -import { hasProperty, isObject } from '@metamask/utils'; import type { KVStore } from '@ocap/kernel'; import { makeLogger } from '@ocap/utils'; -// eslint-disable-next-line @typescript-eslint/naming-convention -import Sqlite from 'better-sqlite3'; +import type { Database } from 'better-sqlite3'; import { mkdir } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; +// We require require because the ESM import does not work properly. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const Sqlite = require('better-sqlite3'); + const dbRoot = join(tmpdir(), './db'); /** @@ -17,7 +19,7 @@ const dbRoot = join(tmpdir(), './db'); */ async function initDB( logger?: ReturnType, -): Promise { +): Promise { const dbPath = join(dbRoot, 'store.db'); console.log('dbPath:', dbPath); await mkdir(dbRoot, { recursive: true }); @@ -53,6 +55,7 @@ export async function makeSQLKVStore( FROM kv WHERE key = ? `); + sqlKVGet.pluck(true); /** * Read a key's value from the database. @@ -63,16 +66,10 @@ export async function makeSQLKVStore( */ function kvGet(key: string, required: boolean): string { const result = sqlKVGet.get(key); - if (isObject(result) && hasProperty(result, 'value')) { - const value = result.value as string; - logger.debug(`kernel get '${key}' as '${value}'`); - return value; - } - if (required) { + if (required && !result) { throw Error(`no record matching key '${key}'`); } - // Sometimes, we really lean on TypeScript's unsoundness - return undefined as unknown as string; + return result as string; } const sqlKVGetNextKey = db.prepare(` @@ -81,6 +78,7 @@ export async function makeSQLKVStore( WHERE key > ? LIMIT 1 `); + sqlKVGetNextKey.pluck(true); /** * Get the lexicographically next key in the KV store after a given key. @@ -111,7 +109,6 @@ export async function makeSQLKVStore( * @param value - The value to assign to it. */ function kvSet(key: string, value: string): void { - logger.debug(`kernel set '${key}' to '${value}'`); sqlKVSet.run(key, value); } @@ -126,7 +123,6 @@ export async function makeSQLKVStore( * @param key - The key to remove. */ function kvDelete(key: string): void { - logger.debug(`kernel delete '${key}'`); sqlKVDelete.run(key); } @@ -138,7 +134,6 @@ export async function makeSQLKVStore( * Delete all keys and values from the database. */ function kvClear(): void { - logger.debug(`kernel clear`); sqlKVDrop.run(); sqlKVInit.run(); } diff --git a/packages/nodejs/src/vat/streams.test.ts b/packages/nodejs/src/vat/streams.test.ts new file mode 100644 index 000000000..cb1c642de --- /dev/null +++ b/packages/nodejs/src/vat/streams.test.ts @@ -0,0 +1,49 @@ +import '@ocap/test-utils/mock-endoify'; + +import { describe, expect, it, vi } from 'vitest'; + +const doMockParentPort = (value: unknown): void => { + vi.doMock('node:worker_threads', () => ({ + parentPort: value, + })); + vi.resetModules(); +}; + +vi.mock('@ocap/kernel', async () => ({ + isVatCommand: vi.fn(() => true), +})); + +vi.mock('@ocap/streams', () => ({ + NodeWorkerDuplexStream: vi.fn(), +})); + +describe('getPort', () => { + it('returns a port', async () => { + const mockParentPort = {}; + doMockParentPort(mockParentPort); + + const { getPort } = await import('./streams.js'); + const port = getPort(); + + expect(port).toStrictEqual(mockParentPort); + }, 4000); // Extra time is needed when running yarn test from monorepo root. + + it('throws if parentPort is not defined', async () => { + doMockParentPort(undefined); + + const { getPort } = await import('./streams.js'); + + expect(getPort).toThrow(/parentPort/u); + }); +}); + +describe('makeCommandStream', () => { + it('returns a NodeWorkerDuplexStream', async () => { + doMockParentPort(new MessageChannel().port1); + + const { NodeWorkerDuplexStream } = await import('@ocap/streams'); + const { makeCommandStream } = await import('./streams.js'); + const commandStream = makeCommandStream(); + expect(commandStream).toBeInstanceOf(NodeWorkerDuplexStream); + }); +}); diff --git a/packages/nodejs/src/vat/streams.ts b/packages/nodejs/src/vat/streams.ts new file mode 100644 index 000000000..b3475c356 --- /dev/null +++ b/packages/nodejs/src/vat/streams.ts @@ -0,0 +1,34 @@ +import { isVatCommand } from '@ocap/kernel'; +import type { VatCommand, VatCommandReply } from '@ocap/kernel'; +import { NodeWorkerDuplexStream } from '@ocap/streams'; +import { parentPort } from 'node:worker_threads'; +import type { MessagePort as NodePort } from 'node:worker_threads'; + +/** + * Return the parent port of the Node.js worker if it exists; otherwise throw. + * + * @returns The parent port. + * @throws If not called from within a Node.js worker. + */ +export function getPort(): NodePort { + if (!parentPort) { + throw new Error('Expected to run in a Node.js worker with parentPort.'); + } + return parentPort; +} + +/** + * When called from within Node.js worker, returns a DuplexStream which + * communicates over the parentPort. + * + * @returns A NodeWorkerDuplexStream + */ +export function makeCommandStream(): NodeWorkerDuplexStream< + VatCommand, + VatCommandReply +> { + return new NodeWorkerDuplexStream( + getPort(), + isVatCommand, + ); +} diff --git a/packages/nodejs/src/vat/vat-worker.ts b/packages/nodejs/src/vat/vat-worker.ts index 784d68819..944a59722 100644 --- a/packages/nodejs/src/vat/vat-worker.ts +++ b/packages/nodejs/src/vat/vat-worker.ts @@ -1,34 +1,30 @@ import '@ocap/shims/endoify'; -import { isVatCommand, VatSupervisor } from '@ocap/kernel'; -import type { VatCommand, VatCommandReply } from '@ocap/kernel'; -import { NodeWorkerDuplexStream } from '@ocap/streams'; +import type { VatId } from '@ocap/kernel'; +import { VatSupervisor } from '@ocap/kernel'; import { makeLogger } from '@ocap/utils'; -import { parentPort } from 'node:worker_threads'; -import { makeSQLKVStore } from '../kernel/sqlite-kv-store.js'; +import { makeCommandStream } from './streams'; +import { makeSQLKVStore } from '../kernel/sqlite-kv-store'; -// eslint-disable-next-line n/no-process-env -const logger = makeLogger(`[vat-worker (${process.env.NODE_VAT_ID})]`); +const vatId = process.env.NODE_VAT_ID as VatId; -main().catch(logger.error); +if (vatId) { + const logger = makeLogger(`[vat-worker (${vatId})]`); + main().catch(logger.error); +} else { + console.log('no vatId set for env variable NODE_VAT_ID'); +} /** * The main function for the iframe. */ async function main(): Promise { - if (!parentPort) { - const errMsg = 'Expected to run in Node Worker with parentPort.'; - logger.error(errMsg); - throw new Error(errMsg); - } - const commandStream = new NodeWorkerDuplexStream( - parentPort, - isVatCommand, - ); + const commandStream = makeCommandStream(); + await commandStream.synchronize(); // eslint-disable-next-line no-void void new VatSupervisor({ - id: 'iframe', + id: vatId, commandStream, makeKVStore: makeSQLKVStore, }); diff --git a/packages/nodejs/test/e2e/VatWorkerService.test.ts b/packages/nodejs/test/e2e/VatWorkerService.test.ts new file mode 100644 index 000000000..c948713d6 --- /dev/null +++ b/packages/nodejs/test/e2e/VatWorkerService.test.ts @@ -0,0 +1,91 @@ +import '@ocap/shims/endoify'; + +import type { VatId } from '@ocap/kernel'; +import { NodeWorkerDuplexStream } from '@ocap/streams'; +import { makeCounter } from '@ocap/utils'; +import { describe, expect, it, vi } from 'vitest'; + +import { NodejsVatWorkerService } from '../../src/kernel/VatWorkerService.js'; +import { getTestWorkerFile } from '../get-test-worker.js'; + +describe('NodejsVatWorkerService', () => { + const testWorkerFile = getTestWorkerFile('stream-sync'); + const vatIdCounter = makeCounter(); + const getTestVatId = (): VatId => `v${vatIdCounter()}`; + + describe('launch', () => { + it('creates a NodeWorker and returns a NodeWorkerDuplexStream', async () => { + const service = new NodejsVatWorkerService({ + workerFilePath: testWorkerFile, + }); + const testVatId: VatId = getTestVatId(); + const stream = await service.launch(testVatId); + + expect(stream).toBeInstanceOf(NodeWorkerDuplexStream); + }); + + it('rejects if synchronize fails', async () => { + const rejected = 'test-reject-value'; + + vi.doMock('@ocap/streams', () => ({ + NodeWorkerDuplexStream: vi.fn().mockImplementation(() => ({ + synchronize: vi.fn(() => 'no').mockRejectedValue(rejected), + })), + })); + vi.resetModules(); + const NVWS = (await import('../../src/kernel/VatWorkerService.js')) + .NodejsVatWorkerService; + + const service = new NVWS({ workerFilePath: testWorkerFile }); + const testVatId: VatId = getTestVatId(); + await expect(async () => await service.launch(testVatId)).rejects.toThrow( + rejected, + ); + }); + }); + + describe('terminate', () => { + it('terminates the target vat', async () => { + const service = new NodejsVatWorkerService({ + workerFilePath: testWorkerFile, + }); + const testVatId: VatId = getTestVatId(); + + await service.launch(testVatId); + expect(service.workers.has(testVatId)).toBe(true); + + await service.terminate(testVatId); + expect(service.workers.has(testVatId)).toBe(false); + }); + + it('throws when terminating an unknown vat', async () => { + const service = new NodejsVatWorkerService({ + workerFilePath: testWorkerFile, + }); + const testVatId: VatId = getTestVatId(); + + await expect( + async () => await service.terminate(testVatId), + ).rejects.toThrow(/No worker found/u); + }); + }); + + describe('terminateAll', () => { + it('terminates all vats', async () => { + const service = new NodejsVatWorkerService({ + workerFilePath: testWorkerFile, + }); + const vatIds: VatId[] = [getTestVatId(), getTestVatId(), getTestVatId()]; + + await Promise.all( + vatIds.map(async (vatId) => await service.launch(vatId)), + ); + + expect(Array.from(service.workers.values())).toHaveLength(vatIds.length); + + await service.terminateAll(); + + expect(Array.from(service.workers.values())).toHaveLength(0); + }); + }); +}); diff --git a/packages/nodejs/test/e2e/kernel-worker.test.ts b/packages/nodejs/test/e2e/kernel-worker.test.ts index 25dc13f4c..2febd10d1 100644 --- a/packages/nodejs/test/e2e/kernel-worker.test.ts +++ b/packages/nodejs/test/e2e/kernel-worker.test.ts @@ -1,9 +1,14 @@ import '@ocap/shims/endoify'; -import { MessageChannel as NodeMessageChannel } from 'node:worker_threads'; -import { describe, it, expect, vi } from 'vitest'; +import { Kernel, VatCommandMethod } from '@ocap/kernel'; +import type { VatConfig, VatId } from '@ocap/kernel'; +import { + MessageChannel as NodeMessageChannel, + MessagePort as NodePort, +} from 'node:worker_threads'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { makeKernel, runVatLifecycle } from '../../src/kernel/kernel-worker.js'; +import { makeKernel } from '../../src/kernel/make-kernel.js'; vi.mock('node:process', () => ({ exit: vi.fn((reason) => { @@ -12,19 +17,69 @@ vi.mock('node:process', () => ({ })); describe('Kernel Worker', () => { - it('should handle the lifecycle of multiple vats', async () => { - console.log('Started test.'); - const kernelChannel = new NodeMessageChannel(); - const { port1: kernelPort } = kernelChannel; - console.log('Creating kernel...'); - const kernel = await makeKernel(kernelPort); - console.log('Kernel created.'); - - console.log('Handling the lifecycle of multiple vats...'); - await runVatLifecycle(kernel, ['v1', 'v2', 'v3']); - console.log('Lifecycle of multiple vats handled.'); - - console.log('Test passed.'); + let kernelPort: NodePort; + let kernel: Kernel; + + // Tests below assume these are sorted for convenience. + const testVatIds = ['v1', 'v2', 'v3'].sort(); + + const testVatConfig: VatConfig = { + bundleSpec: 'http://localhost:3000/sample-vat.bundle', + parameters: { name: 'Nodeen' }, + }; + + beforeEach(async () => { + if (kernelPort) { + kernelPort.close(); + } + kernelPort = new NodeMessageChannel().port1; + kernel = await makeKernel(kernelPort); + }); + + afterEach(async () => { + if (kernel) { + await kernel.terminateAllVats(); + await kernel.clearStorage(); + } + }); + + it('launches a vat', async () => { + expect(kernel.getVatIds()).toHaveLength(0); + const kRef = await kernel.launchVat(testVatConfig); + expect(typeof kRef).toBe('string'); + expect(kernel.getVatIds()).toHaveLength(1); + }); + + const launchTestVats = async (): Promise => { + await Promise.all( + testVatIds.map(async () => await kernel.launchVat(testVatConfig)), + ); + expect(kernel.getVatIds().sort()).toStrictEqual(testVatIds); + }; + + it('restarts vats', async () => { + await launchTestVats(); + await Promise.all(testVatIds.map(kernel.restartVat.bind(kernel))); + expect(kernel.getVatIds().sort()).toStrictEqual(testVatIds); + }, 5000); + + it('terminates all vats', async () => { + await launchTestVats(); + await kernel.terminateAllVats(); + expect(kernel.getVatIds()).toHaveLength(0); + }); + + it('pings vats', async () => { + await launchTestVats(); + await Promise.all( + testVatIds.map( + async (vatId: VatId) => + await kernel.sendMessage(vatId, { + method: VatCommandMethod.ping, + params: null, + }), + ), + ); expect(true).toBe(true); }); }); diff --git a/packages/nodejs/test/e2e/vat-worker.test.ts b/packages/nodejs/test/e2e/vat-worker.test.ts new file mode 100644 index 000000000..8e1f7c2e6 --- /dev/null +++ b/packages/nodejs/test/e2e/vat-worker.test.ts @@ -0,0 +1,37 @@ +import '@ocap/shims/endoify'; + +import type { VatId } from '@ocap/kernel'; +import { makePromiseKitMock } from '@ocap/test-utils'; +import { makeCounter } from '@ocap/utils'; +import { describe, expect, it } from 'vitest'; +import { Worker as NodeWorker } from 'worker_threads'; + +import { getTestWorkerFile } from '../get-test-worker.js'; + +const { makePromiseKit } = makePromiseKitMock(); + +describe('NodejsVatWorkerService', () => { + let testWorkerFile: string; + const vatIdCounter = makeCounter(); + const getTestVatId = (): VatId => `v${vatIdCounter()}`; + + describe('hello-world', () => { + testWorkerFile = getTestWorkerFile('hello-world'); + it('can start in a Node.js worker', async () => { + const vatId = getTestVatId(); + const worker = new NodeWorker(testWorkerFile, { + env: { + NODE_VAT_ID: vatId, + }, + }); + const { resolve, reject, promise } = makePromiseKit(); + worker.once('online', (error: Error) => { + if (error) { + reject(error); + } + resolve(vatId); + }); + expect(await promise).toStrictEqual(vatId); + }); + }); +}); diff --git a/packages/nodejs/test/get-test-worker.ts b/packages/nodejs/test/get-test-worker.ts new file mode 100644 index 000000000..2ad3e594c --- /dev/null +++ b/packages/nodejs/test/get-test-worker.ts @@ -0,0 +1,8 @@ +/** + * Get a path for a node worker file from its name. + * + * @param name - The name of the test worker file to retrieve. + * @returns The path for a test worker file. + */ +export const getTestWorkerFile = (name: string): string => + new URL(`./workers/${name}.js`, import.meta.url).pathname; diff --git a/packages/nodejs/test/workers/hello-world.js b/packages/nodejs/test/workers/hello-world.js new file mode 100644 index 000000000..216a0d654 --- /dev/null +++ b/packages/nodejs/test/workers/hello-world.js @@ -0,0 +1,2 @@ +console.debug('hello world computer', process.env.NODE_VAT_ID); +export {}; diff --git a/packages/nodejs/test/workers/stream-sync.js b/packages/nodejs/test/workers/stream-sync.js new file mode 100644 index 000000000..8cb0d4d01 --- /dev/null +++ b/packages/nodejs/test/workers/stream-sync.js @@ -0,0 +1,13 @@ +import '../../dist/env/endoify.mjs'; +import { makeCommandStream } from '../../dist/vat/streams.mjs'; + +main().catch(console.error); + +/** + * The main function for the test worker. + * No supervisor is created, but the stream is synchronized for comms testing. + */ +async function main() { + const stream = makeCommandStream(); + await stream.synchronize(); +} diff --git a/packages/nodejs/tsconfig.json b/packages/nodejs/tsconfig.json index 0490ddc26..74ba30066 100644 --- a/packages/nodejs/tsconfig.json +++ b/packages/nodejs/tsconfig.json @@ -23,6 +23,7 @@ "./test/**/*.ts", "./vitest.config.ts", "./vitest.config.e2e.ts", - "../../types" + "../../types", + "./test/workers/*.js" ] } diff --git a/packages/nodejs/vitest.config.ts b/packages/nodejs/vitest.config.ts index 7e149b0e5..e460f88d4 100644 --- a/packages/nodejs/vitest.config.ts +++ b/packages/nodejs/vitest.config.ts @@ -13,7 +13,7 @@ const config = mergeConfig( alias: [ { find: '@ocap/shims/endoify', - replacement: path.resolve('../shims/src/endoify.js'), + replacement: path.resolve(__dirname, '../shims/src/endoify.js'), customResolver: (id) => ({ external: true, id }), }, ], diff --git a/vitest.config.ts b/vitest.config.ts index 9680423c6..c0cf0613f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -74,16 +74,16 @@ export default defineConfig({ lines: 71.71, }, 'packages/kernel/**': { - statements: 42.51, - functions: 54.86, - branches: 29.27, - lines: 42.74, + statements: 43.97, + functions: 55.55, + branches: 30.03, + lines: 44.2, }, 'packages/nodejs/**': { - statements: 4.08, - functions: 4.34, - branches: 13.33, - lines: 4.08, + statements: 46.75, + functions: 47.61, + branches: 35.29, + lines: 46.75, }, 'packages/shims/**': { statements: 0, diff --git a/yarn.lock b/yarn.lock index 8fcd366ae..b0de7f3da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2297,11 +2297,11 @@ __metadata: "@metamask/eslint-config": "npm:^14.0.0" "@metamask/eslint-config-nodejs": "npm:^14.0.0" "@metamask/eslint-config-typescript": "npm:^14.0.0" - "@metamask/utils": "npm:^11.0.1" "@ocap/cli": "workspace:^" "@ocap/kernel": "workspace:^" "@ocap/shims": "workspace:^" "@ocap/streams": "workspace:^" + "@ocap/test-utils": "workspace:^" "@ocap/utils": "workspace:^" "@ts-bridge/cli": "npm:^0.6.2" "@ts-bridge/shims": "npm:^0.1.1"