1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeChildProcess from "node:child_process";
3import * as NodeFS from "node:fs";
4import * as NodePath from "node:path";
5import * as NodeURL from "node:url";
6
7import { describe, expect, it, vi } from "@effect/vitest";
8import * as HostProcess from "@t3tools/shared/HostProcess";
9import * as Clock from "effect/Clock";
10import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";
12import * as Scope from "effect/Scope";
13
14import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
15
16const threadSpawnHelperSource = NodeURL.fileURLToPath(
17 new URL("../../../scripts/acp-thread-spawn-helper.c", import.meta.url),
18);
19
20const identity = (
21 pid: number,
22 ppid: number,
23 pgid: number,
24 sid: number,
25 startTime = String(pid),
26 state = "S",
27): AcpSessionRuntime.AcpPosixProcessIdentity => ({
28 executable: `/proc/${pid}/exe`,
29 pgid,
30 pid,
31 ppid,
32 sid,
33 startTime,
34 state,
35});
36
37function makeController(input: {
38 readonly processes: ReadonlyArray<AcpSessionRuntime.AcpPosixProcessIdentity>;
39 readonly onProcess?: (
40 processes: Map<number, AcpSessionRuntime.AcpPosixProcessIdentity>,
41 pid: number,
42 signal: NodeJS.Signals,
43 ) => void;
44}) {
45 const processes = new Map(input.processes.map((process) => [process.pid, process]));
46 const signals: Array<string> = [];
47 const controller: AcpSessionRuntime.AcpPosixProcessTreeController = {
48 childPidsOf: (pid) =>
49 [...processes.values()]
50 .filter((process) => process.ppid === pid)
51 .map((process) => process.pid),
52 childrenOf: (pid) => [...processes.values()].filter((process) => process.ppid === pid),
53 identity: (pid) => processes.get(pid),
54 snapshot: () => [...processes.values()],
55 signalProcess: (pid, signal) => {
56 signals.push(`process:${pid}:${signal}`);
57 if (input.onProcess) return input.onProcess(processes, pid, signal);
58 processes.delete(pid);
59 },
60 };
61 return { controller, processes, signals };
62}
63
64const server = () => identity(process.pid, 1, process.pid, process.pid, "server");
65
66function processExists(pid: number): boolean {
67 try {
68 process.kill(pid, 0);
69 return true;
70 } catch {
71 return false;
72 }
73}
74
75describe("terminatePosixOwnedProcessTree", () => {
76 it("parses unified cgroup paths and escaped cgroup2 mountinfo", () => {
77 expect(AcpSessionRuntime.parseUnifiedCgroupPath("0::/user.slice/app.scope\n")).toBe(
78 "/user.slice/app.scope",
79 );
80 expect(AcpSessionRuntime.parseUnifiedCgroupPath("0::/one\n0::/two\n")).toBeUndefined();
81 expect(AcpSessionRuntime.isSafeCgroupPath("/user.slice/app.scope")).toBe(true);
82 expect(AcpSessionRuntime.isSafeCgroupPath("/user.slice/../escape")).toBe(false);
83 expect(AcpSessionRuntime.isSafeCgroupPath("/user.slice/app.scope (deleted)")).toBe(false);
84 expect(
85 AcpSessionRuntime.parseCgroup2Mount(
86 "42 31 0:37 /user.slice /sys/fs/cgroup/user\\040mount rw - cgroup2 cgroup2 rw\n",
87 ),
88 ).toEqual({ mountPoint: "/sys/fs/cgroup/user mount", root: "/user.slice" });
89 expect(
90 AcpSessionRuntime.parseCgroup2Mount("42 31 0:37 / /sys/fs/cgroup rw - tmpfs tmpfs rw\n"),
91 ).toBeUndefined();
92 expect(
93 AcpSessionRuntime.parseCgroup2Mounts(
94 "41 31 0:36 /other /sys/fs/cgroup/other rw - cgroup2 cgroup2 rw\n" +
95 "42 31 0:37 /user.slice /sys/fs/cgroup/user rw - cgroup2 cgroup2 rw\n",
96 ),
97 ).toHaveLength(2);
98 });
99
100 it.live("sweeps empty t3-acp sibling leases owned by dead pids only", () =>
101 Effect.sync(() => {
102 const scratchRoot = NodePath.join(process.cwd(), "tmp");
103 NodeFS.mkdirSync(scratchRoot, { recursive: true });
104 const parent = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-cgroup-stale-siblings-"));
105 // Temp dirs are not cgroupfs: rmdir fails when control files exist, so the
106 // test injects populated/remove while still exercising the real selection
107 // and path-safety logic against a real parent directory tree.
108 const populatedByPath = new Map<string, "0" | "1">();
109 const writeSibling = (name: string, populated: "0" | "1" | null) => {
110 const path = NodePath.join(parent, name);
111 NodeFS.mkdirSync(path);
112 if (populated !== null) populatedByPath.set(path, populated);
113 return path;
114 };
115 const deadPid = 2_147_483_646;
116 // Synthetic live foreign pid (not this process, still reported alive by the probe).
117 const liveForeignPid = 2_147_483_645;
118 const staleEmpty = writeSibling(`t3-acp-${deadPid}-aaaa`, "0");
119 const currentPidSibling = writeSibling(`t3-acp-${process.pid}-bbbb`, "0");
120 const liveForeignSibling = writeSibling(`t3-acp-${liveForeignPid}-cccc`, "0");
121 const stalePopulated = writeSibling(`t3-acp-${deadPid}-dddd`, "1");
122 const unrelated = writeSibling("other-lease", "0");
123 const isProcessAlive = (pid: number) => pid === process.pid || pid === liveForeignPid;
124 try {
125 AcpSessionRuntime.sweepStaleLinuxCgroupSiblings(parent, {
126 isProcessAlive,
127 readPopulated: (path) => populatedByPath.get(path),
128 remove: (path) => {
129 populatedByPath.delete(path);
130 NodeFS.rmdirSync(path);
131 },
132 });
133 expect(NodeFS.existsSync(staleEmpty)).toBe(false);
134 expect(NodeFS.existsSync(currentPidSibling)).toBe(true);
135 expect(NodeFS.existsSync(liveForeignSibling)).toBe(true);
136 expect(NodeFS.existsSync(stalePopulated)).toBe(true);
137 expect(NodeFS.existsSync(unrelated)).toBe(true);
138 // Missing populated state must not remove a sibling.
139 writeSibling(`t3-acp-${deadPid}-eeee`, null);
140 AcpSessionRuntime.sweepStaleLinuxCgroupSiblings(parent, {
141 isProcessAlive,
142 readPopulated: (path) => populatedByPath.get(path),
143 remove: (path) => {
144 populatedByPath.delete(path);
145 NodeFS.rmdirSync(path);
146 },
147 });
148 expect(NodeFS.existsSync(NodePath.join(parent, `t3-acp-${deadPid}-eeee`))).toBe(true);
149 expect(NodeFS.existsSync(currentPidSibling)).toBe(true);
150 } finally {
151 NodeFS.rmSync(parent, { recursive: true, force: true });
152 }
153 }),
154 );
155
156 it.live("preserves exact argv and strips wrapper-only environment before exec", () =>
157 Effect.gen(function* () {
158 if ((yield* HostProcess.Platform) !== "linux") return;
159 const lease = AcpSessionRuntime.makeLinuxCgroupController().create();
160 if (lease === undefined) return;
161 const scratchRoot = NodePath.join(process.cwd(), "tmp");
162 NodeFS.mkdirSync(scratchRoot, { recursive: true });
163 const scratch = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-cgroup-wrapper-"));
164 const linkedNode = NodePath.join(scratch, "T3 Code AppImage 'quoted'");
165 const bareGrok = NodePath.join(scratch, "grok");
166 const relativeBin = NodePath.join(scratch, "relative-bin");
167 const directoryBin = NodePath.join(scratch, "directory-bin");
168 const validBin = NodePath.join(scratch, "valid-bin");
169 const outputPath = NodePath.join(scratch, "argv.json");
170 NodeFS.mkdirSync(relativeBin);
171 NodeFS.mkdirSync(NodePath.join(directoryBin, "grok"), { recursive: true });
172 NodeFS.mkdirSync(validBin);
173 NodeFS.symlinkSync(process.execPath, linkedNode);
174 NodeFS.symlinkSync(process.execPath, bareGrok);
175 NodeFS.symlinkSync(process.execPath, NodePath.join(relativeBin, "grok"));
176 NodeFS.symlinkSync(process.execPath, NodePath.join(validBin, "grok"));
177 expect(
178 AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: scratch }),
179 ).toBe(bareGrok);
180 expect(AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: "" })).toBe(
181 bareGrok,
182 );
183 // Undefined PATH uses Node's default search path (/usr/bin:/bin), not cwd alone
184 // (bareGrok lives only in cwd / PATH=scratch).
185 expect(
186 AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: undefined }),
187 ).toBeUndefined();
188 expect(
189 AcpSessionRuntime.resolveLinuxCgroupTargetCommand("node", scratch, { PATH: undefined }),
190 ).toBeDefined();
191 expect(
192 AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, {
193 PATH: "relative-bin",
194 }),
195 ).toBe(NodePath.join(relativeBin, "grok"));
196 expect(
197 AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, {
198 PATH: `directory-bin${NodePath.delimiter}valid-bin`,
199 }),
200 ).toBe(NodePath.join(validBin, "grok"));
201 const target = [
202 'const fs = require("node:fs");',
203 "fs.writeFileSync(process.argv[1], JSON.stringify({",
204 " args: process.argv.slice(2),",
205 " electron: process.env.ELECTRON_RUN_AS_NODE,",
206 " wrapper: process.env.T3_ACP_CGROUP_WRAPPER,",
207 "}));",
208 ].join("\n");
209 const wrapped = AcpSessionRuntime.wrapCommandForLinuxCgroup(lease, linkedNode, [
210 "-e",
211 target,
212 outputPath,
213 "space value",
214 "single'quote",
215 'double"quote',
216 ]);
217 try {
218 const result = NodeChildProcess.spawnSync(wrapped.command, wrapped.args, {
219 encoding: "utf8",
220 env: {
221 ...process.env,
222 ELECTRON_RUN_AS_NODE: "1",
223 T3_ACP_CGROUP_WRAPPER: "1",
224 },
225 });
226 expect(result.status, result.stderr).toBe(0);
227 expect(NodeFS.readFileSync(outputPath, "utf8")).toBe(
228 '{"args":["space value","single\'quote","double\\"quote"]}',
229 );
230 } finally {
231 yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease).pipe(Effect.ignore);
232 NodeFS.rmSync(scratch, { recursive: true, force: true });
233 }
234 }),
235 );
236
237 it("contains a packaged-runtime command without requiring cgroup delegation", () => {
238 if (HostProcess.Platform.defaultValue() !== "linux") return;
239 const scratchRoot = NodePath.join(process.cwd(), "tmp");
240 NodeFS.mkdirSync(scratchRoot, { recursive: true });
241 const scratch = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-cgroup-wrapper-fake-"));
242 const leasePath = NodePath.join(scratch, "lease with spaces");
243 const outputPath = NodePath.join(scratch, "argv output");
244 NodeFS.mkdirSync(leasePath);
245 NodeFS.writeFileSync(NodePath.join(leasePath, "cgroup.procs"), "");
246 const relativePath = AcpSessionRuntime.parseUnifiedCgroupPath(
247 NodeFS.readFileSync("/proc/self/cgroup", "utf8"),
248 );
249 expect(relativePath).toBeDefined();
250 const lease: AcpSessionRuntime.AcpLinuxCgroupLease = {
251 contains: () => false,
252 exists: () => true,
253 path: leasePath,
254 relativePath: relativePath!,
255 kill: () => undefined,
256 populated: () => false,
257 remove: () => undefined,
258 };
259 const packagedExecPath = vi.spyOn(process, "execPath", "get").mockReturnValue("/bin/sh");
260 try {
261 const wrapped = AcpSessionRuntime.wrapCommandForLinuxCgroup(lease, "/bin/sh", [
262 "-c",
263 'printf "%s\\n" "$0" "$1" "$2" "$3" "${ELECTRON_RUN_AS_NODE-}" "${T3_ACP_CGROUP_WRAPPER-}" > "$4"',
264 "packaged-target",
265 "space value",
266 "single'quote",
267 'double"quote',
268 outputPath,
269 ]);
270 const result = NodeChildProcess.spawnSync(wrapped.command, wrapped.args, {
271 encoding: "utf8",
272 env: {
273 ...process.env,
274 ELECTRON_RUN_AS_NODE: "1",
275 T3_ACP_CGROUP_WRAPPER: "1",
276 },
277 });
278 expect(result.status, result.stderr).toBe(0);
279 expect(NodeFS.readFileSync(outputPath, "utf8")).toBe(
280 "packaged-target\nspace value\nsingle'quote\ndouble\"quote\n\n\n",
281 );
282 expect(NodeFS.readFileSync(NodePath.join(leasePath, "cgroup.procs"), "utf8")).toMatch(
283 /^\d+\n$/,
284 );
285
286 const mismatch = AcpSessionRuntime.wrapCommandForLinuxCgroup(
287 { ...lease, relativePath: "/not-the-current-cgroup" },
288 "/bin/sh",
289 ["-c", 'printf executed > "$0"', outputPath],
290 );
291 NodeFS.rmSync(outputPath);
292 const mismatchResult = NodeChildProcess.spawnSync(mismatch.command, mismatch.args, {
293 encoding: "utf8",
294 });
295 expect(mismatchResult.status, mismatchResult.stderr).toBe(126);
296 expect(NodeFS.existsSync(outputPath)).toBe(false);
297
298 const missingTarget = AcpSessionRuntime.wrapCommandForLinuxCgroup(
299 lease,
300 "/nonexistent-t3-probe",
301 [],
302 );
303 const missingTargetResult = NodeChildProcess.spawnSync(
304 missingTarget.command,
305 missingTarget.args,
306 {
307 encoding: "utf8",
308 },
309 );
310 expect(missingTargetResult.status, missingTargetResult.stderr).toBe(125);
311 } finally {
312 packagedExecPath.mockRestore();
313 NodeFS.rmSync(scratch, { recursive: true, force: true });
314 }
315 });
316
317 it.live("kills a post-TERM detached double fork without touching an unrelated sentinel", () =>
318 Effect.gen(function* () {
319 if ((yield* HostProcess.Platform) !== "linux") return;
320 const controller = AcpSessionRuntime.makeLinuxCgroupController();
321 const lease = controller.create();
322 if (lease === undefined) return;
323 expect(
324 AcpSessionRuntime.parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8")),
325 ).not.toBe(lease.relativePath);
326 const scratchRoot = NodePath.join(process.cwd(), "tmp");
327 NodeFS.mkdirSync(scratchRoot, { recursive: true });
328 const scratch = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-cgroup-kill-"));
329 const readyPath = NodePath.join(scratch, "ready");
330 const childPath = NodePath.join(scratch, "child");
331 const childProgram = "setInterval(() => {}, 1000);";
332 const launcherProgram = [
333 'const fs = require("node:fs");',
334 'const cp = require("node:child_process");',
335 "const child = cp.spawn(process.execPath, ['-e', process.argv[2]], { detached: true, stdio: 'ignore' });",
336 "fs.writeFileSync(process.argv[1], String(child.pid));",
337 "child.unref();",
338 ].join("\n");
339 const providerProgram = [
340 'const fs = require("node:fs");',
341 'const cp = require("node:child_process");',
342 "fs.writeFileSync(process.argv[1], String(process.pid));",
343 "process.on('SIGTERM', () => {",
344 " const launcher = cp.spawn(process.execPath, ['-e', process.argv[3], process.argv[2], process.argv[4]], { detached: true, stdio: 'ignore' });",
345 " launcher.unref();",
346 " process.exit(0);",
347 "});",
348 "setInterval(() => {}, 1000);",
349 ].join("\n");
350 const wrapped = AcpSessionRuntime.wrapCommandForLinuxCgroup(lease, process.execPath, [
351 "-e",
352 providerProgram,
353 readyPath,
354 childPath,
355 launcherProgram,
356 childProgram,
357 ]);
358 const provider = NodeChildProcess.spawn(wrapped.command, wrapped.args, {
359 detached: true,
360 env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", T3_ACP_CGROUP_WRAPPER: "1" },
361 stdio: "ignore",
362 });
363 provider.unref();
364 const sentinel = NodeChildProcess.spawn(process.execPath, ["-e", childProgram], {
365 detached: true,
366 stdio: "ignore",
367 });
368 sentinel.unref();
369 let detachedChildPid: number | undefined;
370 try {
371 yield* Effect.gen(function* () {
372 while (!NodeFS.existsSync(readyPath)) yield* Effect.sleep("10 millis");
373 }).pipe(Effect.timeout("2 seconds"));
374 process.kill(provider.pid!, "SIGTERM");
375 detachedChildPid = yield* Effect.gen(function* () {
376 while (true) {
377 try {
378 const pid = Number(NodeFS.readFileSync(childPath, "utf8"));
379 if (Number.isSafeInteger(pid) && pid > 1) return pid;
380 } catch {
381 // The TERM-time launcher has not published its child yet.
382 }
383 yield* Effect.sleep("10 millis");
384 }
385 }).pipe(Effect.timeout("2 seconds"));
386 expect(processExists(detachedChildPid)).toBe(true);
387 yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease);
388 expect(processExists(sentinel.pid!)).toBe(true);
389 yield* Effect.gen(function* () {
390 while (processExists(detachedChildPid!)) yield* Effect.sleep("10 millis");
391 }).pipe(Effect.timeout("2 seconds"));
392 expect(NodeFS.existsSync(lease.path)).toBe(false);
393
394 const replacement = controller.create();
395 expect(replacement?.path).not.toBe(lease.path);
396 if (replacement !== undefined) {
397 NodeFS.mkdirSync(NodePath.join(replacement.path, "nested"));
398 yield* AcpSessionRuntime.terminateLinuxCgroupLease(replacement);
399 expect(NodeFS.existsSync(replacement.path)).toBe(false);
400 }
401 } finally {
402 yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease).pipe(Effect.ignore);
403 try {
404 process.kill(-provider.pid!, "SIGKILL");
405 } catch {
406 // The contained provider already exited.
407 }
408 try {
409 process.kill(-sentinel.pid!, "SIGKILL");
410 } catch {
411 // The unrelated sentinel already exited.
412 }
413 if (detachedChildPid !== undefined && processExists(detachedChildPid)) {
414 try {
415 process.kill(detachedChildPid, "SIGKILL");
416 } catch {
417 // The detached child already exited.
418 }
419 }
420 NodeFS.rmSync(scratch, { recursive: true, force: true });
421 }
422 }),
423 );
424
425 it.live("re-kills repopulated cgroups and verifies root removal after retryable errors", () =>
426 Effect.gen(function* () {
427 let exists = true;
428 let killCalls = 0;
429 let populated = false;
430 let removeCalls = 0;
431 const lease: AcpSessionRuntime.AcpLinuxCgroupLease = {
432 contains: () => false,
433 exists: () => exists,
434 path: "/test/t3-acp-repopulation",
435 relativePath: "/test/t3-acp-repopulation",
436 kill: () => {
437 killCalls += 1;
438 populated = false;
439 },
440 populated: () => populated,
441 remove: () => {
442 removeCalls += 1;
443 if (removeCalls === 1) {
444 populated = true;
445 const error = new Error("repopulated") as NodeJS.ErrnoException;
446 error.code = "EBUSY";
447 throw error;
448 }
449 if (removeCalls === 2) {
450 const error = new Error("nested path vanished") as NodeJS.ErrnoException;
451 error.code = "ENOENT";
452 throw error;
453 }
454 exists = false;
455 },
456 };
457
458 yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease);
459 expect(killCalls).toBeGreaterThanOrEqual(3);
460 expect(removeCalls).toBe(3);
461 expect(exists).toBe(false);
462 yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease);
463 expect(removeCalls).toBe(3);
464 }),
465 );
466
467 it.live("reports persistent retryable removal failures as removal failures", () =>
468 Effect.gen(function* () {
469 const removeError = Object.assign(new Error("cgroup remains busy"), {
470 code: "EBUSY",
471 });
472 const lease: AcpSessionRuntime.AcpLinuxCgroupLease = {
473 contains: () => false,
474 exists: () => true,
475 path: "/test/t3-acp-busy",
476 relativePath: "/test/t3-acp-busy",
477 kill: () => undefined,
478 populated: () => false,
479 remove: () => {
480 throw removeError;
481 },
482 };
483
484 const error = yield* Effect.flip(AcpSessionRuntime.terminateLinuxCgroupLease(lease));
485 expect(error.detail).toBe("Failed to remove ACP cgroup /test/t3-acp-busy");
486 expect(error.cause).toBe(removeError);
487 }),
488 );
489
490 it("sends TERM only to the exact captured root while it remains in the child cgroup", () => {
491 const fixture = makeController({
492 processes: [server(), identity(100, process.pid, 100, 100, "owned")],
493 });
494 const ownedRoot: AcpSessionRuntime.AcpOwnedPosixProcess = {
495 ...identity(100, process.pid, 100, 100, "owned"),
496 parentExecutable: server().executable,
497 parentStartTime: server().startTime,
498 };
499 const root: AcpSessionRuntime.AcpPosixOwnershipRoot = {
500 captureAttempted: true,
501 value: ownedRoot,
502 };
503 const lease: AcpSessionRuntime.AcpLinuxCgroupLease = {
504 contains: () => true,
505 exists: () => true,
506 path: "/test/t3-acp-root",
507 relativePath: "/test/t3-acp-root",
508 kill: () => undefined,
509 populated: () => true,
510 remove: () => undefined,
511 };
512 AcpSessionRuntime.signalLinuxCgroupRootTerm({ controller: fixture.controller, lease, root });
513 expect(fixture.signals).toEqual(["process:100:SIGTERM"]);
514
515 const reused = makeController({
516 processes: [server(), identity(100, process.pid, 100, 100, "reused")],
517 });
518 AcpSessionRuntime.signalLinuxCgroupRootTerm({ controller: reused.controller, lease, root });
519 expect(reused.signals).toEqual([]);
520
521 const migrated = makeController({
522 processes: [server(), identity(100, process.pid, 100, 100, "owned")],
523 });
524 AcpSessionRuntime.signalLinuxCgroupRootTerm({
525 controller: migrated.controller,
526 lease: { ...lease, contains: () => false },
527 root,
528 });
529 expect(migrated.signals).toEqual([]);
530 });
531
532 it.live("finds a child forked by a non-leader pthread", () =>
533 Effect.gen(function* () {
534 if ((yield* HostProcess.Platform) !== "linux") return;
535 const scratchRoot = NodePath.join(process.cwd(), "tmp");
536 NodeFS.mkdirSync(scratchRoot, { recursive: true });
537 const scratch = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-pthread-spawn-"));
538 const binary = NodePath.join(scratch, "helper");
539 const pidPath = NodePath.join(scratch, "pids");
540 expect(NodeFS.statSync(threadSpawnHelperSource).isFile()).toBe(true);
541 const compile = NodeChildProcess.spawnSync(
542 "cc",
543 ["-pthread", threadSpawnHelperSource, "-o", binary],
544 {
545 encoding: "utf8",
546 },
547 );
548 if ((compile.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
549 NodeFS.rmSync(scratch, { recursive: true, force: true });
550 return;
551 }
552 expect(compile.status, compile.stderr).toBe(0);
553 const helper = NodeChildProcess.spawn(binary, [pidPath], { detached: true, stdio: "ignore" });
554 helper.unref();
555 try {
556 const published = yield* Effect.gen(function* () {
557 while (true) {
558 try {
559 const ids = NodeFS.readFileSync(pidPath, "utf8").trim().split(/\s+/).map(Number);
560 if (ids.length === 2 && ids.every(Number.isSafeInteger)) return ids;
561 } catch {
562 // The worker has not published its TID and child yet.
563 }
564 yield* Effect.sleep("10 millis");
565 }
566 }).pipe(Effect.timeout("2 seconds"));
567 const [workerTid, childPid] = published;
568 expect(workerTid).not.toBe(helper.pid);
569 expect(
570 AcpSessionRuntime.makePosixProcessTreeController("linux").childrenOf(helper.pid!),
571 ).toContainEqual(expect.objectContaining({ pid: childPid, ppid: helper.pid }));
572 } finally {
573 try {
574 process.kill(-helper.pid!, "SIGKILL");
575 } catch {
576 // The isolated fixture already exited.
577 }
578 NodeFS.rmSync(scratch, { recursive: true, force: true });
579 }
580 }),
581 );
582
583 it.live("keeps ownership across a fork-before-exec executable transition", () =>
584 Effect.gen(function* () {
585 const beforeExec = { ...identity(110, 100, 110, 110), executable: "/provider/fork" };
586 const fixture = makeController({
587 processes: [server(), identity(100, process.pid, 100, 100), beforeExec],
588 });
589 const frontier = new Map<number, AcpSessionRuntime.AcpOwnedPosixProcess>();
590 const childQueues = new Map<number, Array<number>>();
591 const ledger = new Map<string, AcpSessionRuntime.AcpOwnedPosixProcess>();
592 const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined };
593 AcpSessionRuntime.observePosixOwnershipLedger({
594 childQueues,
595 controller: fixture.controller,
596 frontier,
597 ledger,
598 root,
599 rootPid: 100,
600 });
601 fixture.processes.set(110, { ...beforeExec, executable: "/usr/bin/bash" });
602 AcpSessionRuntime.observePosixOwnershipLedger({
603 childQueues,
604 controller: fixture.controller,
605 frontier,
606 ledger,
607 root,
608 rootPid: 100,
609 });
610
611 expect([...ledger.values()].find((process) => process.pid === 110)?.executable).toBe(
612 "/usr/bin/bash",
613 );
614 yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({
615 controller: fixture.controller,
616 grace: 0,
617 ledger,
618 rootPid: 100,
619 });
620 expect(fixture.signals).toContain("process:110:SIGTERM");
621 expect(fixture.processes.has(110)).toBe(false);
622 }),
623 );
624
625 it("never adopts a replacement PID after initial root capture failed", () => {
626 const fixture = makeController({ processes: [server()] });
627 const frontier = new Map<number, AcpSessionRuntime.AcpOwnedPosixProcess>();
628 const childQueues = new Map<number, Array<number>>();
629 const ledger = new Map<string, AcpSessionRuntime.AcpOwnedPosixProcess>();
630 const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined };
631 const capture = () => {
632 try {
633 AcpSessionRuntime.observePosixOwnershipLedger({
634 childQueues,
635 controller: fixture.controller,
636 frontier,
637 ledger,
638 root,
639 rootPid: 100,
640 });
641 return undefined;
642 } catch (error) {
643 return error;
644 }
645 };
646 expect(capture()).toMatchObject({
647 detail: "ACP process 100 exited before its ownership ledger was captured",
648 });
649 fixture.processes.set(100, identity(100, process.pid, 100, 100, "reused"));
650 expect(capture()).toMatchObject({
651 detail: "ACP process 100 exited before its ownership ledger was captured",
652 });
653 expect(ledger.size).toBe(0);
654 });
655
656 it.live("rotates more than 64 live parents without scanning retained tombstones", () =>
657 Effect.gen(function* () {
658 // The server fixture's process group is the runner's real pid, so a synthetic
659 // parent sharing it would be skipped as the server's own group.
660 const parentPidBase = process.pid < 500_000 ? 500_000 : 1_000;
661 const parents = Array.from({ length: 130 }, (_, index) =>
662 identity(parentPidBase + index, 100, parentPidBase + index, parentPidBase + index),
663 );
664 let childListReads = 0;
665 let identityCalls = 0;
666 let snapshotCalls = 0;
667 const fixture = makeController({
668 processes: [server(), identity(100, process.pid, 100, 100)],
669 });
670 const controller: AcpSessionRuntime.AcpPosixProcessTreeController = {
671 ...fixture.controller,
672 childPidsOf: (pid) => {
673 childListReads += 1;
674 return fixture.controller.childPidsOf(pid);
675 },
676 identity: (pid) => {
677 identityCalls += 1;
678 return fixture.controller.identity(pid);
679 },
680 snapshot: () => {
681 snapshotCalls += 1;
682 return fixture.controller.snapshot();
683 },
684 };
685 const frontier = new Map<number, AcpSessionRuntime.AcpOwnedPosixProcess>();
686 const childQueues = new Map<number, Array<number>>();
687 const ledger = new Map<string, AcpSessionRuntime.AcpOwnedPosixProcess>();
688 const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined };
689 for (let index = 0; index < 5_000; index += 1) {
690 const tombstone = identity(100_000 + index, 1, 100_000 + index, 100_000 + index);
691 ledger.set(`${tombstone.pid}:${tombstone.startTime}`, {
692 ...tombstone,
693 parentExecutable: undefined,
694 parentStartTime: "",
695 });
696 }
697 AcpSessionRuntime.observePosixOwnershipLedger({
698 childQueues,
699 controller,
700 frontier,
701 ledger,
702 root,
703 rootPid: 100,
704 });
705 for (const parent of parents) {
706 fixture.processes.set(parent.pid, parent);
707 fixture.processes.set(
708 parent.pid + 10_000,
709 identity(parent.pid + 10_000, parent.pid, parent.pgid, parent.sid),
710 );
711 }
712 let passes = 0;
713 while (
714 !parents.every((parent) =>
715 [...ledger.values()].some((owned) => owned.pid === parent.pid + 10_000),
716 ) &&
717 passes < 40
718 ) {
719 childListReads = 0;
720 identityCalls = 0;
721 AcpSessionRuntime.observePosixOwnershipLedger({
722 childQueues,
723 controller,
724 frontier,
725 ledger,
726 maxProcesses: 64,
727 root,
728 rootPid: 100,
729 });
730 expect(identityCalls + childListReads).toBeLessThanOrEqual(64);
731 passes += 1;
732 }
733
734 const missing = parents.filter(
735 (parent) => ![...ledger.values()].some((owned) => owned.pid === parent.pid + 10_000),
736 );
737 expect(missing, `missing after ${passes} passes`).toEqual([]);
738 expect(passes).toBeGreaterThan(2);
739 yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({
740 controller,
741 grace: 0,
742 ledger,
743 rootPid: 100,
744 });
745 expect(snapshotCalls).toBe(10);
746 expect(fixture.processes.size).toBe(1);
747 }),
748 );
749
750 it.live("polls only known descendants and stops with its scope", () =>
751 Effect.gen(function* () {
752 let childrenCalls = 0;
753 let identityCalls = 0;
754 let snapshotCalls = 0;
755 const fixture = makeController({
756 processes: [server(), identity(100, process.pid, 100, 100), identity(110, 100, 110, 110)],
757 });
758 const controller: AcpSessionRuntime.AcpPosixProcessTreeController = {
759 ...fixture.controller,
760 childPidsOf: (pid) => {
761 childrenCalls += 1;
762 return fixture.controller.childPidsOf(pid);
763 },
764 identity: (pid) => {
765 identityCalls += 1;
766 return fixture.controller.identity(pid);
767 },
768 snapshot: () => {
769 snapshotCalls += 1;
770 return fixture.controller.snapshot();
771 },
772 };
773 const scope = yield* Scope.make();
774 const frontier = new Map<number, AcpSessionRuntime.AcpOwnedPosixProcess>();
775 const childQueues = new Map<number, Array<number>>();
776 let ledgerValuesCalls = 0;
777 const ledger = new (class extends Map<string, AcpSessionRuntime.AcpOwnedPosixProcess> {
778 override values(): MapIterator<AcpSessionRuntime.AcpOwnedPosixProcess> {
779 ledgerValuesCalls += 1;
780 return super.values();
781 }
782 })();
783 const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined };
784 for (let index = 0; index < 5_000; index += 1) {
785 const tombstone = identity(10_000 + index, 1, 10_000 + index, 10_000 + index);
786 ledger.set(`${tombstone.pid}:${tombstone.startTime}`, {
787 ...tombstone,
788 parentExecutable: undefined,
789 parentStartTime: "",
790 });
791 }
792 yield* AcpSessionRuntime.observePosixOwnershipLedgerContinuously({
793 childQueues,
794 controller,
795 frontier,
796 ledger,
797 root,
798 rootPid: 100,
799 }).pipe(Effect.forkIn(scope));
800 const timerStartedAt = yield* Clock.currentTimeMillis;
801 yield* Effect.sleep(0);
802 const timerDelay = (yield* Clock.currentTimeMillis) - timerStartedAt;
803 yield* Effect.sleep("80 millis");
804
805 expect(timerDelay).toBeLessThan(250);
806 expect(ledgerValuesCalls).toBe(0);
807 expect(snapshotCalls).toBe(0);
808 expect(childrenCalls).toBeGreaterThan(0);
809 expect(childrenCalls).toBeLessThanOrEqual(12);
810 const callsAtClose = identityCalls + childrenCalls;
811 yield* Scope.close(scope, Exit.void);
812 yield* Effect.sleep("60 millis");
813 expect(identityCalls + childrenCalls).toBe(callsAtClose);
814 }),
815 );
816
817 it.live("terminates nested owned groups bottom-up and catches a TERM fork race", () =>
818 Effect.gen(function* () {
819 let forked = false;
820 const fixture = makeController({
821 processes: [
822 server(),
823 identity(100, process.pid, 100, 100),
824 identity(110, 100, 110, 110),
825 identity(115, 110, 115, 110),
826 identity(120, 110, 120, 120),
827 identity(121, 120, 120, 120),
828 ],
829 onProcess: (processes, pid, signal) => {
830 if (signal === "SIGTERM" && pid === 121 && !forked) {
831 forked = true;
832 processes.set(122, identity(122, 110, 122, 122));
833 }
834 processes.delete(pid);
835 },
836 });
837
838 yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({
839 controller: fixture.controller,
840 grace: 0,
841 rootPid: 100,
842 });
843
844 expect(fixture.processes.has(100)).toBe(false);
845 expect(fixture.processes.has(122)).toBe(false);
846 expect(fixture.signals.indexOf("process:121:SIGTERM")).toBeLessThan(
847 fixture.signals.indexOf("process:120:SIGTERM"),
848 );
849 expect(fixture.signals.indexOf("process:115:SIGTERM")).toBeLessThan(
850 fixture.signals.indexOf("process:110:SIGTERM"),
851 );
852 }),
853 );
854
855 it.live("falls back to exact PIDs for unknown group members and a missing leader", () =>
856 Effect.gen(function* () {
857 const unrelated = identity(999, 1, 110, 110, "unrelated");
858 const fixture = makeController({
859 processes: [
860 server(),
861 identity(100, process.pid, 100, 100),
862 identity(110, 100, 110, 110),
863 identity(111, 110, 110, 110),
864 unrelated,
865 ],
866 onProcess: (processes, pid) => {
867 processes.delete(pid);
868 if (pid === 111) processes.delete(110);
869 },
870 });
871
872 yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({
873 controller: fixture.controller,
874 grace: 0,
875 rootPid: 100,
876 });
877
878 expect(fixture.signals).toContain("process:111:SIGTERM");
879 expect(fixture.processes.get(999)).toEqual(unrelated);
880 }),
881 );
882
883 it.live("re-admits a still-owned child after PID reuse and never signals the T3 session", () =>
884 Effect.gen(function* () {
885 const reused = identity(110, 100, 110, 110, "reused");
886 const fixture = makeController({
887 processes: [
888 server(),
889 identity(100, process.pid, 100, 100),
890 identity(110, 100, 110, 110, "owned"),
891 identity(120, 100, 120, process.pid),
892 ],
893 onProcess: (processes, pid) => {
894 if (pid === 110) processes.set(110, reused);
895 else processes.delete(pid);
896 },
897 });
898
899 const result = yield* Effect.exit(
900 AcpSessionRuntime.terminatePosixOwnedProcessTree({
901 controller: fixture.controller,
902 grace: 0,
903 rootPid: 100,
904 }),
905 );
906
907 // PID 110 morphs to a new identity under the owned root and never exits, so
908 // teardown fails closed after re-admitting and re-signalling the live child.
909 expect(Exit.isFailure(result)).toBe(true);
910 expect(fixture.processes.get(110)).toEqual(reused);
911 expect(
912 fixture.signals.filter((entry) => entry.startsWith("process:110:")).length,
913 ).toBeGreaterThan(0);
914 expect(fixture.signals.some((entry) => entry.includes(":120:"))).toBe(false);
915 }),
916 );
917
918 it.live("does not treat zombie residual entries as teardown survivors", () =>
919 Effect.gen(function* () {
920 expect(AcpSessionRuntime.posixProcessIsZombie({ ...identity(1, 0, 1, 1), state: "Z" })).toBe(
921 true,
922 );
923 expect(AcpSessionRuntime.posixProcessIsZombie({ ...identity(1, 0, 1, 1), state: "S" })).toBe(
924 false,
925 );
926 const withoutState: AcpSessionRuntime.AcpPosixProcessIdentity = {
927 executable: "/proc/1/exe",
928 pgid: 1,
929 pid: 1,
930 ppid: 0,
931 sid: 1,
932 startTime: "1",
933 };
934 expect(AcpSessionRuntime.posixProcessIsZombie(withoutState)).toBe(false);
935
936 const root = identity(100, process.pid, 100, 100);
937 const zombieChild = identity(110, 100, 110, 110, "110", "Z");
938 const fixture = makeController({
939 processes: [server(), root, zombieChild],
940 onProcess: (processes, pid) => {
941 // Kill leaves a zombie with the same pid/starttime until reaped.
942 const current = processes.get(pid);
943 if (current === undefined) return;
944 processes.set(pid, { ...current, state: "Z" });
945 },
946 });
947 yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({
948 controller: fixture.controller,
949 grace: 0,
950 rootPid: 100,
951 });
952 // Zombies remain visible but must not fail residual teardown.
953 expect(fixture.processes.get(100)?.state).toBe("Z");
954 expect(fixture.processes.get(110)?.state).toBe("Z");
955 }),
956 );
957
958 it("admits a live child after a free PID was previously reserved by a dead entry", () => {
959 const staleChild: AcpSessionRuntime.AcpOwnedPosixProcess = {
960 ...identity(200, 100, 200, 200, "stale-child"),
961 parentExecutable: `/proc/100/exe`,
962 parentStartTime: "100",
963 };
964 const liveChild = identity(200, 100, 200, 200, "live-child");
965 const root = identity(100, process.pid, 100, 100);
966 const ownedRoot: AcpSessionRuntime.AcpOwnedPosixProcess = {
967 ...root,
968 parentExecutable: `/proc/${process.pid}/exe`,
969 parentStartTime: "server",
970 };
971 const ledger = new Map<string, AcpSessionRuntime.AcpOwnedPosixProcess>([
972 [`100:${root.startTime}`, ownedRoot],
973 [`200:${staleChild.startTime}`, staleChild],
974 ]);
975 const controller = makeController({
976 processes: [server(), root],
977 }).controller;
978
979 // First capture: PID 200 is free, so the dead reservation is dropped.
980 AcpSessionRuntime.capturePosixOwnershipLedger({
981 controller,
982 ledger,
983 rootPid: 100,
984 table: [root],
985 });
986 expect([...ledger.values()].some((entry) => entry.pid === 200)).toBe(false);
987
988 // Second capture: a new process reuses PID 200 under the owned parent.
989 AcpSessionRuntime.capturePosixOwnershipLedger({
990 controller: makeController({ processes: [server(), root, liveChild] }).controller,
991 ledger,
992 rootPid: 100,
993 table: [root, liveChild],
994 });
995
996 const admitted = [...ledger.values()].find((entry) => entry.pid === 200);
997 expect(admitted?.startTime).toBe("live-child");
998 });
999
1000 it("admits a live child when a stale ledger entry still occupies the reused PID", () => {
1001 const staleChild: AcpSessionRuntime.AcpOwnedPosixProcess = {
1002 ...identity(200, 100, 200, 200, "stale-child"),
1003 parentExecutable: `/proc/100/exe`,
1004 parentStartTime: "100",
1005 };
1006 const liveChild = identity(200, 100, 200, 200, "live-child");
1007 const root = identity(100, process.pid, 100, 100);
1008 const ownedRoot: AcpSessionRuntime.AcpOwnedPosixProcess = {
1009 ...root,
1010 parentExecutable: `/proc/${process.pid}/exe`,
1011 parentStartTime: "server",
1012 };
1013 const ledger = new Map<string, AcpSessionRuntime.AcpOwnedPosixProcess>([
1014 [`100:${root.startTime}`, ownedRoot],
1015 [`200:${staleChild.startTime}`, staleChild],
1016 ]);
1017
1018 AcpSessionRuntime.capturePosixOwnershipLedger({
1019 controller: makeController({ processes: [server(), root, liveChild] }).controller,
1020 ledger,
1021 rootPid: 100,
1022 table: [root, liveChild],
1023 });
1024
1025 expect([...ledger.values()].some((entry) => entry.startTime === "stale-child")).toBe(false);
1026 const admitted = [...ledger.values()].find((entry) => entry.pid === 200);
1027 expect(admitted?.startTime).toBe("live-child");
1028 });
1029
1030 it.live("signals children forked under a still-live parent during the same pass", () =>
1031 Effect.gen(function* () {
1032 let forked = false;
1033 const fixture = makeController({
1034 processes: [
1035 server(),
1036 identity(100, process.pid, 100, 100),
1037 identity(110, 100, 110, 110),
1038 identity(111, 110, 111, 111),
1039 ],
1040 onProcess: (processes, pid) => {
1041 // Bottom-up: signal 111 first while 110 is still live, then fork 112
1042 // under 110 so the same pass must enqueue and signal 112.
1043 if (pid === 111 && !forked) {
1044 forked = true;
1045 processes.set(112, identity(112, 110, 112, 112));
1046 }
1047 processes.delete(pid);
1048 },
1049 });
1050
1051 yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({
1052 controller: fixture.controller,
1053 grace: 0,
1054 rootPid: 100,
1055 });
1056
1057 expect(forked).toBe(true);
1058 expect(fixture.processes.has(112)).toBe(false);
1059 expect(fixture.signals.some((entry) => entry.startsWith("process:112:"))).toBe(true);
1060 }),
1061 );
1062
1063 it.live("detects an owned child first observed in the final survivor snapshot", () =>
1064 Effect.gen(function* () {
1065 const lateChild = identity(111, 110, 111, 111);
1066 const fixture = makeController({
1067 processes: [server(), identity(100, process.pid, 100, 100), identity(110, 100, 110, 110)],
1068 onProcess: (processes, pid) => {
1069 // Leave the descendant parent alive through teardown so it can fork
1070 // immediately after the last signal pass.
1071 if (pid !== 110) processes.delete(pid);
1072 },
1073 });
1074 let snapshotCalls = 0;
1075 const controller: AcpSessionRuntime.AcpPosixProcessTreeController = {
1076 ...fixture.controller,
1077 snapshot: () => {
1078 snapshotCalls += 1;
1079 // Two TERM discovery passes, the root TERM pass, and two KILL passes
1080 // precede the final survivor snapshot.
1081 if (snapshotCalls === 6) fixture.processes.set(lateChild.pid, lateChild);
1082 return fixture.controller.snapshot();
1083 },
1084 };
1085
1086 const error = yield* Effect.flip(
1087 AcpSessionRuntime.terminatePosixOwnedProcessTree({
1088 controller,
1089 discoveryPasses: 2,
1090 grace: 0,
1091 rootPid: 100,
1092 }),
1093 );
1094
1095 expect(snapshotCalls).toBe(6);
1096 expect(error.detail).toContain("110, 111");
1097 }),
1098 );
1099
1100 it.live("retains the ledger across root exit and an explicit failure for finalizer retry", () =>
1101 Effect.gen(function* () {
1102 let ignoreSignals = true;
1103 const ledger = new Map<string, AcpSessionRuntime.AcpOwnedPosixProcess>();
1104 const frontier = new Map<number, AcpSessionRuntime.AcpOwnedPosixProcess>();
1105 const childQueues = new Map<number, Array<number>>();
1106 const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined };
1107 const fixture = makeController({
1108 processes: [
1109 server(),
1110 identity(100, process.pid, 100, 100),
1111 identity(110, 100, 110, 110),
1112 identity(111, 110, 110, 110),
1113 ],
1114 onProcess: (processes, pid) => {
1115 if (ignoreSignals && pid === 100) processes.delete(100);
1116 if (!ignoreSignals) processes.delete(pid);
1117 },
1118 });
1119 AcpSessionRuntime.observePosixOwnershipLedger({
1120 childQueues,
1121 controller: fixture.controller,
1122 frontier,
1123 ledger,
1124 root,
1125 rootPid: 100,
1126 });
1127
1128 const explicit = yield* Effect.exit(
1129 AcpSessionRuntime.terminatePosixOwnedProcessTree({
1130 controller: fixture.controller,
1131 grace: 0,
1132 ledger,
1133 rootPid: 100,
1134 }),
1135 );
1136 expect(Exit.isFailure(explicit)).toBe(true);
1137 expect(fixture.processes.has(100)).toBe(false);
1138 expect(fixture.processes.has(111)).toBe(true);
1139
1140 fixture.processes.set(112, identity(112, 110, 112, 112));
1141 AcpSessionRuntime.observePosixOwnershipLedger({
1142 childQueues,
1143 controller: fixture.controller,
1144 frontier,
1145 ledger,
1146 root,
1147 rootPid: 100,
1148 });
1149 ignoreSignals = false;
1150 yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({
1151 controller: fixture.controller,
1152 grace: 0,
1153 ledger,
1154 rootPid: 100,
1155 });
1156 expect(fixture.processes.has(110)).toBe(false);
1157 expect(fixture.processes.has(111)).toBe(false);
1158 expect(fixture.processes.has(112)).toBe(false);
1159 }),
1160 );
1161
1162 it("fails closed where a stable POSIX identity provider is unavailable", () => {
1163 try {
1164 AcpSessionRuntime.makePosixProcessTreeController("darwin");
1165 throw new Error("Expected Darwin process ownership to fail closed");
1166 } catch (error) {
1167 expect(error).toMatchObject({
1168 detail: "Detached ACP descendant ownership is unsupported on darwin",
1169 });
1170 }
1171 });
1172});