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

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

Configuring the Process Group

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

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:

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, the configuration guards against zero values:

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, the implementation sends SIGKILL to the entire process group and safely ignores "no such process" errors:

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:

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, which invokes ConfigureShellCommand before execution:

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:

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 implements protection via Windows Job Objects, while internal/shellenv/shell_command_other.go provides a no-op stub for unsupported platforms:

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.

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 uses POSIX process groups and syscall.Kill, Windows systems use Job Objects in 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. These helpers configure the command, execute it, and ensure proper cleanup without additional caller code.

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 →