How no-mistakes Handles Shell Command Cancellation and Grandchild Process Reaping

no-mistakes prevents orphaned processes by isolating subprocesses in their own process groups and using SIGKILL on the negative PID to terminate the entire tree, combined with a 5-second WaitDelay to break pipe deadlocks.

Managing long-running shell commands in a build system requires more than starting processes and hoping they clean up after themselves. The no-mistakes repository solves the shell command cancellation and grandchild process reaping problem through a specialized wrapper in internal/shellenv that guarantees complete subprocess termination across Linux, macOS, and Windows.

Process Group Isolation on Unix

The foundation of reliable cancellation lies in process group isolation. In internal/shellenv/shell_command_unix.go, the ConfigureShellCommand function assigns every spawned command to its own process group by setting SysProcAttr.Setpgid = true.

func ConfigureShellCommand(cmd *exec.Cmd) {
    cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
    // ...
}

This makes the spawned command the leader of a new process group, ensuring that any grandchild processes it creates inherit this group ID. By addressing the group rather than individual PIDs, no-mistakes can terminate entire process trees with a single signal.

Signal-Based Cancellation

Once a command is isolated in its own group, cancellation becomes a matter of targeting the negative PID of the leader. The library installs a cmd.Cancel function that sends SIGKILL to -cmd.Process.Pid, which the Unix kernel interprets as "deliver to the entire process group."

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
}

If the context expires or the user cancels the operation, this callback fires and immediately terminates the leader along with any grandchildren that may have inherited open pipes or file descriptors.

Preventing Pipe Deadlocks with WaitDelay

Even after sending SIGKILL, a grandchild process could survive by holding onto the leader’s stdout or stderr pipes, causing cmd.Wait to block indefinitely. To prevent this pipe-wedge scenario, ConfigureShellCommand sets a default WaitDelay of 5 seconds unless the caller specifies otherwise.

if cmd.WaitDelay == 0 {
    cmd.WaitDelay = defaultWaitDelay // 5 * time.Second
}

After this delay, the Go runtime forcibly closes the inherited pipes, allowing Wait to return even if a stray grandchild remains alive in the background. This guarantees the daemon does not hang on cleanup.

Explicit Group Termination for Streaming Commands

For steps that require manual output streaming, no-mistakes provides TerminateShellCommandGroup in internal/shellenv/shell_command_unix.go. This function sends a final SIGKILL to the entire process group after the leader exits, acting as a safety net for any survivors.

func TerminateShellCommandGroup(cmd *exec.Cmd) {
    if cmd == nil || cmd.Process == nil {
        return
    }
    _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) // ESRCH => no survivors
}

Callers typically defer this function immediately after StartShellCommand, ensuring deterministic cleanup regardless of how the command exited.

Cross-Platform Implementation Strategy

The no-mistakes daemon adapts its reaping strategy to the underlying operating system:

  • Windows: internal/shellenv/shell_command_windows.go creates a Job Object that automatically kills the entire job tree when the leader terminates, providing functionality equivalent to Unix process groups.
  • Unix: Linux and macOS use the Setpgid and negative PID signaling described above.
  • Other platforms: A no-op stub in shell_command_other.go allows compilation on platforms lacking native group support, though without guaranteed grandchild reaping.

Integration Points Throughout the Codebase

All cancellable subprocesses in the repository invoke shellenv.ConfigureShellCommand before starting. Key call sites include:

Summary

  • Process group isolation via Setpgid makes the spawned command a leader, allowing group-targeted signals.
  • Negative PID signaling in cmd.Cancel sends SIGKILL to the entire process group, eliminating grandchildren.
  • WaitDelay of 5 seconds prevents cmd.Wait from blocking on pipe-holding orphans.
  • Explicit termination via TerminateShellCommandGroup provides a deferred cleanup safety net.
  • Platform abstractions use Job Objects on Windows and process groups on Unix to achieve consistent behavior.

Frequently Asked Questions

Why does no-mistakes use a negative PID with syscall.Kill?

Passing a negative PID to syscall.Kill instructs the Unix kernel to deliver the signal to every process in that process group. Since ConfigureShellCommand creates a new group for each command, -cmd.Process.Pid targets the leader and all its descendants simultaneously, ensuring no orphaned grandchildren survive cancellation.

What is the purpose of the 5-second WaitDelay?

The WaitDelay acts as a circuit breaker for pipe deadlocks. If a grandchild holds onto the parent’s stdout or stderr after the leader receives SIGKILL, cmd.Wait would normally block forever. After 5 seconds, the runtime closes these pipes forcibly, allowing Wait to return and the daemon to reclaim resources.

How does the Windows implementation differ from Unix?

On Windows, internal/shellenv/shell_command_windows.go creates a Job Object and associates the spawned command with it. When the leader process terminates, the operating system automatically terminates all other processes in the job, achieving the same grandchild reaping as Unix process groups without manual signal handling.

Where is ConfigureShellCommand typically invoked?

The function is invoked immediately before starting any long-lived subprocess. In internal/pipeline/steps/common_exec.go, it wraps user commands. In internal/agent/*, it protects AI model processes. Test files like internal/shellenv/shell_command_unix_test.go verify that cancellation correctly reaps grandchildren under various timing conditions.

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 →