# How no-mistakes shellenv Prevents Grandchild Process Leaks on Clean Exit

> Learn how no-mistakes shellenv prevents grandchild process leaks. It isolates subprocesses and uses a wait-delay backstop for SIGKILL delivery on clean exits.

- Repository: [Kun Chen/no-mistakes](https://github.com/kunchenguid/no-mistakes)
- Tags: how-to-guide
- Published: 2026-06-30

---

**no-mistakes shellenv prevents grandchild process leaks by isolating every subprocess in its own process group and enforcing a 5-second wait-delay backstop that guarantees SIGKILL delivery to the entire process tree on exit.**

The `no-mistakes` repository provides robust shell environment management for long-running daemons that spawn external commands. When subprocesses create their own children—grandchildren of the original process—traditional cleanup mechanisms often fail, leading to zombie processes that exhaust system memory. The shellenv package implements a multi-layered defense strategy in [`internal/shellenv/shell_command_unix.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/shellenv/shell_command_unix.go) that ensures complete process tree termination even during clean exits.

## Process Group Isolation

The foundation of leak prevention begins with **process group isolation** in [`internal/shellenv/shell_command_unix.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/shellenv/shell_command_unix.go).

### Configuring the Process Group

When spawning a command, `ConfigureShellCommand` configures the `SysProcAttr` to create a new process group:

```go
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}

```

Setting `Setpgid: true` makes the spawned command the leader of a new process group where `pgid == pid`. This allows signals sent to the negative PID to reach every member of the group simultaneously, including any grandchildren spawned by the immediate child.

### The Cancel Hook

The function also wires a custom cancellation handler to `cmd.Cancel`:

```go
cmd.Cancel = func() error {
    return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}

```

This ensures that context cancellation sends `SIGKILL` to the entire process group (`-cmd.Process.Pid`), not just the immediate child.

## The Wait-Delay Backstop

Even with signal delivery, grandchildren can hang indefinitely by holding open the leader's stdout or stderr pipes.

### Preventing Pipe Hangs

The shellenv package prevents this via **cmd.WaitDelay**, set to a default of 5 seconds on Unix systems. In [`internal/shellenv/shell_command_unix.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/shellenv/shell_command_unix.go), the configuration guards against zero values:

```go
defaultWaitDelay = 5 * time.Second

if cmd.WaitDelay == 0 {
    cmd.WaitDelay = defaultWaitDelay
}

```

If a grandchild process survives the initial `SIGKILL` or holds a pipe open, Go's runtime forces closure of the pipes after the delay expires. This prevents `cmd.Wait` from blocking forever and guarantees the leader process can exit cleanly.

## Guaranteed Reaping on Clean Exit

The critical mechanism for preventing leaks during normal termination is `TerminateShellCommandGroup`.

### TerminateShellCommandGroup Implementation

Callers using `StartShellCommand` or the one-shot helpers defer this function after starting the process. Located in [`internal/shellenv/shell_command_unix.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/shellenv/shell_command_unix.go), the implementation sends `SIGKILL` to the entire process group and safely ignores "no such process" errors:

```go
func TerminateShellCommandGroup(cmd *exec.Cmd) error {
    if cmd.Process == nil {
        return nil
    }
    err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
    if errors.Is(err, syscall.ESRCH) {
        return nil // Already terminated
    }
    return err
}

```

Even after `cmd.Wait` returns successfully, the deferred call ensures any stray grandchildren are terminated. If no processes remain in the group, the `Kill` syscall returns `ESRCH`, which the function treats as success.

## Practical Usage Patterns

### Long-Running Commands

For daemon operations that spawn persistent subprocesses:

```go
cmd := exec.CommandContext(ctx, "npm", "test")
shellenv.ConfigureShellCommand(cmd)      // Sets pgid, wait-delay, cancel
if err := shellenv.StartShellCommand(cmd); err != nil {
    return err
}
defer shellenv.TerminateShellCommandGroup(cmd) // Always reap the group

// ... read stdout/stderr ...
if err := cmd.Wait(); err != nil {
    return err
}

```

The `defer` guarantees that even if `cmd.Wait()` returns cleanly, any orphaned grandchildren receive `SIGKILL`.

### One-Shot Helpers

The package provides convenience wrappers that apply the same protection automatically. `OutputShellCommand` internally calls `defaultShellCommandOutput` in [`internal/shellenv/shellenv.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/shellenv/shellenv.go), which invokes `ConfigureShellCommand` before execution:

```go
out, err := shellenv.OutputShellCommand(exec.Command("git", "rev-parse", "HEAD"))

```

This ensures the login-shell probe and other short-lived commands benefit from process-group isolation without manual configuration.

### Context Cancellation

To abort running commands programmatically:

```go
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, "go", "test", "./...")
shellenv.ConfigureShellCommand(cmd)

go func() {
    time.Sleep(2 * time.Second)
    cancel() // Triggers cmd.Cancel → SIGKILL whole group
}()

err := cmd.Run() // Returns after entire group terminates

```

## Cross-Platform Implementation

While Unix systems use process groups, the package maintains API compatibility across platforms. [`internal/shellenv/shell_command_windows.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/shellenv/shell_command_windows.go) implements protection via Windows Job Objects, while [`internal/shellenv/shell_command_other.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/shellenv/shell_command_other.go) provides a no-op stub for unsupported platforms:

```go
func ConfigureShellCommand(cmd *exec.Cmd) {
    // No-op on platforms without process-group support
}

```

This ensures the high-level helpers compile and execute correctly regardless of the target operating system.

## Summary

- **Process groups**: `ConfigureShellCommand` sets `Setpgid: true` to isolate subprocess trees, enabling signal targeting via negative PID.
- **Kill-on-cancel**: The `cmd.Cancel` hook sends `SIGKILL` to the entire group when contexts cancel.
- **Wait-delay protection**: A 5-second `WaitDelay` prevents `cmd.Wait` from hanging when grandchildren hold pipes open.
- **Reap-on-exit**: `TerminateShellCommandGroup` defers ensure no processes survive clean exits, safely ignoring `ESRCH` when groups are already empty.
- **Universal coverage**: One-shot helpers like `OutputShellCommand` automatically apply these protections via `defaultShellCommandOutput` in [`internal/shellenv/shellenv.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/shellenv/shellenv.go).

## Frequently Asked Questions

### What happens if a grandchild process ignores SIGKILL?

`SIGKILL` cannot be caught, blocked, or ignored by any process. However, if a grandchild is in an uninterruptible sleep state (D-state), the signal remains pending. The 5-second `WaitDelay` in `ConfigureShellCommand` forces closure of the stdout/stderr pipes after the timeout, allowing `cmd.Wait` to return and the deferred `TerminateShellCommandGroup` to complete. While the process may persist briefly in the kernel, it cannot outlive the pipe closure indefinitely.

### How does the 5-second WaitDelay prevent indefinite hangs?

When a parent process exits, grandchildren holding open file descriptors (like the parent's stdout pipe) prevent the Go runtime from considering the `exec.Cmd` finished. By setting `cmd.WaitDelay = 5 * time.Second`, the runtime closes these pipes forcefully after the delay, breaking the dependency. This causes `cmd.Wait` to return immediately with an error indicating the pipes closed, rather than blocking forever.

### Is grandchild leak prevention active on Windows?

Yes, though the mechanism differs. While [`internal/shellenv/shell_command_unix.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/shellenv/shell_command_unix.go) uses POSIX process groups and `syscall.Kill`, Windows systems use Job Objects in [`internal/shellenv/shell_command_windows.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/shellenv/shell_command_windows.go) to achieve equivalent process-tree termination. The public API (`ConfigureShellCommand`, `TerminateShellCommandGroup`) remains identical, ensuring portable leak prevention across operating systems.

### Do I need to manually call TerminateShellCommandGroup when using helper functions?

No. When using `StartShellCommand`, you must defer `TerminateShellCommandGroup` manually. However, the one-shot helpers—`RunShellCommand`, `OutputShellCommand`, and `CombinedOutputShellCommand`—handle this automatically via `defaultShellCommandOutput` in [`internal/shellenv/shellenv.go`](https://github.com/kunchenguid/no-mistakes/blob/main/internal/shellenv/shellenv.go). These helpers configure the command, execute it, and ensure proper cleanup without additional caller code.