diff options
Diffstat (limited to 'src/server/headless.rs')
| -rw-r--r-- | src/server/headless.rs | 396 |
1 files changed, 395 insertions, 1 deletions
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); |
