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

  1. Ensure ExitHandler::listen() is called only once and from the main thread / a runtime driving the main thread.
  2. If you don't own the process, use ExitHandler::new_receiver() instead of listen() — it avoids global signal installation.
  3. 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

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.