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:
-
Signal Capture: The watchdog unblocks from
sigwaitupon receiving SIGINT (signal number 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(). -
Network Cleanup: It invokes
NetworkIptablesManager::force_cleanupto remove any iptables rules associated with the container's network namespace. -
Container Destruction: It calls
LxcContainer::destroy()to delete the container and its root filesystem. -
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
mainto ensure all child threads inherit the blocked signal mask. - Cleanup is deterministic: the watchdog removes iptables rules via
NetworkIptablesManager::force_cleanupbefore callingLxcContainer::destroy(). - Exit codes follow Unix conventions (
128 + signal_number), allowing parent processes like themxCLI to detect signal-driven termination accurately. - Signal propagation is managed through
PtyOptionsinattach_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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →