@aztec-foundation/ipc-runtime 0.0.1-commit.b66364b
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/amd64-linux/ipc_runtime_napi.node +0 -0
- package/build/amd64-macos/ipc_runtime_napi.node +0 -0
- package/build/arm64-linux/ipc_runtime_napi.node +0 -0
- package/build/arm64-macos/ipc_runtime_napi.node +0 -0
- package/dest/errors.d.ts +36 -0
- package/dest/errors.js +43 -0
- package/dest/index.d.ts +8 -0
- package/dest/index.js +7 -0
- package/dest/native_loader.d.ts +16 -0
- package/dest/native_loader.js +81 -0
- package/dest/shm_client.d.ts +73 -0
- package/dest/shm_client.js +133 -0
- package/dest/shm_client.test.d.ts +1 -0
- package/dest/shm_client.test.js +60 -0
- package/dest/spawned_backend.d.ts +72 -0
- package/dest/spawned_backend.js +342 -0
- package/dest/spawned_backend.test.d.ts +1 -0
- package/dest/spawned_backend.test.js +173 -0
- package/dest/types.d.ts +29 -0
- package/dest/types.js +19 -0
- package/dest/uds.test.d.ts +1 -0
- package/dest/uds.test.js +182 -0
- package/dest/uds_client.d.ts +42 -0
- package/dest/uds_client.js +183 -0
- package/dest/uds_server.d.ts +29 -0
- package/dest/uds_server.js +135 -0
- package/package.json +28 -0
- package/src/errors.ts +46 -0
- package/src/index.ts +34 -0
- package/src/native_loader.ts +97 -0
- package/src/shm_client.test.ts +72 -0
- package/src/shm_client.ts +194 -0
- package/src/spawned_backend.test.ts +225 -0
- package/src/spawned_backend.ts +460 -0
- package/src/types.ts +38 -0
- package/src/uds.test.ts +207 -0
- package/src/uds_client.ts +250 -0
- package/src/uds_server.ts +166 -0
package/dest/uds.test.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// In-process UDS transport tests: UdsIpcServer + UdsIpcClient round-trips,
|
|
2
|
+
// zero-length responses, disconnect handling and oversized-frame rejection.
|
|
3
|
+
// Run via `yarn test` (node --test against the compiled dest/ output).
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import * as assert from "node:assert/strict";
|
|
6
|
+
import * as net from "node:net";
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { UdsIpcClient } from "./uds_client.js";
|
|
11
|
+
import { UdsIpcServer } from "./uds_server.js";
|
|
12
|
+
function tmpSocketPath(tag) {
|
|
13
|
+
return path.join(os.tmpdir(), `ipc_ts_test_${tag}_${process.pid}.sock`);
|
|
14
|
+
}
|
|
15
|
+
test("echo round-trip", async () => {
|
|
16
|
+
const socketPath = tmpSocketPath("echo");
|
|
17
|
+
const server = await UdsIpcServer.listen(socketPath, (_id, req) => req);
|
|
18
|
+
const client = await UdsIpcClient.connect(socketPath);
|
|
19
|
+
try {
|
|
20
|
+
const payload = new Uint8Array([1, 2, 3, 4, 5]);
|
|
21
|
+
const resp = await client.call(payload);
|
|
22
|
+
assert.deepEqual(resp, payload);
|
|
23
|
+
// Pipelined calls resolve FIFO.
|
|
24
|
+
const [a, b] = await Promise.all([
|
|
25
|
+
client.call(new Uint8Array([7])),
|
|
26
|
+
client.call(new Uint8Array([8, 9])),
|
|
27
|
+
]);
|
|
28
|
+
assert.deepEqual(a, new Uint8Array([7]));
|
|
29
|
+
assert.deepEqual(b, new Uint8Array([8, 9]));
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
await client.destroy();
|
|
33
|
+
await server.close();
|
|
34
|
+
}
|
|
35
|
+
assert.equal(fs.existsSync(socketPath), false, "socket unlinked on close");
|
|
36
|
+
});
|
|
37
|
+
test("socket file is chmod 0600", async () => {
|
|
38
|
+
const socketPath = tmpSocketPath("chmod");
|
|
39
|
+
const server = await UdsIpcServer.listen(socketPath, (_id, req) => req);
|
|
40
|
+
try {
|
|
41
|
+
const mode = fs.statSync(socketPath).mode & 0o777;
|
|
42
|
+
assert.equal(mode, 0o600);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
await server.close();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
test("zero-length response resolves (not a hang/error)", async () => {
|
|
49
|
+
const socketPath = tmpSocketPath("zlen");
|
|
50
|
+
const server = await UdsIpcServer.listen(socketPath, () => new Uint8Array(0));
|
|
51
|
+
const client = await UdsIpcClient.connect(socketPath);
|
|
52
|
+
try {
|
|
53
|
+
const resp = await client.call(new Uint8Array([42]));
|
|
54
|
+
assert.equal(resp.length, 0);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
await client.destroy();
|
|
58
|
+
await server.close();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
test("disconnect rejects pending calls and fails fast afterwards", async () => {
|
|
62
|
+
const socketPath = tmpSocketPath("disc");
|
|
63
|
+
// Raw server that accepts, reads, then kills the connection without
|
|
64
|
+
// responding.
|
|
65
|
+
const rawServer = net.createServer((conn) => {
|
|
66
|
+
conn.once("data", () => conn.destroy());
|
|
67
|
+
});
|
|
68
|
+
await new Promise((resolve) => rawServer.listen(socketPath, () => resolve()));
|
|
69
|
+
const client = await UdsIpcClient.connect(socketPath);
|
|
70
|
+
try {
|
|
71
|
+
await assert.rejects(client.call(new Uint8Array([1])));
|
|
72
|
+
// Socket is dead — further calls fail fast instead of queueing.
|
|
73
|
+
await assert.rejects(client.call(new Uint8Array([2])), /closed/);
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
await client.destroy();
|
|
77
|
+
rawServer.close();
|
|
78
|
+
fs.rmSync(socketPath, { force: true });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
test("client rejects oversized frame from server", async () => {
|
|
82
|
+
const socketPath = tmpSocketPath("oversize_cli");
|
|
83
|
+
// Raw server that answers any request with a corrupt 0xFFFFFFFF length
|
|
84
|
+
// prefix.
|
|
85
|
+
const rawServer = net.createServer((conn) => {
|
|
86
|
+
conn.once("data", () => {
|
|
87
|
+
const bogus = Buffer.allocUnsafe(4);
|
|
88
|
+
bogus.writeUInt32LE(0xffffffff, 0);
|
|
89
|
+
conn.write(bogus);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
await new Promise((resolve) => rawServer.listen(socketPath, () => resolve()));
|
|
93
|
+
const client = await UdsIpcClient.connect(socketPath);
|
|
94
|
+
try {
|
|
95
|
+
await assert.rejects(client.call(new Uint8Array([1])), /oversized frame/);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
await client.destroy();
|
|
99
|
+
rawServer.close();
|
|
100
|
+
fs.rmSync(socketPath, { force: true });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
test("client fails all pending calls on a response with an unknown request id", async () => {
|
|
104
|
+
const socketPath = tmpSocketPath("unknown_id");
|
|
105
|
+
// Raw server that answers with a well-formed frame whose request id matches
|
|
106
|
+
// nothing the client sent — the correlation-desync case.
|
|
107
|
+
const rawServer = net.createServer((conn) => {
|
|
108
|
+
conn.once("data", () => {
|
|
109
|
+
const frame = Buffer.allocUnsafe(12 + 1);
|
|
110
|
+
frame.writeUInt32LE(1 + 8, 0);
|
|
111
|
+
frame.writeBigUInt64LE(0xdeadbeefn, 4);
|
|
112
|
+
frame.writeUInt8(42, 12);
|
|
113
|
+
conn.write(frame);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
await new Promise((resolve) => rawServer.listen(socketPath, () => resolve()));
|
|
117
|
+
const client = await UdsIpcClient.connect(socketPath);
|
|
118
|
+
try {
|
|
119
|
+
await assert.rejects(client.call(new Uint8Array([1])), /unknown request id/);
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
await client.destroy();
|
|
123
|
+
rawServer.close();
|
|
124
|
+
fs.rmSync(socketPath, { force: true });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
test("client fails loudly on an id-less (old-protocol) frame", async () => {
|
|
128
|
+
const socketPath = tmpSocketPath("idless");
|
|
129
|
+
// Raw server speaking the pre-envelope-id protocol: [4B len][payload] with
|
|
130
|
+
// len < 8.
|
|
131
|
+
const rawServer = net.createServer((conn) => {
|
|
132
|
+
conn.once("data", () => {
|
|
133
|
+
const frame = Buffer.allocUnsafe(4 + 1);
|
|
134
|
+
frame.writeUInt32LE(1, 0);
|
|
135
|
+
frame.writeUInt8(42, 4);
|
|
136
|
+
conn.write(frame);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
await new Promise((resolve) => rawServer.listen(socketPath, () => resolve()));
|
|
140
|
+
const client = await UdsIpcClient.connect(socketPath);
|
|
141
|
+
try {
|
|
142
|
+
await assert.rejects(client.call(new Uint8Array([1])), /protocol mismatch/);
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
await client.destroy();
|
|
146
|
+
rawServer.close();
|
|
147
|
+
fs.rmSync(socketPath, { force: true });
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
test("server drops connection on oversized frame", async () => {
|
|
151
|
+
const socketPath = tmpSocketPath("oversize_srv");
|
|
152
|
+
const server = await UdsIpcServer.listen(socketPath, (_id, req) => req);
|
|
153
|
+
const conn = net.createConnection(socketPath);
|
|
154
|
+
try {
|
|
155
|
+
await new Promise((resolve, reject) => {
|
|
156
|
+
conn.once("connect", () => resolve());
|
|
157
|
+
conn.once("error", reject);
|
|
158
|
+
});
|
|
159
|
+
const bogus = Buffer.allocUnsafe(4);
|
|
160
|
+
bogus.writeUInt32LE(0xffffffff, 0);
|
|
161
|
+
conn.write(bogus);
|
|
162
|
+
await new Promise((resolve, reject) => {
|
|
163
|
+
const timer = setTimeout(() => reject(new Error("server did not close the connection")), 5000);
|
|
164
|
+
conn.once("close", () => {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
resolve();
|
|
167
|
+
});
|
|
168
|
+
conn.once("error", () => {
|
|
169
|
+
/* RST is fine — close follows */
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
conn.destroy();
|
|
175
|
+
await server.close();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
test("connect times out against a bound-but-unresponsive path", async () => {
|
|
179
|
+
const socketPath = tmpSocketPath("noaccept");
|
|
180
|
+
fs.rmSync(socketPath, { force: true });
|
|
181
|
+
await assert.rejects(UdsIpcClient.connect(socketPath, { connectTimeoutMs: 300 }), /timed out/);
|
|
182
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as net from "node:net";
|
|
2
|
+
import { IpcClientAsync } from "./types.js";
|
|
3
|
+
export interface UdsIpcClientConnectOptions {
|
|
4
|
+
/** Mark the socket as unref'd so it doesn't keep the Node event loop alive when idle. */
|
|
5
|
+
unref?: boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Retry budget (ms) for the initial connect when the server has bound the
|
|
8
|
+
* path but not yet called listen(). Set to 0 to fail immediately on
|
|
9
|
+
* ECONNREFUSED. Default CONNECT_RETRY_BUDGET_MS (5000).
|
|
10
|
+
*/
|
|
11
|
+
connectTimeoutMs?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Async IPC client over a Unix Domain Socket. Wire format matches the C++
|
|
15
|
+
* ipc::IpcServer/IpcClient socket transport: 4-byte little-endian length
|
|
16
|
+
* prefix, 8-byte little-endian request id, then the msgpack payload (the
|
|
17
|
+
* length counts the id plus the payload), per direction.
|
|
18
|
+
*
|
|
19
|
+
* Supports pipelining: each call carries a unique request id which the
|
|
20
|
+
* server echoes on the response, so responses are paired to callers by id
|
|
21
|
+
* and the server may complete requests in any order. Ids start at a random
|
|
22
|
+
* point per connection.
|
|
23
|
+
*/
|
|
24
|
+
export declare class UdsIpcClient implements IpcClientAsync {
|
|
25
|
+
private conn;
|
|
26
|
+
private buffer;
|
|
27
|
+
private pending;
|
|
28
|
+
private nextRequestId;
|
|
29
|
+
private destroyed;
|
|
30
|
+
/** Set once the socket has errored/closed; new calls fail fast. */
|
|
31
|
+
private closed;
|
|
32
|
+
private constructor();
|
|
33
|
+
static connect(socketPath: string, opts?: UdsIpcClientConnectOptions): Promise<UdsIpcClient>;
|
|
34
|
+
/** Number of in-flight calls awaiting a response. */
|
|
35
|
+
get inflight(): number;
|
|
36
|
+
/** Underlying socket — exposed for ref/unref control (event-loop tuning). */
|
|
37
|
+
get socket(): net.Socket;
|
|
38
|
+
call(input: Uint8Array): Promise<Uint8Array>;
|
|
39
|
+
destroy(): Promise<void>;
|
|
40
|
+
private onData;
|
|
41
|
+
private failAll;
|
|
42
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import * as net from "node:net";
|
|
2
|
+
import { IpcTransportError } from "./errors.js";
|
|
3
|
+
import { CONNECT_RETRY_BUDGET_MS, MAX_FRAME_SIZE, } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Async IPC client over a Unix Domain Socket. Wire format matches the C++
|
|
6
|
+
* ipc::IpcServer/IpcClient socket transport: 4-byte little-endian length
|
|
7
|
+
* prefix, 8-byte little-endian request id, then the msgpack payload (the
|
|
8
|
+
* length counts the id plus the payload), per direction.
|
|
9
|
+
*
|
|
10
|
+
* Supports pipelining: each call carries a unique request id which the
|
|
11
|
+
* server echoes on the response, so responses are paired to callers by id
|
|
12
|
+
* and the server may complete requests in any order. Ids start at a random
|
|
13
|
+
* point per connection.
|
|
14
|
+
*/
|
|
15
|
+
export class UdsIpcClient {
|
|
16
|
+
conn;
|
|
17
|
+
buffer = Buffer.alloc(0);
|
|
18
|
+
pending = new Map();
|
|
19
|
+
nextRequestId = (BigInt(Math.floor(Math.random() * 0xffffffff)) << 16n) + 1n;
|
|
20
|
+
destroyed = false;
|
|
21
|
+
/** Set once the socket has errored/closed; new calls fail fast. */
|
|
22
|
+
closed = false;
|
|
23
|
+
constructor(conn) {
|
|
24
|
+
this.conn = conn;
|
|
25
|
+
conn.on("data", (chunk) => this.onData(chunk));
|
|
26
|
+
conn.on("error", (err) => this.failAll(new IpcTransportError(`UdsIpcClient: socket error: ${err.message}`, {
|
|
27
|
+
cause: err,
|
|
28
|
+
})));
|
|
29
|
+
conn.on("close", () => this.failAll(new IpcTransportError("socket closed")));
|
|
30
|
+
}
|
|
31
|
+
static async connect(socketPath, opts) {
|
|
32
|
+
const conn = await connectWithRetry(socketPath, opts?.connectTimeoutMs ?? CONNECT_RETRY_BUDGET_MS);
|
|
33
|
+
conn.setNoDelay(true);
|
|
34
|
+
if (opts?.unref)
|
|
35
|
+
conn.unref();
|
|
36
|
+
return new UdsIpcClient(conn);
|
|
37
|
+
}
|
|
38
|
+
/** Number of in-flight calls awaiting a response. */
|
|
39
|
+
get inflight() {
|
|
40
|
+
return this.pending.size;
|
|
41
|
+
}
|
|
42
|
+
/** Underlying socket — exposed for ref/unref control (event-loop tuning). */
|
|
43
|
+
get socket() {
|
|
44
|
+
return this.conn;
|
|
45
|
+
}
|
|
46
|
+
async call(input) {
|
|
47
|
+
if (this.destroyed) {
|
|
48
|
+
throw new IpcTransportError("UdsIpcClient: call() after destroy()");
|
|
49
|
+
}
|
|
50
|
+
if (this.closed) {
|
|
51
|
+
throw new IpcTransportError("UdsIpcClient: call() on a closed/errored socket");
|
|
52
|
+
}
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const requestId = this.nextRequestId++;
|
|
55
|
+
this.pending.set(requestId, { resolve, reject });
|
|
56
|
+
const header = Buffer.allocUnsafe(12);
|
|
57
|
+
header.writeUInt32LE(input.length + 8, 0); // length counts id + payload
|
|
58
|
+
header.writeBigUInt64LE(requestId, 4);
|
|
59
|
+
this.conn.write(header);
|
|
60
|
+
this.conn.write(input);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
async destroy() {
|
|
64
|
+
this.destroyed = true;
|
|
65
|
+
this.conn.removeAllListeners();
|
|
66
|
+
this.conn.destroy();
|
|
67
|
+
this.failAll(new IpcTransportError("UdsIpcClient destroyed"));
|
|
68
|
+
}
|
|
69
|
+
onData(chunk) {
|
|
70
|
+
this.buffer =
|
|
71
|
+
this.buffer.length === 0
|
|
72
|
+
? Buffer.from(chunk)
|
|
73
|
+
: Buffer.concat([this.buffer, chunk]);
|
|
74
|
+
while (this.buffer.length >= 4) {
|
|
75
|
+
const len = this.buffer.readUInt32LE(0);
|
|
76
|
+
if (len > MAX_FRAME_SIZE) {
|
|
77
|
+
// Corrupt/malicious frame — close instead of buffering up to the
|
|
78
|
+
// claimed size.
|
|
79
|
+
this.conn.destroy();
|
|
80
|
+
this.failAll(new IpcTransportError(`UdsIpcClient: oversized frame (${len} bytes exceeds MAX_FRAME_SIZE)`));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (len < 8) {
|
|
84
|
+
// Shorter than the request-id field: the server speaks the id-less
|
|
85
|
+
// protocol. Fail loudly instead of misparsing.
|
|
86
|
+
this.conn.destroy();
|
|
87
|
+
this.failAll(new IpcTransportError(`UdsIpcClient: ${len}-byte frame is shorter than the request-id field — ` +
|
|
88
|
+
"IPC protocol mismatch (envelope ids); update the peer binary/package"));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (this.buffer.length < 4 + len)
|
|
92
|
+
return;
|
|
93
|
+
const requestId = this.buffer.readBigUInt64LE(4);
|
|
94
|
+
const payload = this.buffer.subarray(12, 4 + len);
|
|
95
|
+
this.buffer = this.buffer.subarray(4 + len);
|
|
96
|
+
const next = this.pending.get(requestId);
|
|
97
|
+
if (next) {
|
|
98
|
+
this.pending.delete(requestId);
|
|
99
|
+
next.resolve(new Uint8Array(payload));
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// A response that pairs with no pending call means the stream's
|
|
103
|
+
// correlation is broken — fail everything loudly rather than
|
|
104
|
+
// continuing on a connection we can no longer trust.
|
|
105
|
+
this.conn.destroy();
|
|
106
|
+
this.failAll(new IpcTransportError(`UdsIpcClient: response for unknown request id ${requestId} — protocol desync`));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
failAll(err) {
|
|
112
|
+
this.closed = true;
|
|
113
|
+
const pending = [...this.pending.values()];
|
|
114
|
+
this.pending.clear();
|
|
115
|
+
for (const p of pending)
|
|
116
|
+
p.reject(err);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Connect to `socketPath`, retrying "server not ready" errors until
|
|
121
|
+
* `timeoutMs` elapses: ENOENT (socket file not created yet), ECONNREFUSED
|
|
122
|
+
* (the window between the server's bind() and listen()), EAGAIN (Linux
|
|
123
|
+
* reports this for a UDS connect when the accept backlog is momentarily
|
|
124
|
+
* full), and ECONNRESET (a connect racing the server's accept loop under
|
|
125
|
+
* connection churn). Other errors fail immediately. Each attempt is also
|
|
126
|
+
* capped at the remaining budget, so a bound-but-never-accepting server
|
|
127
|
+
* cannot hang the connect past the deadline.
|
|
128
|
+
*/
|
|
129
|
+
async function connectWithRetry(socketPath, timeoutMs) {
|
|
130
|
+
const deadline = Date.now() + timeoutMs;
|
|
131
|
+
let attempt = 0;
|
|
132
|
+
let lastErr;
|
|
133
|
+
while (true) {
|
|
134
|
+
try {
|
|
135
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
136
|
+
return await attemptConnect(socketPath, remainingMs);
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
lastErr = err;
|
|
140
|
+
const code = err.code;
|
|
141
|
+
if (code !== "ECONNREFUSED" &&
|
|
142
|
+
code !== "ECONNRESET" &&
|
|
143
|
+
code !== "ENOENT" &&
|
|
144
|
+
code !== "ETIMEDOUT" &&
|
|
145
|
+
code !== "EAGAIN") {
|
|
146
|
+
throw new IpcTransportError(`UdsIpcClient: connect failed: ${lastErr.message}`, { cause: lastErr });
|
|
147
|
+
}
|
|
148
|
+
if (Date.now() >= deadline) {
|
|
149
|
+
throw new IpcTransportError(`UdsIpcClient: connect timed out: ${lastErr.message}`, { cause: lastErr });
|
|
150
|
+
}
|
|
151
|
+
const delay = Math.min(50, 5 * 2 ** attempt++);
|
|
152
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function attemptConnect(socketPath, timeoutMs) {
|
|
157
|
+
return new Promise((resolve, reject) => {
|
|
158
|
+
const conn = net.createConnection(socketPath);
|
|
159
|
+
const cleanup = () => {
|
|
160
|
+
conn.removeListener("connect", onConnect);
|
|
161
|
+
conn.removeListener("error", onError);
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
};
|
|
164
|
+
const onError = (err) => {
|
|
165
|
+
cleanup();
|
|
166
|
+
conn.destroy();
|
|
167
|
+
reject(err);
|
|
168
|
+
};
|
|
169
|
+
const onConnect = () => {
|
|
170
|
+
cleanup();
|
|
171
|
+
resolve(conn);
|
|
172
|
+
};
|
|
173
|
+
const timer = setTimeout(() => {
|
|
174
|
+
cleanup();
|
|
175
|
+
conn.destroy();
|
|
176
|
+
const err = new Error(`connect attempt timed out after ${timeoutMs}ms`);
|
|
177
|
+
err.code = "ETIMEDOUT";
|
|
178
|
+
reject(err);
|
|
179
|
+
}, timeoutMs);
|
|
180
|
+
conn.once("connect", onConnect);
|
|
181
|
+
conn.once("error", onError);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handler signature mirrors the C++ ipc::IpcServer::Handler: receive raw
|
|
3
|
+
* bytes, return raw bytes. msgpack decode/encode and command dispatch are
|
|
4
|
+
* the caller's responsibility (or the codegen's, when a generated dispatcher
|
|
5
|
+
* is wired in).
|
|
6
|
+
*/
|
|
7
|
+
export type IpcServerHandler = (clientId: number, request: Uint8Array) => Promise<Uint8Array> | Uint8Array;
|
|
8
|
+
/**
|
|
9
|
+
* UDS server with the same wire format as UdsIpcClient and the C++
|
|
10
|
+
* ipc::IpcServer socket transport: 4-byte LE length prefix, 8-byte LE request
|
|
11
|
+
* id (echoed on the response), then the payload; the length counts the id
|
|
12
|
+
* plus the payload. Accepts multiple concurrent connections; handler
|
|
13
|
+
* invocations are serialised per-connection.
|
|
14
|
+
*
|
|
15
|
+
* Signal handling is the caller's responsibility (unlike the C++ server's
|
|
16
|
+
* install_default_signal_handlers); the socket file is unlinked on close()
|
|
17
|
+
* and best-effort on process exit.
|
|
18
|
+
*/
|
|
19
|
+
export declare class UdsIpcServer {
|
|
20
|
+
private socketPath;
|
|
21
|
+
private server;
|
|
22
|
+
private nextClientId;
|
|
23
|
+
private connections;
|
|
24
|
+
private readonly unlinkOnExit;
|
|
25
|
+
private constructor();
|
|
26
|
+
static listen(socketPath: string, handler: IpcServerHandler): Promise<UdsIpcServer>;
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
private handleConnection;
|
|
29
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import * as net from "node:net";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import { MAX_FRAME_SIZE } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* UDS server with the same wire format as UdsIpcClient and the C++
|
|
6
|
+
* ipc::IpcServer socket transport: 4-byte LE length prefix, 8-byte LE request
|
|
7
|
+
* id (echoed on the response), then the payload; the length counts the id
|
|
8
|
+
* plus the payload. Accepts multiple concurrent connections; handler
|
|
9
|
+
* invocations are serialised per-connection.
|
|
10
|
+
*
|
|
11
|
+
* Signal handling is the caller's responsibility (unlike the C++ server's
|
|
12
|
+
* install_default_signal_handlers); the socket file is unlinked on close()
|
|
13
|
+
* and best-effort on process exit.
|
|
14
|
+
*/
|
|
15
|
+
export class UdsIpcServer {
|
|
16
|
+
socketPath;
|
|
17
|
+
server;
|
|
18
|
+
nextClientId = 0;
|
|
19
|
+
connections = new Set();
|
|
20
|
+
unlinkOnExit = () => {
|
|
21
|
+
try {
|
|
22
|
+
fs.unlinkSync(this.socketPath);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
/* may already be gone */
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
constructor(server, socketPath) {
|
|
29
|
+
this.socketPath = socketPath;
|
|
30
|
+
this.server = server;
|
|
31
|
+
}
|
|
32
|
+
static async listen(socketPath, handler) {
|
|
33
|
+
try {
|
|
34
|
+
fs.unlinkSync(socketPath);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
/* socket file may not exist; ignore */
|
|
38
|
+
}
|
|
39
|
+
const server = net.createServer();
|
|
40
|
+
const instance = new UdsIpcServer(server, socketPath);
|
|
41
|
+
server.on("connection", (conn) => instance.handleConnection(conn, handler));
|
|
42
|
+
await new Promise((resolve, reject) => {
|
|
43
|
+
const onError = (err) => {
|
|
44
|
+
server.removeListener("listening", onListening);
|
|
45
|
+
reject(err);
|
|
46
|
+
};
|
|
47
|
+
const onListening = () => {
|
|
48
|
+
server.removeListener("error", onError);
|
|
49
|
+
resolve();
|
|
50
|
+
};
|
|
51
|
+
server.once("error", onError);
|
|
52
|
+
server.once("listening", onListening);
|
|
53
|
+
server.listen(socketPath);
|
|
54
|
+
});
|
|
55
|
+
// Restrict the socket to the owner, matching the C++ server (and the
|
|
56
|
+
// 0600 mode used for SHM segments).
|
|
57
|
+
fs.chmodSync(socketPath, 0o600);
|
|
58
|
+
// Best-effort cleanup if the process exits without close().
|
|
59
|
+
process.on("exit", instance.unlinkOnExit);
|
|
60
|
+
return instance;
|
|
61
|
+
}
|
|
62
|
+
async close() {
|
|
63
|
+
// Force-close live connections (matching the C++ server's shutdown) so close()
|
|
64
|
+
// resolves promptly instead of blocking until every client happens to disconnect.
|
|
65
|
+
for (const conn of this.connections) {
|
|
66
|
+
conn.destroy();
|
|
67
|
+
}
|
|
68
|
+
this.connections.clear();
|
|
69
|
+
await new Promise((resolve) => this.server.close(() => resolve()));
|
|
70
|
+
process.removeListener("exit", this.unlinkOnExit);
|
|
71
|
+
try {
|
|
72
|
+
fs.unlinkSync(this.socketPath);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
/* may already be gone */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
handleConnection(conn, handler) {
|
|
79
|
+
const clientId = this.nextClientId++;
|
|
80
|
+
this.connections.add(conn);
|
|
81
|
+
conn.on("close", () => this.connections.delete(conn));
|
|
82
|
+
let buffer = Buffer.alloc(0);
|
|
83
|
+
let chain = Promise.resolve();
|
|
84
|
+
conn.on("data", (chunk) => {
|
|
85
|
+
buffer =
|
|
86
|
+
buffer.length === 0
|
|
87
|
+
? Buffer.from(chunk)
|
|
88
|
+
: Buffer.concat([buffer, chunk]);
|
|
89
|
+
while (buffer.length >= 4) {
|
|
90
|
+
const len = buffer.readUInt32LE(0);
|
|
91
|
+
if (len > MAX_FRAME_SIZE) {
|
|
92
|
+
// Corrupt/malicious frame — drop the connection instead of
|
|
93
|
+
// buffering up to the claimed size.
|
|
94
|
+
conn.destroy(new Error(`UdsIpcServer: oversized frame (${len} bytes exceeds MAX_FRAME_SIZE)`));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (len < 8) {
|
|
98
|
+
// Shorter than the request-id field: the peer speaks the id-less
|
|
99
|
+
// protocol. Drop the connection with a clear reason.
|
|
100
|
+
conn.destroy(new Error(`UdsIpcServer: ${len}-byte frame is shorter than the request-id field — ` +
|
|
101
|
+
"IPC protocol mismatch (envelope ids); update the peer binary/package"));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (buffer.length < 4 + len)
|
|
105
|
+
break;
|
|
106
|
+
const requestId = buffer.readBigUInt64LE(4);
|
|
107
|
+
// Copy into a standalone Buffer (not a subarray view, and not a plain Uint8Array): handlers
|
|
108
|
+
// decode with msgpackr, which relies on Buffer semantics for correct string/binary decoding.
|
|
109
|
+
const payload = Buffer.from(buffer.subarray(12, 4 + len));
|
|
110
|
+
buffer = buffer.subarray(4 + len);
|
|
111
|
+
const prev = chain;
|
|
112
|
+
chain = (async () => {
|
|
113
|
+
await prev;
|
|
114
|
+
try {
|
|
115
|
+
const resp = await handler(clientId, payload);
|
|
116
|
+
const header = Buffer.allocUnsafe(12);
|
|
117
|
+
header.writeUInt32LE(resp.length + 8, 0); // length counts id + payload
|
|
118
|
+
header.writeBigUInt64LE(requestId, 4);
|
|
119
|
+
conn.write(header);
|
|
120
|
+
conn.write(resp);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
conn.destroy(err);
|
|
124
|
+
}
|
|
125
|
+
})();
|
|
126
|
+
void chain.catch(() => {
|
|
127
|
+
/* errors already handled by destroying the connection */
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
conn.on("error", () => {
|
|
132
|
+
/* swallowed — clients reconnect */
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aztec-foundation/ipc-runtime",
|
|
3
|
+
"packageManager": "yarn@4.13.0",
|
|
4
|
+
"version": "0.0.1-commit.b66364b",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dest/index.js",
|
|
7
|
+
"types": "dest/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dest/index.d.ts",
|
|
11
|
+
"import": "./dest/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc -p tsconfig.json",
|
|
16
|
+
"clean": "rm -rf dest",
|
|
17
|
+
"test": "tsc -p tsconfig.json && node --test dest/*.test.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dest",
|
|
21
|
+
"src",
|
|
22
|
+
"build"
|
|
23
|
+
],
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "^22",
|
|
26
|
+
"typescript": "^5.6.3"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors thrown by the ipc-runtime transports and process backends.
|
|
3
|
+
*
|
|
4
|
+
* The cross-layer contract is the bare `retry` property, not these classes:
|
|
5
|
+
* an error with `retry === true` failed for environmental reasons (process
|
|
6
|
+
* death, machine load, a broken connection) and the operation may be retried;
|
|
7
|
+
* `retry === false` (or no `retry` property at all) means retrying cannot
|
|
8
|
+
* help. Consumers should feature-detect the property rather than import
|
|
9
|
+
* these types, so the convention survives package boundaries.
|
|
10
|
+
*/
|
|
11
|
+
export class IpcError extends Error {
|
|
12
|
+
constructor(
|
|
13
|
+
message: string,
|
|
14
|
+
public readonly retry: boolean,
|
|
15
|
+
options?: { cause?: unknown },
|
|
16
|
+
) {
|
|
17
|
+
super(message, options);
|
|
18
|
+
this.name = new.target.name;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The connection to the server broke while calls were in flight or before they could be sent. */
|
|
23
|
+
export class IpcTransportError extends IpcError {
|
|
24
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
25
|
+
super(message, /*retry=*/ true, options);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The spawned server process exited; carries the exit cause and, when captured, the log path. */
|
|
30
|
+
export class IpcProcessExitedError extends IpcError {
|
|
31
|
+
constructor(
|
|
32
|
+
message: string,
|
|
33
|
+
public readonly code: number | null,
|
|
34
|
+
public readonly signal: NodeJS.Signals | null,
|
|
35
|
+
public readonly logPath?: string,
|
|
36
|
+
) {
|
|
37
|
+
super(message, /*retry=*/ true);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The server process could not be started. Environmental failures (spawn
|
|
43
|
+
* raced a loaded machine, a wedged process hit the connect backstop) are
|
|
44
|
+
* retryable; configuration failures (binary not found) are not.
|
|
45
|
+
*/
|
|
46
|
+
export class IpcSpawnError extends IpcError {}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type { IpcClientAsync, IpcClientSync } from "./types.js";
|
|
2
|
+
export {
|
|
3
|
+
MAX_FRAME_SIZE,
|
|
4
|
+
CONNECT_RETRY_BUDGET_MS,
|
|
5
|
+
DEFAULT_RING_SIZE,
|
|
6
|
+
SOCKET_BACKLOG,
|
|
7
|
+
DEFAULT_CALL_TIMEOUT_NS,
|
|
8
|
+
} from "./types.js";
|
|
9
|
+
export {
|
|
10
|
+
IpcError,
|
|
11
|
+
IpcTransportError,
|
|
12
|
+
IpcProcessExitedError,
|
|
13
|
+
IpcSpawnError,
|
|
14
|
+
} from "./errors.js";
|
|
15
|
+
export {
|
|
16
|
+
SpawnedProcessBackend,
|
|
17
|
+
type SpawnedProcessBackendOptions,
|
|
18
|
+
type SpawnedTransport,
|
|
19
|
+
} from "./spawned_backend.js";
|
|
20
|
+
export { UdsIpcClient, type UdsIpcClientConnectOptions } from "./uds_client.js";
|
|
21
|
+
export { UdsIpcServer, type IpcServerHandler } from "./uds_server.js";
|
|
22
|
+
export {
|
|
23
|
+
NapiShmSyncClient,
|
|
24
|
+
NapiShmAsyncClient,
|
|
25
|
+
createNapiShmSyncClient,
|
|
26
|
+
createNapiShmAsyncClient,
|
|
27
|
+
type NapiMsgpackClientSync,
|
|
28
|
+
type NapiMsgpackClientAsync,
|
|
29
|
+
} from "./shm_client.js";
|
|
30
|
+
export {
|
|
31
|
+
findIpcRuntimeNapi,
|
|
32
|
+
loadIpcRuntimeNapi,
|
|
33
|
+
type Platform,
|
|
34
|
+
} from "./native_loader.js";
|