@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,97 @@
1
+ // Locate the prebuilt ipc_runtime_napi.node addon shipped with this package.
2
+ //
3
+ // The addon is built by `ipc-runtime/bootstrap.sh` (CMake target
4
+ // `ipc_runtime_napi`) and copied into `build/<arch>-<os>/` next to this
5
+ // package's `package.json`. Resolution walks up from this file's URL to the
6
+ // first `package.json` adjacent to a `build/` directory — that's the
7
+ // package root in both `file:`-linked and published consumption.
8
+
9
+ import { createRequire } from "node:module";
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ export type Platform =
15
+ | "x86_64-linux"
16
+ | "x86_64-darwin"
17
+ | "aarch64-linux"
18
+ | "aarch64-darwin";
19
+
20
+ const PLATFORM_TO_BUILD_DIR: Record<Platform, string> = {
21
+ "x86_64-linux": "amd64-linux",
22
+ "x86_64-darwin": "amd64-macos",
23
+ "aarch64-linux": "arm64-linux",
24
+ "aarch64-darwin": "arm64-macos",
25
+ };
26
+
27
+ function detectPlatform(): Platform | null {
28
+ const arch = process.arch;
29
+ const platform = process.platform;
30
+ if (arch === "x64" && platform === "linux") return "x86_64-linux";
31
+ if (arch === "x64" && platform === "darwin") return "x86_64-darwin";
32
+ if (arch === "arm64" && platform === "linux") return "aarch64-linux";
33
+ if (arch === "arm64" && platform === "darwin") return "aarch64-darwin";
34
+ return null;
35
+ }
36
+
37
+ function findPackageRoot(): string | null {
38
+ // `import.meta.url` after tsc compile points at the .js file under
39
+ // <pkg>/dest/...; climb until we find package.json with a sibling build/.
40
+ let currentDir = path.dirname(fileURLToPath(import.meta.url));
41
+ const root = path.parse(currentDir).root;
42
+ while (currentDir !== root) {
43
+ const packageJsonPath = path.join(currentDir, "package.json");
44
+ if (fs.existsSync(packageJsonPath)) {
45
+ const buildDir = path.join(currentDir, "build");
46
+ if (fs.existsSync(buildDir)) {
47
+ return currentDir;
48
+ }
49
+ }
50
+ currentDir = path.dirname(currentDir);
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Resolve the path of `ipc_runtime_napi.node` for the current platform.
57
+ * Returns null if either the platform is unsupported or the artifact is
58
+ * absent (typical reason: ipc-runtime/bootstrap.sh hasn't run yet).
59
+ */
60
+ export function findIpcRuntimeNapi(customPath?: string): string | null {
61
+ if (customPath) {
62
+ return fs.existsSync(customPath) ? path.resolve(customPath) : null;
63
+ }
64
+ const platform = detectPlatform();
65
+ if (!platform) return null;
66
+ const packageRoot = findPackageRoot();
67
+ if (!packageRoot) return null;
68
+ const buildDir = PLATFORM_TO_BUILD_DIR[platform];
69
+ const candidate = path.join(
70
+ packageRoot,
71
+ "build",
72
+ buildDir,
73
+ "ipc_runtime_napi.node",
74
+ );
75
+ return fs.existsSync(candidate) ? candidate : null;
76
+ }
77
+
78
+ /**
79
+ * Load `ipc_runtime_napi.node` and return its native exports
80
+ * (`MsgpackClient`, `MsgpackClientAsync`). Throws a descriptive error when
81
+ * the addon cannot be located or fails to dlopen.
82
+ */
83
+ export function loadIpcRuntimeNapi(customPath?: string): {
84
+ MsgpackClient: new (shmName: string, clientId?: number) => any;
85
+ MsgpackClientAsync: new (shmName: string, clientId?: number) => any;
86
+ } {
87
+ const addonPath = findIpcRuntimeNapi(customPath);
88
+ if (!addonPath) {
89
+ throw new Error(
90
+ "Could not locate ipc_runtime_napi.node. Build with `ipc-runtime/bootstrap.sh` " +
91
+ "or set the optional `customPath` argument to point at a prebuilt addon.",
92
+ );
93
+ }
94
+ // createRequire so this works in both ESM and CJS callers.
95
+ const require = createRequire(import.meta.url);
96
+ return require(addonPath);
97
+ }
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { NapiShmAsyncClient, NapiMsgpackClientAsync } from "./shm_client.js";
4
+
5
+ /**
6
+ * Drives NapiShmAsyncClient through a mock addon, so the id-pairing logic is
7
+ * testable without the native module or a live server.
8
+ */
9
+ class MockAddon implements NapiMsgpackClientAsync {
10
+ public deliver!: (requestId: bigint, response: Buffer) => void;
11
+ public sent: Array<{ requestId: bigint; input: Buffer }> = [];
12
+ public acquires = 0;
13
+ public releases = 0;
14
+ public closed = false;
15
+
16
+ setResponseCallback(cb: (requestId: bigint, response: Buffer) => void): void {
17
+ this.deliver = cb;
18
+ }
19
+ call(requestId: bigint, input: Buffer): void {
20
+ this.sent.push({ requestId, input });
21
+ }
22
+ acquire(): void {
23
+ this.acquires++;
24
+ }
25
+ release(): void {
26
+ this.releases++;
27
+ }
28
+ close(): void {
29
+ this.closed = true;
30
+ }
31
+ }
32
+
33
+ test("shm async client discards a stale frame and still resolves the live call", async () => {
34
+ const addon = new MockAddon();
35
+ const client = new NapiShmAsyncClient(addon);
36
+
37
+ const pending = client.call(new Uint8Array([1, 2, 3]));
38
+ assert.equal(addon.sent.length, 1);
39
+ const liveId = addon.sent[0].requestId;
40
+
41
+ // A leftover frame from a ring's previous occupant: unknown id. Must be
42
+ // discarded — not resolve the live call, not reject anything.
43
+ addon.deliver(liveId ^ 0xdeadbeefn, Buffer.from([0xba, 0xad]));
44
+
45
+ // The real response still pairs and resolves.
46
+ addon.deliver(liveId, Buffer.from([9, 9]));
47
+ assert.deepEqual(await pending, new Uint8Array([9, 9]));
48
+
49
+ // Refcount stayed balanced: one acquire for the call, one release when the
50
+ // live response drained the map (the stale frame must not release).
51
+ assert.equal(addon.acquires, 1);
52
+ assert.equal(addon.releases, 1);
53
+
54
+ await client.destroy();
55
+ });
56
+
57
+ test("shm async client pairs out-of-order responses to the right callers", async () => {
58
+ const addon = new MockAddon();
59
+ const client = new NapiShmAsyncClient(addon);
60
+
61
+ const a = client.call(new Uint8Array([0xaa]));
62
+ const b = client.call(new Uint8Array([0xbb]));
63
+ const [idA, idB] = addon.sent.map((s) => s.requestId);
64
+
65
+ // Complete in reverse order; each caller must get its own payload.
66
+ addon.deliver(idB, Buffer.from([2]));
67
+ addon.deliver(idA, Buffer.from([1]));
68
+ assert.deepEqual(await b, new Uint8Array([2]));
69
+ assert.deepEqual(await a, new Uint8Array([1]));
70
+
71
+ await client.destroy();
72
+ });
@@ -0,0 +1,194 @@
1
+ import { loadIpcRuntimeNapi } from "./native_loader.js";
2
+ import { IpcClientAsync, IpcClientSync } from "./types.js";
3
+
4
+ /**
5
+ * Minimum surface a NAPI msgpack client must expose. Satisfied by the
6
+ * `MsgpackClient` / `MsgpackClientAsync` classes exported from this
7
+ * package's own `ipc_runtime_napi.node` addon (see ipc-runtime/cpp/napi/),
8
+ * which wraps the C++ ipc::IpcClient.
9
+ *
10
+ * The interface is exposed for tests / consumers that want to inject a
11
+ * mock or alternative implementation; the standard production path is the
12
+ * `createNapiShm{Sync,Async}Client` factories below, which load the
13
+ * prebuilt addon shipped in this package's `build/<arch>-<os>/` directory.
14
+ *
15
+ * Note on the async contract: `MsgpackClientAsync.call` is *fire and
16
+ * forget*. Responses arrive via `setResponseCallback` in COMPLETION order on
17
+ * a background-thread → main-thread bridge (Napi::ThreadSafeFunction), each
18
+ * carrying its echoed request id. The TS wrapper below owns the pending map
19
+ * and pairs responses to callers by id.
20
+ */
21
+ export interface NapiMsgpackClientSync {
22
+ call(input: Buffer): Buffer;
23
+ close(): void;
24
+ }
25
+
26
+ export interface NapiMsgpackClientAsync {
27
+ setResponseCallback(cb: (requestId: bigint, response: Buffer) => void): void;
28
+ call(requestId: bigint, input: Buffer): void;
29
+ acquire(): void;
30
+ release(): void;
31
+ /** Stop the native poll thread, release any held TSFN ref, close the client. */
32
+ close(): void;
33
+ }
34
+
35
+ /** Wraps a sync NAPI msgpack client behind the IpcClientSync interface. */
36
+ export class NapiShmSyncClient implements IpcClientSync {
37
+ constructor(private inner: NapiMsgpackClientSync) {}
38
+
39
+ call(input: Uint8Array): Uint8Array {
40
+ const buf = Buffer.isBuffer(input)
41
+ ? input
42
+ : Buffer.from(input.buffer, input.byteOffset, input.byteLength);
43
+ const resp = this.inner.call(buf);
44
+ return new Uint8Array(resp.buffer, resp.byteOffset, resp.byteLength);
45
+ }
46
+
47
+ destroy(): void {
48
+ this.inner.close();
49
+ }
50
+ }
51
+
52
+ interface PendingCallback {
53
+ resolve: (data: Uint8Array) => void;
54
+ reject: (error: Error) => void;
55
+ }
56
+
57
+ /**
58
+ * Wraps the fire-and-forget async NAPI msgpack client behind the
59
+ * `IpcClientAsync` interface. Owns a map of pending calls keyed by request
60
+ * id; the C++ background polling thread invokes `setResponseCallback` once
61
+ * per response (in completion order), and this wrapper pairs it to its
62
+ * caller by the echoed id. Ids start at a random point per client so a
63
+ * stale frame left in a recycled SHM ring slot by a previous occupant
64
+ * cannot pair with a live call.
65
+ *
66
+ * `acquire` / `release` are reference-count hooks the NAPI exposes so the
67
+ * libuv loop is kept alive only while requests are outstanding — without
68
+ * them a `node script.js` would never exit naturally.
69
+ */
70
+ export class NapiShmAsyncClient implements IpcClientAsync {
71
+ private readonly pending = new Map<bigint, PendingCallback>();
72
+ private nextRequestId =
73
+ (BigInt(Math.floor(Math.random() * 0xffffffff)) << 16n) + 1n;
74
+ private destroyed = false;
75
+
76
+ constructor(private inner: NapiMsgpackClientAsync) {
77
+ this.inner.setResponseCallback((requestId: bigint, response: Buffer) => {
78
+ if (this.destroyed) {
79
+ // Late response delivered after destroy(); the native close already
80
+ // balanced the TSFN reference.
81
+ return;
82
+ }
83
+ const cb = this.pending.get(requestId);
84
+ if (cb) {
85
+ this.pending.delete(requestId);
86
+ cb.resolve(new Uint8Array(response));
87
+ if (this.pending.size === 0) {
88
+ this.inner.release();
89
+ }
90
+ } else {
91
+ // SHM rings persist across occupants (slot reclaim / reattach), so a
92
+ // frame addressed to a previous occupant's id is an anticipated
93
+ // leftover — discard it and keep serving live calls. Log it so that if
94
+ // a genuinely lost pairing ever hangs a caller, the evidence is in the
95
+ // log rather than silently dropped. Don't release: no acquire was
96
+ // taken for an orphan response.
97
+ console.warn(
98
+ `NapiShmAsyncClient: discarding response for unknown request id ${requestId} ` +
99
+ "(stale frame from a previous ring occupant?)",
100
+ );
101
+ }
102
+ });
103
+ }
104
+
105
+ call(input: Uint8Array): Promise<Uint8Array> {
106
+ if (this.destroyed) {
107
+ return Promise.reject(
108
+ new Error("NapiShmAsyncClient: call() after destroy()"),
109
+ );
110
+ }
111
+ const buf = Buffer.isBuffer(input)
112
+ ? input
113
+ : Buffer.from(input.buffer, input.byteOffset, input.byteLength);
114
+ return new Promise<Uint8Array>((resolve, reject) => {
115
+ const requestId = this.nextRequestId++;
116
+ if (this.pending.size === 0) {
117
+ this.inner.acquire();
118
+ }
119
+ this.pending.set(requestId, { resolve, reject });
120
+ try {
121
+ this.inner.call(requestId, buf);
122
+ } catch (err: any) {
123
+ // Send failed — unwind the map entry we just added.
124
+ this.pending.delete(requestId);
125
+ if (this.pending.size === 0) {
126
+ this.inner.release();
127
+ }
128
+ reject(
129
+ err instanceof Error
130
+ ? err
131
+ : new Error(`SHM async call failed: ${String(err)}`),
132
+ );
133
+ }
134
+ });
135
+ }
136
+
137
+ async destroy(): Promise<void> {
138
+ if (this.destroyed) {
139
+ return;
140
+ }
141
+ this.destroyed = true;
142
+ // Reject anything still in flight.
143
+ const err = new Error("ipc-runtime SHM client destroyed before response");
144
+ for (const cb of this.pending.values()) {
145
+ cb.reject(err);
146
+ }
147
+ this.pending.clear();
148
+ // Stops the native poll thread and releases the TSFN reference taken
149
+ // when the map went 0 → 1 — without this, Node never exits when
150
+ // destroyed with calls in flight.
151
+ this.inner.close();
152
+ }
153
+ }
154
+
155
+ export interface CreateNapiShmOptions {
156
+ /** MPSC client slot id (default 0). Distinct clients on the same shmName must use distinct slots. */
157
+ clientId?: number;
158
+ /** Override addon path lookup. Rarely needed; useful for tests / unusual deployments. */
159
+ customAddonPath?: string;
160
+ }
161
+
162
+ /**
163
+ * Factories that load the bundled `ipc_runtime_napi.node` addon and
164
+ * construct an MPSC-SHM client wrapped behind the `IpcClient*` interface.
165
+ * Matches the transport used by `ipc::make_server` on the C++ side, so any
166
+ * server started via that helper accepts these clients directly.
167
+ */
168
+ export function createNapiShmSyncClient(
169
+ shmName: string,
170
+ options: CreateNapiShmOptions = {},
171
+ ): NapiShmSyncClient {
172
+ const napi = loadIpcRuntimeNapi(options.customAddonPath);
173
+ return new NapiShmSyncClient(
174
+ // Omit the slot id when not given so the native side self-allocates a free
175
+ // slot (kAutoClientId) instead of aliasing every client onto slot 0.
176
+ options.clientId === undefined
177
+ ? new napi.MsgpackClient(shmName)
178
+ : new napi.MsgpackClient(shmName, options.clientId),
179
+ );
180
+ }
181
+
182
+ export function createNapiShmAsyncClient(
183
+ shmName: string,
184
+ options: CreateNapiShmOptions = {},
185
+ ): NapiShmAsyncClient {
186
+ const napi = loadIpcRuntimeNapi(options.customAddonPath);
187
+ return new NapiShmAsyncClient(
188
+ // Omit the slot id when not given so the native side self-allocates a free
189
+ // slot (kAutoClientId) instead of aliasing every client onto slot 0.
190
+ options.clientId === undefined
191
+ ? new napi.MsgpackClientAsync(shmName)
192
+ : new napi.MsgpackClientAsync(shmName, options.clientId),
193
+ );
194
+ }
@@ -0,0 +1,225 @@
1
+ import assert from "node:assert/strict";
2
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { after, test } from "node:test";
6
+ import { IpcProcessExitedError, IpcSpawnError } from "./errors.js";
7
+ import { SpawnedProcessBackend } from "./spawned_backend.js";
8
+
9
+ const scratch = mkdtempSync(join(tmpdir(), "spawned-backend-test-"));
10
+ after(() => rmSync(scratch, { recursive: true, force: true }));
11
+
12
+ // A minimal length-prefixed echo server speaking the UdsIpcClient wire format.
13
+ // Replies to each request with its own pid as a decimal string, so tests can
14
+ // tell process incarnations apart.
15
+ const PID_ECHO_SERVER = join(scratch, "pid_echo_server.cjs");
16
+ writeFileSync(
17
+ PID_ECHO_SERVER,
18
+ `
19
+ const net = require('node:net');
20
+ const server = net.createServer(conn => {
21
+ let buf = Buffer.alloc(0);
22
+ conn.on('data', chunk => {
23
+ buf = Buffer.concat([buf, chunk]);
24
+ while (buf.length >= 4) {
25
+ const len = buf.readUInt32LE(0);
26
+ if (buf.length < 4 + len) return;
27
+ const requestId = buf.readBigUInt64LE(4); // frame = [len][8B id][payload]
28
+ buf = buf.subarray(4 + len);
29
+ const payload = Buffer.from(String(process.pid));
30
+ const out = Buffer.alloc(12 + payload.length);
31
+ out.writeUInt32LE(payload.length + 8, 0);
32
+ out.writeBigUInt64LE(requestId, 4);
33
+ payload.copy(out, 12);
34
+ conn.write(out);
35
+ }
36
+ });
37
+ });
38
+ server.listen(process.argv[2]);
39
+ `,
40
+ );
41
+
42
+ function shellScript(name: string, body: string): string {
43
+ const path = join(scratch, name);
44
+ writeFileSync(path, `#!/bin/sh\n${body}\n`);
45
+ chmodSync(path, 0o755);
46
+ return path;
47
+ }
48
+
49
+ function spawnPidEcho(
50
+ opts: { respawn?: boolean; connectTimeoutMs?: number } = {},
51
+ ) {
52
+ return SpawnedProcessBackend.spawn({
53
+ binaryPath: process.execPath,
54
+ binaryName: "pid_echo_server",
55
+ instancePrefix: "pid-echo-test",
56
+ ipcPathArgs: [PID_ECHO_SERVER, "{path}"],
57
+ transport: "uds",
58
+ ...opts,
59
+ });
60
+ }
61
+
62
+ async function callPid(backend: SpawnedProcessBackend): Promise<string> {
63
+ return Buffer.from(await backend.call(Uint8Array.from([1]))).toString();
64
+ }
65
+
66
+ function processAlive(pid: number): boolean {
67
+ try {
68
+ process.kill(pid, 0);
69
+ return true;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ async function waitFor(
76
+ cond: () => boolean,
77
+ timeoutMs: number,
78
+ ): Promise<boolean> {
79
+ const deadline = Date.now() + timeoutMs;
80
+ while (Date.now() < deadline) {
81
+ if (cond()) return true;
82
+ await new Promise((resolve) => setTimeout(resolve, 25));
83
+ }
84
+ return cond();
85
+ }
86
+
87
+ test("spawns, round-trips calls, and destroys cleanly", async () => {
88
+ const backend = await spawnPidEcho();
89
+ const pid = await callPid(backend);
90
+ assert.match(pid, /^\d+$/);
91
+ await backend.destroy();
92
+ assert.equal(processAlive(parseInt(pid, 10)), false);
93
+ });
94
+
95
+ test("without respawn, death rejects in-flight and later calls with a retryable exit error", async () => {
96
+ const backend = await spawnPidEcho();
97
+ const pid = parseInt(await callPid(backend), 10);
98
+
99
+ process.kill(pid, "SIGKILL");
100
+ await waitFor(() => !processAlive(pid), 2_000);
101
+
102
+ const err = await callPid(backend).then(
103
+ () => undefined,
104
+ (e: Error) => e,
105
+ );
106
+ assert.ok(
107
+ err instanceof IpcProcessExitedError,
108
+ `expected IpcProcessExitedError, got ${err}`,
109
+ );
110
+ assert.equal((err as IpcProcessExitedError).retry, true);
111
+ assert.equal((err as IpcProcessExitedError).signal, "SIGKILL");
112
+
113
+ // Still down: same classified error, no zombie respawn.
114
+ await assert.rejects(callPid(backend), IpcProcessExitedError);
115
+ await backend.destroy();
116
+ });
117
+
118
+ test("with respawn, the next call after a death transparently gets a fresh process", async () => {
119
+ const backend = await spawnPidEcho({ respawn: true });
120
+ const firstPid = parseInt(await callPid(backend), 10);
121
+
122
+ process.kill(firstPid, "SIGKILL");
123
+ await waitFor(() => !processAlive(firstPid), 2_000);
124
+
125
+ // The first call after the death may race the exit event; it is allowed to
126
+ // fail (with a retryable error) or succeed against the respawned process.
127
+ let secondPid: number | undefined;
128
+ try {
129
+ secondPid = parseInt(await callPid(backend), 10);
130
+ } catch (err) {
131
+ assert.equal((err as { retry?: boolean }).retry, true);
132
+ secondPid = parseInt(await callPid(backend), 10);
133
+ }
134
+ assert.notEqual(secondPid, firstPid);
135
+ assert.equal(processAlive(secondPid!), true);
136
+
137
+ // getIpcPath is stable across incarnations.
138
+ const path = backend.getIpcPath();
139
+ await backend.destroy();
140
+ assert.equal(processAlive(secondPid!), false);
141
+ assert.equal(backend.getIpcPath(), path);
142
+ });
143
+
144
+ test("a missing binary fails with retry=false", async () => {
145
+ const err = await SpawnedProcessBackend.spawn({
146
+ binaryPath: join(scratch, "does_not_exist"),
147
+ binaryName: "ghost",
148
+ instancePrefix: "ghost-test",
149
+ ipcPathArgs: ["{path}"],
150
+ transport: "uds",
151
+ }).then(
152
+ () => undefined,
153
+ (e: Error) => e,
154
+ );
155
+ assert.ok(err instanceof IpcSpawnError, `expected IpcSpawnError, got ${err}`);
156
+ assert.equal((err as IpcSpawnError).retry, false);
157
+ });
158
+
159
+ test("a server dying before listen fails promptly with retry=true and the exit code", async () => {
160
+ const dying = shellScript("dying_server.sh", "exit 7");
161
+ const started = Date.now();
162
+ const err = await SpawnedProcessBackend.spawn({
163
+ binaryPath: dying,
164
+ binaryName: "dying_server",
165
+ instancePrefix: "dying-test",
166
+ ipcPathArgs: ["{path}"],
167
+ transport: "uds",
168
+ }).then(
169
+ () => undefined,
170
+ (e: Error) => e,
171
+ );
172
+ assert.ok(err instanceof IpcSpawnError, `expected IpcSpawnError, got ${err}`);
173
+ assert.equal((err as IpcSpawnError).retry, true);
174
+ assert.match((err as IpcSpawnError).message, /code=7/);
175
+ assert.ok(Date.now() - started < 5_000, "failed before the connect backstop");
176
+ });
177
+
178
+ test("a wedged server is killed when the connect backstop fires", async () => {
179
+ const pidFile = join(scratch, "wedged.pid");
180
+ const wedged = shellScript(
181
+ "wedged_server.sh",
182
+ `echo $$ > "${pidFile}"\nexec sleep 600`,
183
+ );
184
+ const err = await SpawnedProcessBackend.spawn({
185
+ binaryPath: wedged,
186
+ binaryName: "wedged_server",
187
+ instancePrefix: "wedged-test",
188
+ ipcPathArgs: ["{path}"],
189
+ transport: "uds",
190
+ connectTimeoutMs: 1_000,
191
+ }).then(
192
+ () => undefined,
193
+ (e: Error) => e,
194
+ );
195
+ assert.ok(err instanceof IpcSpawnError, `expected IpcSpawnError, got ${err}`);
196
+ assert.equal((err as IpcSpawnError).retry, true);
197
+ const { readFileSync } = await import("node:fs");
198
+ const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
199
+ assert.equal(
200
+ await waitFor(() => !processAlive(pid), 5_000),
201
+ true,
202
+ "wedged process was reaped",
203
+ );
204
+ });
205
+
206
+ test("destroy during a pending respawn does not leak the fresh process", async () => {
207
+ const backend = await spawnPidEcho({ respawn: true });
208
+ const firstPid = parseInt(await callPid(backend), 10);
209
+ process.kill(firstPid, "SIGKILL");
210
+ await waitFor(() => !processAlive(firstPid), 2_000);
211
+
212
+ // Trigger the lazy respawn, then destroy while it may still be in flight.
213
+ const pending = callPid(backend).catch(() => undefined);
214
+ await backend.destroy();
215
+ const result = await pending;
216
+
217
+ if (result !== undefined) {
218
+ // The respawn won the race and served the call; destroy() must still have
219
+ // reaped the fresh process afterwards.
220
+ assert.equal(
221
+ await waitFor(() => !processAlive(parseInt(result, 10)), 2_000),
222
+ true,
223
+ );
224
+ }
225
+ });