@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
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
2
|
+
import { open, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { threadId } from "node:worker_threads";
|
|
6
|
+
import { IpcError, IpcProcessExitedError, IpcSpawnError } from "./errors.js";
|
|
7
|
+
import { createNapiShmAsyncClient } from "./shm_client.js";
|
|
8
|
+
import { IpcClientAsync } from "./types.js";
|
|
9
|
+
import { UdsIpcClient } from "./uds_client.js";
|
|
10
|
+
|
|
11
|
+
export type SpawnedTransport = "uds" | "shm";
|
|
12
|
+
|
|
13
|
+
export interface SpawnedProcessBackendOptions {
|
|
14
|
+
/** Absolute path of the server binary to spawn. Callers resolve it (and fail with retry=false if missing). */
|
|
15
|
+
binaryPath: string;
|
|
16
|
+
/** Binary name used in error messages and log labels. */
|
|
17
|
+
binaryName: string;
|
|
18
|
+
/** Prefix for the per-instance ipc path (socket / shm name). */
|
|
19
|
+
instancePrefix: string;
|
|
20
|
+
/** Argv template; each '{path}' is replaced with the backend's ipc path. */
|
|
21
|
+
ipcPathArgs: string[];
|
|
22
|
+
transport: SpawnedTransport;
|
|
23
|
+
/** Receives the child's stdout/stderr lines. Without it, output is captured to a temp log file. */
|
|
24
|
+
logger?: (msg: string) => void;
|
|
25
|
+
connectTimeoutMs?: number;
|
|
26
|
+
env?: NodeJS.ProcessEnv;
|
|
27
|
+
extraArgs?: string[];
|
|
28
|
+
/**
|
|
29
|
+
* Respawn the server on the next call() after it dies, instead of failing all
|
|
30
|
+
* subsequent calls. Only safe for stateless servers: a respawned process
|
|
31
|
+
* remembers nothing, so any server-side session state (forks, cursors) held
|
|
32
|
+
* by callers would silently dangle. In-flight calls at the time of death
|
|
33
|
+
* still reject (with retry=true); only later calls see the fresh process.
|
|
34
|
+
*/
|
|
35
|
+
respawn?: boolean;
|
|
36
|
+
/** SHM only: fixed client slot id. When unset the client self-allocates a free slot. */
|
|
37
|
+
clientId?: number;
|
|
38
|
+
/** SHM only: override the native addon path. */
|
|
39
|
+
napiPath?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Backstop for a spawned server that is alive but never reaches listen().
|
|
43
|
+
// This is a broken-process detector, not a performance expectation: servers
|
|
44
|
+
// create their socket before any heavy initialization, so the timed window
|
|
45
|
+
// covers only exec + linking + minimal init, and requests issued before the
|
|
46
|
+
// server is fully initialized simply wait in the socket buffer. When the
|
|
47
|
+
// backstop fires, the wedged process is killed rather than orphaned.
|
|
48
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 60_000;
|
|
49
|
+
// After destroy() sends SIGTERM, how long to wait before escalating to
|
|
50
|
+
// SIGKILL so teardown cannot hang on a server that is stuck before its signal
|
|
51
|
+
// handlers were installed (or is wedged inside them).
|
|
52
|
+
const SIGTERM_GRACE_MS = 5_000;
|
|
53
|
+
// How long a failed call() waits for the child's 'exit' event before deciding
|
|
54
|
+
// the process is still alive. The socket usually breaks before 'exit' lands,
|
|
55
|
+
// so without this grace a death would be misreported as a bare transport error.
|
|
56
|
+
const EXIT_ATTRIBUTION_GRACE_MS = 250;
|
|
57
|
+
|
|
58
|
+
let instanceCounter = 0;
|
|
59
|
+
|
|
60
|
+
/** One spawned process together with the connection into it. */
|
|
61
|
+
interface Incarnation {
|
|
62
|
+
child: ChildProcess;
|
|
63
|
+
client: IpcClientAsync;
|
|
64
|
+
exitPromise: Promise<void>;
|
|
65
|
+
exitInfo?: { code: number | null; signal: NodeJS.Signals | null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* An IpcClientAsync backed by a spawned server process. Owns the process
|
|
70
|
+
* lifecycle end to end: spawn, connect (raced against child death, with a
|
|
71
|
+
* kill-on-expiry backstop), death detection, optional lazy respawn, and
|
|
72
|
+
* teardown with SIGTERM→SIGKILL escalation. Failures surface as IpcError
|
|
73
|
+
* subclasses whose `retry` property tells callers whether the operation may
|
|
74
|
+
* be retried; no process or transport state is exposed on the API.
|
|
75
|
+
*/
|
|
76
|
+
export class SpawnedProcessBackend implements IpcClientAsync {
|
|
77
|
+
private current?: Incarnation;
|
|
78
|
+
private starting?: Promise<Incarnation>;
|
|
79
|
+
private destroying = false;
|
|
80
|
+
/** Set when the process died and respawn is disabled; fails all later calls. */
|
|
81
|
+
private exitError?: IpcProcessExitedError;
|
|
82
|
+
private readonly respawn: boolean;
|
|
83
|
+
private readonly ipcPath: string;
|
|
84
|
+
private readonly logPath?: string;
|
|
85
|
+
|
|
86
|
+
private constructor(private readonly options: SpawnedProcessBackendOptions) {
|
|
87
|
+
this.respawn = options.respawn ?? false;
|
|
88
|
+
const instanceId = `${options.instancePrefix}-${process.pid}-${threadId}-${instanceCounter++}`;
|
|
89
|
+
// The ipc path is per-backend, not per-incarnation, so getIpcPath() stays
|
|
90
|
+
// stable across respawns for anyone who was handed the path.
|
|
91
|
+
this.ipcPath =
|
|
92
|
+
options.transport === "shm"
|
|
93
|
+
? `${instanceId}.shm`
|
|
94
|
+
: join(tmpdir(), `${instanceId}.sock`);
|
|
95
|
+
// Same for the log file: respawns append, preserving the death's last words.
|
|
96
|
+
this.logPath = options.logger
|
|
97
|
+
? undefined
|
|
98
|
+
: join(tmpdir(), `${instanceId}.log`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
static async spawn(
|
|
102
|
+
options: SpawnedProcessBackendOptions,
|
|
103
|
+
): Promise<SpawnedProcessBackend> {
|
|
104
|
+
if (options.respawn && options.transport === "shm") {
|
|
105
|
+
throw new IpcError(
|
|
106
|
+
`respawn is not supported over the shm transport`,
|
|
107
|
+
/*retry=*/ false,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const backend = new SpawnedProcessBackend(options);
|
|
111
|
+
backend.current = await backend.spawnIncarnation();
|
|
112
|
+
return backend;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
getIpcPath(): string {
|
|
116
|
+
return this.ipcPath;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async call(input: Uint8Array): Promise<Uint8Array> {
|
|
120
|
+
const incarnation = await this.ensureUp();
|
|
121
|
+
try {
|
|
122
|
+
return await incarnation.client.call(input);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
throw await this.attributeCallError(incarnation, err);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
sendProcessSignal(signal: NodeJS.Signals): void {
|
|
129
|
+
const child = this.current?.child;
|
|
130
|
+
if (child && child.exitCode === null && child.signalCode === null) {
|
|
131
|
+
child.kill(signal);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async destroy(): Promise<void> {
|
|
136
|
+
// Mark intentional teardown so the exit handler doesn't report it as an
|
|
137
|
+
// unexpected death (or trigger a respawn).
|
|
138
|
+
this.destroying = true;
|
|
139
|
+
// A respawn may be mid-flight; let it settle so its child can't leak.
|
|
140
|
+
await this.starting?.then(
|
|
141
|
+
(incarnation) => {
|
|
142
|
+
this.current = incarnation;
|
|
143
|
+
},
|
|
144
|
+
() => {},
|
|
145
|
+
);
|
|
146
|
+
const incarnation = this.current;
|
|
147
|
+
this.current = undefined;
|
|
148
|
+
if (!incarnation) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
await incarnation.client.destroy();
|
|
152
|
+
const { child } = incarnation;
|
|
153
|
+
let killTimer: NodeJS.Timeout | undefined;
|
|
154
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
155
|
+
child.kill("SIGTERM");
|
|
156
|
+
killTimer = setTimeout(() => {
|
|
157
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
158
|
+
child.kill("SIGKILL");
|
|
159
|
+
}
|
|
160
|
+
}, SIGTERM_GRACE_MS);
|
|
161
|
+
}
|
|
162
|
+
await incarnation.exitPromise;
|
|
163
|
+
if (killTimer !== undefined) {
|
|
164
|
+
clearTimeout(killTimer);
|
|
165
|
+
}
|
|
166
|
+
child.stdout?.destroy();
|
|
167
|
+
child.stderr?.destroy();
|
|
168
|
+
child.removeAllListeners();
|
|
169
|
+
await this.cleanupIpcPath();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Return the live incarnation, lazily respawning one when allowed. */
|
|
173
|
+
private async ensureUp(): Promise<Incarnation> {
|
|
174
|
+
if (this.destroying) {
|
|
175
|
+
throw new IpcError(
|
|
176
|
+
`${this.options.binaryName} backend destroyed`,
|
|
177
|
+
/*retry=*/ false,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if (this.current) {
|
|
181
|
+
return this.current;
|
|
182
|
+
}
|
|
183
|
+
if (this.exitError) {
|
|
184
|
+
throw this.exitError;
|
|
185
|
+
}
|
|
186
|
+
// Lazy, shared respawn: the first caller after a death starts it, everyone
|
|
187
|
+
// else awaits the same attempt. Lazy (rather than eager-on-exit) so a
|
|
188
|
+
// crashing binary cannot respawn-loop with no one asking for it.
|
|
189
|
+
this.starting ??= this.spawnIncarnation()
|
|
190
|
+
.then((incarnation) => {
|
|
191
|
+
if (this.destroying) {
|
|
192
|
+
// destroy() raced us and already awaited this promise; it owns teardown.
|
|
193
|
+
return incarnation;
|
|
194
|
+
}
|
|
195
|
+
this.current = incarnation;
|
|
196
|
+
return incarnation;
|
|
197
|
+
})
|
|
198
|
+
.finally(() => {
|
|
199
|
+
this.starting = undefined;
|
|
200
|
+
});
|
|
201
|
+
return await this.starting;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Convert a failed call into the death of the process when that is what
|
|
206
|
+
* actually happened: the socket breaks before the child's 'exit' event
|
|
207
|
+
* lands, so wait a short grace for exit attribution before giving up and
|
|
208
|
+
* rethrowing the transport error as-is.
|
|
209
|
+
*/
|
|
210
|
+
private async attributeCallError(
|
|
211
|
+
incarnation: Incarnation,
|
|
212
|
+
err: unknown,
|
|
213
|
+
): Promise<unknown> {
|
|
214
|
+
if (!incarnation.exitInfo && !this.destroying) {
|
|
215
|
+
await Promise.race([
|
|
216
|
+
incarnation.exitPromise,
|
|
217
|
+
new Promise((resolve) =>
|
|
218
|
+
setTimeout(resolve, EXIT_ATTRIBUTION_GRACE_MS),
|
|
219
|
+
),
|
|
220
|
+
]);
|
|
221
|
+
}
|
|
222
|
+
if (incarnation.exitInfo && !this.destroying) {
|
|
223
|
+
const { code, signal } = incarnation.exitInfo;
|
|
224
|
+
return new IpcProcessExitedError(
|
|
225
|
+
`${this.options.binaryName} exited unexpectedly (code=${code}, signal=${signal})` +
|
|
226
|
+
(this.logPath !== undefined ? `; see logs: ${this.logPath}` : ""),
|
|
227
|
+
code,
|
|
228
|
+
signal,
|
|
229
|
+
this.logPath,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
return err;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Spawn the server process and connect to it; kills the child on any failure. */
|
|
236
|
+
private async spawnIncarnation(): Promise<Incarnation> {
|
|
237
|
+
const { options } = this;
|
|
238
|
+
if (options.transport === "uds") {
|
|
239
|
+
await rm(this.ipcPath, { force: true });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Without a live logger, capture the child's stdout/stderr to the backend's
|
|
243
|
+
// log file (a plain fd, not a pipe — a pipe would keep the libuv loop
|
|
244
|
+
// referenced and break clean process exit). The path is surfaced on
|
|
245
|
+
// failures so the child's errors are recoverable instead of vanishing.
|
|
246
|
+
// Async fs throughout: this runs on respawn under exactly the machine
|
|
247
|
+
// conditions where a sync open against a struggling disk could stall the
|
|
248
|
+
// whole event loop.
|
|
249
|
+
const logFile =
|
|
250
|
+
this.logPath !== undefined ? await open(this.logPath, "a") : undefined;
|
|
251
|
+
const child = spawn(
|
|
252
|
+
options.binaryPath,
|
|
253
|
+
[
|
|
254
|
+
...options.ipcPathArgs.map((arg) =>
|
|
255
|
+
arg === "{path}" ? this.ipcPath : arg,
|
|
256
|
+
),
|
|
257
|
+
...(options.extraArgs ?? []),
|
|
258
|
+
],
|
|
259
|
+
{
|
|
260
|
+
stdio: [
|
|
261
|
+
"ignore",
|
|
262
|
+
options.logger ? "pipe" : logFile!.fd,
|
|
263
|
+
options.logger ? "pipe" : logFile!.fd,
|
|
264
|
+
],
|
|
265
|
+
env: { ...process.env, ...(options.env ?? {}) },
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
if (options.logger) {
|
|
269
|
+
child.stdout?.on("data", (data: Buffer) =>
|
|
270
|
+
options.logger?.(
|
|
271
|
+
`[${options.binaryName} stdout] ${data.toString().trimEnd()}`,
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
child.stderr?.on("data", (data: Buffer) =>
|
|
275
|
+
options.logger?.(
|
|
276
|
+
`[${options.binaryName} stderr] ${data.toString().trimEnd()}`,
|
|
277
|
+
),
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const incarnation: Partial<Incarnation> & { child: ChildProcess } = {
|
|
282
|
+
child,
|
|
283
|
+
};
|
|
284
|
+
const exitPromise = new Promise<void>((resolve) => {
|
|
285
|
+
child.on("exit", (code, signal) => {
|
|
286
|
+
incarnation.exitInfo = { code, signal };
|
|
287
|
+
this.onIncarnationExit(incarnation as Incarnation, code, signal);
|
|
288
|
+
resolve();
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
incarnation.exitPromise = exitPromise;
|
|
292
|
+
|
|
293
|
+
const childReadyFailure = new Promise<never>((_, reject) => {
|
|
294
|
+
// Spawn syscall failures are the one place errno distinguishes a
|
|
295
|
+
// configuration error (missing/non-executable binary → retrying cannot
|
|
296
|
+
// help) from an environmental one.
|
|
297
|
+
child.once("error", (err) => {
|
|
298
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
299
|
+
const retry = code !== "ENOENT" && code !== "EACCES";
|
|
300
|
+
reject(
|
|
301
|
+
new IpcSpawnError(
|
|
302
|
+
`Failed to spawn ${options.binaryName}: ${err.message}`,
|
|
303
|
+
retry,
|
|
304
|
+
{ cause: err },
|
|
305
|
+
),
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
child.once("exit", (code, signal) => {
|
|
309
|
+
reject(
|
|
310
|
+
new IpcSpawnError(
|
|
311
|
+
`${options.binaryName} exited before IPC connection was ready (code=${code}, signal=${signal})`,
|
|
312
|
+
/*retry=*/ true,
|
|
313
|
+
),
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// Observe immediately: childReadyFailure can reject during the awaits
|
|
319
|
+
// below (spawn failures land on nextTick), before Promise.race attaches
|
|
320
|
+
// its handler — without this it would count as an unhandled rejection.
|
|
321
|
+
childReadyFailure.catch(() => {});
|
|
322
|
+
|
|
323
|
+
if (logFile !== undefined) {
|
|
324
|
+
// spawn() dups the fd synchronously; the parent's handle isn't needed.
|
|
325
|
+
// Closed only now, after the 'error'/'exit' listeners are attached: spawn
|
|
326
|
+
// failures are emitted on nextTick, so an await between spawn() and the
|
|
327
|
+
// listeners would let them fire unhandled.
|
|
328
|
+
await logFile.close();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Liveness-based startup: wait on the connect for as long as the process
|
|
332
|
+
// is alive (the connect path retries socket-not-ready errors internally),
|
|
333
|
+
// and fail immediately with the real cause if the process dies first. On
|
|
334
|
+
// any failure, reap the child and its ipc path so a failed spawn cannot
|
|
335
|
+
// leak an orphan process still holding sockets or database locks.
|
|
336
|
+
try {
|
|
337
|
+
incarnation.client = await Promise.race([
|
|
338
|
+
this.connectClient(),
|
|
339
|
+
childReadyFailure,
|
|
340
|
+
]);
|
|
341
|
+
} catch (err) {
|
|
342
|
+
if (child.pid !== undefined) {
|
|
343
|
+
// SIGKILL, not SIGTERM: the process never became ready, so it has no
|
|
344
|
+
// state to flush, and a wedged process may not honour SIGTERM.
|
|
345
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
346
|
+
child.kill("SIGKILL");
|
|
347
|
+
}
|
|
348
|
+
await exitPromise;
|
|
349
|
+
}
|
|
350
|
+
child.stdout?.destroy();
|
|
351
|
+
child.stderr?.destroy();
|
|
352
|
+
child.removeAllListeners();
|
|
353
|
+
await this.cleanupIpcPath();
|
|
354
|
+
throw this.asSpawnError(err);
|
|
355
|
+
}
|
|
356
|
+
return incarnation as Incarnation;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Handles an incarnation's death: reject/replace state so later calls behave per the respawn policy. */
|
|
360
|
+
private onIncarnationExit(
|
|
361
|
+
incarnation: Incarnation,
|
|
362
|
+
code: number | null,
|
|
363
|
+
signal: NodeJS.Signals | null,
|
|
364
|
+
): void {
|
|
365
|
+
// Break the connection so in-flight calls reject rather than wait forever
|
|
366
|
+
// (matters over SHM, where there is no socket to break).
|
|
367
|
+
void incarnation.client?.destroy();
|
|
368
|
+
if (this.destroying || this.current !== incarnation) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
this.current = undefined;
|
|
372
|
+
if (!this.respawn) {
|
|
373
|
+
this.exitError = new IpcProcessExitedError(
|
|
374
|
+
`${this.options.binaryName} exited unexpectedly (code=${code}, signal=${signal})` +
|
|
375
|
+
(this.logPath !== undefined ? `; see logs: ${this.logPath}` : ""),
|
|
376
|
+
code,
|
|
377
|
+
signal,
|
|
378
|
+
this.logPath,
|
|
379
|
+
);
|
|
380
|
+
console.error(this.exitError.message);
|
|
381
|
+
} else {
|
|
382
|
+
console.error(
|
|
383
|
+
`${this.options.binaryName} exited unexpectedly (code=${code}, signal=${signal}); will respawn on next call`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private async connectClient(): Promise<IpcClientAsync> {
|
|
389
|
+
const { options } = this;
|
|
390
|
+
const timeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
391
|
+
if (options.transport === "uds") {
|
|
392
|
+
// UdsIpcClient.connect retries socket-not-ready errors (path not yet
|
|
393
|
+
// created, server not yet accepting, backlog momentarily full) until the
|
|
394
|
+
// budget expires, and fails immediately on hard errors. Process death is
|
|
395
|
+
// raced against this by the caller, so a dead server short-circuits the
|
|
396
|
+
// wait with its real exit cause.
|
|
397
|
+
return await UdsIpcClient.connect(this.ipcPath, {
|
|
398
|
+
connectTimeoutMs: timeoutMs,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
// The SHM client attaches to server-created rings, so creation can race
|
|
402
|
+
// server startup; retry until the backstop expires.
|
|
403
|
+
const deadline = Date.now() + timeoutMs;
|
|
404
|
+
let lastError: unknown;
|
|
405
|
+
while (Date.now() <= deadline) {
|
|
406
|
+
try {
|
|
407
|
+
return createNapiShmAsyncClient(this.ipcPath.replace(/\.shm$/, ""), {
|
|
408
|
+
clientId: options.clientId,
|
|
409
|
+
customAddonPath: options.napiPath,
|
|
410
|
+
});
|
|
411
|
+
} catch (err) {
|
|
412
|
+
lastError = err;
|
|
413
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const message =
|
|
417
|
+
lastError instanceof Error ? lastError.message : String(lastError);
|
|
418
|
+
throw new IpcSpawnError(
|
|
419
|
+
`Timed out connecting to ${this.options.binaryName}: ${message}`,
|
|
420
|
+
/*retry=*/ true,
|
|
421
|
+
{ cause: lastError },
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Wrap a spawn/connect failure as IpcSpawnError and point at the captured log. */
|
|
426
|
+
private asSpawnError(err: unknown): IpcSpawnError {
|
|
427
|
+
const logHint =
|
|
428
|
+
this.logPath !== undefined ? `; see logs: ${this.logPath}` : "";
|
|
429
|
+
if (err instanceof IpcSpawnError) {
|
|
430
|
+
// Already classified at its source (child spawn 'error', exit-before-ready).
|
|
431
|
+
return new IpcSpawnError(err.message + logHint, err.retry, {
|
|
432
|
+
cause: err.cause ?? err,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
// Everything else reaching here (connect backstop expiry, transient
|
|
436
|
+
// transport errors) is environmental.
|
|
437
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
438
|
+
return new IpcSpawnError(
|
|
439
|
+
`Failed to start ${this.options.binaryName}: ${message}${logHint}`,
|
|
440
|
+
/*retry=*/ true,
|
|
441
|
+
{ cause: err },
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private async cleanupIpcPath(): Promise<void> {
|
|
446
|
+
try {
|
|
447
|
+
if (this.options.transport === "uds") {
|
|
448
|
+
await rm(this.ipcPath, { force: true });
|
|
449
|
+
}
|
|
450
|
+
if (this.options.transport === "shm") {
|
|
451
|
+
const shmName = this.ipcPath.replace(/\.shm$/, "");
|
|
452
|
+
for (const suffix of ["_request", "_response"]) {
|
|
453
|
+
await rm(`/dev/shm/${shmName}${suffix}`, { force: true });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
} catch {
|
|
457
|
+
// Cleanup is best-effort; the paths live under tmpdir.
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal byte-in / byte-out interface that the ipc-codegen-emitted
|
|
3
|
+
* <Service>Api types consume. Both UDS and SHM transports satisfy this.
|
|
4
|
+
*/
|
|
5
|
+
export interface IpcClientAsync {
|
|
6
|
+
call(input: Uint8Array): Promise<Uint8Array>;
|
|
7
|
+
destroy(): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface IpcClientSync {
|
|
11
|
+
call(input: Uint8Array): Uint8Array;
|
|
12
|
+
destroy(): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Shared transport constants, mirroring cpp/ipc_runtime/constants.hpp —
|
|
16
|
+
// keep the two in sync.
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Maximum length-prefix value accepted on receive. A frame claiming more
|
|
20
|
+
* than this is treated as corruption and the connection is closed instead
|
|
21
|
+
* of buffering the claimed size.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_FRAME_SIZE = 256 * 1024 * 1024; // 256 MiB
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Total budget (ms) for connect() retry loops, covering the window where
|
|
27
|
+
* the server process is still starting up.
|
|
28
|
+
*/
|
|
29
|
+
export const CONNECT_RETRY_BUDGET_MS = 5000;
|
|
30
|
+
|
|
31
|
+
/** Default ring size for SHM transports (per direction, per client). */
|
|
32
|
+
export const DEFAULT_RING_SIZE = 4 * 1024 * 1024; // 4 MiB
|
|
33
|
+
|
|
34
|
+
/** Default listen backlog for UDS servers. */
|
|
35
|
+
export const SOCKET_BACKLOG = 10;
|
|
36
|
+
|
|
37
|
+
/** Default per-call timeout: 0 = infinite (matches the C++ client APIs). */
|
|
38
|
+
export const DEFAULT_CALL_TIMEOUT_NS = 0;
|
package/src/uds.test.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
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
|
+
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import * as assert from "node:assert/strict";
|
|
7
|
+
import * as net from "node:net";
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { UdsIpcClient } from "./uds_client.js";
|
|
12
|
+
import { UdsIpcServer } from "./uds_server.js";
|
|
13
|
+
|
|
14
|
+
function tmpSocketPath(tag: string): string {
|
|
15
|
+
return path.join(os.tmpdir(), `ipc_ts_test_${tag}_${process.pid}.sock`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("echo round-trip", async () => {
|
|
19
|
+
const socketPath = tmpSocketPath("echo");
|
|
20
|
+
const server = await UdsIpcServer.listen(socketPath, (_id, req) => req);
|
|
21
|
+
const client = await UdsIpcClient.connect(socketPath);
|
|
22
|
+
try {
|
|
23
|
+
const payload = new Uint8Array([1, 2, 3, 4, 5]);
|
|
24
|
+
const resp = await client.call(payload);
|
|
25
|
+
assert.deepEqual(resp, payload);
|
|
26
|
+
|
|
27
|
+
// Pipelined calls resolve FIFO.
|
|
28
|
+
const [a, b] = await Promise.all([
|
|
29
|
+
client.call(new Uint8Array([7])),
|
|
30
|
+
client.call(new Uint8Array([8, 9])),
|
|
31
|
+
]);
|
|
32
|
+
assert.deepEqual(a, new Uint8Array([7]));
|
|
33
|
+
assert.deepEqual(b, new Uint8Array([8, 9]));
|
|
34
|
+
} finally {
|
|
35
|
+
await client.destroy();
|
|
36
|
+
await server.close();
|
|
37
|
+
}
|
|
38
|
+
assert.equal(fs.existsSync(socketPath), false, "socket unlinked on close");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("socket file is chmod 0600", async () => {
|
|
42
|
+
const socketPath = tmpSocketPath("chmod");
|
|
43
|
+
const server = await UdsIpcServer.listen(socketPath, (_id, req) => req);
|
|
44
|
+
try {
|
|
45
|
+
const mode = fs.statSync(socketPath).mode & 0o777;
|
|
46
|
+
assert.equal(mode, 0o600);
|
|
47
|
+
} finally {
|
|
48
|
+
await server.close();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("zero-length response resolves (not a hang/error)", async () => {
|
|
53
|
+
const socketPath = tmpSocketPath("zlen");
|
|
54
|
+
const server = await UdsIpcServer.listen(socketPath, () => new Uint8Array(0));
|
|
55
|
+
const client = await UdsIpcClient.connect(socketPath);
|
|
56
|
+
try {
|
|
57
|
+
const resp = await client.call(new Uint8Array([42]));
|
|
58
|
+
assert.equal(resp.length, 0);
|
|
59
|
+
} finally {
|
|
60
|
+
await client.destroy();
|
|
61
|
+
await server.close();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("disconnect rejects pending calls and fails fast afterwards", async () => {
|
|
66
|
+
const socketPath = tmpSocketPath("disc");
|
|
67
|
+
// Raw server that accepts, reads, then kills the connection without
|
|
68
|
+
// responding.
|
|
69
|
+
const rawServer = net.createServer((conn) => {
|
|
70
|
+
conn.once("data", () => conn.destroy());
|
|
71
|
+
});
|
|
72
|
+
await new Promise<void>((resolve) =>
|
|
73
|
+
rawServer.listen(socketPath, () => resolve()),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const client = await UdsIpcClient.connect(socketPath);
|
|
77
|
+
try {
|
|
78
|
+
await assert.rejects(client.call(new Uint8Array([1])));
|
|
79
|
+
// Socket is dead — further calls fail fast instead of queueing.
|
|
80
|
+
await assert.rejects(client.call(new Uint8Array([2])), /closed/);
|
|
81
|
+
} finally {
|
|
82
|
+
await client.destroy();
|
|
83
|
+
rawServer.close();
|
|
84
|
+
fs.rmSync(socketPath, { force: true });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("client rejects oversized frame from server", async () => {
|
|
89
|
+
const socketPath = tmpSocketPath("oversize_cli");
|
|
90
|
+
// Raw server that answers any request with a corrupt 0xFFFFFFFF length
|
|
91
|
+
// prefix.
|
|
92
|
+
const rawServer = net.createServer((conn) => {
|
|
93
|
+
conn.once("data", () => {
|
|
94
|
+
const bogus = Buffer.allocUnsafe(4);
|
|
95
|
+
bogus.writeUInt32LE(0xffffffff, 0);
|
|
96
|
+
conn.write(bogus);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
await new Promise<void>((resolve) =>
|
|
100
|
+
rawServer.listen(socketPath, () => resolve()),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const client = await UdsIpcClient.connect(socketPath);
|
|
104
|
+
try {
|
|
105
|
+
await assert.rejects(client.call(new Uint8Array([1])), /oversized frame/);
|
|
106
|
+
} finally {
|
|
107
|
+
await client.destroy();
|
|
108
|
+
rawServer.close();
|
|
109
|
+
fs.rmSync(socketPath, { force: true });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("client fails all pending calls on a response with an unknown request id", async () => {
|
|
114
|
+
const socketPath = tmpSocketPath("unknown_id");
|
|
115
|
+
// Raw server that answers with a well-formed frame whose request id matches
|
|
116
|
+
// nothing the client sent — the correlation-desync case.
|
|
117
|
+
const rawServer = net.createServer((conn) => {
|
|
118
|
+
conn.once("data", () => {
|
|
119
|
+
const frame = Buffer.allocUnsafe(12 + 1);
|
|
120
|
+
frame.writeUInt32LE(1 + 8, 0);
|
|
121
|
+
frame.writeBigUInt64LE(0xdeadbeefn, 4);
|
|
122
|
+
frame.writeUInt8(42, 12);
|
|
123
|
+
conn.write(frame);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
await new Promise<void>((resolve) =>
|
|
127
|
+
rawServer.listen(socketPath, () => resolve()),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const client = await UdsIpcClient.connect(socketPath);
|
|
131
|
+
try {
|
|
132
|
+
await assert.rejects(
|
|
133
|
+
client.call(new Uint8Array([1])),
|
|
134
|
+
/unknown request id/,
|
|
135
|
+
);
|
|
136
|
+
} finally {
|
|
137
|
+
await client.destroy();
|
|
138
|
+
rawServer.close();
|
|
139
|
+
fs.rmSync(socketPath, { force: true });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("client fails loudly on an id-less (old-protocol) frame", async () => {
|
|
144
|
+
const socketPath = tmpSocketPath("idless");
|
|
145
|
+
// Raw server speaking the pre-envelope-id protocol: [4B len][payload] with
|
|
146
|
+
// len < 8.
|
|
147
|
+
const rawServer = net.createServer((conn) => {
|
|
148
|
+
conn.once("data", () => {
|
|
149
|
+
const frame = Buffer.allocUnsafe(4 + 1);
|
|
150
|
+
frame.writeUInt32LE(1, 0);
|
|
151
|
+
frame.writeUInt8(42, 4);
|
|
152
|
+
conn.write(frame);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
await new Promise<void>((resolve) =>
|
|
156
|
+
rawServer.listen(socketPath, () => resolve()),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const client = await UdsIpcClient.connect(socketPath);
|
|
160
|
+
try {
|
|
161
|
+
await assert.rejects(client.call(new Uint8Array([1])), /protocol mismatch/);
|
|
162
|
+
} finally {
|
|
163
|
+
await client.destroy();
|
|
164
|
+
rawServer.close();
|
|
165
|
+
fs.rmSync(socketPath, { force: true });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("server drops connection on oversized frame", async () => {
|
|
170
|
+
const socketPath = tmpSocketPath("oversize_srv");
|
|
171
|
+
const server = await UdsIpcServer.listen(socketPath, (_id, req) => req);
|
|
172
|
+
const conn = net.createConnection(socketPath);
|
|
173
|
+
try {
|
|
174
|
+
await new Promise<void>((resolve, reject) => {
|
|
175
|
+
conn.once("connect", () => resolve());
|
|
176
|
+
conn.once("error", reject);
|
|
177
|
+
});
|
|
178
|
+
const bogus = Buffer.allocUnsafe(4);
|
|
179
|
+
bogus.writeUInt32LE(0xffffffff, 0);
|
|
180
|
+
conn.write(bogus);
|
|
181
|
+
await new Promise<void>((resolve, reject) => {
|
|
182
|
+
const timer = setTimeout(
|
|
183
|
+
() => reject(new Error("server did not close the connection")),
|
|
184
|
+
5000,
|
|
185
|
+
);
|
|
186
|
+
conn.once("close", () => {
|
|
187
|
+
clearTimeout(timer);
|
|
188
|
+
resolve();
|
|
189
|
+
});
|
|
190
|
+
conn.once("error", () => {
|
|
191
|
+
/* RST is fine — close follows */
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
} finally {
|
|
195
|
+
conn.destroy();
|
|
196
|
+
await server.close();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("connect times out against a bound-but-unresponsive path", async () => {
|
|
201
|
+
const socketPath = tmpSocketPath("noaccept");
|
|
202
|
+
fs.rmSync(socketPath, { force: true });
|
|
203
|
+
await assert.rejects(
|
|
204
|
+
UdsIpcClient.connect(socketPath, { connectTimeoutMs: 300 }),
|
|
205
|
+
/timed out/,
|
|
206
|
+
);
|
|
207
|
+
});
|