From 83cb955a893d7bb09779c53d2b591754dfb1b12a Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 9 Jul 2026 12:43:41 +0800 Subject: [PATCH 1/3] fix(pty): close master after child exit Co-authored-by: GPT-5.6 --- crates/pty_terminal/src/terminal.rs | 47 ++++++++++++++++++--------- crates/pty_terminal/tests/terminal.rs | 7 ++-- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/crates/pty_terminal/src/terminal.rs b/crates/pty_terminal/src/terminal.rs index 164c6c186..9ecf5a1e0 100644 --- a/crates/pty_terminal/src/terminal.rs +++ b/crates/pty_terminal/src/terminal.rs @@ -28,7 +28,7 @@ pub struct PtyReader { pub struct PtyWriter { writer: Arc>>>, parser: Arc>>, - master: Box, + master: Arc>>>, } /// A cloneable handle to a child process spawned in a PTY. @@ -249,18 +249,23 @@ impl PtyWriter { /// /// # Errors /// - /// Returns an error if the PTY cannot be resized. + /// Returns an error if the child process has exited or the PTY cannot be resized. /// /// # Panics /// - /// Panics if the parser lock is poisoned. + /// Panics if the PTY master or parser lock is poisoned. pub fn resize(&self, size: ScreenSize) -> anyhow::Result<()> { - self.master.resize(portable_pty::PtySize { - rows: size.rows, - cols: size.cols, - pixel_width: 0, - pixel_height: 0, - })?; + self.master + .lock() + .unwrap() + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Child process has exited"))? + .resize(portable_pty::PtySize { + rows: size.rows, + cols: size.cols, + pixel_width: 0, + pixel_height: 0, + })?; self.parser.lock().unwrap().screen_mut().set_size(size.rows, size.cols); @@ -299,9 +304,6 @@ impl Terminal { /// /// Returns an error if the PTY cannot be opened or the command fails to spawn. /// - /// # Panics - /// - /// Panics if the writer lock is poisoned when the background thread closes it. pub fn spawn(size: ScreenSize, cmd: CommandBuilder) -> anyhow::Result { // On musl libc (Alpine Linux), concurrent PTY operations trigger // SIGSEGV/SIGBUS in musl internals (sysconf, fcntl). This affects @@ -324,7 +326,7 @@ impl Terminal { Arc::new(Mutex::new(Some(pty_pair.master.take_writer()?))); let mut child = pty_pair.slave.spawn_command(cmd)?; let child_killer = child.clone_killer(); - let master = pty_pair.master; + let master = Arc::new(Mutex::new(Some(pty_pair.master))); let exit_status: Arc> = Arc::new(OnceLock::new()); // Background thread: wait for child to exit, then clean up. @@ -338,17 +340,30 @@ impl Terminal { // over guarantees the PTY stays connected while the child runs. thread::spawn({ let writer = Arc::clone(&writer); + let master = Arc::downgrade(&master); let exit_status = Arc::clone(&exit_status); let slave = pty_pair.slave; move || { - let _ = exit_status.set(child.wait().map_err(Arc::new)); + let result = child.wait().map_err(Arc::new); + // Pin the master before publishing the result so a waiter cannot + // race cleanup and run a blocking ClosePseudoConsole itself. + let master = master.upgrade(); + let _ = exit_status.set(result); // On musl, serialize FD cleanup (close) with PTY spawn to // prevent racing on musl-internal state. #[cfg(target_env = "musl")] let _cleanup_guard = PTY_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - // Close writer first, then drop slave to trigger EOF on the reader. - *writer.lock().unwrap() = None; + // Close writer and slave before the master. On pre-26100 Windows, + // dropping the master calls ClosePseudoConsole and may block until + // the caller concurrently drains the output pipe. + let writer = + writer.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take(); + drop(writer); drop(slave); + let master = master.and_then(|master| { + master.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() + }); + drop(master); } }); diff --git a/crates/pty_terminal/tests/terminal.rs b/crates/pty_terminal/tests/terminal.rs index 17bf0f7ac..0d0b06e9a 100644 --- a/crates/pty_terminal/tests/terminal.rs +++ b/crates/pty_terminal/tests/terminal.rs @@ -380,13 +380,15 @@ fn read_to_end_returns_exit_status_success() { println!("success"); })); - let Terminal { mut pty_reader, pty_writer: _pty_writer, child_handle, .. } = + let Terminal { mut pty_reader, pty_writer: _retained_pty_writer, child_handle, .. } = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + // Keep the writer alive: the child monitor must release the PTY master while output is drained. let mut discard = Vec::new(); pty_reader.read_to_end(&mut discard).unwrap(); let status = child_handle.wait().unwrap(); assert!(status.success()); assert_eq!(status.exit_code(), 0); + assert!(String::from_utf8_lossy(&discard).contains("success")); } #[test] @@ -396,8 +398,9 @@ fn read_to_end_returns_exit_status_nonzero() { std::process::exit(42); })); - let Terminal { mut pty_reader, pty_writer: _pty_writer, child_handle, .. } = + let Terminal { mut pty_reader, pty_writer: _retained_pty_writer, child_handle, .. } = Terminal::spawn(ScreenSize { rows: 80, cols: 80 }, cmd).unwrap(); + // Keep the writer alive: the child monitor must release the PTY master while output is drained. let mut discard = Vec::new(); pty_reader.read_to_end(&mut discard).unwrap(); let status = child_handle.wait().unwrap(); From 8adde6bcea8c3e94443e7437846d7f7bc8c41b8b Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 9 Jul 2026 12:43:59 +0800 Subject: [PATCH 2/3] ci: verify ConPTY fix on Windows Server 2022 Co-authored-by: GPT-5.6 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06dc1f404..04dc31bf8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -237,7 +237,7 @@ jobs: - shard: windows-ignored partition: '' mode: ignored - runs-on: windows-latest + runs-on: namespace-profile-windows-4c-8g env: PARTITION: ${{ matrix.partition }} steps: From c87d310035f16cb6601b35e3390dd96ffa284cdc Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 9 Jul 2026 12:48:33 +0800 Subject: [PATCH 3/3] ci: restore default Windows runner Co-authored-by: GPT-5.6 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04dc31bf8..06dc1f404 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -237,7 +237,7 @@ jobs: - shard: windows-ignored partition: '' mode: ignored - runs-on: namespace-profile-windows-4c-8g + runs-on: windows-latest env: PARTITION: ${{ matrix.partition }} steps: