From 37745afaee9b8df438c67c7a4675790cbdf6a740 Mon Sep 17 00:00:00 2001 From: Adam Hitchcock Date: Thu, 15 Oct 2026 00:03:12 -0700 Subject: [PATCH] fix(daemon): a pane no window shows still answers DA1 (#1176) * fix(daemon): a pane no window shows still answers DA1 The client's emulator answers a pane's queries, so a pane with no client attached answered nothing, and the replay on attach drops replies. Claude Code ends its OSC 11 theme query with DA1 and waits for it; started in such a pane it hung on that query and skipped every later theme check. The daemon now answers DA1 itself while no client is attached. Co-Authored-By: Claude Opus 5.5 * fix(daemon): write the DA1 answer after the state lock is released A pty whose program is not reading its input blocks the write, and holding the pane's state lock across it would stall everything else that needs that pane. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- crates/tty7-core/src/core/term_modes.rs | 28 ++++++++++ crates/tty7-core/src/daemon/pane.rs | 69 +++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/crates/tty7-core/src/core/term_modes.rs b/crates/tty7-core/src/core/term_modes.rs index 1e4e8fc1..6078aa19 100644 --- a/crates/tty7-core/src/core/term_modes.rs +++ b/crates/tty7-core/src/core/term_modes.rs @@ -178,6 +178,9 @@ pub struct TerminalModes { /// `CSI ? 996 n` queries seen and not yet taken — see /// [`Self::take_color_scheme_queries`]. color_scheme_queries: usize, + /// `CSI c` (primary device attributes) queries seen and not yet taken — + /// see [`Self::take_device_attribute_queries`]. + device_attribute_queries: usize, keyboard: KeyboardStacks, } @@ -222,6 +225,12 @@ impl TerminalModes { std::mem::take(&mut self.color_scheme_queries) } + /// How many `CSI c` / `CSI 0 c` (primary device attributes) queries were + /// fed since the last call. Counted only, like the 996 ones. + pub fn take_device_attribute_queries(&mut self) -> usize { + std::mem::take(&mut self.device_attribute_queries) + } + /// The bytes that put a freshly reset terminal back into these modes, or /// `None` when there is nothing to restore. pub fn restore_bytes(&self) -> Option> { @@ -364,6 +373,12 @@ impl TerminalModes { } self.state = State::Text; } + b'c' => { + if marker == 0 && matches!(self.params.as_slice(), b"" | b"0") { + self.device_attribute_queries += 1; + } + self.state = State::Text; + } // Any other final byte — or an intermediate such as the `$` // of a DECRQM query — ends a sequence that is not a mode // set. Intermediates are lumped in with finals on purpose: @@ -696,6 +711,19 @@ mod tests { assert!(!modes.is_on(BRACKETED_PASTE)); } + #[test] + fn counts_primary_device_attribute_queries() { + let mut modes = TerminalModes::new(); + modes.feed(b"\x1b]11;?\x07\x1b["); + modes.feed(b"c\x1b[0c\x1b[>c\x1b[=c\x1b[?6c"); + assert_eq!( + modes.take_device_attribute_queries(), + 2, + "a split DA1 counts, DA2, DA3 and a DA1 reply do not" + ); + assert_eq!(modes.take_device_attribute_queries(), 0); + } + #[test] fn follows_colour_scheme_updates_and_counts_its_queries() { let mut modes = TerminalModes::new(); diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 3f31d67f..d03d2b67 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -2423,6 +2423,7 @@ impl DaemonPane { let facts_before = may_change_facts.then(|| observed_facts(&st)); record_output(&mut st, bytes); fan_out_output(&mut st, bytes, frames, &gate); + let da1_owed = detached_device_attribute_queries(&mut st); apply_signals(&mut st, signals); if let Some(remote) = remote { apply_remote_context(&mut st, remote); @@ -2444,6 +2445,7 @@ impl DaemonPane { let alive = st.alive; let facts_after = may_change_facts.then(|| observed_facts(&st)); drop(st); + answer_device_attributes(&writer, da1_owed); if !shutting_down.load(Ordering::SeqCst) && let (Some(before), Some(after)) = (facts_before, facts_after) && facts_changed(&before, &after) @@ -3338,6 +3340,38 @@ fn record_output(st: &mut PaneState, bytes: &[u8]) { st.modes.feed(bytes); } +/// The primary device attributes a client's emulator answers with. +const DA1_REPLY: &[u8] = b"\x1b[?6c"; + +/// A pane with no client attached has nobody to answer its queries, and a +/// replay drops replies, so one asked then is never answered. Programs that +/// end a batch of queries with DA1 wait on that answer: Claude Code's OSC 11 +/// theme check hangs on it and then skips every later check, so the theme +/// flips tty7 reports never reach it. Answering DA1 here lets the batch end; +/// the colour report a client sends on attach then asks again, and is answered. +/// +/// Returns how many DA1 queries the daemon owes an answer to. With a client +/// attached the client answers, so none are owed. Read under the state lock; +/// the answers are written by [`answer_device_attributes`] after it is +/// released, because a pty whose program is not reading its input blocks the +/// write, and nothing else that needs this pane's state should wait on that. +fn detached_device_attribute_queries(st: &mut PaneState) -> usize { + let asked = st.modes.take_device_attribute_queries(); + if st.subscriber.is_some() { 0 } else { asked } +} + +fn answer_device_attributes(writer: &Mutex>, owed: usize) { + if owed == 0 { + return; + } + if let Ok(mut writer) = writer.lock() { + for _ in 0..owed { + let _ = writer.write_all(DA1_REPLY); + } + let _ = writer.flush(); + } +} + /// Everything a client needs to rebuild the pane's screen and status, in the /// order it has to be applied. /// @@ -6554,6 +6588,41 @@ mod tests { withdraw_observations(); } + /// Claude Code ends its OSC 11 theme query with DA1 and waits for both. + /// Started in a pane no window shows (an agent woken at launch), it got + /// neither and never checked the theme again. + #[test] + fn a_detached_pane_answers_device_attributes_and_an_attached_one_leaves_it_to_the_client() { + #[derive(Clone, Default)] + struct Sink(Arc>>); + impl Write for Sink { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let sink = Sink::default(); + let writer: Mutex> = Mutex::new(Box::new(sink.clone())); + let mut st = test_state(true); + + record_output(&mut st, b"\x1b]11;?\x07\x1b[c"); + answer_device_attributes(&writer, detached_device_attribute_queries(&mut st)); + assert_eq!(sink.0.lock().unwrap().as_slice(), DA1_REPLY); + + let (tx, _rx) = mpsc::channel(); + st.subscriber = Some(tx); + record_output(&mut st, b"\x1b[c"); + answer_device_attributes(&writer, detached_device_attribute_queries(&mut st)); + assert_eq!( + sink.0.lock().unwrap().as_slice(), + DA1_REPLY, + "the client answers" + ); + } + #[test] fn sentinel_events_drive_agent_session_state() { use crate::core::cli_agent::{AgentStatus, CLIAgent};