# How no-mistakes Implements Subprocess Lifecycle Management to Prevent Leaked Grandchild Processes

> no-mistakes implements subprocess lifecycle management using process groups and custom cancellation routines to prevent leaked grandchild processes, ensuring reliable cleanup.

- Repository: [Kun Chen/no-mistakes](https://github.com/kunchenguid/no-mistakes)
- Tags: internals
- Published: 2026-07-04

---

**The no-mistakes repository eliminates leaked grandchild processes by isolating every subprocess in its own process group and attaching a custom cancellation routine that guarantees cleanup across all descendant processes.**

The `no-mistakes` repository provides a robust **subprocess lifecycle management** solution that prevents the memory leaks and resource exhaustion caused by orphaned grandchild processes. By leveraging Unix process groups and Windows job objects, the codebase ensures that when a parent command exits—whether successfully, on error, or via cancellation—every descendant process is properly terminated. This approach is implemented consistently across pipeline steps and AI agent runners to maintain system stability.

## Process Group Isolation with ConfigureShellCommand

The foundation of the leak-prevention strategy lies in [`internal/shellenv/shell_command_unix.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/shellenv/shell_command_unix.go), where the `ConfigureShellCommand` function isolates each subprocess before execution begins. This function modifies the `exec.Cmd` structure to create a new process group, ensuring that grandchildren and deeper descendants are contained within a single administrative boundary.

### Unix Implementation

On Unix systems, the implementation sets `Setpgid: true` within `syscall.SysProcAttr`, making the launched command the leader of a new process group. It also installs a custom `Cancel` function that targets the entire group using a negative PID.

```go
// internal/shellenv/shell_command_unix.go
func ConfigureShellCommand(cmd *exec.Cmd) {
    // each cmd runs in its own process group
    cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
    // install a back‑stop timeout for pipe reads
    if cmd.WaitDelay == 0 {
        cmd.WaitDelay = defaultWaitDelay
    }
    // Cancel kills the whole group (negative PID)
    cmd.Cancel = func() error {
        if cmd.Process == nil {
            return os.ErrProcessDone
        }
        err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
        if errors.Is(err, syscall.ESRCH) {
            return os.ErrProcessDone
        }
        return err
    }
}

```

### Windows Job Objects

For Windows environments, [`internal/shellenv/shell_command_windows.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/shellenv/shell_command_windows.go) implements equivalent semantics using job objects. This creates an isolated container for the subprocess and its descendants, allowing the lifecycle manager to terminate the entire tree in a single operation.

## Guaranteed Group Termination via TerminateShellCommandGroup

When a command completes—or must be aborted—the `TerminateShellCommandGroup` function in [`internal/shellenv/shell_command_unix.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/shellenv/shell_command_unix.go) ensures no descendant survives. By passing the negative of the process PID to `syscall.Kill`, the signal reaches every member of the process group simultaneously.

```go
// internal/shellenv/shell_command_unix.go
func TerminateShellCommandGroup(cmd *exec.Cmd) {
    if cmd == nil || cmd.Process == nil {
        return
    }
    // negative PID = whole group
    _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}

```

The function safely ignores `ESRCH` errors (indicating the group has already exited), making it safe to invoke unconditionally via `defer` statements.

## One-Shot Wrappers for Complete Lifecycle Control

Typical callers do not manually configure and terminate processes. Instead, they use the exported wrappers in [`internal/shellenv/shellenv.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/shellenv/shellenv.go): **RunShellCommand**, **OutputShellCommand**, and **CombinedOutputShellCommand**. These helpers invoke `ConfigureShellCommand`, start the process, wait for completion, and finally call `TerminateShellCommandGroup` to reap the entire tree—even on success.

```go
// Example: Running a user‑provided lint command safely
func runUserLint(cmdStr string) error {
    cmd := exec.Command("sh", "-c", cmdStr) // build the exec.Cmd
    shellenv.ConfigureShellCommand(cmd)    // isolate in its own group
    // start and wait in one shot; the group is terminated on return
    return shellenv.RunShellCommand(context.Background(), cmd)
}

```

For scenarios requiring manual control (such as streaming output), the pattern remains consistent: configure, defer termination, then wait.

```go
// Example: Manual start/stop when you need to stream output
func streamAndCleanup(cmdStr string) error {
    cmd := exec.Command("sh", "-c", cmdStr)
    shellenv.ConfigureShellCommand(cmd)

    if err := shellenv.StartShellCommand(cmd); err != nil {
        return err
    }
    defer shellenv.TerminateShellCommandGroup(cmd) // always clean up

    // pipe handling …
    // e.g., io.Copy(os.Stdout, cmd.StdoutPipe())
    return cmd.Wait()
}

```

## Production Usage in Pipeline Steps and Agent Runners

The **subprocess lifecycle management** pattern is applied throughout the codebase to prevent resource leaks in long-running operations.

- **Pipeline execution**: [`internal/pipeline/steps/common_exec.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/pipeline/steps/common_exec.go) calls `shellenv.ConfigureShellCommand` before executing user-defined commands, ensuring that build steps cannot leak processes.
- **AI agent runners**: Each native agent (Claude, Codex, Copilot, Pi, ACP) prepares its `exec.Cmd` with `ConfigureShellCommand` in files such as [`internal/agent/claude.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/agent/claude.go) and [`internal/agent/codex.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/agent/codex.go).
- **Verification**: Tests like `TestCodexAgent_Run_ReapsLeakedGrandchildOnCleanExit` in [`internal/agent/reap_unix_test.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/agent/reap_unix_test.go) and analogous tests in [`internal/pipeline/steps/shell_unix_test.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/pipeline/steps/shell_unix_test.go) verify that grandchildren are killed after the parent exits cleanly.

## Summary

- **Process group isolation**: Uses `Setpgid: true` (Unix) and job objects (Windows) to contain descendant processes.
- **Custom cancellation**: The `Cancel` closure sends `SIGKILL` to the negative PID, targeting the entire group.
- **Deferred cleanup**: `TerminateShellCommandGroup` reaps remaining processes on every exit path.
- **Unified wrappers**: `RunShellCommand` and related functions enforce the pattern consistently across the codebase.
- **Production coverage**: Implemented in pipeline steps and AI agent runners with automated regression tests.

## Frequently Asked Questions

### What causes grandchild process leaks in Go applications?

Standard `exec.Cmd` only tracks the immediate child PID. If that child spawns its own children (grandchildren), they can outlive the parent if the intermediate process terminates abruptly or if the parent only waits for the direct child. These orphaned processes continue consuming memory and may hold file descriptors open.

### How does Setpgid prevent process leaks?

By setting `Setpgid: true`, the subprocess becomes the leader of a new process group with a unique group ID matching its PID. This allows the parent to target the entire group with a single signal using the negative PID value (`-cmd.Process.Pid`), ensuring all descendants receive the termination signal simultaneously regardless of nesting depth.

### Why does no-mistakes use SIGKILL instead of SIGTERM?

SIGKILL guarantees immediate termination without allowing the process to ignore or handle the signal. This is critical for preventing hung processes or misbehaving grandchildren from holding resources (such as git worktrees or TCP ports) open indefinitely, which could eventually crash the daemon.

### Can this pattern handle deeply nested descendant processes?

Yes. Because process groups are hierarchical and `TerminateShellCommandGroup` targets the entire group via the negative PID, all members of the group—including great-grandchildren—are terminated regardless of nesting depth. The kernel handles the propagation through the group membership list.