Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ describe('CapTP Integration', () => {
}),
reset: vi.fn().mockResolvedValue(undefined),
terminateSubcluster: vi.fn().mockResolvedValue(undefined),
registerKernelServiceObject: vi.fn(),
provideFacet: vi.fn(),
} as unknown as Kernel;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ describe('makeKernelCapTP', () => {
launchSubcluster: vi.fn(),
pingVat: vi.fn(),
queueMessage: vi.fn(),
registerKernelServiceObject: vi.fn(),
reset: vi.fn(),
terminateSubcluster: vi.fn(),
provideFacet: vi.fn(),
Expand Down
6 changes: 4 additions & 2 deletions packages/kernel-test/src/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,10 @@ describe('Kernel service object invocation', () => {
await kernel.queueMessage(testVatRootObject, 'goBadly', []);
await waitUntilQuiescent(100);
const testLogs = extractTestLogs(entries);
expect(testLogs).toContain(
`kernel service threw: unknown service method 'nonexistentMethod'`,
expect(testLogs).toStrictEqual(
expect.arrayContaining([
expect.stringMatching(/kernel service threw:.*nonexistentMethod/u),
]),
);
});
});
1 change: 1 addition & 0 deletions packages/nodejs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.17.4",
"@endo/captp": "^4.4.8",
"@metamask/auto-changelog": "^5.3.0",
"@metamask/eslint-config": "^15.0.0",
"@metamask/eslint-config-nodejs": "^15.0.0",
Expand Down
98 changes: 98 additions & 0 deletions packages/nodejs/test/e2e/captp-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { makeCapTP } from '@endo/captp';
import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs';
import { waitUntilQuiescent } from '@metamask/kernel-utils';
import { Kernel, kunser } from '@metamask/ocap-kernel';
import type { ClusterConfig } from '@metamask/ocap-kernel';
import { Worker } from 'node:worker_threads';
import { describe, it, expect, afterEach } from 'vitest';

import { makeTestKernel } from '../helpers/kernel.ts';

const CAPTP_SERVICE_VAT_BUNDLE_URL =
'http://localhost:3000/captp-service-vat.bundle';

const READY_SIGNAL = 'captp-service-client:ready';

const workerPath = new URL(
'../workers/captp-service-client.js',
import.meta.url,
).pathname;

describe('CapTP kernel service registration', { timeout: 30_000 }, () => {
let kernel: Kernel | undefined;
let worker: Worker | undefined;
let abortCapTP: ((reason?: unknown) => void) | undefined;

afterEach(async () => {
abortCapTP?.('test cleanup');
abortCapTP = undefined;

if (worker) {
const workerRef = worker;
worker = undefined;
await workerRef.terminate();
}

if (kernel) {
const stopResult = kernel.stop();
kernel = undefined;
await stopResult;
}
});

it('vat invokes a method on a service object registered over CapTP from a worker', async () => {
// 1. Create a real kernel
kernel = await makeTestKernel(
await makeSQLKernelDatabase({ dbFilename: ':memory:' }),
);

// 2. Spawn the worker that will act as the CapTP client
worker = new Worker(workerPath);

// 3. Set up CapTP on the main thread (kernel side)
const { dispatch, abort } = makeCapTP(
'kernel',
(message: Record<string, unknown>) => worker!.postMessage(message),
kernel.provideFacet(),
);
abortCapTP = abort;

// 4. Wire up message dispatching from worker → kernel CapTP
// and wait for the worker to signal that registration is complete
await new Promise<void>((resolve, reject) => {
worker!.on('message', (message: unknown) => {
if (message === READY_SIGNAL) {
resolve();
} else {
dispatch(message as Record<string, unknown>);
}
});
worker!.on('error', reject);
worker!.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`Worker exited with code ${code}`));
}
});
});

// 5. Launch a subcluster with a vat that uses the 'testService' service
const config: ClusterConfig = {
bootstrap: 'main',
services: ['testService'],
vats: {
main: {
bundleSpec: CAPTP_SERVICE_VAT_BUNDLE_URL,
},
},
};

const { rootKref } = await kernel.launchSubcluster(config);
await waitUntilQuiescent();

// 6. Have the vat call E(testService).doSomething(3, 4) and verify the result
const result = await kernel.queueMessage(rootKref, 'go', []);
await waitUntilQuiescent();

expect(kunser(result)).toBe(7);
});
});
22 changes: 22 additions & 0 deletions packages/nodejs/test/vats/captp-service-vat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { E } from '@endo/eventual-send';
import { makeDefaultExo } from '@metamask/kernel-utils/exo';

/**
* Build function for a vat that invokes a CapTP-registered kernel service.
*
* @param _vatPowers - Special powers granted to this vat (unused).
* @param _parameters - Initialization parameters (unused).
* @returns The root object for the new vat.
*/
export function buildRootObject(_vatPowers: unknown, _parameters: unknown) {
let testService: unknown;

return makeDefaultExo('root', {
async bootstrap(_vats: unknown, services: { testService: unknown }) {
testService = services.testService;
},
async go() {
return E(testService).doSomething(3, 4);
},
});
}
42 changes: 42 additions & 0 deletions packages/nodejs/test/workers/captp-service-client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// @ts-check

import '@metamask/kernel-shims/endoify-node';

import { makeCapTP } from '@endo/captp';
import { E } from '@endo/eventual-send';
import { makeDefaultExo } from '@metamask/kernel-utils';
import { parentPort } from 'node:worker_threads';

if (!parentPort) {
throw new Error('Expected to run as a Node.js worker thread');
}

const port = parentPort;

const READY_SIGNAL = 'captp-service-client:ready';

const { dispatch, getBootstrap } = makeCapTP(
'service-client',
(message) => port.postMessage(message),
undefined,
);

port.on('message', (message) => {
dispatch(message);
});

const testExo = makeDefaultExo('testExo', {
doSomething(left, right) {
return left + right;
},
});

async function main() {
const kernel = await getBootstrap();
await E(kernel).registerKernelServiceObject('testService', testExo);
port.postMessage(READY_SIGNAL);
}

main().catch((error) => {
throw error;
});
1 change: 1 addition & 0 deletions packages/ocap-kernel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"@chainsafe/libp2p-noise": "^16.1.3",
"@chainsafe/libp2p-yamux": "patch:@chainsafe/libp2p-yamux@npm%3A7.0.4#~/.yarn/patches/@chainsafe-libp2p-yamux-npm-7.0.4-284c2f6812.patch",
"@endo/errors": "^1.2.13",
"@endo/eventual-send": "^1.3.4",
"@endo/marshal": "^1.8.0",
"@endo/pass-style": "^1.6.3",
"@endo/promise-kit": "^1.1.13",
Expand Down
108 changes: 87 additions & 21 deletions packages/ocap-kernel/src/KernelServiceManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ import { makeKernelStore } from './store/index.ts';
import type { Message } from './types.ts';
import { makeMapKernelDatabase } from '../test/storage.ts';

/**
* Create a trackable service method that records calls without using vi.fn(),
* which doesn't work well with E() under SES/lockdown (frozen mock state).
*
* @param implementation - Optional implementation to call.
* @returns An object with the method and call tracking state.
*/
function makeTrackableMethod(implementation?: (...args: unknown[]) => unknown) {
const calls: unknown[][] = [];
const method = (...args: unknown[]) => {
calls.push(args);
return implementation?.(...args);
};
return { method, calls };
}

describe('KernelServiceManager', () => {
let serviceManager: KernelServiceManager;
let kernelStore: ReturnType<typeof makeKernelStore>;
Expand Down Expand Up @@ -272,10 +288,10 @@ describe('KernelServiceManager', () => {

describe('invokeKernelService', () => {
it('successfully invokes a service method without result', async () => {
const testMethod = vi.fn().mockReturnValue('test result');
const testService = {
testMethod,
};
const { method: testMethod, calls } = makeTrackableMethod(
() => 'test result',
);
const testService = { testMethod };

const registered = serviceManager.registerKernelServiceObject(
'testService',
Expand All @@ -289,15 +305,15 @@ describe('KernelServiceManager', () => {
serviceManager.invokeKernelService(registered.kref, message);
await delay();

expect(testMethod).toHaveBeenCalledWith('arg1', 'arg2');
expect(calls).toStrictEqual([['arg1', 'arg2']]);
expect(mockKernelQueue.resolvePromises).not.toHaveBeenCalled();
});

it('successfully invokes a service method with result', async () => {
const testMethod = vi.fn().mockResolvedValue('test result');
const testService = {
testMethod,
};
const { method: testMethod, calls } = makeTrackableMethod(async () =>
Promise.resolve('test result'),
);
const testService = { testMethod };

const registered = serviceManager.registerKernelServiceObject(
'testService',
Expand All @@ -312,18 +328,18 @@ describe('KernelServiceManager', () => {
serviceManager.invokeKernelService(registered.kref, message);
await delay();

expect(testMethod).toHaveBeenCalledWith('arg1');
expect(calls).toStrictEqual([['arg1']]);
expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [
['kp123', false, kser('test result')],
]);
});

it('handles errors when invoking service method with result', async () => {
const testError = new Error('Test error');
const testMethod = vi.fn().mockRejectedValue(testError);
const testService = {
testMethod,
};
const { method: testMethod } = makeTrackableMethod(async () =>
Promise.reject(testError),
);
const testService = { testMethod };

const registered = serviceManager.registerKernelServiceObject(
'testService',
Expand All @@ -346,10 +362,10 @@ describe('KernelServiceManager', () => {
it('handles errors when invoking service method without result', async () => {
const loggerErrorSpy = vi.spyOn(logger, 'error');
const testError = new Error('Test error');
const testMethod = vi.fn().mockRejectedValue(testError);
const testService = {
testMethod,
};
const { method: testMethod } = makeTrackableMethod(async () =>
Promise.reject(testError),
);
const testService = { testMethod };

const registered = serviceManager.registerKernelServiceObject(
'testService',
Expand Down Expand Up @@ -399,7 +415,13 @@ describe('KernelServiceManager', () => {
await delay();

expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [
['kp123', true, kser(Error("unknown service method 'unknownMethod'"))],
[
'kp123',
true,
expect.objectContaining({
body: expect.stringContaining('unknownMethod'),
}),
],
]);
});

Expand All @@ -422,7 +444,10 @@ describe('KernelServiceManager', () => {
await delay();

expect(loggerErrorSpy).toHaveBeenCalledWith(
"unknown service method 'unknownMethod'",
'Error in kernel service method:',
expect.objectContaining({
message: expect.stringContaining('unknownMethod'),
}),
);
expect(mockKernelQueue.resolvePromises).not.toHaveBeenCalled();
});
Expand All @@ -444,7 +469,48 @@ describe('KernelServiceManager', () => {
await delay();

expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [
['kp123', true, kser(Error("unknown service method 'anyMethod'"))],
[
'kp123',
true,
expect.objectContaining({
body: expect.stringContaining('anyMethod'),
}),
],
]);
});

it('invokes methods on a proxy-based service (simulated remote presence)', async () => {
const { method: testMethod, calls } = makeTrackableMethod(async () =>
Promise.resolve('remote result'),
);
const proxyService = new Proxy(
{},
{
get: (_target, prop) => {
if (prop === 'testMethod') {
return testMethod;
}
return undefined;
},
},
);

const registered = serviceManager.registerKernelServiceObject(
'proxyService',
proxyService,
);

const message: Message = {
methargs: kser(['testMethod', ['arg1']]),
result: 'kp123',
};

serviceManager.invokeKernelService(registered.kref, message);
await delay();

expect(calls).toStrictEqual([['arg1']]);
expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [
['kp123', false, kser('remote result')],
]);
});

Expand Down
Loading
Loading