Repository navigation
ChildProcess: support scoped ownership after the group leader exits successfully #8338
Description
Activity
Update from
4.0.0-rc.117: #8245 (shipped in rc.116) covers the successful-exit case. When a referenced, detached command's leader exits 0 or by a signal, scope release now runsterminateProcessGroup: kill signal, thenforceKillAfter, then SIGKILL. We deleted our adapter.One exit path still skips escalation: a leader that exits with a non-zero code. On that path, release only calls
killProcessGroupwith the kill signal, with no wait and noforceKillAfter. Theexitlistener does the same when the leader exits. Nothing on that path ever sends SIGKILL, so a descendant that ignores SIGTERM outlives the scope.packages/platform/node-shared/src/NodeChildProcessSpawner.tsat34e09be:- L549-L550: release after a non-zero exit calls only
killProcessGroup(...) - L551-L552: release after exit 0 or a signal calls
terminateProcessGroup(...) - L559: release while the leader is still running calls
terminateProcessGroup(...) - L564-L567: the
exitlistener callskillProcessGroupOnExit(...)on a non-zero exit
Repro (
effectand@effect/platform-node4.0.0-rc.117, Node 22.23.1, macOS; onmainafter #8354 the import iseffect/process):import * as NodeServices from "@effect/platform-node/NodeServices" import { Effect } from "effect" import { ChildProcess } from "effect/unstable/process" import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" const isAlive = (pid) => { try { process.kill(pid, 0) return true } catch { return false } } const run = (leaderExitCode) => Effect.gen(function*() { const pidFile = path.join(os.tmpdir(), `repro-8338-${process.pid}-${leaderExitCode}.pid`) // The leader starts a same-group child that ignores SIGTERM, records its // pid, then exits with `leaderExitCode`. const script = `(trap '' TERM; exec sleep 30) & echo $! > ${pidFile}; exit ${leaderExitCode}` yield* Effect.scoped( Effect.gen(function*() { const handle = yield* ChildProcess.make("sh", ["-c", script], { forceKillAfter: "100 millis" }) yield* handle.exitCode }) ) // The scope is closed, so the spawner's release has run. yield* Effect.sleep("2 seconds") const pid = Number(fs.readFileSync(pidFile, "utf8").trim()) const alive = isAlive(pid) if (alive) process.kill(pid, "SIGKILL") fs.rmSync(pidFile, { force: true }) return { leaderExitCode, childAliveTwoSecondsAfterRelease: alive } }) const results = await Effect.runPromise( Effect.all([run(0), run(1)]).pipe(Effect.provide(NodeServices.layer)) ) console.log(JSON.stringify(results))
Output:
[{"leaderExitCode":0,"childAliveTwoSecondsAfterRelease":false},{"leaderExitCode":1,"childAliveTwoSecondsAfterRelease":true}]Expected: release on the non-zero path escalates the same way as on the other two.
forceKillAfterthen applies whenever the scope releases the group, whatever the leader's exit, as this issue's "Desired behavior" asks.- L549-L550: release after a non-zero exit calls only
Request
Provide a native scoped process-group ownership policy that terminates descendants when the scope closes even if the leader already exited successfully.
This is an enhancement to the documented behavior, not a request to reopen #7821. #8018 fixed waiting and escalation when terminating a group, but explicitly preserves the successful-leader early return. The ownership goal is related to #5303.
Why we still need an adapter
Our development-process supervisor owns the commands it starts. A shell or launcher can exit with code 0 while leaving same-group descendants alive. Closing that child's scope should still clean up those descendants, including under an "await all" or "first exit wins" policy.
With
effect/@effect/platform-node4.0.0-rc.115, settingforceKillAfteron the command does not express that ownership:NodeChildProcessSpawner's automatic release returns early for an already-successful leader.We can now delete our custom group signalling/polling/escalation code thanks to #8018, but still need this extra finalizer on POSIX:
The explicit handle
killdoes clean up a group after its leader exits. The pipeline handle also forwards it to every member.Desired behavior
A caller can declare ownership of the entire command process group for the scope's lifetime. Scope closure then uses the configured kill signal and escalation deadline regardless of whether the leader is still running or already exited. Applications that intentionally let descendants outlive the launcher should be able to retain that behavior.
This would let us remove the remaining adapter and use native scoped commands directly.
Regression case
exitCodewhile the child remains alive.killin the test body.This report is based on the installed rc.115 source and #8018's documented semantics. I have added this regression to our adapter's tests locally; that new test has not been run yet.