From b69ddbf6dbb5d13395e81523b883fe1aa4fdbef1 Mon Sep 17 00:00:00 2001 From: Dom Giandinoto Date: Wed, 7 Oct 2026 16:56:49 -0700 Subject: [PATCH 1/3] Piped stdin cancels/closes on command completion. Pipe classification changed from just relying on interactive trait of stdout --- .../isolation_session/common/manager.rs | 141 ++++++++++++------ .../isolation_session/common/pipe_relay.rs | 53 +++++-- 2 files changed, 140 insertions(+), 54 deletions(-) diff --git a/src/mxc-sdk/src/backends/isolation_session/common/manager.rs b/src/mxc-sdk/src/backends/isolation_session/common/manager.rs index 7ec4c8b79..6ec718099 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/manager.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/manager.rs @@ -19,9 +19,11 @@ use crate::isolation_session_bindings::bindings::{ IsoSessionUserResult, }; use windows::Win32::Foundation::{HANDLE, WAIT_OBJECT_0}; +use windows::Win32::Storage::FileSystem::{GetFileType, FILE_TYPE_CHAR}; use windows::Win32::System::Com::{CoDecrementMTAUsage, CoIncrementMTAUsage, CO_MTA_USAGE_COOKIE}; use windows::Win32::System::Console::{ - GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, + GetConsoleMode, GetStdHandle, CONSOLE_MODE, STD_ERROR_HANDLE, STD_INPUT_HANDLE, + STD_OUTPUT_HANDLE, }; use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject}; use windows::UI::WindowId; @@ -42,6 +44,23 @@ use super::process_options::{build_iso_process_options, ProcessOptions}; // The OS account name is lifecycle addressing data, not diagnostic identity. const AGENT_IDENTITY_MARKER: &str = "isolation-session-agent"; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StdinHandleKind { + Console, + PipeOrFile, +} + +fn classify_stdin_handle(handle: HANDLE) -> StdinHandleKind { + let file_type = unsafe { GetFileType(handle) }; + if file_type == FILE_TYPE_CHAR { + let mut mode = CONSOLE_MODE(0); + if unsafe { GetConsoleMode(handle, &mut mode) }.is_ok() { + return StdinHandleKind::Console; + } + } + StdinHandleKind::PipeOrFile +} + /// Keeps the process's MTA alive for as long as the lifecycle's WinRT objects, /// which outlive the call that created them. /// `CoIncrementMTAUsage` holds a reference releasable from any thread; @@ -418,10 +437,7 @@ impl IsolationSessionManager { let wxc_stdin = unsafe { GetStdHandle(STD_INPUT_HANDLE) } .map_err(|e| lifecycle_err(format!("GetStdHandle(stdin) failed: {}", e)))?; - // Manual-reset stop event for the stdin relay. Effective for a waitable - // `h_read` (console = TTY mode); for pipe handles it has no effect on a - // blocked `ReadFile`, so that relay misses its join and ends only when - // the calling process exits. Only `wxc-exec` reaches the non-TTY case. + // Manual-reset stop event for console-backed stdin relays. let stdin_stop_event = unsafe { CreateEventW(None, true, false, PCWSTR::null()) .map_err(|e| lifecycle_err(format!("CreateEventW(stdin stop): {}", e)))? @@ -516,13 +532,15 @@ impl IsolationSessionManager { } else { None }; - // Stdin: in interactive mode use the console-aware relay so - // `WINDOW_BUFFER_SIZE_EVENT` records propagate as - // `ResizeConsole(cols, rows)` calls on the agent's inner ConPTY. - // In non-interactive mode the agent's stdin is plain byte-oriented - // and the simpler stop-aware pipe relay is appropriate. + // Stdin relay selection is based on the actual local stdin handle, + // not on `options.interactive` (which is derived from stdout). A + // pipe/file read must be cancelled with `PipeReadCanceller`; a + // console handle can be interrupted by the stop event and, when the + // agent side is interactive, needs the console-aware relay for + // resize records. enum StdinRelayKind { None, + CancellablePipe, Pipe(PipeRelayWithStopParams), Console(ConsoleRelayParams), } @@ -530,37 +548,47 @@ impl IsolationSessionManager { let stdin_relay_state = if stdin_handle_val == 0 { StdinRelayKind::None } else { - // Owned, like the output relays': `process.Close()` releases the - // original while this relay may still be writing. - let stdin_h_write = - duplicate_handle(HANDLE(stdin_handle_val as *mut core::ffi::c_void)) - .map_err(|e| lifecycle_err(format!("duplicate stdin handle: {}", e)))?; - // Shares the event object, so `signal_stop` still reaches the relay. - let stdin_h_stop = duplicate_handle(stdin_stop_owned.get()) - .map_err(|e| lifecycle_err(format!("duplicate stdin stop event: {}", e)))?; - if options.interactive { - // Clone the WinRT process handle so the relay thread holds - // its own ref-counted reference (WinRT clone = AddRef), - // released when the thread frees the params it owns. - let process_for_resize = process.clone(); - let impersonation = self.impersonation.clone(); - StdinRelayKind::Console(ConsoleRelayParams { - h_read: wxc_stdin, - h_write: stdin_h_write, - h_stop_event: stdin_h_stop, - resize_callback: Box::new(move |cols, rows| { - let _ = owned_thread::call(&impersonation, || { - let _ = process_for_resize.ResizeConsole(cols, rows); - Ok(()) - }); - }), - }) - } else { - StdinRelayKind::Pipe(PipeRelayWithStopParams { - h_read: wxc_stdin, - h_write: stdin_h_write, - h_stop_event: stdin_h_stop, - }) + match classify_stdin_handle(wxc_stdin) { + StdinHandleKind::PipeOrFile => StdinRelayKind::CancellablePipe, + StdinHandleKind::Console => { + // Owned, like the output relays': `process.Close()` + // releases the original while this relay may still be + // writing. + let stdin_h_write = + duplicate_handle(HANDLE(stdin_handle_val as *mut core::ffi::c_void)) + .map_err(|e| { + lifecycle_err(format!("duplicate stdin handle: {}", e)) + })?; + // Shares the event object, so `signal_stop` still reaches the relay. + let stdin_h_stop = + duplicate_handle(stdin_stop_owned.get()).map_err(|e| { + lifecycle_err(format!("duplicate stdin stop event: {}", e)) + })?; + if options.interactive { + // Clone the WinRT process handle so the relay thread holds + // its own ref-counted reference (WinRT clone = AddRef), + // released when the thread frees the params it owns. + let process_for_resize = process.clone(); + let impersonation = self.impersonation.clone(); + StdinRelayKind::Console(ConsoleRelayParams { + h_read: wxc_stdin, + h_write: stdin_h_write, + h_stop_event: stdin_h_stop, + resize_callback: Box::new(move |cols, rows| { + let _ = owned_thread::call(&impersonation, || { + let _ = process_for_resize.ResizeConsole(cols, rows); + Ok(()) + }); + }), + }) + } else { + StdinRelayKind::Pipe(PipeRelayWithStopParams { + h_read: wxc_stdin, + h_write: stdin_h_write, + h_stop_event: stdin_h_stop, + }) + } + } } }; @@ -568,6 +596,17 @@ impl IsolationSessionManager { // them on the heap under the relay thread's ownership. scope.stdin = match stdin_relay_state { StdinRelayKind::None => None, + StdinRelayKind::CancellablePipe => { + let (thread, canceller) = unsafe { + create_relay_thread( + wxc_stdin, + HANDLE(stdin_handle_val as *mut core::ffi::c_void), + ) + } + .map_err(|e| lifecycle_err(format!("create stdin relay: {}", e)))?; + scope.stdin_canceller = Some(canceller); + Some(thread) + } StdinRelayKind::Pipe(params) => Some( unsafe { create_relay_thread_with_stop(params) } .map_err(|e| lifecycle_err(format!("create stdin relay: {}", e)))?, @@ -1035,6 +1074,10 @@ struct RelayScope<'a> { /// ends keeps them open past the workload's exit, so EOF alone is not a /// bound. output_cancellers: Vec, + /// Ends a pipe/file-backed stdin relay. Console stdin relays use the stop + /// event instead because `CancelSynchronousIo` has console-handle edge + /// cases. + stdin_canceller: Option, finished: bool, } @@ -1051,6 +1094,7 @@ impl<'a> RelayScope<'a> { stderr: None, stdin: None, output_cancellers: Vec::new(), + stdin_canceller: None, finished: false, } } @@ -1068,6 +1112,12 @@ impl<'a> RelayScope<'a> { } } + fn cancel_stdin_read(&self) { + if let Some(canceller) = &self.stdin_canceller { + canceller.close(); + } + } + /// Whether the relay exited. An absent relay was never spawned. fn join(relay: &Option, timeout_ms: u32) -> bool { match relay { @@ -1085,12 +1135,14 @@ impl<'a> RelayScope<'a> { fn stop_stdin(&self) { self.signal_stop(); + self.cancel_stdin_read(); Self::join(&self.stdin, Self::JOIN_MS); } /// Teardown for a workload that has already exited. fn finish(&mut self) { self.signal_stop(); + self.cancel_stdin_read(); let stdout_drained = Self::join(&self.stdout, Self::DRAIN_MS); let stderr_drained = Self::join(&self.stderr, Self::DRAIN_MS); if !(stdout_drained && stderr_drained) { @@ -1110,6 +1162,7 @@ impl Drop for RelayScope<'_> { return; } self.signal_stop(); + self.cancel_stdin_read(); self.cancel_output_reads(); let _ = self.process.Terminate(); Self::join(&self.stdout, Self::JOIN_MS); @@ -1296,9 +1349,9 @@ fn plan_wait( } /// Three-tier graceful shutdown for an `IsoSessionProcess` that's still -/// running after `WaitForExit(timeout_ms)` returns. Tier 1: close stdin — -/// many REPLs exit on EOF alone. Tier 2: `SendCtrlClose` — ConPTY-only; -/// `E_NOTIMPL` outside ConPTY, benign. Tier 3: force-terminate, wait +/// running after `WaitForExit(timeout_ms)` returns. Tier 1 asks the platform +/// to close stdin, tier 2 tries `SendCtrlClose` (ConPTY-only; `E_NOTIMPL` +/// outside ConPTY is benign), and tier 3 force-terminates, then waits /// infinitely (`WaitForExit(0)` = INFINITE) for the kill to land. /// /// The first `ExitCode()` query is `?`-propagated: a failure there means diff --git a/src/mxc-sdk/src/backends/isolation_session/common/pipe_relay.rs b/src/mxc-sdk/src/backends/isolation_session/common/pipe_relay.rs index e179f9b54..0fe72ea74 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/pipe_relay.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/pipe_relay.rs @@ -6,11 +6,12 @@ //! desktop-session boundary. //! //! Two relay variants: -//! - [`create_relay_thread`] — cancellable; used for stdout / stderr. Ends at -//! EOF or when its [`PipeReadCanceller`] fires. -//! - [`create_relay_thread_with_stop`] — stop-event-aware; used for stdin -//! in TTY (ConPTY) mode where the agent can exit while the local stdin -//! handle remains open. +//! - [`create_relay_thread`] — cancellable; used for stdout / stderr and for +//! pipe/file-backed stdin. Ends at EOF or when its [`PipeReadCanceller`] +//! fires. +//! - [`create_relay_thread_with_stop`] — stop-event-aware; used for +//! console-backed stdin when the agent can exit while the local stdin handle +//! remains open. use std::io::Read; @@ -132,10 +133,9 @@ pub(super) unsafe fn create_relay_thread( // ── Stop-event-aware pipe relay ──────────────────────────────────────────── // -// Used for the IsolationSession stdin relay in TTY (ConPTY) mode, where the -// agent process can exit naturally while wxc-exec's stdin remains open (held -// by the parent: node-pty, shell, etc.). Without an external signal the relay -// sits in `ReadFile` forever. +// Used for the IsolationSession stdin relay when wxc-exec's stdin is a console +// handle and the agent process can exit naturally while that handle remains +// open. Without an external signal the relay sits in `ReadFile` forever. // // `CancelSynchronousIo` is the obvious alternative but has documented edge // cases on console handles, and wxc-exec's stdin is a console handle in the @@ -149,7 +149,7 @@ pub(super) unsafe fn create_relay_thread( // signalled when open", so the wait returns immediately, the relay enters // `ReadFile`, and from there the stop event cannot interrupt it. For // pipe-backed stdin (the non-TTY case), use the simpler EOF-driven -// `create_relay_thread` and rely on natural EOF or process exit for cleanup. +// `create_relay_thread` and rely on natural EOF or its canceller for cleanup. /// Parameters for a stop-event-aware relay thread. The thread loops /// `WaitForMultipleObjects({h_stop_event, h_read})`; copies a chunk when @@ -407,6 +407,39 @@ mod tests { ); } + /// Pipe-backed stdin uses `create_relay_thread` so shutdown can cancel the + /// blocked source read. Once cancelled, the relay must also release its + /// duplicate of the agent stdin write handle so the reader observes EOF. + #[test] + fn a_cancelled_stdin_pipe_relay_releases_its_destination_handle() { + let (source_read, _source_write) = create_test_pipes(); + let (mut dest_read, dest_write) = create_test_pipes(); + + let (relay_thread, canceller) = + unsafe { create_relay_thread(source_read.get(), dest_write.get()).unwrap() }; + + // Mirror `process.Close()`: the caller drops its original agent-stdin + // write handle while the relay's duplicate is still live. + drop(dest_write); + + assert_eq!( + unsafe { WaitForSingleObject(relay_thread.get(), 250) }, + WAIT_TIMEOUT, + "the relay should still be blocked before the cancel" + ); + + canceller.close(); + + assert_eq!( + unsafe { WaitForSingleObject(relay_thread.get(), 5000) }, + WAIT_OBJECT_0, + "the cancelled stdin relay should exit promptly" + ); + + let dest_send = SendOwnedHandle::take(&mut dest_read); + assert_eq!(read_from_pipe(dest_send.get()), ""); + } + /// Closing the source handle the relay was built from does not disturb it: /// it reads a duplicate. This is what lets `IsoSessionProcess::Close` run /// unconditionally. From 8a7db8ef3d117f78cc0b95bc3184ac920393dccd Mon Sep 17 00:00:00 2001 From: Dom Giandinoto Date: Tue, 13 Oct 2026 17:52:41 -0700 Subject: [PATCH 2/3] ensuring agent's stdin closes on EOF for piped wxc stdin, clarifying StdinRelayKind values --- .../isolation_session/common/manager.rs | 34 +++-- .../isolation_session/common/pipe_relay.rs | 118 +++++++++++++++++- .../src/core/mxc_common/process_util.rs | 7 ++ 3 files changed, 144 insertions(+), 15 deletions(-) diff --git a/src/mxc-sdk/src/backends/isolation_session/common/manager.rs b/src/mxc-sdk/src/backends/isolation_session/common/manager.rs index 6ec718099..cf66325b7 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/manager.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/manager.rs @@ -37,7 +37,8 @@ use super::error::{ }; use super::owned_thread::{self, Impersonation}; use super::pipe_relay::{ - create_relay_thread, create_relay_thread_with_stop, duplicate_handle, PipeRelayWithStopParams, + create_relay_thread, create_relay_thread_with_eof, create_relay_thread_with_stop, + duplicate_handle, PipeRelayWithStopParams, }; use super::process_options::{build_iso_process_options, ProcessOptions}; @@ -540,16 +541,16 @@ impl IsolationSessionManager { // resize records. enum StdinRelayKind { None, - CancellablePipe, - Pipe(PipeRelayWithStopParams), - Console(ConsoleRelayParams), + PipeOrFile, + ConsoleByteStream(PipeRelayWithStopParams), + ConsoleInteractive(ConsoleRelayParams), } let stdin_relay_state = if stdin_handle_val == 0 { StdinRelayKind::None } else { match classify_stdin_handle(wxc_stdin) { - StdinHandleKind::PipeOrFile => StdinRelayKind::CancellablePipe, + StdinHandleKind::PipeOrFile => StdinRelayKind::PipeOrFile, StdinHandleKind::Console => { // Owned, like the output relays': `process.Close()` // releases the original while this relay may still be @@ -570,7 +571,7 @@ impl IsolationSessionManager { // released when the thread frees the params it owns. let process_for_resize = process.clone(); let impersonation = self.impersonation.clone(); - StdinRelayKind::Console(ConsoleRelayParams { + StdinRelayKind::ConsoleInteractive(ConsoleRelayParams { h_read: wxc_stdin, h_write: stdin_h_write, h_stop_event: stdin_h_stop, @@ -582,7 +583,7 @@ impl IsolationSessionManager { }), }) } else { - StdinRelayKind::Pipe(PipeRelayWithStopParams { + StdinRelayKind::ConsoleByteStream(PipeRelayWithStopParams { h_read: wxc_stdin, h_write: stdin_h_write, h_stop_event: stdin_h_stop, @@ -596,22 +597,33 @@ impl IsolationSessionManager { // them on the heap under the relay thread's ownership. scope.stdin = match stdin_relay_state { StdinRelayKind::None => None, - StdinRelayKind::CancellablePipe => { + StdinRelayKind::PipeOrFile => { + // Ensure the agent's stdin will close on EOF for pipe/file-backed + // stdin. + let process_for_eof = process.clone(); + let impersonation = self.impersonation.clone(); + let on_eof: Box = Box::new(move || { + let _ = owned_thread::call(&impersonation, || { + let _ = process_for_eof.CloseStandardInput(); + Ok(()) + }); + }); let (thread, canceller) = unsafe { - create_relay_thread( + create_relay_thread_with_eof( wxc_stdin, HANDLE(stdin_handle_val as *mut core::ffi::c_void), + Some(on_eof), ) } .map_err(|e| lifecycle_err(format!("create stdin relay: {}", e)))?; scope.stdin_canceller = Some(canceller); Some(thread) } - StdinRelayKind::Pipe(params) => Some( + StdinRelayKind::ConsoleByteStream(params) => Some( unsafe { create_relay_thread_with_stop(params) } .map_err(|e| lifecycle_err(format!("create stdin relay: {}", e)))?, ), - StdinRelayKind::Console(params) => { + StdinRelayKind::ConsoleInteractive(params) => { Some(unsafe { create_console_relay_thread(params) }.map_err(|e| { lifecycle_err(format!("create console-aware stdin relay: {}", e)) })?) diff --git a/src/mxc-sdk/src/backends/isolation_session/common/pipe_relay.rs b/src/mxc-sdk/src/backends/isolation_session/common/pipe_relay.rs index 0fe72ea74..c4c8d7d88 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/pipe_relay.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/pipe_relay.rs @@ -52,6 +52,7 @@ pub(super) fn duplicate_handle(handle: HANDLE) -> Result struct PipeRelayParams { reader: InterruptiblePipeReader, h_write: OwnedHandle, + on_eof: Option>, } /// Thread procedure for relaying data between two handles. @@ -66,9 +67,13 @@ unsafe extern "system" fn pipe_relay_thread_proc(param: *mut core::ffi::c_void) let mut params = unsafe { Box::from_raw(param as *mut PipeRelayParams) }; let mut buffer = [0u8; BUFFER_SIZE as usize]; - loop { + let reached_source_eof = loop { let bytes_read = match params.reader.read(&mut buffer) { - Ok(0) | Err(_) => break, + // `Ok(0)` is reported for a genuine source EOF *and* for a fired + // canceller, so disambiguate with `was_cancelled` to determine if + // this is a genuine EOF. + Ok(0) => break !params.reader.was_cancelled(), + Err(_) => break false, Ok(n) => n, }; @@ -82,10 +87,16 @@ unsafe extern "system" fn pipe_relay_thread_proc(param: *mut core::ffi::c_void) .is_err() || bytes_written as usize != bytes_read { - break; + break false; } let _ = FlushFileBuffers(params.h_write.get()); + }; + + if reached_source_eof { + if let Some(on_eof) = params.on_eof.take() { + on_eof(); + } } 0 @@ -101,12 +112,26 @@ unsafe extern "system" fn pipe_relay_thread_proc(param: *mut core::ffi::c_void) pub(super) unsafe fn create_relay_thread( h_read: HANDLE, h_write: HANDLE, +) -> Result<(OwnedHandle, PipeReadCanceller), WxcError> { + unsafe { create_relay_thread_with_eof(h_read, h_write, None) } +} + +/// As [`create_relay_thread`], but runs `on_eof` once if the relay ends because +/// the source reached a genuine, uncancelled EOF. Pipe/file-backed stdin uses +/// this to propagate end-of-input to the agent (`CloseStandardInput`); a fired +/// canceller or a write error does **not** run it, so the forced-teardown path +/// stays distinct from natural end-of-input. +pub(super) unsafe fn create_relay_thread_with_eof( + h_read: HANDLE, + h_write: HANDLE, + on_eof: Option>, ) -> Result<(OwnedHandle, PipeReadCanceller), WxcError> { let reader = InterruptiblePipeReader::new(duplicate_handle(h_read)?); let canceller = reader.canceller(); let raw = Box::into_raw(Box::new(PipeRelayParams { reader, h_write: duplicate_handle(h_write)?, + on_eof, })); match unsafe { CreateThread( @@ -440,7 +465,92 @@ mod tests { assert_eq!(read_from_pipe(dest_send.get()), ""); } - /// Closing the source handle the relay was built from does not disturb it: + /// A genuine, uncancelled source EOF must run the relay's `on_eof` hook — + /// this is what closes the agent's stdin so a read-to-EOF workload exits. + #[test] + fn a_pipe_relay_at_source_eof_runs_its_eof_callback() { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + let (source_read, source_write) = create_test_pipes(); + let (mut dest_read, dest_write) = create_test_pipes(); + + let fired = Arc::new(AtomicBool::new(false)); + let fired_cb = Arc::clone(&fired); + let on_eof: Box = + Box::new(move || fired_cb.store(true, Ordering::SeqCst)); + + let (relay_thread, _canceller) = unsafe { + create_relay_thread_with_eof(source_read.get(), dest_write.get(), Some(on_eof)).unwrap() + }; + + // Drain concurrently so the relay's post-write FlushFileBuffers returns. + let dest_send = SendOwnedHandle::take(&mut dest_read); + let reader = std::thread::spawn(move || read_from_pipe(dest_send.get())); + + let mut written = 0u32; + unsafe { + WriteFile( + source_write.get(), + Some(b"payload"), + Some(&mut written), + None, + ) + .unwrap(); + } + // Source EOF: the relay copies the payload, reads `Ok(0)`, runs on_eof. + drop(source_write); + + assert_eq!( + unsafe { WaitForSingleObject(relay_thread.get(), 5000) }, + WAIT_OBJECT_0, + "the relay should reach EOF and exit" + ); + // The hook runs inside the thread proc before it returns, so by the time + // the thread is signalled the flag is already set. + assert!( + fired.load(Ordering::SeqCst), + "a genuine source EOF must run the EOF callback" + ); + + drop(dest_write); + assert_eq!(reader.join().unwrap(), "payload"); + } + + /// A cancellation also surfaces as `Ok(0)`, but it is forced teardown, not + /// end-of-input — the `on_eof` hook must not run (otherwise shutdown would + /// spuriously close the agent's stdin). + #[test] + fn a_cancelled_pipe_relay_does_not_run_its_eof_callback() { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + let (source_read, _source_write) = create_test_pipes(); + let (_dest_read, dest_write) = create_test_pipes(); + + let fired = Arc::new(AtomicBool::new(false)); + let fired_cb = Arc::clone(&fired); + let on_eof: Box = + Box::new(move || fired_cb.store(true, Ordering::SeqCst)); + + let (relay_thread, canceller) = unsafe { + create_relay_thread_with_eof(source_read.get(), dest_write.get(), Some(on_eof)).unwrap() + }; + + // Parked in `ReadFile` (`_source_write` keeps it from reaching EOF). + canceller.close(); + + assert_eq!( + unsafe { WaitForSingleObject(relay_thread.get(), 5000) }, + WAIT_OBJECT_0, + "the cancelled relay should exit" + ); + assert!( + !fired.load(Ordering::SeqCst), + "cancellation is not EOF: the callback must not run" + ); + } + /// it reads a duplicate. This is what lets `IsoSessionProcess::Close` run /// unconditionally. #[test] diff --git a/src/mxc-sdk/src/core/mxc_common/process_util.rs b/src/mxc-sdk/src/core/mxc_common/process_util.rs index 749f38882..e800ab66b 100644 --- a/src/mxc-sdk/src/core/mxc_common/process_util.rs +++ b/src/mxc-sdk/src/core/mxc_common/process_util.rs @@ -163,6 +163,13 @@ impl InterruptiblePipeReader { PipeReadCanceller(Arc::downgrade(&self.0)) } + /// Whether a [`PipeReadCanceller`] has already fired for this reader. A + /// cancelled read reports `Ok(0)` just like a genuine EOF, so a relay that + /// must act only on real end-of-input checks this to tell them apart. + pub fn was_cancelled(&self) -> bool { + self.0.lock().cancelled + } + pub fn try_clone_owned_handle(&self) -> std::io::Result { self.0.handle.try_clone_owned_handle() } From fbf70b16a9b1b9c798f150a8361a04796d6f20bb Mon Sep 17 00:00:00 2001 From: Dom Giandinoto Date: Tue, 13 Oct 2026 18:02:07 -0700 Subject: [PATCH 3/3] fmt change --- src/mxc-sdk/src/backends/isolation_session/common/manager.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mxc-sdk/src/backends/isolation_session/common/manager.rs b/src/mxc-sdk/src/backends/isolation_session/common/manager.rs index cf66325b7..cf81eef40 100644 --- a/src/mxc-sdk/src/backends/isolation_session/common/manager.rs +++ b/src/mxc-sdk/src/backends/isolation_session/common/manager.rs @@ -598,7 +598,7 @@ impl IsolationSessionManager { scope.stdin = match stdin_relay_state { StdinRelayKind::None => None, StdinRelayKind::PipeOrFile => { - // Ensure the agent's stdin will close on EOF for pipe/file-backed + // Ensure the agent's stdin will close on EOF for pipe/file-backed // stdin. let process_for_eof = process.clone(); let impersonation = self.impersonation.clone();