From fc01b3e31fd0bf58becfc06a4e42a15ef4587889 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:04:51 +0800 Subject: [PATCH] fix(client): stop timeout setup from masking the daemon's refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS, setsockopt against a socket whose peer has already closed fails with EINVAL. The daemon answers a bad request by writing one Error frame and hanging up at once, so PaneSession's `set_recv_timeout(...)?` would fail before the refusal was ever read — turning "no such pane 42", already sitting in the buffer, into "Invalid argument". Bounding the reply wait is an optimisation, not a correctness requirement, so it is now best effort in both attach/observe and spawn. Nothing can hang as a result: a closed peer returns EOF immediately, and a live peer is exactly the case where setsockopt succeeds. This is what made client_lib's reattach test red. --- crates/tty7-core/src/client/pane.rs | 29 +++++++++++++++++++++----- crates/tty7-server/tests/client_lib.rs | 3 +-- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/crates/tty7-core/src/client/pane.rs b/crates/tty7-core/src/client/pane.rs index 4cfacae0..c1f64fc8 100644 --- a/crates/tty7-core/src/client/pane.rs +++ b/crates/tty7-core/src/client/pane.rs @@ -118,7 +118,15 @@ impl PaneClient { owner: Option, workspace: Option, ) -> io::Result { - PaneSession::spawn_over(self.open()?, cwd, size, shell, owner, workspace, OPEN_REPLY_WAIT) + PaneSession::spawn_over( + self.open()?, + cwd, + size, + shell, + owner, + workspace, + OPEN_REPLY_WAIT, + ) } pub fn attach(&self, pane_id: u64, size: WinSize) -> io::Result { @@ -155,9 +163,12 @@ impl PaneSession { } .encode(&mut stream)?; let mut session = PaneSession::over(stream, 0)?; - session.set_recv_timeout(Some(reply_wait))?; + // Best effort, deliberately not `?`: see `checked` — a daemon that has + // already refused and hung up makes setsockopt fail, and that must not + // become the error the caller sees instead of the refusal itself. + let _ = session.set_recv_timeout(Some(reply_wait)); let first = session.recv(); - session.set_recv_timeout(None)?; + let _ = session.set_recv_timeout(None); match first { Ok(DaemonMsg::Spawned { pane_id }) => { session.input.pane_id = pane_id; @@ -202,9 +213,17 @@ impl PaneSession { reply_wait: Duration, ) -> io::Result { let mut session = PaneSession::over(stream, pane_id)?; - session.set_recv_timeout(Some(reply_wait))?; + // Best effort, deliberately not `?`. The daemon answers a bad request by + // writing one Error frame and closing immediately; on macOS setsockopt + // against a socket whose peer is already gone fails with EINVAL. Letting + // that propagate replaces "no such pane 42" — which is sitting in our + // buffer right now — with "Invalid argument", the least useful thing we + // could tell the caller. If the timeout does not take, the read below + // still cannot hang: a closed peer returns EOF at once, and a peer + // that is alive is exactly the case where setsockopt succeeds. + let _ = session.set_recv_timeout(Some(reply_wait)); let verdict = session.output.refusal_check(request, pane_id); - session.set_recv_timeout(None)?; + let _ = session.set_recv_timeout(None); verdict?; Ok(session) } diff --git a/crates/tty7-server/tests/client_lib.rs b/crates/tty7-server/tests/client_lib.rs index 0cbe80bb..4ab3256d 100644 --- a/crates/tty7-server/tests/client_lib.rs +++ b/crates/tty7-server/tests/client_lib.rs @@ -249,8 +249,7 @@ fn control_requests_build_the_tree_and_events_reach_the_other_client() { ); match watcher.next_event(remaining) { Some(ControlEvent::Layout { workspace, delta }) - if workspace == key - && matches!(delta, LayoutDelta::WorkspaceCreated { .. }) => + if workspace == key && matches!(delta, LayoutDelta::WorkspaceCreated { .. }) => { break; }