855 lines
32 KiB
Rust
855 lines
32 KiB
Rust
//! Terminal process/session registry.
|
|
|
|
use std::cell::RefCell;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::path::{Path, PathBuf};
|
|
use std::rc::Rc;
|
|
use std::time::Instant;
|
|
|
|
use thiserror::Error;
|
|
|
|
use crate::ansi::AnsiParserProfile;
|
|
use crate::buffer::{Buffer, BufferId};
|
|
use crate::cell::{Cell, CellCoord, CellSize};
|
|
use crate::editor_core::EditorCore;
|
|
use crate::key::{Chord, parse_chord};
|
|
use crate::process::{
|
|
ProcessEventKind, ProcessId, ProcessMode, ProcessSpec, ProcessState, ProcessSupervisor,
|
|
RestartPolicy, StdinMode, TerminalMode,
|
|
};
|
|
use crate::terminal::screen::TerminalScreen;
|
|
use crate::terminal::view::{TerminalController, TerminalViewKey, TerminalViewState};
|
|
use crate::terminal::{
|
|
MAX_TERMINAL_COLS, MAX_TERMINAL_HISTORY_CELLS, MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS,
|
|
MAX_TERMINAL_VISIBLE_CELLS, TerminalFrame, TerminalProcessState, TerminalSelectionSpan,
|
|
};
|
|
|
|
/// Shared single-owner terminal registry used by editor and future Lua bindings.
|
|
pub type SharedTerminalManager = Rc<RefCell<TerminalManager>>;
|
|
|
|
/// Complete owned description of a terminal child and its initial screen.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct TerminalSpec {
|
|
/// Executable path or name resolved through `PATH`.
|
|
pub command: String,
|
|
/// Child arguments, excluding argv[0].
|
|
pub args: Vec<String>,
|
|
/// Working directory, or the editor process directory when absent.
|
|
pub cwd: Option<PathBuf>,
|
|
/// Environment overrides inherited by the child. `TERM` defaults to
|
|
/// `xterm-256color` when the caller does not provide it.
|
|
pub env: Vec<(String, String)>,
|
|
/// Identity-buffer name. Defaults to `*terminal:<command>*`.
|
|
pub name: Option<String>,
|
|
/// Initial terminal rows.
|
|
pub rows: u16,
|
|
/// Initial terminal columns.
|
|
pub cols: u16,
|
|
/// Retained main-screen scrollback row cap.
|
|
pub scrollback_rows: usize,
|
|
}
|
|
|
|
impl TerminalSpec {
|
|
/// Construct a conventional 24x80 terminal specification.
|
|
#[must_use]
|
|
pub fn new(command: impl Into<String>) -> Self {
|
|
Self {
|
|
command: command.into(),
|
|
args: Vec::new(),
|
|
cwd: None,
|
|
env: Vec::new(),
|
|
name: None,
|
|
rows: 24,
|
|
cols: 80,
|
|
scrollback_rows: crate::terminal::DEFAULT_TERMINAL_SCROLLBACK_ROWS,
|
|
}
|
|
}
|
|
|
|
/// Validate every raw field before any buffer or process is created.
|
|
pub fn validate(&self) -> Result<(), TerminalError> {
|
|
if self.command.is_empty() {
|
|
return Err(TerminalError::InvalidSpec(
|
|
"command must not be empty".into(),
|
|
));
|
|
}
|
|
reject_nul("command", self.command.as_bytes())?;
|
|
for arg in &self.args {
|
|
reject_nul("argument", arg.as_bytes())?;
|
|
}
|
|
if let Some(cwd) = &self.cwd {
|
|
if cwd.as_os_str().is_empty() {
|
|
return Err(TerminalError::InvalidSpec(
|
|
"cwd must not be an empty path".into(),
|
|
));
|
|
}
|
|
reject_nul("cwd", cwd.as_os_str().as_encoded_bytes())?;
|
|
}
|
|
let mut env_names = HashSet::with_capacity(self.env.len());
|
|
for (name, value) in &self.env {
|
|
if name.is_empty() || name.contains('=') {
|
|
return Err(TerminalError::InvalidSpec(format!(
|
|
"environment name {name:?} must be non-empty and contain no '='"
|
|
)));
|
|
}
|
|
reject_nul("environment name", name.as_bytes())?;
|
|
reject_nul("environment value", value.as_bytes())?;
|
|
if !env_names.insert(name) {
|
|
return Err(TerminalError::InvalidSpec(format!(
|
|
"duplicate environment name {name:?}"
|
|
)));
|
|
}
|
|
}
|
|
if let Some(name) = &self.name {
|
|
if name.is_empty() {
|
|
return Err(TerminalError::InvalidSpec(
|
|
"buffer name must not be empty".into(),
|
|
));
|
|
}
|
|
reject_nul("buffer name", name.as_bytes())?;
|
|
if name.contains(['\r', '\n']) {
|
|
return Err(TerminalError::InvalidSpec(
|
|
"buffer name must fit on one line".into(),
|
|
));
|
|
}
|
|
}
|
|
validate_size(self.rows, self.cols)?;
|
|
if self.scrollback_rows > MAX_TERMINAL_HISTORY_CELLS {
|
|
return Err(TerminalError::InvalidSpec(format!(
|
|
"scrollback row cap {} exceeds terminal history cell budget {}",
|
|
self.scrollback_rows, MAX_TERMINAL_HISTORY_CELLS
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn buffer_name(&self) -> String {
|
|
self.name.clone().unwrap_or_else(|| {
|
|
let command = Path::new(&self.command)
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.filter(|name| !name.is_empty())
|
|
.unwrap_or(self.command.as_str());
|
|
format!("*terminal:{command}*")
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Owned, renderer-safe terminal state captured after a manager tick.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct TerminalSnapshot {
|
|
/// Identity buffer backing this terminal.
|
|
pub buffer_id: BufferId,
|
|
/// Visible grid dimensions.
|
|
pub size: CellSize,
|
|
/// Row-major visible cells.
|
|
pub cells: Vec<Cell>,
|
|
/// Visible child cursor, if enabled.
|
|
pub cursor: Option<CellCoord>,
|
|
/// Sanitized child title.
|
|
pub title: Option<String>,
|
|
/// Published screen generation.
|
|
pub screen_generation: u64,
|
|
/// Context selection. Empty in context-free Stage 1 snapshots.
|
|
pub selection: Vec<TerminalSelectionSpan>,
|
|
/// Context scrollback offset. Zero in Stage 1 snapshots.
|
|
pub scroll_offset: u32,
|
|
/// Whether this context follows the bottom. Always true in Stage 1.
|
|
pub at_bottom: bool,
|
|
/// Exact operating-system process id for this session generation.
|
|
pub pid: u32,
|
|
/// Latest observed process state.
|
|
pub process: TerminalProcessState,
|
|
}
|
|
|
|
impl TerminalSnapshot {
|
|
/// Convert an owned snapshot into its protocol-v19 wire form.
|
|
///
|
|
/// The two shapes are deliberately distinct types even though their
|
|
/// fields line up: the snapshot is core-owned state the TUI also
|
|
/// consumes, while [`TerminalFrame`] is wire input a peer may forge.
|
|
/// The conversion is total, but the CALLER must still
|
|
/// [`TerminalFrame::validate`] before emitting — the aggregate glyph
|
|
/// bound is a wire limit the screen does not enforce, so a child
|
|
/// that builds a legal-but-huge internal snapshot must be caught
|
|
/// here rather than sent truncated.
|
|
#[must_use]
|
|
pub fn into_terminal_frame(self) -> TerminalFrame {
|
|
TerminalFrame {
|
|
buffer_id: self.buffer_id,
|
|
size: self.size,
|
|
cells: self.cells,
|
|
cursor: self.cursor,
|
|
title: self.title,
|
|
screen_generation: self.screen_generation,
|
|
selection: self.selection,
|
|
scroll_offset: self.scroll_offset,
|
|
at_bottom: self.at_bottom,
|
|
pid: self.pid,
|
|
process: self.process,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Terminal session/registry failures.
|
|
#[derive(Debug, Error)]
|
|
pub enum TerminalError {
|
|
/// Specification validation failed before creation began.
|
|
#[error("invalid terminal specification: {0}")]
|
|
InvalidSpec(String),
|
|
/// The synchronous PTY spawn failed; no session is published.
|
|
#[error("terminal spawn failed: {0}")]
|
|
Spawn(String),
|
|
/// Buffer registry work failed during transactional creation.
|
|
#[error("terminal buffer operation failed: {0}")]
|
|
Buffer(String),
|
|
/// Screen construction or resize failed.
|
|
#[error("terminal screen operation failed: {0}")]
|
|
Screen(String),
|
|
/// No session owns the requested identity buffer.
|
|
#[error("buffer {0:?} is not a terminal")]
|
|
NotTerminal(BufferId),
|
|
/// Process I/O, resize, signal, or cleanup failed.
|
|
#[error("terminal process operation failed: {0}")]
|
|
Process(String),
|
|
}
|
|
|
|
pub(super) struct TerminalSession {
|
|
pub(super) process_id: ProcessId,
|
|
pub(super) pid: u32,
|
|
pub(super) screen: TerminalScreen,
|
|
pub(super) process: TerminalProcessState,
|
|
pub(super) annotated: bool,
|
|
/// Resolved `terminal.escape-key` for this terminal (Q#TC4c).
|
|
///
|
|
/// The cache lives HERE, not in an editor-side map, because a
|
|
/// session is created in [`TerminalManager::open`] and dropped on
|
|
/// kill/prune — so its lifetime is exactly the cache's, with no
|
|
/// purge hook to forget. An editor-side map would leak an entry per
|
|
/// terminal; a single last-entry cache would reparse (and re-report
|
|
/// an invalid value) every time focus alternates between two
|
|
/// terminals.
|
|
pub(super) escape: Option<EscapeCache>,
|
|
}
|
|
|
|
/// One terminal's parsed escape chord, valid for one config epoch.
|
|
pub(super) struct EscapeCache {
|
|
/// The `ConfigRegistry::value_epoch` this was parsed at. The key is
|
|
/// `(this session, epoch)`: the epoch alone is not enough, because
|
|
/// it does not advance when focus moves between terminals with
|
|
/// different buffer-local values.
|
|
pub(super) epoch: u64,
|
|
/// The effective chord — the parsed spelling, or the `C-c` fallback.
|
|
pub(super) chord: Chord,
|
|
/// The invalid spelling already reported for this terminal, if any.
|
|
/// Reporting is once per terminal per effective invalid value: an
|
|
/// unchanged bad value stays quiet, a *different* bad value reports
|
|
/// again because it is a new mistake.
|
|
pub(super) reported_invalid: Option<String>,
|
|
}
|
|
|
|
/// Owns the one-buffer/one-process/one-screen terminal registry.
|
|
#[derive(Default)]
|
|
pub struct TerminalManager {
|
|
pub(super) sessions: HashMap<BufferId, TerminalSession>,
|
|
/// Total escape-key parses performed (Q#TC4c observability).
|
|
escape_parses: u64,
|
|
process_to_buffer: HashMap<ProcessId, BufferId>,
|
|
/// Removed buffers whose children are still being reaped. Their events
|
|
/// remain manager-owned so Lua/LSP/MCP consumers cannot steal a batch.
|
|
closing: HashSet<ProcessId>,
|
|
/// Per-frontend/window projections over the one session screen.
|
|
pub(super) views: HashMap<TerminalViewKey, TerminalViewState>,
|
|
/// At most one authenticated frontend/window controls each session PTY.
|
|
pub(super) controllers: HashMap<BufferId, TerminalController>,
|
|
}
|
|
|
|
impl TerminalManager {
|
|
/// Construct an empty manager.
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Number of published terminal sessions.
|
|
#[must_use]
|
|
pub fn len(&self) -> usize {
|
|
self.sessions.len()
|
|
}
|
|
|
|
/// Whether no terminal session is currently published.
|
|
#[must_use]
|
|
pub fn is_empty(&self) -> bool {
|
|
self.sessions.is_empty()
|
|
}
|
|
|
|
/// Transactionally create an internal terminal identity, PTY, and screen.
|
|
pub fn open(
|
|
&mut self,
|
|
spec: TerminalSpec,
|
|
core: &mut EditorCore,
|
|
supervisor: &mut ProcessSupervisor,
|
|
) -> Result<BufferId, TerminalError> {
|
|
spec.validate()?;
|
|
let size = CellSize::new(u32::from(spec.rows), u32::from(spec.cols));
|
|
let screen = TerminalScreen::new(size, spec.scrollback_rows)
|
|
.map_err(|error| TerminalError::Screen(error.to_string()))?;
|
|
|
|
let base_name = spec.buffer_name();
|
|
let buffer_name = if spec.name.is_some() {
|
|
base_name
|
|
} else {
|
|
unique_terminal_name(core, &base_name)
|
|
};
|
|
let buffer_id = BufferId::next();
|
|
let mut buffer = Buffer::new(buffer_id, buffer_name.clone());
|
|
buffer.set_read_only(true);
|
|
core.registry.borrow_mut().insert(buffer);
|
|
|
|
let mut process_spec = ProcessSpec::new(buffer_name, spec.command);
|
|
process_spec.args = spec.args;
|
|
process_spec.cwd = spec.cwd;
|
|
process_spec.env = spec.env;
|
|
if !process_spec.env.iter().any(|(name, _)| name == "TERM") {
|
|
process_spec
|
|
.env
|
|
.push(("TERM".into(), "xterm-256color".into()));
|
|
}
|
|
process_spec.mode = ProcessMode::Pty {
|
|
rows: spec.rows,
|
|
cols: spec.cols,
|
|
mode: TerminalMode::Raw,
|
|
};
|
|
process_spec.restart = RestartPolicy::Never;
|
|
process_spec.ansi_events = true;
|
|
process_spec.ansi_profile = AnsiParserProfile::FullScreen;
|
|
process_spec.stdin = StdinMode::Piped;
|
|
process_spec.group = false;
|
|
|
|
let process_id = match supervisor.spawn_terminal(process_spec) {
|
|
Ok(id) => id,
|
|
Err(error) => {
|
|
core.registry
|
|
.borrow_mut()
|
|
.remove(buffer_id)
|
|
.map_err(|rollback| {
|
|
TerminalError::Buffer(format!(
|
|
"spawn failed ({error}); buffer rollback failed: {rollback}"
|
|
))
|
|
})?;
|
|
return Err(TerminalError::Spawn(error));
|
|
}
|
|
};
|
|
let pid =
|
|
if let Some(ProcessState::Running { pid, .. } | ProcessState::Exiting { pid, .. }) =
|
|
supervisor.state(process_id)
|
|
{
|
|
*pid
|
|
} else {
|
|
let _ = supervisor.terminate(process_id);
|
|
let _ = core.registry.borrow_mut().remove(buffer_id);
|
|
return Err(TerminalError::Spawn(
|
|
"supervisor published a PTY without a running pid".into(),
|
|
));
|
|
};
|
|
|
|
let previous = self.sessions.insert(
|
|
buffer_id,
|
|
TerminalSession {
|
|
process_id,
|
|
pid,
|
|
screen,
|
|
process: TerminalProcessState::Running,
|
|
annotated: false,
|
|
escape: None,
|
|
},
|
|
);
|
|
debug_assert!(previous.is_none(), "fresh BufferId collided");
|
|
self.process_to_buffer.insert(process_id, buffer_id);
|
|
core.set_round_trip_input(buffer_id, true);
|
|
Ok(buffer_id)
|
|
}
|
|
|
|
/// Whether `buffer_id` identifies a published terminal session.
|
|
#[must_use]
|
|
pub fn is_terminal(&self, buffer_id: BufferId) -> bool {
|
|
self.sessions.contains_key(&buffer_id)
|
|
}
|
|
|
|
/// Owned process id for a terminal buffer. The OS pid stays in snapshots.
|
|
#[must_use]
|
|
pub fn process_id(&self, buffer_id: BufferId) -> Option<ProcessId> {
|
|
self.sessions
|
|
.get(&buffer_id)
|
|
.map(|session| session.process_id)
|
|
}
|
|
|
|
/// Monotonic terminal BEL count used for per-frontend delivery baselines.
|
|
#[must_use]
|
|
pub fn bell_count(&self, buffer_id: BufferId) -> Option<u64> {
|
|
self.sessions
|
|
.get(&buffer_id)
|
|
.map(|session| session.screen.bell_count())
|
|
}
|
|
|
|
/// Ensure an exact terminal view exists without changing its controller.
|
|
///
|
|
/// Returns `false` when the key's buffer is not a published terminal.
|
|
pub fn register_view(&mut self, key: TerminalViewKey) -> bool {
|
|
if !self.sessions.contains_key(&key.buffer_id) {
|
|
return false;
|
|
}
|
|
self.views.entry(key).or_default();
|
|
true
|
|
}
|
|
|
|
/// Borrow fresh mutable state for an already registered exact view.
|
|
pub fn view_state_mut(&mut self, key: TerminalViewKey) -> Option<&mut TerminalViewState> {
|
|
self.views.get_mut(&key)
|
|
}
|
|
|
|
/// Borrow fresh state for an already registered exact view.
|
|
#[must_use]
|
|
pub fn view_state(&self, key: TerminalViewKey) -> Option<&TerminalViewState> {
|
|
self.views.get(&key)
|
|
}
|
|
|
|
/// Retain only `live` views belonging to one authenticated frontend.
|
|
pub fn retain_frontend_views(
|
|
&mut self,
|
|
frontend_id: crate::protocol::FrontendId,
|
|
live: &HashSet<TerminalViewKey>,
|
|
) {
|
|
self.views.retain(|key, _| {
|
|
key.frontend_id != frontend_id
|
|
|| (live.contains(key) && self.sessions.contains_key(&key.buffer_id))
|
|
});
|
|
self.controllers.retain(|buffer_id, controller| {
|
|
controller.frontend_id != frontend_id
|
|
|| live.contains(&TerminalViewKey::new(
|
|
frontend_id,
|
|
controller.window_id,
|
|
*buffer_id,
|
|
))
|
|
});
|
|
}
|
|
|
|
/// Drop all view and controller state owned by a detached frontend.
|
|
pub fn detach_frontend(&mut self, frontend_id: crate::protocol::FrontendId) {
|
|
self.views.retain(|key, _| key.frontend_id != frontend_id);
|
|
self.controllers
|
|
.retain(|_, controller| controller.frontend_id != frontend_id);
|
|
}
|
|
|
|
/// Give an exact registered view durable PTY control for its session.
|
|
///
|
|
/// A frontend controls at most one session. Claiming another registered
|
|
/// view atomically releases that frontend's previous session first.
|
|
pub fn claim_controller(&mut self, key: TerminalViewKey) -> bool {
|
|
if !self.views.contains_key(&key) || !self.sessions.contains_key(&key.buffer_id) {
|
|
return false;
|
|
}
|
|
self.controllers.retain(|buffer_id, controller| {
|
|
controller.frontend_id != key.frontend_id || *buffer_id == key.buffer_id
|
|
});
|
|
self.controllers
|
|
.insert(key.buffer_id, TerminalController::from_view(key));
|
|
true
|
|
}
|
|
|
|
/// Release control only when `key` is the current controller.
|
|
pub fn release_controller(&mut self, key: TerminalViewKey) -> bool {
|
|
if self
|
|
.controllers
|
|
.get(&key.buffer_id)
|
|
.is_some_and(|controller| controller.matches(key))
|
|
{
|
|
self.controllers.remove(&key.buffer_id);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Current durable controller for one terminal session.
|
|
#[must_use]
|
|
pub fn controller(&self, buffer_id: BufferId) -> Option<TerminalController> {
|
|
self.controllers.get(&buffer_id).copied()
|
|
}
|
|
|
|
/// Capture context-free owned visible state after the latest tick.
|
|
#[must_use]
|
|
pub fn snapshot(&self, buffer_id: BufferId) -> Option<TerminalSnapshot> {
|
|
let session = self.sessions.get(&buffer_id)?;
|
|
let screen = session.screen.snapshot();
|
|
Some(TerminalSnapshot {
|
|
buffer_id,
|
|
size: screen.size,
|
|
cells: screen.cells,
|
|
cursor: screen.cursor,
|
|
title: screen.title.map(|title| sanitize_metadata(&title)),
|
|
screen_generation: screen.generation,
|
|
selection: Vec::new(),
|
|
scroll_offset: 0,
|
|
at_bottom: true,
|
|
pid: session.pid,
|
|
process: session.process.clone(),
|
|
})
|
|
}
|
|
|
|
/// Drain only terminal-owned process IDs after the supervisor tick.
|
|
pub fn tick(&mut self, supervisor: &mut ProcessSupervisor) {
|
|
let process_ids: Vec<ProcessId> = self.process_to_buffer.keys().copied().collect();
|
|
for process_id in process_ids {
|
|
let Some(buffer_id) = self.process_to_buffer.get(&process_id).copied() else {
|
|
continue;
|
|
};
|
|
let events = supervisor.take_events(process_id);
|
|
let Some(session) = self.sessions.get_mut(&buffer_id) else {
|
|
continue;
|
|
};
|
|
let mut outcome = None;
|
|
for event in events {
|
|
match event.kind {
|
|
ProcessEventKind::Started { pid } => session.pid = pid,
|
|
ProcessEventKind::Ansi(events) => {
|
|
for event in events {
|
|
if let Some(response) = session.screen.apply_event(event) {
|
|
let _ = supervisor.write_stdin(process_id, &response);
|
|
}
|
|
}
|
|
}
|
|
ProcessEventKind::Exited { code } => {
|
|
outcome = Some(TerminalProcessState::Exited(code));
|
|
}
|
|
ProcessEventKind::Signaled { signal } => {
|
|
outcome = Some(TerminalProcessState::Signaled(sanitize_metadata(&signal)));
|
|
}
|
|
ProcessEventKind::Crashed { error } => {
|
|
outcome = Some(TerminalProcessState::Crashed(sanitize_metadata(&error)));
|
|
}
|
|
ProcessEventKind::Stdout(_)
|
|
| ProcessEventKind::Stderr(_)
|
|
| ProcessEventKind::Restarting { .. } => {}
|
|
}
|
|
}
|
|
let _ = session.screen.synchronized_watchdog_expired(Instant::now());
|
|
if let Some(outcome) = outcome {
|
|
finish_session(session, outcome);
|
|
}
|
|
}
|
|
|
|
let closing: Vec<ProcessId> = self.closing.iter().copied().collect();
|
|
for process_id in closing {
|
|
// Continue owning and discarding every final batch until reaped.
|
|
let _ = supervisor.take_events(process_id);
|
|
if matches!(
|
|
supervisor.state(process_id),
|
|
Some(ProcessState::Terminated(_)) | None
|
|
) {
|
|
let _ = supervisor.forget(process_id);
|
|
self.closing.remove(&process_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Queue raw terminal input for a running child.
|
|
pub fn send(
|
|
&self,
|
|
buffer_id: BufferId,
|
|
bytes: &[u8],
|
|
supervisor: &mut ProcessSupervisor,
|
|
) -> Result<(), TerminalError> {
|
|
let session = self
|
|
.sessions
|
|
.get(&buffer_id)
|
|
.ok_or(TerminalError::NotTerminal(buffer_id))?;
|
|
supervisor
|
|
.write_stdin(session.process_id, bytes)
|
|
.map_err(TerminalError::Process)
|
|
}
|
|
|
|
/// Resolve this terminal's effective escape chord, parsing at most
|
|
/// once per `(terminal, config epoch)` (Q#TC4c).
|
|
///
|
|
/// `spelling` is the caller-resolved `terminal.escape-key` value and
|
|
/// `epoch` the registry's `value_epoch()` it was read at. Returns the
|
|
/// effective chord plus, at most once per terminal per effective
|
|
/// invalid value, a message the caller should surface.
|
|
///
|
|
/// An unparseable spelling falls back to `C-c` rather than leaving the
|
|
/// terminal with no escape at all (Q#TC4a): without one, every key goes
|
|
/// to the child and the user cannot reach the binding that would fix
|
|
/// the setting that broke it.
|
|
pub fn escape_chord(
|
|
&mut self,
|
|
buffer_id: BufferId,
|
|
epoch: u64,
|
|
spelling: &str,
|
|
) -> (Chord, Option<String>) {
|
|
let fallback = default_escape_chord();
|
|
if let Some(session) = self.sessions.get(&buffer_id)
|
|
&& let Some(cache) = session.escape.as_ref()
|
|
&& cache.epoch == epoch
|
|
{
|
|
return (cache.chord, None);
|
|
}
|
|
self.escape_parses = self.escape_parses.saturating_add(1);
|
|
let Some(session) = self.sessions.get_mut(&buffer_id) else {
|
|
return (fallback, None);
|
|
};
|
|
let previously_reported = session
|
|
.escape
|
|
.as_ref()
|
|
.and_then(|cache| cache.reported_invalid.clone());
|
|
let (chord, reported_invalid, report) = match parse_chord(spelling) {
|
|
Ok(chord) => (chord, None, None),
|
|
Err(error) => {
|
|
let already = previously_reported.as_deref() == Some(spelling);
|
|
let message = (!already).then(|| {
|
|
format!(
|
|
"terminal.escape-key {spelling:?} is not a valid chord ({error}); using C-c"
|
|
)
|
|
});
|
|
(fallback, Some(spelling.to_owned()), message)
|
|
}
|
|
};
|
|
session.escape = Some(EscapeCache {
|
|
epoch,
|
|
chord,
|
|
reported_invalid,
|
|
});
|
|
(chord, report)
|
|
}
|
|
|
|
/// How many escape-key spellings this manager has parsed.
|
|
///
|
|
/// An observability seam for Q#TC4c's cache contract, which is
|
|
/// otherwise unpinnable for a VALID setting: a correct per-session
|
|
/// cache and a single last-entry cache produce identical behavior
|
|
/// there and differ only in how often they parse. Counting reports
|
|
/// covers the invalid case; this covers the valid one.
|
|
#[must_use]
|
|
pub fn escape_parses(&self) -> u64 {
|
|
self.escape_parses
|
|
}
|
|
|
|
/// How many terminals currently hold a cached escape chord.
|
|
///
|
|
/// The LIFETIME half of Q#TC4c's cache contract, which `escape_parses`
|
|
/// cannot cover: parse counting says a valid setting is read once, but
|
|
/// says nothing about whether the cache is ever released. Because the
|
|
/// cache lives on [`TerminalSession`], this count falls with the
|
|
/// session set by construction — which is exactly the property worth
|
|
/// pinning, since the rejected alternative (an editor-side
|
|
/// `HashMap<BufferId, EscapeCache>`) has no purge hook and would hold
|
|
/// this at its high-water mark while sessions drained.
|
|
#[must_use]
|
|
pub fn escape_caches(&self) -> usize {
|
|
self.sessions
|
|
.values()
|
|
.filter(|session| session.escape.is_some())
|
|
.count()
|
|
}
|
|
|
|
/// Resize a terminal screen and its PTY after validating shared limits.
|
|
pub fn resize(
|
|
&mut self,
|
|
buffer_id: BufferId,
|
|
rows: u16,
|
|
cols: u16,
|
|
supervisor: &mut ProcessSupervisor,
|
|
) -> Result<(), TerminalError> {
|
|
validate_size(rows, cols)?;
|
|
let session = self
|
|
.sessions
|
|
.get_mut(&buffer_id)
|
|
.ok_or(TerminalError::NotTerminal(buffer_id))?;
|
|
if matches!(session.process, TerminalProcessState::Running) {
|
|
supervisor
|
|
.resize_pty(session.process_id, rows, cols)
|
|
.map_err(TerminalError::Process)?;
|
|
}
|
|
session
|
|
.screen
|
|
.resize(CellSize::new(u32::from(rows), u32::from(cols)))
|
|
.map_err(|error| TerminalError::Screen(error.to_string()))
|
|
}
|
|
|
|
/// Request SIGTERM. Snapshot state stays `Running` until the outcome event.
|
|
pub fn terminate(
|
|
&mut self,
|
|
buffer_id: BufferId,
|
|
supervisor: &mut ProcessSupervisor,
|
|
) -> Result<(), TerminalError> {
|
|
let session = self
|
|
.sessions
|
|
.get(&buffer_id)
|
|
.ok_or(TerminalError::NotTerminal(buffer_id))?;
|
|
if matches!(session.process, TerminalProcessState::Running) {
|
|
supervisor
|
|
.terminate(session.process_id)
|
|
.map_err(TerminalError::Process)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Tear down sessions whose identity buffers were removed by any path.
|
|
pub fn prune(&mut self, core: &mut EditorCore, supervisor: &mut ProcessSupervisor) {
|
|
let removed: Vec<BufferId> = {
|
|
let registry = core.registry.borrow();
|
|
self.sessions
|
|
.keys()
|
|
.copied()
|
|
.filter(|buffer_id| !registry.contains(*buffer_id))
|
|
.collect()
|
|
};
|
|
for buffer_id in removed {
|
|
core.set_round_trip_input(buffer_id, false);
|
|
let Some(session) = self.sessions.remove(&buffer_id) else {
|
|
continue;
|
|
};
|
|
self.process_to_buffer.remove(&session.process_id);
|
|
self.views.retain(|key, _| key.buffer_id != buffer_id);
|
|
self.controllers.remove(&buffer_id);
|
|
match supervisor.state(session.process_id) {
|
|
Some(
|
|
ProcessState::Starting
|
|
| ProcessState::Running { .. }
|
|
| ProcessState::Exiting { .. },
|
|
) => {
|
|
let _ = supervisor.terminate(session.process_id);
|
|
self.closing.insert(session.process_id);
|
|
}
|
|
Some(ProcessState::Terminated(_)) => {
|
|
let _ = supervisor.take_events(session.process_id);
|
|
let _ = supervisor.forget(session.process_id);
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Terminate every terminal child and unpublish all sessions.
|
|
///
|
|
/// The editor follows this with the supervisor's bounded global shutdown,
|
|
/// which performs final TERM/KILL escalation for terminal and non-terminal
|
|
/// processes alike.
|
|
pub fn shutdown(&mut self, supervisor: &mut ProcessSupervisor) {
|
|
let process_ids: Vec<ProcessId> = self.process_to_buffer.keys().copied().collect();
|
|
for process_id in process_ids {
|
|
if matches!(
|
|
supervisor.state(process_id),
|
|
Some(
|
|
ProcessState::Running { .. }
|
|
| ProcessState::Exiting { .. }
|
|
| ProcessState::Starting
|
|
)
|
|
) {
|
|
let _ = supervisor.terminate(process_id);
|
|
}
|
|
self.closing.insert(process_id);
|
|
}
|
|
self.sessions.clear();
|
|
self.process_to_buffer.clear();
|
|
self.views.clear();
|
|
self.controllers.clear();
|
|
}
|
|
}
|
|
|
|
fn unique_terminal_name(core: &EditorCore, base: &str) -> String {
|
|
let registry = core.registry.borrow();
|
|
if registry.find_by_name(base).is_none() {
|
|
return base.to_owned();
|
|
}
|
|
for suffix in 2usize.. {
|
|
let candidate = format!("{base}<{suffix}>");
|
|
if registry.find_by_name(&candidate).is_none() {
|
|
return candidate;
|
|
}
|
|
}
|
|
unreachable!("unbounded terminal suffix search must find a free name")
|
|
}
|
|
|
|
fn finish_session(session: &mut TerminalSession, outcome: TerminalProcessState) {
|
|
if session.annotated {
|
|
session.process = outcome;
|
|
return;
|
|
}
|
|
session.screen.finish_output();
|
|
let annotation = match &outcome {
|
|
TerminalProcessState::Running => return,
|
|
TerminalProcessState::Exited(0) => {
|
|
format!("Process {} exited normally with code 0", session.pid)
|
|
}
|
|
TerminalProcessState::Exited(code) => {
|
|
format!("Process {} exited abnormally with code {code}", session.pid)
|
|
}
|
|
TerminalProcessState::Signaled(signal) => format!(
|
|
"Process {} exited abnormally with signal {signal}",
|
|
session.pid
|
|
),
|
|
TerminalProcessState::Crashed(error) => {
|
|
format!("Process {} crashed: {error}", session.pid)
|
|
}
|
|
};
|
|
session.screen.append_process_annotation(&annotation);
|
|
session.annotated = true;
|
|
// Publish exit metadata only after final bytes and annotation are applied.
|
|
session.process = outcome;
|
|
}
|
|
|
|
fn validate_size(rows: u16, cols: u16) -> Result<(), TerminalError> {
|
|
let cells = usize::from(rows) * usize::from(cols);
|
|
if rows == 0 || rows > MAX_TERMINAL_ROWS {
|
|
return Err(TerminalError::InvalidSpec(format!(
|
|
"rows must be in 1..={MAX_TERMINAL_ROWS}; got {rows}"
|
|
)));
|
|
}
|
|
if cols == 0 || cols > MAX_TERMINAL_COLS {
|
|
return Err(TerminalError::InvalidSpec(format!(
|
|
"cols must be in 1..={MAX_TERMINAL_COLS}; got {cols}"
|
|
)));
|
|
}
|
|
if cells > MAX_TERMINAL_VISIBLE_CELLS {
|
|
return Err(TerminalError::InvalidSpec(format!(
|
|
"visible cell count {cells} exceeds {MAX_TERMINAL_VISIBLE_CELLS}"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn reject_nul(field: &str, bytes: &[u8]) -> Result<(), TerminalError> {
|
|
if bytes.contains(&0) {
|
|
Err(TerminalError::InvalidSpec(format!(
|
|
"{field} must not contain NUL"
|
|
)))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn sanitize_metadata(value: &str) -> String {
|
|
let mut clean = String::with_capacity(value.len().min(MAX_TERMINAL_METADATA_BYTES));
|
|
for ch in value.chars() {
|
|
let ch = if ch == '\r' || ch == '\n' || ch.is_control() {
|
|
' '
|
|
} else {
|
|
ch
|
|
};
|
|
if clean.len() + ch.len_utf8() > MAX_TERMINAL_METADATA_BYTES {
|
|
break;
|
|
}
|
|
clean.push(ch);
|
|
}
|
|
clean
|
|
}
|
|
|
|
/// The built-in terminal escape chord, and the fallback for an
|
|
/// unparseable `terminal.escape-key` (Q#TC4a).
|
|
pub(super) fn default_escape_chord() -> Chord {
|
|
Chord::new(
|
|
crossterm::event::KeyCode::Char('c'),
|
|
crossterm::event::KeyModifiers::CONTROL,
|
|
)
|
|
}
|