fix: read alternate-screen agent history

This commit is contained in:
Ogulcan Celik
2026-07-31 16:22:04 +03:00
parent 7db744ab47
commit bc1c052d15
20 changed files with 1167 additions and 14 deletions
+383
View File
@@ -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(|_| ())
}
+395 -1
View File
@@ -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,
@@ -5405,6 +5689,64 @@ next_tab = ""
control_rx
}
#[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, _| {
@@ -5497,6 +5839,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, _| {
+1
View File
@@ -1,3 +1,4 @@
mod alt_screen_read;
pub mod autodetect;
#[cfg(unix)]
pub(crate) mod client_accept;