| 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import * as NodeChildProcess from "node:child_process"; |
| 3 | import * as NodeFS from "node:fs"; |
| 4 | import * as NodePath from "node:path"; |
| 5 | import * as NodeURL from "node:url"; |
| 6 | |
| 7 | import { describe, expect, it, vi } from "@effect/vitest"; |
| 8 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 9 | import * as Clock from "effect/Clock"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Exit from "effect/Exit"; |
| 12 | import * as Scope from "effect/Scope"; |
| 13 | |
| 14 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 15 | |
| 16 | const threadSpawnHelperSource = NodeURL.fileURLToPath( |
| 17 | new URL("../../../scripts/acp-thread-spawn-helper.c", import.meta.url), |
| 18 | ); |
| 19 | |
| 20 | const 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 | |
| 37 | function 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 | |
| 64 | const server = () => identity(process.pid, 1, process.pid, process.pid, "server"); |
| 65 | |
| 66 | function processExists(pid: number): boolean { |
| 67 | try { |
| 68 | process.kill(pid, 0); |
| 69 | return true; |
| 70 | } catch { |
| 71 | return false; |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | describe("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 | }); |