Skip to content

Commit a270541

Browse files
grypezclaude
andcommitted
feat(kernel-daemon): add IPC server, client, and lifecycle
Introduce the `@ocap/kernel-daemon` package with Unix-domain-socket IPC infrastructure: - `DaemonServer` — JSON-RPC dispatcher over `net.Server` - `connectToDaemon` / `sendShutdown` — IPC client helpers - `startDaemon` / `stopDaemon` / `isDaemonRunning` — process lifecycle - Constants for socket, PID, DB, and log file paths Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d1973a2 commit a270541

16 files changed

+1165
-0
lines changed

.depcheckrc.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ ignores:
1717
- '@ts-bridge/cli'
1818
- '@ts-bridge/shims'
1919

20+
# yargs (type-only usage in kernel-daemon)
21+
- '@types/yargs'
22+
2023
# vitest
2124
- 'vite'
2225
- '@types/vitest'
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
{
2+
"name": "@ocap/kernel-daemon",
3+
"version": "0.0.0",
4+
"private": true,
5+
"description": "Persistent ocap kernel daemon with IPC server and client",
6+
"type": "module",
7+
"exports": {
8+
".": {
9+
"import": {
10+
"types": "./dist/index.d.mts",
11+
"default": "./dist/index.mjs"
12+
},
13+
"require": {
14+
"types": "./dist/index.d.cts",
15+
"default": "./dist/index.cjs"
16+
}
17+
},
18+
"./package.json": "./package.json"
19+
},
20+
"files": [
21+
"dist/"
22+
],
23+
"scripts": {
24+
"build": "ts-bridge --project tsconfig.build.json --no-references --clean",
25+
"clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs",
26+
"lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies",
27+
"lint:dependencies": "depcheck --quiet",
28+
"lint:eslint": "eslint . --cache",
29+
"lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write && yarn constraints --fix && yarn lint:dependencies",
30+
"lint:misc": "prettier --no-error-on-unmatched-pattern '**/*.json' '**/*.md' '**/*.html' '!**/CHANGELOG.old.md' '**/*.yml' '!.yarnrc.yml' '!merged-packages/**' --ignore-path ../../.gitignore --log-level error",
31+
"test": "vitest run --config vitest.config.ts",
32+
"test:clean": "yarn test --no-cache --coverage.clean",
33+
"test:dev": "yarn test --mode development",
34+
"test:verbose": "yarn test --reporter verbose",
35+
"test:watch": "vitest --config vitest.config.ts",
36+
"test:dev:quiet": "yarn test:dev --reporter @ocap/repo-tools/vitest-reporters/silent",
37+
"build:docs": "typedoc"
38+
},
39+
"dependencies": {
40+
"@metamask/kernel-rpc-methods": "workspace:^",
41+
"@metamask/logger": "workspace:^"
42+
},
43+
"devDependencies": {
44+
"@ocap/repo-tools": "workspace:^",
45+
"@ts-bridge/cli": "^0.6.3",
46+
"@ts-bridge/shims": "^0.1.1",
47+
"@types/node": "^22.13.1",
48+
"depcheck": "^1.4.7",
49+
"eslint": "^9.23.0",
50+
"prettier": "^3.5.3",
51+
"rimraf": "^6.0.1",
52+
"ses": "^1.14.0",
53+
"typescript": "~5.8.2",
54+
"vite": "^7.3.0",
55+
"vitest": "^4.0.16"
56+
},
57+
"engines": {
58+
"node": "^20.11 || >=22"
59+
},
60+
"repository": {
61+
"type": "git",
62+
"url": "https://github.com/MetaMask/ocap-kernel.git"
63+
}
64+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { homedir } from 'node:os';
2+
import { join } from 'node:path';
3+
4+
/**
5+
* Base directory for daemon state files.
6+
*/
7+
export const DAEMON_DIR = join(homedir(), '.ocap-kernel-daemon');
8+
9+
/**
10+
* Path to the daemon PID file.
11+
*/
12+
export const PID_FILE = join(DAEMON_DIR, 'daemon.pid');
13+
14+
/**
15+
* Path to the daemon Unix domain socket.
16+
*/
17+
export const SOCK_FILE = join(DAEMON_DIR, 'daemon.sock');
18+
19+
/**
20+
* Path to the persistent SQLite database.
21+
*/
22+
export const DB_FILE = join(DAEMON_DIR, 'store.db');
23+
24+
/**
25+
* Path to the daemon log file.
26+
*/
27+
export const LOG_FILE = join(DAEMON_DIR, 'daemon.log');
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { EventEmitter } from 'node:events';
2+
import { createConnection } from 'node:net';
3+
import { describe, it, expect, vi, beforeEach } from 'vitest';
4+
5+
import { connectToDaemon, sendShutdown } from './daemon-client.ts';
6+
7+
vi.mock('@metamask/kernel-rpc-methods', () => {
8+
const MockRpcClient = vi.fn();
9+
// eslint-disable-next-line vitest/prefer-spy-on -- vi.spyOn fails on bare vi.fn() constructors
10+
MockRpcClient.prototype.handleResponse = vi.fn();
11+
// eslint-disable-next-line vitest/prefer-spy-on -- vi.spyOn fails on bare vi.fn() constructors
12+
MockRpcClient.prototype.call = vi.fn();
13+
return { RpcClient: MockRpcClient };
14+
});
15+
16+
vi.mock('node:net', () => ({
17+
createConnection: vi.fn(),
18+
}));
19+
20+
const makeMockSocket = () => {
21+
const emitter = new EventEmitter();
22+
return Object.assign(emitter, {
23+
write: vi.fn(),
24+
end: vi.fn(),
25+
destroy: vi.fn(),
26+
removeAllListeners: vi.fn(),
27+
});
28+
};
29+
30+
const mockMethodSpecs = {
31+
getStatus: { method: 'getStatus' },
32+
};
33+
34+
const mockLogger = {
35+
info: vi.fn(),
36+
warn: vi.fn(),
37+
error: vi.fn(),
38+
debug: vi.fn(),
39+
};
40+
41+
describe('daemon-client', () => {
42+
let mockSocket: ReturnType<typeof makeMockSocket>;
43+
44+
beforeEach(() => {
45+
mockSocket = makeMockSocket();
46+
vi.mocked(createConnection).mockReturnValue(mockSocket as never);
47+
});
48+
49+
describe('connectToDaemon', () => {
50+
it('resolves with a daemon connection on successful connect', async () => {
51+
const connectionPromise = connectToDaemon(
52+
mockMethodSpecs,
53+
mockLogger as never,
54+
);
55+
mockSocket.emit('connect');
56+
const connection = await connectionPromise;
57+
58+
expect(connection.client).toBeDefined();
59+
expect(connection.close).toBeInstanceOf(Function);
60+
expect(connection.socket).toBe(mockSocket);
61+
});
62+
63+
it('rejects when connection fails', async () => {
64+
const connectionPromise = connectToDaemon(
65+
mockMethodSpecs,
66+
mockLogger as never,
67+
);
68+
mockSocket.emit('error', new Error('ENOENT'));
69+
70+
await expect(connectionPromise).rejects.toThrow(
71+
'Failed to connect to daemon: ENOENT',
72+
);
73+
});
74+
75+
it('removes all listeners and destroys socket on close', async () => {
76+
const connectionPromise = connectToDaemon(
77+
mockMethodSpecs,
78+
mockLogger as never,
79+
);
80+
mockSocket.emit('connect');
81+
const connection = await connectionPromise;
82+
83+
connection.close();
84+
85+
expect(mockSocket.removeAllListeners).toHaveBeenCalled();
86+
expect(mockSocket.destroy).toHaveBeenCalled();
87+
});
88+
});
89+
90+
describe('sendShutdown', () => {
91+
it('sends shutdown command and resolves on response', async () => {
92+
const shutdownPromise = sendShutdown();
93+
mockSocket.emit('connect');
94+
95+
expect(mockSocket.write).toHaveBeenCalledWith(
96+
expect.stringContaining('"method":"shutdown"'),
97+
);
98+
99+
// Simulate response
100+
mockSocket.emit(
101+
'data',
102+
Buffer.from('{"jsonrpc":"2.0","id":"shutdown-1","result":true}\n'),
103+
);
104+
105+
await shutdownPromise;
106+
expect(mockSocket.destroy).toHaveBeenCalled();
107+
});
108+
109+
it('rejects when connection fails', async () => {
110+
const shutdownPromise = sendShutdown();
111+
mockSocket.emit('error', new Error('ECONNREFUSED'));
112+
113+
await expect(shutdownPromise).rejects.toThrow(
114+
'Failed to connect to daemon: ECONNREFUSED',
115+
);
116+
});
117+
});
118+
});
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { RpcClient } from '@metamask/kernel-rpc-methods';
2+
import type { Logger } from '@metamask/logger';
3+
import { createConnection } from 'node:net';
4+
5+
import { SOCK_FILE } from './constants.ts';
6+
import type { DaemonConnection } from './types.ts';
7+
8+
/**
9+
* Connect to the daemon's Unix domain socket and return an RPC client.
10+
*
11+
* @param methodSpecs - RPC method specifications for the client (e.g. rpcMethodSpecs
12+
* from kernel-browser-runtime). Passed as a parameter to avoid cyclic dependencies.
13+
* @param logger - Logger instance.
14+
* @returns A daemon connection with an RPC client and close function.
15+
*/
16+
export async function connectToDaemon(
17+
methodSpecs: Record<string, { method: string }>,
18+
logger: Logger,
19+
): Promise<DaemonConnection> {
20+
return new Promise((resolve, reject) => {
21+
const socket = createConnection(SOCK_FILE);
22+
23+
socket.once('connect', () => {
24+
const sendMessage = async (
25+
payload: Record<string, unknown>,
26+
): Promise<void> => {
27+
socket.write(`${JSON.stringify(payload)}\n`);
28+
};
29+
30+
const client = new RpcClient(
31+
methodSpecs as never,
32+
sendMessage,
33+
'cli-',
34+
logger,
35+
);
36+
37+
let buffer = '';
38+
socket.on('data', (chunk: Buffer) => {
39+
buffer += chunk.toString();
40+
let newlineIndex: number;
41+
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
42+
const line = buffer.slice(0, newlineIndex);
43+
buffer = buffer.slice(newlineIndex + 1);
44+
try {
45+
const response = JSON.parse(line);
46+
if (response.id !== undefined && response.id !== null) {
47+
client.handleResponse(String(response.id), response);
48+
}
49+
} catch {
50+
logger.error('Failed to parse daemon response');
51+
}
52+
}
53+
});
54+
55+
resolve({
56+
client,
57+
socket,
58+
close: () => {
59+
socket.removeAllListeners();
60+
socket.destroy();
61+
},
62+
});
63+
});
64+
65+
socket.once('error', (error) => {
66+
reject(new Error(`Failed to connect to daemon: ${error.message}`));
67+
});
68+
});
69+
}
70+
71+
/**
72+
* Send a raw shutdown command to the daemon over the Unix socket.
73+
* This bypasses the typed RPC client since `shutdown` is daemon-specific.
74+
*
75+
* @returns A promise that resolves when the shutdown acknowledgment is received.
76+
*/
77+
export async function sendShutdown(): Promise<void> {
78+
return new Promise((resolve, reject) => {
79+
const socket = createConnection(SOCK_FILE);
80+
81+
socket.once('connect', () => {
82+
const request = {
83+
jsonrpc: '2.0',
84+
id: 'shutdown-1',
85+
method: 'shutdown',
86+
params: [],
87+
};
88+
socket.write(`${JSON.stringify(request)}\n`);
89+
90+
let buffer = '';
91+
socket.on('data', (chunk: Buffer) => {
92+
buffer += chunk.toString();
93+
if (buffer.includes('\n')) {
94+
socket.destroy();
95+
resolve();
96+
}
97+
});
98+
});
99+
100+
socket.once('error', (error) => {
101+
reject(new Error(`Failed to connect to daemon: ${error.message}`));
102+
});
103+
});
104+
}

0 commit comments

Comments
 (0)