aboutsummaryrefslogtreecommitdiff
path: root/src/server/headless.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/server/headless.rs')
-rw-r--r--src/server/headless.rs396
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(&params.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(&params.target).ok()?,
+ params.source,
+ params.lines,
+ params.format,
+ ),
+ Method::PaneRead(params) if params.intent == ReadIntent::Interactive => (
+ self.app.resolve_terminal_target(&params.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(&params.target).ok(),
+ params.source,
+ params.lines,
+ params.format,
+ ),
+ api::schema::Method::PaneRead(params) => (
+ self.app.resolve_terminal_target(&params.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);