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

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, 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.

// 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 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 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.

// 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: 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.

// 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.

// 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.

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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →