# How MXC Handles Ctrl-C Signals for Graceful Container Shutdown

> Discover how MXC gracefully handles Ctrl-C signals. Learn about its watchdog thread, signal handling, and iptables cleanup for safe container shutdowns.

- Repository: [Microsoft/mxc](https://github.com/microsoft/mxc)
- Tags: internals
- Published: 2026-06-07

---

**MXC installs a dedicated watchdog thread that blocks SIGHUP, SIGTERM, and SIGINT in the main process, waits for these signals using `sigwait`, and executes a forced cleanup of iptables rules and LXC container destruction before exiting with status `128 + signo`.**

When running Linux containers via the LXC backend, the Microsoft Extended Containers (MXC) project ensures that a **Ctrl-C** (SIGINT) interruption does not leave orphaned containers or dangling network rules. According to the `microsoft/mxc` source code, this is achieved through a specialized signal-handling architecture that separates signal reception from the main execution flow.

## Signal Handling Architecture

### The Watchdog Thread Pattern

The MXC LXC backend guarantees container destruction by spawning a **watchdog thread** during initialization. This thread blocks the three fatal signals—**SIGHUP**, **SIGTERM**, and **SIGINT**—in the main thread using `pthread_sigmask`, then enters a `sigwait` loop to receive them synchronously. When the watchdog catches a signal, it performs a deterministic cleanup sequence: retrieving the active container name, removing iptables hooks, destroying the container, and terminating the process with a conventional Unix exit status.

### Thread Inheritance Requirements

The watchdog installation **must execute once and early in `main`** before any additional threads are spawned. Because `pthread_sigmask` only affects the calling thread, and new threads inherit the signal mask at creation time, delaying installation would allow spawned threads to receive the fatal signals directly—circumventing the watchdog and potentially leaving the container in a zombie state.

## Container Cleanup Workflow

When a user presses **Ctrl-C**, the watchdog executes the following sequence:

1. **Signal Capture**: The watchdog unblocks from `sigwait` upon receiving SIGINT (signal number 2).

2. **Container Identification**: It retrieves the currently active container name and optional veth interface via the global registration state populated by `signal_cleanup::set_active()`.

3. **Network Cleanup**: It invokes `NetworkIptablesManager::force_cleanup` to remove any iptables rules associated with the container's network namespace.

4. **Container Destruction**: It calls `LxcContainer::destroy()` to delete the container and its root filesystem.

5. **Process Termination**: It exits with status `128 + signo` (e.g., 130 for SIGINT), ensuring the parent process interprets the termination as a standard signal-driven exit.

## Implementation in the LXC Backend

The implementation spans two primary source files: [`src/backends/lxc/common/src/signal_cleanup.rs`](https://github.com/microsoft/mxc/blob/main/src/backends/lxc/common/src/signal_cleanup.rs) for the watchdog logic, and [`src/backends/lxc/common/src/lxc_bindings.rs`](https://github.com/microsoft/mxc/blob/main/src/backends/lxc/common/src/lxc_bindings.rs) for the container wrapper.

### Setting Up the Watchdog

Install the watchdog at the entry point of your LXC runner to ensure proper signal handling:

```rust
fn main() -> Result<(), String> {
    // Install must happen first, before spawning threads
    signal_cleanup::install()?;

    let container = LxcContainer::new("demo", None);
    container.start()?;
    
    // Register the active container for cleanup tracking
    signal_cleanup::set_active(container.name());
    
    // Optional: register veth if network isolation is used
    if let Some(veth) = container.veth_name() {
        signal_cleanup::set_active_veth(veth);
    }

    let (code, _out, _err) = container.attach_run(
        "while true; do echo tick; sleep 1; done",
        "/",
        None,
    )?;

    println!("Command exited with code {code}");
    Ok(())
}

```

### Propagating Signals to the Watchdog

When executing commands inside the container, the `attach_run` method ensures that **Ctrl-C** propagates correctly to the watchdog rather than being trapped by the inner shell. It unblocks the three fatal signals for the PTY layer:

```rust
// Inside LxcContainer::attach_run implementation
const UNBLOCK: &[Signal] = &[Signal::SIGHUP, Signal::SIGTERM, Signal::SIGINT];
let options = PtyOptions {
    unblock_signals: UNBLOCK,
    ..PtyOptions::default()
};

```

This design ensures that SIGINT generated by a **Ctrl-C** keystroke inside the container reaches the main process's watchdog thread, triggering the graceful teardown.

### Watchdog Execution Logic

The internal `run_watchdog` function in [`signal_cleanup.rs`](https://github.com/microsoft/mxc/blob/main/signal_cleanup.rs) implements the cleanup sequence:

```rust
fn run_watchdog() -> Result<(), Box<dyn Error>> {
    let mask = SignalMask::new(Signal::SIGHUP, Signal::SIGTERM, Signal::SIGINT);
    
    loop {
        let sig = mask.wait()?; // Blocks until one of the three signals arrives
        
        let mut guard = lock_slot();
        let active = std::mem::take(&mut *guard);
        
        if let Some(name) = active.name {
            let mut logger = get_logger();
            NetworkIptablesManager::force_cleanup(
                &name, 
                active.veth.as_deref(), 
                &mut logger
            );
            let _ = LxcContainer::new(&name, None).destroy();
        }
        
        std::process::exit(128 + sig as i32);
    }
}

```

## Summary

- **MXC uses a watchdog thread** to exclusively handle SIGHUP, SIGTERM, and SIGINT, preventing signal races in a multi-threaded process.
- **Installation must occur early in `main`** to ensure all child threads inherit the blocked signal mask.
- **Cleanup is deterministic**: the watchdog removes iptables rules via `NetworkIptablesManager::force_cleanup` before calling `LxcContainer::destroy()`.
- **Exit codes follow Unix conventions** (`128 + signal_number`), allowing parent processes like the `mx` CLI to detect signal-driven termination accurately.
- **Signal propagation** is managed through `PtyOptions` in `attach_run`, ensuring Ctrl-C reaches the watchdog even when interacting with container shells.

## Frequently Asked Questions

### Why does MXC use a watchdog thread instead of traditional signal handlers?

The watchdog pattern using `sigwait` is **async-signal-safe** and avoids the restricted execution context of traditional signal handlers. By blocking the signals in all threads and dedicating a single thread to `sigwait`, MXC can safely invoke complex cleanup operations—including network rule deletion and container destruction—that would be unsafe within an asynchronous signal handler context.

### What exit status does MXC return when Ctrl-C is pressed?

When the watchdog receives SIGINT (signal 2), the process exits with status **130** (`128 + 2`). Similarly, SIGTERM yields 143 and SIGHUP yields 129. This follows the standard Unix convention where exit codes above 128 indicate termination by signal, allowing shell scripts and parent processes to distinguish between normal exits and signal-induced shutdowns.

### Can the watchdog handle multiple simultaneous containers?

The current implementation tracks a **single active container** at a time through the `set_active` registration API. When a new container starts, it replaces the previous registration in the global slot. This design suits the typical MXC workflow where one `mx` CLI invocation manages one container lifecycle. For parallel container management, separate process isolation would be required.

### How does MXC prevent Ctrl-C from terminating processes inside the container?

When `attach_run` creates the PTY for the container command, it explicitly **unblocks SIGHUP, SIGTERM, and SIGINT** in the `PtyOptions`. This ensures that a Ctrl-C keystroke inside the terminal generates SIGINT that propagates to the main process's watchdog rather than being consumed by the containerized shell or blocked by signal masks, guaranteeing the watchdog can initiate the graceful shutdown sequence.