Fixing Ctrl+C in Rust Terminal Apps: Child Process Management

Published Tue, July 29, 2025 ∙ Product, Update ∙ by Nicolas Joseph

When a terminal application that spawns child processes doesn't exit cleanly after a Ctrl+C, the user is left with a corrupted terminal. Instead of a clean prompt, you get garbled output and a non-functional shell. This post covers how to solve these issues, with examples from the Moose CLI (for the PR that fixed many of these issues, see here).

In this post, you’ll read learnings from solving these issues in the Moose CLI— terminal application that manages multiple child processes, including Docker containers, TypeScript compilers, and background workers.

The Problems: Terminal Corruption and Hanging Processes

Terminal corruption manifests in several ways:

  1. Terminal State Corruption: After Ctrl+C, the terminal cursor might be hidden, raw mode might still be enabled, or the alternate screen buffer might still be active
  2. Child Process Output Interference: Child processes continue writing to stdout/stderr, mixing with your shell prompt
  3. Hanging Background Processes: Child processes don't receive proper termination signals and continue running
  4. Race Conditions: Cleanup code races with child process output, leading to unpredictable terminal state

How We Solved It

1. Process Output Proxying

Child process output must be completely isolated from the terminal. Direct child process output to the terminal creates race conditions and corruption.

Key principles:

  • Pipe all child process stdio: Use Stdio::piped() for stdout/stderr and Stdio::null() for stdin. Stdio::piped() will create a new pipe that is going to be readable by the parent process but will only be written to the stdout of the parent if explicitly done. And Stdio::null() will enable to ignore the inputs.
  • Proxy to logging system: Forward child process output to your logging system instead of directly to terminal
  • Handle I/O errors gracefully: child process streams can fail; don't let that crash your proxy
  • Wait for completion: Ensure all output is read before proceeding with cleanup

2. Terminal State Management

Terminal applications need explicit cleanup to restore the terminal to its original state:

Key principles:

  • Always cleanup on exit: Call cleanup in both success and error paths
  • Use crossterm for consistency: Crossterm provides cross-platform terminal manipulation
  • Ignore cleanup errors: Terminal might already be in the desired state
  • Follow the standard cleanup sequence: Raw mode, alternate screen, cursor visibility

3. Graceful Process Termination

Proper child process lifecycle management prevents hanging processes:

Key principles:

  • Graceful before forceful: Attempt graceful shutdown with SIGTERM before forcing termination with SIGKILL.
  • Use timeouts: Don't wait forever for processes to stop
  • Track all processes: Maintain a registry of spawned processes
  • Handle partial failures: Some processes might fail to stop cleanly

4. Thread-Safe Spinner Management

Interactive elements like spinners need careful coordination with child process output to prevent both from writing to the terminal simultaneously, which misformats characters in the terminal display.

Key principles:

  • Reserve terminal lines: Capture cursor position to reserve lines for updates
  • Synchronize thread termination: Wait for animation threads to fully stop before cleanup
  • Use atomic signals: Coordinate between threads with atomic operations
  • Clean up reserved space: Clear spinner lines completely when stopping

Testing Strategies

  1. Signal Handling Tests: Verify proper cleanup when receiving SIGINT/SIGTERM
  2. Race Condition Tests: Use tools like tokio-test to simulate timing issues
  3. Terminal State Tests: Verify terminal state before and after operations

Common Pitfalls to Avoid

  1. Direct child process output to terminal: Always proxy through your logging system
  2. Forgetting stdin: Set stdin(Stdio::null()) to prevent child processes from reading terminal input
  3. Not waiting for threads: Always join/await background threads before cleanup
  4. Ignoring partial failures: Handle cases where some processes fail to stop
  5. Platform-specific assumptions: Use cross-platform libraries like crossterm
  6. Blocking cleanup: Keep cleanup operations non-blocking where possible

Conclusion

Building robust terminal applications requires careful child process management. To provide a clean user experience, especially when handling Ctrl+C:

  • Isolate child process output.
  • Implement comprehensive terminal cleanup on exit.
  • Use graceful shutdown patterns with timeouts.
  • Coordinate interactive elements with the process lifecycle.

Implementing these patterns from the start will save you from dealing with frustrated users and terminal issues down the line.

Appendix: Implementation Patterns

Pattern 1: Output Isolation

Pattern 2: Graceful Shutdown with Timeout

Pattern 3: Component-Based Terminal Management