diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-31 16:21:53 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-31 16:22:04 +0300 |
| commit | bc1c052d1524749b3f24d91e83f120ca8b7f37c4 (patch) | |
| tree | d012bd4288bec8033bd8e9ab4e2086e8f110178e /src/server | |
| parent | 7db744ab4758df30f3e811ff9930646ec5f5970d (diff) | |
fix: read alternate-screen agent history
Diffstat (limited to 'src/server')
| -rw-r--r-- | src/server/alt_screen_read.rs | 383 | ||||
| -rw-r--r-- | src/server/headless.rs | 396 | ||||
| -rw-r--r-- | src/server/mod.rs | 1 |
3 files changed, 779 insertions, 1 deletions
diff --git a/src/server/alt_screen_read.rs b/src/server/alt_screen_read.rs new file mode 100644 index 00000000..240e96de --- /dev/null +++ b/src/server/alt_screen_read.rs @@ -0,0 +1,383 @@ +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use bytes::Bytes; +use crossterm::event::{KeyModifiers, MouseEventKind}; +use tracing::debug; + +use crate::api::schema::{PaneReadResult, ResponseResult, SuccessResponse}; +use crate::terminal::{ScreenSnapshot, TerminalId, TerminalRuntime, UpwardMerge}; + +const STEP_SETTLE: Duration = Duration::from_millis(120); +const MAX_DURATION: Duration = Duration::from_secs(15); +const MAX_RESTORE_DURATION: Duration = Duration::from_secs(5); +const MAX_UNALIGNED_CHECKS: u8 = 4; +const WHEEL_STEP_EVENTS: usize = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Phase { + SettleInitial { checks: u8 }, + ProbeBottom, + RestoreProbe, + Harvest { unaligned_checks: u8 }, + Restore { stable_checks: u8 }, +} + +pub(crate) struct PendingAltScreenRead { + pub(crate) terminal_id: TerminalId, + request_id: String, + respond_to: mpsc::Sender<String>, + fallback_response: String, + read: PaneReadResult, + lines: usize, + unwrap: bool, + initial: ScreenSnapshot, + previous: ScreenSnapshot, + history: Vec<crate::ghostty::ScreenTextRow>, + phase: Phase, + next_poll_at: Instant, + started_at: Instant, + restore_started_at: Option<Instant>, + upward_events: usize, + reached_top: bool, + valid: bool, +} + +impl PendingAltScreenRead { + pub(crate) fn start( + terminal_id: TerminalId, + request_id: String, + respond_to: mpsc::Sender<String>, + fallback_response: String, + read: PaneReadResult, + lines: usize, + unwrap: bool, + initial: ScreenSnapshot, + now: Instant, + ) -> Self { + Self { + terminal_id, + request_id, + respond_to, + fallback_response, + read, + lines, + unwrap, + previous: initial.clone(), + history: initial.rows.clone(), + initial, + phase: Phase::SettleInitial { checks: 0 }, + next_poll_at: now + STEP_SETTLE, + started_at: now, + restore_started_at: None, + upward_events: 0, + reached_top: false, + valid: true, + } + } + + pub(crate) fn next_deadline(&self) -> Instant { + self.next_poll_at + } + + pub(crate) fn frozen_snapshot( + &self, + source: crate::api::schema::ReadSource, + lines: Option<u32>, + ) -> crate::pane::TerminalReadSnapshot { + let line_limit = lines.map(|lines| lines.min(1000) as usize); + match source { + crate::api::schema::ReadSource::Recent + | crate::api::schema::ReadSource::RecentUnwrapped => { + let limit = line_limit.unwrap_or(80); + crate::terminal::snapshot_text( + &self.initial.rows, + limit, + source == crate::api::schema::ReadSource::RecentUnwrapped, + self.initial.rows.len() > limit, + ) + } + crate::api::schema::ReadSource::Visible | crate::api::schema::ReadSource::Detection => { + let snapshot = crate::terminal::snapshot_text( + &self.initial.rows, + self.initial.rows.len(), + false, + false, + ); + crate::app::limit_snapshot_lines(snapshot.text, line_limit) + } + } + } + + pub(crate) fn abort(mut self, runtime: Option<&TerminalRuntime>, now: Instant) -> PollOutcome { + self.valid = false; + match self.phase { + Phase::SettleInitial { .. } => self.complete_fallback(), + Phase::Harvest { .. } => match runtime { + Some(runtime) => self.start_restore(runtime, now), + None => self.complete_fallback(), + }, + Phase::ProbeBottom | Phase::RestoreProbe | Phase::Restore { .. } => { + self.poll(runtime, now) + } + } + } + + pub(crate) fn poll(mut self, runtime: Option<&TerminalRuntime>, now: Instant) -> PollOutcome { + if now < self.next_poll_at { + return Some(self); + } + let Some(runtime) = runtime else { + return self.complete_fallback(); + }; + let Some((screen, snapshot)) = runtime.screen_text_snapshot() else { + return self.complete_fallback(); + }; + if screen != crate::ghostty::ActiveScreen::Alternate + || snapshot.cols != self.initial.cols + || snapshot.rows.len() != self.initial.rows.len() + { + return self.complete_fallback(); + } + if self + .restore_started_at + .is_some_and(|started| now.duration_since(started) >= MAX_RESTORE_DURATION) + { + return self.complete_fallback(); + } + if now.duration_since(self.started_at) >= MAX_DURATION + && !matches!( + self.phase, + Phase::ProbeBottom | Phase::Restore { .. } | Phase::RestoreProbe + ) + { + self.valid = false; + return self.start_restore(runtime, now); + } + + match self.phase { + Phase::SettleInitial { checks } => { + if snapshot.similar_text(&self.initial) { + if checks >= 1 { + if send_wheel( + runtime, + MouseEventKind::ScrollDown, + WHEEL_STEP_EVENTS, + &snapshot, + ) + .is_err() + { + return self.complete_fallback(); + } + self.phase = Phase::ProbeBottom; + } else { + self.phase = Phase::SettleInitial { checks: checks + 1 }; + } + } else { + self.initial = snapshot.clone(); + self.previous = snapshot.clone(); + self.history = snapshot.rows; + self.phase = Phase::SettleInitial { checks: 0 }; + } + self.next_poll_at = now + STEP_SETTLE; + Some(self) + } + Phase::ProbeBottom => { + debug!( + terminal_id = %self.terminal_id, + at_bottom = snapshot.similar_text(&self.initial), + "alternate-screen read bottom probe settled" + ); + if snapshot.similar_text(&self.initial) { + if self.valid { + self.start_harvest(runtime, now) + } else { + self.complete_fallback() + } + } else if send_wheel( + runtime, + MouseEventKind::ScrollUp, + WHEEL_STEP_EVENTS, + &snapshot, + ) + .is_ok() + { + self.phase = Phase::RestoreProbe; + self.restore_started_at = Some(now); + self.next_poll_at = now + STEP_SETTLE; + Some(self) + } else { + self.complete_fallback() + } + } + Phase::RestoreProbe => { + if snapshot.similar_text(&self.initial) { + self.complete_fallback() + } else { + self.next_poll_at = now + STEP_SETTLE; + Some(self) + } + } + Phase::Harvest { unaligned_checks } => { + let merge = crate::terminal::merge_scrolled_up( + &mut self.history, + &self.previous, + &snapshot, + ); + match merge { + UpwardMerge::Advanced { .. } => { + self.previous = snapshot; + if self.history.len() >= self.lines { + self.start_restore(runtime, now) + } else { + self.start_harvest(runtime, now) + } + } + UpwardMerge::Unchanged => { + self.reached_top = true; + self.start_restore(runtime, now) + } + UpwardMerge::Unaligned if unaligned_checks + 1 < MAX_UNALIGNED_CHECKS => { + self.phase = Phase::Harvest { + unaligned_checks: unaligned_checks + 1, + }; + self.next_poll_at = now + STEP_SETTLE; + Some(self) + } + UpwardMerge::Unaligned => { + self.valid = false; + self.start_restore(runtime, now) + } + } + } + Phase::Restore { stable_checks } => { + if snapshot.similar_text(&self.previous) { + if stable_checks >= 1 { + if self.valid { + self.complete_success() + } else { + self.complete_fallback() + } + } else { + self.phase = Phase::Restore { + stable_checks: stable_checks + 1, + }; + self.next_poll_at = now + STEP_SETTLE; + Some(self) + } + } else { + self.previous = snapshot; + if send_wheel( + runtime, + MouseEventKind::ScrollDown, + restore_batch_size(&self.previous), + &self.previous, + ) + .is_ok() + { + self.phase = Phase::Restore { stable_checks: 0 }; + self.next_poll_at = now + STEP_SETTLE; + Some(self) + } else { + self.complete_fallback() + } + } + } + } + } + + fn start_harvest(mut self, runtime: &TerminalRuntime, now: Instant) -> PollOutcome { + let events = WHEEL_STEP_EVENTS; + if send_wheel(runtime, MouseEventKind::ScrollUp, events, &self.previous).is_err() { + return self.complete_fallback(); + } + self.upward_events = self.upward_events.saturating_add(events); + self.phase = Phase::Harvest { + unaligned_checks: 0, + }; + self.next_poll_at = now + STEP_SETTLE; + Some(self) + } + + fn start_restore(mut self, runtime: &TerminalRuntime, now: Instant) -> PollOutcome { + if self.upward_events == 0 { + return self.complete_fallback(); + } + if send_wheel( + runtime, + MouseEventKind::ScrollDown, + self.upward_events, + &self.previous, + ) + .is_err() + { + return self.complete_fallback(); + } + self.phase = Phase::Restore { stable_checks: 0 }; + self.restore_started_at = Some(now); + self.next_poll_at = now + STEP_SETTLE; + Some(self) + } + + fn complete_success(mut self) -> PollOutcome { + debug!( + terminal_id = %self.terminal_id, + retained_rows = self.history.len(), + requested_rows = self.lines, + reached_top = self.reached_top, + "alternate-screen read completed" + ); + let truncated = !self.reached_top || self.history.len() > self.lines; + let snapshot = + crate::terminal::snapshot_text(&self.history, self.lines, self.unwrap, truncated); + self.read.text = snapshot.text; + self.read.truncated = snapshot.truncated; + let response = serde_json::to_string(&SuccessResponse { + id: self.request_id, + result: ResponseResult::PaneRead { read: self.read }, + }) + .unwrap_or(self.fallback_response); + let _ = self.respond_to.send(response); + None + } + + fn complete_fallback(self) -> PollOutcome { + debug!( + terminal_id = %self.terminal_id, + ?self.phase, + retained_rows = self.history.len(), + upward_events = self.upward_events, + valid = self.valid, + "alternate-screen read fell back to passive snapshot" + ); + let _ = self.respond_to.send(self.fallback_response); + None + } +} + +pub(crate) type PollOutcome = Option<PendingAltScreenRead>; + +fn restore_batch_size(snapshot: &ScreenSnapshot) -> usize { + snapshot.rows.len().saturating_div(2).max(1) +} + +fn send_wheel( + runtime: &TerminalRuntime, + kind: MouseEventKind, + events: usize, + snapshot: &ScreenSnapshot, +) -> Result<(), ()> { + if runtime.wheel_routing() != Some(crate::pane::WheelRouting::MouseReport) { + return Err(()); + } + let column = snapshot.cols.saturating_sub(1) / 2; + let row = u16::try_from(snapshot.rows.len().saturating_sub(1) / 2).unwrap_or(0); + let event = runtime + .encode_mouse_wheel(kind, column, row, KeyModifiers::empty()) + .ok_or(())?; + let mut bytes = Vec::with_capacity(event.len().saturating_mul(events)); + for _ in 0..events { + bytes.extend_from_slice(&event); + } + runtime.try_send_bytes(Bytes::from(bytes)).map_err(|_| ()) +} diff --git a/src/server/headless.rs b/src/server/headless.rs index 343493cd..6fdb83ce 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -275,6 +275,19 @@ const CLIENT_ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(250); // Headless server // --------------------------------------------------------------------------- +struct AltScreenReadSpec { + terminal_id: crate::terminal::TerminalId, + lines: usize, + unwrap: bool, + initial: crate::terminal::ScreenSnapshot, +} + +enum AltScreenReadConflict { + None, + Frozen(crate::pane::TerminalReadSnapshot), + Defer, +} + /// The headless server — runs the herdr event loop without a real terminal. pub struct HeadlessServer { app: app::App, @@ -300,6 +313,10 @@ pub struct HeadlessServer { server_config_diagnostic_without_keybindings: Option<String>, /// Writable direct attach owner per terminal id string. terminal_attach_owners: HashMap<String, u64>, + /// Deferred application-history reads currently driving alternate-screen viewports. + pending_alt_screen_reads: Vec<crate::server::alt_screen_read::PendingAltScreenRead>, + /// Reads waiting for an alternate-screen traversal of the same terminal to finish. + deferred_alt_screen_reads: Vec<api::ApiRequestMessage>, /// Monotonic activity counter used to pick the most recently active client. next_activity_stamp: u64, /// Shared pane runtime size derived from the foreground client, @@ -492,6 +509,8 @@ impl HeadlessServer { server_config_diagnostic, server_config_diagnostic_without_keybindings, terminal_attach_owners: HashMap::new(), + pending_alt_screen_reads: Vec::new(), + deferred_alt_screen_reads: Vec::new(), next_activity_stamp: 1, effective_size: (MIN_COLS, MIN_ROWS), shutting_down: false, @@ -638,6 +657,13 @@ impl HeadlessServer { needs_graphics_render = false; } + self.poll_pending_alt_screen_reads(now); + if self.process_deferred_alt_screen_reads() { + needs_render = true; + needs_full_render = true; + needs_graphics_render = false; + } + if latest_app_client(&self.clients).is_some() && self.app.ensure_default_workspace() { needs_render = true; needs_full_render = true; @@ -728,6 +754,13 @@ impl HeadlessServer { ) .map(|deadline| deadline.min(now + CLIENT_ACCEPT_POLL_INTERVAL)) .or(Some(now + CLIENT_ACCEPT_POLL_INTERVAL)); + let next_deadline = self + .pending_alt_screen_reads + .iter() + .map(|pending| pending.next_deadline()) + .fold(next_deadline, |deadline, pending| { + Some(deadline.map_or(pending, |current| current.min(pending))) + }); let event = { tokio::select! { maybe_api = self.app.api_rx.recv() => match maybe_api { @@ -2563,6 +2596,23 @@ impl HeadlessServer { return false; }; + if self + .pending_alt_screen_reads + .iter() + .any(|pending| pending.terminal_id == real_terminal_id) + { + self.send_to_client( + client_id, + ServerMessage::ServerShutdown { + reason: Some(format!( + "terminal attach failed: terminal {terminal_id} has a read in progress; retry" + )), + }, + ); + self.remove_client_and_resize_if_needed(client_id); + return false; + } + if let Some(existing_owner) = self.terminal_attach_owners.get(&terminal_id).copied() { if existing_owner != client_id && !takeover { self.send_to_client( @@ -3021,6 +3071,188 @@ impl HeadlessServer { ) } + fn agent_read_not_idle_error( + &self, + request: &api::schema::Request, + ) -> Option<api::schema::ErrorBody> { + use api::schema::{Method, ReadFormat, ReadSource}; + + let Method::AgentRead(params) = &request.method else { + return None; + }; + let requested = params.lines?; + if params.format != ReadFormat::Text + || !matches!( + params.source, + ReadSource::Recent | ReadSource::RecentUnwrapped + ) + { + return None; + } + let target = self.app.resolve_agent_target(¶ms.target).ok()?; + let terminal = self + .app + .state + .terminals + .values() + .find(|terminal| terminal.id.as_str() == target.terminal_id)?; + if terminal.effective_known_agent().is_none() + || terminal.state == crate::detect::AgentState::Idle + { + return None; + } + let runtime = self.app.terminal_runtimes.get(&terminal.id)?; + let (screen, snapshot) = runtime.screen_text_snapshot()?; + if screen != crate::ghostty::ActiveScreen::Alternate + || snapshot.rows.len() >= requested.min(1000) as usize + { + return None; + } + let status = crate::detect::manifest::agent_state_label(terminal.state); + Some(api::schema::ErrorBody { + code: "agent_not_idle".into(), + message: format!( + "cannot read {requested} lines while {} is {status}: its alternate-screen history can only be captured by scrolling while idle. Wait and retry, or use --source visible", + params.target + ), + }) + } + + fn alt_screen_read_spec(&self, request: &api::schema::Request) -> Option<AltScreenReadSpec> { + use api::schema::{Method, ReadFormat, ReadIntent, ReadSource}; + + let (target, source, lines, format) = match &request.method { + Method::AgentRead(params) => ( + self.app.resolve_agent_target(¶ms.target).ok()?, + params.source, + params.lines, + params.format, + ), + Method::PaneRead(params) if params.intent == ReadIntent::Interactive => ( + self.app.resolve_terminal_target(¶ms.pane_id).ok()?, + params.source, + params.lines, + params.format, + ), + _ => return None, + }; + if format != ReadFormat::Text + || !matches!(source, ReadSource::Recent | ReadSource::RecentUnwrapped) + { + return None; + } + let lines = lines.unwrap_or(80).min(1000) as usize; + if lines == 0 + || self + .terminal_attach_owners + .contains_key(target.terminal_id.as_str()) + || self + .pending_alt_screen_reads + .iter() + .any(|pending| pending.terminal_id.as_str() == target.terminal_id) + { + return None; + } + let terminal = self + .app + .state + .terminals + .values() + .find(|terminal| terminal.id.as_str() == target.terminal_id)?; + if terminal.effective_known_agent().is_none() + || terminal.state != crate::detect::AgentState::Idle + { + return None; + } + let runtime = self.app.terminal_runtimes.get(&terminal.id)?; + if runtime.wheel_routing() != Some(crate::pane::WheelRouting::MouseReport) { + return None; + } + let (screen, initial) = runtime.screen_text_snapshot()?; + if screen != crate::ghostty::ActiveScreen::Alternate || initial.rows.len() >= lines { + return None; + } + Some(AltScreenReadSpec { + terminal_id: terminal.id.clone(), + lines, + unwrap: source == ReadSource::RecentUnwrapped, + initial, + }) + } + + fn poll_pending_alt_screen_reads(&mut self, now: Instant) { + let pending = std::mem::take(&mut self.pending_alt_screen_reads); + for read in pending { + let runtime = self.app.terminal_runtimes.get(&read.terminal_id); + let remains_idle = self + .app + .state + .terminals + .get(&read.terminal_id) + .is_some_and(|terminal| terminal.state == crate::detect::AgentState::Idle); + let attached = self + .terminal_attach_owners + .contains_key(read.terminal_id.as_str()); + let outcome = if remains_idle && !attached { + read.poll(runtime, now) + } else { + read.abort(runtime, now) + }; + if let Some(read) = outcome { + self.pending_alt_screen_reads.push(read); + } + } + } + + fn alt_screen_read_conflict(&self, request: &api::schema::Request) -> AltScreenReadConflict { + let (target, source, lines, format) = match &request.method { + api::schema::Method::AgentRead(params) => ( + self.app.resolve_agent_target(¶ms.target).ok(), + params.source, + params.lines, + params.format, + ), + api::schema::Method::PaneRead(params) => ( + self.app.resolve_terminal_target(¶ms.pane_id).ok(), + params.source, + params.lines, + params.format, + ), + _ => return AltScreenReadConflict::None, + }; + let Some(target) = target else { + return AltScreenReadConflict::None; + }; + let Some(pending) = self + .pending_alt_screen_reads + .iter() + .find(|pending| pending.terminal_id.as_str() == target.terminal_id) + else { + return AltScreenReadConflict::None; + }; + if format == api::schema::ReadFormat::Text { + AltScreenReadConflict::Frozen(pending.frozen_snapshot(source, lines)) + } else { + AltScreenReadConflict::Defer + } + } + + fn process_deferred_alt_screen_reads(&mut self) -> bool { + let deferred = std::mem::take(&mut self.deferred_alt_screen_reads); + let mut changed = false; + for msg in deferred { + match self.alt_screen_read_conflict(&msg.request) { + AltScreenReadConflict::None => { + changed |= self.handle_api_request_with_shutdown_check(msg); + } + AltScreenReadConflict::Frozen(_) | AltScreenReadConflict::Defer => { + self.deferred_alt_screen_reads.push(msg); + } + } + } + changed + } + /// Drains API requests with shutdown awareness. /// /// During shutdown, remaining requests get a `server_unavailable` error. @@ -3089,6 +3321,15 @@ impl HeadlessServer { return false; } + let frozen_alt_screen_read = match self.alt_screen_read_conflict(&msg.request) { + AltScreenReadConflict::None => None, + AltScreenReadConflict::Frozen(snapshot) => Some(snapshot), + AltScreenReadConflict::Defer => { + self.deferred_alt_screen_reads.push(msg); + return false; + } + }; + let metadata_expired = self.app.expire_due_metadata(Instant::now()); if let api::schema::Method::ServerLiveHandoff(params) = &msg.request.method { @@ -3193,6 +3434,16 @@ impl HeadlessServer { }; self.sync_foreground_client_state(); + if let Some(error) = self.agent_read_not_idle_error(&msg.request) { + let response = serde_json::to_string(&api::schema::ErrorResponse { + id: msg.request.id.clone(), + error, + }) + .unwrap_or_else(|_| "{}".to_owned()); + let _ = msg.respond_to.send(response); + return changed; + } + let alt_screen_read_spec = self.alt_screen_read_spec(&msg.request); if matches!( &msg.request.method, api::schema::Method::WorktreeCreate(_) | api::schema::Method::WorktreeRemove(_) @@ -3202,7 +3453,7 @@ impl HeadlessServer { .handle_deferred_worktree_api_request(msg.request, msg.respond_to); return changed | deferred_changed; } - let response = if matches!( + let mut response = if matches!( &msg.request.method, api::schema::Method::ServerReloadConfig(_) ) { @@ -3228,6 +3479,37 @@ impl HeadlessServer { self.app .handle_api_request_after_internal_events_drained(msg.request) }; + if let Some(snapshot) = frozen_alt_screen_read { + if let Ok(mut success) = serde_json::from_str::<api::schema::SuccessResponse>(&response) + { + if let api::schema::ResponseResult::PaneRead { read } = &mut success.result { + read.text = snapshot.text; + read.truncated = snapshot.truncated; + if let Ok(serialized) = serde_json::to_string(&success) { + response = serialized; + } + } + } + } + if let Some(spec) = alt_screen_read_spec { + if let Ok(success) = serde_json::from_str::<api::schema::SuccessResponse>(&response) { + if let api::schema::ResponseResult::PaneRead { read } = success.result { + let pending = crate::server::alt_screen_read::PendingAltScreenRead::start( + spec.terminal_id, + success.id, + msg.respond_to, + response, + read, + spec.lines, + spec.unwrap, + spec.initial, + Instant::now(), + ); + self.pending_alt_screen_reads.push(pending); + return changed; + } + } + } let _ = msg.respond_to.send(response); if let Some(revision_before) = pane_graphics_revision_before { @@ -4709,6 +4991,8 @@ mod tests { server_config_diagnostic: None, server_config_diagnostic_without_keybindings: None, terminal_attach_owners: HashMap::new(), + pending_alt_screen_reads: Vec::new(), + deferred_alt_screen_reads: Vec::new(), next_activity_stamp: 1, effective_size: (MIN_COLS, MIN_ROWS), shutting_down: false, @@ -5406,6 +5690,64 @@ next_tab = "" } #[test] + fn explicit_agent_history_read_requires_idle_on_alternate_screen() { + with_terminal_session_test_server( + |server, terminal_id, _terminal_id_string, public_pane_id| { + let terminal = server + .app + .state + .terminals + .get_mut(&terminal_id) + .expect("terminal"); + terminal.detected_agent = Some(crate::detect::Agent::Claude); + terminal.state = crate::detect::AgentState::Working; + server.app.terminal_runtimes.insert( + terminal_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes( + 80, + 24, + b"\x1b[?1049hworking", + ), + ); + let request = api::schema::Request { + id: "read".into(), + method: api::schema::Method::AgentRead(api::schema::AgentReadParams { + target: public_pane_id.clone(), + source: api::schema::ReadSource::Recent, + lines: Some(200), + format: api::schema::ReadFormat::Text, + strip_ansi: true, + }), + }; + + assert_eq!( + server.agent_read_not_idle_error(&request), + Some(api::schema::ErrorBody { + code: "agent_not_idle".into(), + message: format!( + "cannot read 200 lines while {public_pane_id} is working: its alternate-screen history can only be captured by scrolling while idle. Wait and retry, or use --source visible" + ), + }) + ); + + let mut default_request = request.clone(); + let api::schema::Method::AgentRead(params) = &mut default_request.method else { + unreachable!(); + }; + params.lines = None; + assert_eq!(server.agent_read_not_idle_error(&default_request), None); + + let mut visible_request = request; + let api::schema::Method::AgentRead(params) = &mut visible_request.method else { + unreachable!(); + }; + params.source = api::schema::ReadSource::Visible; + assert_eq!(server.agent_read_not_idle_error(&visible_request), None); + }, + ); + } + + #[test] fn terminal_observe_allows_multiple_clients_without_attach_ownership() { with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { let initial_size = server @@ -5498,6 +5840,58 @@ next_tab = "" } #[test] + fn terminal_control_rejects_attach_during_alt_screen_read() { + with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| { + let (respond_to, _response_rx) = std::sync::mpsc::channel(); + server.pending_alt_screen_reads.push( + crate::server::alt_screen_read::PendingAltScreenRead::start( + terminal_id, + "read".into(), + respond_to, + "fallback".into(), + api::schema::PaneReadResult { + pane_id: "w1:p1".into(), + workspace_id: "w1".into(), + tab_id: "w1:t1".into(), + source: api::schema::ReadSource::Recent, + format: api::schema::ReadFormat::Text, + text: String::new(), + revision: 0, + truncated: false, + }, + 120, + false, + crate::terminal::ScreenSnapshot { + cols: 80, + rows: Vec::new(), + }, + Instant::now(), + ), + ); + let control_rx = connect_pending_terminal_client_with_control_rx(server, 7); + + assert!( + !server.handle_server_event(ServerEvent::ClientControlTerminal { + client_id: 7, + target: terminal_id_string.clone(), + takeover: false, + }) + ); + assert!(!server.clients.contains_key(&7)); + assert!(!server + .terminal_attach_owners + .contains_key(&terminal_id_string)); + let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); + assert_eq!( + reason, + Some(format!( + "terminal attach failed: terminal {terminal_id_string} has a read in progress; retry" + )) + ); + }); + } + + #[test] fn terminal_control_rejects_second_controller_without_takeover() { with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| { connect_pending_terminal_client(server, 7); diff --git a/src/server/mod.rs b/src/server/mod.rs index 58b552e2..8566b4bd 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,3 +1,4 @@ +mod alt_screen_read; pub mod autodetect; #[cfg(unix)] pub(crate) mod client_accept; |
