How Worktrunk Centralizes Signal Handling for Child Processes

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 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, 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 (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:

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, 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 (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).
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 and handle_command_error in 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 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 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, 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, 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 automatically applies this calculation when it detects a signal-induced failure via err.interrupt_signal().

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 →