@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.
@@ -0,0 +1,250 @@
1
+ import * as net from "node:net";
2
+ import { IpcTransportError } from "./errors.js";
3
+ import {
4
+ IpcClientAsync,
5
+ CONNECT_RETRY_BUDGET_MS,
6
+ MAX_FRAME_SIZE,
7
+ } from "./types.js";
8
+
9
+ interface PendingCall {
10
+ resolve: (resp: Uint8Array) => void;
11
+ reject: (err: Error) => void;
12
+ }
13
+
14
+ export interface UdsIpcClientConnectOptions {
15
+ /** Mark the socket as unref'd so it doesn't keep the Node event loop alive when idle. */
16
+ unref?: boolean;
17
+ /**
18
+ * Retry budget (ms) for the initial connect when the server has bound the
19
+ * path but not yet called listen(). Set to 0 to fail immediately on
20
+ * ECONNREFUSED. Default CONNECT_RETRY_BUDGET_MS (5000).
21
+ */
22
+ connectTimeoutMs?: number;
23
+ }
24
+
25
+ /**
26
+ * Async IPC client over a Unix Domain Socket. Wire format matches the C++
27
+ * ipc::IpcServer/IpcClient socket transport: 4-byte little-endian length
28
+ * prefix, 8-byte little-endian request id, then the msgpack payload (the
29
+ * length counts the id plus the payload), per direction.
30
+ *
31
+ * Supports pipelining: each call carries a unique request id which the
32
+ * server echoes on the response, so responses are paired to callers by id
33
+ * and the server may complete requests in any order. Ids start at a random
34
+ * point per connection.
35
+ */
36
+ export class UdsIpcClient implements IpcClientAsync {
37
+ private buffer: Buffer = Buffer.alloc(0);
38
+ private pending = new Map<bigint, PendingCall>();
39
+ private nextRequestId =
40
+ (BigInt(Math.floor(Math.random() * 0xffffffff)) << 16n) + 1n;
41
+ private destroyed = false;
42
+ /** Set once the socket has errored/closed; new calls fail fast. */
43
+ private closed = false;
44
+
45
+ private constructor(private conn: net.Socket) {
46
+ conn.on("data", (chunk) => this.onData(chunk));
47
+ conn.on("error", (err) =>
48
+ this.failAll(
49
+ new IpcTransportError(`UdsIpcClient: socket error: ${err.message}`, {
50
+ cause: err,
51
+ }),
52
+ ),
53
+ );
54
+ conn.on("close", () =>
55
+ this.failAll(new IpcTransportError("socket closed")),
56
+ );
57
+ }
58
+
59
+ static async connect(
60
+ socketPath: string,
61
+ opts?: UdsIpcClientConnectOptions,
62
+ ): Promise<UdsIpcClient> {
63
+ const conn = await connectWithRetry(
64
+ socketPath,
65
+ opts?.connectTimeoutMs ?? CONNECT_RETRY_BUDGET_MS,
66
+ );
67
+ conn.setNoDelay(true);
68
+ if (opts?.unref) conn.unref();
69
+ return new UdsIpcClient(conn);
70
+ }
71
+
72
+ /** Number of in-flight calls awaiting a response. */
73
+ get inflight(): number {
74
+ return this.pending.size;
75
+ }
76
+
77
+ /** Underlying socket — exposed for ref/unref control (event-loop tuning). */
78
+ get socket(): net.Socket {
79
+ return this.conn;
80
+ }
81
+
82
+ async call(input: Uint8Array): Promise<Uint8Array> {
83
+ if (this.destroyed) {
84
+ throw new IpcTransportError("UdsIpcClient: call() after destroy()");
85
+ }
86
+ if (this.closed) {
87
+ throw new IpcTransportError(
88
+ "UdsIpcClient: call() on a closed/errored socket",
89
+ );
90
+ }
91
+ return new Promise<Uint8Array>((resolve, reject) => {
92
+ const requestId = this.nextRequestId++;
93
+ this.pending.set(requestId, { resolve, reject });
94
+ const header = Buffer.allocUnsafe(12);
95
+ header.writeUInt32LE(input.length + 8, 0); // length counts id + payload
96
+ header.writeBigUInt64LE(requestId, 4);
97
+ this.conn.write(header);
98
+ this.conn.write(input);
99
+ });
100
+ }
101
+
102
+ async destroy(): Promise<void> {
103
+ this.destroyed = true;
104
+ this.conn.removeAllListeners();
105
+ this.conn.destroy();
106
+ this.failAll(new IpcTransportError("UdsIpcClient destroyed"));
107
+ }
108
+
109
+ private onData(chunk: Buffer): void {
110
+ this.buffer =
111
+ this.buffer.length === 0
112
+ ? Buffer.from(chunk)
113
+ : Buffer.concat([this.buffer, chunk]);
114
+ while (this.buffer.length >= 4) {
115
+ const len = this.buffer.readUInt32LE(0);
116
+ if (len > MAX_FRAME_SIZE) {
117
+ // Corrupt/malicious frame — close instead of buffering up to the
118
+ // claimed size.
119
+ this.conn.destroy();
120
+ this.failAll(
121
+ new IpcTransportError(
122
+ `UdsIpcClient: oversized frame (${len} bytes exceeds MAX_FRAME_SIZE)`,
123
+ ),
124
+ );
125
+ return;
126
+ }
127
+ if (len < 8) {
128
+ // Shorter than the request-id field: the server speaks the id-less
129
+ // protocol. Fail loudly instead of misparsing.
130
+ this.conn.destroy();
131
+ this.failAll(
132
+ new IpcTransportError(
133
+ `UdsIpcClient: ${len}-byte frame is shorter than the request-id field — ` +
134
+ "IPC protocol mismatch (envelope ids); update the peer binary/package",
135
+ ),
136
+ );
137
+ return;
138
+ }
139
+ if (this.buffer.length < 4 + len) return;
140
+ const requestId = this.buffer.readBigUInt64LE(4);
141
+ const payload = this.buffer.subarray(12, 4 + len);
142
+ this.buffer = this.buffer.subarray(4 + len);
143
+ const next = this.pending.get(requestId);
144
+ if (next) {
145
+ this.pending.delete(requestId);
146
+ next.resolve(new Uint8Array(payload));
147
+ } else {
148
+ // A response that pairs with no pending call means the stream's
149
+ // correlation is broken — fail everything loudly rather than
150
+ // continuing on a connection we can no longer trust.
151
+ this.conn.destroy();
152
+ this.failAll(
153
+ new IpcTransportError(
154
+ `UdsIpcClient: response for unknown request id ${requestId} — protocol desync`,
155
+ ),
156
+ );
157
+ return;
158
+ }
159
+ }
160
+ }
161
+
162
+ private failAll(err: Error): void {
163
+ this.closed = true;
164
+ const pending = [...this.pending.values()];
165
+ this.pending.clear();
166
+ for (const p of pending) p.reject(err);
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Connect to `socketPath`, retrying "server not ready" errors until
172
+ * `timeoutMs` elapses: ENOENT (socket file not created yet), ECONNREFUSED
173
+ * (the window between the server's bind() and listen()), EAGAIN (Linux
174
+ * reports this for a UDS connect when the accept backlog is momentarily
175
+ * full), and ECONNRESET (a connect racing the server's accept loop under
176
+ * connection churn). Other errors fail immediately. Each attempt is also
177
+ * capped at the remaining budget, so a bound-but-never-accepting server
178
+ * cannot hang the connect past the deadline.
179
+ */
180
+ async function connectWithRetry(
181
+ socketPath: string,
182
+ timeoutMs: number,
183
+ ): Promise<net.Socket> {
184
+ const deadline = Date.now() + timeoutMs;
185
+ let attempt = 0;
186
+ let lastErr: Error | undefined;
187
+ while (true) {
188
+ try {
189
+ const remainingMs = Math.max(1, deadline - Date.now());
190
+ return await attemptConnect(socketPath, remainingMs);
191
+ } catch (err) {
192
+ lastErr = err as Error;
193
+ const code = (err as NodeJS.ErrnoException).code;
194
+ if (
195
+ code !== "ECONNREFUSED" &&
196
+ code !== "ECONNRESET" &&
197
+ code !== "ENOENT" &&
198
+ code !== "ETIMEDOUT" &&
199
+ code !== "EAGAIN"
200
+ ) {
201
+ throw new IpcTransportError(
202
+ `UdsIpcClient: connect failed: ${lastErr.message}`,
203
+ { cause: lastErr },
204
+ );
205
+ }
206
+ if (Date.now() >= deadline) {
207
+ throw new IpcTransportError(
208
+ `UdsIpcClient: connect timed out: ${lastErr.message}`,
209
+ { cause: lastErr },
210
+ );
211
+ }
212
+ const delay = Math.min(50, 5 * 2 ** attempt++);
213
+ await new Promise((resolve) => setTimeout(resolve, delay));
214
+ }
215
+ }
216
+ }
217
+
218
+ function attemptConnect(
219
+ socketPath: string,
220
+ timeoutMs: number,
221
+ ): Promise<net.Socket> {
222
+ return new Promise<net.Socket>((resolve, reject) => {
223
+ const conn = net.createConnection(socketPath);
224
+ const cleanup = () => {
225
+ conn.removeListener("connect", onConnect);
226
+ conn.removeListener("error", onError);
227
+ clearTimeout(timer);
228
+ };
229
+ const onError = (err: Error) => {
230
+ cleanup();
231
+ conn.destroy();
232
+ reject(err);
233
+ };
234
+ const onConnect = () => {
235
+ cleanup();
236
+ resolve(conn);
237
+ };
238
+ const timer = setTimeout(() => {
239
+ cleanup();
240
+ conn.destroy();
241
+ const err: NodeJS.ErrnoException = new Error(
242
+ `connect attempt timed out after ${timeoutMs}ms`,
243
+ );
244
+ err.code = "ETIMEDOUT";
245
+ reject(err);
246
+ }, timeoutMs);
247
+ conn.once("connect", onConnect);
248
+ conn.once("error", onError);
249
+ });
250
+ }
@@ -0,0 +1,166 @@
1
+ import * as net from "node:net";
2
+ import * as fs from "node:fs";
3
+ import { MAX_FRAME_SIZE } from "./types.js";
4
+
5
+ /**
6
+ * Handler signature mirrors the C++ ipc::IpcServer::Handler: receive raw
7
+ * bytes, return raw bytes. msgpack decode/encode and command dispatch are
8
+ * the caller's responsibility (or the codegen's, when a generated dispatcher
9
+ * is wired in).
10
+ */
11
+ export type IpcServerHandler = (
12
+ clientId: number,
13
+ request: Uint8Array,
14
+ ) => Promise<Uint8Array> | Uint8Array;
15
+
16
+ /**
17
+ * UDS server with the same wire format as UdsIpcClient and the C++
18
+ * ipc::IpcServer socket transport: 4-byte LE length prefix, 8-byte LE request
19
+ * id (echoed on the response), then the payload; the length counts the id
20
+ * plus the payload. Accepts multiple concurrent connections; handler
21
+ * invocations are serialised per-connection.
22
+ *
23
+ * Signal handling is the caller's responsibility (unlike the C++ server's
24
+ * install_default_signal_handlers); the socket file is unlinked on close()
25
+ * and best-effort on process exit.
26
+ */
27
+ export class UdsIpcServer {
28
+ private server: net.Server;
29
+ private nextClientId = 0;
30
+ private connections = new Set<net.Socket>();
31
+ private readonly unlinkOnExit = () => {
32
+ try {
33
+ fs.unlinkSync(this.socketPath);
34
+ } catch {
35
+ /* may already be gone */
36
+ }
37
+ };
38
+
39
+ private constructor(
40
+ server: net.Server,
41
+ private socketPath: string,
42
+ ) {
43
+ this.server = server;
44
+ }
45
+
46
+ static async listen(
47
+ socketPath: string,
48
+ handler: IpcServerHandler,
49
+ ): Promise<UdsIpcServer> {
50
+ try {
51
+ fs.unlinkSync(socketPath);
52
+ } catch {
53
+ /* socket file may not exist; ignore */
54
+ }
55
+
56
+ const server = net.createServer();
57
+ const instance = new UdsIpcServer(server, socketPath);
58
+ server.on("connection", (conn) => instance.handleConnection(conn, handler));
59
+
60
+ await new Promise<void>((resolve, reject) => {
61
+ const onError = (err: Error) => {
62
+ server.removeListener("listening", onListening);
63
+ reject(err);
64
+ };
65
+ const onListening = () => {
66
+ server.removeListener("error", onError);
67
+ resolve();
68
+ };
69
+ server.once("error", onError);
70
+ server.once("listening", onListening);
71
+ server.listen(socketPath);
72
+ });
73
+
74
+ // Restrict the socket to the owner, matching the C++ server (and the
75
+ // 0600 mode used for SHM segments).
76
+ fs.chmodSync(socketPath, 0o600);
77
+
78
+ // Best-effort cleanup if the process exits without close().
79
+ process.on("exit", instance.unlinkOnExit);
80
+
81
+ return instance;
82
+ }
83
+
84
+ async close(): Promise<void> {
85
+ // Force-close live connections (matching the C++ server's shutdown) so close()
86
+ // resolves promptly instead of blocking until every client happens to disconnect.
87
+ for (const conn of this.connections) {
88
+ conn.destroy();
89
+ }
90
+ this.connections.clear();
91
+ await new Promise<void>((resolve) => this.server.close(() => resolve()));
92
+ process.removeListener("exit", this.unlinkOnExit);
93
+ try {
94
+ fs.unlinkSync(this.socketPath);
95
+ } catch {
96
+ /* may already be gone */
97
+ }
98
+ }
99
+
100
+ private handleConnection(conn: net.Socket, handler: IpcServerHandler): void {
101
+ const clientId = this.nextClientId++;
102
+ this.connections.add(conn);
103
+ conn.on("close", () => this.connections.delete(conn));
104
+ let buffer = Buffer.alloc(0);
105
+ let chain: Promise<void> = Promise.resolve();
106
+
107
+ conn.on("data", (chunk: Buffer) => {
108
+ buffer =
109
+ buffer.length === 0
110
+ ? Buffer.from(chunk)
111
+ : Buffer.concat([buffer, chunk]);
112
+ while (buffer.length >= 4) {
113
+ const len = buffer.readUInt32LE(0);
114
+ if (len > MAX_FRAME_SIZE) {
115
+ // Corrupt/malicious frame — drop the connection instead of
116
+ // buffering up to the claimed size.
117
+ conn.destroy(
118
+ new Error(
119
+ `UdsIpcServer: oversized frame (${len} bytes exceeds MAX_FRAME_SIZE)`,
120
+ ),
121
+ );
122
+ return;
123
+ }
124
+ if (len < 8) {
125
+ // Shorter than the request-id field: the peer speaks the id-less
126
+ // protocol. Drop the connection with a clear reason.
127
+ conn.destroy(
128
+ new Error(
129
+ `UdsIpcServer: ${len}-byte frame is shorter than the request-id field — ` +
130
+ "IPC protocol mismatch (envelope ids); update the peer binary/package",
131
+ ),
132
+ );
133
+ return;
134
+ }
135
+ if (buffer.length < 4 + len) break;
136
+ const requestId = buffer.readBigUInt64LE(4);
137
+ // Copy into a standalone Buffer (not a subarray view, and not a plain Uint8Array): handlers
138
+ // decode with msgpackr, which relies on Buffer semantics for correct string/binary decoding.
139
+ const payload = Buffer.from(buffer.subarray(12, 4 + len));
140
+ buffer = buffer.subarray(4 + len);
141
+
142
+ const prev = chain;
143
+ chain = (async () => {
144
+ await prev;
145
+ try {
146
+ const resp = await handler(clientId, payload);
147
+ const header = Buffer.allocUnsafe(12);
148
+ header.writeUInt32LE(resp.length + 8, 0); // length counts id + payload
149
+ header.writeBigUInt64LE(requestId, 4);
150
+ conn.write(header);
151
+ conn.write(resp);
152
+ } catch (err) {
153
+ conn.destroy(err as Error);
154
+ }
155
+ })();
156
+ void chain.catch(() => {
157
+ /* errors already handled by destroying the connection */
158
+ });
159
+ }
160
+ });
161
+
162
+ conn.on("error", () => {
163
+ /* swallowed — clients reconnect */
164
+ });
165
+ }
166
+ }