From 8726db2b6d0cdbf2a8f9b34c83416be49b9495bc Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Mon, 15 Jun 2026 13:42:48 -0600 Subject: [PATCH 1/7] fix: Clear terminal before TUI output replay --- crates/turborepo-ui/src/tui/app.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/turborepo-ui/src/tui/app.rs b/crates/turborepo-ui/src/tui/app.rs index 303706c2c75c6..9934c5dc7f48b 100644 --- a/crates/turborepo-ui/src/tui/app.rs +++ b/crates/turborepo-ui/src/tui/app.rs @@ -1091,6 +1091,12 @@ fn cleanup + io::Write>( super::panic_handler::set_mouse_capture_disabled(); } + // Clear stale main-screen content before replaying task logs and summary. + crossterm::execute!( + terminal.backend_mut(), + crossterm::terminal::Clear(crossterm::terminal::ClearType::FromCursorDown), + )?; + let tasks_started = app.tasks_by_status.tasks_started(); app.persist_tasks(tasks_started)?; app.persist_summary()?; From 9115b344fcfcd680b0a47589d628515fed001b6d Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Mon, 15 Jun 2026 14:21:47 -0600 Subject: [PATCH 2/7] fix: Avoid PTY for Windows stream tasks --- crates/turborepo-lib/src/run/builder.rs | 9 ++++----- crates/turborepo-ui/src/tui/app.rs | 6 ------ 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/crates/turborepo-lib/src/run/builder.rs b/crates/turborepo-lib/src/run/builder.rs index 197c7a8176a8f..8b23f88104d2e 100644 --- a/crates/turborepo-lib/src/run/builder.rs +++ b/crates/turborepo-lib/src/run/builder.rs @@ -34,7 +34,7 @@ use turborepo_telemetry::events::{ repo::{RepoEventBuilder, RepoType}, EventBuilder, TrackedErrors, }; -use turborepo_types::FilterMode; +use turborepo_types::{FilterMode, UIMode}; use turborepo_ui::ColorConfig; use turborepo_vercel_api::CachingStatusResponse; use url::Url; @@ -84,14 +84,13 @@ impl RunBuilder { #[tracing::instrument(skip_all)] pub fn new(base: CommandBase, http_client: Option) -> Result { let http_client = http_client.unwrap_or_default(); + let opts = base.opts(); let api_auth = base.api_auth()?; let version = base.version(); let processes = ProcessManager::new( - // A terminal-backed PTY lets Turbo own interactive task input. On - // Windows this is also how we deliver a targeted Ctrl+C to tasks - // instead of relying on console-wide Ctrl+C broadcasts. - std::io::stdout().is_terminal(), + std::io::stdout().is_terminal() + && (!cfg!(windows) || matches!(opts.run_opts.ui_mode, UIMode::Tui)), ); let CommandBase { diff --git a/crates/turborepo-ui/src/tui/app.rs b/crates/turborepo-ui/src/tui/app.rs index 9934c5dc7f48b..303706c2c75c6 100644 --- a/crates/turborepo-ui/src/tui/app.rs +++ b/crates/turborepo-ui/src/tui/app.rs @@ -1091,12 +1091,6 @@ fn cleanup + io::Write>( super::panic_handler::set_mouse_capture_disabled(); } - // Clear stale main-screen content before replaying task logs and summary. - crossterm::execute!( - terminal.backend_mut(), - crossterm::terminal::Clear(crossterm::terminal::ClearType::FromCursorDown), - )?; - let tasks_started = app.tasks_by_status.tasks_started(); app.persist_tasks(tasks_started)?; app.persist_summary()?; From cf30e0cc5684a86fa8c4fb48eb08765dcad2767a Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 16 Jun 2026 08:43:01 -0600 Subject: [PATCH 3/7] fix: Handle Windows package manager shutdown --- Cargo.lock | 1 + crates/turborepo-lib/src/run/builder.rs | 7 +- crates/turborepo-process/Cargo.toml | 2 + crates/turborepo-process/src/child/handle.rs | 91 +++++++++++++++-- crates/turborepo-process/src/child/io.rs | 76 -------------- crates/turborepo-process/src/child/test.rs | 4 +- crates/turborepo-process/src/command.rs | 10 +- crates/turborepo-process/src/lib.rs | 1 + crates/turborepo-signals/src/listeners.rs | 48 ++++++++- crates/turborepo/Cargo.toml | 6 ++ crates/turborepo/src/main.rs | 75 ++++++++++++++ packages/turbo/bin/turbo | 102 ++++++++++++++++++- 12 files changed, 332 insertions(+), 91 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 18711f4288e2e..c3f8fb093915c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7241,6 +7241,7 @@ dependencies = [ "turborepo-signals", "turborepo-ui", "which", + "windows-sys 0.59.0", ] [[package]] diff --git a/crates/turborepo-lib/src/run/builder.rs b/crates/turborepo-lib/src/run/builder.rs index 8b23f88104d2e..2ade9e7f369d1 100644 --- a/crates/turborepo-lib/src/run/builder.rs +++ b/crates/turborepo-lib/src/run/builder.rs @@ -89,8 +89,11 @@ impl RunBuilder { let version = base.version(); let processes = ProcessManager::new( - std::io::stdout().is_terminal() - && (!cfg!(windows) || matches!(opts.run_opts.ui_mode, UIMode::Tui)), + // We currently only use a pty if the following are met: + // - we're attached to a tty + std::io::stdout().is_terminal() && + // - if we're on windows, we're using the UI + (!cfg!(windows) || matches!(opts.run_opts.ui_mode, UIMode::Tui)), ); let CommandBase { diff --git a/crates/turborepo-process/Cargo.toml b/crates/turborepo-process/Cargo.toml index a09f3f18d3fa4..b23865648ce3f 100644 --- a/crates/turborepo-process/Cargo.toml +++ b/crates/turborepo-process/Cargo.toml @@ -30,4 +30,6 @@ windows-sys = { version = "0.59", features = [ "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Threading", + "Win32_UI", + "Win32_UI_WindowsAndMessaging", ] } diff --git a/crates/turborepo-process/src/child/handle.rs b/crates/turborepo-process/src/child/handle.rs index 17201492e52d5..342b54695dafa 100644 --- a/crates/turborepo-process/src/child/handle.rs +++ b/crates/turborepo-process/src/child/handle.rs @@ -145,6 +145,39 @@ pub(super) fn signal_process_group(process_group_id: libc::pid_t, signal: libc:: let _ = unsafe { libc::kill(-process_group_id, signal) }; } +#[cfg(windows)] +fn run_child_console_helper(pid: u32, command: &str) -> bool { + let debug_ctrl_c = std::env::var("TURBO_DEBUG_WINDOWS_CTRL_C").as_deref() == Ok("1"); + if debug_ctrl_c { + eprintln!("[turbo process ctrl-c] spawning helper `{command}` for child console {pid}"); + } + + let Ok(exe) = std::env::current_exe() else { + return false; + }; + + let success = std::process::Command::new(exe) + .arg("__internal_windows_ctrl_c") + .arg(command) + .arg(pid.to_string()) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + + if debug_ctrl_c { + eprintln!("[turbo process ctrl-c] helper returned {success}"); + } + + success +} + +#[cfg(windows)] +fn send_ctrl_c_to_child_console(pid: u32) -> bool { + run_child_console_helper(pid, "ctrl_c") +} + #[cfg(unix)] fn capture_target_identity(pid: Option) -> Option { pid.and_then(|pid| match target_identity(pid as libc::pid_t) { @@ -162,11 +195,13 @@ impl ChildHandle { #[cfg(windows)] let command_for_fallback = command.clone(); - let mut command = TokioCommand::from(command); + let mut command = std::process::Command::from(command); // Create a new process group so we can send signals (e.g. SIGINT) to // the child and all of its descendants via kill(-pgid, sig). #[cfg(unix)] + use std::os::unix::process::CommandExt as _; + #[cfg(unix)] command.process_group(0); #[cfg(windows)] @@ -178,14 +213,35 @@ impl ChildHandle { } }; + #[cfg(windows)] + let wrapper_ctrl_c = std::env::var_os("TURBO_WINDOWS_CTRL_C_PORT").is_some(); + + #[cfg(windows)] + use std::os::windows::process::CommandExt as _; + + #[cfg(windows)] + if wrapper_ctrl_c { + command.show_window(windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE as u16); + } + #[cfg(windows)] if job.is_some() { + let mut creation_flags = windows_sys::Win32::System::Threading::CREATE_SUSPENDED + | windows_sys::Win32::System::Threading::CREATE_BREAKAWAY_FROM_JOB; + if wrapper_ctrl_c { + creation_flags |= windows_sys::Win32::System::Threading::CREATE_NEW_CONSOLE + | windows_sys::Win32::System::Threading::CREATE_NO_WINDOW; + } + command.creation_flags(creation_flags); + } else if wrapper_ctrl_c { command.creation_flags( - windows_sys::Win32::System::Threading::CREATE_SUSPENDED - | windows_sys::Win32::System::Threading::CREATE_BREAKAWAY_FROM_JOB, + windows_sys::Win32::System::Threading::CREATE_NEW_CONSOLE + | windows_sys::Win32::System::Threading::CREATE_NO_WINDOW, ); } + let mut command = TokioCommand::from(command); + #[cfg(not(windows))] let mut child = command.spawn()?; @@ -195,8 +251,17 @@ impl ChildHandle { Err(err) if job.is_some() => { debug!("failed to spawn child with job breakaway: {err}"); let mut fallback_command = TokioCommand::from(command_for_fallback); - fallback_command - .creation_flags(windows_sys::Win32::System::Threading::CREATE_SUSPENDED); + let mut creation_flags = windows_sys::Win32::System::Threading::CREATE_SUSPENDED; + if wrapper_ctrl_c { + creation_flags |= windows_sys::Win32::System::Threading::CREATE_NEW_CONSOLE + | windows_sys::Win32::System::Threading::CREATE_NO_WINDOW; + } + if wrapper_ctrl_c { + fallback_command + .as_std_mut() + .show_window(windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE as u16); + } + fallback_command.creation_flags(creation_flags); fallback_command.spawn()? } Err(err) => return Err(err), @@ -527,11 +592,23 @@ impl ChildHandle { /// when a user types Ctrl-C in a real console. Returns whether the /// keystroke was written. /// - /// Children not attached to a ConPTY share turbo's console and receive - /// console Ctrl-C events directly, so there is nothing to send here. + /// When the npm package wrapper captures Ctrl-C in raw mode, Windows does + /// not generate the console event. In that case, synthesize it here so + /// non-ConPTY children still receive the same event as direct `turbo` use. #[cfg(windows)] pub(super) fn send_graceful_interrupt(&self) -> bool { let Some(pty_input) = &self.pty_input else { + if std::env::var_os("TURBO_WINDOWS_CTRL_C_PORT").is_some() + && let Some(pid) = self.pid + { + let sent = send_ctrl_c_to_child_console(pid); + if sent { + debug!("generated console Ctrl-C for child console {pid}"); + } else { + debug!("failed to generate console Ctrl-C for child console {pid}"); + } + return sent; + } return false; }; diff --git a/crates/turborepo-process/src/child/io.rs b/crates/turborepo-process/src/child/io.rs index dc439dd25d2d8..913f8cdfb37e4 100644 --- a/crates/turborepo-process/src/child/io.rs +++ b/crates/turborepo-process/src/child/io.rs @@ -12,8 +12,6 @@ use tracing::{debug, trace}; use super::{Child, ChildExit}; const POST_EXIT_OUTPUT_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(100); -#[cfg(any(windows, test))] -const CONPTY_CURSOR_POSITION_REQUEST: &[u8] = b"\x1b[6n"; pub(super) struct ChildIO { pub(super) stdin: Option, @@ -153,20 +151,9 @@ impl Child { tokio::task::spawn_blocking(move || { let mut buffer = [0; 1024]; let mut last_byte = None; - #[cfg(windows)] - let mut conpty_cursor_request_match = 0; loop { match stdout_lines.read(&mut buffer) { Ok(0) => { - #[cfg(windows)] - if conpty_cursor_request_match > 0 { - byte_tx - .blocking_send( - CONPTY_CURSOR_POSITION_REQUEST[..conpty_cursor_request_match] - .to_vec(), - ) - .ok(); - } if !matches!(last_byte, Some(b'\n')) { // Ignore if this fails as we already are shutting down byte_tx.blocking_send(vec![b'\n']).ok(); @@ -176,16 +163,6 @@ impl Child { Ok(n) => { let mut bytes = Vec::with_capacity(n); bytes.extend_from_slice(&buffer[..n]); - #[cfg(windows)] - { - bytes = strip_conpty_cursor_position_requests( - &bytes, - &mut conpty_cursor_request_match, - ); - } - if bytes.is_empty() { - continue; - } last_byte = bytes.last().copied(); if byte_tx.blocking_send(bytes).is_err() { // A dropped receiver indicates that there was an issue writing to the @@ -336,56 +313,3 @@ fn add_trailing_newline(buffer: &mut Vec) { buffer.push(b'\n'); } } - -#[cfg(any(windows, test))] -fn strip_conpty_cursor_position_requests(bytes: &[u8], matched: &mut usize) -> Vec { - let mut output = Vec::with_capacity(bytes.len()); - - for byte in bytes { - if *byte == CONPTY_CURSOR_POSITION_REQUEST[*matched] { - *matched += 1; - if *matched == CONPTY_CURSOR_POSITION_REQUEST.len() { - *matched = 0; - } - continue; - } - - if *matched > 0 { - output.extend_from_slice(&CONPTY_CURSOR_POSITION_REQUEST[..*matched]); - *matched = 0; - } - - if *byte == CONPTY_CURSOR_POSITION_REQUEST[0] { - *matched = 1; - } else { - output.push(*byte); - } - } - - output -} - -#[cfg(test)] -mod tests { - use super::strip_conpty_cursor_position_requests; - - #[test] - fn strips_complete_conpty_cursor_position_request() { - let mut matched = 0; - let output = strip_conpty_cursor_position_requests(b"before\x1b[6nafter", &mut matched); - - assert_eq!(output, b"beforeafter"); - assert_eq!(matched, 0); - } - - #[test] - fn strips_conpty_cursor_position_request_across_chunks() { - let mut matched = 0; - let first = strip_conpty_cursor_position_requests(b"before\x1b[", &mut matched); - let second = strip_conpty_cursor_position_requests(b"6nafter", &mut matched); - - assert_eq!(first, b"before"); - assert_eq!(second, b"after"); - assert_eq!(matched, 0); - } -} diff --git a/crates/turborepo-process/src/child/test.rs b/crates/turborepo-process/src/child/test.rs index a83050fad3cc6..22e09ae2a4b05 100644 --- a/crates/turborepo-process/src/child/test.rs +++ b/crates/turborepo-process/src/child/test.rs @@ -345,7 +345,7 @@ async fn test_graceful_shutdown(use_pty: bool) { child.stop().await; let exit = child.wait().await; - if cfg!(windows) && !use_pty { + if cfg!(windows) { assert_matches!(exit, Some(ChildExit::Killed)); } else { assert_matches!(exit, Some(ChildExit::Interrupted)); @@ -387,7 +387,7 @@ async fn test_graceful_shutdown_drains_final_output(use_pty: bool) { assert!(output.contains("ready"), "missing startup output: {output}"); - if cfg!(windows) && !use_pty { + if cfg!(windows) { assert_matches!(exit, Some(ChildExit::Killed)); } else { assert!( diff --git a/crates/turborepo-process/src/command.rs b/crates/turborepo-process/src/command.rs index a8814f8229871..deb719dbfb5ec 100644 --- a/crates/turborepo-process/src/command.rs +++ b/crates/turborepo-process/src/command.rs @@ -108,7 +108,7 @@ impl Command { } } -impl From for tokio::process::Command { +impl From for std::process::Command { fn from(value: Command) -> Self { let Command { program, @@ -119,7 +119,7 @@ impl From for tokio::process::Command { env_clear, } = value; - let mut cmd = tokio::process::Command::new(program); + let mut cmd = std::process::Command::new(program); if env_clear { cmd.env_clear(); } @@ -141,6 +141,12 @@ impl From for tokio::process::Command { } } +impl From for tokio::process::Command { + fn from(value: Command) -> Self { + tokio::process::Command::from(std::process::Command::from(value)) + } +} + impl From for portable_pty::CommandBuilder { fn from(value: Command) -> Self { let Command { diff --git a/crates/turborepo-process/src/lib.rs b/crates/turborepo-process/src/lib.rs index 719d7ca8aad9a..27528f2054ae4 100644 --- a/crates/turborepo-process/src/lib.rs +++ b/crates/turborepo-process/src/lib.rs @@ -9,6 +9,7 @@ //! As of now, the manager will execute futures in a random order, and //! must be either `wait`ed on or `stop`ped to drive state. +#![cfg_attr(windows, feature(windows_process_extensions_show_window))] #![deny(clippy::all)] mod child; diff --git a/crates/turborepo-signals/src/listeners.rs b/crates/turborepo-signals/src/listeners.rs index 9eea7584fc15f..4d3c8e17e159c 100644 --- a/crates/turborepo-signals/src/listeners.rs +++ b/crates/turborepo-signals/src/listeners.rs @@ -9,9 +9,55 @@ pub struct Error(#[from] std::io::Error); #[cfg(windows)] /// A listener for Windows Console Ctrl-C events pub fn get_signal() -> Result>, Error> { + use tokio::io::AsyncReadExt; + + let debug_ctrl_c = std::env::var("TURBO_DEBUG_WINDOWS_CTRL_C").as_deref() == Ok("1"); let mut ctrl_c = tokio::signal::windows::ctrl_c()?; Ok(stream::once(async move { - ctrl_c.recv().await.map(|_| Signal::CtrlC) + let wrapper_ctrl_c_port = std::env::var("TURBO_WINDOWS_CTRL_C_PORT") + .ok() + .and_then(|port| port.parse::().ok()); + + if let Some(port) = wrapper_ctrl_c_port { + if debug_ctrl_c { + eprintln!("[turbo rust ctrl-c] connecting to wrapper on port {port}"); + } + let wrapper_ctrl_c = async move { + loop { + if let Ok(mut stream) = + tokio::net::TcpStream::connect(("127.0.0.1", port)).await + { + if debug_ctrl_c { + eprintln!("[turbo rust ctrl-c] connected to wrapper"); + } + let mut byte = [0]; + if stream.read_exact(&mut byte).await.is_ok() && byte[0] == 0x03 { + if debug_ctrl_c { + eprintln!("[turbo rust ctrl-c] received wrapper ctrl-c"); + } + return Some(Signal::CtrlC); + } + } + + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }; + + tokio::select! { + signal = ctrl_c.recv() => { + if debug_ctrl_c { + eprintln!("[turbo rust ctrl-c] received console ctrl-c"); + } + signal.map(|_| Signal::CtrlC) + }, + signal = wrapper_ctrl_c => signal, + } + } else { + if debug_ctrl_c { + eprintln!("[turbo rust ctrl-c] waiting for console ctrl-c"); + } + ctrl_c.recv().await.map(|_| Signal::CtrlC) + } })) } diff --git a/crates/turborepo/Cargo.toml b/crates/turborepo/Cargo.toml index 363a2d22f0b65..935cb0f062843 100644 --- a/crates/turborepo/Cargo.toml +++ b/crates/turborepo/Cargo.toml @@ -48,3 +48,9 @@ turborepo-query = { workspace = true } turborepo-query-api = { workspace = true } turborepo-signals = { workspace = true } turborepo-ui = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.59", features = [ + "Win32_Foundation", + "Win32_System_Console", +] } diff --git a/crates/turborepo/src/main.rs b/crates/turborepo/src/main.rs index f6ab81491cfe7..a0cbe8ccd0552 100644 --- a/crates/turborepo/src/main.rs +++ b/crates/turborepo/src/main.rs @@ -7,6 +7,8 @@ use anyhow::Result; use miette::Report; const INTERNAL_LSP_COMMAND: &str = "__internal_lsp"; +#[cfg(windows)] +const INTERNAL_WINDOWS_CTRL_C_COMMAND: &str = "__internal_windows_ctrl_c"; #[cfg(feature = "heap-dhat")] #[global_allocator] @@ -18,6 +20,11 @@ enum InternalLspCommand { Server, } +#[cfg(windows)] +enum InternalWindowsConsoleCommand { + CtrlC(u32), +} + /// Concrete [`turborepo_query_api::QueryServer`] that delegates to /// `turborepo_query`. /// @@ -63,6 +70,14 @@ impl turborepo_query_api::QueryServer for TurboQueryServer { // This function should not expanded. Please add any logic to // `turborepo_lib::main` instead fn main() -> Result<()> { + #[cfg(windows)] + if let Some(command) = internal_windows_ctrl_c_command(std::env::args_os()) { + let exit_code = match command { + InternalWindowsConsoleCommand::CtrlC(pid) => send_windows_ctrl_c(pid), + }; + process::exit(exit_code); + } + if let Some(command) = internal_lsp_command(std::env::args_os()) { if command == InternalLspCommand::Probe { println!("turbo-lsp"); @@ -85,6 +100,66 @@ fn main() -> Result<()> { process::exit(exit_code) } +#[cfg(windows)] +fn attach_to_windows_console(pid: u32) -> bool { + use windows_sys::Win32::System::Console::{AttachConsole, FreeConsole}; + + unsafe { FreeConsole() }; + (unsafe { AttachConsole(pid) }) != 0 +} + +#[cfg(windows)] +fn send_windows_ctrl_c(pid: u32) -> i32 { + use windows_sys::Win32::{ + Foundation::TRUE, + System::Console::{ + CTRL_C_EVENT, FreeConsole, GenerateConsoleCtrlEvent, SetConsoleCtrlHandler, + }, + }; + + if !attach_to_windows_console(pid) { + return 1; + } + + unsafe { + SetConsoleCtrlHandler(None, TRUE); + } + let success = unsafe { GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0) } != 0; + std::thread::sleep(std::time::Duration::from_millis(100)); + unsafe { FreeConsole() }; + + if success { 0 } else { 1 } +} + +#[cfg(windows)] +fn internal_windows_ctrl_c_command( + args: impl IntoIterator, +) -> Option +where + T: AsRef, +{ + let mut args = args.into_iter().skip(1); + let first_arg = args.next()?; + let command_arg = if first_arg.as_ref() == OsStr::new("--skip-infer") { + args.next()? + } else { + first_arg + }; + + if command_arg.as_ref() != OsStr::new(INTERNAL_WINDOWS_CTRL_C_COMMAND) { + return None; + } + + let subcommand_or_pid = args.next()?; + if subcommand_or_pid.as_ref() == OsStr::new("ctrl_c") { + let pid = args.next()?.as_ref().to_str()?.parse().ok()?; + Some(InternalWindowsConsoleCommand::CtrlC(pid)) + } else { + let pid = subcommand_or_pid.as_ref().to_str()?.parse().ok()?; + Some(InternalWindowsConsoleCommand::CtrlC(pid)) + } +} + fn internal_lsp_command(args: impl IntoIterator) -> Option where T: AsRef, diff --git a/packages/turbo/bin/turbo b/packages/turbo/bin/turbo index 903742b8c6ff6..a6a71b02998a4 100755 --- a/packages/turbo/bin/turbo +++ b/packages/turbo/bin/turbo @@ -7,6 +7,7 @@ const child_process = require('child_process'); const fs = require('fs'); +const net = require('net'); const path = require('path'); // If we do not find the correct platform binary, should we attempt to install it? @@ -310,16 +311,112 @@ function exitWithCodeOrSignal(code, signal) { process.exit(code === null ? 1 : code); } +function debugWindowsCtrlC(message) { + if (process.env.TURBO_DEBUG_WINDOWS_CTRL_C === "1") { + console.error(`[turbo wrapper ctrl-c] ${message}`); + } +} + +function shouldOwnWindowsCtrlC() { + if (process.platform !== "win32" || !process.stdin.isTTY) { + debugWindowsCtrlC("not owning ctrl-c: not win32 or stdin is not a TTY"); + return false; + } + + const shouldOwn = Boolean( + process.env.npm_command || + process.env.npm_lifecycle_event || + process.env.npm_config_user_agent + ); + debugWindowsCtrlC(shouldOwn ? "owning ctrl-c for package-manager launch" : "not owning ctrl-c: no package-manager env"); + return shouldOwn; +} + +function createWindowsCtrlCServer(callback) { + const clients = new Set(); + const server = net.createServer((socket) => { + debugWindowsCtrlC("rust listener connected"); + clients.add(socket); + socket.on("error", () => {}); + socket.once("close", () => { + clients.delete(socket); + debugWindowsCtrlC("rust listener disconnected"); + }); + }); + + server.listen(0, "127.0.0.1", () => { + debugWindowsCtrlC(`listening on ${server.address().port}`); + callback({ + port: server.address().port, + sendCtrlC() { + debugWindowsCtrlC(`forwarding ctrl-c to ${clients.size} listener(s)`); + for (const client of clients) { + client.write(Buffer.from([0x03])); + } + }, + close() { + for (const client of clients) { + client.destroy(); + } + server.close(); + }, + }); + }); +} + function runTurbo() { + if (shouldOwnWindowsCtrlC()) { + return createWindowsCtrlCServer((ctrlCServer) => { + runTurboChild({ + ...process.env, + TURBO_WINDOWS_CTRL_C_PORT: String(ctrlCServer.port), + }, ctrlCServer); + }); + } + + runTurboChild(process.env, null); +} + +function runTurboChild(env, ctrlCServer) { const child = child_process.spawn( getBinaryPath(), process.argv.slice(2), - { stdio: "inherit" } + { stdio: "inherit", env } ); + const restoreStdin = () => { + if (!ctrlCServer) { + return; + } + + if (process.stdin.isRaw) { + process.stdin.setRawMode(false); + debugWindowsCtrlC("disabled stdin raw mode"); + } + process.stdin.pause(); + }; + + const handleInput = (chunk) => { + for (const byte of chunk) { + if (byte === 0x03) { + debugWindowsCtrlC("read ctrl-c from stdin"); + ctrlCServer.sendCtrlC(); + } + } + }; + + if (ctrlCServer) { + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.on("data", handleInput); + debugWindowsCtrlC("enabled stdin raw mode"); + } + const handleSigint = () => { // The child shares our process group, so Ctrl+C already reached it. // Keep the wrapper alive until the child finishes its own shutdown. + debugWindowsCtrlC("received SIGINT"); + ctrlCServer?.sendCtrlC(); }; const handleSigterm = () => { @@ -331,6 +428,9 @@ function runTurbo() { const cleanup = () => { process.off("SIGINT", handleSigint); process.off("SIGTERM", handleSigterm); + process.stdin.off("data", handleInput); + restoreStdin(); + ctrlCServer?.close(); }; process.on("SIGINT", handleSigint); From 5951dc5bdb7660251adddf47fad5580b98d159b7 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 16 Jun 2026 08:56:02 -0600 Subject: [PATCH 4/7] fix: Remove Windows shutdown debug logs --- crates/turborepo-process/src/child/handle.rs | 19 ++++----------- crates/turborepo-signals/src/listeners.rs | 22 ++--------------- packages/turbo/bin/turbo | 25 +++----------------- 3 files changed, 9 insertions(+), 57 deletions(-) diff --git a/crates/turborepo-process/src/child/handle.rs b/crates/turborepo-process/src/child/handle.rs index 342b54695dafa..754dd91d3fc08 100644 --- a/crates/turborepo-process/src/child/handle.rs +++ b/crates/turborepo-process/src/child/handle.rs @@ -147,16 +147,11 @@ pub(super) fn signal_process_group(process_group_id: libc::pid_t, signal: libc:: #[cfg(windows)] fn run_child_console_helper(pid: u32, command: &str) -> bool { - let debug_ctrl_c = std::env::var("TURBO_DEBUG_WINDOWS_CTRL_C").as_deref() == Ok("1"); - if debug_ctrl_c { - eprintln!("[turbo process ctrl-c] spawning helper `{command}` for child console {pid}"); - } - let Ok(exe) = std::env::current_exe() else { return false; }; - let success = std::process::Command::new(exe) + std::process::Command::new(exe) .arg("__internal_windows_ctrl_c") .arg(command) .arg(pid.to_string()) @@ -164,13 +159,7 @@ fn run_child_console_helper(pid: u32, command: &str) -> bool { .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() - .is_ok_and(|status| status.success()); - - if debug_ctrl_c { - eprintln!("[turbo process ctrl-c] helper returned {success}"); - } - - success + .is_ok_and(|status| status.success()) } #[cfg(windows)] @@ -214,7 +203,7 @@ impl ChildHandle { }; #[cfg(windows)] - let wrapper_ctrl_c = std::env::var_os("TURBO_WINDOWS_CTRL_C_PORT").is_some(); + let wrapper_ctrl_c = std::env::var_os("__TURBO_WINDOWS_CTRL_C_PORT").is_some(); #[cfg(windows)] use std::os::windows::process::CommandExt as _; @@ -598,7 +587,7 @@ impl ChildHandle { #[cfg(windows)] pub(super) fn send_graceful_interrupt(&self) -> bool { let Some(pty_input) = &self.pty_input else { - if std::env::var_os("TURBO_WINDOWS_CTRL_C_PORT").is_some() + if std::env::var_os("__TURBO_WINDOWS_CTRL_C_PORT").is_some() && let Some(pid) = self.pid { let sent = send_ctrl_c_to_child_console(pid); diff --git a/crates/turborepo-signals/src/listeners.rs b/crates/turborepo-signals/src/listeners.rs index 4d3c8e17e159c..b6f0856961847 100644 --- a/crates/turborepo-signals/src/listeners.rs +++ b/crates/turborepo-signals/src/listeners.rs @@ -11,30 +11,20 @@ pub struct Error(#[from] std::io::Error); pub fn get_signal() -> Result>, Error> { use tokio::io::AsyncReadExt; - let debug_ctrl_c = std::env::var("TURBO_DEBUG_WINDOWS_CTRL_C").as_deref() == Ok("1"); let mut ctrl_c = tokio::signal::windows::ctrl_c()?; Ok(stream::once(async move { - let wrapper_ctrl_c_port = std::env::var("TURBO_WINDOWS_CTRL_C_PORT") + let wrapper_ctrl_c_port = std::env::var("__TURBO_WINDOWS_CTRL_C_PORT") .ok() .and_then(|port| port.parse::().ok()); if let Some(port) = wrapper_ctrl_c_port { - if debug_ctrl_c { - eprintln!("[turbo rust ctrl-c] connecting to wrapper on port {port}"); - } let wrapper_ctrl_c = async move { loop { if let Ok(mut stream) = tokio::net::TcpStream::connect(("127.0.0.1", port)).await { - if debug_ctrl_c { - eprintln!("[turbo rust ctrl-c] connected to wrapper"); - } let mut byte = [0]; if stream.read_exact(&mut byte).await.is_ok() && byte[0] == 0x03 { - if debug_ctrl_c { - eprintln!("[turbo rust ctrl-c] received wrapper ctrl-c"); - } return Some(Signal::CtrlC); } } @@ -44,18 +34,10 @@ pub fn get_signal() -> Result>, Error> { }; tokio::select! { - signal = ctrl_c.recv() => { - if debug_ctrl_c { - eprintln!("[turbo rust ctrl-c] received console ctrl-c"); - } - signal.map(|_| Signal::CtrlC) - }, + signal = ctrl_c.recv() => signal.map(|_| Signal::CtrlC), signal = wrapper_ctrl_c => signal, } } else { - if debug_ctrl_c { - eprintln!("[turbo rust ctrl-c] waiting for console ctrl-c"); - } ctrl_c.recv().await.map(|_| Signal::CtrlC) } })) diff --git a/packages/turbo/bin/turbo b/packages/turbo/bin/turbo index a6a71b02998a4..51dff3363ab1a 100755 --- a/packages/turbo/bin/turbo +++ b/packages/turbo/bin/turbo @@ -311,45 +311,30 @@ function exitWithCodeOrSignal(code, signal) { process.exit(code === null ? 1 : code); } -function debugWindowsCtrlC(message) { - if (process.env.TURBO_DEBUG_WINDOWS_CTRL_C === "1") { - console.error(`[turbo wrapper ctrl-c] ${message}`); - } -} - function shouldOwnWindowsCtrlC() { if (process.platform !== "win32" || !process.stdin.isTTY) { - debugWindowsCtrlC("not owning ctrl-c: not win32 or stdin is not a TTY"); return false; } - const shouldOwn = Boolean( + return Boolean( process.env.npm_command || process.env.npm_lifecycle_event || process.env.npm_config_user_agent ); - debugWindowsCtrlC(shouldOwn ? "owning ctrl-c for package-manager launch" : "not owning ctrl-c: no package-manager env"); - return shouldOwn; } function createWindowsCtrlCServer(callback) { const clients = new Set(); const server = net.createServer((socket) => { - debugWindowsCtrlC("rust listener connected"); clients.add(socket); socket.on("error", () => {}); - socket.once("close", () => { - clients.delete(socket); - debugWindowsCtrlC("rust listener disconnected"); - }); + socket.once("close", () => clients.delete(socket)); }); server.listen(0, "127.0.0.1", () => { - debugWindowsCtrlC(`listening on ${server.address().port}`); callback({ port: server.address().port, sendCtrlC() { - debugWindowsCtrlC(`forwarding ctrl-c to ${clients.size} listener(s)`); for (const client of clients) { client.write(Buffer.from([0x03])); } @@ -369,7 +354,7 @@ function runTurbo() { return createWindowsCtrlCServer((ctrlCServer) => { runTurboChild({ ...process.env, - TURBO_WINDOWS_CTRL_C_PORT: String(ctrlCServer.port), + __TURBO_WINDOWS_CTRL_C_PORT: String(ctrlCServer.port), }, ctrlCServer); }); } @@ -391,7 +376,6 @@ function runTurboChild(env, ctrlCServer) { if (process.stdin.isRaw) { process.stdin.setRawMode(false); - debugWindowsCtrlC("disabled stdin raw mode"); } process.stdin.pause(); }; @@ -399,7 +383,6 @@ function runTurboChild(env, ctrlCServer) { const handleInput = (chunk) => { for (const byte of chunk) { if (byte === 0x03) { - debugWindowsCtrlC("read ctrl-c from stdin"); ctrlCServer.sendCtrlC(); } } @@ -409,13 +392,11 @@ function runTurboChild(env, ctrlCServer) { process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.on("data", handleInput); - debugWindowsCtrlC("enabled stdin raw mode"); } const handleSigint = () => { // The child shares our process group, so Ctrl+C already reached it. // Keep the wrapper alive until the child finishes its own shutdown. - debugWindowsCtrlC("received SIGINT"); ctrlCServer?.sendCtrlC(); }; From 48a3c7509687f20dcec56872b122a6c26545d941 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 16 Jun 2026 10:37:20 -0600 Subject: [PATCH 5/7] fix: Handle Windows wrapper interrupts --- Cargo.lock | 1 + crates/turborepo-lib/src/run/mod.rs | 3 + crates/turborepo-process/src/child/handle.rs | 4 +- crates/turborepo-signals/Cargo.toml | 1 + crates/turborepo-signals/src/lib.rs | 28 +++++ crates/turborepo-signals/src/listeners.rs | 81 ++++++++++----- crates/turborepo-ui/src/tui/app.rs | 19 +++- crates/turborepo-ui/src/tui/event.rs | 1 + crates/turborepo-ui/src/tui/input.rs | 25 +---- packages/turbo/bin/turbo | 102 +++++++++++-------- 10 files changed, 171 insertions(+), 94 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c3f8fb093915c..220403b45f5aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8290,6 +8290,7 @@ name = "turborepo-signals" version = "0.1.0" dependencies = [ "futures", + "libc", "thiserror 2.0.18", "tokio", ] diff --git a/crates/turborepo-lib/src/run/mod.rs b/crates/turborepo-lib/src/run/mod.rs index 30a8e599398c3..80fec8ca8f117 100644 --- a/crates/turborepo-lib/src/run/mod.rs +++ b/crates/turborepo-lib/src/run/mod.rs @@ -542,6 +542,8 @@ impl Run { let color_config = self.color_config; let scrollback_len = self.opts.tui_opts.scrollback_length; let repo_root = self.repo_root.clone(); + let signal_handler = self.signal_handler.clone(); + let interrupt = Arc::new(move || signal_handler.notify_signal()); let handle = tokio::task::spawn(async move { Ok(tui::run_app( task_names, @@ -549,6 +551,7 @@ impl Run { color_config, &repo_root, scrollback_len, + Some(interrupt), ) .await?) }); diff --git a/crates/turborepo-process/src/child/handle.rs b/crates/turborepo-process/src/child/handle.rs index 754dd91d3fc08..467075e257672 100644 --- a/crates/turborepo-process/src/child/handle.rs +++ b/crates/turborepo-process/src/child/handle.rs @@ -203,7 +203,7 @@ impl ChildHandle { }; #[cfg(windows)] - let wrapper_ctrl_c = std::env::var_os("__TURBO_WINDOWS_CTRL_C_PORT").is_some(); + let wrapper_ctrl_c = std::env::var_os("__TURBO_WINDOWS_CTRL_C_FD").is_some(); #[cfg(windows)] use std::os::windows::process::CommandExt as _; @@ -587,7 +587,7 @@ impl ChildHandle { #[cfg(windows)] pub(super) fn send_graceful_interrupt(&self) -> bool { let Some(pty_input) = &self.pty_input else { - if std::env::var_os("__TURBO_WINDOWS_CTRL_C_PORT").is_some() + if std::env::var_os("__TURBO_WINDOWS_CTRL_C_FD").is_some() && let Some(pid) = self.pid { let sent = send_ctrl_c_to_child_console(pid); diff --git a/crates/turborepo-signals/Cargo.toml b/crates/turborepo-signals/Cargo.toml index a7ca72167e134..ebfdcf763461d 100644 --- a/crates/turborepo-signals/Cargo.toml +++ b/crates/turborepo-signals/Cargo.toml @@ -6,6 +6,7 @@ license = "MIT" [dependencies] futures = { workspace = true } +libc = "0.2" thiserror = { workspace = true } tokio = { workspace = true, features = ["full", "time"] } diff --git a/crates/turborepo-signals/src/lib.rs b/crates/turborepo-signals/src/lib.rs index 81fd8db4c64d8..f5d498d505aed 100644 --- a/crates/turborepo-signals/src/lib.rs +++ b/crates/turborepo-signals/src/lib.rs @@ -38,6 +38,7 @@ pub enum ShutdownReason { pub struct SignalHandler { state: Arc>, close: mpsc::Sender<()>, + signal: mpsc::Sender<()>, shutdown_reason: Arc, started: Arc, } @@ -75,6 +76,7 @@ impl SignalHandler { let started = Arc::new(Notify::new()); let worker_started = started.clone(); let (close, mut rx) = mpsc::channel::<()>(1); + let (signal, mut signal_rx) = mpsc::channel::<()>(1); tokio::spawn(async move { pin!(signal_source); let shutdown_reason = tokio::select! { @@ -82,6 +84,7 @@ impl SignalHandler { Some(Some(_signal)) => ShutdownReason::Signal, Some(None) | None => ShutdownReason::Close, }, + _ = signal_rx.recv() => ShutdownReason::Signal, // We don't care if a close message was sent or if all handlers are dropped. // Either way start the shutdown process. _ = rx.recv() => ShutdownReason::Close, @@ -115,6 +118,7 @@ impl SignalHandler { Self { state, close, + signal, shutdown_reason, started, } @@ -141,6 +145,13 @@ impl SignalHandler { self.done().await; } + /// Notify subscribers that shutdown should start because of an in-process + /// signal source, such as the TUI consuming Ctrl-C while raw mode is + /// active. + pub fn notify_signal(&self) { + self.signal.try_send(()).ok(); + } + /// Wait until handler is finished and all subscribers finish their cleanup /// work pub async fn done(&self) { @@ -275,6 +286,23 @@ mod test { handler.done().await; } + #[tokio::test] + async fn test_subscribers_triggered_from_notify_signal() { + let (_tx, rx) = oneshot::channel::<()>(); + let handler = SignalHandler::new(stream::once(async move { + rx.await.ok(); + Some(DEFAULT_SIGNAL) + })); + let subscriber = handler.subscribe().unwrap(); + + handler.notify_signal(); + + let _guard = subscriber.listen().await.unwrap(); + assert_eq!(handler.shutdown_reason(), Some(ShutdownReason::Signal)); + drop(_guard); + handler.done().await; + } + #[tokio::test] async fn test_close_idempotent() { let (_tx, rx) = oneshot::channel::<()>(); diff --git a/crates/turborepo-signals/src/listeners.rs b/crates/turborepo-signals/src/listeners.rs index b6f0856961847..3fd5c472eddda 100644 --- a/crates/turborepo-signals/src/listeners.rs +++ b/crates/turborepo-signals/src/listeners.rs @@ -9,38 +9,67 @@ pub struct Error(#[from] std::io::Error); #[cfg(windows)] /// A listener for Windows Console Ctrl-C events pub fn get_signal() -> Result>, Error> { - use tokio::io::AsyncReadExt; + let wrapper_ctrl_c = wrapper_ctrl_c_fd_from_env(std::env::var_os("__TURBO_WINDOWS_CTRL_C_FD")) + .map(wrapper_ctrl_c_receiver) + .transpose()?; + let ctrl_c = if wrapper_ctrl_c.is_none() { + Some(tokio::signal::windows::ctrl_c()?) + } else { + None + }; - let mut ctrl_c = tokio::signal::windows::ctrl_c()?; Ok(stream::once(async move { - let wrapper_ctrl_c_port = std::env::var("__TURBO_WINDOWS_CTRL_C_PORT") - .ok() - .and_then(|port| port.parse::().ok()); + if let Some(wrapper_ctrl_c) = wrapper_ctrl_c { + wrapper_ctrl_c.await.ok().flatten() + } else if let Some(mut ctrl_c) = ctrl_c { + ctrl_c.recv().await.map(|_| Signal::CtrlC) + } else { + None + } + })) +} - if let Some(port) = wrapper_ctrl_c_port { - let wrapper_ctrl_c = async move { - loop { - if let Ok(mut stream) = - tokio::net::TcpStream::connect(("127.0.0.1", port)).await - { - let mut byte = [0]; - if stream.read_exact(&mut byte).await.is_ok() && byte[0] == 0x03 { - return Some(Signal::CtrlC); - } - } +#[cfg(windows)] +fn wrapper_ctrl_c_fd_from_env(value: Option) -> Option { + value.and_then(|fd| fd.to_str()?.parse::().ok()) +} - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - }; +#[cfg(windows)] +fn wrapper_ctrl_c_receiver( + fd: i32, +) -> Result>, std::io::Error> { + let (tx, rx) = tokio::sync::oneshot::channel(); + std::thread::Builder::new() + .name("turbo-windows-ctrl-c-pipe".to_string()) + .spawn(move || { + tx.send(read_wrapper_ctrl_c(fd)).ok(); + })?; + Ok(rx) +} - tokio::select! { - signal = ctrl_c.recv() => signal.map(|_| Signal::CtrlC), - signal = wrapper_ctrl_c => signal, - } - } else { - ctrl_c.recv().await.map(|_| Signal::CtrlC) +#[cfg(windows)] +fn read_wrapper_ctrl_c(fd: i32) -> Option { + let mut byte = [0]; + loop { + match unsafe { libc::read(fd, byte.as_mut_ptr().cast(), 1) } { + 1 if byte[0] == 0x03 => return Some(Signal::CtrlC), + 1 => {} + _ => return None, } - })) + } +} + +#[cfg(windows)] +#[cfg(test)] +mod tests { + use super::wrapper_ctrl_c_fd_from_env; + + #[test] + fn parses_wrapper_ctrl_c_fd() { + assert_eq!(wrapper_ctrl_c_fd_from_env(Some("3".into())), Some(3)); + assert_eq!(wrapper_ctrl_c_fd_from_env(Some("not-a-fd".into())), None); + assert_eq!(wrapper_ctrl_c_fd_from_env(None), None); + } } #[cfg(not(windows))] diff --git a/crates/turborepo-ui/src/tui/app.rs b/crates/turborepo-ui/src/tui/app.rs index 303706c2c75c6..0d0af742a7a9b 100644 --- a/crates/turborepo-ui/src/tui/app.rs +++ b/crates/turborepo-ui/src/tui/app.rs @@ -2,6 +2,7 @@ use std::{ collections::BTreeMap, io::{self, Stdout, Write}, mem, + sync::Arc, time::Duration, }; @@ -804,6 +805,7 @@ pub async fn run_app( color_config: ColorConfig, repo_root: &AbsoluteSystemPathBuf, scrollback_len: u64, + interrupt: Option>, ) -> Result<(), Error> { // Get terminal size before potentially entering alternate screen let size = crossterm::terminal::size()?; @@ -823,6 +825,7 @@ pub async fn run_app( receiver, crossterm_rx, color_config, + interrupt.as_deref(), ) .await { @@ -873,6 +876,7 @@ async fn run_app_inner( mut receiver: AppReceiver, mut crossterm_rx: mpsc::Receiver, color_config: ColorConfig, + interrupt: Option<&(dyn Fn() + Send + Sync)>, ) -> Result>, Error> { let mut last_render = Instant::now(); let mut resize_debouncer = Debouncer::new(RESIZE_DEBOUNCE_DELAY); @@ -909,10 +913,10 @@ async fn run_app_inner( if let Some(term) = terminal.as_mut() { term.autoresize()?; } - update(app, resize)?; + update(app, resize, interrupt)?; } if let Some(event) = event { - callback = update(app, event)?; + callback = update(app, event, interrupt)?; if callback.is_some() { drain_after_stop(terminal, app, &mut receiver, &mut last_render).await?; break; @@ -948,7 +952,7 @@ async fn drain_after_stop( if !matches!(event, Event::Tick) { needs_rerender = true; } - update(app, event)?; + update(app, event, None)?; if let Some(term) = terminal.as_mut() && FRAMERATE <= last_render.elapsed() @@ -1109,6 +1113,7 @@ fn cleanup + io::Write>( fn update( app: &mut App>, event: Event, + interrupt: Option<&(dyn Fn() + Send + Sync)>, ) -> Result>, Error> { match event { Event::LogEvent(log_event) => { @@ -1131,6 +1136,14 @@ fn update( debug!("shutting down due to internal failure"); app.done = true; } + Event::Interrupt => { + if let Some(interrupt) = interrupt { + interrupt(); + } else { + debug!("unable to notify interrupt handler, shutting down"); + app.done = true; + } + } Event::Stop(callback) => { debug!("shutting down due to message"); return Ok(Some(callback)); diff --git a/crates/turborepo-ui/src/tui/event.rs b/crates/turborepo-ui/src/tui/event.rs index 9d359331c1dd7..0670ab6466a01 100644 --- a/crates/turborepo-ui/src/tui/event.rs +++ b/crates/turborepo-ui/src/tui/event.rs @@ -26,6 +26,7 @@ pub enum Event { Stop(oneshot::Sender<()>), // Stop initiated by the TUI itself InternalStop, + Interrupt, Tick, Up, Down, diff --git a/crates/turborepo-ui/src/tui/input.rs b/crates/turborepo-ui/src/tui/input.rs index bc68522895a99..c7d11b0702631 100644 --- a/crates/turborepo-ui/src/tui/input.rs +++ b/crates/turborepo-ui/src/tui/input.rs @@ -3,7 +3,6 @@ use std::io::IsTerminal; use crossterm::event::{EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use futures::StreamExt; use tokio::{sync::mpsc, task::JoinHandle}; -use tracing::debug; use super::{ app::LayoutSections, @@ -150,7 +149,7 @@ fn ctrl_c() -> Option { Ok(_) => None, // We're unable to send the signal, stop rendering to force shutdown Err(_) => { - debug!("unable to send sigint, shutting down"); + tracing::debug!("unable to send sigint, shutting down"); Some(Event::InternalStop) } } @@ -158,27 +157,7 @@ fn ctrl_c() -> Option { #[cfg(windows)] fn ctrl_c() -> Option { - use windows_sys::Win32::{ - Foundation::{BOOL, TRUE}, - System::Console::GenerateConsoleCtrlEvent, - }; - // First parameter corresponds to what event to generate, 0 is a Ctrl-C - let ctrl_c_event = 0x0; - // Second parameter corresponds to which process group to send the event to. - // If 0 is passed the event gets sent to every process connected to the current - // Console. - let process_group_id = 0x0; - let success: BOOL = unsafe { - // See docs https://learn.microsoft.com/en-us/windows/console/generateconsolectrlevent - GenerateConsoleCtrlEvent(ctrl_c_event, process_group_id) - }; - if success == TRUE { - None - } else { - // We're unable to send the Ctrl-C event, stop rendering to force shutdown - debug!("unable to send sigint, shutting down"); - Some(Event::InternalStop) - } + Some(Event::Interrupt) } // Inspired by mprocs encode_term module diff --git a/packages/turbo/bin/turbo b/packages/turbo/bin/turbo index 51dff3363ab1a..8dcbf3961b7ab 100755 --- a/packages/turbo/bin/turbo +++ b/packages/turbo/bin/turbo @@ -7,11 +7,12 @@ const child_process = require('child_process'); const fs = require('fs'); -const net = require('net'); const path = require('path'); // If we do not find the correct platform binary, should we attempt to install it? const SHOULD_INSTALL = true; +const CTRL_C_BYTE = Buffer.from([0x03]); +const CTRL_C_DEBOUNCE_MS = 200; // If we do not find the correct platform binary, should we trust calling an emulated variant? const SHOULD_ATTEMPT_EMULATED = true; @@ -316,6 +317,10 @@ function shouldOwnWindowsCtrlC() { return false; } + if (usesTui(process.argv.slice(2))) { + return false; + } + return Boolean( process.env.npm_command || process.env.npm_lifecycle_event || @@ -323,54 +328,71 @@ function shouldOwnWindowsCtrlC() { ); } -function createWindowsCtrlCServer(callback) { - const clients = new Set(); - const server = net.createServer((socket) => { - clients.add(socket); - socket.on("error", () => {}); - socket.once("close", () => clients.delete(socket)); - }); - - server.listen(0, "127.0.0.1", () => { - callback({ - port: server.address().port, - sendCtrlC() { - for (const client of clients) { - client.write(Buffer.from([0x03])); - } - }, - close() { - for (const client of clients) { - client.destroy(); - } - server.close(); - }, - }); - }); +function usesTui(args) { + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--ui=tui") { + return true; + } + if (arg === "--ui" && args[i + 1] === "tui") { + return true; + } + } + return false; } function runTurbo() { if (shouldOwnWindowsCtrlC()) { - return createWindowsCtrlCServer((ctrlCServer) => { - runTurboChild({ + return runTurboChild( + { ...process.env, - __TURBO_WINDOWS_CTRL_C_PORT: String(ctrlCServer.port), - }, ctrlCServer); - }); + __TURBO_WINDOWS_CTRL_C_FD: "3", + }, + true + ); } - runTurboChild(process.env, null); + runTurboChild(process.env, false); } -function runTurboChild(env, ctrlCServer) { +function runTurboChild(env, useCtrlCPipe) { const child = child_process.spawn( getBinaryPath(), process.argv.slice(2), - { stdio: "inherit", env } + { + stdio: useCtrlCPipe ? ["inherit", "inherit", "inherit", "pipe"] : "inherit", + env, + } ); + const ctrlCPipe = useCtrlCPipe ? child.stdio[3] : null; + ctrlCPipe?.on("error", () => {}); + let lastCtrlCSentAt = 0; + + const sendCtrlC = () => { + const now = Date.now(); + if (now - lastCtrlCSentAt < CTRL_C_DEBOUNCE_MS) { + return; + } + lastCtrlCSentAt = now; + + if (ctrlCPipe && !ctrlCPipe.destroyed && ctrlCPipe.writable) { + ctrlCPipe.write(CTRL_C_BYTE); + } + }; + + const handleCtrlCPipeData = (chunk) => { + for (const byte of chunk) { + if (byte === 0x03) { + sendCtrlC(); + } + } + }; + + ctrlCPipe?.on("data", handleCtrlCPipeData); + const restoreStdin = () => { - if (!ctrlCServer) { + if (!useCtrlCPipe) { return; } @@ -383,21 +405,20 @@ function runTurboChild(env, ctrlCServer) { const handleInput = (chunk) => { for (const byte of chunk) { if (byte === 0x03) { - ctrlCServer.sendCtrlC(); + sendCtrlC(); } } }; - if (ctrlCServer) { + if (useCtrlCPipe) { process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.on("data", handleInput); } const handleSigint = () => { - // The child shares our process group, so Ctrl+C already reached it. - // Keep the wrapper alive until the child finishes its own shutdown. - ctrlCServer?.sendCtrlC(); + // Keep the wrapper alive and let native turbo handle graceful shutdown. + sendCtrlC(); }; const handleSigterm = () => { @@ -410,8 +431,9 @@ function runTurboChild(env, ctrlCServer) { process.off("SIGINT", handleSigint); process.off("SIGTERM", handleSigterm); process.stdin.off("data", handleInput); + ctrlCPipe?.off("data", handleCtrlCPipeData); restoreStdin(); - ctrlCServer?.close(); + ctrlCPipe?.destroy(); }; process.on("SIGINT", handleSigint); From 64076614ee151395e9685a517e055705ed82efc2 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 16 Jun 2026 10:44:07 -0600 Subject: [PATCH 6/7] refactor: Clean up Windows interrupt handling --- crates/turborepo-signals/src/lib.rs | 10 +++++----- packages/turbo/bin/turbo | 19 ------------------- 2 files changed, 5 insertions(+), 24 deletions(-) diff --git a/crates/turborepo-signals/src/lib.rs b/crates/turborepo-signals/src/lib.rs index f5d498d505aed..5cac7c69a9c55 100644 --- a/crates/turborepo-signals/src/lib.rs +++ b/crates/turborepo-signals/src/lib.rs @@ -38,7 +38,7 @@ pub enum ShutdownReason { pub struct SignalHandler { state: Arc>, close: mpsc::Sender<()>, - signal: mpsc::Sender<()>, + in_process_signal: mpsc::Sender<()>, shutdown_reason: Arc, started: Arc, } @@ -76,7 +76,7 @@ impl SignalHandler { let started = Arc::new(Notify::new()); let worker_started = started.clone(); let (close, mut rx) = mpsc::channel::<()>(1); - let (signal, mut signal_rx) = mpsc::channel::<()>(1); + let (in_process_signal, mut in_process_signal_rx) = mpsc::channel::<()>(1); tokio::spawn(async move { pin!(signal_source); let shutdown_reason = tokio::select! { @@ -84,7 +84,7 @@ impl SignalHandler { Some(Some(_signal)) => ShutdownReason::Signal, Some(None) | None => ShutdownReason::Close, }, - _ = signal_rx.recv() => ShutdownReason::Signal, + _ = in_process_signal_rx.recv() => ShutdownReason::Signal, // We don't care if a close message was sent or if all handlers are dropped. // Either way start the shutdown process. _ = rx.recv() => ShutdownReason::Close, @@ -118,7 +118,7 @@ impl SignalHandler { Self { state, close, - signal, + in_process_signal, shutdown_reason, started, } @@ -149,7 +149,7 @@ impl SignalHandler { /// signal source, such as the TUI consuming Ctrl-C while raw mode is /// active. pub fn notify_signal(&self) { - self.signal.try_send(()).ok(); + self.in_process_signal.try_send(()).ok(); } /// Wait until handler is finished and all subscribers finish their cleanup diff --git a/packages/turbo/bin/turbo b/packages/turbo/bin/turbo index 8dcbf3961b7ab..d548bad0c8e8e 100755 --- a/packages/turbo/bin/turbo +++ b/packages/turbo/bin/turbo @@ -12,7 +12,6 @@ const path = require('path'); // If we do not find the correct platform binary, should we attempt to install it? const SHOULD_INSTALL = true; const CTRL_C_BYTE = Buffer.from([0x03]); -const CTRL_C_DEBOUNCE_MS = 200; // If we do not find the correct platform binary, should we trust calling an emulated variant? const SHOULD_ATTEMPT_EMULATED = true; @@ -367,30 +366,13 @@ function runTurboChild(env, useCtrlCPipe) { const ctrlCPipe = useCtrlCPipe ? child.stdio[3] : null; ctrlCPipe?.on("error", () => {}); - let lastCtrlCSentAt = 0; const sendCtrlC = () => { - const now = Date.now(); - if (now - lastCtrlCSentAt < CTRL_C_DEBOUNCE_MS) { - return; - } - lastCtrlCSentAt = now; - if (ctrlCPipe && !ctrlCPipe.destroyed && ctrlCPipe.writable) { ctrlCPipe.write(CTRL_C_BYTE); } }; - const handleCtrlCPipeData = (chunk) => { - for (const byte of chunk) { - if (byte === 0x03) { - sendCtrlC(); - } - } - }; - - ctrlCPipe?.on("data", handleCtrlCPipeData); - const restoreStdin = () => { if (!useCtrlCPipe) { return; @@ -431,7 +413,6 @@ function runTurboChild(env, useCtrlCPipe) { process.off("SIGINT", handleSigint); process.off("SIGTERM", handleSigterm); process.stdin.off("data", handleInput); - ctrlCPipe?.off("data", handleCtrlCPipeData); restoreStdin(); ctrlCPipe?.destroy(); }; From 0f6756ff84ebb494c07726b3a2def13ad62cf90b Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 16 Jun 2026 10:49:28 -0600 Subject: [PATCH 7/7] fix: Update TUI interrupt tests --- crates/turborepo-ui/src/tui/app.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/turborepo-ui/src/tui/app.rs b/crates/turborepo-ui/src/tui/app.rs index 0d0af742a7a9b..1192eca21e513 100644 --- a/crates/turborepo-ui/src/tui/app.rs +++ b/crates/turborepo-ui/src/tui/app.rs @@ -2667,7 +2667,7 @@ mod test { turborepo_log::Source::turbo(turborepo_log::Subsystem::Scm), "something went wrong", ); - update(&mut app, Event::LogEvent(event))?; + update(&mut app, Event::LogEvent(event), None)?; assert_eq!(app.log_events.len(), 1); assert_eq!(app.log_events[0].message(), "something went wrong"); @@ -2701,7 +2701,7 @@ mod test { sender.end_task("app-a#dev".to_string(), TaskResult::Success); let (callback_tx, _callback_rx) = oneshot::channel(); - update(&mut app, Event::Stop(callback_tx))?; + update(&mut app, Event::Stop(callback_tx), None)?; let mut terminal = None; let mut last_render = Instant::now(); drain_after_stop(&mut terminal, &mut app, &mut receiver, &mut last_render).await?;