Skip to content

ChildProcess: support scoped ownership after the group leader exits successfully #8338

Description

@jasonkuhrt

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-node 4.0.0-rc.115, setting forceKillAfter on 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:

const process = yield* command

yield* Effect.addFinalizer(() =>
  process.kill({
    killSignal: "SIGTERM",
    forceKillAfter: "1500 millis"
  }).pipe(Effect.ignore)
)

The explicit handle kill does 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

  1. Start a group leader that spawns a same-group child which ignores SIGTERM.
  2. Let the leader exit successfully and await its exitCode while the child remains alive.
  3. Close the scope without explicitly calling the handle's kill in the test body.
  4. With group ownership selected, the descendant is terminated within the configured escalation deadline.

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.

Activity

  1. jasonkuhrt commented on Sep 23, 2026

    @jasonkuhrt
    ContributorAuthor

    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 runs terminateProcessGroup: kill signal, then forceKillAfter, 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 killProcessGroup with the kill signal, with no wait and no forceKillAfter. The exit listener 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.ts at 34e09be:

    • 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 exit listener calls killProcessGroupOnExit(...) on a non-zero exit

    Repro (effect and @effect/platform-node 4.0.0-rc.117, Node 22.23.1, macOS; on main after #8354 the import is effect/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. forceKillAfter then applies whenever the scope releases the group, whatever the leader's exit, as this issue's "Desired behavior" asks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions