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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
Adam Hitchcock
2026-10-15 15:03:12 +08:00
committed by GitHub
co-authored by Claude Opus 5.5 l0ng-ai
parent 8c5ea523b3
commit 37745afaee
2 changed files with 97 additions and 0 deletions
+28
View File
@@ -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<Vec<u8>> {
@@ -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();
+69
View File
@@ -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<Box<dyn Write + Send>>, 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<Mutex<Vec<u8>>>);
impl Write for Sink {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
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<Box<dyn Write + Send>> = 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};