From f7a91029b6a4ee2d6b9b9e7dbe1069db17c43436 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Feb 2026 01:42:58 +0000 Subject: [PATCH 1/2] fix(cli): prevent terminal hang after graceful container shutdown The stdin reader in stop_service_with_spinner used tokio::io::stdin() which spawns an internal blocking thread. When the container stopped gracefully (winning the select! race), aborting the tokio task did not terminate the underlying OS thread blocked on read(), preventing the process from exiting cleanly. Replace with a libc::poll-based approach that checks a cancellation AtomicBool every 100ms, allowing the blocking thread to exit promptly when the stop completes. https://claude.ai/code/session_01MwaCtnDsCDV5cmwhX8LGFP --- Cargo.lock | 1 + Cargo.toml | 3 ++ packages/cli-rust/Cargo.toml | 1 + packages/cli-rust/src/commands/service.rs | 66 ++++++++++++++++------- 4 files changed, 51 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4f24ef1..10f2873 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2567,6 +2567,7 @@ dependencies = [ "futures-util", "humantime", "indicatif", + "libc", "opencode-cloud-core", "rand 0.9.2", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index a3230b9..d075b19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,3 +59,6 @@ comfy-table = "7" # Random generation rand = "0.9" sysinfo = "0.38" + +# Unix low-level I/O +libc = "0.2" diff --git a/packages/cli-rust/Cargo.toml b/packages/cli-rust/Cargo.toml index b5448f6..cd6a118 100644 --- a/packages/cli-rust/Cargo.toml +++ b/packages/cli-rust/Cargo.toml @@ -42,6 +42,7 @@ rand.workspace = true sysinfo.workspace = true reqwest.workspace = true dirs = "6" +libc.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/packages/cli-rust/src/commands/service.rs b/packages/cli-rust/src/commands/service.rs index 4b208df..d3e8f65 100644 --- a/packages/cli-rust/src/commands/service.rs +++ b/packages/cli-rust/src/commands/service.rs @@ -7,8 +7,9 @@ use anyhow::Result; use console::style; use opencode_cloud_core::docker::{DockerClient, stop_service}; use std::io::IsTerminal; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Instant; -use tokio::io::AsyncBufReadExt; pub struct StopSpinnerMessages<'a> { pub action_message: &'a str, @@ -63,11 +64,11 @@ pub async fn stop_service_with_spinner( let stop_future = stop_service(client, remove, Some(timeout_secs)); tokio::pin!(stop_future); - let (stdin_handle, stdin_abort) = spawn_enter_listener(); + let (stdin_handle, cancel_flag) = spawn_enter_listener(); let outcome = tokio::select! { result = &mut stop_future => { - stdin_abort.abort(); + cancel_flag.store(true, Ordering::Relaxed); StopOutcome::Graceful(result) } join_result = stdin_handle => { @@ -168,26 +169,51 @@ fn stop_success_message( (message, should_warn) } -/// Spawns an abortable task that waits for the user to press Enter. +/// Spawns a cancellable blocking task that waits for the user to press Enter. /// -/// Returns (JoinHandle, AbortHandle) so the caller can race against other futures -/// and abort the stdin reader when no longer needed. This prevents the program from -/// hanging, as tokio's async stdin spawns an internal blocking thread that persists -/// even after the future is dropped. -fn spawn_enter_listener() -> ( - tokio::task::JoinHandle>, - tokio::task::AbortHandle, -) { - let handle = tokio::spawn(async move { - let mut stdin = tokio::io::BufReader::new(tokio::io::stdin()); - let mut input = String::new(); - stdin.read_line(&mut input).await.map(|n| n > 0) +/// Uses `libc::poll` with a short timeout to periodically check a cancellation +/// flag, avoiding the permanent block that `tokio::io::stdin()` causes when its +/// internal blocking thread persists after task abort. +fn spawn_enter_listener() -> (tokio::task::JoinHandle, Arc) { + let cancelled = Arc::new(AtomicBool::new(false)); + let flag = cancelled.clone(); + + let handle = tokio::task::spawn_blocking(move || { + use std::io::BufRead; + use std::os::unix::io::AsRawFd; + + let stdin = std::io::stdin(); + let fd = stdin.as_raw_fd(); + + loop { + if flag.load(Ordering::Relaxed) { + return false; + } + + let mut pfd = libc::pollfd { + fd, + events: libc::POLLIN, + revents: 0, + }; + + // Poll with 100ms timeout so we can check the cancellation flag + let ret = unsafe { libc::poll(&mut pfd as *mut _, 1, 100) }; + if ret > 0 && (pfd.revents & libc::POLLIN) != 0 { + let mut line = String::new(); + return match stdin.lock().read_line(&mut line) { + Ok(n) => n > 0, + Err(_) => false, + }; + } + // ret == 0: timeout, loop to check flag + // ret < 0: error, loop to retry + } }); - let abort = handle.abort_handle(); - (handle, abort) + + (handle, cancelled) } /// Returns true if the stdin task result indicates the user pressed Enter. -fn user_pressed_enter(join_result: Result, tokio::task::JoinError>) -> bool { - matches!(join_result, Ok(Ok(true))) +fn user_pressed_enter(join_result: Result) -> bool { + matches!(join_result, Ok(true)) } From 576a6e58b379c26d0493080cb4c37850b42c8eca Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Feb 2026 02:21:01 +0000 Subject: [PATCH 2/2] refactor: drop libc dep, use std::thread + oneshot for stdin listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace libc::poll-based stdin reader with a plain std::thread that communicates through a tokio::sync::oneshot channel. A plain thread is not part of tokio's blocking pool, so the runtime can shut down cleanly even if the thread is still blocked on read_line — the OS terminates it when the process exits. This removes the libc dependency while keeping the same fix for the terminal hang after graceful container shutdown. https://claude.ai/code/session_01MwaCtnDsCDV5cmwhX8LGFP --- Cargo.lock | 1 - Cargo.toml | 3 - packages/cli-rust/Cargo.toml | 1 - packages/cli-rust/src/commands/service.rs | 70 ++++++++--------------- 4 files changed, 25 insertions(+), 50 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 10f2873..4f24ef1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2567,7 +2567,6 @@ dependencies = [ "futures-util", "humantime", "indicatif", - "libc", "opencode-cloud-core", "rand 0.9.2", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index d075b19..a3230b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,3 @@ comfy-table = "7" # Random generation rand = "0.9" sysinfo = "0.38" - -# Unix low-level I/O -libc = "0.2" diff --git a/packages/cli-rust/Cargo.toml b/packages/cli-rust/Cargo.toml index cd6a118..b5448f6 100644 --- a/packages/cli-rust/Cargo.toml +++ b/packages/cli-rust/Cargo.toml @@ -42,7 +42,6 @@ rand.workspace = true sysinfo.workspace = true reqwest.workspace = true dirs = "6" -libc.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/packages/cli-rust/src/commands/service.rs b/packages/cli-rust/src/commands/service.rs index d3e8f65..41ef242 100644 --- a/packages/cli-rust/src/commands/service.rs +++ b/packages/cli-rust/src/commands/service.rs @@ -7,8 +7,6 @@ use anyhow::Result; use console::style; use opencode_cloud_core::docker::{DockerClient, stop_service}; use std::io::IsTerminal; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Instant; pub struct StopSpinnerMessages<'a> { @@ -64,14 +62,16 @@ pub async fn stop_service_with_spinner( let stop_future = stop_service(client, remove, Some(timeout_secs)); tokio::pin!(stop_future); - let (stdin_handle, cancel_flag) = spawn_enter_listener(); + let stdin_rx = spawn_enter_listener(); let outcome = tokio::select! { result = &mut stop_future => { - cancel_flag.store(true, Ordering::Relaxed); + // stdin_rx is dropped, unblocking the oneshot. + // The std::thread reading stdin will exit once the process does; + // it is NOT in tokio's blocking pool so it won't block runtime shutdown. StopOutcome::Graceful(result) } - join_result = stdin_handle => { + join_result = stdin_rx => { if user_pressed_enter(join_result) { spinner.update(&crate::format_host_message( host_name, @@ -169,51 +169,31 @@ fn stop_success_message( (message, should_warn) } -/// Spawns a cancellable blocking task that waits for the user to press Enter. +/// Spawns a detached thread that waits for the user to press Enter and signals +/// back through a oneshot channel. /// -/// Uses `libc::poll` with a short timeout to periodically check a cancellation -/// flag, avoiding the permanent block that `tokio::io::stdin()` causes when its -/// internal blocking thread persists after task abort. -fn spawn_enter_listener() -> (tokio::task::JoinHandle, Arc) { - let cancelled = Arc::new(AtomicBool::new(false)); - let flag = cancelled.clone(); - - let handle = tokio::task::spawn_blocking(move || { +/// Uses a plain `std::thread` instead of `tokio::task::spawn_blocking` so the +/// thread is **not** part of tokio's blocking pool. This way the runtime can +/// shut down cleanly even if the thread is still blocked on `read_line` — the +/// OS will terminate the thread when the process exits. +fn spawn_enter_listener() -> tokio::sync::oneshot::Receiver { + let (tx, rx) = tokio::sync::oneshot::channel(); + + std::thread::spawn(move || { use std::io::BufRead; - use std::os::unix::io::AsRawFd; - let stdin = std::io::stdin(); - let fd = stdin.as_raw_fd(); - - loop { - if flag.load(Ordering::Relaxed) { - return false; - } - - let mut pfd = libc::pollfd { - fd, - events: libc::POLLIN, - revents: 0, - }; - - // Poll with 100ms timeout so we can check the cancellation flag - let ret = unsafe { libc::poll(&mut pfd as *mut _, 1, 100) }; - if ret > 0 && (pfd.revents & libc::POLLIN) != 0 { - let mut line = String::new(); - return match stdin.lock().read_line(&mut line) { - Ok(n) => n > 0, - Err(_) => false, - }; - } - // ret == 0: timeout, loop to check flag - // ret < 0: error, loop to retry - } + let mut line = String::new(); + let pressed = match stdin.lock().read_line(&mut line) { + Ok(n) => n > 0, + Err(_) => false, + }; + let _ = tx.send(pressed); }); - (handle, cancelled) + rx } -/// Returns true if the stdin task result indicates the user pressed Enter. -fn user_pressed_enter(join_result: Result) -> bool { - matches!(join_result, Ok(true)) +/// Returns true if the stdin listener indicates the user pressed Enter. +fn user_pressed_enter(result: Result) -> bool { + matches!(result, Ok(true)) }