@specific.dev/spectest 0.59.1 → 0.59.2

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.
@@ -3,8 +3,9 @@ import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
3
3
  export { COVERAGE_CONTAINER_DIR };
4
4
  /** Where `node()` mounts its hook inside the container. */
5
5
  export declare const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
6
- /** The socket the node hook answers on, relative to the coverage dir. */
7
- export declare const NODE_COVERAGE_SOCKET = ".ctl";
6
+ /** Prefix of the per-process sockets the node hook answers on, relative
7
+ * to the coverage dir: `.ctl-<pid>`. */
8
+ export declare const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
8
9
  /** What `configure` learns about the service it rewrites. */
9
10
  export interface CoverageConfigureInfo {
10
11
  /** The services-map key. */
@@ -85,29 +86,40 @@ export declare function applyCoverageAdapters<S extends ServiceConfig>(key: stri
85
86
  /** `env` with `value` appended to `name` (space-separated), or set. */
86
87
  export declare function appendEnvFlag(env: Readonly<Record<string, string>> | undefined, name: string, value: string): Record<string, string>;
87
88
  /**
88
- * The hook `node()` mounts and `--require`s into every node
89
- * process of the container. Node writes V8 coverage JSON into
90
- * `NODE_V8_COVERAGE` when a process exits; a long-lived server never
91
- * exits, so the hook binds a Unix socket in the coverage directory and
92
- * calls `v8.takeCoverage()` for each connection. The first process to
93
- * start owns the socket (a stale socket left by a dead process is
94
- * reclaimed); a later process — a CLI run by `ctx.exec` — leaves it
95
- * alone and writes at its own exit. No signal is used: signals are
96
- * claimed by frameworks (SIGUSR2 stops a Temporal worker, restarts
97
- * nodemon), a socket is nobody's.
89
+ * The hook `node()` mounts and `--require`s into every node process of
90
+ * the container. Node writes V8 coverage JSON into `NODE_V8_COVERAGE`
91
+ * when a process exits; a long-lived server never exits, so the hook
92
+ * binds a Unix socket in the coverage directory and calls
93
+ * `v8.takeCoverage()` on request. **Every** process binds its own socket
94
+ * (`.ctl-<pid>`) — there is no "first process owns it" rule, because the
95
+ * first node process is often a wrapper (`pnpm exec`, the `tsx` binary,
96
+ * `npm run`) whose child is the real server; a single socket on the
97
+ * wrapper dumped the wrapper and silently never the server (reported by
98
+ * a user 2026-08-27). At capture spectest asks every live socket and
99
+ * unlinks the stale ones. Unref'd, so a short-lived process still exits.
100
+ * No signal is used: signals are claimed by frameworks (SIGUSR2 stops a
101
+ * Temporal worker, restarts nodemon), a socket is nobody's.
98
102
  */
99
103
  export declare const NODE_COVERAGE_HOOK: string;
100
104
  /**
101
105
  * Coverage for a Node service. Sets `NODE_V8_COVERAGE` to the coverage
102
106
  * directory (every node process in the container then writes V8
103
107
  * coverage JSON when it exits) and `--require`s a hook that lets spectest
104
- * ask the long-lived server process for a dump at capture time. Nothing
108
+ * ask every live node process for a dump at capture time — the server,
109
+ * and any wrapper it sits behind (`pnpm exec`, `tsx`). Nothing
105
110
  * for the app to write. Each dump is compacted to the app's own scripts
106
111
  * at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
107
112
  * `sourceMappingURL` next to the file) Node records the map in the
108
113
  * report, which is what maps a TypeScript service back to its sources.
109
114
  */
110
115
  export declare function node(): CoverageAdapter;
116
+ /**
117
+ * Ask every node process that holds a hook socket in `dir` for a dump.
118
+ * A socket nobody answers (its process died without unlinking — SIGKILL,
119
+ * OOM) is removed. Returns how many processes answered; a process that
120
+ * answered with an error throws.
121
+ */
122
+ export declare function dumpAllNodeProcesses(dir: string, signal: AbortSignal): Promise<number>;
111
123
  /** A script is the app's own when it is a file outside node_modules. */
112
124
  export declare function isAppScriptUrl(url: string): boolean;
113
125
  /**
package/dist/coverage.js CHANGED
@@ -29,8 +29,9 @@ import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
29
29
  export { COVERAGE_CONTAINER_DIR };
30
30
  /** Where `node()` mounts its hook inside the container. */
31
31
  export const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
32
- /** The socket the node hook answers on, relative to the coverage dir. */
33
- export const NODE_COVERAGE_SOCKET = ".ctl";
32
+ /** Prefix of the per-process sockets the node hook answers on, relative
33
+ * to the coverage dir: `.ctl-<pid>`. */
34
+ export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
34
35
  /** Type an inline adapter. Identity at runtime. */
35
36
  export function defineAdapter(adapter) {
36
37
  return adapter;
@@ -95,16 +96,19 @@ export function appendEnvFlag(env, name, value) {
95
96
  }
96
97
  // ── node ──────────────────────────────────────────────────────────
97
98
  /**
98
- * The hook `node()` mounts and `--require`s into every node
99
- * process of the container. Node writes V8 coverage JSON into
100
- * `NODE_V8_COVERAGE` when a process exits; a long-lived server never
101
- * exits, so the hook binds a Unix socket in the coverage directory and
102
- * calls `v8.takeCoverage()` for each connection. The first process to
103
- * start owns the socket (a stale socket left by a dead process is
104
- * reclaimed); a later process — a CLI run by `ctx.exec` — leaves it
105
- * alone and writes at its own exit. No signal is used: signals are
106
- * claimed by frameworks (SIGUSR2 stops a Temporal worker, restarts
107
- * nodemon), a socket is nobody's.
99
+ * The hook `node()` mounts and `--require`s into every node process of
100
+ * the container. Node writes V8 coverage JSON into `NODE_V8_COVERAGE`
101
+ * when a process exits; a long-lived server never exits, so the hook
102
+ * binds a Unix socket in the coverage directory and calls
103
+ * `v8.takeCoverage()` on request. **Every** process binds its own socket
104
+ * (`.ctl-<pid>`) — there is no "first process owns it" rule, because the
105
+ * first node process is often a wrapper (`pnpm exec`, the `tsx` binary,
106
+ * `npm run`) whose child is the real server; a single socket on the
107
+ * wrapper dumped the wrapper and silently never the server (reported by
108
+ * a user 2026-08-27). At capture spectest asks every live socket and
109
+ * unlinks the stale ones. Unref'd, so a short-lived process still exits.
110
+ * No signal is used: signals are claimed by frameworks (SIGUSR2 stops a
111
+ * Temporal worker, restarts nodemon), a socket is nobody's.
108
112
  */
109
113
  export const NODE_COVERAGE_HOOK = `"use strict";
110
114
  // spectest coverage hook (coverage.node() adapter). See \`spectest docs /services/coverage\`.
@@ -112,10 +116,9 @@ if (process.env.NODE_V8_COVERAGE) {
112
116
  const net = require("node:net");
113
117
  const fs = require("node:fs");
114
118
  const v8 = require("node:v8");
115
- const SOCK = require("node:path").join(process.env.NODE_V8_COVERAGE, ${JSON.stringify(NODE_COVERAGE_SOCKET)});
119
+ const SOCK = require("node:path").join(process.env.NODE_V8_COVERAGE, ${JSON.stringify(NODE_COVERAGE_SOCKET_PREFIX)} + process.pid);
116
120
  const server = net.createServer((conn) => {
117
- // A dump is taken only on an explicit "dump" request: a bystander's
118
- // liveness probe connects and closes without one.
121
+ // A dump is taken only on an explicit "dump" request.
119
122
  let buf = "";
120
123
  conn.on("data", (chunk) => {
121
124
  buf += chunk;
@@ -132,25 +135,18 @@ if (process.env.NODE_V8_COVERAGE) {
132
135
  });
133
136
  });
134
137
  server.unref();
135
- server.on("error", (err) => {
136
- if (err.code !== "EADDRINUSE") return;
137
- // Someone holds the socket. If it answers, it is the live server and
138
- // this process is a bystander; if not, it is a stale file.
139
- const probe = net.connect(SOCK);
140
- probe.on("connect", () => probe.destroy());
141
- probe.on("error", () => {
142
- try { fs.unlinkSync(SOCK); } catch {}
143
- server.listen(SOCK);
144
- });
145
- });
138
+ server.on("error", () => {});
139
+ try { fs.unlinkSync(SOCK); } catch {}
146
140
  server.listen(SOCK);
141
+ process.on("exit", () => { try { fs.unlinkSync(SOCK); } catch {} });
147
142
  }
148
143
  `;
149
144
  /**
150
145
  * Coverage for a Node service. Sets `NODE_V8_COVERAGE` to the coverage
151
146
  * directory (every node process in the container then writes V8
152
147
  * coverage JSON when it exits) and `--require`s a hook that lets spectest
153
- * ask the long-lived server process for a dump at capture time. Nothing
148
+ * ask every live node process for a dump at capture time — the server,
149
+ * and any wrapper it sits behind (`pnpm exec`, `tsx`). Nothing
154
150
  * for the app to write. Each dump is compacted to the app's own scripts
155
151
  * at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
156
152
  * `sourceMappingURL` next to the file) Node records the map in the
@@ -169,35 +165,68 @@ export function node() {
169
165
  };
170
166
  },
171
167
  async capture(ctx) {
172
- const { connect } = await import("node:net");
173
- const path = await import("node:path");
174
- const sock = path.join(ctx.reportDir, NODE_COVERAGE_SOCKET);
175
- const reply = await new Promise((resolve, reject) => {
176
- const chunks = [];
177
- const c = connect(sock, () => c.write("dump\n"));
178
- const onAbort = () => {
179
- c.destroy();
180
- reject(new Error("timed out waiting for the node hook to write a report"));
181
- };
182
- ctx.signal.addEventListener("abort", onAbort, { once: true });
183
- c.on("data", (b) => chunks.push(b));
184
- c.on("error", (err) => {
185
- ctx.signal.removeEventListener("abort", onAbort);
186
- reject(new Error(err.code === "ENOENT" || err.code === "ECONNREFUSED"
187
- ? `no node process is serving ${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_SOCKET} — is the service's main process node, and does it run with the service's env (NODE_OPTIONS)?`
188
- : err.message));
189
- });
190
- c.on("close", () => {
191
- ctx.signal.removeEventListener("abort", onAbort);
192
- resolve(Buffer.concat(chunks).toString("utf8").trim());
193
- });
194
- });
195
- if (reply !== "ok")
196
- throw new Error(`the node hook answered: ${reply || "(nothing)"}`);
168
+ const live = await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
169
+ if (live === 0) {
170
+ throw new Error(`no node process is serving a ${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_SOCKET_PREFIX}<pid> socket — does the service run node with the service's env (NODE_OPTIONS), and is it still alive?`);
171
+ }
197
172
  await compactV8Reports(ctx.reportDir);
198
173
  },
199
174
  };
200
175
  }
176
+ /**
177
+ * Ask every node process that holds a hook socket in `dir` for a dump.
178
+ * A socket nobody answers (its process died without unlinking — SIGKILL,
179
+ * OOM) is removed. Returns how many processes answered; a process that
180
+ * answered with an error throws.
181
+ */
182
+ export async function dumpAllNodeProcesses(dir, signal) {
183
+ const fs = await import("node:fs/promises");
184
+ const path = await import("node:path");
185
+ const { connect } = await import("node:net");
186
+ let names;
187
+ try {
188
+ names = (await fs.readdir(dir)).filter((n) => n.startsWith(NODE_COVERAGE_SOCKET_PREFIX));
189
+ }
190
+ catch {
191
+ return 0;
192
+ }
193
+ let live = 0;
194
+ for (const name of names) {
195
+ const sock = path.join(dir, name);
196
+ const reply = await new Promise((resolve, reject) => {
197
+ const chunks = [];
198
+ const c = connect(sock, () => c.write("dump\n"));
199
+ const onAbort = () => {
200
+ c.destroy();
201
+ reject(new Error(`timed out waiting for the node hook (${name}) to write a report`));
202
+ };
203
+ signal.addEventListener("abort", onAbort, { once: true });
204
+ c.on("data", (b) => chunks.push(b));
205
+ c.on("error", (err) => {
206
+ signal.removeEventListener("abort", onAbort);
207
+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED")
208
+ resolve(null);
209
+ else
210
+ reject(err);
211
+ });
212
+ c.on("close", (hadError) => {
213
+ signal.removeEventListener("abort", onAbort);
214
+ if (!hadError)
215
+ resolve(Buffer.concat(chunks).toString("utf8").trim());
216
+ });
217
+ });
218
+ if (reply === null) {
219
+ // Nobody home: the process is gone. Clean up so the next capture
220
+ // does not knock again.
221
+ await fs.unlink(sock).catch(() => { });
222
+ continue;
223
+ }
224
+ if (reply !== "ok")
225
+ throw new Error(`the node hook (${name}) answered: ${reply || "(nothing)"}`);
226
+ live++;
227
+ }
228
+ return live;
229
+ }
201
230
  /** V8 files already compacted, by path. Module memory: forks with the
202
231
  * environment, so a child never re-parses its ancestors' dumps. */
203
232
  const COMPACTED_V8_REPORTS = new Set();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.59.1",
3
+ "version": "0.59.2",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -6,7 +6,7 @@ import path from "node:path";
6
6
  import {
7
7
  NODE_COVERAGE_HOOK,
8
8
  NODE_COVERAGE_HOOK_PATH,
9
- NODE_COVERAGE_SOCKET,
9
+ NODE_COVERAGE_SOCKET_PREFIX,
10
10
  applyCoverageAdapters,
11
11
  appendEnvFlag,
12
12
  browser,
@@ -133,94 +133,116 @@ describe("browserCoverage", () => {
133
133
  });
134
134
 
135
135
  // The node hook against a real `node` (the box has one; skipped where it
136
- // does not). A long-lived process armed with the hook must answer the
137
- // socket with a fresh V8 report; a second process must stay a bystander
138
- // and exit on its own; the socket path must work across a bind mount —
139
- // which is what a plain directory path stands in for here.
136
+ // does not). Every process armed with the hook must answer its own
137
+ // socket with a fresh V8 report — including a server that sits behind a
138
+ // wrapper process, the pnpm/tsx shape; stale sockets must be cleaned up;
139
+ // the socket path must work across a bind mount — which is what a plain
140
+ // directory path stands in for here.
140
141
  const hasNode = await new Promise<boolean>((resolve) => {
141
142
  const p = spawn("node", ["--version"]);
142
143
  p.on("error", () => resolve(false));
143
144
  p.on("exit", (code) => resolve(code === 0));
144
145
  });
145
146
 
147
+ const SERVER_JS = "setInterval(() => {}, 1000); process.stdout.write('up\\n');\n";
148
+ // A wrapper that spawns the real server as a child node process and
149
+ // waits on it — what `pnpm exec`, `npm run` and the `tsx` binary do.
150
+ const WRAPPER_JS =
151
+ "const { spawn } = require('node:child_process');\n" +
152
+ "const c = spawn(process.execPath, [require('node:path').join(__dirname, 'server.js')], { stdio: 'inherit' });\n" +
153
+ "c.on('exit', (code) => process.exit(code ?? 1));\n";
154
+
146
155
  describe.if(hasNode)("nodeCoverage hook (real node)", () => {
147
- async function startServer(dir: string): Promise<ChildProcess> {
156
+ async function socketsIn(dir: string): Promise<string[]> {
157
+ return (await fs.readdir(dir)).filter((n) => n.startsWith(NODE_COVERAGE_SOCKET_PREFIX)).sort();
158
+ }
159
+ async function reportsIn(dir: string): Promise<string[]> {
160
+ return (await fs.readdir(dir)).filter((n) => n.startsWith("coverage-"));
161
+ }
162
+ async function start(dir: string, entry: string, expectSockets: number): Promise<ChildProcess> {
148
163
  const hook = path.join(dir, "hook.cjs");
149
164
  await fs.writeFile(hook, NODE_COVERAGE_HOOK);
150
- const server = path.join(dir, "server.js");
151
- await fs.writeFile(server, "setInterval(() => {}, 1000); process.stdout.write('up\\n');\n");
152
- const child = spawn("node", ["--require", hook, server], {
153
- env: { ...process.env, NODE_V8_COVERAGE: dir },
165
+ await fs.writeFile(path.join(dir, "server.js"), SERVER_JS);
166
+ await fs.writeFile(path.join(dir, "wrapper.js"), WRAPPER_JS);
167
+ const child = spawn("node", [path.join(dir, entry)], {
168
+ env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
154
169
  stdio: ["ignore", "pipe", "inherit"],
155
170
  });
156
171
  await new Promise<void>((resolve) => child.stdout!.once("data", () => resolve()));
157
- // The socket is bound after the module graph runs; wait for it.
158
- const sock = path.join(dir, NODE_COVERAGE_SOCKET);
159
- for (let i = 0; i < 100; i++) {
160
- try {
161
- await fs.stat(sock);
162
- break;
163
- } catch {
164
- await new Promise((r) => setTimeout(r, 20));
165
- }
172
+ for (let i = 0; i < 100 && (await socketsIn(dir)).length < expectSockets; i++) {
173
+ await new Promise((r) => setTimeout(r, 20));
166
174
  }
167
175
  return child;
168
176
  }
169
177
 
170
- test("capture asks the live server for a dump; a bystander exits on its own", async () => {
178
+ test("capture asks the live server; a short-lived process exits and unlinks its socket", async () => {
171
179
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
172
- const server = await startServer(dir);
180
+ const server = await start(dir, "server.js", 1);
173
181
  try {
174
- const reportsBefore = (await fs.readdir(dir)).filter((n) => n.startsWith("coverage-"));
175
- expect(reportsBefore).toEqual([]);
182
+ expect(await reportsIn(dir)).toEqual([]);
176
183
  await node().capture!(fakeCtx(dir));
177
- const reports = (await fs.readdir(dir)).filter((n) => n.startsWith("coverage-"));
184
+ const reports = await reportsIn(dir);
178
185
  expect(reports.length).toBe(1);
179
186
  const doc = JSON.parse(await fs.readFile(path.join(dir, reports[0]!), "utf8")) as {
180
187
  result: { url: string }[];
181
188
  };
182
189
  expect(doc.result.some((s) => s.url.endsWith("/server.js"))).toBe(true);
183
190
 
184
- // A second process with the same env: the socket is held, so it must
185
- // not try to serve, and it must exit (the server's socket is unref'd,
186
- // so a bystander is never kept alive by it).
187
- const bystander = spawn("node", ["--require", path.join(dir, "hook.cjs"), "-e", "1"], {
188
- env: { ...process.env, NODE_V8_COVERAGE: dir },
191
+ const short = spawn("node", ["-e", "1"], {
192
+ env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${path.join(dir, "hook.cjs")}` },
189
193
  stdio: "ignore",
190
194
  });
191
- const code = await new Promise<number | null>((resolve) => bystander.on("exit", resolve));
195
+ const code = await new Promise<number | null>((resolve) => short.on("exit", resolve));
192
196
  expect(code).toBe(0);
193
- // …and it wrote its own exit-time report, as every node process does.
194
- const after = (await fs.readdir(dir)).filter((n) => n.startsWith("coverage-"));
195
- expect(after.length).toBe(2);
197
+ expect((await socketsIn(dir)).length).toBe(1); // its socket is gone with it
198
+ expect((await reportsIn(dir)).length).toBe(2); // its exit-time report is there
196
199
 
197
- // The live server still answers.
198
200
  await node().capture!(fakeCtx(dir));
199
- expect((await fs.readdir(dir)).filter((n) => n.startsWith("coverage-")).length).toBe(3);
201
+ expect((await reportsIn(dir)).length).toBe(3);
200
202
  } finally {
201
203
  server.kill("SIGKILL");
202
204
  }
203
205
  });
204
206
 
205
- test("a stale socket left by a dead process is reclaimed", async () => {
207
+ test("a server behind a wrapper process is dumped too (pnpm exec / tsx shape)", async () => {
206
208
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
207
- const first = await startServer(dir);
208
- first.kill("SIGKILL");
209
- await new Promise<void>((resolve) => first.on("exit", () => resolve()));
210
- expect(await fs.stat(path.join(dir, NODE_COVERAGE_SOCKET))).toBeTruthy(); // stale file
211
- const second = await startServer(dir);
209
+ const wrapper = await start(dir, "wrapper.js", 2);
212
210
  try {
213
- // Give the EADDRINUSE → probe → unlink → listen dance a moment.
214
- let ok = false;
215
- for (let i = 0; i < 50 && !ok; i++) {
211
+ expect((await socketsIn(dir)).length).toBe(2);
212
+ await node().capture!(fakeCtx(dir));
213
+ const reports = await reportsIn(dir);
214
+ expect(reports.length).toBe(2);
215
+ const urls = new Set<string>();
216
+ for (const r of reports) {
217
+ const doc = JSON.parse(await fs.readFile(path.join(dir, r), "utf8")) as { result: { url: string }[] };
218
+ for (const s of doc.result) urls.add(s.url);
219
+ }
220
+ expect([...urls].some((u) => u.endsWith("/server.js"))).toBe(true);
221
+ expect([...urls].some((u) => u.endsWith("/wrapper.js"))).toBe(true);
222
+ } finally {
223
+ wrapper.kill("SIGKILL");
224
+ // The wrapper's child is orphaned by SIGKILL; find and kill it too.
225
+ const pids = (await socketsIn(dir)).map((n) => Number(n.slice(NODE_COVERAGE_SOCKET_PREFIX.length)));
226
+ for (const pid of pids) {
216
227
  try {
217
- await node().capture!(fakeCtx(dir));
218
- ok = true;
219
- } catch {
220
- await new Promise((r) => setTimeout(r, 50));
221
- }
228
+ process.kill(pid, "SIGKILL");
229
+ } catch {}
222
230
  }
223
- expect(ok).toBe(true);
231
+ }
232
+ });
233
+
234
+ test("a stale socket left by a killed process is removed at capture", async () => {
235
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
236
+ const first = await start(dir, "server.js", 1);
237
+ first.kill("SIGKILL");
238
+ await new Promise<void>((resolve) => first.on("exit", () => resolve()));
239
+ expect((await socketsIn(dir)).length).toBe(1); // stale
240
+ await expect(node().capture!(fakeCtx(dir))).rejects.toThrow(/no node process is serving/);
241
+ expect((await socketsIn(dir)).length).toBe(0); // cleaned up
242
+ const second = await start(dir, "server.js", 1);
243
+ try {
244
+ await node().capture!(fakeCtx(dir));
245
+ expect((await reportsIn(dir)).length).toBe(1);
224
246
  } finally {
225
247
  second.kill("SIGKILL");
226
248
  }
package/src/coverage.ts CHANGED
@@ -34,8 +34,9 @@ export { COVERAGE_CONTAINER_DIR };
34
34
  /** Where `node()` mounts its hook inside the container. */
35
35
  export const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
36
36
 
37
- /** The socket the node hook answers on, relative to the coverage dir. */
38
- export const NODE_COVERAGE_SOCKET = ".ctl";
37
+ /** Prefix of the per-process sockets the node hook answers on, relative
38
+ * to the coverage dir: `.ctl-<pid>`. */
39
+ export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
39
40
 
40
41
  /** What `configure` learns about the service it rewrites. */
41
42
  export interface CoverageConfigureInfo {
@@ -178,16 +179,19 @@ export function appendEnvFlag(
178
179
  // ── node ──────────────────────────────────────────────────────────
179
180
 
180
181
  /**
181
- * The hook `node()` mounts and `--require`s into every node
182
- * process of the container. Node writes V8 coverage JSON into
183
- * `NODE_V8_COVERAGE` when a process exits; a long-lived server never
184
- * exits, so the hook binds a Unix socket in the coverage directory and
185
- * calls `v8.takeCoverage()` for each connection. The first process to
186
- * start owns the socket (a stale socket left by a dead process is
187
- * reclaimed); a later process — a CLI run by `ctx.exec` — leaves it
188
- * alone and writes at its own exit. No signal is used: signals are
189
- * claimed by frameworks (SIGUSR2 stops a Temporal worker, restarts
190
- * nodemon), a socket is nobody's.
182
+ * The hook `node()` mounts and `--require`s into every node process of
183
+ * the container. Node writes V8 coverage JSON into `NODE_V8_COVERAGE`
184
+ * when a process exits; a long-lived server never exits, so the hook
185
+ * binds a Unix socket in the coverage directory and calls
186
+ * `v8.takeCoverage()` on request. **Every** process binds its own socket
187
+ * (`.ctl-<pid>`) — there is no "first process owns it" rule, because the
188
+ * first node process is often a wrapper (`pnpm exec`, the `tsx` binary,
189
+ * `npm run`) whose child is the real server; a single socket on the
190
+ * wrapper dumped the wrapper and silently never the server (reported by
191
+ * a user 2026-08-27). At capture spectest asks every live socket and
192
+ * unlinks the stale ones. Unref'd, so a short-lived process still exits.
193
+ * No signal is used: signals are claimed by frameworks (SIGUSR2 stops a
194
+ * Temporal worker, restarts nodemon), a socket is nobody's.
191
195
  */
192
196
  export const NODE_COVERAGE_HOOK = `"use strict";
193
197
  // spectest coverage hook (coverage.node() adapter). See \`spectest docs /services/coverage\`.
@@ -195,10 +199,9 @@ if (process.env.NODE_V8_COVERAGE) {
195
199
  const net = require("node:net");
196
200
  const fs = require("node:fs");
197
201
  const v8 = require("node:v8");
198
- const SOCK = require("node:path").join(process.env.NODE_V8_COVERAGE, ${JSON.stringify(NODE_COVERAGE_SOCKET)});
202
+ const SOCK = require("node:path").join(process.env.NODE_V8_COVERAGE, ${JSON.stringify(NODE_COVERAGE_SOCKET_PREFIX)} + process.pid);
199
203
  const server = net.createServer((conn) => {
200
- // A dump is taken only on an explicit "dump" request: a bystander's
201
- // liveness probe connects and closes without one.
204
+ // A dump is taken only on an explicit "dump" request.
202
205
  let buf = "";
203
206
  conn.on("data", (chunk) => {
204
207
  buf += chunk;
@@ -215,18 +218,10 @@ if (process.env.NODE_V8_COVERAGE) {
215
218
  });
216
219
  });
217
220
  server.unref();
218
- server.on("error", (err) => {
219
- if (err.code !== "EADDRINUSE") return;
220
- // Someone holds the socket. If it answers, it is the live server and
221
- // this process is a bystander; if not, it is a stale file.
222
- const probe = net.connect(SOCK);
223
- probe.on("connect", () => probe.destroy());
224
- probe.on("error", () => {
225
- try { fs.unlinkSync(SOCK); } catch {}
226
- server.listen(SOCK);
227
- });
228
- });
221
+ server.on("error", () => {});
222
+ try { fs.unlinkSync(SOCK); } catch {}
229
223
  server.listen(SOCK);
224
+ process.on("exit", () => { try { fs.unlinkSync(SOCK); } catch {} });
230
225
  }
231
226
  `;
232
227
 
@@ -234,7 +229,8 @@ if (process.env.NODE_V8_COVERAGE) {
234
229
  * Coverage for a Node service. Sets `NODE_V8_COVERAGE` to the coverage
235
230
  * directory (every node process in the container then writes V8
236
231
  * coverage JSON when it exits) and `--require`s a hook that lets spectest
237
- * ask the long-lived server process for a dump at capture time. Nothing
232
+ * ask every live node process for a dump at capture time — the server,
233
+ * and any wrapper it sits behind (`pnpm exec`, `tsx`). Nothing
238
234
  * for the app to write. Each dump is compacted to the app's own scripts
239
235
  * at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
240
236
  * `sourceMappingURL` next to the file) Node records the map in the
@@ -253,39 +249,67 @@ export function node(): CoverageAdapter {
253
249
  };
254
250
  },
255
251
  async capture(ctx) {
256
- const { connect } = await import("node:net");
257
- const path = await import("node:path");
258
- const sock = path.join(ctx.reportDir, NODE_COVERAGE_SOCKET);
259
- const reply = await new Promise<string>((resolve, reject) => {
260
- const chunks: Buffer[] = [];
261
- const c = connect(sock, () => c.write("dump\n"));
262
- const onAbort = (): void => {
263
- c.destroy();
264
- reject(new Error("timed out waiting for the node hook to write a report"));
265
- };
266
- ctx.signal.addEventListener("abort", onAbort, { once: true });
267
- c.on("data", (b: Buffer) => chunks.push(b));
268
- c.on("error", (err: NodeJS.ErrnoException) => {
269
- ctx.signal.removeEventListener("abort", onAbort);
270
- reject(
271
- new Error(
272
- err.code === "ENOENT" || err.code === "ECONNREFUSED"
273
- ? `no node process is serving ${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_SOCKET} — is the service's main process node, and does it run with the service's env (NODE_OPTIONS)?`
274
- : err.message,
275
- ),
276
- );
277
- });
278
- c.on("close", () => {
279
- ctx.signal.removeEventListener("abort", onAbort);
280
- resolve(Buffer.concat(chunks).toString("utf8").trim());
281
- });
282
- });
283
- if (reply !== "ok") throw new Error(`the node hook answered: ${reply || "(nothing)"}`);
252
+ const live = await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
253
+ if (live === 0) {
254
+ throw new Error(
255
+ `no node process is serving a ${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_SOCKET_PREFIX}<pid> socket — does the service run node with the service's env (NODE_OPTIONS), and is it still alive?`,
256
+ );
257
+ }
284
258
  await compactV8Reports(ctx.reportDir);
285
259
  },
286
260
  };
287
261
  }
288
262
 
263
+ /**
264
+ * Ask every node process that holds a hook socket in `dir` for a dump.
265
+ * A socket nobody answers (its process died without unlinking — SIGKILL,
266
+ * OOM) is removed. Returns how many processes answered; a process that
267
+ * answered with an error throws.
268
+ */
269
+ export async function dumpAllNodeProcesses(dir: string, signal: AbortSignal): Promise<number> {
270
+ const fs = await import("node:fs/promises");
271
+ const path = await import("node:path");
272
+ const { connect } = await import("node:net");
273
+ let names: string[];
274
+ try {
275
+ names = (await fs.readdir(dir)).filter((n) => n.startsWith(NODE_COVERAGE_SOCKET_PREFIX));
276
+ } catch {
277
+ return 0;
278
+ }
279
+ let live = 0;
280
+ for (const name of names) {
281
+ const sock = path.join(dir, name);
282
+ const reply = await new Promise<string | null>((resolve, reject) => {
283
+ const chunks: Buffer[] = [];
284
+ const c = connect(sock, () => c.write("dump\n"));
285
+ const onAbort = (): void => {
286
+ c.destroy();
287
+ reject(new Error(`timed out waiting for the node hook (${name}) to write a report`));
288
+ };
289
+ signal.addEventListener("abort", onAbort, { once: true });
290
+ c.on("data", (b: Buffer) => chunks.push(b));
291
+ c.on("error", (err: NodeJS.ErrnoException) => {
292
+ signal.removeEventListener("abort", onAbort);
293
+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED") resolve(null);
294
+ else reject(err);
295
+ });
296
+ c.on("close", (hadError: boolean) => {
297
+ signal.removeEventListener("abort", onAbort);
298
+ if (!hadError) resolve(Buffer.concat(chunks).toString("utf8").trim());
299
+ });
300
+ });
301
+ if (reply === null) {
302
+ // Nobody home: the process is gone. Clean up so the next capture
303
+ // does not knock again.
304
+ await fs.unlink(sock).catch(() => {});
305
+ continue;
306
+ }
307
+ if (reply !== "ok") throw new Error(`the node hook (${name}) answered: ${reply || "(nothing)"}`);
308
+ live++;
309
+ }
310
+ return live;
311
+ }
312
+
289
313
  /** V8 files already compacted, by path. Module memory: forks with the
290
314
  * environment, so a child never re-parses its ancestors' dumps. */
291
315
  const COMPACTED_V8_REPORTS = new Set<string>();