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

> Learn how no-mistakes prevents orphaned processes with process groups and SIGKILL. Discover how it handles shell command cancellation and grandchild process reaping.

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

---

**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`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/shellenv/shell_command_unix.go), the `ConfigureShellCommand` function assigns every spawned command to its own process group by setting `SysProcAttr.Setpgid = true`.

```go
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."

```go
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.

```go
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`](https://github.com/kunchenguid/no-mistakes/blob/main/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.

```go
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`](https://github.com/kunchenguid/no-mistakes/blob/main/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`](https://github.com/kunchenguid/no-mistakes/blob/main/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:

- [`internal/pipeline/steps/common_exec.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/pipeline/steps/common_exec.go) – wraps user-defined build commands.
- `internal/agent/*` – spawns AI model processes and agent adapters.
- Test suites in [`internal/agent/reap_unix_test.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/agent/reap_unix_test.go) and [`internal/shellenv/shell_command_unix_test.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/shellenv/shell_command_unix_test.go) – verify that cancellation and reaping work correctly under race conditions.

## 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`](https://github.com/kunchenguid/no-mistakes/blob/main/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`](https://github.com/kunchenguid/no-mistakes/blob/main/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`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/shellenv/shell_command_unix_test.go) verify that cancellation correctly reaps grandchildren under various timing conditions.