# How Worktrunk Centralizes Signal Handling for Child Processes

> Discover how Worktrunk centralizes signal handling for child processes using a two-tier architecture for seamless signal capture and consistent exit code propagation.

- Repository: [Maximilian Roos/worktrunk](https://github.com/max-sixty/worktrunk)
- Tags: internals
- Published: 2026-09-14

---

**Worktrunk centralizes signal handling for child processes through a two-tier architecture that combines a `ForegroundSignals` listener to capture and forward signals with a `handle_command_error` routine to propagate signal-derived exit codes consistently.**

Managing subprocess lifecycle in a monorepo tool requires reliable signal propagation. In the `max-sixty/worktrunk` repository, signal handling is not delegated to individual commands but centralized through dedicated modules that ensure `SIGINT`, `SIGTERM`, and `SIGHUP` reach every child process while maintaining predictable parent exit codes.

## The Two-Tier Architecture for Signal Management

Worktrunk treats signals as first-class events requiring consistent propagation. The implementation splits responsibility across two core components:

- **`ForegroundSignals`** – Installs a process-wide listener in [`src/signal_forwarder.rs`](https://github.com/max-sixty/worktrunk/blob/main/src/signal_forwarder.rs) that records the first signal received and provides helpers to forward that signal to specific child PIDs or process-group IDs.
- **`handle_command_error`** – Located in [`src/commands/command_executor.rs`](https://github.com/max-sixty/worktrunk/blob/main/src/commands/command_executor.rs), this function inspects child exit errors and aborts execution loops when it detects signal-induced failures, ensuring the parent exits with the appropriate signal-derived code (e.g., 130 for `SIGINT`).

## Capturing Signals with ForegroundSignals

Before executing any foreground command, Worktrunk initializes the signal infrastructure. The `ForegroundSignals::install()` method creates a `signal-hook` listener registered for `SIGINT`, `SIGTERM`, and `SIGHUP`.

According to the source in [`src/signal_forwarder.rs`](https://github.com/max-sixty/worktrunk/blob/main/src/signal_forwarder.rs) (lines 68-108), the listener stores only the *first* signal received. This design prevents race conditions where multiple rapid interrupts might create inconsistent state. Once captured, the signal remains available for the duration of the command execution.

### Forwarding to Individual Processes

When spawning a child via the unified `Cmd` wrapper, the code extracts the child's PID. The `forward_to_pid()` method sends the recorded signal to that specific process:

```rust
use worktrunk::signal_forwarder::ForegroundSignals;

// Install at the start of a foreground command
let signals = ForegroundSignals::install()?;

// Spawn a child via the Cmd wrapper
let child = Cmd::new("git")
    .args(&["status"])
    .current_dir(&worktree_path)
    .run()?;

// Forward any captured signal to the child PID
let _forwarder = signals.forward_to_pid(child.pid(), false);

```

The second parameter controls whether the child shares the parent's process group. The `ActiveForwarder` returned by this method holds an OS-level forwarding thread; dropping it or calling `stop()` ceases further signal propagation.

### Forwarding to Process Groups

For commands that spawn multiple subprocesses—such as hook pipelines—Worktrunk uses `forward_to_pgids()`. This iterates over a list of process-group IDs (PGIDs), ensuring complex command chains receive consistent signal delivery. This approach is essential in [`src/shell_exec.rs`](https://github.com/max-sixty/worktrunk/blob/main/src/shell_exec.rs), where the `Cmd` wrapper isolates children in dedicated process groups to enable clean termination of entire process trees.

## Propagating Exit Codes via handle_command_error

After a child exits, `handle_command_error` in [`src/commands/command_executor.rs`](https://github.com/max-sixty/worktrunk/blob/main/src/commands/command_executor.rs) (lines 682-715) inspects the result. If the error carries a signal—detected via `err.interrupt_signal()`—the function performs two critical actions:

1. **Aborts surrounding loops**: Whether iterating over worktrees or executing hook pipelines, the function returns early to prevent subsequent steps from running after an interrupt.
2. **Uniform exit status**: The parent process exits with the signal-derived code calculated as `128 + signal_number` (e.g., 143 for `SIGTERM` or 130 for `SIGINT`).

```rust
fn run_hook_step(cmd: Cmd, failure_strategy: FailureStrategy) -> Result<()> {
    match cmd.run() {
        Ok(_) => Ok(()),
        Err(err) => handle_command_error(
            err,
            &cmd,
            &error_wrapper,
            failure_strategy,
        ),
    }
}

```

If the child process was killed by `SIGTERM`, `handle_command_error` ensures the parent `wt` process exits with code 143, satisfying the design goal that "Ctrl-C aborts the current command, not the whole program" without treating signal exits as ordinary failures.

## Summary

- Worktrunk centralizes signal handling through `ForegroundSignals` in [`src/signal_forwarder.rs`](https://github.com/max-sixty/worktrunk/blob/main/src/signal_forwarder.rs) and `handle_command_error` in [`src/commands/command_executor.rs`](https://github.com/max-sixty/worktrunk/blob/main/src/commands/command_executor.rs).
- The `ForegroundSignals` listener captures only the first incoming signal to prevent race conditions and stores it for the command duration.
- Signals are forwarded to children via `forward_to_pid()` for single processes or `forward_to_pgids()` for process groups, using `ActiveForwarder` to manage the lifecycle.
- The `Cmd` wrapper in [`src/shell_exec.rs`](https://github.com/max-sixty/worktrunk/blob/main/src/shell_exec.rs) isolates children in process groups, enabling clean signal propagation to entire subprocess trees.
- `handle_command_error` converts signal-derived child exits into consistent parent exit codes (128 + signal) and aborts execution loops to prevent further processing after interruption.

## Frequently Asked Questions

### What signals does Worktrunk capture and forward?

Worktrunk registers listeners for `SIGINT`, `SIGTERM`, and `SIGHUP` through the `signal-hook` crate. The `ForegroundSignals` implementation in [`src/signal_forwarder.rs`](https://github.com/max-sixty/worktrunk/blob/main/src/signal_forwarder.rs) captures only the first signal received and propagates that specific signal to all relevant child processes.

### How does Worktrunk prevent multiple signals from causing inconsistent state?

The `ForegroundSignals` struct stores only the *first* signal it receives, ignoring subsequent signals of the same or different types. This design, implemented in [`src/signal_forwarder.rs`](https://github.com/max-sixty/worktrunk/blob/main/src/signal_forwarder.rs), ensures that the signal forwarded to children remains consistent throughout the command lifecycle, preventing race conditions during rapid user interrupts.

### Why does Worktrunk forward signals to process groups instead of only individual PIDs?

While single commands use `forward_to_pid()`, complex operations like hook pipelines require `forward_to_pgids()` because they spawn multiple subprocesses. By isolating children in dedicated process groups via the `Cmd` wrapper in [`src/shell_exec.rs`](https://github.com/max-sixty/worktrunk/blob/main/src/shell_exec.rs), Worktrunk can terminate entire process trees with a single signal, ensuring no orphaned processes remain when a user interrupts a multi-step command.

### How are exit codes calculated when a child process exits due to a signal?

Worktrunk follows the standard Unix convention where signal-derived exit codes equal 128 plus the signal number. For example, `SIGINT` (signal 2) results in exit code 130, while `SIGTERM` (signal 15) results in code 143. The `handle_command_error` function in [`src/commands/command_executor.rs`](https://github.com/max-sixty/worktrunk/blob/main/src/commands/command_executor.rs) automatically applies this calculation when it detects a signal-induced failure via `err.interrupt_signal()`.