vercel/next.js · critical
failed to set ctrl_c handler
Error message
failed to set ctrl_c handler
What it means
ExitHandler::listen() spawns an async task that awaits tokio::signal::ctrl_c() to install a SIGINT handler for graceful shutdown; it calls .expect("failed to set ctrl_c handler") on the result. If installing the handler fails, the task panics. The most common cause is calling listen() from a non-main thread (Unix signal delivery requires the main thread) or when another signal handler is already installed.
Source
Thrown at turbopack/crates/turbopack-trace-utils/src/exit.rs:69
/// Waits for `SIGINT` using [`tokio::signal::ctrl_c`], and exits the process with exit code `0`
/// after running any futures scheduled with [`ExitHandler::on_exit`].
///
/// As this uses global process signals, this must only be called once, and will panic if called
/// multiple times. Use this when you own the process (e.g. `turbopack-cli`).
///
/// If you don't own the process (e.g. you're called as a library, such as in `next-swc`), use
/// [`ExitHandler::new_receiver`] instead.
///
/// This may listen for other signals, like `SIGTERM` or `SIGPIPE` in the future.
pub fn listen() -> &'static Arc<ExitHandler> {
let (handler, receiver) = Self::new_receiver();
if GLOBAL_EXIT_HANDLER.set(handler).is_err() {
panic!("ExitHandler::listen must only be called once");
}
tokio::spawn(async move {
tokio::signal::ctrl_c()
.await
.expect("failed to set ctrl_c handler");
receiver.run_exit_handler().await;
std::process::exit(0);
});
GLOBAL_EXIT_HANDLER.get().expect("value is set")
}
/// Creates an [`ExitHandler`] that can be manually controlled with an [`ExitReceiver`].
///
/// This does not actually exit the process or listen for any signals. If you'd like that
/// behavior, use [`ExitHandler::listen`].
///
/// Because this API has no global side-effects and can be called many times within the same
/// process, it is possible to use it to provide a mock [`ExitHandler`] inside unit tests.
pub fn new_receiver() -> (Arc<ExitHandler>, ExitReceiver) {
let (tx, rx) = mpsc::unbounded_channel();
(Arc::new(ExitHandler { tx }), ExitReceiver { rx })
}
View on GitHub (pinned to 0ae8c72462)
Solutions
- Ensure ExitHandler::listen() is called only once and from the main thread / a runtime driving the main thread.
- If you don't own the process, use ExitHandler::new_receiver() instead of listen() — it avoids global signal installation.
- Remove any conflicting custom SIGINT handler installed by another part of the host process.
Example fix
// before: called from a library / worker thread let handler = ExitHandler::listen(); // after: use the non-global receiver in library contexts let (handler, _receiver) = ExitHandler::new_receiver();
Defensive patterns
Strategy: validation
Prevention
- Call ExitHandler::listen() exactly once and only when you own the process main thread.
- In library/embedded contexts, use ExitHandler::new_receiver() instead to avoid global signal installation.
- Ensure no other crate has installed a conflicting SIGINT handler.
When it happens
Trigger: Calling ExitHandler::listen() from a worker thread or a tokio runtime that is not driving the main thread; invoking it twice (it also panics on the second GLOBAL_EXIT_HANDLER set); environments where SIGINT installation is blocked.
Common situations: Embedding the Turbopack/trace runtime inside a host process that doesn't own the main thread; a library context (next-swc) that should use ExitHandler::new_receiver() instead; conflicting signal handlers from another crate.
Related errors
AI-assisted analysis of vercel/next.js@0ae8c72462 (2026-08-06).
Data as JSON: /api/errors/64ae6c13ff7abcee.
Report an issue: GitHub.