How MXC Handles Ctrl-C Signals for Graceful Container Shutdown

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 for the watchdog logic, and 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:

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:

// 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 implements the cleanup sequence:

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.

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 →