From e8dfbc47ecc83144bf991dd6e49b0b33d7b953fa Mon Sep 17 00:00:00 2001 From: whackur Date: Thu, 30 Jul 2026 20:34:24 +0900 Subject: [PATCH 1/9] feat: add [[plugin]] config with a per-pane opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An external plugin process is declared by a [[plugin]] entry and reaches a terminal pane only when that pane's [[startup_command]] names it. Both halves are off by default: enabled is false, and a pane with no plugin field is never handed to anyone. allowed_resume_flags is the list of flags a plugin may append when re-opening a pane's session. It is empty by default, and nightcrow refuses anything absent from it — the core has no idea what a given CLI's flags mean, so which ones a plugin may pass is the user's decision to make and to read. An opt-in naming a plugin that does not exist is a config error, since the only way to reach that state is a typo. Naming one that exists but is disabled is allowed: enabled is the switch for turning a plugin off without unpicking every pane that refers to it. --- src/config.rs | 16 +++ src/config/layout.rs | 4 + src/config/plugin.rs | 76 +++++++++++++ src/config/tests/config_core.rs | 3 + src/config/tests/mod.rs | 1 + src/config/tests/plugin.rs | 138 +++++++++++++++++++++++ src/config/tests/startup.rs | 2 + src/web/viewer/terminal/tests/startup.rs | 12 +- 8 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 src/config/plugin.rs create mode 100644 src/config/tests/plugin.rs diff --git a/src/config.rs b/src/config.rs index d3ba7549..006f59d8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; mod layout; mod log; mod panels; +mod plugin; mod web; pub use layout::{Accent, InputConfig, LayoutConfig, StartupCommand, ThemeConfig, parse_leader}; @@ -12,6 +13,7 @@ pub use layout::{Accent, InputConfig, LayoutConfig, StartupCommand, ThemeConfig, pub use log::LogLevel; pub use log::{LogConfig, LogRotation}; pub use panels::{AgentIndicatorConfig, MouseConfig, TreeConfig}; +pub use plugin::PluginConfig; #[cfg(test)] pub use web::generate_password; pub use web::{WebViewerConfig, ensure_web_viewer_password}; @@ -25,6 +27,12 @@ pub use web::{WebViewerConfig, ensure_web_viewer_password}; /// are reachable by focus cycling (`Shift+←/→`) rather than a jump key. pub const MAX_STARTUP_COMMANDS: usize = 8; +/// Upper bound on `[[plugin]]` entries. Each one is a child process the host +/// keeps alive for the whole session, and a pane opts into exactly one of them, +/// so the bound tracks `MAX_STARTUP_COMMANDS` rather than being independently +/// generous. +pub const MAX_PLUGINS: usize = 8; + #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct Config { @@ -41,6 +49,11 @@ pub struct Config { /// default, which preserves the single empty-shell startup behaviour. #[serde(rename = "startup_command")] pub startup_commands: Vec, + /// External plugin processes, from TOML `[[plugin]]`. Empty by default, and + /// every entry is additionally off until it sets `enabled = true`, so no + /// plugin runs unless the user asked for it twice over. + #[serde(rename = "plugin")] + pub plugins: Vec, } fn default_config_path() -> Option { @@ -157,6 +170,7 @@ pub fn validate_config(cfg: &Config) -> Result<()> { "startup_command[{i}].command must not be empty" ); } + plugin::validate_plugins(cfg)?; anyhow::ensure!( (1..=1024).contains(&cfg.tree.max_depth), "tree.max_depth must be between 1 and 1024" @@ -195,6 +209,8 @@ pub fn resolve_startup_commands(cfg: &Config, cli_exec: &[String]) -> Result, /// Shell command run in the pane immediately on launch. pub command: String, + /// Name of the `[[plugin]]` that may act on this pane. `None` — the default — + /// means no plugin ever receives this pane's events or can act on it. + #[serde(default)] + pub plugin: Option, } diff --git a/src/config/plugin.rs b/src/config/plugin.rs new file mode 100644 index 00000000..99ae0882 --- /dev/null +++ b/src/config/plugin.rs @@ -0,0 +1,76 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +/// An external plugin process (`[[plugin]]`). nightcrow itself knows nothing +/// about what a plugin does: it launches the executable and speaks its protocol. +/// A plugin only ever sees a pane whose `[[startup_command]]` opted in by name, +/// so adding a plugin here does not hand it the whole session. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PluginConfig { + /// Name a startup pane refers to in its `plugin =` field. + pub name: String, + /// Executable to run. Resolved against PATH and the plugin dir by the host. + pub command: String, + /// Arguments passed verbatim. + #[serde(default)] + pub args: Vec, + /// Extra environment for the plugin process only (NOT for panes). + #[serde(default)] + pub env: BTreeMap, + /// Flags this plugin may append when relaunching a pane's command. + /// + /// Empty by default, which refuses every relaunch that passes a flag. The + /// core has no idea what any CLI's flags mean, so the decision of which + /// ones a plugin may add is the user's: a flag that is not listed here + /// cannot be smuggled into the pane's command line, which is what keeps a + /// plugin from quietly weakening a CLI's permission posture. + #[serde(default)] + pub allowed_resume_flags: Vec, + /// Off unless explicitly turned on. + #[serde(default)] + pub enabled: bool, +} + +/// Check the `[[plugin]]` list and every startup pane's opt-in against it. +/// +/// An opt-in naming a plugin that does not exist is an error, because the only +/// way to reach that state is a typo and the pane would silently never be +/// watched. Naming a plugin that exists but is disabled is allowed: `enabled` +/// is the switch for turning a plugin off without unpicking every pane that +/// refers to it, and "off" already means nothing happens. +pub(super) fn validate_plugins(cfg: &super::Config) -> Result<()> { + anyhow::ensure!( + cfg.plugins.len() <= super::MAX_PLUGINS, + "at most {} [[plugin]] entries are allowed, found {}", + super::MAX_PLUGINS, + cfg.plugins.len() + ); + let mut seen: BTreeSet<&str> = BTreeSet::new(); + for (i, p) in cfg.plugins.iter().enumerate() { + anyhow::ensure!( + !p.name.trim().is_empty(), + "plugin[{i}].name must not be empty" + ); + anyhow::ensure!( + !p.command.trim().is_empty(), + "plugin[{i}].command must not be empty" + ); + anyhow::ensure!( + seen.insert(p.name.as_str()), + "duplicate [[plugin]] name \"{}\"; plugin names must be unique so \ + a startup_command's plugin = \"...\" is unambiguous", + p.name + ); + } + for (i, sc) in cfg.startup_commands.iter().enumerate() { + let Some(name) = sc.plugin.as_deref() else { + continue; + }; + anyhow::ensure!( + cfg.plugins.iter().any(|p| p.name == name), + "startup_command[{i}].plugin \"{name}\" does not name any [[plugin]]" + ); + } + Ok(()) +} diff --git a/src/config/tests/config_core.rs b/src/config/tests/config_core.rs index 6091283f..511c29aa 100644 --- a/src/config/tests/config_core.rs +++ b/src/config/tests/config_core.rs @@ -81,6 +81,7 @@ fn startup_command_validation_rejects_empty_command() { cfg.startup_commands.push(StartupCommand { name: Some("blank".into()), command: " ".into(), + plugin: None, }); assert!(validate_config(&cfg).is_err()); } @@ -92,6 +93,7 @@ fn startup_command_validation_rejects_too_many() { cfg.startup_commands.push(StartupCommand { name: None, command: format!("echo {i}"), + plugin: None, }); } assert!(validate_config(&cfg).is_err()); @@ -104,6 +106,7 @@ fn startup_command_validation_accepts_max() { cfg.startup_commands.push(StartupCommand { name: None, command: format!("echo {i}"), + plugin: None, }); } assert!(validate_config(&cfg).is_ok()); diff --git a/src/config/tests/mod.rs b/src/config/tests/mod.rs index f9a9575d..8d34b226 100644 --- a/src/config/tests/mod.rs +++ b/src/config/tests/mod.rs @@ -2,6 +2,7 @@ mod config_core; mod input; mod log; mod panels; +mod plugin; mod startup; mod theme; mod tree; diff --git a/src/config/tests/plugin.rs b/src/config/tests/plugin.rs new file mode 100644 index 00000000..99e44b1b --- /dev/null +++ b/src/config/tests/plugin.rs @@ -0,0 +1,138 @@ +use crate::config::{Config, MAX_PLUGINS, PluginConfig, StartupCommand, validate_config}; + +fn plugin(name: &str, enabled: bool) -> PluginConfig { + PluginConfig { + name: name.to_string(), + command: "nightcrow-recovery".to_string(), + enabled, + ..PluginConfig::default() + } +} + +fn pane_using(plugin: Option<&str>) -> StartupCommand { + StartupCommand { + name: None, + command: "claude".to_string(), + plugin: plugin.map(str::to_string), + } +} + +#[test] +fn plugin_section_parses_and_stays_disabled_unless_enabled_is_set() { + let toml = r#" +[[plugin]] +name = "recovery" +command = "nightcrow-recovery" +args = ["--verbose"] + +[plugin.env] +NIGHTCROW_RECOVERY_LOG = "info" +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert_eq!(cfg.plugins.len(), 1); + assert_eq!(cfg.plugins[0].name, "recovery"); + assert_eq!(cfg.plugins[0].command, "nightcrow-recovery"); + assert_eq!(cfg.plugins[0].args, vec!["--verbose".to_string()]); + assert_eq!( + cfg.plugins[0].env.get("NIGHTCROW_RECOVERY_LOG").unwrap(), + "info" + ); + assert!(!cfg.plugins[0].enabled, "plugins must be off by default"); + validate_config(&cfg).unwrap(); +} + +#[test] +fn plugin_args_and_env_default_to_empty_when_omitted() { + let cfg: Config = + toml::from_str("[[plugin]]\nname = \"recovery\"\ncommand = \"run-me\"\n").unwrap(); + assert!(cfg.plugins[0].args.is_empty()); + assert!(cfg.plugins[0].env.is_empty()); +} + +#[test] +fn a_plugin_without_name_or_command_fails_to_deserialize() { + assert!(toml::from_str::("[[plugin]]\ncommand = \"run-me\"\n").is_err()); + assert!(toml::from_str::("[[plugin]]\nname = \"recovery\"\n").is_err()); +} + +#[test] +fn absent_plugin_section_defaults_to_empty_and_validates() { + assert!(Config::default().plugins.is_empty()); + let cfg: Config = toml::from_str("[layout]\nupper_pct = 50\n").unwrap(); + assert!(cfg.plugins.is_empty()); + validate_config(&cfg).unwrap(); +} + +#[test] +fn a_startup_command_without_a_plugin_field_defaults_to_no_plugin() { + let cfg: Config = toml::from_str("[[startup_command]]\ncommand = \"claude\"\n").unwrap(); + assert_eq!(cfg.startup_commands[0].plugin, None); + validate_config(&cfg).unwrap(); +} + +#[test] +fn a_startup_command_naming_an_enabled_plugin_validates() { + let mut cfg = Config::default(); + cfg.plugins.push(plugin("recovery", true)); + cfg.startup_commands.push(pane_using(Some("recovery"))); + validate_config(&cfg).unwrap(); +} + +#[test] +fn a_startup_command_naming_an_unknown_plugin_is_rejected() { + let mut cfg = Config::default(); + cfg.plugins.push(plugin("recovery", true)); + cfg.startup_commands.push(pane_using(Some("typo"))); + let err = validate_config(&cfg).unwrap_err().to_string(); + assert!(err.contains("typo"), "error should name the opt-in: {err}"); +} + +#[test] +fn a_startup_command_naming_a_disabled_plugin_still_validates() { + // `enabled = false` is how a plugin gets switched off; requiring every + // opt-in to be unpicked first would make that a two-place edit. Off simply + // means the pane is never handed to anyone. + let mut cfg = Config::default(); + cfg.plugins.push(plugin("recovery", false)); + cfg.startup_commands.push(pane_using(Some("recovery"))); + validate_config(&cfg).expect("a disabled plugin is a valid target"); +} + +#[test] +fn duplicate_plugin_names_are_rejected() { + let mut cfg = Config::default(); + cfg.plugins.push(plugin("recovery", true)); + cfg.plugins.push(plugin("recovery", true)); + let err = validate_config(&cfg).unwrap_err().to_string(); + assert!( + err.contains("recovery"), + "error should name the clash: {err}" + ); +} + +#[test] +fn a_blank_plugin_name_is_rejected() { + let mut cfg = Config::default(); + cfg.plugins.push(plugin(" ", true)); + assert!(validate_config(&cfg).is_err()); +} + +#[test] +fn a_blank_plugin_command_is_rejected() { + let mut cfg = Config::default(); + let mut p = plugin("recovery", true); + p.command = " ".to_string(); + cfg.plugins.push(p); + assert!(validate_config(&cfg).is_err()); +} + +#[test] +fn exceeding_the_plugin_cap_is_rejected_but_the_cap_itself_validates() { + let mut cfg = Config::default(); + for i in 0..MAX_PLUGINS { + cfg.plugins.push(plugin(&format!("p{i}"), true)); + } + validate_config(&cfg).unwrap(); + cfg.plugins.push(plugin("one-too-many", true)); + assert!(validate_config(&cfg).is_err()); +} diff --git a/src/config/tests/startup.rs b/src/config/tests/startup.rs index 5316671e..9db9807b 100644 --- a/src/config/tests/startup.rs +++ b/src/config/tests/startup.rs @@ -37,6 +37,7 @@ fn resolve_startup_commands_appends_cli_exec_after_config() { cfg.startup_commands.push(StartupCommand { name: Some("Claude".into()), command: "claude".into(), + plugin: None, }); let resolved = resolve_startup_commands(&cfg, &["codex".to_string(), "vim".to_string()]).unwrap(); @@ -68,6 +69,7 @@ fn resolve_startup_commands_caps_combined_total() { cfg.startup_commands.push(StartupCommand { name: None, command: format!("echo {i}"), + plugin: None, }); } // 4 config + 5 CLI = 9 > MAX_STARTUP_COMMANDS (8). diff --git a/src/web/viewer/terminal/tests/startup.rs b/src/web/viewer/terminal/tests/startup.rs index 6e46a07c..305614a5 100644 --- a/src/web/viewer/terminal/tests/startup.rs +++ b/src/web/viewer/terminal/tests/startup.rs @@ -19,6 +19,7 @@ fn startup(command: &str) -> StartupCommand { StartupCommand { name: None, command: command.to_string(), + plugin: None, } } @@ -27,7 +28,10 @@ fn a_startup_terminal_is_offered_for_sizing_and_born_at_that_size() { // The whole point of the handshake: the child must never draw a frame at a // size no client chose, so the PTY does not exist until one has measured. let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), vec![startup("printf hello")]); + let hub = TerminalHub::spawn( + &dir.path().to_string_lossy(), + vec![startup("printf hello")], + ); let session = hub.connect(); assert_eq!( @@ -239,6 +243,7 @@ fn a_configured_startup_terminal_is_announced_under_its_name() { vec![StartupCommand { name: Some("Claude".into()), command: "sleep 30".into(), + plugin: None, }], ); let session = hub.connect(); @@ -261,7 +266,10 @@ fn a_configured_startup_terminal_is_announced_under_its_name() { fn an_unnamed_startup_terminal_falls_back_to_its_command() { // What the operator wrote is what they would recognise it by. let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), vec![startup("printf hello")]); + let hub = TerminalHub::spawn( + &dir.path().to_string_lossy(), + vec![startup("printf hello")], + ); let session = hub.connect(); session.dispatch(ClientMessage::Start { sizes: vec![PaneSize { rows: 24, cols: 80 }], From 05917268b4d8149943034c505946b1d5c31193da Mon Sep 17 00:00:00 2001 From: whackur Date: Thu, 30 Jul 2026 20:35:38 +0900 Subject: [PATCH 2/9] feat: run an opted-in pane's plugin from the session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nightcrow launches each enabled plugin as a long-lived child and speaks newline-delimited JSON to it over a pipe. Rust has no stable ABI, so a dylib plugin would be undefined behaviour the moment versions diverged, and a cargo feature would need a recompile rather than an install. A process boundary is also the honest trust model: the plugin is not in our address space. The pane a plugin sees is named by an opaque token rather than by a PaneId or a working directory. A PaneId is a per-backend counter that restarts at 1, and a directory identifies the project, not the pane — several panes on one repository is a supported layout. The token is injected into the pane's child environment, so the provider's own helper processes inherit it and an event can be traced to its pane without guessing. Guard is the only way a plugin command becomes an action. Decoding checked shape and size; authority is checked here: the pane must exist and have opted in, the generation must match so a decision about a replaced process cannot land on its successor, input goes only to a live and quiet pane, a relaunch only to an exited one, and control characters other than CR/LF/TAB are refused so a plugin cannot drive the emulator or the running CLI's key bindings. Attempts are counted per slot, keyed by the token. A relaunch always mints a new PaneId, so counting by id handed out a fresh allowance every time and a command that exits at once paired with a plugin that relaunches on every exit would never reach the ceiling. A relaunch cannot reuse an id — ids are monotonic and every client treats Exited as final for one — so the replacement is born as a new pane that inherits the token at the next generation, and is put back at its old index with the existing Reordered message rather than a new one. Waiting for a reset can take days, so the process is released the moment it exits while the slot stays; the slot is what a relaunch needs, and holding a dead child's fds and threads for days is not. Recovery state reaches humans as pane metadata, never as screen content: the pane's tab carries a marker, the notice row carries the detail, and c gives up on a wait. Typing into the pane cancels it too — a person taking the pane back outranks whatever a plugin was planning. --- README.md | 1 + src/app/terminal_ctrl.rs | 13 + src/application/input/dispatch.rs | 6 + src/application/tests/terminal.rs | 43 +++ src/backend/hub.rs | 22 ++ src/backend/identity.rs | 88 ++++++ src/backend/identity_tests.rs | 47 +++ src/backend/mod.rs | 28 ++ src/backend/pty.rs | 153 ++++----- src/backend/pty_spawn.rs | 153 +++++++++ src/backend/pty_tests.rs | 203 ++++++++++++ src/backend/slot.rs | 180 +++++++++++ src/backend/slot_tests.rs | 157 ++++++++++ src/cli.rs | 11 + src/cli/plugin_cmd.rs | 160 ++++++++++ src/daemon/terminals.rs | 4 + src/daemon/terminals_tests.rs | 69 +++++ src/input/mod.rs | 5 + src/input/routing.rs | 4 + src/main.rs | 2 + src/plugin/guard.rs | 283 +++++++++++++++++ src/plugin/guard_budget.rs | 123 ++++++++ src/plugin/guard_refusal.rs | 109 +++++++ src/plugin/guard_tests/budget.rs | 194 ++++++++++++ src/plugin/guard_tests/identity.rs | 126 ++++++++ src/plugin/guard_tests/input.rs | 112 +++++++ src/plugin/guard_tests/mod.rs | 77 +++++ src/plugin/guard_tests/relaunch.rs | 96 ++++++ src/plugin/guard_text.rs | 32 ++ src/plugin/host.rs | 264 ++++++++++++++++ src/plugin/host_pump.rs | 161 ++++++++++ src/plugin/host_tests.rs | 181 +++++++++++ src/plugin/mod.rs | 32 ++ src/plugin/protocol.rs | 247 +++++++++++++++ src/plugin/protocol_tests.rs | 277 +++++++++++++++++ src/plugin/registry.rs | 271 ++++++++++++++++ src/plugin/registry_tests.rs | 262 ++++++++++++++++ src/runtime/terminal/lifecycle.rs | 7 + src/runtime/terminal/mod.rs | 9 + src/runtime/terminal/recovery.rs | 104 +++++++ src/runtime/terminal/tests/mod.rs | 1 + src/runtime/terminal/tests/recovery_tests.rs | 147 +++++++++ src/test_util.rs | 15 + src/ui/hint_text.rs | 10 +- src/ui/mod.rs | 1 + src/ui/notice.rs | 33 ++ src/ui/terminal_tab/mod.rs | 13 +- src/ui/terminal_tab/recovery.rs | 115 +++++++ src/ui/terminal_tab/tab_bar.rs | 11 +- src/ui/terminal_tab/tests/tab_tests.rs | 59 ++++ src/ui/wall_clock.rs | 94 ++++++ src/web/viewer/catalog/mod.rs | 28 +- src/web/viewer/server/mod.rs | 58 +++- src/web/viewer/terminal/frame.rs | 33 ++ src/web/viewer/terminal/hub_events.rs | 201 ++++++++++++ src/web/viewer/terminal/hub_helpers.rs | 11 + src/web/viewer/terminal/hub_layout.rs | 90 ++++++ src/web/viewer/terminal/hub_plugins.rs | 262 ++++++++++++++++ src/web/viewer/terminal/hub_recovery.rs | 117 +++++++ src/web/viewer/terminal/hub_relaunch.rs | 241 +++++++++++++++ src/web/viewer/terminal/hub_run.rs | 145 ++++----- src/web/viewer/terminal/mod.rs | 20 +- src/web/viewer/terminal/session.rs | 1 + src/web/viewer/terminal/startup.rs | 3 + src/web/viewer/terminal/startup_run.rs | 20 +- src/web/viewer/terminal/tests/behavior.rs | 16 +- src/web/viewer/terminal/tests/mod.rs | 181 ++--------- src/web/viewer/terminal/tests/plugin_rules.rs | 155 ++++++++++ src/web/viewer/terminal/tests/plugin_slots.rs | 168 ++++++++++ src/web/viewer/terminal/tests/plugins.rs | 291 ++++++++++++++++++ src/web/viewer/terminal/tests/recovery.rs | 257 ++++++++++++++++ src/web/viewer/terminal/tests/size_owner.rs | 10 +- src/web/viewer/terminal/tests/startup.rs | 17 +- src/web/viewer/terminal/tests/wire.rs | 220 +++++++++++++ .../src/components/terminal/PanelToolbar.tsx | 21 +- .../src/components/terminal/RecoveryChip.tsx | 42 +++ .../src/components/terminal/StartupSlots.tsx | 1 + .../src/components/terminal/Terminal.tsx | 8 + .../src/components/terminal/TerminalCell.tsx | 10 + .../src/hooks/terminal/usePaneRecovery.ts | 25 ++ .../src/hooks/terminal/useTerminalSocket.ts | 10 + viewer-ui/src/lib/recovery.test.ts | 172 +++++++++++ viewer-ui/src/lib/recovery.ts | 123 ++++++++ 83 files changed, 7378 insertions(+), 394 deletions(-) create mode 100644 src/backend/identity.rs create mode 100644 src/backend/identity_tests.rs create mode 100644 src/backend/pty_spawn.rs create mode 100644 src/backend/slot.rs create mode 100644 src/backend/slot_tests.rs create mode 100644 src/cli/plugin_cmd.rs create mode 100644 src/daemon/terminals_tests.rs create mode 100644 src/plugin/guard.rs create mode 100644 src/plugin/guard_budget.rs create mode 100644 src/plugin/guard_refusal.rs create mode 100644 src/plugin/guard_tests/budget.rs create mode 100644 src/plugin/guard_tests/identity.rs create mode 100644 src/plugin/guard_tests/input.rs create mode 100644 src/plugin/guard_tests/mod.rs create mode 100644 src/plugin/guard_tests/relaunch.rs create mode 100644 src/plugin/guard_text.rs create mode 100644 src/plugin/host.rs create mode 100644 src/plugin/host_pump.rs create mode 100644 src/plugin/host_tests.rs create mode 100644 src/plugin/mod.rs create mode 100644 src/plugin/protocol.rs create mode 100644 src/plugin/protocol_tests.rs create mode 100644 src/plugin/registry.rs create mode 100644 src/plugin/registry_tests.rs create mode 100644 src/runtime/terminal/recovery.rs create mode 100644 src/runtime/terminal/tests/recovery_tests.rs create mode 100644 src/ui/terminal_tab/recovery.rs create mode 100644 src/ui/wall_clock.rs create mode 100644 src/web/viewer/terminal/hub_events.rs create mode 100644 src/web/viewer/terminal/hub_layout.rs create mode 100644 src/web/viewer/terminal/hub_plugins.rs create mode 100644 src/web/viewer/terminal/hub_recovery.rs create mode 100644 src/web/viewer/terminal/hub_relaunch.rs create mode 100644 src/web/viewer/terminal/tests/plugin_rules.rs create mode 100644 src/web/viewer/terminal/tests/plugin_slots.rs create mode 100644 src/web/viewer/terminal/tests/plugins.rs create mode 100644 src/web/viewer/terminal/tests/recovery.rs create mode 100644 src/web/viewer/terminal/tests/wire.rs create mode 100644 viewer-ui/src/components/terminal/RecoveryChip.tsx create mode 100644 viewer-ui/src/hooks/terminal/usePaneRecovery.ts create mode 100644 viewer-ui/src/lib/recovery.test.ts create mode 100644 viewer-ui/src/lib/recovery.ts diff --git a/README.md b/README.md index f677fa67..d3651449 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,7 @@ visible from the terminal pane. | ` w` | Close active terminal pane — terminal focus only, since without it no pane is highlighted as the close target | | ` s` then `3`…`9`,`0` | Swap the active terminal pane with pane 1…8 (focus follows the pane; same pane numbering as the jump keys, so in terminal fullscreen the swap digits are `1`…`8`) — terminal focus only, like `w`, and needs at least two panes | | ` z` | Resize this project's terminal panes to fit this screen. The session gives the sizing to whichever client attached most recently — a PTY has one size, and a program drawing on an alternate screen cannot be re-flowed afterwards — so while another client (a second terminal, or the browser) holds it, this one renders that grid: padded if it is smaller than the pane, cropped if larger. Advertised in the hint bar only while that is the case | +| ` c` | Give up on the recovery a plugin has pending for a pane — the held slot is released, so nothing can be relaunched into it, and every attached client is told. Targets the focused pane's recovery, or the pane whose process has already ended while its slot was being held (that pane has no tab to focus). Advertised in the hint bar only while something is actually pending | | ` l` | Toggle between status view and commit log view | | ` b` | Toggle the read-only file-tree view (returns to status view) | | ` f` | Fullscreen the focused pane. For the terminal it cycles `off → grid (all panes) → zoom (active pane only) → off`; with a single pane it toggles straight off/on. File list and diff viewer toggle off/on | diff --git a/src/app/terminal_ctrl.rs b/src/app/terminal_ctrl.rs index a11cf27c..2208fed5 100644 --- a/src/app/terminal_ctrl.rs +++ b/src/app/terminal_ctrl.rs @@ -57,6 +57,19 @@ impl App { !self.terminal.owns_size } + /// Give up on the recovery a plugin has pending, releasing the slot it was + /// holding. The session decides and tells every client; nothing is assumed + /// here (see `TerminalState::cancel_recovery`). + pub fn cancel_pane_recovery(&mut self) { + self.terminal.cancel_recovery(); + } + + /// Whether the cancel key would do anything — the hint rows advertise it only + /// where it would, since a hint for a no-op lies. + pub fn can_cancel_recovery(&self) -> bool { + self.terminal.can_cancel_recovery() + } + /// Ask the session to close the active pane. Nothing is re-clamped here: /// the pane goes when its exit arrives, and `poll_terminal` clamps focus and /// fullscreen then — the same path a pane another client closed takes. diff --git a/src/application/input/dispatch.rs b/src/application/input/dispatch.rs index 79a6446d..287de284 100644 --- a/src/application/input/dispatch.rs +++ b/src/application/input/dispatch.rs @@ -131,6 +131,12 @@ pub(super) fn handle_global_action(app: &mut App, action: Action) -> Option { + app.cancel_pane_recovery(); + Some(KeyOutcome::Continue) + } Action::ClosePane => { // Scoped by `can_close_pane` (terminal focus — the close target // is invisible without it). The key is still consumed so it diff --git a/src/application/tests/terminal.rs b/src/application/tests/terminal.rs index 804bbe65..74763c8b 100644 --- a/src/application/tests/terminal.rs +++ b/src/application/tests/terminal.rs @@ -36,3 +36,46 @@ fn handle_key_terminal_ctrl_app_keys_all_pass_through() { ); } } + +#[test] +fn handle_key_leader_then_c_cancels_the_pane_recovery() { + // The fake backend answers a cancel the way a session does — by reporting the + // pane `cancelled` — so the badge clearing on the next poll is proof the + // request actually went out. + let mut app = app_with_terminal_pane(); + let pane = app.terminal.panes[0].id; + app.terminal.recovery.insert( + pane, + crate::runtime::terminal::PaneRecovery { + state: "waiting_for_reset".to_string(), + detail: None, + deadline_epoch: Some(1_700_000_000), + attempt: 1, + }, + ); + assert!(app.can_cancel_recovery(), "the hint must be advertised"); + + let _ = handle_key(&mut app, leader()); + let _ = handle_key(&mut app, press(KeyCode::Char('c'), KeyModifiers::NONE)); + app.poll_terminal(); + + assert!( + !app.can_cancel_recovery(), + "` c` must reach the session" + ); + assert!( + backend_payloads(&app).is_empty(), + "a leader follow-up must never leak into the PTY" + ); +} + +#[test] +fn handle_key_bare_c_in_a_terminal_pane_reaches_the_program() { + // The cancel binding is leader-prefixed precisely so a bare `c` is still + // ordinary typing in whatever the pane is running. + let mut app = app_with_terminal_pane(); + + let _ = handle_key(&mut app, press(KeyCode::Char('c'), KeyModifiers::NONE)); + + assert_eq!(backend_payloads(&app), vec![b"c".to_vec()]); +} diff --git a/src/backend/hub.rs b/src/backend/hub.rs index 61efddb6..b90bf8dd 100644 --- a/src/backend/hub.rs +++ b/src/backend/hub.rs @@ -105,6 +105,15 @@ impl TerminalBackend for HubBackend { } } + fn cancel_recovery(&mut self, id: PaneId) { + if let Err(err) = self + .link + .send(HubClientMessage::CancelRecovery { pane: id }) + { + tracing::warn!(%err, pane = id, "could not cancel a pane's recovery"); + } + } + fn drain_events(&mut self) -> Vec { let mut events = Vec::new(); for message in self.link.drain() { @@ -140,6 +149,19 @@ impl TerminalBackend for HubBackend { TerminalMessage::Event(HubServerMessage::Reordered { order }) => { events.push(BackendEvent::Reordered { order }) } + TerminalMessage::Event(HubServerMessage::Recovery { + pane, + state, + detail, + deadline_epoch, + attempt, + }) => events.push(BackendEvent::Recovery { + pane, + state, + detail, + deadline_epoch, + attempt, + }), // Refusals do not come this way — they are not about a pane, so // the client keeps them on the queue that reaches its notices. TerminalMessage::Event(HubServerMessage::Error { message }) => { diff --git a/src/backend/identity.rs b/src/backend/identity.rs new file mode 100644 index 00000000..5a8939c9 --- /dev/null +++ b/src/backend/identity.rs @@ -0,0 +1,88 @@ +use anyhow::{Result, anyhow}; +use serde::{Deserialize, Serialize}; + +/// Env var carrying the pane token into the pane's child process. +/// +/// A provider CLI spawns its own helper processes (hooks, statusline commands) +/// and those inherit this, which is what lets an out-of-process observer say +/// which pane an event came from. The working directory cannot answer that — +/// nightcrow deliberately allows several panes on one repository, so cwd +/// identifies the project rather than the pane. +pub const PANE_TOKEN_ENV: &str = "NIGHTCROW_PANE_TOKEN"; + +/// Entropy behind a pane token, matching the viewer's session tokens at half +/// the width: a session holds a handful of panes, not thousands. +const TOKEN_BYTES: usize = 16; + +/// Opaque name for a pane slot, stable for as long as the slot exists. +/// +/// [`PaneId`](super::PaneId) cannot serve this purpose outside the process that +/// owns the panes: it is a per-backend counter that restarts at 1 whenever a +/// backend is rebuilt, so the same number means different panes across two +/// runs. The token is random instead, and it deliberately outlives the process +/// occupying the slot — an observer tracking a slot keeps its state when the +/// slot's process is replaced. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct PaneToken(String); + +impl PaneToken { + /// Mint a token from OS entropy. + /// + /// Fallible rather than silently weakened: a predictable token would let + /// anything that can guess one address a pane it was never given. + pub fn new() -> Result { + let mut bytes = [0u8; TOKEN_BYTES]; + getrandom::fill(&mut bytes) + .map_err(|e| anyhow!("OS RNG unavailable for pane token: {e}"))?; + let mut hex = String::with_capacity(TOKEN_BYTES * 2); + for b in bytes { + hex.push_str(&format!("{b:02x}")); + } + Ok(Self(hex)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Which spawn of a pane slot something refers to. +/// +/// Starts at [`FIRST_GENERATION`] and rises every time the slot's process is +/// replaced. An out-of-process observer decides what to do asynchronously, so +/// by the time it asks for something the process it watched may already be +/// gone; carrying the generation is what makes that detectable instead of +/// letting a decision about one process land on its successor. +pub type PaneGeneration = u32; + +pub const FIRST_GENERATION: PaneGeneration = 1; + +/// A pane slot's identity: which slot, and which spawn within it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaneIdentity { + pub token: PaneToken, + pub generation: PaneGeneration, +} + +impl PaneIdentity { + pub fn new() -> Result { + Ok(Self { + token: PaneToken::new()?, + generation: FIRST_GENERATION, + }) + } + + /// Advance to the next spawn of the same slot. + /// + /// Saturating rather than wrapping: a wrapped generation would make a stale + /// command look current again, which is the one thing the counter exists to + /// prevent. A slot that somehow reached `u32::MAX` relaunches stops being + /// able to distinguish spawns, and refusing to move is the safe end state. + pub fn advance(&mut self) { + self.generation = self.generation.saturating_add(1); + } +} + +#[cfg(test)] +#[path = "identity_tests.rs"] +mod tests; diff --git a/src/backend/identity_tests.rs b/src/backend/identity_tests.rs new file mode 100644 index 00000000..bad9fdfb --- /dev/null +++ b/src/backend/identity_tests.rs @@ -0,0 +1,47 @@ +use super::*; + +#[test] +fn minting_two_tokens_yields_distinct_values() { + let a = PaneToken::new().expect("OS RNG"); + let b = PaneToken::new().expect("OS RNG"); + assert_ne!(a, b); + assert_eq!(a.as_str().len(), TOKEN_BYTES * 2); + assert!(a.as_str().chars().all(|c| c.is_ascii_hexdigit())); +} + +#[test] +fn a_new_identity_starts_at_the_first_generation() { + let id = PaneIdentity::new().expect("OS RNG"); + assert_eq!(id.generation, FIRST_GENERATION); +} + +#[test] +fn advancing_an_identity_keeps_the_token_and_raises_the_generation() { + let mut id = PaneIdentity::new().expect("OS RNG"); + let token = id.token.clone(); + id.advance(); + // The slot is the same slot; only the spawn inside it changed. + assert_eq!(id.token, token); + assert_eq!(id.generation, FIRST_GENERATION + 1); +} + +#[test] +fn advancing_at_the_counter_ceiling_refuses_to_wrap() { + let mut id = PaneIdentity::new().expect("OS RNG"); + id.generation = PaneGeneration::MAX; + id.advance(); + // Wrapping would make a stale generation compare equal to the live one. + assert_eq!(id.generation, PaneGeneration::MAX); +} + +#[test] +fn a_token_serialises_as_a_plain_string() { + // The plugin protocol carries tokens as JSON; a newtype that serialised as + // an object or array would break every out-of-process reader. + let token = PaneToken::new().expect("OS RNG"); + let json = serde_json::to_string(&token).expect("serialise"); + assert_eq!(json, format!("\"{}\"", token.as_str())); + + let back: PaneToken = serde_json::from_str(&json).expect("deserialise"); + assert_eq!(back, token); +} diff --git a/src/backend/mod.rs b/src/backend/mod.rs index de0df8e7..5947e38d 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -1,7 +1,10 @@ pub mod hub; +pub mod identity; pub mod pty; +pub mod slot; pub use hub::HubBackend; +pub use identity::{PaneGeneration, PaneToken}; pub use pty::PtyBackend; use anyhow::Result; @@ -64,6 +67,22 @@ pub enum BackendEvent { SizeOwnership { owned: bool, }, + /// What a plugin driving `pane` reports about getting it running again. + /// + /// Only a backend serving a shared session reports this: the plugins run + /// beside the session's panes, not beside this client. Pane metadata, not + /// screen content — nothing here reaches an emulator. + Recovery { + pane: PaneId, + /// The plugin's own short label. Uninterpreted here; the one value with a + /// meaning is `"cancelled"`, which ends the report. + state: String, + detail: Option, + /// When the wait ends, in unix epoch seconds, or `None` when no clock is + /// involved. + deadline_epoch: Option, + attempt: u32, + }, } pub trait TerminalBackend { @@ -96,6 +115,15 @@ pub trait TerminalBackend { /// shared session has anything to ask. fn claim_size(&mut self) {} + /// Ask the session to give up on a pane's pending recovery. + /// + /// A no-op by default: a backend that owns its PTYs has no plugin nursing one + /// back, so there is nothing pending to abandon. The session answers with a + /// [`BackendEvent::Recovery`] whose state is `"cancelled"`. + fn cancel_recovery(&mut self, pane: PaneId) { + let _ = pane; + } + /// Test hook: byte payloads recorded by a recording backend. Real /// backends return `None`; the in-memory test `FakeBackend` overrides /// this so input tests can assert exact PTY pass-through bytes. diff --git a/src/backend/pty.rs b/src/backend/pty.rs index e56bea2b..fae5fda3 100644 --- a/src/backend/pty.rs +++ b/src/backend/pty.rs @@ -1,13 +1,17 @@ +use super::slot::{PaneSlot, PaneSlots}; use super::{BackendEvent, PaneId, TerminalBackend}; use crate::platform::threading::try_timed_join; use anyhow::Result; -use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem}; +use portable_pty::PtySize; use std::collections::BTreeMap; -use std::io::{Read, Write}; +use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::mpsc::{self, Receiver}; +use std::sync::mpsc::Receiver; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant}; + +#[path = "pty_spawn.rs"] +mod spawn; /// Reap window for PTY reader / wait threads. Bigger than the commit-log /// REAP_TIMEOUT because `read()` on a PTY master can stay blocked if a @@ -21,21 +25,21 @@ const PTY_REAP_TIMEOUT: Duration = Duration::from_millis(50); /// the round-robin cap bounds the work per pane to a small constant. const PER_PANE_DRAIN_BUDGET: usize = 64; -enum PtyEvent { +pub(super) enum PtyEvent { Output(Vec), Exited, } -struct PtyPane { +pub(super) struct PtyPane { // master/writer are wrapped in Option so `Drop` can release them // before joining the reader thread — the reader blocks in `read()` // and only unblocks when both sides of the PTY are closed. - master: Option>, - writer: Option>, - killer: Box, - rx: Receiver, - reader_handle: Option>, - wait_handle: Option>, + pub(super) master: Option>, + pub(super) writer: Option>, + pub(super) killer: Box, + pub(super) rx: Receiver, + pub(super) reader_handle: Option>, + pub(super) wait_handle: Option>, } impl Drop for PtyPane { @@ -68,11 +72,15 @@ pub struct PtyBackend { // and stays deterministic across runs. HashMap iteration was random // per process, which made inter-pane event ordering unobservable // and could mask fairness regressions in tests. - panes: BTreeMap, - next_id: PaneId, + pub(super) panes: BTreeMap, + /// Slot bookkeeping — identity, launch, idle clock — kept beside `panes` + /// rather than inside `PtyPane` because a relaunch replaces the `PtyPane` + /// while the slot has to survive it. + pub(super) slots: PaneSlots, + pub(super) next_id: PaneId, // Each new pane spawns the shell here so its cwd matches the repo // nightcrow is tracking, even when the binary was launched elsewhere. - cwd: PathBuf, + pub(super) cwd: PathBuf, /// Panes created since the last drain, waiting to be reported. /// /// This backend knows the id the moment it makes the pane, but the trait @@ -85,94 +93,44 @@ impl PtyBackend { pub fn new(cwd: impl AsRef) -> Self { Self { panes: BTreeMap::new(), + slots: PaneSlots::default(), next_id: 1, cwd: cwd.as_ref().to_path_buf(), created: Vec::new(), } } -} -impl PtyBackend { - /// Open a pane and say which one it is. - /// - /// The trait reports panes as events, because a backend serving a shared - /// session cannot answer on the spot. This one can, and the terminal hub — - /// which owns a `PtyBackend` outright rather than through the trait — needs - /// the id to register the pane before anything else happens to it. - pub fn open_pane(&mut self, rows: u16, cols: u16, command: Option<&str>) -> Result { - // Reserve the next id only after every fallible PTY/spawn step succeeds, - // so a failure here does not consume an id slot. - let pty_system = NativePtySystem::default(); - let pair = pty_system.openpty(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - })?; - - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); - let mut cmd = CommandBuilder::new(&shell); - // A reserved startup command runs through the login shell's `-lc`: - // the command text is passed as a single argv item, so the shell — - // not us — handles its quoting/word-splitting. This avoids the race - // of spawning a shell and later injecting `command\r`, and avoids any - // string interpolation into a wrapper on our side. - if let Some(command) = command { - cmd.arg("-lc"); - cmd.arg(command); - } - cmd.env("TERM", "xterm-256color"); - // Only set cwd if the directory actually exists; otherwise inherit - // ours so spawn does not fail outright (matters for unit tests that - // pass placeholder paths). - if let Ok(canonical) = self.cwd.canonicalize() { - cmd.cwd(canonical); - } - let mut child = pair.slave.spawn_command(cmd)?; - let killer = child.clone_killer(); - drop(pair.slave); - - let mut reader = pair.master.try_clone_reader()?; - let writer = pair.master.take_writer()?; + /// The slot behind a live pane, or `None` once the pane is gone. + pub fn slot(&self, id: PaneId) -> Option<&PaneSlot> { + self.slots.get(id) + } - let id = self.next_id; - let next = id - .checked_add(1) - .ok_or_else(|| anyhow::anyhow!("pane id counter overflow"))?; - self.next_id = next; + /// Which live pane a token names. + pub fn pane_for_token(&self, token: &super::PaneToken) -> Option { + self.slots.find_by_token(token) + } - let (tx, rx) = mpsc::channel(); - let reader_handle = thread::spawn(move || { - let mut buf = [0u8; 4096]; - loop { - match reader.read(&mut buf) { - Ok(0) | Err(_) => break, - Ok(n) => { - if tx.send(PtyEvent::Output(buf[..n].to_vec())).is_err() { - break; - } - } - } - } - let _ = tx.send(PtyEvent::Exited); - }); + /// Whether the pane still has a running process. + pub fn is_process_alive(&self, id: PaneId) -> bool { + self.panes.contains_key(&id) + } - let wait_handle = thread::spawn(move || { - let _ = child.wait(); - }); + /// Let go of a pane's process while keeping its slot. + /// + /// Splitting this out of `destroy_pane` is what makes waiting for a reset + /// affordable: a wait can run for hours, and holding the dead child's fds + /// and threads open for that long to preserve the token would be pure + /// waste. The slot is small, and it is the only part a relaunch needs. + pub fn release_process(&mut self, id: PaneId) { + self.panes.remove(&id); + } - self.panes.insert( - id, - PtyPane { - master: Some(pair.master), - writer: Some(writer), - killer, - rx, - reader_handle: Some(reader_handle), - wait_handle: Some(wait_handle), - }, - ); - Ok(id) + /// Drop a slot for good, retiring its token. + /// + /// Called when nothing more is expected of the pane — the wait was + /// abandoned, the pane was closed, or the session is going away. + pub fn retire_slot(&mut self, id: PaneId) { + self.slots.remove(id); } } @@ -198,6 +156,9 @@ impl TerminalBackend for PtyBackend { // Removing the pane drops it, which runs PtyPane::drop: kill, // release master/writer, join reader/wait threads. self.panes.remove(&id); + // The slot goes with it, retiring its token. A relaunch keeps the slot + // by going through `relaunch_pane` instead of destroy-then-open. + self.slots.remove(id); } fn send_input(&mut self, id: PaneId, data: &[u8]) -> Result<()> { @@ -249,11 +210,17 @@ impl TerminalBackend for PtyBackend { // Ahead of any output: a pane has to exist before bytes can be routed // to it, and both can be queued before the same drain. let mut events: Vec = std::mem::take(&mut self.created); + let now = Instant::now(); for (id, pane) in &self.panes { let mut budget = PER_PANE_DRAIN_BUDGET; while budget > 0 { match pane.rx.try_recv() { Ok(PtyEvent::Output(data)) => { + // One timestamp for the whole drain: the bytes arrived + // between this tick and the last, and a per-event clock + // read would claim a precision the 8 ms poll does not + // have. + self.slots.mark_output(*id, now); events.push(BackendEvent::Output { pane: *id, data }); } Ok(PtyEvent::Exited) => { diff --git a/src/backend/pty_spawn.rs b/src/backend/pty_spawn.rs new file mode 100644 index 00000000..7238cd05 --- /dev/null +++ b/src/backend/pty_spawn.rs @@ -0,0 +1,153 @@ +use super::{PtyBackend, PtyEvent, PtyPane}; +use crate::backend::PaneId; +use crate::backend::identity::{PANE_TOKEN_ENV, PaneIdentity}; +use crate::backend::slot::{PaneLaunch, resume_command_line}; +use anyhow::Result; +use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem}; +use std::io::Read; +use std::sync::mpsc; +use std::thread; +use std::time::Instant; + +impl PtyBackend { + /// Open a pane and say which one it is. + /// + /// The trait reports panes as events, because a backend serving a shared + /// session cannot answer on the spot. This one can, and the terminal hub — + /// which owns a `PtyBackend` outright rather than through the trait — needs + /// the id to register the pane before anything else happens to it. + pub fn open_pane(&mut self, rows: u16, cols: u16, command: Option<&str>) -> Result { + let identity = PaneIdentity::new()?; + let launch = PaneLaunch { + command: command.map(str::to_string), + }; + self.spawn_pane(rows, cols, command, identity, launch) + } + + /// Replace an exited pane's process, keeping the slot it ran in. + /// + /// A new `PaneId` is unavoidable: ids are monotonic and every client treats + /// `Exited` as final for one. The slot's token is what carries over, so an + /// observer that has been tracking this pane keeps its place, and the + /// generation moves so decisions made about the old process cannot land on + /// the new one. + /// + /// The composed command line is checked before anything is torn down, so a + /// refused relaunch leaves the pane exactly as it was. + pub fn relaunch_pane( + &mut self, + id: PaneId, + rows: u16, + cols: u16, + resume_args: &[String], + allowed_flags: &[String], + ) -> Result { + let slot = self + .slots + .get(id) + .ok_or_else(|| anyhow::anyhow!("pane {id} has no slot to relaunch"))?; + let launch = slot.launch.clone(); + let mut identity = slot.identity.clone(); + let line = resume_command_line(launch.command.as_deref(), resume_args, allowed_flags)?; + + identity.advance(); + // Retire the old process first: two children writing one slot's PTY + // would interleave, and the reader thread has to be let go before the + // replacement's is started. + self.panes.remove(&id); + self.slots.remove(id); + + // The retained launch stays the *original* invocation. Carrying the + // composed line forward instead would accumulate resume arguments on + // every further relaunch. + self.spawn_pane(rows, cols, Some(line.as_str()), identity, launch) + } + + fn spawn_pane( + &mut self, + rows: u16, + cols: u16, + command: Option<&str>, + identity: PaneIdentity, + launch: PaneLaunch, + ) -> Result { + // Reserve the next id only after every fallible PTY/spawn step succeeds, + // so a failure here does not consume an id slot. + let pty_system = NativePtySystem::default(); + let pair = pty_system.openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + })?; + + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); + let mut cmd = CommandBuilder::new(&shell); + // A reserved startup command runs through the login shell's `-lc`: + // the command text is passed as a single argv item, so the shell — + // not us — handles its quoting/word-splitting. This avoids the race + // of spawning a shell and later injecting `command\r`, and avoids any + // string interpolation into a wrapper on our side. + if let Some(command) = command { + cmd.arg("-lc"); + cmd.arg(command); + } + cmd.env("TERM", "xterm-256color"); + // Set at spawn time because a child cannot be told afterwards, and the + // provider's own helper processes inherit it — that inheritance is what + // lets an out-of-process observer name the pane an event came from. + cmd.env(PANE_TOKEN_ENV, identity.token.as_str()); + // Only set cwd if the directory actually exists; otherwise inherit + // ours so spawn does not fail outright (matters for unit tests that + // pass placeholder paths). + if let Ok(canonical) = self.cwd.canonicalize() { + cmd.cwd(canonical); + } + let mut child = pair.slave.spawn_command(cmd)?; + let killer = child.clone_killer(); + drop(pair.slave); + + let mut reader = pair.master.try_clone_reader()?; + let writer = pair.master.take_writer()?; + + let id = self.next_id; + let next = id + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("pane id counter overflow"))?; + self.next_id = next; + + let (tx, rx) = mpsc::channel(); + let reader_handle = thread::spawn(move || { + let mut buf = [0u8; 4096]; + loop { + match reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + if tx.send(PtyEvent::Output(buf[..n].to_vec())).is_err() { + break; + } + } + } + } + let _ = tx.send(PtyEvent::Exited); + }); + + let wait_handle = thread::spawn(move || { + let _ = child.wait(); + }); + + self.panes.insert( + id, + PtyPane { + master: Some(pair.master), + writer: Some(writer), + killer, + rx, + reader_handle: Some(reader_handle), + wait_handle: Some(wait_handle), + }, + ); + self.slots.insert(id, identity, launch, Instant::now()); + Ok(id) + } +} diff --git a/src/backend/pty_tests.rs b/src/backend/pty_tests.rs index c1362396..4f36a50c 100644 --- a/src/backend/pty_tests.rs +++ b/src/backend/pty_tests.rs @@ -1,6 +1,10 @@ use super::*; +use crate::backend::identity::FIRST_GENERATION; use std::time::{Duration, Instant}; +/// Long enough that a `printf` and its exit are certainly drained. +const RELAUNCH_MARKER: &str = "nightcrow-relaunched"; + #[test] fn pty_backend_create_and_destroy_pane() { let mut backend = PtyBackend::new("."); @@ -53,6 +57,205 @@ fn pty_backend_drains_output_before_exit_event() { ); } +#[test] +fn opening_a_pane_gives_it_a_token_at_the_first_generation() { + let mut backend = PtyBackend::new("."); + let id = backend.open_pane(24, 80, None).expect("open_pane failed"); + + let identity = backend.slot(id).expect("pane has a slot").identity.clone(); + assert_eq!(identity.generation, FIRST_GENERATION); + assert!(!identity.token.as_str().is_empty()); + + backend.destroy_pane(id); +} + +#[test] +fn a_token_resolves_to_the_pane_holding_it() { + let mut backend = PtyBackend::new("."); + let id = backend.open_pane(24, 80, None).expect("open_pane failed"); + let token = backend.slot(id).expect("slot").identity.token.clone(); + + assert_eq!(backend.pane_for_token(&token), Some(id)); + backend.destroy_pane(id); + assert_eq!(backend.pane_for_token(&token), None); +} + +#[test] +fn relaunching_a_pane_keeps_the_token_and_advances_the_generation() { + let mut backend = PtyBackend::new("."); + let first = backend + .open_pane(24, 80, Some("printf first; exit")) + .expect("open_pane failed"); + let token = backend.slot(first).expect("slot").identity.token.clone(); + + let second = backend + .relaunch_pane(first, 24, 80, &[], &[]) + .expect("relaunch failed"); + + // A new id is unavoidable — ids are monotonic and clients treat `Exited` as + // final — so the token is what carries the slot's identity across. + assert_ne!(second, first); + let slot = backend.slot(second).expect("relaunched slot"); + assert_eq!(slot.identity.token, token); + assert_eq!(slot.identity.generation, FIRST_GENERATION + 1); + // The old id stops resolving, so a decision made about it cannot land here. + assert!(backend.slot(first).is_none()); + assert_eq!(backend.pane_for_token(&token), Some(second)); +} + +#[test] +fn a_relaunch_reproduces_the_original_command() { + let mut backend = PtyBackend::new("."); + let first = backend + .open_pane(24, 80, Some(&format!("printf {RELAUNCH_MARKER}; exit"))) + .expect("open_pane failed"); + let second = backend + .relaunch_pane(first, 24, 80, &[], &[]) + .expect("relaunch failed"); + + let deadline = Instant::now() + PTY_TEST_DEADLINE; + let mut output = Vec::new(); + let mut saw_exit = false; + while Instant::now() < deadline { + for event in backend.drain_events() { + match event { + BackendEvent::Output { pane, data } if pane == second => output.extend(data), + BackendEvent::Exited { pane } if pane == second => saw_exit = true, + _ => {} + } + } + if saw_exit { + break; + } + thread::sleep(Duration::from_millis(10)); + } + + assert!( + String::from_utf8_lossy(&output).contains(RELAUNCH_MARKER), + "relaunch did not re-run the original command" + ); +} + +#[test] +fn a_relaunch_keeps_the_original_command_rather_than_accumulating_resume_args() { + let mut backend = PtyBackend::new("."); + let allowed = vec!["--flag".to_string()]; + let args = vec!["--flag".to_string()]; + let first = backend + .open_pane(24, 80, Some("true")) + .expect("open_pane failed"); + + let second = backend + .relaunch_pane(first, 24, 80, &args, &allowed) + .expect("first relaunch"); + // The retained launch is the original invocation, so a second relaunch does + // not stack another copy of the resume arguments onto it. + assert_eq!( + backend + .slot(second) + .expect("slot") + .launch + .command + .as_deref(), + Some("true") + ); + + let third = backend + .relaunch_pane(second, 24, 80, &args, &allowed) + .expect("second relaunch"); + assert_eq!( + backend.slot(third).expect("slot").launch.command.as_deref(), + Some("true") + ); +} + +#[test] +fn relaunching_a_pane_that_is_gone_is_refused() { + let mut backend = PtyBackend::new("."); + let id = backend.open_pane(24, 80, Some("true")).expect("open_pane"); + backend.destroy_pane(id); + + let err = backend.relaunch_pane(id, 24, 80, &[], &[]).unwrap_err(); + assert!(err.to_string().contains("no slot"), "{err}"); +} + +#[test] +fn a_refused_relaunch_leaves_the_pane_running() { + let mut backend = PtyBackend::new("."); + let id = backend + .open_pane(24, 80, Some("sleep 30")) + .expect("open_pane"); + let token = backend.slot(id).expect("slot").identity.token.clone(); + + // Not in the allowlist, so the command line is refused before anything is + // torn down. + let args = vec!["--nope".to_string()]; + assert!(backend.relaunch_pane(id, 24, 80, &args, &[]).is_err()); + + assert_eq!(backend.pane_for_token(&token), Some(id)); + assert!(backend.panes.contains_key(&id)); + backend.destroy_pane(id); +} + +#[test] +fn two_panes_get_distinct_tokens() { + let mut backend = PtyBackend::new("."); + let a = backend.open_pane(24, 80, None).expect("open_pane failed"); + let b = backend.open_pane(24, 80, None).expect("open_pane failed"); + + // Two panes on one repository is a supported layout, so the token — not the + // working directory — is what tells them apart. + let ta = backend.slot(a).expect("a").identity.token.clone(); + let tb = backend.slot(b).expect("b").identity.token.clone(); + assert_ne!(ta, tb); + + backend.destroy_pane(a); + backend.destroy_pane(b); +} + +#[test] +fn destroying_a_pane_retires_its_token() { + let mut backend = PtyBackend::new("."); + let id = backend.open_pane(24, 80, None).expect("open_pane failed"); + backend.destroy_pane(id); + + // A held token must stop resolving, or it would address whatever id lands + // in this slot next. + assert!(backend.slot(id).is_none()); +} + +#[test] +fn a_panes_child_process_sees_its_token_in_the_environment() { + let mut backend = PtyBackend::new("."); + let id = backend + .open_pane(24, 80, Some("printf %s \"$NIGHTCROW_PANE_TOKEN\"; exit")) + .expect("open_pane failed"); + let token = backend.slot(id).expect("slot").identity.token.clone(); + + let deadline = Instant::now() + PTY_TEST_DEADLINE; + let mut output = Vec::new(); + let mut saw_exit = false; + while Instant::now() < deadline { + for event in backend.drain_events() { + match event { + BackendEvent::Output { data, .. } => output.extend(data), + BackendEvent::Exited { pane } if pane == id => saw_exit = true, + _ => {} + } + } + if saw_exit { + break; + } + thread::sleep(Duration::from_millis(10)); + } + + // This is the correlation path a provider's own hook processes inherit. + assert!( + String::from_utf8_lossy(&output).contains(token.as_str()), + "pane token was not exported to the child environment" + ); +} + #[test] fn pty_backend_runs_startup_command() { let mut backend = PtyBackend::new("."); diff --git a/src/backend/slot.rs b/src/backend/slot.rs new file mode 100644 index 00000000..66837b56 --- /dev/null +++ b/src/backend/slot.rs @@ -0,0 +1,180 @@ +use super::PaneId; +use super::identity::{PaneIdentity, PaneToken}; +use anyhow::{Result, bail}; +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; + +/// What it takes to put a pane's process back after it exits. +/// +/// The hub discards the startup command once a pane is spawned, which is fine +/// until something wants to replace the process rather than the pane: a shell +/// cannot be asked what it was told to run. Keeping the text here is what makes +/// a relaunch reproduce the original launch instead of guessing at it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaneLaunch { + /// Startup command exactly as configured, or `None` for a bare shell. + pub command: Option, +} + +/// A pane slot: who it is, what it was launched as, and when it last spoke. +#[derive(Debug)] +pub struct PaneSlot { + pub identity: PaneIdentity, + pub launch: PaneLaunch, + last_output: Instant, +} + +impl PaneSlot { + /// How long the pane has been quiet. + /// + /// Measured from the last byte the child produced, not from the last thing + /// written to it: a CLI that is mid-answer keeps emitting, and typing into + /// it then would interleave with what it is drawing. + pub fn idle_for(&self, now: Instant) -> Duration { + now.saturating_duration_since(self.last_output) + } +} + +/// Per-pane slot bookkeeping, held beside the live PTYs. +/// +/// Separate from the PTY map because the two have different lifetimes: a +/// relaunch replaces the PTY while the slot — and so the token an outside +/// observer holds — has to survive it. +#[derive(Debug, Default)] +pub struct PaneSlots(BTreeMap); + +impl PaneSlots { + pub fn insert(&mut self, id: PaneId, identity: PaneIdentity, launch: PaneLaunch, now: Instant) { + self.0.insert( + id, + PaneSlot { + identity, + launch, + // A pane that has said nothing yet counts as quiet since it + // opened, so a plugin does not have to wait for first output + // before a freshly opened pane can be considered idle. + last_output: now, + }, + ); + } + + pub fn remove(&mut self, id: PaneId) -> Option { + self.0.remove(&id) + } + + pub fn get(&self, id: PaneId) -> Option<&PaneSlot> { + self.0.get(&id) + } + + /// Note that the pane produced output. + pub fn mark_output(&mut self, id: PaneId, now: Instant) { + if let Some(slot) = self.0.get_mut(&id) { + slot.last_output = now; + } + } + + /// Find the pane a token names, if it still exists. + /// + /// Linear over a handful of panes; a reverse index would be more state to + /// keep consistent than the scan costs. + pub fn find_by_token(&self, token: &PaneToken) -> Option { + self.0 + .iter() + .find(|(_, slot)| &slot.identity.token == token) + .map(|(id, _)| *id) + } +} + +/// Longest resume argument list a plugin may append. +/// +/// A resume invocation is a flag and an id; anything longer is not a resume. +const MAX_RESUME_ARGS: usize = 6; + +/// Longest single resume argument. Comfortably past a UUID or a session name. +const MAX_RESUME_ARG_LEN: usize = 256; + +/// Characters a resume argument may consist of. +/// +/// Deliberately narrower than "anything the shell can be made to swallow": the +/// argument is appended to a command line that a login shell parses, so a value +/// carrying a space, quote, backtick, `$`, or `;` is refused outright rather +/// than trusted to survive quoting. Quoting is applied as well — this is the +/// belt to that braces. +fn is_safe_arg_char(c: char) -> bool { + c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':' | '/' | '=' | '@' | '+') +} + +/// Build the command line for a relaunch. +/// +/// `allowed_flags` is the plugin's declared list from config. Anything that +/// looks like a flag must appear there, which is how the core refuses to weaken +/// a CLI's permission posture without knowing what any particular CLI's +/// permission flags are called — the user names what a plugin may pass, and a +/// flag they did not name cannot be smuggled in. +pub fn resume_command_line( + base: Option<&str>, + resume_args: &[String], + allowed_flags: &[String], +) -> Result { + let Some(base) = base else { + // Nothing was configured to run, so there is no session to resume and + // no original invocation to preserve. + bail!("pane has no startup command to relaunch"); + }; + if resume_args.is_empty() { + return Ok(base.to_string()); + } + if resume_args.len() > MAX_RESUME_ARGS { + bail!( + "relaunch passed {} arguments, at most {MAX_RESUME_ARGS} allowed", + resume_args.len() + ); + } + + let mut line = String::from(base); + for arg in resume_args { + if arg.is_empty() { + bail!("relaunch argument must not be empty"); + } + if arg.len() > MAX_RESUME_ARG_LEN { + bail!( + "relaunch argument is {} bytes, at most {MAX_RESUME_ARG_LEN} allowed", + arg.len() + ); + } + if !arg.chars().all(is_safe_arg_char) { + bail!("relaunch argument {arg:?} holds characters that are not allowed"); + } + if arg.starts_with('-') && !allowed_flags.iter().any(|f| f == arg) { + bail!( + "relaunch flag {arg:?} is not in the plugin's allowed_resume_flags; \ + add it there if the plugin is meant to pass it" + ); + } + line.push(' '); + line.push_str(&shell_quote(arg)); + } + Ok(line) +} + +/// Wrap a value so a login shell reads it as one literal word. +/// +/// Single quotes suspend every expansion the shell would otherwise perform, so +/// the only character needing care is the quote itself. +fn shell_quote(arg: &str) -> String { + let mut out = String::with_capacity(arg.len() + 2); + out.push('\''); + for c in arg.chars() { + if c == '\'' { + out.push_str("'\\''"); + } else { + out.push(c); + } + } + out.push('\''); + out +} + +#[cfg(test)] +#[path = "slot_tests.rs"] +mod tests; diff --git a/src/backend/slot_tests.rs b/src/backend/slot_tests.rs new file mode 100644 index 00000000..d4801a12 --- /dev/null +++ b/src/backend/slot_tests.rs @@ -0,0 +1,157 @@ +use super::*; +use crate::backend::identity::FIRST_GENERATION; + +fn launch(command: &str) -> PaneLaunch { + PaneLaunch { + command: Some(command.to_string()), + } +} + +fn slots_with_one(now: Instant) -> (PaneSlots, PaneId, PaneToken) { + let mut slots = PaneSlots::default(); + let identity = PaneIdentity::new().expect("OS RNG"); + let token = identity.token.clone(); + slots.insert(7, identity, launch("agent"), now); + (slots, 7, token) +} + +#[test] +fn a_freshly_opened_pane_counts_as_quiet_since_it_opened() { + let now = Instant::now(); + let (slots, id, _) = slots_with_one(now); + // Otherwise a pane that has printed nothing could never be called idle. + assert_eq!(slots.get(id).expect("slot").idle_for(now), Duration::ZERO); +} + +#[test] +fn marking_output_restarts_the_idle_clock() { + let start = Instant::now(); + let (mut slots, id, _) = slots_with_one(start); + let later = start + Duration::from_secs(5); + slots.mark_output(id, later); + + assert_eq!(slots.get(id).expect("slot").idle_for(later), Duration::ZERO); + assert_eq!( + slots + .get(id) + .expect("slot") + .idle_for(later + Duration::from_secs(2)), + Duration::from_secs(2) + ); +} + +#[test] +fn marking_output_on_a_pane_that_is_gone_is_ignored() { + let now = Instant::now(); + let (mut slots, id, _) = slots_with_one(now); + slots.remove(id); + slots.mark_output(id, now); + assert!(slots.get(id).is_none()); +} + +#[test] +fn a_token_resolves_to_its_pane_and_stops_resolving_once_removed() { + let now = Instant::now(); + let (mut slots, id, token) = slots_with_one(now); + assert_eq!(slots.find_by_token(&token), Some(id)); + + slots.remove(id); + // A held token must not address whatever occupies the slot next. + assert_eq!(slots.find_by_token(&token), None); +} + +#[test] +fn a_slot_keeps_the_launch_command_so_it_can_be_reproduced() { + let now = Instant::now(); + let (slots, id, _) = slots_with_one(now); + assert_eq!( + slots.get(id).expect("slot").launch.command.as_deref(), + Some("agent") + ); + assert_eq!( + slots.get(id).expect("slot").identity.generation, + FIRST_GENERATION + ); +} + +#[test] +fn no_resume_arguments_leaves_the_original_command_untouched() { + let line = resume_command_line(Some("agent --model x"), &[], &[]).expect("no args is fine"); + assert_eq!(line, "agent --model x"); +} + +#[test] +fn an_allowed_flag_and_its_value_are_appended_quoted() { + let args = vec!["--resume".to_string(), "abc-123".to_string()]; + let allowed = vec!["--resume".to_string()]; + let line = resume_command_line(Some("agent"), &args, &allowed).expect("allowed"); + assert_eq!(line, "agent '--resume' 'abc-123'"); +} + +#[test] +fn a_flag_the_plugin_was_not_allowed_to_pass_is_refused() { + // The whole point: a plugin cannot weaken a CLI's permission posture unless + // the user named that flag themselves. + let args = vec!["--dangerously-skip-permissions".to_string()]; + let allowed = vec!["--resume".to_string()]; + let err = resume_command_line(Some("agent"), &args, &allowed).unwrap_err(); + assert!( + err.to_string().contains("allowed_resume_flags"), + "error should point at the allowlist: {err}" + ); +} + +#[test] +fn an_argument_holding_shell_metacharacters_is_refused() { + let allowed: Vec = Vec::new(); + for hostile in [ + "a; rm -rf /", + "$(id)", + "`id`", + "a b", + "a'b", + "a\nb", + "a|b", + "a>b", + "a&b", + ] { + let args = vec![hostile.to_string()]; + assert!( + resume_command_line(Some("agent"), &args, &allowed).is_err(), + "should refuse {hostile:?}" + ); + } +} + +#[test] +fn too_many_or_too_long_arguments_are_refused() { + let allowed: Vec = Vec::new(); + let many: Vec = (0..MAX_RESUME_ARGS + 1).map(|i| format!("v{i}")).collect(); + assert!(resume_command_line(Some("agent"), &many, &allowed).is_err()); + + let long = vec!["v".repeat(MAX_RESUME_ARG_LEN + 1)]; + assert!(resume_command_line(Some("agent"), &long, &allowed).is_err()); +} + +#[test] +fn an_empty_argument_is_refused() { + let allowed: Vec = Vec::new(); + let args = vec![String::new()]; + assert!(resume_command_line(Some("agent"), &args, &allowed).is_err()); +} + +#[test] +fn a_pane_with_no_startup_command_cannot_be_relaunched() { + let args = vec!["--resume".to_string()]; + let allowed = vec!["--resume".to_string()]; + // A bare shell has no session to resume, and nothing to reproduce. + let err = resume_command_line(None, &args, &allowed).unwrap_err(); + assert!(err.to_string().contains("no startup command"), "{err}"); +} + +#[test] +fn quoting_neutralises_an_embedded_single_quote() { + // Reached only if the charset check is ever loosened; the quoting has to be + // correct on its own rather than relying on that check. + assert_eq!(shell_quote("a'b"), "'a'\\''b'"); +} diff --git a/src/cli.rs b/src/cli.rs index 91777956..ec345d59 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,8 @@ use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; +pub(crate) mod plugin_cmd; + /// nightcrow — session daemon for agentic coding /// /// Run with no subcommand to start the session: a git diff viewer and @@ -51,6 +53,14 @@ pub(crate) enum Commands { /// are opened from inside, with the leader chord's open dialog or the /// browser's folder picker. Leaving does not end the session. Attach, + /// Manage plugin executables in ~/.nightcrow/plugins. + /// + /// Installing one only puts the binary in place; it stays inert until + /// config.toml declares it and a startup pane opts in by name. + Plugin { + #[command(subcommand)] + command: plugin_cmd::PluginCommands, + }, } /// Run the session daemon in the foreground until it is stopped. @@ -136,6 +146,7 @@ pub(crate) fn run_daemon( &paths, true, startup, + cfg.plugins.clone(), )?; if paths.is_empty() { // An empty catalog is a legitimate state — the same one the TUI starts diff --git a/src/cli/plugin_cmd.rs b/src/cli/plugin_cmd.rs new file mode 100644 index 00000000..64d3660a --- /dev/null +++ b/src/cli/plugin_cmd.rs @@ -0,0 +1,160 @@ +//! `nightcrow plugin` — argument parsing and reporting only. Every filesystem +//! decision lives in [`crate::plugin::registry`]. + +use anyhow::Result; +use clap::Subcommand; +use std::path::{Path, PathBuf}; + +use crate::plugin::registry::{self, InstallOutcome, RemoveOutcome}; + +#[derive(Subcommand)] +pub(crate) enum PluginCommands { + /// Copy an executable into ~/.nightcrow/plugins + Install { + /// Path to the plugin executable + #[arg(value_name = "PATH")] + path: PathBuf, + /// Install under this name instead of the source file stem + #[arg(long, value_name = "NAME")] + name: Option, + /// Replace an already-installed plugin of the same name + #[arg(long)] + force: bool, + }, + /// List installed plugins and how config.toml refers to them + List, + /// Delete an installed plugin + Remove { + #[arg(value_name = "NAME")] + name: String, + }, +} + +pub(crate) fn run_plugin(command: PluginCommands) -> Result<()> { + let dir = registry::default_plugins_dir()?; + match command { + PluginCommands::Install { path, name, force } => { + run_install(&dir, &path, name.as_deref(), force) + } + PluginCommands::List => run_list(&dir), + PluginCommands::Remove { name } => run_remove(&dir, &name), + } +} + +fn run_install(dir: &Path, path: &Path, name: Option<&str>, force: bool) -> Result<()> { + let installed = match registry::install(dir, path, name, force)? { + InstallOutcome::Created(installed) => { + println!("Installed {}", installed.display()); + installed + } + InstallOutcome::Replaced(installed) => { + println!("Replaced {}", installed.display()); + installed + } + InstallOutcome::AlreadyExists(installed) => { + println!( + "A plugin is already installed at {} — left untouched (pass --force to replace).", + installed.display() + ); + return Ok(()); + } + }; + let name = installed + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + println!(); + println!("Add this to ~/.nightcrow/config.toml to declare it:"); + println!(); + print!("{}", registry::config_snippet(&name, &installed)); + println!(); + // Deliberately not written for the user: a plugin that is enabled and + // opted into can drive that pane's terminal, so both switches stay an + // explicit, reviewed edit. + println!( + "Nothing was written to your config — flip `enabled` yourself, and add \ + `plugin = \"{name}\"` to a [[startup_command]] to opt that pane in. \ + A plugin with no opted-in pane never sees anything." + ); + Ok(()) +} + +fn run_list(dir: &Path) -> Result<()> { + let names = registry::list(dir)?; + if names.is_empty() { + println!("No plugins installed in {}", dir.display()); + return Ok(()); + } + // A broken config must not hide what is on disk — listing files is the + // thing this command is for, and the config line is extra detail. + let cfg = match crate::config::load_config() { + Ok(cfg) => Some(cfg), + Err(e) => { + println!("warning: config.toml could not be read ({e:#})"); + println!("Listing installed files only."); + None + } + }; + println!("Installed plugins in {}:", dir.display()); + for name in names { + match cfg.as_ref() { + Some(cfg) => println!(" {name} — {}", describe(®istry::status(cfg, &name))), + None => println!(" {name}"), + } + } + Ok(()) +} + +fn describe(status: ®istry::PluginStatus) -> String { + if !status.declared { + return "not declared in config.toml".to_string(); + } + format!( + "declared, {}, {} startup pane{} opted in", + if status.enabled { + "enabled" + } else { + "disabled" + }, + status.opt_ins, + if status.opt_ins == 1 { "" } else { "s" } + ) +} + +fn run_remove(dir: &Path, name: &str) -> Result<()> { + let references = crate::config::load_config() + .ok() + .map(|cfg| registry::status(&cfg, name)); + match registry::remove(dir, name)? { + RemoveOutcome::Removed(path) => println!("Removed {}", path.display()), + RemoveOutcome::NotInstalled(name) => { + println!( + "No plugin named \"{name}\" is installed in {}", + dir.display() + ); + } + } + // A `plugin =` opt-in naming a plugin that is no longer declared is a hard + // config error, so a dangling reference stops the next startup outright. + if let Some(status) = references.filter(|s| s.declared || s.opt_ins > 0) { + println!(); + println!("WARNING: config.toml still refers to \"{name}\"."); + if status.declared { + println!(" - remove its [[plugin]] table (name = \"{name}\")"); + } + if status.opt_ins > 0 { + println!( + " - remove `plugin = \"{name}\"` from {} [[startup_command]] entr{}", + status.opt_ins, + if status.opt_ins == 1 { "y" } else { "ies" } + ); + } + println!( + "Left as they are, the next `nightcrow` start tries to launch a plugin that is \ + no longer installed — and a `plugin =` opt-in whose [[plugin]] table is gone \ + fails config validation outright." + ); + } + Ok(()) +} diff --git a/src/daemon/terminals.rs b/src/daemon/terminals.rs index 43e33b47..ab436508 100644 --- a/src/daemon/terminals.rs +++ b/src/daemon/terminals.rs @@ -197,3 +197,7 @@ impl Drop for Bridge { } } } + +#[cfg(test)] +#[path = "terminals_tests.rs"] +mod tests; diff --git a/src/daemon/terminals_tests.rs b/src/daemon/terminals_tests.rs new file mode 100644 index 00000000..4af3e46e --- /dev/null +++ b/src/daemon/terminals_tests.rs @@ -0,0 +1,69 @@ +//! What the relay is allowed to change on its way through. +//! +//! `Created` is the one event whose requester is rewritten into the attach +//! socket's id space; everything else has to arrive at the client exactly as the +//! hub said it, and a recovery report in particular — a rewritten deadline or a +//! dropped detail would be a silent lie about a pane nobody can see. + +use super::rewrite_requester; +use crate::daemon::protocol::ServerMessage; +use crate::web::viewer::terminal::frame::ServerMessage as HubServerMessage; + +const HUB_CLIENT: u64 = 4; +const ATTACHED_CLIENT: u64 = 91; + +fn recovery() -> HubServerMessage { + HubServerMessage::Recovery { + pane: 6, + state: "waiting_for_reset".to_string(), + detail: Some("provider window closed".to_string()), + deadline_epoch: Some(1_700_000_000), + attempt: 2, + } +} + +#[test] +fn a_recovery_report_is_relayed_unchanged() { + assert_eq!( + rewrite_requester(recovery(), HUB_CLIENT, ATTACHED_CLIENT), + recovery() + ); +} + +#[test] +fn a_recovery_report_survives_the_attach_envelope_intact() { + // The relay parses a hub control frame and re-encodes it under a repository + // tag, so the round trip is part of the delivery path rather than a test + // convenience. + let tagged = ServerMessage::Terminal { + repo: "repo-a".to_string(), + event: rewrite_requester(recovery(), HUB_CLIENT, ATTACHED_CLIENT), + }; + let json = serde_json::to_string(&tagged).unwrap(); + let back: ServerMessage = serde_json::from_str(&json).unwrap(); + + match back { + ServerMessage::Terminal { repo, event } => { + assert_eq!(repo, "repo-a"); + assert_eq!(event, recovery()); + } + other => panic!("the envelope changed shape: {other:?}"), + } +} + +#[test] +fn a_recovery_report_with_no_deadline_or_detail_relays_as_absent_not_as_zero() { + let bare = HubServerMessage::Recovery { + pane: 6, + state: "cancelled".to_string(), + detail: None, + deadline_epoch: None, + attempt: 0, + }; + let relayed = rewrite_requester(bare.clone(), HUB_CLIENT, ATTACHED_CLIENT); + assert_eq!(relayed, bare); + + let json = serde_json::to_string(&relayed).unwrap(); + assert!(!json.contains("deadline_epoch"), "{json}"); + assert!(!json.contains("detail"), "{json}"); +} diff --git a/src/input/mod.rs b/src/input/mod.rs index 3ad453d4..860699ac 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -15,6 +15,11 @@ pub enum Action { /// render that grid; this asks for it. Inert when this client already has /// it, and when its panes are its own. ClaimPaneSizing, + /// Give up on the recovery a plugin has pending for a pane. + /// + /// Inert when nothing is pending. Behind the leader like every other app + /// command: a bare key in a terminal pane belongs to the program in it. + CancelRecovery, /// Arm swap mode: the next digit picks the pane to swap the active pane /// with. Emitted by the ` s` follow-up; the digit is resolved in a /// separate tick (see `handle_swap_target_followup` in `main`). diff --git a/src/input/routing.rs b/src/input/routing.rs index 994e9aad..bd4a2320 100644 --- a/src/input/routing.rs +++ b/src/input/routing.rs @@ -56,6 +56,10 @@ pub fn prefix_action(event: KeyEvent) -> Action { 'f' => Action::ToggleFullscreen, 's' => Action::SwapPanePrompt, 'z' => Action::ClaimPaneSizing, + // `c` for cancel. Bare, not `ctrl+c`: the follow-up handler treats + // `ctrl+c` as the universal prefix cancel, and it has to keep doing + // so whatever leader is configured. + 'c' => Action::CancelRecovery, 'o' => Action::OpenProject, 'x' => Action::CloseProject, 'p' => Action::CycleTheme, diff --git a/src/main.rs b/src/main.rs index e02dc4d4..0dc64d42 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod daemon; mod git; mod input; mod platform; +pub mod plugin; mod runtime; #[cfg(test)] mod test_util; @@ -30,6 +31,7 @@ fn main() -> Result<()> { match cli.command { Some(Commands::Init { force }) => run_init(force), Some(Commands::Attach) => application::attach::run_attach(), + Some(Commands::Plugin { command }) => cli::plugin_cmd::run_plugin(command), None => run_daemon(cli.exec, cli.port, cli.bind, cli.detach), } } diff --git a/src/plugin/guard.rs b/src/plugin/guard.rs new file mode 100644 index 00000000..b268fe4b --- /dev/null +++ b/src/plugin/guard.rs @@ -0,0 +1,283 @@ +//! The trust boundary between an untrusted plugin and the panes. +//! +//! [`decode_command`](super::protocol::decode_command) checked shape and bounds; +//! it never checked authority. Everything a plugin asks for passes through +//! [`Guard::judge`], which returns either an [`Approved`] action naming a real +//! [`PaneId`] or a [`Refused`] saying why not. There is no other way through: a +//! [`PluginCommand`] carries a token, and only the guard turns one into a pane. + +use super::guard_budget::{Budgets, RateAction, RateLimits}; +use super::guard_refusal::Refused; +use super::guard_text::{is_forbidden_control, truncate_message}; +use super::protocol::{LogLevel, MAX_INPUT_BYTES, PluginCommand}; +use crate::backend::slot::resume_command_line; +use crate::backend::{PaneGeneration, PaneId, PaneToken}; +use std::time::{Duration, Instant}; + +/// What the guard needs to know about the pane a command names. +/// +/// Supplied by the caller rather than looked up here, so every rule is testable +/// without a PTY and the guard holds no reference to the backend. +#[derive(Debug, Clone)] +pub struct PaneFacts { + pub pane: PaneId, + pub generation: PaneGeneration, + /// The pane's `[[startup_command]]` named *this* plugin. + pub opted_in: bool, + /// The pane's process is still running. + pub alive: bool, + /// Since the pane last produced output. + pub idle: Duration, + /// The pane's configured startup command, or `None` for a bare shell. + /// + /// Needed because a relaunch is validated by calling the real + /// [`resume_command_line`], which builds the line from the original + /// invocation and is also what refuses a pane that has no command to resume. + pub launch_command: Option, +} + +/// A command that passed every rule, addressed to a pane by id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Approved { + SendInput { + pane: PaneId, + data: Vec, + }, + Relaunch { + pane: PaneId, + resume_args: Vec, + /// The line the guard already validated and built. Carried so the + /// caller runs exactly what was approved instead of rebuilding it. + command_line: String, + }, + Status { + pane: PaneId, + state: String, + detail: Option, + deadline_epoch: Option, + attempt: u32, + }, + Log { + level: LogLevel, + message: String, + }, +} + +pub struct Guard { + min_idle: Duration, + limits: RateLimits, + budgets: Budgets, +} + +impl Guard { + /// `min_idle` is how long a pane must have been quiet before input may be + /// typed into it. + pub fn new(min_idle: Duration, limits: RateLimits) -> Self { + Self { + min_idle, + limits, + budgets: Budgets::default(), + } + } + + /// Forget everything held for the slot `token` names. + /// + /// Called when a human types into the pane, when the pane closes, and when + /// the session is replaced: in all three the plugin's picture of the pane is + /// void, and its spent budget belongs to a situation that no longer exists. + /// Keyed by token because the budget is, so this must be called while the + /// slot still exists — before the slot is retired, not after. + pub fn cancel(&mut self, token: &PaneToken) { + self.budgets.clear(token); + } + + /// Decide one command. Never panics. + /// + /// `facts` is what the caller knows about the pane `cmd`'s token resolves + /// to, or `None` if it resolves to nothing. `allowed_resume_flags` is the + /// plugin's configured list. + pub fn judge( + &mut self, + cmd: PluginCommand, + facts: Option<&PaneFacts>, + allowed_resume_flags: &[String], + now: Instant, + ) -> Result { + match cmd { + // Not pane-scoped, so none of the pane rules can apply to it. + PluginCommand::Log { level, message, .. } => Ok(Approved::Log { + level, + message: truncate_message(message), + }), + PluginCommand::SendInput { + token, + generation, + data, + .. + } => { + let facts = pane_facts(&token, generation, facts)?; + self.judge_send_input(&token, facts, data, now) + } + PluginCommand::Relaunch { + token, + generation, + resume_args, + .. + } => { + let facts = pane_facts(&token, generation, facts)?; + self.judge_relaunch(&token, facts, resume_args, allowed_resume_flags, now) + } + PluginCommand::Status { + token, + generation, + state, + detail, + deadline_epoch, + attempt, + .. + } => { + let facts = pane_facts(&token, generation, facts)?; + // Observability only: nothing happens to the pane, so there is + // no effect to rate-limit. + Ok(Approved::Status { + pane: facts.pane, + state, + detail, + deadline_epoch, + attempt, + }) + } + } + } + + fn judge_send_input( + &mut self, + token: &PaneToken, + facts: &PaneFacts, + data: String, + now: Instant, + ) -> Result { + let pane = facts.pane; + if !facts.alive { + // The slot outlives its process, so typing here would reach + // whatever occupies it next rather than what the plugin watched. + return Err(Refused::PaneNotRunning { pane }); + } + if facts.idle < self.min_idle { + return Err(Refused::PaneBusy { + pane, + idle: facts.idle, + min_idle: self.min_idle, + }); + } + if data.len() > MAX_INPUT_BYTES { + return Err(Refused::InputTooLarge { + pane, + bytes: data.len(), + limit: MAX_INPUT_BYTES, + }); + } + if let Some(bad) = data.chars().find(|c| is_forbidden_control(*c)) { + return Err(Refused::ControlCharacter { + pane, + code: bad as u32, + }); + } + self.spend(token, pane, RateAction::SendInput, now)?; + Ok(Approved::SendInput { + pane, + data: data.into_bytes(), + }) + } + + fn judge_relaunch( + &mut self, + token: &PaneToken, + facts: &PaneFacts, + resume_args: Vec, + allowed_resume_flags: &[String], + now: Instant, + ) -> Result { + let pane = facts.pane; + if facts.alive { + // Half of the guarantee that one incident is handled once: input + // recovery acts on live panes, relaunch only on exited ones. + return Err(Refused::PaneStillRunning { pane }); + } + let command_line = resume_command_line( + facts.launch_command.as_deref(), + &resume_args, + allowed_resume_flags, + ) + .map_err(|e| Refused::ResumeArgsRejected { + pane, + reason: e.to_string(), + })?; + self.spend(token, pane, RateAction::Relaunch, now)?; + Ok(Approved::Relaunch { + pane, + resume_args, + command_line, + }) + } + + /// Charge the budget, as the last check before approval. + /// + /// Only approvals spend, deliberately: the budget bounds what a plugin + /// *does* to a pane, and a refused command did nothing. Charging refusals + /// would let noise — a stale generation the plugin could not have known + /// about, a flag config forbids — eat the budget a legitimate action needs, + /// losing the pane's one real attempt to a race. Spam is bounded elsewhere + /// and more cheaply: the outbound queue drops and every refusal is logged. + fn spend( + &mut self, + token: &PaneToken, + pane: PaneId, + action: RateAction, + now: Instant, + ) -> Result<(), Refused> { + if self.budgets.try_spend(token, action, &self.limits, now) { + return Ok(()); + } + Err(Refused::RateLimited { + pane, + action, + limit: match action { + RateAction::SendInput => self.limits.max_sends_per_window, + RateAction::Relaunch => self.limits.max_relaunches_per_window, + }, + window: self.limits.window, + }) + } +} + +/// Rules 2, 3 and 4: the pane must exist, have opted in, and be the same spawn. +fn pane_facts<'a>( + token: &PaneToken, + generation: PaneGeneration, + facts: Option<&'a PaneFacts>, +) -> Result<&'a PaneFacts, Refused> { + let Some(facts) = facts else { + return Err(Refused::UnknownPane { + token: token.clone(), + }); + }; + if !facts.opted_in { + return Err(Refused::NotOptedIn { + pane: facts.pane, + token: token.clone(), + }); + } + if generation != facts.generation { + return Err(Refused::StaleGeneration { + pane: facts.pane, + claimed: generation, + current: facts.generation, + }); + } + Ok(facts) +} + +#[cfg(test)] +#[path = "guard_tests/mod.rs"] +mod tests; diff --git a/src/plugin/guard_budget.rs b/src/plugin/guard_budget.rs new file mode 100644 index 00000000..c17c1b27 --- /dev/null +++ b/src/plugin/guard_budget.rs @@ -0,0 +1,123 @@ +//! How much a plugin may actually do to a pane, per pane, per window. +//! +//! The point is that a plugin's attempts are bounded and its failures visible: +//! a recovery flow that is not working must stop trying rather than type into a +//! pane forever. +//! +//! Counted per *slot*, keyed by [`PaneToken`], not per `PaneId`. A relaunch +//! always mints a new id, so an id-keyed budget would hand a fresh allowance to +//! every relaunch — a command that exits at once plus a plugin that relaunches +//! on every exit would then loop with nothing to stop it. The token is what +//! survives a relaunch, so it is what the ceiling has to be attached to. + +use crate::backend::PaneToken; +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +/// Window over which a pane's actions are counted. +pub const DEFAULT_RATE_WINDOW: Duration = Duration::from_secs(60); + +/// Approved inputs per pane per window by default. +/// +/// A recovery exchange is a prompt and perhaps a confirmation. More than a +/// handful in a minute is a loop, not a conversation. +pub const DEFAULT_MAX_SENDS: u32 = 5; + +/// Approved relaunches per slot per window by default. Fewer than sends: a +/// relaunch that did not take is unlikely to take on the fourth try either. +pub const DEFAULT_MAX_RELAUNCHES: u32 = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RateLimits { + pub max_sends_per_window: u32, + pub max_relaunches_per_window: u32, + pub window: Duration, +} + +impl Default for RateLimits { + fn default() -> Self { + Self { + max_sends_per_window: DEFAULT_MAX_SENDS, + max_relaunches_per_window: DEFAULT_MAX_RELAUNCHES, + window: DEFAULT_RATE_WINDOW, + } + } +} + +/// Which counter an action draws on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RateAction { + SendInput, + Relaunch, +} + +impl RateAction { + pub fn as_str(self) -> &'static str { + match self { + Self::SendInput => "send_input", + Self::Relaunch => "relaunch", + } + } +} + +#[derive(Debug, Default)] +struct PaneBudget { + sends: Vec, + relaunches: Vec, +} + +/// When each slot's approved actions happened, pruned as it is read. +#[derive(Debug, Default)] +pub(super) struct Budgets(HashMap); + +impl Budgets { + /// Charge one `action` against the slot's budget, if there is room. + /// + /// Nothing is recorded when there is not, so a refusal here costs the slot + /// nothing beyond the refusal itself. Each list is capped at its limit by + /// construction, so this stays O(limit). + pub(super) fn try_spend( + &mut self, + token: &PaneToken, + action: RateAction, + limits: &RateLimits, + now: Instant, + ) -> bool { + let budget = self.0.entry(token.clone()).or_default(); + let (stamps, max) = match action { + RateAction::SendInput => (&mut budget.sends, limits.max_sends_per_window), + RateAction::Relaunch => (&mut budget.relaunches, limits.max_relaunches_per_window), + }; + stamps.retain(|at| now.saturating_duration_since(*at) < limits.window); + if stamps.len() as u32 >= max { + return false; + } + stamps.push(now); + true + } + + pub(super) fn clear(&mut self, token: &PaneToken) { + self.0.remove(token); + } + + /// How much of `action`'s budget the slot has spent inside the window. Lets + /// a test assert on the budget itself rather than on the next refusal. + #[cfg(test)] + pub(super) fn spent( + &mut self, + token: &PaneToken, + action: RateAction, + limits: &RateLimits, + now: Instant, + ) -> u32 { + let Some(budget) = self.0.get_mut(token) else { + return 0; + }; + let stamps = match action { + RateAction::SendInput => &mut budget.sends, + RateAction::Relaunch => &mut budget.relaunches, + }; + stamps.retain(|at| now.saturating_duration_since(*at) < limits.window); + stamps.len() as u32 + } +} diff --git a/src/plugin/guard_refusal.rs b/src/plugin/guard_refusal.rs new file mode 100644 index 00000000..5729e0f5 --- /dev/null +++ b/src/plugin/guard_refusal.rs @@ -0,0 +1,109 @@ +//! Why a plugin's command was not carried out. +//! +//! Each variant carries what a log line needs to be actionable without the +//! reader having to correlate it with anything else: which pane, and the values +//! the rule compared. + +use super::guard_budget::RateAction; +use crate::backend::{PaneGeneration, PaneId, PaneToken}; +use std::fmt; +use std::time::Duration; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Refused { + /// The token names no pane the host still has. + UnknownPane { token: PaneToken }, + /// The pane exists but its `[[startup_command]]` did not name this plugin. + NotOptedIn { pane: PaneId, token: PaneToken }, + /// The command is about a process that has already been replaced. + StaleGeneration { + pane: PaneId, + claimed: PaneGeneration, + current: PaneGeneration, + }, + /// Input for a pane whose process has exited. + PaneNotRunning { pane: PaneId }, + /// Input for a pane that is still producing output. + PaneBusy { + pane: PaneId, + idle: Duration, + min_idle: Duration, + }, + InputTooLarge { + pane: PaneId, + bytes: usize, + limit: usize, + }, + /// Input holding a control character that is not `\r`, `\n`, or `\t`. + ControlCharacter { pane: PaneId, code: u32 }, + /// A relaunch for a pane whose process is still running. + PaneStillRunning { pane: PaneId }, + /// The resume arguments did not survive the command-line rules. + ResumeArgsRejected { pane: PaneId, reason: String }, + RateLimited { + pane: PaneId, + action: RateAction, + limit: u32, + window: Duration, + }, +} + +impl fmt::Display for Refused { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownPane { token } => { + write!(f, "no live pane for token {}", token.as_str()) + } + Self::NotOptedIn { pane, token } => write!( + f, + "pane {pane} (token {}) did not opt in to this plugin", + token.as_str() + ), + Self::StaleGeneration { + pane, + claimed, + current, + } => write!( + f, + "pane {pane} is on generation {current}, command claims {claimed}" + ), + Self::PaneNotRunning { pane } => { + write!(f, "pane {pane} has no running process to type into") + } + Self::PaneBusy { + pane, + idle, + min_idle, + } => write!( + f, + "pane {pane} has been idle {idle:?}, under the {min_idle:?} required" + ), + Self::InputTooLarge { pane, bytes, limit } => { + write!(f, "input for pane {pane} is {bytes} bytes, over {limit}") + } + Self::ControlCharacter { pane, code } => write!( + f, + "input for pane {pane} holds control character U+{code:04X}" + ), + Self::PaneStillRunning { pane } => { + write!( + f, + "pane {pane} is still running, so it cannot be relaunched" + ) + } + Self::ResumeArgsRejected { pane, reason } => { + write!(f, "relaunch of pane {pane} refused: {reason}") + } + Self::RateLimited { + pane, + action, + limit, + window, + } => write!( + f, + "pane {pane} already used its {limit} {} per {window:?}", + action.as_str() + ), + } + } +} diff --git a/src/plugin/guard_tests/budget.rs b/src/plugin/guard_tests/budget.rs new file mode 100644 index 00000000..5a3e4197 --- /dev/null +++ b/src/plugin/guard_tests/budget.rs @@ -0,0 +1,194 @@ +//! Rules 10-11 and `cancel`: how much a plugin may do, and what it costs. +//! +//! Every test here binds ONE token and reuses it. The budget is keyed by the +//! slot's token, so a fresh token per call would each get its own allowance and +//! the ceiling these tests exist to pin would never be reached. + +use super::*; + +fn limits(sends: u32, relaunches: u32) -> RateLimits { + RateLimits { + max_sends_per_window: sends, + max_relaunches_per_window: relaunches, + ..RateLimits::default() + } +} + +#[test] +fn sends_beyond_the_window_limit_are_refused() { + let limits = limits(2, 1); + let mut g = Guard::new(MIN_IDLE, limits); + let now = Instant::now(); + let t = token(); + for _ in 0..limits.max_sends_per_window { + assert!(g.judge(send(&t, "hi\n"), Some(&facts()), &[], now).is_ok()); + } + assert_eq!( + g.judge(send(&t, "hi\n"), Some(&facts()), &[], now) + .expect_err("refused"), + Refused::RateLimited { + pane: PANE, + action: RateAction::SendInput, + limit: 2, + window: limits.window + } + ); +} + +#[test] +fn relaunches_beyond_the_window_limit_are_refused() { + let mut g = Guard::new(MIN_IDLE, limits(2, 1)); + let now = Instant::now(); + let t = token(); + assert!( + g.judge(relaunch(&t, &[]), Some(&exited_facts()), &[], now) + .is_ok() + ); + assert!(matches!( + g.judge(relaunch(&t, &[]), Some(&exited_facts()), &[], now), + Err(Refused::RateLimited { + action: RateAction::Relaunch, + .. + }) + )); +} + +#[test] +fn a_relaunch_budget_is_not_refreshed_by_the_new_pane_id_it_produces() { + // The loop this closes: a relaunch always mints a new `PaneId`, so an + // id-keyed budget handed out a fresh allowance every time and a plugin + // answering every exit with another relaunch never hit the ceiling. The + // token is what a relaunch preserves, so it is what the ceiling counts. + let mut g = Guard::new(MIN_IDLE, limits(1, 1)); + let now = Instant::now(); + let t = token(); + + assert!( + g.judge(relaunch(&t, &[]), Some(&exited_facts()), &[], now) + .is_ok() + ); + + // The replacement process: same slot and so the same token, a different id, + // and the next generation. + let replacement = PaneFacts { + pane: PANE + 1, + generation: GENERATION + 1, + ..exited_facts() + }; + let mut cmd = relaunch(&t, &[]); + if let PluginCommand::Relaunch { generation, .. } = &mut cmd { + *generation = GENERATION + 1; + } + assert!(matches!( + g.judge(cmd, Some(&replacement), &[], now), + Err(Refused::RateLimited { + action: RateAction::Relaunch, + .. + }) + )); +} + +#[test] +fn two_panes_have_budgets_of_their_own() { + // Two opted-in panes in one repository is a supported layout, so one pane + // exhausting its allowance must not silence the other. + let mut g = Guard::new(MIN_IDLE, limits(1, 1)); + let now = Instant::now(); + let a = token(); + let b = token(); + + assert!(g.judge(send(&a, "hi\n"), Some(&facts()), &[], now).is_ok()); + assert!(g.judge(send(&a, "hi\n"), Some(&facts()), &[], now).is_err()); + assert!(g.judge(send(&b, "hi\n"), Some(&facts()), &[], now).is_ok()); +} + +#[test] +fn the_two_budgets_are_counted_separately() { + let mut g = Guard::new(MIN_IDLE, limits(1, 1)); + let now = Instant::now(); + let t = token(); + assert!(g.judge(send(&t, "hi\n"), Some(&facts()), &[], now).is_ok()); + // A spent send budget must not block the relaunch the pane is owed. + assert!( + g.judge(relaunch(&t, &[]), Some(&exited_facts()), &[], now) + .is_ok() + ); +} + +#[test] +fn a_zero_limit_refuses_every_attempt() { + let mut g = Guard::new(MIN_IDLE, limits(0, 0)); + assert!(matches!( + g.judge(send(&token(), "hi\n"), Some(&facts()), &[], Instant::now()), + Err(Refused::RateLimited { .. }) + )); +} + +#[test] +fn a_status_command_spends_no_budget() { + let mut g = Guard::new(MIN_IDLE, limits(1, 1)); + let now = Instant::now(); + let t = token(); + for _ in 0..5 { + assert!(g.judge(status(&t), Some(&facts()), &[], now).is_ok()); + } + assert!(g.judge(send(&t, "hi\n"), Some(&facts()), &[], now).is_ok()); +} + +#[test] +fn budget_spent_before_the_window_elapsed_is_available_again_after_it() { + let limits = limits(1, 1); + let mut g = Guard::new(MIN_IDLE, limits); + let start = Instant::now(); + let t = token(); + assert!( + g.judge(send(&t, "hi\n"), Some(&facts()), &[], start) + .is_ok() + ); + assert!( + g.judge(send(&t, "hi\n"), Some(&facts()), &[], start + limits.window) + .is_ok() + ); +} + +#[test] +fn a_refused_command_does_not_consume_budget() { + let limits = limits(1, 1); + let mut g = Guard::new(MIN_IDLE, limits); + let now = Instant::now(); + let t = token(); + let busy = PaneFacts { + idle: Duration::ZERO, + ..facts() + }; + // Refusals are what a plugin produces by racing or by being wrong, and they + // must not cost the pane the one legitimate attempt it is owed. + for _ in 0..5 { + assert!(g.judge(send(&t, "hi\n"), Some(&busy), &[], now).is_err()); + } + assert_eq!(g.budgets.spent(&t, RateAction::SendInput, &limits, now), 0); + assert!(g.judge(send(&t, "hi\n"), Some(&facts()), &[], now).is_ok()); +} + +#[test] +fn cancelling_a_pane_clears_its_spent_budget() { + let limits = limits(1, 1); + let mut g = Guard::new(MIN_IDLE, limits); + let now = Instant::now(); + let t = token(); + assert!(g.judge(send(&t, "hi\n"), Some(&facts()), &[], now).is_ok()); + assert!(g.judge(send(&t, "hi\n"), Some(&facts()), &[], now).is_err()); + + g.cancel(&t); + + assert_eq!(g.budgets.spent(&t, RateAction::SendInput, &limits, now), 0); + assert!(g.judge(send(&t, "hi\n"), Some(&facts()), &[], now).is_ok()); +} + +#[test] +fn cancelling_a_pane_that_holds_no_state_is_harmless() { + let mut g = guard(); + let t = token(); + g.cancel(&t); + g.cancel(&t); +} diff --git a/src/plugin/guard_tests/identity.rs b/src/plugin/guard_tests/identity.rs new file mode 100644 index 00000000..5aa08b28 --- /dev/null +++ b/src/plugin/guard_tests/identity.rs @@ -0,0 +1,126 @@ +//! Rules 1-4: what a command must prove before any of its own rules run. + +use super::*; + +#[test] +fn a_log_command_is_always_allowed_without_a_pane() { + let mut g = guard(); + let cmd = PluginCommand::Log { + v: PROTOCOL_VERSION, + level: LogLevel::Info, + message: "watching".to_string(), + }; + assert_eq!( + g.judge(cmd, None, &[], Instant::now()).expect("allowed"), + Approved::Log { + level: LogLevel::Info, + message: "watching".to_string() + } + ); +} + +#[test] +fn an_over_long_log_message_is_truncated_rather_than_refused() { + let mut g = guard(); + let cmd = PluginCommand::Log { + v: PROTOCOL_VERSION, + level: LogLevel::Warn, + // Multi-byte so a naive truncation would split a character. + message: "가".repeat(MAX_LOG_MESSAGE_BYTES), + }; + let Approved::Log { message, .. } = g.judge(cmd, None, &[], Instant::now()).expect("allowed") + else { + panic!("expected a log approval"); + }; + assert!(message.len() <= MAX_LOG_MESSAGE_BYTES); + assert!(message.chars().all(|c| c == '가')); +} + +#[test] +fn a_command_for_a_token_with_no_live_pane_is_refused() { + let mut g = guard(); + let t = token(); + assert_eq!( + g.judge(send(&t, "hi\n"), None, &[], Instant::now()) + .expect_err("refused"), + Refused::UnknownPane { token: t } + ); +} + +#[test] +fn a_command_for_a_pane_that_did_not_opt_in_is_refused() { + let mut g = guard(); + let t = token(); + let facts = PaneFacts { + opted_in: false, + ..facts() + }; + assert_eq!( + g.judge(send(&t, "hi\n"), Some(&facts), &[], Instant::now()) + .expect_err("refused"), + Refused::NotOptedIn { + pane: PANE, + token: t + } + ); +} + +#[test] +fn a_status_command_for_a_pane_that_did_not_opt_in_is_refused_too() { + let mut g = guard(); + let facts = PaneFacts { + opted_in: false, + ..facts() + }; + assert!(matches!( + g.judge(status(&token()), Some(&facts), &[], Instant::now()), + Err(Refused::NotOptedIn { .. }) + )); +} + +#[test] +fn a_stale_generation_command_is_refused() { + let mut g = guard(); + let facts = PaneFacts { + generation: GENERATION + 1, + ..facts() + }; + assert_eq!( + g.judge(send(&token(), "hi\n"), Some(&facts), &[], Instant::now()) + .expect_err("refused"), + Refused::StaleGeneration { + pane: PANE, + claimed: GENERATION, + current: GENERATION + 1 + } + ); +} + +#[test] +fn a_stale_generation_is_refused_for_a_relaunch_too() { + let mut g = guard(); + let facts = PaneFacts { + generation: GENERATION + 3, + ..exited_facts() + }; + assert!(matches!( + g.judge(relaunch(&token(), &[]), Some(&facts), &[], Instant::now()), + Err(Refused::StaleGeneration { .. }) + )); +} + +#[test] +fn a_status_command_is_approved_with_its_fields_intact() { + let mut g = guard(); + assert_eq!( + g.judge(status(&token()), Some(&facts()), &[], Instant::now()) + .expect("allowed"), + Approved::Status { + pane: PANE, + state: "waiting".to_string(), + detail: None, + deadline_epoch: Some(42), + attempt: 2 + } + ); +} diff --git a/src/plugin/guard_tests/input.rs b/src/plugin/guard_tests/input.rs new file mode 100644 index 00000000..d3dd3930 --- /dev/null +++ b/src/plugin/guard_tests/input.rs @@ -0,0 +1,112 @@ +//! Rules 5-7: when input may be typed into a pane, and what it may contain. + +use super::*; + +#[test] +fn sending_input_to_a_pane_whose_process_exited_is_refused() { + let mut g = guard(); + assert_eq!( + g.judge( + send(&token(), "hi\n"), + Some(&exited_facts()), + &[], + Instant::now() + ) + .expect_err("refused"), + Refused::PaneNotRunning { pane: PANE } + ); +} + +#[test] +fn sending_input_to_a_pane_that_is_still_talking_is_refused() { + let mut g = guard(); + let facts = PaneFacts { + idle: MIN_IDLE - Duration::from_millis(1), + ..facts() + }; + assert!(matches!( + g.judge(send(&token(), "hi\n"), Some(&facts), &[], Instant::now()), + Err(Refused::PaneBusy { pane: PANE, .. }) + )); +} + +#[test] +fn input_exactly_at_the_idle_threshold_is_allowed() { + let mut g = guard(); + // The threshold is inclusive: a pane quiet for exactly min_idle qualifies. + assert_eq!( + g.judge(send(&token(), "hi\n"), Some(&facts()), &[], Instant::now()) + .expect("allowed"), + Approved::SendInput { + pane: PANE, + data: b"hi\n".to_vec() + } + ); +} + +#[test] +fn oversized_input_is_refused() { + let mut g = guard(); + let data = "a".repeat(MAX_INPUT_BYTES + 1); + assert_eq!( + g.judge(send(&token(), &data), Some(&facts()), &[], Instant::now()) + .expect_err("refused"), + Refused::InputTooLarge { + pane: PANE, + bytes: MAX_INPUT_BYTES + 1, + limit: MAX_INPUT_BYTES + } + ); +} + +#[test] +fn input_exactly_at_the_size_limit_is_allowed() { + let mut g = guard(); + let data = "a".repeat(MAX_INPUT_BYTES); + assert!( + g.judge(send(&token(), &data), Some(&facts()), &[], Instant::now()) + .is_ok() + ); +} + +#[test] +fn empty_input_is_allowed() { + let mut g = guard(); + assert!( + g.judge(send(&token(), ""), Some(&facts()), &[], Instant::now()) + .is_ok() + ); +} + +#[test] +fn only_carriage_return_newline_and_tab_are_accepted_as_control_characters() { + let cases: &[(&str, bool)] = &[ + ("plain text", true), + ("line\n", true), + ("line\r\n", true), + ("col\tcol", true), + ("한글도 통과한다\n", true), + ("\u{1b}[2J", false), + ("bell\u{7}", false), + ("nul\u{0}", false), + ("del\u{7f}", false), + ("csi\u{9b}31m", false), + ("back\u{8}space", false), + ("vertical\u{b}tab", false), + ]; + for (data, allowed) in cases { + let mut g = guard(); + let verdict = g.judge(send(&token(), data), Some(&facts()), &[], Instant::now()); + assert_eq!( + verdict.is_ok(), + *allowed, + "input {data:?} should be allowed={allowed}" + ); + if !allowed { + assert!(matches!( + verdict.expect_err("refused"), + Refused::ControlCharacter { pane: PANE, .. } + )); + } + } +} diff --git a/src/plugin/guard_tests/mod.rs b/src/plugin/guard_tests/mod.rs new file mode 100644 index 00000000..e939b633 --- /dev/null +++ b/src/plugin/guard_tests/mod.rs @@ -0,0 +1,77 @@ +//! Guard rules, one module per group of rules, sharing the fixtures below. + +use super::*; +use crate::plugin::guard_text::MAX_LOG_MESSAGE_BYTES; +use crate::plugin::protocol::PROTOCOL_VERSION; + +mod budget; +mod identity; +mod input; +mod relaunch; + +pub(super) const PANE: PaneId = 4; +pub(super) const GENERATION: PaneGeneration = 9; +pub(super) const MIN_IDLE: Duration = Duration::from_secs(10); +pub(super) const LAUNCH: &str = "provider-cli"; + +pub(super) fn token() -> PaneToken { + PaneToken::new().expect("OS RNG") +} + +pub(super) fn guard() -> Guard { + Guard::new(MIN_IDLE, RateLimits::default()) +} + +/// A pane that passes every precondition, so each test spoils exactly one. +pub(super) fn facts() -> PaneFacts { + PaneFacts { + pane: PANE, + generation: GENERATION, + opted_in: true, + alive: true, + idle: MIN_IDLE, + launch_command: Some(LAUNCH.to_string()), + } +} + +/// The same pane after its process ended, which is what a relaunch needs. +pub(super) fn exited_facts() -> PaneFacts { + PaneFacts { + alive: false, + ..facts() + } +} + +pub(super) fn send(token: &PaneToken, data: &str) -> PluginCommand { + PluginCommand::SendInput { + v: PROTOCOL_VERSION, + token: token.clone(), + generation: GENERATION, + data: data.to_string(), + } +} + +pub(super) fn relaunch(token: &PaneToken, args: &[&str]) -> PluginCommand { + PluginCommand::Relaunch { + v: PROTOCOL_VERSION, + token: token.clone(), + generation: GENERATION, + resume_args: args.iter().map(|a| a.to_string()).collect(), + } +} + +pub(super) fn status(token: &PaneToken) -> PluginCommand { + PluginCommand::Status { + v: PROTOCOL_VERSION, + token: token.clone(), + generation: GENERATION, + state: "waiting".to_string(), + detail: None, + deadline_epoch: Some(42), + attempt: 2, + } +} + +pub(super) fn flags(names: &[&str]) -> Vec { + names.iter().map(|n| n.to_string()).collect() +} diff --git a/src/plugin/guard_tests/relaunch.rs b/src/plugin/guard_tests/relaunch.rs new file mode 100644 index 00000000..f62e461c --- /dev/null +++ b/src/plugin/guard_tests/relaunch.rs @@ -0,0 +1,96 @@ +//! Rules 8-9: when a pane's process may be replaced, and with what. + +use super::*; + +#[test] +fn relaunching_a_pane_whose_process_is_still_running_is_refused() { + let mut g = guard(); + assert_eq!( + g.judge(relaunch(&token(), &[]), Some(&facts()), &[], Instant::now()) + .expect_err("refused"), + Refused::PaneStillRunning { pane: PANE } + ); +} + +#[test] +fn a_relaunch_with_no_arguments_reproduces_the_original_command() { + let mut g = guard(); + let approved = g + .judge( + relaunch(&token(), &[]), + Some(&exited_facts()), + &[], + Instant::now(), + ) + .expect("allowed"); + assert_eq!( + approved, + Approved::Relaunch { + pane: PANE, + resume_args: Vec::new(), + command_line: LAUNCH.to_string() + } + ); +} + +#[test] +fn a_relaunch_flag_outside_the_allowed_list_is_refused() { + let mut g = guard(); + assert!(matches!( + g.judge( + relaunch(&token(), &["--continue"]), + Some(&exited_facts()), + &flags(&["--resume"]), + Instant::now() + ), + Err(Refused::ResumeArgsRejected { pane: PANE, .. }) + )); +} + +#[test] +fn a_relaunch_argument_holding_shell_metacharacters_is_refused() { + let mut g = guard(); + assert!(matches!( + g.judge( + relaunch(&token(), &["a; rm -rf /"]), + Some(&exited_facts()), + &[], + Instant::now() + ), + Err(Refused::ResumeArgsRejected { pane: PANE, .. }) + )); +} + +#[test] +fn a_relaunch_with_an_allowed_flag_carries_the_built_command_line() { + let mut g = guard(); + let approved = g + .judge( + relaunch(&token(), &["--resume", "abc123"]), + Some(&exited_facts()), + &flags(&["--resume"]), + Instant::now(), + ) + .expect("allowed"); + assert_eq!( + approved, + Approved::Relaunch { + pane: PANE, + resume_args: vec!["--resume".to_string(), "abc123".to_string()], + command_line: format!("{LAUNCH} '--resume' 'abc123'"), + } + ); +} + +#[test] +fn relaunching_a_pane_with_no_configured_command_is_refused() { + let mut g = guard(); + let facts = PaneFacts { + launch_command: None, + ..exited_facts() + }; + assert!(matches!( + g.judge(relaunch(&token(), &[]), Some(&facts), &[], Instant::now()), + Err(Refused::ResumeArgsRejected { pane: PANE, .. }) + )); +} diff --git a/src/plugin/guard_text.rs b/src/plugin/guard_text.rs new file mode 100644 index 00000000..60831837 --- /dev/null +++ b/src/plugin/guard_text.rs @@ -0,0 +1,32 @@ +//! Bounding and sanitising the text a plugin sends. + +/// Longest log message the host will keep from a plugin. +/// +/// A log line is unbounded text from an untrusted process heading for a file +/// that rotates on size, so truncating is what keeps a chatty plugin from being +/// a way to fill a disk. +pub const MAX_LOG_MESSAGE_BYTES: usize = 2 * 1024; + +/// Whether a character may not appear in input typed into a pane. +/// +/// Input stands in for a human at a keyboard, and a keyboard produces `\r`, +/// `\n` and `\t` and no other control characters. Anything else is an escape +/// sequence: a plugin that could send ESC could reprogram the emulator, move the +/// cursor over what the user is reading, or drive the running CLI's own key +/// bindings. +pub(super) fn is_forbidden_control(c: char) -> bool { + c.is_control() && !matches!(c, '\r' | '\n' | '\t') +} + +/// Cut a message to [`MAX_LOG_MESSAGE_BYTES`], on a character boundary. +pub(super) fn truncate_message(mut message: String) -> String { + if message.len() <= MAX_LOG_MESSAGE_BYTES { + return message; + } + let mut end = MAX_LOG_MESSAGE_BYTES; + while end > 0 && !message.is_char_boundary(end) { + end -= 1; + } + message.truncate(end); + message +} diff --git a/src/plugin/host.rs b/src/plugin/host.rs new file mode 100644 index 00000000..56c2f63e --- /dev/null +++ b/src/plugin/host.rs @@ -0,0 +1,264 @@ +//! One long-lived child process per enabled plugin. +//! +//! The host owns the process and three pump threads (see +//! [`host_pump`](super::host_pump)) and exposes only two non-blocking +//! operations: queue an event, take a decoded command. Nothing a plugin does can +//! make either of those block, because the terminal hub calls them on the thread +//! that also serves every pane. +//! +//! A plugin child is not one of `PtyBackend`'s panes, so [`PluginHost`] is the +//! only place it is ever reaped — hence `shutdown` running from `Drop` too. + +use super::host_pump; +use super::protocol::{PROTOCOL_VERSION, PluginCommand, PluginEvent, encode_event}; +use crate::config::PluginConfig; +use crate::platform::threading::{REAP_TIMEOUT, try_timed_join}; +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{self, Receiver, SyncSender, TrySendError}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +/// Events that may sit unwritten before further ones are dropped. +/// +/// Bounded on purpose: the queue exists to absorb a plugin that is briefly busy, +/// not to buffer one that has stopped reading. Deep enough to cover a burst of +/// output from every pane at once, shallow enough that a wedged plugin's backlog +/// is bounded memory rather than a growing one. +pub const OUTBOUND_QUEUE_DEPTH: usize = 256; + +/// Commands that may sit undrained before the reader stops accepting more. +/// +/// Bounded for the same reason the outbound queue is, from the other direction: +/// the hub drains only a handful per tick, so an unbounded inbound queue let a +/// plugin writing faster than that grow the host's memory without limit. +/// +/// Unlike the outbound side this blocks rather than drops — the reader thread +/// stalls, the plugin's stdout pipe fills, and the plugin blocks writing. That +/// is backpressure onto whoever is being too loud, and it loses no command a +/// well-behaved plugin sent. Shutdown still ends the thread, because dropping +/// the receiver makes the blocked send fail. +const INBOUND_QUEUE_DEPTH: usize = 256; + +/// How long a plugin gets to exit on its own after being told to. +const CHILD_EXIT_GRACE: Duration = Duration::from_millis(200); + +/// Gap between `try_wait` polls while waiting out [`CHILD_EXIT_GRACE`]. +const CHILD_EXIT_POLL: Duration = Duration::from_millis(5); + +pub struct PluginHost { + name: String, + /// Behind a mutex so [`Self::is_alive`] can ask the OS without `&mut`: the + /// hub holds hosts immutably while dispatching events. + child: Mutex, + /// Held in an `Option` so shutdown can drop it, which is what ends the + /// writer thread and closes the plugin's stdin. + events: Option>, + commands: Receiver, + dropped: Arc, + writer: Option>, + reader: Option>, + stderr: Option>, + shut_down: bool, +} + +impl PluginHost { + /// Launch `cfg.command` and start pumping. + /// + /// Resolution order for the program: a `cfg.command` containing a path + /// separator is taken as a path and used as given; otherwise `plugin_dir` is + /// searched first, so an installed plugin wins over a same-named binary on + /// the user's `PATH`, and only if it is not there is the bare name handed to + /// the OS to resolve against `PATH`. + /// + /// No pane token is passed in the environment. A plugin learns which panes + /// exist only from the events it is sent, which is what keeps a plugin from + /// addressing a pane that never opted in to it. + pub fn spawn(cfg: &PluginConfig, plugin_dir: Option<&Path>) -> Result { + Self::spawn_with_queue_depth(cfg, plugin_dir, OUTBOUND_QUEUE_DEPTH) + } + + fn spawn_with_queue_depth( + cfg: &PluginConfig, + plugin_dir: Option<&Path>, + depth: usize, + ) -> Result { + let program = resolve_program(&cfg.command, plugin_dir); + let mut child = Command::new(&program) + .args(&cfg.args) + .envs(&cfg.env) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| { + format!( + "cannot launch plugin \"{}\" from {}", + cfg.name, + program.display() + ) + })?; + + // Every pipe was requested above, so these are present; the context + // still says which one is missing rather than unwrapping blind. + let stdin = child.stdin.take().context("plugin stdin pipe missing")?; + let stdout = child.stdout.take().context("plugin stdout pipe missing")?; + let stderr = child.stderr.take().context("plugin stderr pipe missing")?; + + let (events_tx, events_rx) = mpsc::sync_channel::(depth); + let (commands_tx, commands_rx) = mpsc::sync_channel::(INBOUND_QUEUE_DEPTH); + let name = cfg.name.clone(); + + let writer_name = name.clone(); + let reader_name = name.clone(); + let stderr_name = name.clone(); + Ok(PluginHost { + name, + child: Mutex::new(child), + events: Some(events_tx), + commands: commands_rx, + dropped: Arc::new(AtomicU64::new(0)), + writer: Some(thread::spawn(move || { + host_pump::write_events(stdin, events_rx, writer_name) + })), + reader: Some(thread::spawn(move || { + host_pump::read_commands(stdout, commands_tx, reader_name) + })), + stderr: Some(thread::spawn(move || { + host_pump::drain_stderr(stderr, stderr_name) + })), + shut_down: false, + }) + } + + pub fn name(&self) -> &str { + &self.name + } + + /// Queue one event. `false` means it was not queued — the plugin is behind, + /// gone, or the event could not be encoded — and the caller carries on + /// regardless: a slow plugin must never stall the pane it is watching. + pub fn send(&self, ev: &PluginEvent) -> bool { + let line = match encode_event(ev) { + Ok(line) => line, + Err(error) => { + tracing::warn!(plugin = %self.name, %error, "dropping unencodable plugin event"); + self.dropped.fetch_add(1, Ordering::Relaxed); + return false; + } + }; + let Some(events) = self.events.as_ref() else { + return false; + }; + match events.try_send(line) { + Ok(()) => true, + Err(TrySendError::Full(_)) => { + self.dropped.fetch_add(1, Ordering::Relaxed); + false + } + Err(TrySendError::Disconnected(_)) => false, + } + } + + /// Take one decoded command, if the plugin has sent one. Never blocks. + pub fn try_recv(&self) -> Option { + self.commands.try_recv().ok() + } + + /// How many events were thrown away rather than queued. Rises whenever the + /// plugin cannot keep up, so it is the signal that a plugin is wedged. + pub fn dropped_events(&self) -> u64 { + self.dropped.load(Ordering::Relaxed) + } + + pub fn is_alive(&self) -> bool { + matches!(self.locked_child().try_wait(), Ok(None)) + } + + /// Tell the plugin to stop, then make sure it has. Idempotent. + pub fn shutdown(&mut self) { + if self.shut_down { + return; + } + self.shut_down = true; + + self.send(&PluginEvent::Shutdown { + v: PROTOCOL_VERSION, + }); + // Ends the writer thread, which drops the plugin's stdin: the plugin + // sees EOF even if it never looked at the shutdown event. + self.events = None; + if let Some(writer) = self.writer.take() { + try_timed_join(writer, REAP_TIMEOUT); + } + + if !self.wait_for_exit() { + let mut child = self.locked_child(); + if let Err(error) = child.kill() { + tracing::warn!(plugin = %self.name, %error, "cannot kill plugin process"); + } + // Reaps the zombie; the kill above only delivers the signal. + if let Err(error) = child.wait() { + tracing::warn!(plugin = %self.name, %error, "cannot reap plugin process"); + } + } + + // Both read a pipe the dead child owned, so both are at EOF by now. + for handle in [self.reader.take(), self.stderr.take()] + .into_iter() + .flatten() + { + try_timed_join(handle, REAP_TIMEOUT); + } + } + + /// Poll for a clean exit within [`CHILD_EXIT_GRACE`]. `true` if it happened. + fn wait_for_exit(&self) -> bool { + let deadline = Instant::now() + CHILD_EXIT_GRACE; + loop { + match self.locked_child().try_wait() { + Ok(Some(_)) => return true, + // Cannot be asked about, so waiting longer will not help. + Err(_) => return true, + Ok(None) => {} + } + if Instant::now() >= deadline { + return false; + } + thread::sleep(CHILD_EXIT_POLL); + } + } + + /// The child, recovering from a poisoned lock rather than panicking: a + /// panic while holding it would otherwise make the process unreapable. + fn locked_child(&self) -> std::sync::MutexGuard<'_, Child> { + self.child.lock().unwrap_or_else(|e| e.into_inner()) + } +} + +impl Drop for PluginHost { + fn drop(&mut self) { + self.shutdown(); + } +} + +/// See [`PluginHost::spawn`] for the order and why it is that way. +fn resolve_program(command: &str, plugin_dir: Option<&Path>) -> PathBuf { + if command.contains(std::path::MAIN_SEPARATOR) || command.contains('/') { + return PathBuf::from(command); + } + if let Some(dir) = plugin_dir { + let candidate = dir.join(command); + if candidate.is_file() { + return candidate; + } + } + PathBuf::from(command) +} + +#[cfg(all(test, unix))] +#[path = "host_tests.rs"] +mod tests; diff --git a/src/plugin/host_pump.rs b/src/plugin/host_pump.rs new file mode 100644 index 00000000..e26033e3 --- /dev/null +++ b/src/plugin/host_pump.rs @@ -0,0 +1,161 @@ +//! The three pump threads behind a [`PluginHost`](super::host::PluginHost): +//! events out, commands in, and diagnostics off stderr. +//! +//! Every loop here ends on its own when the pipe it holds reaches EOF or the +//! peer it talks to is gone, so shutdown never has to interrupt one — it closes +//! a pipe and reaps. + +use super::protocol::{MAX_LINE_BYTES, PluginCommand, decode_command, is_blank_line}; +use std::io::{BufRead, BufReader, Write}; +use std::process::{ChildStderr, ChildStdin, ChildStdout}; +use std::sync::mpsc::{Receiver, SyncSender}; + +/// What one read off a plugin's stdout produced. +enum Pulled { + Line(String), + /// Over [`MAX_LINE_BYTES`]; the rest of the line was thrown away so the + /// next newline resynchronises the stream. + TooLong(usize), + NotUtf8, + Eof, +} + +/// Write encoded event lines to the plugin's stdin until the queue closes. +/// +/// Dropping `stdin` on return is what tells the plugin the host is done, so +/// this returning is a meaningful signal and not only a thread ending. +pub(super) fn write_events(mut stdin: ChildStdin, rx: Receiver, name: String) { + for line in rx { + let written = stdin + .write_all(line.as_bytes()) + .and_then(|()| stdin.write_all(b"\n")) + .and_then(|()| stdin.flush()); + if let Err(error) = written { + tracing::warn!(plugin = %name, %error, "plugin stdin unusable; event writer stopping"); + return; + } + } +} + +/// Decode commands off the plugin's stdout until EOF or nobody is listening. +pub(super) fn read_commands(stdout: ChildStdout, tx: SyncSender, name: String) { + let mut reader = BufReader::new(stdout); + loop { + match pull_line(&mut reader) { + Ok(Pulled::Line(line)) => { + if is_blank_line(&line) { + continue; + } + match decode_command(&line) { + Ok(command) => { + if tx.send(command).is_err() { + return; + } + } + Err(error) => { + tracing::warn!(plugin = %name, %error, "refused a line from plugin") + } + } + } + Ok(Pulled::TooLong(bytes)) => tracing::warn!( + plugin = %name, + bytes, + limit = MAX_LINE_BYTES, + "discarded an over-long line from plugin" + ), + Ok(Pulled::NotUtf8) => { + tracing::warn!(plugin = %name, "discarded a line from plugin that is not UTF-8") + } + Ok(Pulled::Eof) => return, + Err(error) => { + tracing::warn!(plugin = %name, %error, "plugin stdout unusable; reader stopping"); + return; + } + } + } +} + +/// Relay the plugin's stderr into the host's log. +/// +/// Drained rather than ignored for two reasons: a plugin that dies needs to be +/// diagnosable, and an undrained stderr pipe fills and blocks the plugin inside +/// its own `write`, which looks exactly like a hang. +pub(super) fn drain_stderr(stderr: ChildStderr, name: String) { + let mut reader = BufReader::new(stderr); + loop { + match pull_line(&mut reader) { + Ok(Pulled::Line(line)) => { + if !is_blank_line(&line) { + tracing::warn!(plugin = %name, "{line}"); + } + } + Ok(Pulled::TooLong(bytes)) => { + tracing::warn!(plugin = %name, bytes, "discarded an over-long stderr line") + } + Ok(Pulled::NotUtf8) => { + tracing::warn!(plugin = %name, "discarded a stderr line that is not UTF-8") + } + Ok(Pulled::Eof) => return, + Err(error) => { + tracing::warn!(plugin = %name, %error, "plugin stderr unusable; drain stopping"); + return; + } + } + } +} + +/// Read one line, or say why there is none to decode. +fn pull_line(reader: &mut impl BufRead) -> std::io::Result { + let mut buf = Vec::new(); + let (seen, terminated) = read_capped(reader, &mut buf)?; + if seen == 0 { + return Ok(Pulled::Eof); + } + let line_bytes = seen - usize::from(terminated); + if line_bytes > MAX_LINE_BYTES { + // Everything up to the newline was consumed and only the cap's worth + // kept, so the stream is already resynchronised. + return Ok(Pulled::TooLong(line_bytes)); + } + if buf.last() == Some(&b'\r') { + buf.pop(); + } + match String::from_utf8(buf) { + Ok(line) => Ok(Pulled::Line(line)), + Err(_) => Ok(Pulled::NotUtf8), + } +} + +/// Consume bytes up to and including the next newline, keeping at most +/// [`MAX_LINE_BYTES`] of them in `buf` (the newline is never kept). +/// +/// Returns how many bytes the line actually spanned and whether a newline ended +/// it. The cap is applied while reading rather than left to +/// [`decode_command`]'s own length check: by the time that runs the host has +/// already allocated whatever the plugin chose to send, which is the thing worth +/// preventing. +fn read_capped(reader: &mut impl BufRead, buf: &mut Vec) -> std::io::Result<(usize, bool)> { + let mut seen = 0; + loop { + let available = match reader.fill_buf() { + Ok(available) => available, + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + }; + if available.is_empty() { + return Ok((seen, false)); + } + let (take, terminated) = match available.iter().position(|b| *b == b'\n') { + Some(at) => (at, true), + None => (available.len(), false), + }; + let room = MAX_LINE_BYTES.saturating_sub(buf.len()); + buf.extend_from_slice(&available[..take.min(room)]); + let consumed = take + usize::from(terminated); + reader.consume(consumed); + seen += consumed; + if terminated { + return Ok((seen, true)); + } + } +} diff --git a/src/plugin/host_tests.rs b/src/plugin/host_tests.rs new file mode 100644 index 00000000..81f08962 --- /dev/null +++ b/src/plugin/host_tests.rs @@ -0,0 +1,181 @@ +//! Round-trip tests against a real `/bin/sh` child, which is guaranteed present +//! wherever these run. + +use super::*; +use crate::backend::PaneToken; +use crate::backend::identity::FIRST_GENERATION; +use crate::plugin::protocol::{LogLevel, MAX_LINE_BYTES}; + +/// Generous like the PTY tests': spawning processes under a parallel test run is +/// slow, and the point of every deadline here is only to fail rather than hang. +const HOST_TEST_DEADLINE: Duration = Duration::from_secs(15); + +const POLL: Duration = Duration::from_millis(10); + +/// Env var each script reads the command it should echo back from. +const CANNED_ENV: &str = "CANNED"; + +const CANNED_MESSAGE: &str = "round trip"; + +fn canned_line() -> String { + serde_json::to_string(&PluginCommand::Log { + v: PROTOCOL_VERSION, + level: LogLevel::Info, + message: CANNED_MESSAGE.to_string(), + }) + .expect("encode a command") +} + +fn shell_plugin(script: &str) -> PluginConfig { + PluginConfig { + name: "test-plugin".to_string(), + command: "/bin/sh".to_string(), + args: vec!["-c".to_string(), script.to_string()], + env: [(CANNED_ENV.to_string(), canned_line())] + .into_iter() + .collect(), + enabled: true, + ..PluginConfig::default() + } +} + +fn pane_opened() -> PluginEvent { + PluginEvent::PaneOpened { + v: PROTOCOL_VERSION, + token: PaneToken::new().expect("OS RNG"), + generation: FIRST_GENERATION, + title: None, + command: None, + cwd: "/".to_string(), + } +} + +fn wait_for_command(host: &PluginHost) -> Option { + let deadline = Instant::now() + HOST_TEST_DEADLINE; + while Instant::now() < deadline { + if let Some(cmd) = host.try_recv() { + return Some(cmd); + } + thread::sleep(POLL); + } + None +} + +fn assert_is_canned(cmd: Option) { + match cmd { + Some(PluginCommand::Log { message, .. }) => assert_eq!(message, CANNED_MESSAGE), + other => panic!("expected the canned log command, got {other:?}"), + } +} + +#[test] +fn an_event_reaches_the_plugin_and_the_command_it_answers_with_is_decoded() { + // The command only comes back if the line the plugin read was the event, so + // receiving it proves both directions. + let cfg = + shell_plugin(r#"read line; case "$line" in *pane_opened*) printf '%s\n' "$CANNED";; esac"#); + let mut host = PluginHost::spawn(&cfg, None).expect("spawn"); + assert!(host.send(&pane_opened())); + assert_is_canned(wait_for_command(&host)); + host.shutdown(); + assert_eq!(host.dropped_events(), 0); +} + +#[test] +fn a_blank_line_from_the_plugin_is_skipped_rather_than_ending_the_stream() { + let cfg = shell_plugin(r#"printf '\n \n%s\n' "$CANNED""#); + let mut host = PluginHost::spawn(&cfg, None).expect("spawn"); + assert_is_canned(wait_for_command(&host)); + host.shutdown(); +} + +#[test] +fn a_line_the_host_cannot_decode_does_not_stop_later_commands() { + let cfg = shell_plugin(r#"printf 'not json\n{"cmd":"nope"}\n%s\n' "$CANNED""#); + let mut host = PluginHost::spawn(&cfg, None).expect("spawn"); + assert_is_canned(wait_for_command(&host)); + host.shutdown(); +} + +#[test] +fn an_over_long_line_is_discarded_and_the_stream_resynchronises() { + // 70 KiB in 1000-byte chunks, past MAX_LINE_BYTES, then a valid command: + // the host must survive the first and still deliver the second. + const CHUNKS: usize = 70; + const CHUNK_BYTES: usize = 1000; + const { assert!(CHUNKS * CHUNK_BYTES > MAX_LINE_BYTES) }; + let cfg = shell_plugin( + r#"i=0; while [ $i -lt 70 ]; do printf '%01000d' 0; i=$((i+1)); done; printf '\n'; printf '%s\n' "$CANNED""#, + ); + let mut host = PluginHost::spawn(&cfg, None).expect("spawn"); + assert_is_canned(wait_for_command(&host)); + host.shutdown(); +} + +#[test] +fn a_plugin_that_exits_at_once_is_reported_not_alive_and_shuts_down_cleanly() { + let cfg = shell_plugin("exit 0"); + let mut host = PluginHost::spawn(&cfg, None).expect("spawn"); + let deadline = Instant::now() + HOST_TEST_DEADLINE; + while host.is_alive() && Instant::now() < deadline { + thread::sleep(POLL); + } + assert!(!host.is_alive(), "plugin should have exited"); + host.shutdown(); + assert!(!host.is_alive()); +} + +#[test] +fn shutting_down_twice_is_safe() { + let cfg = shell_plugin("cat"); + let mut host = PluginHost::spawn(&cfg, None).expect("spawn"); + assert!(host.is_alive()); + host.shutdown(); + host.shutdown(); + assert!(!host.is_alive()); + // The second call must not have queued anything either. + assert!(!host.send(&pane_opened())); +} + +#[test] +fn a_full_outbound_queue_drops_events_instead_of_blocking() { + // Depth 1 against a plugin that never reads: the pipe fills, the writer + // blocks inside its write, and every further event has nowhere to go. + let cfg = shell_plugin("sleep 30"); + let mut host = PluginHost::spawn_with_queue_depth(&cfg, None, 1).expect("spawn"); + let bulky = PluginEvent::PaneOutput { + v: PROTOCOL_VERSION, + token: PaneToken::new().expect("OS RNG"), + generation: FIRST_GENERATION, + text: "a".repeat(8 * 1024), + }; + + let deadline = Instant::now() + HOST_TEST_DEADLINE; + let mut refused = false; + while !refused && Instant::now() < deadline { + refused = !host.send(&bulky); + } + + assert!(refused, "a full queue should have refused an event"); + assert!(host.dropped_events() > 0); + host.shutdown(); +} + +#[test] +fn a_command_named_by_path_is_launched_from_that_path() { + // "/bin/sh" holds a separator, so it is used as given rather than searched + // for in the plugin directory. + let cfg = shell_plugin("exit 0"); + let host = PluginHost::spawn(&cfg, Some(Path::new("/nonexistent"))).expect("spawn"); + assert_eq!(host.name(), "test-plugin"); +} + +#[test] +fn a_plugin_that_cannot_be_launched_is_reported_rather_than_ignored() { + let mut cfg = shell_plugin("exit 0"); + cfg.command = "/nonexistent/plugin-binary".to_string(); + let Err(err) = PluginHost::spawn(&cfg, None) else { + panic!("launching a missing binary should have failed"); + }; + assert!(err.to_string().contains("cannot launch plugin")); +} diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs new file mode 100644 index 00000000..aa087264 --- /dev/null +++ b/src/plugin/mod.rs @@ -0,0 +1,32 @@ +//! The wire contract for an out-of-process plugin. +//! +//! Trust posture: a plugin is a separate program the host launches and speaks +//! NDJSON to, so everything arriving from it is untrusted input. Every command +//! is validated by the host — version, line length, payload bounds, and the +//! pane identity it names — before anything acts on it. This module is types +//! and parsing only: no IO, no process spawning, no threads. +//! +//! The contract is deliberately provider-agnostic. It speaks of panes, output, +//! idleness and relaunching, and never of any particular tool; which program a +//! pane runs stays the host's knowledge, which is what keeps +//! [`protocol::PluginCommand::Relaunch`] from being arbitrary execution. + +//! The layering is deliberate: `protocol` is types only, `host` moves bytes and +//! knows nothing about authority, and `guard` decides authority and touches no +//! IO. Nothing acts on a plugin's command without passing through `guard`. + +pub mod guard; +pub mod host; +pub mod protocol; +pub mod registry; + +mod guard_budget; +mod guard_refusal; +mod guard_text; +mod host_pump; + +pub use guard::{Approved, Guard, PaneFacts}; +pub use guard_budget::{RateAction, RateLimits}; +pub use guard_refusal::Refused; +pub use guard_text::MAX_LOG_MESSAGE_BYTES; +pub use host::PluginHost; diff --git a/src/plugin/protocol.rs b/src/plugin/protocol.rs new file mode 100644 index 00000000..558a2611 --- /dev/null +++ b/src/plugin/protocol.rs @@ -0,0 +1,247 @@ +//! What the host and an out-of-process plugin say to each other. +//! +//! Newline-delimited JSON over the plugin child's stdin and stdout: the host +//! writes one [`PluginEvent`] per line, the plugin writes one +//! [`PluginCommand`] per line. A line is the framing, so nothing here may emit +//! an embedded newline — [`encode_event`] refuses rather than corrupt the +//! stream. +//! +//! Unlike the daemon's control protocol, the two sides here are separate +//! builds: a plugin is written against a version of this contract and shipped +//! independently. That makes [`PROTOCOL_VERSION`] a real negotiation, and a +//! mismatch is refused rather than half-understood. + +use crate::backend::{PaneGeneration, PaneToken}; +use anyhow::{Result, anyhow, bail}; +use serde::{Deserialize, Serialize}; + +/// Bumped when a field changes meaning. A plugin built against a version the +/// host does not speak is refused rather than half-understood. +pub const PROTOCOL_VERSION: u32 = 1; + +/// Longest line the host will read from a plugin. +/// +/// The plugin is untrusted input on a stream with no length prefix, so without +/// a cap a plugin that never writes a newline makes the host's reader allocate +/// without bound. 64 KiB is far above any legitimate command — the largest is a +/// [`PluginCommand::SendInput`] bounded by [`MAX_INPUT_BYTES`] plus JSON +/// escaping and a token — while still being a single small allocation. +pub const MAX_LINE_BYTES: usize = 64 * 1024; + +/// Longest `data` a single [`PluginCommand::SendInput`] may carry. +/// +/// Typed input stands in for a human at a keyboard: a prompt, a confirmation, +/// a pasted snippet. 8 KiB covers that with room to spare and keeps one command +/// from filling a PTY's input buffer, which would block the writer and stall +/// every other pane behind it. +pub const MAX_INPUT_BYTES: usize = 8 * 1024; + +/// Sentinel [`decode_command`] answers a blank line with. Prefer +/// [`is_blank_line`] over matching this text. +const BLANK_LINE_MESSAGE: &str = "blank line carries no command"; + +/// Something that happened, sent host to plugin. +/// +/// Every pane-scoped variant carries both the slot's token and the generation +/// within it: a plugin decides asynchronously, so the spawn it is reacting to +/// may already be gone by the time it answers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum PluginEvent { + /// A pane slot began a spawn. `title` and `command` are absent when the + /// host has none to report. + PaneOpened { + v: u32, + token: PaneToken, + generation: PaneGeneration, + title: Option, + command: Option, + cwd: String, + }, + /// Plain text the pane produced, already escape-stripped. + PaneOutput { + v: u32, + token: PaneToken, + generation: PaneGeneration, + text: String, + }, + /// The pane has produced nothing for this long. + PaneIdle { + v: u32, + token: PaneToken, + generation: PaneGeneration, + idle_ms: u64, + }, + /// The pane's process ended. The slot survives, so a relaunch is possible. + PaneExited { + v: u32, + token: PaneToken, + generation: PaneGeneration, + }, + /// The slot itself is gone. No relaunch is possible; drop any state held + /// for this token. + PaneClosed { + v: u32, + token: PaneToken, + generation: PaneGeneration, + }, + /// A human typed into the pane. A plugin must treat this as a cancellation + /// signal: the person has taken the pane back. + UserInput { + v: u32, + token: PaneToken, + generation: PaneGeneration, + }, + /// The host is going away. Exit cleanly. + Shutdown { v: u32 }, +} + +/// A request, sent plugin to host. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "cmd", rename_all = "snake_case")] +pub enum PluginCommand { + /// Text to type into a live pane, bounded by [`MAX_INPUT_BYTES`]. + SendInput { + v: u32, + token: PaneToken, + generation: PaneGeneration, + data: String, + }, + /// Replace an exited pane's process, appending these args to the original + /// command. The plugin does not name the program: which binary a pane runs + /// is the host's knowledge, and letting a plugin choose it would make this + /// command arbitrary execution. + Relaunch { + v: u32, + token: PaneToken, + generation: PaneGeneration, + resume_args: Vec, + }, + /// What the plugin believes about a pane. Observability only — the host + /// displays it and acts on nothing. + Status { + v: u32, + token: PaneToken, + generation: PaneGeneration, + state: String, + detail: Option, + deadline_epoch: Option, + attempt: u32, + }, + /// A line for the host's log. + Log { + v: u32, + level: LogLevel, + message: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LogLevel { + Error, + Warn, + Info, + Debug, +} + +impl PluginCommand { + /// The protocol version the plugin claims to speak. + pub fn version(&self) -> u32 { + match self { + Self::SendInput { v, .. } + | Self::Relaunch { v, .. } + | Self::Status { v, .. } + | Self::Log { v, .. } => *v, + } + } + + /// Which slot this addresses, or `None` for [`Self::Log`], which is not + /// pane-scoped. Lets the guard layer check identity without matching every + /// variant. + pub fn token(&self) -> Option<&PaneToken> { + match self { + Self::SendInput { token, .. } + | Self::Relaunch { token, .. } + | Self::Status { token, .. } => Some(token), + Self::Log { .. } => None, + } + } + + /// Which spawn of the slot this addresses, paired with [`Self::token`]. + pub fn generation(&self) -> Option { + match self { + Self::SendInput { generation, .. } + | Self::Relaunch { generation, .. } + | Self::Status { generation, .. } => Some(*generation), + Self::Log { .. } => None, + } + } + + /// Check the bounds serde cannot express. Called by [`decode_command`], and + /// separately callable by anything that builds a command in-process. + pub fn validate(&self) -> Result<()> { + if let Self::SendInput { data, .. } = self + && data.len() > MAX_INPUT_BYTES + { + bail!( + "send_input data is {} bytes, over the {MAX_INPUT_BYTES}-byte limit", + data.len() + ); + } + Ok(()) + } +} + +/// Serialise one event as exactly one NDJSON line, without its terminator. +/// +/// `serde_json::to_string` never emits a newline — it escapes them inside +/// strings — but the framing depends on that, so it is checked rather than +/// assumed. +pub fn encode_event(ev: &PluginEvent) -> Result { + let line = serde_json::to_string(ev).map_err(|e| anyhow!("cannot encode plugin event: {e}"))?; + if line.contains('\n') { + bail!("encoded plugin event contains a newline, which would split the frame"); + } + Ok(line) +} + +/// Whether a line carries no command and should be skipped. +/// +/// A plugin's writer may flush a bare newline, so a blank line is expected +/// traffic rather than an error. [`decode_command`] still refuses it — it has +/// no command to return — so a reader loop tests this first. +pub fn is_blank_line(line: &str) -> bool { + line.trim().is_empty() +} + +/// Parse one line from a plugin. +/// +/// Refuses an over-long line, a version this host does not speak, an unknown +/// `cmd`, and a command that fails [`PluginCommand::validate`]. A blank line is +/// refused too, with [`BLANK_LINE_MESSAGE`]; see [`is_blank_line`]. +pub fn decode_command(line: &str) -> Result { + if line.len() > MAX_LINE_BYTES { + bail!( + "plugin line is {} bytes, over the {MAX_LINE_BYTES}-byte limit", + line.len() + ); + } + if is_blank_line(line) { + bail!("{BLANK_LINE_MESSAGE}"); + } + let command: PluginCommand = serde_json::from_str(line) + .map_err(|e| anyhow!("cannot parse plugin command: {e}; unknown or malformed cmd"))?; + if command.version() != PROTOCOL_VERSION { + bail!( + "plugin speaks protocol version {}, host speaks {PROTOCOL_VERSION}", + command.version() + ); + } + command.validate()?; + Ok(command) +} + +#[cfg(test)] +#[path = "protocol_tests.rs"] +mod tests; diff --git a/src/plugin/protocol_tests.rs b/src/plugin/protocol_tests.rs new file mode 100644 index 00000000..f32bd984 --- /dev/null +++ b/src/plugin/protocol_tests.rs @@ -0,0 +1,277 @@ +use super::{ + LogLevel, MAX_INPUT_BYTES, MAX_LINE_BYTES, PROTOCOL_VERSION, PluginCommand, PluginEvent, + decode_command, encode_event, is_blank_line, +}; +use crate::backend::{PaneGeneration, PaneToken}; + +const TOKEN: &str = "0123456789abcdef0123456789abcdef"; +const GENERATION: PaneGeneration = 2; + +fn token() -> PaneToken { + serde_json::from_str(&format!("\"{TOKEN}\"")).expect("a token is a JSON string") +} + +fn every_event() -> Vec { + vec![ + PluginEvent::PaneOpened { + v: PROTOCOL_VERSION, + token: token(), + generation: GENERATION, + title: Some("worker".into()), + command: None, + cwd: "/w/repo".into(), + }, + PluginEvent::PaneOutput { + v: PROTOCOL_VERSION, + token: token(), + generation: GENERATION, + text: "first\nsecond".into(), + }, + PluginEvent::PaneIdle { + v: PROTOCOL_VERSION, + token: token(), + generation: GENERATION, + idle_ms: 30_000, + }, + PluginEvent::PaneExited { + v: PROTOCOL_VERSION, + token: token(), + generation: GENERATION, + }, + PluginEvent::PaneClosed { + v: PROTOCOL_VERSION, + token: token(), + generation: GENERATION, + }, + PluginEvent::UserInput { + v: PROTOCOL_VERSION, + token: token(), + generation: GENERATION, + }, + PluginEvent::Shutdown { + v: PROTOCOL_VERSION, + }, + ] +} + +fn every_command() -> Vec { + vec![ + PluginCommand::SendInput { + v: PROTOCOL_VERSION, + token: token(), + generation: GENERATION, + data: "continue\r".into(), + }, + PluginCommand::Relaunch { + v: PROTOCOL_VERSION, + token: token(), + generation: GENERATION, + resume_args: vec!["--resume".into(), "last".into()], + }, + PluginCommand::Status { + v: PROTOCOL_VERSION, + token: token(), + generation: GENERATION, + state: "waiting".into(), + detail: None, + deadline_epoch: Some(1_700_000_000), + attempt: 1, + }, + PluginCommand::Log { + v: PROTOCOL_VERSION, + level: LogLevel::Warn, + message: "retrying".into(), + }, + ] +} + +#[test] +fn encoding_an_event_yields_one_line_tagged_with_its_name() { + let expected = [ + "pane_opened", + "pane_output", + "pane_idle", + "pane_exited", + "pane_closed", + "user_input", + "shutdown", + ]; + for (event, tag) in every_event().iter().zip(expected) { + let line = encode_event(event).expect("encodes"); + assert!(!line.contains('\n'), "{tag} split the frame: {line}"); + assert!( + line.starts_with(&format!(r#"{{"event":"{tag}""#)), + "{tag} is not the tag of {line}" + ); + } +} + +#[test] +fn text_holding_a_newline_is_escaped_rather_than_splitting_the_line() { + let event = PluginEvent::PaneOutput { + v: PROTOCOL_VERSION, + token: token(), + generation: GENERATION, + text: "first\nsecond".into(), + }; + let line = encode_event(&event).expect("encodes"); + assert!(!line.contains('\n')); + assert!(line.contains(r"first\nsecond")); +} + +#[test] +fn every_command_survives_the_round_trip() { + for command in &every_command() { + let line = serde_json::to_string(command).expect("encodes"); + assert_eq!(&decode_command(&line).expect("decodes"), command); + } +} + +#[test] +fn an_events_json_shape_is_the_wire_contract() { + // Pinned literally: this is what an independently built plugin parses, so + // an accidental rename must fail here rather than in the field. + let event = PluginEvent::PaneIdle { + v: PROTOCOL_VERSION, + token: token(), + generation: GENERATION, + idle_ms: 30_000, + }; + assert_eq!( + encode_event(&event).unwrap(), + format!( + r#"{{"event":"pane_idle","v":1,"token":"{TOKEN}","generation":2,"idle_ms":30000}}"# + ) + ); +} + +#[test] +fn a_commands_json_shape_is_the_wire_contract() { + let command = PluginCommand::SendInput { + v: PROTOCOL_VERSION, + token: token(), + generation: GENERATION, + data: "go".into(), + }; + assert_eq!( + serde_json::to_string(&command).unwrap(), + format!(r#"{{"cmd":"send_input","v":1,"token":"{TOKEN}","generation":2,"data":"go"}}"#) + ); +} + +#[test] +fn a_log_levels_json_shape_is_lowercase() { + assert_eq!( + serde_json::to_string(&LogLevel::Error).unwrap(), + r#""error""# + ); + assert_eq!(serde_json::to_string(&LogLevel::Warn).unwrap(), r#""warn""#); + assert_eq!(serde_json::to_string(&LogLevel::Info).unwrap(), r#""info""#); + assert_eq!( + serde_json::to_string(&LogLevel::Debug).unwrap(), + r#""debug""# + ); +} + +#[test] +fn a_command_from_another_protocol_version_is_refused_and_both_versions_named() { + let line = format!( + r#"{{"cmd":"log","v":{},"level":"info","message":"hi"}}"#, + PROTOCOL_VERSION + 1 + ); + let err = decode_command(&line).expect_err("refused"); + let message = err.to_string(); + assert!( + message.contains(&(PROTOCOL_VERSION + 1).to_string()) + && message.contains(&PROTOCOL_VERSION.to_string()), + "message names only one version: {message}" + ); +} + +#[test] +fn a_line_over_the_length_limit_is_refused_before_it_is_parsed() { + // Refused on length alone, so nothing here depends on the payload being + // valid JSON — an unbounded line must not become an unbounded parse. + let line = "x".repeat(MAX_LINE_BYTES + 1); + let message = decode_command(&line).expect_err("refused").to_string(); + assert!(message.contains(&MAX_LINE_BYTES.to_string()), "{message}"); +} + +#[test] +fn a_line_at_the_length_limit_is_parsed() { + let padding = "p".repeat(MAX_INPUT_BYTES); + let line = format!(r#"{{"cmd":"log","v":1,"level":"debug","message":"{padding}"}}"#); + assert!(line.len() <= MAX_LINE_BYTES); + assert!(decode_command(&line).is_ok()); +} + +#[test] +fn an_unknown_cmd_is_refused_rather_than_guessed() { + let message = decode_command(r#"{"cmd":"drop_everything","v":1}"#) + .expect_err("refused") + .to_string(); + assert!(message.contains("drop_everything"), "{message}"); + assert!(message.contains("cmd"), "{message}"); +} + +#[test] +fn a_command_missing_a_required_field_is_refused() { + assert!(decode_command(r#"{"cmd":"send_input","v":1}"#).is_err()); +} + +#[test] +fn send_input_data_over_the_input_limit_is_refused() { + let command = PluginCommand::SendInput { + v: PROTOCOL_VERSION, + token: token(), + generation: GENERATION, + data: "y".repeat(MAX_INPUT_BYTES + 1), + }; + let message = command.validate().expect_err("refused").to_string(); + assert!(message.contains(&MAX_INPUT_BYTES.to_string()), "{message}"); + + let line = serde_json::to_string(&command).unwrap(); + assert!( + line.len() <= MAX_LINE_BYTES, + "the length cap would mask this" + ); + assert!(decode_command(&line).is_err()); +} + +#[test] +fn send_input_data_at_the_input_limit_is_accepted() { + let command = PluginCommand::SendInput { + v: PROTOCOL_VERSION, + token: token(), + generation: GENERATION, + data: "y".repeat(MAX_INPUT_BYTES), + }; + assert!(command.validate().is_ok()); +} + +#[test] +fn a_blank_line_is_recognised_as_blank_and_carries_no_command() { + for line in ["", " ", "\t", "\r\n"] { + assert!(is_blank_line(line), "not seen as blank: {line:?}"); + assert!(decode_command(line).is_err()); + } + assert!(!is_blank_line( + r#"{"cmd":"log","v":1,"level":"info","message":"x"}"# + )); +} + +#[test] +fn only_a_pane_scoped_command_reports_an_identity() { + for command in &every_command() { + match command { + PluginCommand::Log { .. } => { + assert_eq!(command.token(), None); + assert_eq!(command.generation(), None); + } + _ => { + assert_eq!(command.token(), Some(&token())); + assert_eq!(command.generation(), Some(GENERATION)); + } + } + } +} diff --git a/src/plugin/registry.rs b/src/plugin/registry.rs new file mode 100644 index 00000000..12964b66 --- /dev/null +++ b/src/plugin/registry.rs @@ -0,0 +1,271 @@ +//! The on-disk set of installed plugin executables (`~/.nightcrow/plugins`). +//! +//! Installing a binary here does not switch it on: the host only ever launches +//! a plugin that `config.toml` declares in a `[[plugin]]` table *and* that a +//! `[[startup_command]]` opted a pane into by name. That edit is left to the +//! user on purpose — a plugin can drive a pane's terminal, so the file that +//! grants it that has to be one a person read. +//! +//! Every function takes the plugins directory as a parameter so the filesystem +//! behaviour is testable against a temp directory. + +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; + +/// Subdirectory of `~/.nightcrow` holding installed plugin executables. +const PLUGINS_SUBDIR: &str = "plugins"; +/// Owner-only read/write/execute for an installed plugin. Least privilege, and +/// the same posture the config file's 0600 takes: nothing else on the machine +/// has business running a binary nightcrow will attach to a terminal. +#[cfg(unix)] +const PLUGIN_MODE: u32 = 0o700; +/// Longest accepted plugin name. +const MAX_NAME_LEN: usize = 64; + +/// `~/.nightcrow/plugins`, resolved whether or not it exists yet. Errors only +/// when the home directory cannot be determined. +pub fn default_plugins_dir() -> Result { + let home = dirs::home_dir().context("cannot determine the home directory")?; + Ok(home.join(".nightcrow").join(PLUGINS_SUBDIR)) +} + +/// Result of [`install`], so the caller can report exactly what was touched. +#[derive(Debug)] +pub enum InstallOutcome { + Created(PathBuf), + Replaced(PathBuf), + /// A plugin of that name was already installed and `force` was not set. + AlreadyExists(PathBuf), +} + +/// Result of [`remove`]. Removing something absent is a report, not a failure. +#[derive(Debug)] +pub enum RemoveOutcome { + Removed(PathBuf), + NotInstalled(String), +} + +/// How `config.toml` currently refers to an installed plugin. +#[derive(Debug, PartialEq, Eq)] +pub struct PluginStatus { + pub declared: bool, + pub enabled: bool, + /// `[[startup_command]]` entries whose `plugin =` names this plugin. + pub opt_ins: usize, +} + +/// Accept only a safe single filename. +/// +/// This is the path-traversal boundary: the name is joined onto the plugins +/// directory and then written to and deleted, so anything that could escape +/// that directory — a separator, `.`/`..` — is refused here rather than +/// sanitised. A leading `-` is refused too, because such a file name is read as +/// a flag by every command the user might later point at it. +pub fn validate_name(name: &str) -> Result<()> { + anyhow::ensure!(!name.is_empty(), "a plugin name must not be empty"); + anyhow::ensure!( + name.len() <= MAX_NAME_LEN, + "plugin name \"{name}\" is longer than {MAX_NAME_LEN} characters" + ); + anyhow::ensure!( + !name.contains('/') && !name.contains('\\'), + "plugin name \"{name}\" must be a single file name, not a path" + ); + anyhow::ensure!( + name != "." && name != "..", + "plugin name \"{name}\" is a directory reference, not a file name" + ); + anyhow::ensure!( + !name.starts_with('-'), + "plugin name \"{name}\" must not start with '-'; such a name is read as a flag" + ); + anyhow::ensure!( + name.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-'), + "plugin name \"{name}\" may only contain letters, digits, '.', '_' and '-'" + ); + Ok(()) +} + +/// Copy the executable at `source` into `base` under `name` (defaulting to the +/// source file stem) and restrict it to owner-only. +pub fn install( + base: &Path, + source: &Path, + name: Option<&str>, + force: bool, +) -> Result { + let name = match name { + Some(name) => name.to_string(), + None => derive_name(source)?, + }; + validate_name(&name)?; + check_source(source)?; + + let dest = base.join(&name); + let installed = dest.symlink_metadata().is_ok(); + if installed && !force { + return Ok(InstallOutcome::AlreadyExists(dest)); + } + std::fs::create_dir_all(base) + .with_context(|| format!("creating plugin directory {}", base.display()))?; + // Unlink rather than copy over: truncating a binary that is currently + // running fails with ETXTBSY, and a fresh inode leaves any live process + // on the old file. + if installed { + std::fs::remove_file(&dest) + .with_context(|| format!("replacing installed plugin {}", dest.display()))?; + } + std::fs::copy(source, &dest) + .with_context(|| format!("copying plugin {} to {}", source.display(), dest.display()))?; + restrict_permissions(&dest)?; + Ok(if installed { + InstallOutcome::Replaced(dest) + } else { + InstallOutcome::Created(dest) + }) +} + +/// Installed plugin names, sorted. A missing directory lists as empty — that is +/// the state before the first install, not an error. +pub fn list(base: &Path) -> Result> { + let entries = match std::fs::read_dir(base) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => { + return Err(e).with_context(|| format!("reading plugin directory {}", base.display())); + } + }; + let mut names = Vec::new(); + for entry in entries { + let entry = + entry.with_context(|| format!("reading plugin directory {}", base.display()))?; + // Follows symlinks, so a hand-linked plugin still lists. + if !std::fs::metadata(entry.path()).is_ok_and(|m| m.is_file()) { + continue; + } + names.push(entry.file_name().to_string_lossy().into_owned()); + } + names.sort(); + Ok(names) +} + +/// Delete an installed plugin. +pub fn remove(base: &Path, name: &str) -> Result { + validate_name(name)?; + let path = base.join(name); + if path.symlink_metadata().is_err() { + return Ok(RemoveOutcome::NotInstalled(name.to_string())); + } + std::fs::remove_file(&path) + .with_context(|| format!("removing installed plugin {}", path.display()))?; + Ok(RemoveOutcome::Removed(path)) +} + +/// What the loaded config says about `name`. +pub fn status(cfg: &crate::config::Config, name: &str) -> PluginStatus { + let declared = cfg.plugins.iter().find(|p| p.name == name); + PluginStatus { + declared: declared.is_some(), + enabled: declared.is_some_and(|p| p.enabled), + opt_ins: cfg + .startup_commands + .iter() + .filter(|sc| sc.plugin.as_deref() == Some(name)) + .count(), + } +} + +/// The `[[plugin]]` block to paste into `config.toml` after an install. +/// +/// Printed, never written: enabling a plugin hands it a pane's terminal, so the +/// opt-in stays an explicit edit the user reviews. `enabled = false` and an +/// empty `allowed_resume_flags` are the off positions of both switches. +pub fn config_snippet(name: &str, command: &Path) -> String { + format!( + "[[plugin]]\n\ + name = \"{name}\"\n\ + command = \"{}\"\n\ + args = []\n\ + allowed_resume_flags = []\n\ + enabled = false\n", + toml_escape(&command.display().to_string()) + ) +} + +fn toml_escape(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn derive_name(source: &Path) -> Result { + source + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .ok_or_else(|| { + anyhow::anyhow!( + "cannot derive a plugin name from {}; pass --name", + source.display() + ) + }) +} + +fn check_source(source: &Path) -> Result<()> { + let meta = std::fs::symlink_metadata(source).with_context(|| { + format!( + "plugin source {} cannot be read; it must be an existing executable file", + source.display() + ) + })?; + let meta = if meta.file_type().is_symlink() { + std::fs::metadata(source) + .with_context(|| format!("plugin source {} is a broken symlink", source.display()))? + } else { + meta + }; + anyhow::ensure!( + meta.is_file(), + "plugin source {} is not a regular file", + source.display() + ); + anyhow::ensure!( + is_executable(source), + "plugin source {} is not executable by the current user; chmod +x it first", + source.display() + ); + Ok(()) +} + +#[cfg(unix)] +fn is_executable(path: &Path) -> bool { + use std::os::unix::ffi::OsStrExt; + let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else { + return false; + }; + // access(2) rather than a permission-bit test: it answers the question that + // matters — whether *this* user may execute it — for owner, group and other + // in one call. + unsafe { libc::access(c_path.as_ptr(), libc::X_OK) == 0 } +} + +#[cfg(not(unix))] +fn is_executable(_path: &Path) -> bool { + true +} + +fn restrict_permissions(path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(PLUGIN_MODE)) + .with_context(|| format!("restricting permissions on {}", path.display()))?; + } + #[cfg(not(unix))] + { + let _ = path; + } + Ok(()) +} + +#[cfg(test)] +#[path = "registry_tests.rs"] +mod tests; diff --git a/src/plugin/registry_tests.rs b/src/plugin/registry_tests.rs new file mode 100644 index 00000000..060be259 --- /dev/null +++ b/src/plugin/registry_tests.rs @@ -0,0 +1,262 @@ +use super::*; +use std::os::unix::fs::PermissionsExt; +use tempfile::TempDir; + +/// Source executable plus the plugins directory it installs into, both inside +/// one temp dir that is removed when the returned handle drops. +fn workspace() -> (TempDir, PathBuf, PathBuf) { + let root = TempDir::new().expect("a temp dir"); + let source = root.path().join("watcher"); + std::fs::write(&source, b"#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o755)).unwrap(); + let base = root.path().join("plugins"); + (root, source, base) +} + +fn mode_of(path: &Path) -> u32 { + std::fs::metadata(path).unwrap().permissions().mode() & 0o777 +} + +#[test] +fn installing_copies_the_file_sets_owner_only_mode_and_reports_created() { + let (_root, source, base) = workspace(); + + let outcome = install(&base, &source, Some("watcher"), false).expect("install to succeed"); + + let InstallOutcome::Created(path) = outcome else { + panic!("expected Created, got {outcome:?}"); + }; + assert_eq!(path, base.join("watcher")); + assert_eq!( + std::fs::read(&path).unwrap(), + std::fs::read(&source).unwrap() + ); + assert_eq!(mode_of(&path), 0o700); +} + +#[test] +fn installing_over_an_existing_name_without_force_is_refused_and_keeps_the_original_bytes() { + let (_root, source, base) = workspace(); + install(&base, &source, Some("watcher"), false).unwrap(); + let other = source.parent().unwrap().join("other"); + std::fs::write(&other, b"#!/bin/sh\nexit 1\n").unwrap(); + std::fs::set_permissions(&other, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let outcome = install(&base, &other, Some("watcher"), false).expect("no error, a report"); + + let InstallOutcome::AlreadyExists(path) = outcome else { + panic!("expected AlreadyExists, got {outcome:?}"); + }; + assert_eq!( + std::fs::read(&path).unwrap(), + std::fs::read(&source).unwrap() + ); +} + +#[test] +fn installing_with_force_replaces_the_installed_plugin() { + let (_root, source, base) = workspace(); + install(&base, &source, Some("watcher"), false).unwrap(); + let other = source.parent().unwrap().join("other"); + std::fs::write(&other, b"#!/bin/sh\nexit 1\n").unwrap(); + std::fs::set_permissions(&other, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let outcome = install(&base, &other, Some("watcher"), true).expect("install to succeed"); + + let InstallOutcome::Replaced(path) = outcome else { + panic!("expected Replaced, got {outcome:?}"); + }; + assert_eq!( + std::fs::read(&path).unwrap(), + std::fs::read(&other).unwrap() + ); + assert_eq!(mode_of(&path), 0o700); +} + +#[test] +fn a_name_that_is_not_a_safe_single_filename_is_rejected() { + let unsafe_names = [ + "../escape", + "..", + ".", + "sub/dir", + "back\\slash", + "-rf", + "has space", + "semi;colon", + "quote\"d", + "null\0byte", + "", + ]; + + for name in unsafe_names { + assert!( + validate_name(name).is_err(), + "expected {name:?} to be rejected" + ); + } +} + +#[test] +fn an_unsafe_name_is_rejected_by_install_and_remove_before_any_filesystem_write() { + let (_root, source, base) = workspace(); + + assert!(install(&base, &source, Some("../escape"), false).is_err()); + assert!(remove(&base, "../escape").is_err()); + assert!(!base.exists(), "nothing should have been created"); +} + +#[test] +fn a_source_that_does_not_exist_is_rejected() { + let (_root, source, base) = workspace(); + let missing = source.parent().unwrap().join("nope"); + + let err = install(&base, &missing, None, false).expect_err("a missing source is an error"); + + assert!( + format!("{err:#}").contains("cannot be read"), + "unexpected error: {err:#}" + ); +} + +#[test] +fn a_source_that_is_a_directory_is_rejected() { + let (_root, source, base) = workspace(); + let dir = source.parent().unwrap().join("adir"); + std::fs::create_dir(&dir).unwrap(); + + let err = install(&base, &dir, None, false).expect_err("a directory source is an error"); + + assert!( + format!("{err:#}").contains("not a regular file"), + "unexpected error: {err:#}" + ); +} + +#[test] +fn a_source_that_is_not_executable_is_rejected() { + let (_root, source, base) = workspace(); + std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o644)).unwrap(); + + let err = + install(&base, &source, None, false).expect_err("a non-executable source is an error"); + + assert!( + format!("{err:#}").contains("not executable"), + "unexpected error: {err:#}" + ); +} + +#[test] +fn listing_an_empty_directory_yields_nothing_and_does_not_error() { + let (_root, _source, base) = workspace(); + std::fs::create_dir_all(&base).unwrap(); + + assert_eq!( + list(&base).expect("an empty list, not an error"), + Vec::::new() + ); +} + +#[test] +fn listing_a_directory_that_does_not_exist_yet_yields_nothing() { + let (_root, _source, base) = workspace(); + + assert_eq!( + list(&base).expect("an empty list, not an error"), + Vec::::new() + ); +} + +#[test] +fn listing_reports_installed_names_in_sorted_order() { + let (_root, source, base) = workspace(); + install(&base, &source, Some("zulu"), false).unwrap(); + install(&base, &source, Some("alpha"), false).unwrap(); + std::fs::create_dir(base.join("subdir")).unwrap(); + + assert_eq!(list(&base).unwrap(), vec!["alpha", "zulu"]); +} + +#[test] +fn removing_an_installed_plugin_deletes_the_file_and_reports_removed() { + let (_root, source, base) = workspace(); + install(&base, &source, Some("watcher"), false).unwrap(); + + let outcome = remove(&base, "watcher").expect("remove to succeed"); + + let RemoveOutcome::Removed(path) = outcome else { + panic!("expected Removed, got {outcome:?}"); + }; + assert!(!path.exists()); +} + +#[test] +fn removing_a_name_that_is_not_installed_reports_not_installed() { + let (_root, _source, base) = workspace(); + std::fs::create_dir_all(&base).unwrap(); + + let outcome = remove(&base, "watcher").expect("no error, a report"); + + let RemoveOutcome::NotInstalled(name) = outcome else { + panic!("expected NotInstalled, got {outcome:?}"); + }; + assert_eq!(name, "watcher"); +} + +#[test] +fn the_default_name_is_derived_from_the_source_file_stem() { + let (_root, source, base) = workspace(); + let stemmed = source.parent().unwrap().join("my-plugin.sh"); + std::fs::copy(&source, &stemmed).unwrap(); + std::fs::set_permissions(&stemmed, std::fs::Permissions::from_mode(0o755)).unwrap(); + + install(&base, &stemmed, None, false).expect("install to succeed"); + + assert_eq!(list(&base).unwrap(), vec!["my-plugin"]); +} + +#[test] +fn status_reports_declaration_enablement_and_the_number_of_opted_in_panes() { + let mut cfg = crate::config::Config::default(); + cfg.plugins.push(crate::config::PluginConfig { + name: "watcher".into(), + command: "watcher".into(), + enabled: true, + ..Default::default() + }); + for _ in 0..2 { + cfg.startup_commands.push(crate::config::StartupCommand { + name: None, + command: "bash".into(), + plugin: Some("watcher".into()), + }); + } + + assert_eq!( + status(&cfg, "watcher"), + PluginStatus { + declared: true, + enabled: true, + opt_ins: 2, + } + ); + assert_eq!( + status(&cfg, "absent"), + PluginStatus { + declared: false, + enabled: false, + opt_ins: 0, + } + ); +} + +#[test] +fn the_printed_config_snippet_leaves_the_plugin_off_and_grants_no_resume_flags() { + let snippet = config_snippet("watcher", Path::new("/home/u/.nightcrow/plugins/watcher")); + + assert!(snippet.contains("name = \"watcher\"")); + assert!(snippet.contains("command = \"/home/u/.nightcrow/plugins/watcher\"")); + assert!(snippet.contains("allowed_resume_flags = []")); + assert!(snippet.contains("enabled = false")); +} diff --git a/src/runtime/terminal/lifecycle.rs b/src/runtime/terminal/lifecycle.rs index 90eb64ff..609f5f9c 100644 --- a/src/runtime/terminal/lifecycle.rs +++ b/src/runtime/terminal/lifecycle.rs @@ -62,6 +62,13 @@ impl TerminalState { } } BackendEvent::Reordered { order } => self.apply_order(&order), + BackendEvent::Recovery { + pane, + state, + detail, + deadline_epoch, + attempt, + } => self.apply_recovery(pane, state, detail, deadline_epoch, attempt), BackendEvent::SizeOwnership { owned } => { // Gaining it means the panes are this client's layout to // set, and they are currently at someone else's sizes — so diff --git a/src/runtime/terminal/mod.rs b/src/runtime/terminal/mod.rs index f17b70e2..2a9a2ee8 100644 --- a/src/runtime/terminal/mod.rs +++ b/src/runtime/terminal/mod.rs @@ -5,11 +5,13 @@ use std::collections::HashMap; mod escape; mod input; mod lifecycle; +mod recovery; mod scroll; mod session_panes; mod state; pub(crate) use escape::strip_escape_sequences; +pub use recovery::PaneRecovery; /// Upper bound on a pane's in-flight prompt buffer before further chars are /// dropped. Prevents unbounded growth when a program writes a stream of bytes @@ -120,6 +122,12 @@ pub struct TerminalState { /// this client owns follow its layout; panes it does not follow /// [`BackendEvent::Resized`](crate::backend::BackendEvent::Resized). pub owns_size: bool, + /// What each pane's plugin last reported about recovering it, for the panes + /// any has spoken about. Deliberately outlives a pane's process: the report + /// that matters most arrives while the pane is gone and its slot is held for + /// a relaunch. Cleared only by a `cancelled` report (see + /// [`recovery::RECOVERY_CANCELLED`]). + pub(crate) recovery: HashMap, /// Index of the first pane in the visible split-view window. pub visible_start: usize, pub max_visible_normal: usize, @@ -143,6 +151,7 @@ impl TerminalState { fullscreen: TerminalFullscreen::Off, last_content_size: HashMap::new(), owns_size: true, + recovery: HashMap::new(), visible_start: 0, max_visible_normal: MAX_VISIBLE_NORMAL, max_visible_fullscreen: MAX_VISIBLE_FULLSCREEN, diff --git a/src/runtime/terminal/recovery.rs b/src/runtime/terminal/recovery.rs new file mode 100644 index 00000000..355451a0 --- /dev/null +++ b/src/runtime/terminal/recovery.rs @@ -0,0 +1,104 @@ +//! What a pane's plugin last said about getting it running again. +//! +//! Held per pane and nowhere near the emulators: this is metadata the session +//! reports, not screen content, so it never touches a grid and a pane without a +//! report costs one absent map entry. + +use crate::backend::PaneId; + +use super::TerminalState; + +/// The `state` a session sends once a pane's recovery is over without having +/// succeeded. Mirrors the hub's own constant; the wire is the contract between +/// them, so the value is asserted rather than shared. +pub const RECOVERY_CANCELLED: &str = "cancelled"; + +/// One pane's latest recovery report. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaneRecovery { + /// The plugin's own short label, e.g. `waiting_for_reset`. + pub state: String, + /// A short human line, when the plugin gave one. + pub detail: Option, + /// When the wait ends, in unix epoch seconds. `None` when the plugin is not + /// waiting on a clock — the renderer then shows no time at all rather than + /// inventing one. + pub deadline_epoch: Option, + pub attempt: u32, +} + +impl TerminalState { + /// Record what the session reports about `pane`. + /// + /// A `cancelled` report *clears* the entry instead of storing it: there is + /// nothing left to wait for, and keeping the label would leave a badge on a + /// pane whose recovery is over. + pub(super) fn apply_recovery( + &mut self, + pane: PaneId, + state: String, + detail: Option, + deadline_epoch: Option, + attempt: u32, + ) { + if state == RECOVERY_CANCELLED { + self.recovery.remove(&pane); + return; + } + self.recovery.insert( + pane, + PaneRecovery { + state, + detail, + deadline_epoch, + attempt, + }, + ); + } + + /// What `pane`'s plugin last reported, if anything. + pub fn recovery_for(&self, pane: PaneId) -> Option<&PaneRecovery> { + self.recovery.get(&pane) + } + + /// The one report a person is looking at, and the one the cancel key acts on. + /// + /// The focused pane's own report comes first. Failing that, a report for a + /// pane this client no longer lists — a pane whose process has ended while + /// its slot is held for a relaunch. That pane cannot be focused, and it is + /// exactly the one someone would want to release, so it must still be + /// reachable. Lowest id wins so the display and the key can never disagree + /// about which one that is. + pub fn recovery_focus(&self) -> Option<(PaneId, &PaneRecovery)> { + if let Some(pane) = self.active_pane_id() + && let Some(report) = self.recovery.get(&pane) + { + return Some((pane, report)); + } + self.recovery + .iter() + .filter(|(pane, _)| !self.panes.iter().any(|p| p.id == **pane)) + .min_by_key(|(pane, _)| **pane) + .map(|(pane, report)| (*pane, report)) + } + + /// Whether the cancel key would do anything. Single source for the key gate + /// and the hint row, so a hint can never advertise a no-op. + pub fn can_cancel_recovery(&self) -> bool { + self.recovery_focus().is_some() + } + + /// Ask the session to give up on the recovery a person is looking at. + /// + /// Nothing is cleared here: the entry goes when the session broadcasts + /// `cancelled`, which is also what tells every other client. Assuming it + /// locally would hide a cancellation the session refused. + pub fn cancel_recovery(&mut self) { + let Some((pane, _)) = self.recovery_focus() else { + return; + }; + if let Some(backend) = &mut self.backend { + backend.cancel_recovery(pane); + } + } +} diff --git a/src/runtime/terminal/tests/mod.rs b/src/runtime/terminal/tests/mod.rs index 3fa9e879..c7438c6c 100644 --- a/src/runtime/terminal/tests/mod.rs +++ b/src/runtime/terminal/tests/mod.rs @@ -3,6 +3,7 @@ use super::*; mod common; mod lifecycle_tests; mod poll_tests; +mod recovery_tests; mod scroll_tests; mod session_panes_tests; mod size_owner_tests; diff --git a/src/runtime/terminal/tests/recovery_tests.rs b/src/runtime/terminal/tests/recovery_tests.rs new file mode 100644 index 00000000..65d7e423 --- /dev/null +++ b/src/runtime/terminal/tests/recovery_tests.rs @@ -0,0 +1,147 @@ +use super::common::state_with_event_queue; +use crate::backend::BackendEvent; +use crate::runtime::terminal::recovery::RECOVERY_CANCELLED; + +const STATE: &str = "waiting_for_reset"; +const DEADLINE: i64 = 1_700_000_000; + +fn report(pane: crate::backend::PaneId, state: &str, attempt: u32) -> BackendEvent { + BackendEvent::Recovery { + pane, + state: state.to_string(), + detail: Some("provider window closed".to_string()), + deadline_epoch: Some(DEADLINE), + attempt, + } +} + +#[test] +fn a_reported_state_is_kept_verbatim_for_the_pane_it_names() { + let (mut state, events) = state_with_event_queue(); + state.create_pane_now().unwrap(); + let pane = state.panes[0].id; + + events.borrow_mut().push(report(pane, STATE, 2)); + state.poll(); + + let held = state.recovery_for(pane).expect("the report was dropped"); + assert_eq!(held.state, STATE); + assert_eq!(held.deadline_epoch, Some(DEADLINE)); + assert_eq!(held.attempt, 2); + assert_eq!(held.detail.as_deref(), Some("provider window closed")); +} + +#[test] +fn a_later_report_replaces_the_earlier_one_rather_than_accumulating() { + let (mut state, events) = state_with_event_queue(); + state.create_pane_now().unwrap(); + let pane = state.panes[0].id; + + events.borrow_mut().push(report(pane, STATE, 1)); + state.poll(); + events.borrow_mut().push(report(pane, "backoff", 3)); + state.poll(); + + let held = state.recovery_for(pane).expect("the report was dropped"); + assert_eq!(held.state, "backoff"); + assert_eq!(held.attempt, 3, "the newest report is the whole picture"); +} + +#[test] +fn a_cancelled_report_clears_the_pane_instead_of_being_stored() { + let (mut state, events) = state_with_event_queue(); + state.create_pane_now().unwrap(); + let pane = state.panes[0].id; + + events.borrow_mut().push(report(pane, STATE, 1)); + state.poll(); + events + .borrow_mut() + .push(report(pane, RECOVERY_CANCELLED, 0)); + state.poll(); + + assert!( + state.recovery_for(pane).is_none(), + "a finished recovery must leave no badge behind" + ); + assert!(!state.can_cancel_recovery()); +} + +#[test] +fn a_report_survives_the_pane_it_names_going_away() { + // The report that matters most arrives while the pane is gone and its slot is + // held for a relaunch, so an exit must not take it with it. + let (mut state, events) = state_with_event_queue(); + state.create_pane_now().unwrap(); + let pane = state.panes[0].id; + + events.borrow_mut().push(report(pane, STATE, 1)); + events.borrow_mut().push(BackendEvent::Exited { pane }); + state.poll(); + + assert!(state.panes.is_empty(), "the pane must be gone"); + assert_eq!( + state.recovery_focus().map(|(id, _)| id), + Some(pane), + "a pane with no tab must still be reachable" + ); +} + +#[test] +fn the_focused_pane_outranks_a_report_for_a_pane_that_is_gone() { + let (mut state, events) = state_with_event_queue(); + state.create_pane_now().unwrap(); + state.create_pane_now().unwrap(); + let (first, second) = (state.panes[0].id, state.panes[1].id); + state.active = 1; + + events.borrow_mut().push(report(first, STATE, 1)); + events.borrow_mut().push(report(second, "resuming", 0)); + events + .borrow_mut() + .push(BackendEvent::Exited { pane: first }); + state.poll(); + state.active = 0; + + // `poll` removed the exited pane, so the surviving one is index 0 now. + assert_eq!(state.panes.len(), 1); + assert_eq!( + state.recovery_focus().map(|(id, _)| id), + Some(second), + "the pane a person is looking at comes first" + ); +} + +#[test] +fn cancelling_with_nothing_reported_asks_the_session_for_nothing() { + let (mut state, _events) = state_with_event_queue(); + state.create_pane_now().unwrap(); + + state.cancel_recovery(); + state.poll(); + + assert!( + state.recovery_focus().is_none(), + "there was nothing to cancel and nothing to show" + ); +} + +#[test] +fn cancelling_asks_the_session_and_clears_only_on_its_answer() { + let (mut state, events) = state_with_event_queue(); + state.create_pane_now().unwrap(); + let pane = state.panes[0].id; + events.borrow_mut().push(report(pane, STATE, 1)); + state.poll(); + + state.cancel_recovery(); + + // Nothing is assumed locally: the badge is still there until the session + // confirms, which is what keeps a refused cancellation visible. + assert!(state.recovery_for(pane).is_some()); + state.poll(); + assert!( + state.recovery_for(pane).is_none(), + "the session's answer must clear the pane" + ); +} diff --git a/src/test_util.rs b/src/test_util.rs index 96e81533..04a54a83 100644 --- a/src/test_util.rs +++ b/src/test_util.rs @@ -136,6 +136,21 @@ impl crate::backend::TerminalBackend for FakeBackend { }); } + /// Echoed back like `destroy_pane` and `reorder`: a cancel is a request, and + /// the report a session broadcasts is what actually clears the pane's state. + /// A test therefore proves the request was made by polling for the answer. + fn cancel_recovery(&mut self, pane: crate::backend::PaneId) { + self.pending_events + .borrow_mut() + .push(crate::backend::BackendEvent::Recovery { + pane, + state: "cancelled".to_string(), + detail: None, + deadline_epoch: None, + attempt: 0, + }); + } + fn send_input(&mut self, _id: crate::backend::PaneId, data: &[u8]) -> anyhow::Result<()> { self.sent.push(data.to_vec()); Ok(()) diff --git a/src/ui/hint_text.rs b/src/ui/hint_text.rs index fab43e31..fee30810 100644 --- a/src/ui/hint_text.rs +++ b/src/ui/hint_text.rs @@ -33,6 +33,14 @@ pub(crate) fn prefix_armed_hint_text(app: &App) -> String { } else { "" }; + // Only while a plugin actually has a recovery pending, which is rare — an + // always-present hint for it would spend a scarce row on a key that is + // usually inert. + let cancel = if app.can_cancel_recovery() { + "c: cancel recovery | " + } else { + "" + }; // The view toggles name their destination from the current mode. let (log_toggle, tree_toggle) = match app.mode { ViewMode::Log => ("l: status view", "b: tree view"), @@ -43,7 +51,7 @@ pub(crate) fn prefix_armed_hint_text(app: &App) -> String { // reports why on the notice row, so the key always produces a visible // result. format!( - " t: new pane | {close}{swap}{resize}{log_toggle} | {tree_toggle} | f: fullscreen | o: open project | x: close project | p: theme | r: redraw | q: detach | {digits} | esc: cancel" + " t: new pane | {close}{swap}{resize}{cancel}{log_toggle} | {tree_toggle} | f: fullscreen | o: open project | x: close project | p: theme | r: redraw | q: detach | {digits} | esc: cancel" ) } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2ffdd619..1ac66444 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -23,6 +23,7 @@ mod hit_test; mod notice; #[cfg(test)] mod tests; +mod wall_clock; pub(crate) use chrome::{Chrome, chrome_rows, main_content_constraints}; pub(crate) use helpers::{ diff --git a/src/ui/notice.rs b/src/ui/notice.rs index 2f985876..6059a4b1 100644 --- a/src/ui/notice.rs +++ b/src/ui/notice.rs @@ -1,5 +1,6 @@ use crate::app::{App, Notice}; use crate::ui::status_view::RepoInput; +use crate::ui::wall_clock::local_hour_minute; use ratatui::{ style::{Color, Modifier, Style}, text::{Line, Span}, @@ -103,9 +104,41 @@ pub(crate) fn render_repo_header<'a>(app: &'a App, accent: Color) -> Paragraph<' Style::default().fg(Color::Cyan), )); } + if let Some(chip) = recovery_chip(app) { + spans.push(Span::styled( + chip, + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )); + } Paragraph::new(Line::from(spans)) } +/// The full recovery report as one chip: which pane, the plugin's state, the +/// deadline as a local wall-clock time, the attempts spent, and the detail line. +/// +/// On this row rather than in a row or overlay of its own for the reason the +/// notices are: a row that appears and disappears resizes every open PTY. It is +/// the last chip, so an actual notice still covers the whole line — a rejected +/// action needs explaining more than a wait does. The pane it describes is the +/// one ` c` would cancel (see `TerminalState::recovery_focus`). +fn recovery_chip(app: &App) -> Option { + let (pane, report) = app.terminal.recovery_focus()?; + let mut chip = format!(" pane {pane}: {}", report.state); + if let Some(at) = report.deadline_epoch.and_then(local_hour_minute) { + chip.push_str(&format!(" until {at}")); + } + if report.attempt > 0 { + chip.push_str(&format!(" (attempt {})", report.attempt)); + } + if let Some(detail) = report.detail.as_deref() { + chip.push_str(&format!(" — {detail}")); + } + chip.push(' '); + Some(chip) +} + pub(crate) fn home_relative_path(path: &str) -> String { let trimmed = path.trim_end_matches('/'); if let Some(home) = dirs::home_dir() diff --git a/src/ui/terminal_tab/mod.rs b/src/ui/terminal_tab/mod.rs index a7ae2afc..17c14aae 100644 --- a/src/ui/terminal_tab/mod.rs +++ b/src/ui/terminal_tab/mod.rs @@ -1,5 +1,6 @@ mod cells; mod layout; +mod recovery; mod screen; mod tab_bar; #[cfg(test)] @@ -11,9 +12,8 @@ pub(crate) use tab_bar::tab_target_at; use crate::app::{App, Focus}; use crate::runtime::terminal::visible_range; use crate::ui::terminal_tab::cells::visible_pane_cells; -use crate::ui::terminal_tab::layout::{ - TAB_TITLE_MAX_CHARS, TERMINAL_BORDERS, terminal_layout, truncate_tab_title, -}; +use crate::ui::terminal_tab::layout::{TERMINAL_BORDERS, terminal_layout}; +use crate::ui::terminal_tab::recovery::pane_label; use crate::ui::terminal_tab::screen::{build_screen_lines, render_cursor}; use crate::ui::terminal_tab::tab_bar::render_tab_bar; use ratatui::{ @@ -83,12 +83,7 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { } else { Style::default().fg(Color::DarkGray) }; - let pane_title = app - .terminal - .panes - .get(i) - .map(|p| truncate_tab_title(&p.title, TAB_TITLE_MAX_CHARS)) - .unwrap_or_default(); + let pane_title = pane_label(app, i); let cell_block = Block::default() .borders(Borders::ALL) .border_style(pane_border_style) diff --git a/src/ui/terminal_tab/recovery.rs b/src/ui/terminal_tab/recovery.rs new file mode 100644 index 00000000..9a7a55df --- /dev/null +++ b/src/ui/terminal_tab/recovery.rs @@ -0,0 +1,115 @@ +//! The recovery marker a pane's tab label carries. +//! +//! Deliberately a *suffix on an existing label* rather than a row or an overlay: +//! adding or removing a layout row resizes every open PTY (see the Layout and +//! Notice Row sections of `docs/architecture.md`), and a badge that comes and +//! goes would do that every time a plugin changed its mind. The full report — +//! state, deadline, attempt and detail — is on the notice row; this is only the +//! "which pane" pointer, so it has to stay short. + +use crate::app::App; +use crate::runtime::terminal::PaneRecovery; +use crate::ui::terminal_tab::layout::{TAB_TITLE_MAX_CHARS, truncate_tab_title}; +use crate::ui::wall_clock::local_hour_minute; + +/// Chars of the pane title kept when a marker rides along. +/// +/// Well under [`TAB_TITLE_MAX_CHARS`](super::layout::TAB_TITLE_MAX_CHARS): the +/// title is truncated to make room *before* the marker is appended, so a narrow +/// pane loses title characters rather than the marker. Losing the marker is the +/// one degradation that defeats the point of having it. +pub(crate) const RECOVERY_TITLE_MAX_CHARS: usize = 8; + +/// A wait with a known end. +const WAITING_GLYPH: char = '⏳'; +/// Attempts already spent, which is what a person judges "is this going +/// anywhere" by. +const ATTENTION_GLYPH: char = '⚠'; + +/// The label for the pane at `index`: its title, plus a recovery marker when its +/// plugin has reported one. Single source for the tab bar and the pane's own +/// border title, so the two cannot disagree about which pane is waiting. +pub(crate) fn pane_label(app: &App, index: usize) -> String { + let Some(pane) = app.terminal.panes.get(index) else { + return String::new(); + }; + match app.terminal.recovery_for(pane.id).map(recovery_marker) { + Some(marker) => format!( + "{} {marker}", + truncate_tab_title(&pane.title, RECOVERY_TITLE_MAX_CHARS) + ), + None => truncate_tab_title(&pane.title, TAB_TITLE_MAX_CHARS), + } +} + +/// The marker for one pane's report: the deadline as a local wall-clock time +/// when there is one, and the attempt count when any have been spent. +/// +/// A report with neither is still marked, with the bare hourglass — a pane its +/// plugin is doing something about must be distinguishable from one it is not, +/// even when there is no number to show. +pub(crate) fn recovery_marker(report: &PaneRecovery) -> String { + let mut marker = String::new(); + if let Some(at) = report.deadline_epoch.and_then(local_hour_minute) { + marker.push(WAITING_GLYPH); + marker.push_str(&at); + } + if report.attempt > 0 { + marker.push(ATTENTION_GLYPH); + marker.push_str(&report.attempt.to_string()); + } + if marker.is_empty() { + marker.push(WAITING_GLYPH); + } + marker +} + +#[cfg(test)] +mod tests { + use super::{ATTENTION_GLYPH, WAITING_GLYPH, recovery_marker}; + use crate::runtime::terminal::PaneRecovery; + + fn report(deadline_epoch: Option, attempt: u32) -> PaneRecovery { + PaneRecovery { + state: "waiting_for_reset".to_string(), + detail: None, + deadline_epoch, + attempt, + } + } + + #[test] + fn a_report_with_a_deadline_marks_it_as_a_wall_clock_time() { + let marker = recovery_marker(&report(Some(1_700_000_000), 0)); + assert!(marker.starts_with(WAITING_GLYPH), "{marker}"); + assert_eq!(marker.chars().count(), 6, "{marker}"); + } + + #[test] + fn a_report_with_no_deadline_shows_no_time_at_all() { + let marker = recovery_marker(&report(None, 3)); + assert_eq!(marker, format!("{ATTENTION_GLYPH}3")); + assert!(!marker.contains(':'), "no deadline must mean no clock time"); + } + + #[test] + fn a_report_with_both_carries_the_deadline_and_the_attempt_count() { + let marker = recovery_marker(&report(Some(1_700_000_000), 2)); + assert!(marker.contains(':'), "{marker}"); + assert!(marker.ends_with(&format!("{ATTENTION_GLYPH}2")), "{marker}"); + } + + #[test] + fn a_report_with_neither_is_still_marked() { + assert_eq!(recovery_marker(&report(None, 0)), WAITING_GLYPH.to_string()); + } + + #[test] + fn a_deadline_no_clock_can_place_falls_back_to_the_bare_marker() { + assert_eq!( + recovery_marker(&report(Some(i64::MIN), 0)), + WAITING_GLYPH.to_string(), + "an unplaceable deadline must not print a wrong time" + ); + } +} diff --git a/src/ui/terminal_tab/tab_bar.rs b/src/ui/terminal_tab/tab_bar.rs index 922f1a97..3a87aec2 100644 --- a/src/ui/terminal_tab/tab_bar.rs +++ b/src/ui/terminal_tab/tab_bar.rs @@ -1,8 +1,7 @@ use crate::app::App; use crate::runtime::terminal::visible_range; -use crate::ui::terminal_tab::layout::{ - JUMP_KEY_PANE_COUNT, TAB_TITLE_MAX_CHARS, terminal_layout, truncate_tab_title, -}; +use crate::ui::terminal_tab::layout::{JUMP_KEY_PANE_COUNT, terminal_layout}; +use crate::ui::terminal_tab::recovery::pane_label; use ratatui::{ Frame, layout::{Position, Rect}, @@ -64,14 +63,16 @@ pub(crate) fn tab_segments( )); } segments.extend(app.terminal.panes[visible.clone()].iter().enumerate().map( - |(offset, pane)| { + |(offset, _pane)| { let i = visible.start + offset; // Panes 0..=7 carry a jump key: ` 1..8` in fullscreen, // ` 3..9,0` in the split view (the digit row is // layout-aware). Panes past the 8th have no jump key, so they // carry no hint to avoid implying an unbound shortcut. The bare // F-keys are NOT advertised here: they select project tabs. - let title = truncate_tab_title(&pane.title, TAB_TITLE_MAX_CHARS); + // Carries the recovery marker when the pane has one, so a pane its + // plugin is nursing back is visible without leaving the tab row. + let title = pane_label(app, i); let label = if i < JUMP_KEY_PANE_COUNT { // Split view runs 3,4..9 then wraps to 0 for the eighth pane. let digit = if fullscreen { diff --git a/src/ui/terminal_tab/tests/tab_tests.rs b/src/ui/terminal_tab/tests/tab_tests.rs index 9b311546..226d182e 100644 --- a/src/ui/terminal_tab/tests/tab_tests.rs +++ b/src/ui/terminal_tab/tests/tab_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::runtime::terminal::PaneRecovery; use crate::ui::terminal_tab::layout::terminal_layout; use crate::ui::terminal_tab::render; use crate::ui::terminal_tab::tab_bar::tab_target_at; @@ -185,3 +186,61 @@ fn tab_bar_labels_panes_with_digits_in_fullscreen() { "fullscreen must not show the split-view F-key legend, got: {text}" ); } + +#[test] +fn a_tab_label_carries_a_recovery_marker_only_while_one_is_reported() { + let mut app = crate::app::tests::app_with_fake_backend(); + app.terminal + .create_pane_with_now(None, Some("agent")) + .unwrap(); + let pane = app.terminal.panes[0].id; + let visible = 0..1; + + let plain = tab_segments(&app, visible.clone())[0].0.clone(); + assert!(plain.contains("agent"), "{plain}"); + assert!( + !plain.contains('⏳'), + "an unwatched pane must carry no marker" + ); + + app.terminal.recovery.insert( + pane, + PaneRecovery { + state: "waiting_for_reset".to_string(), + detail: Some("provider window closed".to_string()), + deadline_epoch: Some(1_700_000_000), + attempt: 3, + }, + ); + + let marked = tab_segments(&app, visible)[0].0.clone(); + assert!(marked.contains("agent"), "the title must survive: {marked}"); + assert!(marked.contains('⏳'), "{marked}"); + assert!(marked.contains("⚠3"), "{marked}"); + // The deadline is a wall-clock time, so the label carries a `HH:MM` colon + // that the plain label did not. + assert!(marked.contains(':'), "{marked}"); +} + +#[test] +fn a_long_title_loses_characters_before_a_recovery_marker_does() { + let mut app = crate::app::tests::app_with_fake_backend(); + let long = "a-very-long-program-title-indeed"; + app.terminal.create_pane_with_now(None, Some(long)).unwrap(); + let pane = app.terminal.panes[0].id; + app.terminal.recovery.insert( + pane, + PaneRecovery { + state: "backoff".to_string(), + detail: None, + deadline_epoch: None, + attempt: 7, + }, + ); + + let label = tab_segments(&app, 0..1)[0].0.clone(); + + assert!(label.contains("⚠7"), "the marker must survive: {label}"); + assert!(label.contains('…'), "the title must be cut: {label}"); + assert!(!label.contains(long), "{label}"); +} diff --git a/src/ui/wall_clock.rs b/src/ui/wall_clock.rs new file mode 100644 index 00000000..2b74d680 --- /dev/null +++ b/src/ui/wall_clock.rs @@ -0,0 +1,94 @@ +//! Turning a unix epoch second into the `HH:MM` a person reads off their own +//! clock. +//! +//! Hand-rolled because nightcrow has no date crate and a deadline is the only +//! absolute time it ever renders: adding `chrono`/`time` for two integers would +//! buy a dependency and its transitive tree for one format string. + +#[cfg(any(not(unix), test))] +const SECS_PER_MINUTE: i64 = 60; +#[cfg(any(not(unix), test))] +const SECS_PER_HOUR: i64 = 3_600; +#[cfg(any(not(unix), test))] +const SECS_PER_DAY: i64 = 86_400; + +/// `HH:MM` in the machine's local zone, or `None` when the timestamp is one the +/// platform cannot place. +/// +/// `None` rather than a fallback on purpose: a wrong wall-clock time reads as +/// fact, and the caller is expected to show nothing instead. +pub(crate) fn local_hour_minute(epoch: i64) -> Option { + let (hour, minute) = local_hm(epoch)?; + Some(format!("{hour:02}:{minute:02}")) +} + +// The conversion is identity where `time_t` is 64-bit and a real narrowing check +// where it is 32-bit, so it has to stay even though it looks redundant here. +#[allow(clippy::useless_conversion)] +#[cfg(unix)] +fn local_hm(epoch: i64) -> Option<(u32, u32)> { + let seconds: libc::time_t = epoch.try_into().ok()?; + let mut parts: libc::tm = unsafe { std::mem::zeroed() }; + // SAFETY: `seconds` is a live `time_t` and `parts` a live `tm` for the whole + // call; `localtime_r` reads the first and writes only into the second, and is + // the reentrant form precisely so it needs no shared state. + let filled = unsafe { libc::localtime_r(&seconds, &mut parts) }; + if filled.is_null() { + return None; + } + Some(( + u32::try_from(parts.tm_hour).ok()?, + u32::try_from(parts.tm_min).ok()?, + )) +} + +/// UTC on platforms with no `localtime_r`. The zone database is the OS's to +/// expose, and guessing an offset would be worse than being explicit about the +/// one this falls back to. +#[cfg(not(unix))] +fn local_hm(epoch: i64) -> Option<(u32, u32)> { + utc_hm(epoch) +} + +/// `HH:MM` in UTC, which is what the epoch already counts. +/// +/// `epoch.rem_euclid` rather than `%` so a pre-1970 timestamp lands on the right +/// side of midnight instead of producing a negative hour. +#[cfg(any(not(unix), test))] +fn utc_hm(epoch: i64) -> Option<(u32, u32)> { + let into_day = epoch.rem_euclid(SECS_PER_DAY); + Some(( + u32::try_from(into_day / SECS_PER_HOUR).ok()?, + u32::try_from((into_day % SECS_PER_HOUR) / SECS_PER_MINUTE).ok()?, + )) +} + +#[cfg(test)] +mod tests { + use super::{local_hour_minute, utc_hm}; + + #[test] + fn the_epoch_itself_is_midnight_utc() { + assert_eq!(utc_hm(0), Some((0, 0))); + } + + #[test] + fn a_timestamp_before_the_epoch_stays_inside_the_day() { + // One minute before 1970 is 23:59, not a negative hour. + assert_eq!(utc_hm(-60), Some((23, 59))); + } + + #[test] + fn a_deadline_renders_as_two_padded_fields() { + let rendered = local_hour_minute(1_700_000_000).expect("a plausible deadline must render"); + assert_eq!(rendered.len(), 5, "{rendered}"); + let (hour, minute) = rendered.split_once(':').expect("HH:MM"); + assert!(hour.parse::().unwrap() < 24, "{rendered}"); + assert!(minute.parse::().unwrap() < 60, "{rendered}"); + } + + #[test] + fn a_timestamp_no_platform_clock_can_place_renders_nothing() { + assert_eq!(local_hour_minute(i64::MIN), None); + } +} diff --git a/src/web/viewer/catalog/mod.rs b/src/web/viewer/catalog/mod.rs index 9e0715c5..1e7ce7c4 100644 --- a/src/web/viewer/catalog/mod.rs +++ b/src/web/viewer/catalog/mod.rs @@ -44,6 +44,10 @@ pub struct Catalog { /// first client connect (empty = one bare shell). Applied to every hub the /// catalog spawns. startup_commands: Vec, + /// The `[[plugin]]` table, handed to every hub the catalog spawns. A hub only + /// launches the ones its own startup commands opted into, so an entry here is + /// an offer rather than a process. + plugins: Vec, } impl Catalog { @@ -60,6 +64,24 @@ impl Catalog { } } + /// Like [`Catalog::with_startup`], and every hub is also given the + /// `[[plugin]]` table its startup commands may name. + /// + /// Paired with the startup commands rather than set separately, because the + /// two are one decision: a plugin is only ever reachable through a startup + /// command's `plugin =`, so a catalog with one and not the other is a + /// half-configured session. + pub fn with_startup_and_plugins( + startup_commands: Vec, + plugins: Vec, + ) -> Self { + Self { + startup_commands, + plugins, + ..Self::default() + } + } + /// Replace the base served set — CLI `--repo` args or the TUI workspace's /// open tabs. Browser-opened repositories ([`Catalog::add_path`]) survive /// this, so a workspace change does not close a tab a viewer opened. @@ -160,7 +182,11 @@ impl Catalog { name: repo_name(&path), display_path: display_path(&path), runtime: RepoRuntime::spawn(&path), - terminals: TerminalHub::spawn(&path, self.startup_commands.clone()), + terminals: TerminalHub::spawn( + &path, + self.startup_commands.clone(), + self.plugins.clone(), + ), id, path, })), diff --git a/src/web/viewer/server/mod.rs b/src/web/viewer/server/mod.rs index 8b940bec..2cfab08c 100644 --- a/src/web/viewer/server/mod.rs +++ b/src/web/viewer/server/mod.rs @@ -118,6 +118,17 @@ impl ViewerState { /// or not a browser listener does: the daemon socket serves this same /// state, and a test drives it without taking a port. pub fn new(options: ViewerOptions) -> Self { + Self::with_plugins(options, Vec::new()) + } + + /// Like [`ViewerState::new`], with the `[[plugin]]` table the session's + /// startup panes may hand themselves to. + /// + /// Taken here rather than as a [`ViewerOptions`] field because it has to + /// reach the catalog before [`crate::web::viewer::catalog::Catalog::set_paths`] + /// spawns the first hub — a plugin association is decided when a pane is + /// created, not afterwards. + pub fn with_plugins(options: ViewerOptions, plugins: Vec) -> Self { let ViewerOptions { bind, port: _, @@ -129,7 +140,10 @@ impl ViewerState { prefs, } = options; let state = Self { - catalog: crate::web::viewer::catalog::Catalog::with_startup(startup_commands), + catalog: crate::web::viewer::catalog::Catalog::with_startup_and_plugins( + startup_commands, + plugins, + ), bound_loopback: bind.is_loopback(), auth, sessions: SessionStore::new(), @@ -149,6 +163,10 @@ impl ViewerState { impl ViewerServer { /// Bind and start from `[web_viewer]`, building the password verifier from /// either `hashed_password` or `password`. + /// + /// `plugins` is `config.toml`'s `[[plugin]]` table; an empty list means no + /// pane can be plugin-managed, which is the default. + #[allow(clippy::too_many_arguments)] pub fn start_from_config( viewer: &crate::config::WebViewerConfig, agent_indicator: &crate::config::AgentIndicatorConfig, @@ -156,6 +174,7 @@ impl ViewerServer { paths: &[String], persist: bool, startup_commands: Vec, + plugins: Vec, ) -> Result { let auth = if let Some(hash) = viewer.hashed_password.as_deref() { Auth::from_hashed(hash)? @@ -170,23 +189,34 @@ impl ViewerServer { viewer.bind ) })?; - Self::start(ViewerOptions { - bind, - port: viewer.port, - auth, - repos: paths.to_vec(), - persist, - startup_commands, - hot: agent_indicator.clone(), - // The session's accent outlives any one config edit, so `[theme]` - // only names the colour a session with no stored choice starts in. - prefs: PrefsStore::load_seeded(theme.preset_index()), - }) + Self::start_with_plugins( + ViewerOptions { + bind, + port: viewer.port, + auth, + repos: paths.to_vec(), + persist, + startup_commands, + hot: agent_indicator.clone(), + // The session's accent outlives any one config edit, so `[theme]` + // only names the colour a session with no stored choice starts in. + prefs: PrefsStore::load_seeded(theme.preset_index()), + }, + plugins, + ) } /// Bind and start accepting. The seeded repositories may be replaced later /// through [`ViewerServer::set_repos`]. pub fn start(options: ViewerOptions) -> Result { + Self::start_with_plugins(options, Vec::new()) + } + + /// Like [`ViewerServer::start`], with the `[[plugin]]` table. + pub fn start_with_plugins( + options: ViewerOptions, + plugins: Vec, + ) -> Result { // Copied out before the options are consumed: the listener is bound // first so a port conflict fails before any repository is opened. let (bind, port) = (options.bind, options.port); @@ -196,7 +226,7 @@ impl ViewerServer { .local_addr() .unwrap_or_else(|_| SocketAddr::new(bind, port)); - let state = Arc::new(ViewerState::new(options)); + let state = Arc::new(ViewerState::with_plugins(options, plugins)); let accept_state = Arc::clone(&state); std::thread::Builder::new() diff --git a/src/web/viewer/terminal/frame.rs b/src/web/viewer/terminal/frame.rs index 01869146..6f10e9e3 100644 --- a/src/web/viewer/terminal/frame.rs +++ b/src/web/viewer/terminal/frame.rs @@ -40,6 +40,16 @@ pub enum ClientMessage { /// glancing at a phone repaint everybody's screen. #[serde(rename = "claim_size")] ClaimSize, + /// Give up on whatever recovery is pending for `pane`. + /// + /// A person deciding the wait is over outranks the plugin still waiting: the + /// hold on the pane's slot is dropped and the slot retired, so nothing can + /// be relaunched into it afterwards. Harmless for a pane with no recovery in + /// flight — a client can be a beat behind the hold expiring. + #[serde(rename = "cancel_recovery")] + CancelRecovery { + pane: PaneId, + }, /// The sizes to give the startup terminals, answering [`ServerMessage::Pending`]. /// /// One entry per pending pane, in the order they will be created. A short @@ -159,6 +169,29 @@ pub enum ServerMessage { Pending { count: usize, }, + /// What a plugin reports about a pane it is nursing back, relayed verbatim. + /// + /// Pane metadata rather than screen content: nothing here is drawn into a + /// terminal grid, and a client that ignores it renders exactly as before. + /// `state` is the plugin's own short label; the hub neither interprets it nor + /// keeps it, so this is a broadcast of the latest word and not a state + /// machine. The one label the hub itself sends is + /// [`RECOVERY_CANCELLED`](super::hub_recovery::RECOVERY_CANCELLED), which a + /// client treats as "there is nothing pending any more". + Recovery { + pane: PaneId, + state: String, + /// A short human line, absent when the plugin gave none. Never carries + /// transcript or payload text. + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option, + /// When the wait ends, in **unix epoch seconds**, absent when the plugin + /// is not waiting on a clock. A client renders it in its own local zone; + /// an absent one must render nothing rather than a guess. + #[serde(default, skip_serializing_if = "Option::is_none")] + deadline_epoch: Option, + attempt: u32, + }, } /// One frame queued for a connected client. diff --git a/src/web/viewer/terminal/hub_events.rs b/src/web/viewer/terminal/hub_events.rs new file mode 100644 index 00000000..a609e3ad --- /dev/null +++ b/src/web/viewer/terminal/hub_events.rs @@ -0,0 +1,201 @@ +//! Turning pane activity into plugin events, and plugin commands into judged +//! actions. +//! +//! Every function here is a no-op for a pane no plugin owns, so the ordinary +//! terminal path pays one hash lookup and nothing else. + +use super::hub_plugins::{MAX_COMMANDS_PER_TICK, PANE_IDLE_THRESHOLD, Plugins}; +use crate::backend::{PaneGeneration, PaneId, PaneToken, PtyBackend}; +use crate::plugin::protocol::{PROTOCOL_VERSION, PluginCommand, PluginEvent}; +use crate::plugin::{Approved, PaneFacts, Refused}; +use crate::runtime::terminal::strip_escape_sequences; +use std::time::Instant; + +impl Plugins { + /// Send one pane-scoped event to whichever plugin owns `pane`, stamped with + /// the pane's current identity. + /// + /// A dropped event is logged and nothing else: a plugin that cannot keep up + /// must never stall the pane it is watching, which is why + /// [`crate::plugin::PluginHost::send`] does not block. + fn send_for( + &self, + backend: &PtyBackend, + pane: PaneId, + build: impl FnOnce(PaneToken, PaneGeneration) -> PluginEvent, + ) { + let Some(name) = self.owners.get(&pane) else { + return; + }; + let Some(host) = self.hosts.get(name) else { + return; + }; + let Some(slot) = backend.slot(pane) else { + return; + }; + let event = build(slot.identity.token.clone(), slot.identity.generation); + if !host.send(&event) { + tracing::debug!(plugin = %name, pane, "viewer: plugin event dropped"); + } + } + + /// Announce a pane a plugin has just been given, including one that a + /// relaunch has just put back — the generation is what tells them apart. + pub(super) fn pane_opened(&self, backend: &PtyBackend, pane: PaneId, title: Option<&str>) { + let command = backend + .slot(pane) + .and_then(|slot| slot.launch.command.clone()); + let (title, cwd) = (title.map(str::to_string), self.cwd.clone()); + self.send_for(backend, pane, |token, generation| PluginEvent::PaneOpened { + v: PROTOCOL_VERSION, + token, + generation, + title, + command, + cwd, + }); + } + + /// Feed a pane's output to its plugin as plain text. + /// + /// Stripped per chunk, which makes it best-effort: an escape sequence split + /// across two reads survives in fragments. Acceptable because output text is + /// only ever a fallback signal — nothing happens to a pane unless the plugin + /// asks, and every ask goes through the guard. + pub(super) fn pane_output(&mut self, backend: &PtyBackend, pane: PaneId, data: &[u8]) { + if !self.owners.contains_key(&pane) { + return; + } + // Fresh bytes end the quiet period, so the next one is announced again. + self.idle_announced.remove(&pane); + let text = strip_escape_sequences(data); + if text.is_empty() { + return; + } + self.send_for(backend, pane, |token, generation| PluginEvent::PaneOutput { + v: PROTOCOL_VERSION, + token, + generation, + text, + }); + } + + /// The pane's process ended, but its slot is being held: a relaunch is + /// possible until the hold expires. + pub(super) fn pane_exited(&self, backend: &PtyBackend, pane: PaneId) { + self.send_for(backend, pane, |token, generation| PluginEvent::PaneExited { + v: PROTOCOL_VERSION, + token, + generation, + }); + } + + /// The slot itself is going away, so nothing more can be done with it. + /// Must be sent before the slot is retired — the event carries its identity. + pub(super) fn pane_closed(&self, backend: &PtyBackend, pane: PaneId) { + self.send_for(backend, pane, |token, generation| PluginEvent::PaneClosed { + v: PROTOCOL_VERSION, + token, + generation, + }); + } + + /// A human typed into the pane. Both halves matter: the plugin is told it + /// has been taken over, and whatever it had already spent on this pane is + /// dropped so its picture of the pane cannot outlive the person's. + pub(super) fn user_input(&mut self, backend: &PtyBackend, pane: PaneId) { + if !self.owners.contains_key(&pane) { + return; + } + self.send_for(backend, pane, |token, generation| PluginEvent::UserInput { + v: PROTOCOL_VERSION, + token, + generation, + }); + if let Some(slot) = backend.slot(pane) { + self.guard.cancel(&slot.identity.token.clone()); + } + } + + /// Tell each plugin about any of its panes that has just gone quiet, once + /// per quiet period. + pub(super) fn notify_idle(&mut self, backend: &PtyBackend, now: Instant) { + if self.owners.is_empty() { + return; + } + let due: Vec<(PaneId, u64)> = self + .owners + .keys() + .copied() + .filter(|pane| !self.idle_announced.contains(pane)) + // An exited pane is quiet by definition; its plugin already had + // `PaneExited`, which is the stronger signal. + .filter(|pane| backend.is_process_alive(*pane)) + .filter_map(|pane| { + let idle = backend.slot(pane)?.idle_for(now); + (idle >= PANE_IDLE_THRESHOLD).then_some((pane, idle.as_millis() as u64)) + }) + .collect(); + for (pane, idle_ms) in due { + self.send_for(backend, pane, |token, generation| PluginEvent::PaneIdle { + v: PROTOCOL_VERSION, + token, + generation, + idle_ms, + }); + self.idle_announced.insert(pane); + } + } + + /// Take what every plugin has asked for since the last tick, at most + /// [`MAX_COMMANDS_PER_TICK`] each so a chatty plugin cannot starve the panes. + pub(super) fn take_commands(&self) -> Vec<(String, PluginCommand)> { + let mut taken = Vec::new(); + for (name, host) in &self.hosts { + for _ in 0..MAX_COMMANDS_PER_TICK { + match host.try_recv() { + Some(command) => taken.push((name.clone(), command)), + None => break, + } + } + } + taken + } + + /// Put one command through the guard. + /// + /// The pane the command names is resolved from its token — a plugin never + /// sees a [`PaneId`] — and every fact the rules need is read from the + /// backend here, so the guard itself holds no reference to it. + pub(super) fn judge( + &mut self, + plugin: &str, + command: PluginCommand, + backend: &PtyBackend, + now: Instant, + ) -> Result { + let facts = command + .token() + .and_then(|token| backend.pane_for_token(token)) + .and_then(|pane| { + let slot = backend.slot(pane)?; + Some(PaneFacts { + pane, + generation: slot.identity.generation, + // This plugin's own claim on the pane, not any plugin's: + // one plugin must not be able to act through another's + // opt-in. + opted_in: self.owners.get(&pane).is_some_and(|name| name == plugin), + alive: backend.is_process_alive(pane), + idle: slot.idle_for(now), + launch_command: slot.launch.command.clone(), + }) + }); + let allowed = self + .allowed_flags + .get(plugin) + .map(Vec::as_slice) + .unwrap_or_default(); + self.guard.judge(command, facts.as_ref(), allowed, now) + } +} diff --git a/src/web/viewer/terminal/hub_helpers.rs b/src/web/viewer/terminal/hub_helpers.rs index 574dd0f4..30ec8256 100644 --- a/src/web/viewer/terminal/hub_helpers.rs +++ b/src/web/viewer/terminal/hub_helpers.rs @@ -58,6 +58,12 @@ pub enum Command { Reorder { order: Vec, }, + /// Abandon a pane's pending relaunch. On the worker queue because carrying it + /// out needs the backend and the plugin bookkeeping, both of which are + /// worker-local. + CancelRecovery { + pane: PaneId, + }, } /// One startup terminal: the command to run, at the size a client measured, under @@ -66,6 +72,11 @@ pub struct StartupPane { pub(super) size: crate::web::viewer::terminal::frame::PaneSize, pub(super) command: Option, pub(super) title: Option, + /// The `[[plugin]]` this pane's configuration handed it to, if any. Carried + /// only as far as the worker, which records it in a map of its own — it is + /// deliberately absent from [`PaneState`] and from every `ServerMessage`, so + /// no client learns which panes a plugin can act on. + pub(super) plugin: Option, } /// A live terminal and the recent raw bytes it has produced, kept so a client diff --git a/src/web/viewer/terminal/hub_layout.rs b/src/web/viewer/terminal/hub_layout.rs new file mode 100644 index 00000000..2b62eb6c --- /dev/null +++ b/src/web/viewer/terminal/hub_layout.rs @@ -0,0 +1,90 @@ +//! Pane geometry and ordering: the two worker operations that change how the +//! panes are arranged rather than what is in them. +//! +//! Split out of `hub_run.rs` so the worker loop stays readable; the behaviour is +//! unchanged. + +use super::TerminalHub; +use super::frame::{ServerMessage, TerminalFrame}; +use super::hub_helpers::{broadcast_locked, canonical_order}; +use crate::backend::{PaneId, PtyBackend, TerminalBackend}; + +impl TerminalHub { + /// Resize a live pane's PTY at the sizing owner's request, record the size it + /// is now set to, and tell every client what it is. + /// + /// All under one lock, and the liveness check with them. `connect` reports + /// each pane's size from this record and the client caches it as "already + /// applied"; a client that slipped between the two would be told the old + /// size for a PTY that has the new one, and would then skip the resize that + /// would have corrected it. The `resize` itself is an ioctl on the master — + /// far cheaper than the broadcast this lock already covers. + pub(super) fn resize_pane( + &self, + backend: &mut PtyBackend, + pane: PaneId, + rows: u16, + cols: u16, + client: u64, + ) { + let mut state = self.state.lock().expect("terminal state poisoned"); + // Not this client's to set. Dropped rather than refused: a client can + // lose the sizing between laying out a frame and this arriving, which is + // ordinary rather than an error worth interrupting anyone over. + if state.size_owner != Some(client) { + return; + } + // An unknown pane is ignored rather than errored: a client racing a + // pane exit is normal, not an attack. + let Some(p) = state.panes.iter_mut().find(|p| p.id == pane) else { + return; + }; + if (p.rows, p.cols) == (rows, cols) { + return; + } + backend.resize(pane, rows, cols); + p.rows = rows; + p.cols = cols; + // Every client's emulator has to wrap where the child now does, so the + // size it was actually set to goes to all of them — including the one + // that asked, which learns here if its request was clamped. + if let Ok(json) = serde_json::to_string(&ServerMessage::Resized { pane, rows, cols }) { + broadcast_locked(&mut state.clients, TerminalFrame::Control(json)); + } + } + + /// Reorder the live panes to match `order` and tell every client the + /// result. + /// + /// `order` is a full desired sequence of pane ids. It is reconciled + /// against what is actually live so a reorder is robust to races with + /// create/close: unknown ids are dropped and any live pane the request + /// omits (e.g. one another client created in the same beat) is kept, + /// appended in its current order (see [`canonical_order`]). The hub + /// converges on that one canonical order and broadcasts it, so the + /// sender and every other device end up with the same layout. Reordering + /// only restyles the grid — pane ids, scrollback, and the live PTYs are + /// untouched. A no-op reorder sends nothing. + pub(super) fn reorder_panes(&self, order: Vec) { + let mut state = self.state.lock().expect("terminal state poisoned"); + let before: Vec = state.panes.iter().map(|p| p.id).collect(); + let target = canonical_order(&before, &order); + if target == before { + return; + } + // `target` is a permutation of `before`, so every id resolves and `old` + // ends empty. Move each `PaneState` rather than clone it — it owns the + // pane's scrollback. + let mut old = std::mem::take(&mut state.panes); + let mut reordered = Vec::with_capacity(old.len()); + for id in &target { + if let Some(pos) = old.iter().position(|p| p.id == *id) { + reordered.push(old.remove(pos)); + } + } + state.panes = reordered; + if let Ok(json) = serde_json::to_string(&ServerMessage::Reordered { order: target }) { + broadcast_locked(&mut state.clients, TerminalFrame::Control(json)); + } + } +} diff --git a/src/web/viewer/terminal/hub_plugins.rs b/src/web/viewer/terminal/hub_plugins.rs new file mode 100644 index 00000000..bdafdda3 --- /dev/null +++ b/src/web/viewer/terminal/hub_plugins.rs @@ -0,0 +1,262 @@ +//! The plugin side of a terminal worker: which panes a plugin may see, the +//! hosts watching them, and the slots being held open for a relaunch. +//! +//! Every field here is worker-local. A plugin can drive a pane's keyboard, so +//! none of this is reachable from a connection thread — the only way in is the +//! command queue the worker already drains, and the only way out is +//! [`crate::plugin::Guard`]. +//! +//! A pane appears here **only** if its `[[startup_command]]` named a plugin by +//! hand. A pane a client opened has no association and never gains one, which is +//! what makes "an arbitrary shell is never plugin-controlled" a property of the +//! code rather than a promise. + +use super::hub_helpers::PaneState; +use crate::backend::{PaneId, PtyBackend}; +use crate::config::{PluginConfig, StartupCommand}; +use crate::plugin::{Guard, PluginHost, RateLimits}; +use std::collections::{HashMap, HashSet}; +use std::time::{Duration, Instant}; + +/// How long a pane must be quiet before its plugin is told it is idle. +/// +/// Deliberately the same value the guard requires before it will let a plugin +/// type into that pane: announcing idleness any earlier would only invite +/// commands the guard is bound to refuse. Ten seconds is well past the pause a +/// CLI takes mid-answer and well short of a wait a person would notice. +pub(super) const PANE_IDLE_THRESHOLD: Duration = Duration::from_secs(10); + +/// How long an exited pane's slot is kept so a relaunch can still reuse its +/// token. +/// +/// This is a backstop against a plugin that died or lost interest, so it has to +/// outlast every wait a plugin may legitimately be in the middle of. Providers +/// quote windows in hours *and* in days — a weekly quota is a real case — so a +/// value picked around the five-hour window would silently throw the pane's +/// identity away days before the wait paid off, and the relaunch it was being +/// kept for would fail. Nine days clears the longest window a bundled plugin +/// will wait out (`nightcrow-recovery`'s own clamp is eight days) with slack for +/// a reset that lands late. +/// +/// Holding it that long is cheap on purpose: a token, a generation and a command +/// string. The process, its fds and its threads were let go the moment it exited +/// (see [`PtyBackend::release_process`]), and closing the pane or stopping the +/// session retires the slot immediately either way. +pub(super) const PENDING_RELAUNCH_TTL: Duration = Duration::from_secs(9 * 24 * 60 * 60); + +/// Commands taken from any one plugin per loop iteration. +/// +/// One thread serves every pane in the repository, so a plugin that writes +/// without pause must not be able to hold it. Eight per 8 ms tick is a thousand +/// a second — far past anything a legitimate plugin needs, and bounded. +pub(super) const MAX_COMMANDS_PER_TICK: usize = 8; + +/// Where a pane sat and what it looked like, captured before it is removed. +pub(super) struct PaneSpot { + /// Its position in the client-visible order, so a relaunch lands back where + /// the operator left it instead of at the end of the row. + pub(super) index: usize, + pub(super) rows: u16, + pub(super) cols: u16, + pub(super) title: Option, +} + +impl PaneSpot { + pub(super) fn of(index: usize, pane: &PaneState) -> Self { + Self { + index, + rows: pane.rows, + cols: pane.cols, + title: pane.title.clone(), + } + } +} + +/// A pane whose process exited while a plugin was watching it, held so that +/// plugin still has something to relaunch. +pub(super) struct Pending { + pub(super) spot: PaneSpot, + /// When the slot is given up on. See [`PENDING_RELAUNCH_TTL`]. + deadline: Instant, +} + +pub(super) struct Plugins { + /// Live plugin children, by configured name. A plugin that failed to launch + /// is absent, and its panes are therefore never adopted below. + pub(super) hosts: HashMap, + /// Which plugin owns which pane. The authority for `opted_in`. + pub(super) owners: HashMap, + /// Each plugin's `allowed_resume_flags`, as the guard needs them. + pub(super) allowed_flags: HashMap>, + pub(super) guard: Guard, + pub(super) pending: HashMap, + /// Panes whose plugin has already been told about the current quiet period, + /// so it is told once rather than on every tick. + pub(super) idle_announced: HashSet, + /// The repository the panes run in, reported with every `PaneOpened`. + pub(super) cwd: String, +} + +impl Plugins { + /// Launch a host for every plugin that is enabled *and* that some startup + /// pane opted into. + /// + /// Both conditions, because a host with no pane to watch is a child process + /// that can never be given anything to do. A plugin that will not launch is + /// logged and left out: its panes then behave exactly like unwatched ones, + /// so a broken plugin costs the operator a warning rather than a terminal. + pub(super) fn start(cwd: &str, configs: &[PluginConfig], startup: &[StartupCommand]) -> Self { + let dir = crate::plugin::registry::default_plugins_dir() + .inspect_err(|error| { + tracing::debug!(%error, "viewer: no plugin directory; resolving plugins on PATH"); + }) + .ok(); + let mut hosts = HashMap::new(); + let mut allowed_flags = HashMap::new(); + for cfg in configs { + let opted_in = startup + .iter() + .any(|sc| sc.plugin.as_deref() == Some(cfg.name.as_str())); + if !cfg.enabled || !opted_in { + continue; + } + match PluginHost::spawn(cfg, dir.as_deref()) { + Ok(host) => { + allowed_flags.insert(cfg.name.clone(), cfg.allowed_resume_flags.clone()); + hosts.insert(cfg.name.clone(), host); + } + Err(error) => tracing::warn!( + plugin = %cfg.name, + %error, + "viewer: plugin did not launch; its panes run unwatched" + ), + } + } + Self { + hosts, + owners: HashMap::new(), + allowed_flags, + guard: Guard::new(PANE_IDLE_THRESHOLD, RateLimits::default()), + pending: HashMap::new(), + idle_announced: HashSet::new(), + cwd: cwd.to_string(), + } + } + + /// Hand `pane` to `plugin`, reporting whether it took. + /// + /// Refused when that plugin has no host: recording an association nothing + /// can act on would put the pane on the relaunch path — its slot kept alive + /// after an exit for a plugin that will never ask — for no benefit. + pub(super) fn adopt(&mut self, pane: PaneId, plugin: &str) -> bool { + if !self.hosts.contains_key(plugin) { + return false; + } + self.owners.insert(pane, plugin.to_string()); + true + } + + /// Which plugin watches `pane`, if any. + pub(super) fn owner(&self, pane: PaneId) -> Option<&str> { + self.owners.get(&pane).map(String::as_str) + } + + /// Whether there is nothing for the per-tick work to do, which is the common + /// case: no host means no pane can be watched and no command can arrive. + /// + /// Deliberately *not* "nothing is watched yet". A host's inbound queue is + /// drained only by that per-tick work, so skipping it while a live plugin was + /// writing — before any startup pane had been claimed, say — would let its + /// commands pile up unread with nothing to bound them. + pub(super) fn is_inert(&self) -> bool { + self.hosts.is_empty() + } + + /// Hold `pane`'s slot open for a relaunch. Nothing is held for a pane no + /// plugin watches — that pane's slot is gone by the time this is reached. + pub(super) fn hold_for_relaunch(&mut self, pane: PaneId, spot: PaneSpot, now: Instant) { + if !self.owners.contains_key(&pane) { + return; + } + self.idle_announced.remove(&pane); + self.pending.insert( + pane, + Pending { + spot, + deadline: now + PENDING_RELAUNCH_TTL, + }, + ); + } + + /// Take the hold on `pane`, if it is still within its window. + pub(super) fn claim_pending(&mut self, pane: PaneId) -> Option { + self.pending.remove(&pane) + } + + /// Put a hold back after a relaunch attempt failed, so the pane keeps its + /// remaining window instead of being retired by a single bad try. + pub(super) fn restore_pending(&mut self, pane: PaneId, pending: Pending) { + self.pending.insert(pane, pending); + } + + /// Move a pane's association onto the process that replaced it. + /// + /// The spent budget is deliberately left alone. It is keyed by the slot's + /// token, which a relaunch preserves, and that is the only thing bounding a + /// plugin that answers every exit with another relaunch — clearing it here + /// would hand out a fresh allowance on every attempt and the ceiling would + /// never be reached. + pub(super) fn take_over(&mut self, old: PaneId, new: PaneId) { + if let Some(plugin) = self.owners.remove(&old) { + self.owners.insert(new, plugin); + } + self.idle_announced.remove(&old); + } + + /// Forget `pane` entirely. The caller still has to retire its slot. + /// + /// Takes the backend because the budget is keyed by the slot's token, so it + /// has to be read before the slot is retired. + pub(super) fn forget(&mut self, backend: &PtyBackend, pane: PaneId) { + self.owners.remove(&pane); + self.pending.remove(&pane); + self.idle_announced.remove(&pane); + if let Some(slot) = backend.slot(pane) { + self.guard.cancel(&slot.identity.token.clone()); + } + } + + /// Retire the slots nobody relaunched in time, reporting which panes those + /// were so the caller can tell the clients still showing their deadlines. + pub(super) fn expire_pending(&mut self, backend: &mut PtyBackend, now: Instant) -> Vec { + let expired: Vec = self + .pending + .iter() + .filter(|(_, held)| now >= held.deadline) + .map(|(pane, _)| *pane) + .collect(); + for pane in &expired { + tracing::info!( + pane, + "viewer: no relaunch within the window; retiring the pane's slot" + ); + self.forget(backend, *pane); + backend.retire_slot(*pane); + } + expired + } + + /// Stop every plugin child. + /// + /// The only place that happens: a plugin is not one of `PtyBackend`'s panes, + /// so nothing else will ever reap it. + pub(super) fn shutdown(&mut self) { + for (name, host) in self.hosts.iter_mut() { + let dropped = host.dropped_events(); + if dropped > 0 { + tracing::warn!(plugin = %name, dropped, "viewer: plugin fell behind its events"); + } + host.shutdown(); + } + } +} diff --git a/src/web/viewer/terminal/hub_recovery.rs b/src/web/viewer/terminal/hub_recovery.rs new file mode 100644 index 00000000..189f5c41 --- /dev/null +++ b/src/web/viewer/terminal/hub_recovery.rs @@ -0,0 +1,117 @@ +//! Making a plugin's recovery visible to people, and cancellable by them. +//! +//! The hub keeps no recovery state of its own: a report is broadcast as it +//! arrives and forgotten. What the hub *does* own is the hold on an exited +//! pane's slot, and that is the thing a person can take away — so cancelling is +//! the one place here that touches the backend. + +use super::TerminalHub; +use super::frame::{ServerMessage, TerminalFrame}; +use super::hub_helpers::broadcast_locked; +use super::hub_plugins::Plugins; +use crate::backend::{PaneId, PtyBackend, TerminalBackend}; +use std::time::Instant; + +/// The `state` the hub itself sends when a pane's recovery is over without +/// having succeeded — cancelled by a person, or given up on when the hold ran +/// out. Every client reads it as "stop showing a deadline for this pane". +pub(crate) const RECOVERY_CANCELLED: &str = "cancelled"; + +impl TerminalHub { + /// Tell every client the latest word on a pane's recovery. + pub(super) fn broadcast_recovery( + &self, + pane: PaneId, + state: &str, + detail: Option<&str>, + deadline_epoch: Option, + attempt: u32, + ) { + let Ok(json) = serde_json::to_string(&ServerMessage::Recovery { + pane, + state: state.to_string(), + detail: detail.map(str::to_string), + deadline_epoch, + attempt, + }) else { + return; + }; + // Serialized before the lock and broadcast under it, exactly as the pane + // announcements are: a client either connects before this frame or after, + // never into the middle of it. + let mut state = self.state.lock().expect("terminal state poisoned"); + broadcast_locked(&mut state.clients, TerminalFrame::Control(json)); + } + + /// Tell every client there is nothing pending for `pane` any more. + /// + /// Sent wherever a hold ends without leaving one behind — cancelled, expired, + /// relaunched, or closed for good. Without it a client keeps the last report + /// it saw, and a deadline that has already come and gone stays on screen. + pub(super) fn end_recovery(&self, pane: PaneId) { + self.broadcast_recovery(pane, RECOVERY_CANCELLED, None, None, 0); + } + + /// A pane's process ended. + /// + /// For a pane no plugin watches this is the long-standing path: destroy it + /// and tell everyone. For a watched one the slot has to survive, because its + /// token is the only thing a relaunch can reuse — so the process alone is let + /// go and the slot is held until the plugin acts or the window closes. + pub(super) fn pane_exited( + &self, + backend: &mut PtyBackend, + plugins: &mut Plugins, + pane: PaneId, + ) { + if plugins.owner(pane).is_none() { + backend.destroy_pane(pane); + self.remove_pane_and_announce(pane); + return; + } + // Where it sat, read before the removal below takes it out of the order. + match self.pane_spot(pane) { + Some(spot) => { + backend.release_process(pane); + plugins.hold_for_relaunch(pane, spot, Instant::now()); + plugins.pane_exited(backend, pane); + } + // Not in the client-visible order, so there is nowhere to put a + // replacement and no reason to keep the slot alive for one. + None => { + plugins.pane_closed(backend, pane); + plugins.forget(backend, pane); + backend.destroy_pane(pane); + self.end_recovery(pane); + } + } + // Clients see the truth either way: the process is gone, and a relaunch + // arrives as a new pane rather than as this one coming back. + self.remove_pane_and_announce(pane); + } + + /// A person has given up on `pane`'s recovery. + /// + /// Taking the hold is what makes this a cancellation rather than a no-op: the + /// hold *is* the pending recovery, so a pane without one has nothing to + /// invalidate and is left alone. When there is one, the plugin is told the + /// slot is going while it can still be named, and only then is it retired — + /// `forget` reads the slot's token to drop the plugin's spent budget, so it + /// has to run before `retire_slot` takes the slot away. + pub(super) fn cancel_recovery( + &self, + backend: &mut PtyBackend, + plugins: &mut Plugins, + pane: PaneId, + ) { + if plugins.claim_pending(pane).is_none() { + tracing::debug!(pane, "viewer: cancel with no recovery pending; ignored"); + return; + } + tracing::info!(pane, "viewer: a client cancelled a pane's recovery"); + plugins.pane_closed(backend, pane); + plugins.forget(backend, pane); + backend.retire_slot(pane); + self.end_recovery(pane); + } +} diff --git a/src/web/viewer/terminal/hub_relaunch.rs b/src/web/viewer/terminal/hub_relaunch.rs new file mode 100644 index 00000000..a5b20a76 --- /dev/null +++ b/src/web/viewer/terminal/hub_relaunch.rs @@ -0,0 +1,241 @@ +//! Carrying out what a plugin was allowed to do. +//! +//! Everything here runs on the worker thread and acts only on an [`Approved`] +//! value. There is no path from a [`PluginCommand`](crate::plugin::protocol::PluginCommand) +//! to a pane that does not pass through [`Plugins::judge`] first, and a +//! [`Refused`] is logged and dropped — never retried, never acted on. + +use super::TerminalHub; +use super::frame::{ServerMessage, TerminalFrame}; +use super::hub_helpers::broadcast_locked; +use super::hub_plugins::{PaneSpot, Plugins}; +use crate::backend::{PaneId, PtyBackend, TerminalBackend}; +use crate::plugin::protocol::LogLevel; +use crate::plugin::{Approved, Refused}; +use std::time::Instant; + +impl TerminalHub { + /// Take everything the plugins have asked for this tick, judge it, and do + /// whatever survived. + pub(super) fn dispatch_plugin_commands( + &self, + backend: &mut PtyBackend, + plugins: &mut Plugins, + now: Instant, + ) { + for (plugin, command) in plugins.take_commands() { + let verdict = plugins.judge(&plugin, command, backend, now); + match verdict { + // Straight to the PTY rather than back through + // `Command::Input`: that queue is the human's, and a plugin's + // own input arriving on it would raise `UserInput` and cancel + // the very recovery this input is part of. + Ok(Approved::SendInput { pane, data }) => { + if let Err(error) = backend.send_input(pane, &data) { + tracing::warn!( + plugin = %plugin, + pane, + %error, + "viewer: plugin input could not be written" + ); + } + } + Ok(Approved::Relaunch { + pane, + resume_args, + command_line, + }) => self.relaunch_for_plugin( + backend, + plugins, + &plugin, + pane, + &resume_args, + &command_line, + ), + // Observability, and now a client-visible one. Still logged + // whole: the log is the record of what a plugin claimed, and a + // client that was not connected saw none of it. + Ok(Approved::Status { + pane, + state, + detail, + deadline_epoch, + attempt, + }) => { + tracing::info!( + plugin = %plugin, + pane, + state = %state, + detail = ?detail, + deadline_epoch = ?deadline_epoch, + attempt, + "viewer: plugin reports a pane's state" + ); + self.broadcast_recovery( + pane, + &state, + detail.as_deref(), + deadline_epoch, + attempt, + ); + } + Ok(Approved::Log { level, message }) => log_plugin_line(&plugin, level, &message), + Err(refused) => log_refusal(&plugin, &refused), + } + } + } + + /// Put a process back into the slot a plugin was holding. + /// + /// Only for a pane the hub is actually holding: that hold is the proof the + /// pane exited and is still inside its window, and without it the slot has + /// either been retired or never belonged to this flow at all. + fn relaunch_for_plugin( + &self, + backend: &mut PtyBackend, + plugins: &mut Plugins, + plugin: &str, + pane: PaneId, + resume_args: &[String], + command_line: &str, + ) { + let Some(held) = plugins.claim_pending(pane) else { + tracing::debug!( + plugin = %plugin, + pane, + "viewer: relaunch for a pane with no hold; ignored" + ); + return; + }; + // The cap still binds. The pane's own slot came free when its process + // ended, but a hold can be open for hours and a client may have taken + // that slot in the meantime — and the ceiling counts real processes, not + // who is entitled to one. Left held so the plugin can try again while its + // window lasts, rather than losing the recovery to a full grid. + if !self.has_free_slot() { + tracing::warn!( + plugin = %plugin, + pane, + "viewer: no terminal slot free for a relaunch; the pane keeps its hold" + ); + plugins.restore_pending(pane, held); + return; + } + let (rows, cols, index) = (held.spot.rows, held.spot.cols, held.spot.index); + let title = held.spot.title.clone(); + let allowed = plugins + .allowed_flags + .get(plugin) + .cloned() + .unwrap_or_default(); + + match backend.relaunch_pane(pane, rows, cols, resume_args, &allowed) { + Ok(replacement) => { + // `relaunch_pane` composes the line itself from the same slot, + // args and flags the guard used, so the approved text is the + // text that runs; logging it records exactly that without + // giving the command line a second source of truth. + tracing::info!( + plugin = %plugin, + pane, + replacement, + command_line = %command_line, + "viewer: relaunched a pane for its plugin" + ); + self.register_pane(replacement, rows, cols, None, title.clone()); + self.restore_pane_index(replacement, index); + plugins.take_over(pane, replacement); + plugins.pane_opened(backend, replacement, title.as_deref()); + // The old pane id is spent, so any report a client is still + // showing for it is now about nothing. The plugin's next report + // arrives under the replacement's id. + self.end_recovery(pane); + } + // Held rather than retried. The window is what bounds this, and a + // retry loop here would spend the plugin's whole relaunch budget + // inside one 8 ms tick. + Err(error) => { + tracing::warn!( + plugin = %plugin, + pane, + %error, + "viewer: relaunch failed; the pane keeps its hold" + ); + plugins.restore_pending(pane, held); + } + } + } + + /// Put `pane` back at `index` in the client-visible order and tell every + /// client the result. + /// + /// The order *is* `Shared::panes`, so this is how a relaunched pane keeps + /// its predecessor's place without a new wire message: clients already + /// apply `Reordered`. + fn restore_pane_index(&self, pane: PaneId, index: usize) { + let mut state = self.state.lock().expect("terminal state poisoned"); + let Some(from) = state.panes.iter().position(|p| p.id == pane) else { + return; + }; + // Clamped, because panes can have closed while the hold was open and an + // index past the end would panic `insert`. + let to = index.min(state.panes.len().saturating_sub(1)); + if from == to { + return; + } + let entry = state.panes.remove(from); + state.panes.insert(to, entry); + let order: Vec = state.panes.iter().map(|p| p.id).collect(); + if let Ok(json) = serde_json::to_string(&ServerMessage::Reordered { order }) { + broadcast_locked(&mut state.clients, TerminalFrame::Control(json)); + } + } + + /// Where `pane` sits and what it looks like, captured before it is removed + /// so its replacement can be put back in the same place under the same name. + pub(super) fn pane_spot(&self, pane: PaneId) -> Option { + let state = self.state.lock().expect("terminal state poisoned"); + let index = state.panes.iter().position(|p| p.id == pane)?; + Some(PaneSpot::of(index, &state.panes[index])) + } +} + +/// A plugin's own log line, attributed to it so it cannot be mistaken for one +/// of nightcrow's. +fn log_plugin_line(plugin: &str, level: LogLevel, message: &str) { + match level { + LogLevel::Error => tracing::error!(plugin = %plugin, "{message}"), + LogLevel::Warn => tracing::warn!(plugin = %plugin, "{message}"), + LogLevel::Info => tracing::info!(plugin = %plugin, "{message}"), + LogLevel::Debug => tracing::debug!(plugin = %plugin, "{message}"), + } +} + +/// Log a refusal at the level that says whether anyone should look into it. +/// +/// A plugin decides asynchronously, so being late is ordinary traffic rather +/// than a fault: the pane moved on, or is not quiet yet. The rest mean the +/// plugin asked for something it was never allowed — a pane that is not its, an +/// oversized or control-laden payload, a flag the config does not list, or more +/// attempts than the budget allows — and that is worth an operator's attention. +/// Matched exhaustively on purpose, so a new refusal has to be classified rather +/// than defaulting to silence. +fn log_refusal(plugin: &str, refused: &Refused) { + let ordinary = match refused { + Refused::UnknownPane { .. } + | Refused::StaleGeneration { .. } + | Refused::PaneNotRunning { .. } + | Refused::PaneBusy { .. } + | Refused::PaneStillRunning { .. } => true, + Refused::NotOptedIn { .. } + | Refused::InputTooLarge { .. } + | Refused::ControlCharacter { .. } + | Refused::ResumeArgsRejected { .. } + | Refused::RateLimited { .. } => false, + }; + if ordinary { + tracing::debug!(plugin = %plugin, "viewer: plugin command refused: {refused}"); + } else { + tracing::warn!(plugin = %plugin, "viewer: plugin command refused: {refused}"); + } +} diff --git a/src/web/viewer/terminal/hub_run.rs b/src/web/viewer/terminal/hub_run.rs index fc910133..a27cdd5a 100644 --- a/src/web/viewer/terminal/hub_run.rs +++ b/src/web/viewer/terminal/hub_run.rs @@ -1,6 +1,7 @@ use super::TerminalHub; use super::frame::{ServerMessage, TerminalFrame}; -use super::hub_helpers::{Command, PaneState, broadcast_locked, canonical_order, push_scrollback}; +use super::hub_helpers::{Command, PaneState, broadcast_locked, push_scrollback}; +use super::hub_plugins::Plugins; use crate::backend::{BackendEvent, PaneId, PtyBackend, TerminalBackend}; use crate::web::viewer::limits; use std::collections::VecDeque; @@ -8,13 +9,17 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::Receiver; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant}; const POLL_INTERVAL: Duration = Duration::from_millis(8); impl TerminalHub { pub(super) fn run(&self, cwd: &str, commands: Receiver, stop: Arc) { let mut backend = PtyBackend::new(cwd); + // Before the loop, because a pane can be created on the first iteration + // and a plugin has to exist to be told about it. Only the plugins some + // configured pane opted into are launched (see `Plugins::start`). + let mut plugins = Plugins::start(cwd, &self.plugins, &self.startup); while !stop.load(Ordering::Acquire) { while let Ok(command) = commands.try_recv() { @@ -33,7 +38,9 @@ impl TerminalHub { } match backend.open_pane(rows, cols, command.as_deref()) { // Unnamed: a pane a client asked for is that client's - // to name, and the hub has nothing to add. + // to name, and the hub has nothing to add. No plugin + // association either, ever — a shell a client opened + // is nobody's to drive but the person at it. Ok(pane) => self.register_pane(pane, rows, cols, Some(client), None), Err(err) => { tracing::warn!(%err, "viewer: could not create a terminal"); @@ -45,10 +52,18 @@ impl TerminalHub { panes, client, reserved, - } => self.open_startup_panes(&mut backend, panes, client, reserved), + } => { + self.open_startup_panes(&mut backend, &mut plugins, panes, client, reserved) + } // Unknown pane ids are ignored rather than errored: a // client racing a pane exit is normal, not an attack. Command::Input { pane, data } if self.pane_is_live(pane) => { + // A person at the keyboard has taken the pane back, so + // its plugin is told and everything it had planned for + // the pane is dropped — before the bytes land, so the + // cancellation cannot be overtaken by a decision made + // from the output this input produces. + plugins.user_input(&backend, pane); let _ = backend.send_input(pane, &data); } Command::Resize { @@ -60,17 +75,34 @@ impl TerminalHub { self.resize_pane(&mut backend, pane, rows, cols, client); } Command::Close { pane } if self.pane_is_live(pane) => { + // Closed for good, unlike an exit: the slot goes with + // the process, so there is nothing left to relaunch. + if plugins.owner(pane).is_some() { + plugins.pane_closed(&backend, pane); + plugins.forget(&backend, pane); + backend.retire_slot(pane); + self.end_recovery(pane); + } backend.destroy_pane(pane); self.remove_pane_and_announce(pane); } Command::Reorder { order } => self.reorder_panes(order), + // Deliberately not gated on the pane being live: a pane with + // a recovery pending is one whose process has already ended, + // so it is no longer in the client-visible list. + Command::CancelRecovery { pane } => { + self.cancel_recovery(&mut backend, &mut plugins, pane) + } _ => {} } } for event in backend.drain_events() { match event { - BackendEvent::Output { pane, data } => self.record_and_broadcast(pane, data), + BackendEvent::Output { pane, data } => { + plugins.pane_output(&backend, pane, &data); + self.record_and_broadcast(pane, data); + } // Destroyed as well as forgotten. `PtyBackend` leaves pane // removal to its caller (see its `drain_events`), so a pane // that ended on its own — the user typed `exit`, or the @@ -79,8 +111,7 @@ impl TerminalHub { // counts live panes, not those, so open-and-exit in a loop // accumulated descriptors with nothing to stop it. BackendEvent::Exited { pane } => { - backend.destroy_pane(pane); - self.remove_pane_and_announce(pane); + self.pane_exited(&mut backend, &mut plugins, pane) } // The hub owns its PTYs outright: it opens them through // `open_pane`, which answers directly, and it is what @@ -89,14 +120,32 @@ impl TerminalHub { BackendEvent::Created { pane, .. } | BackendEvent::Resized { pane, .. } => { tracing::debug!(pane, "hub: unexpected event from its own backend"); } - BackendEvent::SizeOwnership { .. } | BackendEvent::Reordered { .. } => { + BackendEvent::SizeOwnership { .. } + | BackendEvent::Reordered { .. } + | BackendEvent::Recovery { .. } => { tracing::debug!("hub: unexpected session event from its own backend"); } } } + + if !plugins.is_inert() { + let now = Instant::now(); + plugins.notify_idle(&backend, now); + self.dispatch_plugin_commands(&mut backend, &mut plugins, now); + // Told to the clients, or one that was shown a deadline keeps + // counting down to a moment that has already passed. + for pane in plugins.expire_pending(&mut backend, now) { + self.end_recovery(pane); + } + } thread::sleep(POLL_INTERVAL); } + // Ahead of the panes: a plugin child is not one of `PtyBackend`'s panes, + // so this is the only place it is ever reaped, and telling it to stop + // before its panes disappear beneath it is the courteous order. + plugins.shutdown(); + let ids: Vec = self .state .lock() @@ -171,49 +220,6 @@ impl TerminalHub { } } - /// Resize a live pane's PTY at the sizing owner's request, record the size it - /// is now set to, and tell every client what it is. - /// - /// All under one lock, and the liveness check with them. `connect` reports - /// each pane's size from this record and the client caches it as "already - /// applied"; a client that slipped between the two would be told the old - /// size for a PTY that has the new one, and would then skip the resize that - /// would have corrected it. The `resize` itself is an ioctl on the master — - /// far cheaper than the broadcast this lock already covers. - fn resize_pane( - &self, - backend: &mut PtyBackend, - pane: PaneId, - rows: u16, - cols: u16, - client: u64, - ) { - let mut state = self.state.lock().expect("terminal state poisoned"); - // Not this client's to set. Dropped rather than refused: a client can - // lose the sizing between laying out a frame and this arriving, which is - // ordinary rather than an error worth interrupting anyone over. - if state.size_owner != Some(client) { - return; - } - // An unknown pane is ignored rather than errored: a client racing a - // pane exit is normal, not an attack. - let Some(p) = state.panes.iter_mut().find(|p| p.id == pane) else { - return; - }; - if (p.rows, p.cols) == (rows, cols) { - return; - } - backend.resize(pane, rows, cols); - p.rows = rows; - p.cols = cols; - // Every client's emulator has to wrap where the child now does, so the - // size it was actually set to goes to all of them — including the one - // that asked, which learns here if its request was clamped. - if let Ok(json) = serde_json::to_string(&ServerMessage::Resized { pane, rows, cols }) { - broadcast_locked(&mut state.clients, TerminalFrame::Control(json)); - } - } - /// Append output to the pane's bounded scrollback and broadcast it, both /// under one lock so a concurrently connecting client cannot slip a replay /// snapshot between the append and the broadcast. @@ -228,7 +234,7 @@ impl TerminalHub { /// Drop a pane and tell every client, but only if it was still live — a pane /// closed by command and then reported `Exited` by the backend must announce /// once, not twice. - fn remove_pane_and_announce(&self, pane: PaneId) { + pub(super) fn remove_pane_and_announce(&self, pane: PaneId) { let json = serde_json::to_string(&ServerMessage::Exited { pane }).ok(); let mut state = self.state.lock().expect("terminal state poisoned"); let existed = state.panes.iter().any(|p| p.id == pane); @@ -241,41 +247,6 @@ impl TerminalHub { } } - /// Reorder the live panes to match `order` and tell every client the - /// result. - /// - /// `order` is a full desired sequence of pane ids. It is reconciled - /// against what is actually live so a reorder is robust to races with - /// create/close: unknown ids are dropped and any live pane the request - /// omits (e.g. one another client created in the same beat) is kept, - /// appended in its current order (see [`canonical_order`]). The hub - /// converges on that one canonical order and broadcasts it, so the - /// sender and every other device end up with the same layout. Reordering - /// only restyles the grid — pane ids, scrollback, and the live PTYs are - /// untouched. A no-op reorder sends nothing. - fn reorder_panes(&self, order: Vec) { - let mut state = self.state.lock().expect("terminal state poisoned"); - let before: Vec = state.panes.iter().map(|p| p.id).collect(); - let target = canonical_order(&before, &order); - if target == before { - return; - } - // `target` is a permutation of `before`, so every id resolves and `old` - // ends empty. Move each `PaneState` rather than clone it — it owns the - // pane's scrollback. - let mut old = std::mem::take(&mut state.panes); - let mut reordered = Vec::with_capacity(old.len()); - for id in &target { - if let Some(pos) = old.iter().position(|p| p.id == *id) { - reordered.push(old.remove(pos)); - } - } - state.panes = reordered; - if let Ok(json) = serde_json::to_string(&ServerMessage::Reordered { order: target }) { - broadcast_locked(&mut state.clients, TerminalFrame::Control(json)); - } - } - pub(super) fn send_error_to(&self, client_id: u64, message: &str) { let Ok(json) = serde_json::to_string(&ServerMessage::Error { message: message.to_string(), diff --git a/src/web/viewer/terminal/mod.rs b/src/web/viewer/terminal/mod.rs index 5816374a..b5fa2699 100644 --- a/src/web/viewer/terminal/mod.rs +++ b/src/web/viewer/terminal/mod.rs @@ -16,7 +16,12 @@ //! bytes would leave a subtly wrong screen. pub mod frame; +mod hub_events; mod hub_helpers; +mod hub_layout; +mod hub_plugins; +mod hub_recovery; +mod hub_relaunch; mod hub_run; mod session; mod size_owner; @@ -53,6 +58,11 @@ pub struct TerminalHub { /// were configured under. Empty means a single bare shell (matching the /// TUI's default). startup: Vec, + /// The `[[plugin]]` table. The worker launches a host for each entry that is + /// enabled *and* that some `startup` entry opted into, and nothing else — a + /// plugin no pane named is never started, so declaring one costs nothing + /// until a pane hands itself over. + plugins: Vec, /// Set when a client claims the startup terminals by answering with their /// sizes, so they are created exactly once for the hub's life rather than /// on every (re)connection. See [`TerminalHub::claim_startup`]. @@ -61,8 +71,13 @@ pub struct TerminalHub { impl TerminalHub { /// Start a hub whose terminals run in `cwd`. `startup` is the list of - /// commands to launch when the first client connects (empty = one shell). - pub fn spawn(cwd: &str, startup: Vec) -> Arc { + /// commands to launch when the first client connects (empty = one shell), + /// and `plugins` the configured plugin table those commands may opt into. + pub fn spawn( + cwd: &str, + startup: Vec, + plugins: Vec, + ) -> Arc { let (commands, command_rx) = mpsc::sync_channel::(256); let hub = Arc::new(Self { commands, @@ -76,6 +91,7 @@ impl TerminalHub { stop: Arc::new(AtomicBool::new(false)), worker: Mutex::new(None), startup, + plugins, started: AtomicBool::new(false), }); diff --git a/src/web/viewer/terminal/session.rs b/src/web/viewer/terminal/session.rs index 6f749235..d86a997a 100644 --- a/src/web/viewer/terminal/session.rs +++ b/src/web/viewer/terminal/session.rs @@ -64,6 +64,7 @@ impl TerminalSession { } ClientMessage::Close { pane } => Command::Close { pane }, ClientMessage::Reorder { order } => Command::Reorder { order }, + ClientMessage::CancelRecovery { pane } => Command::CancelRecovery { pane }, // Off the worker queue for the same reason `start` is: it decides // who may resize, and a backed-up hub must not drop the message that // hands the sizing over — the client would then be a spectator with diff --git a/src/web/viewer/terminal/startup.rs b/src/web/viewer/terminal/startup.rs index 40f2d914..26299cc3 100644 --- a/src/web/viewer/terminal/startup.rs +++ b/src/web/viewer/terminal/startup.rs @@ -53,6 +53,9 @@ impl TerminalHub { .clone() .unwrap_or_else(|| sc.command.trim().to_string()) }), + // The opt-in, carried through so the worker can record it. Only + // a configured pane can have one; nothing else ever gains it. + plugin: configured.as_ref().and_then(|sc| sc.plugin.clone()), command: configured.map(|sc| sc.command), }) .collect(); diff --git a/src/web/viewer/terminal/startup_run.rs b/src/web/viewer/terminal/startup_run.rs index d7518c87..fd48fb2d 100644 --- a/src/web/viewer/terminal/startup_run.rs +++ b/src/web/viewer/terminal/startup_run.rs @@ -6,6 +6,7 @@ use super::TerminalHub; use super::hub_helpers::StartupPane; +use super::hub_plugins::Plugins; use crate::backend::PtyBackend; /// How a startup pane is named back to the client when it could not be opened. @@ -27,6 +28,7 @@ impl TerminalHub { pub(super) fn open_startup_panes( &self, backend: &mut PtyBackend, + plugins: &mut Plugins, panes: Vec, client: u64, reserved: usize, @@ -66,7 +68,23 @@ impl TerminalHub { // first, so they must not pull that client's // focus onto them. Ok(id) => { - self.register_pane(id, pane.size.rows, pane.size.cols, None, pane.title.clone()) + self.register_pane( + id, + pane.size.rows, + pane.size.cols, + None, + pane.title.clone(), + ); + // Only here, and only from the pane's own configuration: + // this is the single place a pane ever becomes visible to a + // plugin. `adopt` refuses when the named plugin has no live + // host, so a pane whose plugin failed to launch stays an + // ordinary terminal. + if let Some(name) = pane.plugin.as_deref() + && plugins.adopt(id, name) + { + plugins.pane_opened(backend, id, pane.title.as_deref()); + } } Err(err) => { tracing::warn!(%err, "viewer: could not start a terminal"); diff --git a/src/web/viewer/terminal/tests/behavior.rs b/src/web/viewer/terminal/tests/behavior.rs index 8e2885a2..1b317f10 100644 --- a/src/web/viewer/terminal/tests/behavior.rs +++ b/src/web/viewer/terminal/tests/behavior.rs @@ -9,7 +9,7 @@ use crate::web::viewer::terminal::frame::{ClientMessage, PaneSize, TerminalFrame #[test] fn creating_a_terminal_announces_it_and_streams_output() { let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); let session = hub.connect(); session.dispatch(ClientMessage::Create { rows: 24, cols: 80 }); @@ -40,7 +40,7 @@ fn creating_a_terminal_announces_it_and_streams_output() { #[test] fn the_per_repo_terminal_cap_is_enforced() { let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); let session = hub.connect(); for _ in 0..limits::MAX_PTYS_PER_REPO + 2 { @@ -61,7 +61,7 @@ fn the_per_repo_terminal_cap_is_enforced() { #[test] fn a_dropped_session_stops_receiving() { let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); let session = hub.connect(); assert_eq!(hub.client_count(), 1); @@ -74,7 +74,7 @@ fn a_dropped_session_stops_receiving() { #[test] fn reordering_panes_echoes_the_order_and_replays_it_to_a_later_joiner() { let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); let first = hub.connect(); // The startup shell (claimed with a size, as a client does) plus one @@ -111,7 +111,7 @@ fn reordering_panes_echoes_the_order_and_replays_it_to_a_later_joiner() { #[test] fn a_reconnecting_client_receives_existing_panes_and_scrollback() { let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); let first = hub.connect(); first.dispatch(ClientMessage::Create { rows: 24, cols: 80 }); @@ -151,7 +151,7 @@ fn a_replayed_pane_reports_the_size_it_was_last_resized_to() { // this reported the birth size instead, every reload would send a resize // the PTY does not need and cost the child a full repaint. let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); let first = hub.connect(); first.dispatch(ClientMessage::Create { rows: 24, cols: 80 }); @@ -205,7 +205,7 @@ fn input_for_an_unknown_pane_is_ignored() { // A client racing a pane exit is normal traffic, not an error worth // tearing the connection down for. let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); let session = hub.connect(); session.dispatch(ClientMessage::Input { @@ -232,7 +232,7 @@ fn input_for_an_unknown_pane_is_ignored() { #[test] fn stop_is_idempotent() { let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); hub.stop(); hub.stop(); } diff --git a/src/web/viewer/terminal/tests/mod.rs b/src/web/viewer/terminal/tests/mod.rs index e184bce8..ebc32486 100644 --- a/src/web/viewer/terminal/tests/mod.rs +++ b/src/web/viewer/terminal/tests/mod.rs @@ -1,15 +1,18 @@ +//! The hub's test harness: the shared deadline and the frame accessors every +//! hub test reads its assertions through. The tests themselves live beside it. + mod behavior; +mod plugin_rules; +mod plugin_slots; +mod plugins; +mod recovery; mod scrollback_depth; mod size_owner; mod startup; +mod wire; use crate::backend::PaneId; -use crate::web::viewer::limits; -use crate::web::viewer::terminal::frame::{ - ClientMessage, PaneSize, ServerMessage, TerminalFrame, decode_output, encode_output, -}; -use crate::web::viewer::terminal::hub_helpers::{canonical_order, push_scrollback}; -use std::collections::VecDeque; +use crate::web::viewer::terminal::frame::TerminalFrame; use std::thread; use std::time::{Duration, Instant}; @@ -57,6 +60,18 @@ pub(super) fn created_pane(frame: &TerminalFrame) -> Option { None } +/// The pane an `exited` frame announces the end of. +pub(super) fn exited_pane(frame: &TerminalFrame) -> Option { + let TerminalFrame::Control(json) = frame else { + return None; + }; + let value: serde_json::Value = serde_json::from_str(json).ok()?; + if value["type"] != "exited" { + return None; + } + value["pane"].as_u64().map(|n| n as PaneId) +} + /// The pane count a `pending` frame offers for sizing. pub(super) fn pending_count(frame: &TerminalFrame) -> Option { let TerminalFrame::Control(json) = frame else { @@ -144,157 +159,3 @@ pub(super) fn collect_created(session: &TerminalSession, n: usize) -> Vec(r#"{"type":"nope"}"#).is_err()); - assert!(serde_json::from_str::(r#"{"type":"create"}"#).is_err()); -} - -#[test] -fn server_messages_serialize_with_a_type_tag() { - let json = serde_json::to_string(&ServerMessage::Created { - pane: 2, - rows: 40, - cols: 120, - client: None, - title: None, - }) - .unwrap(); - // No requester, no field: the browser reads these already, and a pane - // nobody asked for must look to it exactly as it did before. - assert_eq!(json, r#"{"type":"created","pane":2,"rows":40,"cols":120}"#); - - let json = serde_json::to_string(&ServerMessage::Created { - pane: 2, - rows: 40, - cols: 120, - client: Some(7), - title: None, - }) - .unwrap(); - assert_eq!( - json, - r#"{"type":"created","pane":2,"rows":40,"cols":120,"client":7}"# - ); - - // And back, because the daemon reads these off a hub session to relay them. - let created: ServerMessage = - serde_json::from_str(r#"{"type":"created","pane":1,"rows":2,"cols":3}"#).unwrap(); - assert!(matches!( - created, - ServerMessage::Created { client: None, .. } - )); - - let json = serde_json::to_string(&ServerMessage::Reordered { order: vec![2, 1] }).unwrap(); - assert_eq!(json, r#"{"type":"reordered","order":[2,1]}"#); - - let json = serde_json::to_string(&ServerMessage::Pending { count: 2 }).unwrap(); - assert_eq!(json, r#"{"type":"pending","count":2}"#); -} - -#[test] -fn a_pane_size_is_clamped_into_the_bounds_a_pty_can_use() { - // These arrive from the client's own measurement, so they are input from - // outside. Zero gives the child a terminal it cannot draw in and can fail - // `openpty`; the far end asks a full-screen program for a screen buffer of - // rows * cells. - assert_eq!( - PaneSize { rows: 0, cols: 0 }.clamped(), - PaneSize { - rows: limits::MIN_PANE_DIMENSION, - cols: limits::MIN_PANE_DIMENSION - } - ); - assert_eq!( - PaneSize { - rows: u16::MAX, - cols: u16::MAX - } - .clamped(), - PaneSize { - rows: limits::MAX_PANE_ROWS, - cols: limits::MAX_PANE_COLS - } - ); - // A real display passes through untouched. - let real = PaneSize { - rows: 48, - cols: 210, - }; - assert_eq!(real.clamped(), real); -} - -#[test] -fn canonical_order_reconciles_a_request_against_the_live_panes() { - // A full permutation is honored verbatim. - assert_eq!(canonical_order(&[1, 2, 3], &[3, 1, 2]), vec![3, 1, 2]); - // A partial request moves the named panes; the rest keep their order. - assert_eq!(canonical_order(&[1, 2, 3], &[3]), vec![3, 1, 2]); - // An id that is no longer live (closed in a race) is dropped. - assert_eq!(canonical_order(&[1, 2], &[9, 2, 1]), vec![2, 1]); - // A repeated id is taken once, keeping the result a permutation. - assert_eq!(canonical_order(&[1, 2], &[2, 2, 1]), vec![2, 1]); - // An empty request leaves the order untouched. - assert_eq!(canonical_order(&[1, 2], &[]), vec![1, 2]); -} - -#[test] -fn scrollback_is_bounded_and_keeps_the_most_recent_bytes() { - let cap = limits::MAX_TERMINAL_SCROLLBACK_BYTES; - let mut buf = VecDeque::new(); - for _ in 0..(cap / 1000 + 5) { - push_scrollback(&mut buf, &vec![b'x'; 1000]); - } - assert_eq!(buf.len(), cap, "scrollback must be capped"); - - // The tail is what restores the visible screen, so the newest bytes must - // survive eviction. - push_scrollback(&mut buf, b"TAIL"); - assert_eq!(buf.len(), cap); - let contents: Vec = buf.iter().copied().collect(); - assert!(contents.ends_with(b"TAIL"), "newest bytes must be retained"); -} diff --git a/src/web/viewer/terminal/tests/plugin_rules.rs b/src/web/viewer/terminal/tests/plugin_rules.rs new file mode 100644 index 00000000..9a65b04b --- /dev/null +++ b/src/web/viewer/terminal/tests/plugin_rules.rs @@ -0,0 +1,155 @@ +//! The rules the worker applies to a plugin's panes, driven directly against a +//! real `PtyBackend` rather than through the hub. +//! +//! Deterministic on purpose: [`Plugins::judge`] and the idle check both take +//! `now` as a parameter, so the clock is an input and nothing here has to wait +//! for time to pass. The hub's *routing* — which of these calls it makes on an +//! exit — is pinned by the integration tests in `plugins.rs`. + +use super::plugins::{fixture, recorder}; +use crate::backend::{PaneId, PaneToken, PtyBackend, TerminalBackend}; +use crate::config::StartupCommand; +use crate::plugin::Refused; +use crate::plugin::protocol::{PROTOCOL_VERSION, PluginCommand}; +use crate::web::viewer::terminal::hub_plugins::{PANE_IDLE_THRESHOLD, Plugins}; +use std::time::Instant; + +pub(super) const ROWS: u16 = 24; +pub(super) const COLS: u16 = 80; +pub(super) const PLUGIN: &str = "watch"; +/// Long enough that the panes below stay alive for the whole test. +pub(super) const LONG_RUNNING: &str = "sleep 30"; + +pub(super) fn opt_in() -> StartupCommand { + StartupCommand { + name: None, + command: LONG_RUNNING.to_string(), + plugin: Some(PLUGIN.to_string()), + } +} + +/// Far enough past a pane's birth that the guard's idle rule is satisfied +/// without the test sleeping. +pub(super) fn well_idle() -> Instant { + Instant::now() + PANE_IDLE_THRESHOLD * 2 +} + +pub(super) fn token_of(backend: &PtyBackend, pane: PaneId) -> PaneToken { + backend + .slot(pane) + .expect("the pane should still have a slot") + .identity + .token + .clone() +} + +pub(super) fn send_input(token: &PaneToken, generation: u32) -> PluginCommand { + PluginCommand::SendInput { + v: PROTOCOL_VERSION, + token: token.clone(), + generation, + data: "recovered\n".to_string(), + } +} + +#[test] +fn a_plugin_no_pane_opted_into_is_never_launched() { + // A host with no pane to watch is a child process that can never be given + // anything to do, so declaring a plugin must not by itself start one. + let f = fixture(); + let mut plugins = Plugins::start(&f.cwd(), &[recorder(PLUGIN, &f.log)], &[]); + + assert!( + !plugins.adopt(1, PLUGIN), + "a plugin nothing opted into must have no host to adopt with" + ); +} + +#[test] +fn a_disabled_plugin_is_never_launched_even_when_a_pane_opted_in() { + let f = fixture(); + let mut off = recorder(PLUGIN, &f.log); + off.enabled = false; + let mut plugins = Plugins::start(&f.cwd(), &[off], &[opt_in()]); + + assert!(!plugins.adopt(1, PLUGIN)); +} + +#[test] +fn a_plugin_that_will_not_launch_leaves_its_panes_unmanaged() { + // Unmanaged rather than half-managed: a pane recorded against a host that + // does not exist would take the relaunch path on exit, holding its slot open + // for hours for a plugin that will never ask. + let f = fixture(); + let mut broken = recorder(PLUGIN, &f.log); + broken.command = "/nonexistent/nightcrow-test-plugin".to_string(); + let mut plugins = Plugins::start(&f.cwd(), &[broken], &[opt_in()]); + + assert!(!plugins.adopt(1, PLUGIN)); + assert_eq!(plugins.owner(1), None); +} + +#[test] +fn a_command_for_a_pane_that_did_not_opt_in_is_refused() { + let f = fixture(); + let mut backend = PtyBackend::new(f.cwd()); + let mut plugins = Plugins::start(&f.cwd(), &[recorder(PLUGIN, &f.log)], &[opt_in()]); + let pane = backend + .open_pane(ROWS, COLS, Some(LONG_RUNNING)) + .expect("open a pane"); + let token = token_of(&backend, pane); + + // Deliberately not adopted: the token resolves, but nothing handed the pane + // over. + let refused = plugins + .judge(PLUGIN, send_input(&token, 1), &backend, well_idle()) + .expect_err("a pane nobody handed over must not be typed into"); + + assert!(matches!(refused, Refused::NotOptedIn { .. }), "{refused}"); + backend.destroy_pane(pane); +} + +#[test] +fn a_command_naming_a_stale_generation_is_refused_and_leaves_the_replacement_alone() { + let f = fixture(); + let mut backend = PtyBackend::new(f.cwd()); + let mut plugins = Plugins::start(&f.cwd(), &[recorder(PLUGIN, &f.log)], &[opt_in()]); + let pane = backend + .open_pane(ROWS, COLS, Some(LONG_RUNNING)) + .expect("open a pane"); + let token = token_of(&backend, pane); + assert!(plugins.adopt(pane, PLUGIN)); + + // The exit-and-relaunch the hub performs, without the hub. + backend.release_process(pane); + let replacement = backend + .relaunch_pane(pane, ROWS, COLS, &[], &[]) + .expect("relaunch the slot"); + plugins.take_over(pane, replacement); + assert_eq!( + token_of(&backend, replacement), + token, + "the slot's token must survive the relaunch" + ); + + let refused = plugins + .judge(PLUGIN, send_input(&token, 1), &backend, well_idle()) + .expect_err("a command about the previous process must not land on this one"); + + assert!( + matches!( + refused, + Refused::StaleGeneration { + claimed: 1, + current: 2, + .. + } + ), + "{refused}" + ); + assert!( + backend.is_process_alive(replacement), + "a refused command must leave the replacement process untouched" + ); + backend.destroy_pane(replacement); +} diff --git a/src/web/viewer/terminal/tests/plugin_slots.rs b/src/web/viewer/terminal/tests/plugin_slots.rs new file mode 100644 index 00000000..be7fed76 --- /dev/null +++ b/src/web/viewer/terminal/tests/plugin_slots.rs @@ -0,0 +1,168 @@ +//! How long a watched pane's slot lives, and how a quiet one is announced. +//! +//! Beside `plugin_rules.rs` rather than in it: those tests are about what a +//! plugin is allowed to ask for, these about what the worker keeps, gives up on +//! its own, or hands back to a person. Both drive a real `PtyBackend` with the +//! clock as an input. + +use super::plugin_rules::{ + COLS, LONG_RUNNING, PLUGIN, ROWS, opt_in, send_input, token_of, well_idle, +}; +use super::plugins::{fixture, logged, logged_event, recorder}; +use crate::backend::{PtyBackend, TerminalBackend}; +use crate::plugin::{Approved, RateLimits, Refused}; +use crate::web::viewer::terminal::hub_plugins::{PENDING_RELAUNCH_TTL, PaneSpot, Plugins}; +use std::time::Instant; + +#[test] +fn an_exited_watched_pane_keeps_its_token_while_a_plain_pane_loses_its() { + // The two calls the hub chooses between on an exit. `release_process` is + // what makes a relaunch possible at all; `destroy_pane` is what makes an + // ordinary pane unaddressable the moment it ends. + let f = fixture(); + let mut backend = PtyBackend::new(f.cwd()); + let watched = backend + .open_pane(ROWS, COLS, Some(LONG_RUNNING)) + .expect("open a pane"); + let plain = backend + .open_pane(ROWS, COLS, Some(LONG_RUNNING)) + .expect("open a pane"); + let (watched_token, plain_token) = (token_of(&backend, watched), token_of(&backend, plain)); + + backend.release_process(watched); + backend.destroy_pane(plain); + + assert_eq!( + backend.pane_for_token(&watched_token), + Some(watched), + "a watched pane's slot must outlive its process" + ); + assert_eq!( + backend.pane_for_token(&plain_token), + None, + "a plain pane must leave nothing addressable behind" + ); + backend.destroy_pane(watched); +} + +#[test] +fn a_hold_that_runs_out_of_time_retires_the_slot() { + let f = fixture(); + let mut backend = PtyBackend::new(f.cwd()); + let mut plugins = Plugins::start(&f.cwd(), &[recorder(PLUGIN, &f.log)], &[opt_in()]); + let pane = backend + .open_pane(ROWS, COLS, Some(LONG_RUNNING)) + .expect("open a pane"); + let token = token_of(&backend, pane); + assert!(plugins.adopt(pane, PLUGIN)); + let exited_at = Instant::now(); + backend.release_process(pane); + plugins.hold_for_relaunch( + pane, + PaneSpot { + index: 0, + rows: ROWS, + cols: COLS, + title: None, + }, + exited_at, + ); + + // Still inside the window: nothing is given up. + plugins.expire_pending(&mut backend, exited_at + PENDING_RELAUNCH_TTL / 2); + assert_eq!(backend.pane_for_token(&token), Some(pane)); + + plugins.expire_pending(&mut backend, exited_at + PENDING_RELAUNCH_TTL); + + assert_eq!( + backend.pane_for_token(&token), + None, + "a hold nobody used must retire the slot" + ); + assert_eq!(plugins.owner(pane), None); +} + +#[test] +fn a_human_typing_into_a_watched_pane_clears_what_its_plugin_had_spent() { + let f = fixture(); + let mut backend = PtyBackend::new(f.cwd()); + let mut plugins = Plugins::start(&f.cwd(), &[recorder(PLUGIN, &f.log)], &[opt_in()]); + let pane = backend + .open_pane(ROWS, COLS, Some(LONG_RUNNING)) + .expect("open a pane"); + let token = token_of(&backend, pane); + assert!(plugins.adopt(pane, PLUGIN)); + let now = well_idle(); + + // Spend the whole per-pane allowance, then confirm it really is spent. + for _ in 0..RateLimits::default().max_sends_per_window { + plugins + .judge(PLUGIN, send_input(&token, 1), &backend, now) + .expect("an idle, opted-in pane accepts input"); + } + let refused = plugins + .judge(PLUGIN, send_input(&token, 1), &backend, now) + .expect_err("the allowance should be gone"); + assert!(matches!(refused, Refused::RateLimited { .. }), "{refused}"); + + plugins.user_input(&backend, pane); + + // The pane belongs to the person now, so what the plugin had spent on the + // situation it thought it was in is void — including the budget. + assert!( + matches!( + plugins.judge(PLUGIN, send_input(&token, 1), &backend, now), + Ok(Approved::SendInput { .. }) + ), + "a human taking the pane back must clear the plugin's spent budget" + ); + assert!( + logged_event(&f.log, |e| e["event"] == "user_input").is_some(), + "the plugin must be told a human took the pane back" + ); + backend.destroy_pane(pane); +} + +#[test] +fn a_quiet_pane_is_announced_idle_once_until_it_speaks_again() { + let f = fixture(); + let mut backend = PtyBackend::new(f.cwd()); + let mut plugins = Plugins::start(&f.cwd(), &[recorder(PLUGIN, &f.log)], &[opt_in()]); + let pane = backend + .open_pane(ROWS, COLS, Some(LONG_RUNNING)) + .expect("open a pane"); + assert!(plugins.adopt(pane, PLUGIN)); + let now = well_idle(); + + plugins.notify_idle(&backend, now); + plugins.notify_idle(&backend, now); + assert!( + logged_event(&f.log, |e| e["event"] == "pane_idle").is_some(), + "the plugin was never told the pane went quiet" + ); + assert_eq!( + logged(&f.log) + .iter() + .filter(|e| e["event"] == "pane_idle") + .count(), + 1, + "one quiet period must be announced once, not on every tick" + ); + + // Output ends the quiet period, so the next one is announced again. + plugins.pane_output(&backend, pane, b"still here\n"); + plugins.notify_idle(&backend, now); + + assert!( + super::wait_for(|| { + let idles = logged(&f.log) + .iter() + .filter(|e| e["event"] == "pane_idle") + .count(); + (idles == 2).then_some(()) + }) + .is_some(), + "a new quiet period must be announced again" + ); + backend.destroy_pane(pane); +} diff --git a/src/web/viewer/terminal/tests/plugins.rs b/src/web/viewer/terminal/tests/plugins.rs new file mode 100644 index 00000000..b1a6eeca --- /dev/null +++ b/src/web/viewer/terminal/tests/plugins.rs @@ -0,0 +1,291 @@ +//! The plugin wiring, driven through a real hub and a real plugin child. +//! +//! The fake plugin is `/bin/sh`, which is present wherever these run. It appends +//! every event it is handed to a file the test polls, so what is asserted is what +//! the hub actually sent rather than what it meant to send — and a pane the hub +//! must never mention simply never appears in that file. + +use super::{collect_created, created_pane, exited_pane, next_matching, reordered_order, wait_for}; +use crate::config::{PluginConfig, StartupCommand}; +use crate::plugin::protocol::PROTOCOL_VERSION; +use crate::web::viewer::terminal::TerminalHub; +use crate::web::viewer::terminal::frame::{ClientMessage, PaneSize, TerminalFrame}; +use std::path::{Path, PathBuf}; + +/// Env var the fake plugin appends the events it receives to. +const LOG_ENV: &str = "NC_TEST_PLUGIN_LOG"; +/// Env var holding the protocol version the fake plugin answers with, so no +/// script hard-codes a number a version bump would silently invalidate. +const VERSION_ENV: &str = "NC_TEST_PLUGIN_V"; +/// Env var naming the file the relaunching plugin uses to act exactly once. A +/// puppet that relaunched on every exit would keep relaunching a command that +/// exits immediately, which is the test's own doing rather than the hub's. +const ONCE_ENV: &str = "NC_TEST_PLUGIN_ONCE"; + +/// A plugin that only records. +fn logging_script() -> String { + format!(r#"while IFS= read -r line; do printf '%s\n' "$line" >> "${LOG_ENV}"; done"#) +} + +/// A plugin that records, then answers the first `pane_exited` with a relaunch +/// of exactly the generation it was told about. `{{`/`}}` are `format!`'s +/// escapes for the JSON braces the command line needs. +fn relaunch_script() -> String { + format!( + r#"while IFS= read -r line; do + printf '%s\n' "$line" >> "${LOG_ENV}" + case "$line" in + *pane_exited*) + if [ ! -f "${ONCE_ENV}" ]; then + : > "${ONCE_ENV}" + t=$(printf '%s' "$line" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p') + g=$(printf '%s' "$line" | sed -n 's/.*"generation":\([0-9]*\).*/\1/p') + printf '{{"cmd":"relaunch","v":%s,"token":"%s","generation":%s,"resume_args":[]}}\n' \ + "${VERSION_ENV}" "$t" "$g" + fi + ;; + esac +done"# + ) +} + +pub(super) fn shell_plugin(name: &str, script: String, log: &Path, once: &Path) -> PluginConfig { + PluginConfig { + name: name.to_string(), + command: "/bin/sh".to_string(), + args: vec!["-c".to_string(), script], + env: [ + (LOG_ENV.to_string(), log.to_string_lossy().into_owned()), + (ONCE_ENV.to_string(), once.to_string_lossy().into_owned()), + (VERSION_ENV.to_string(), PROTOCOL_VERSION.to_string()), + ] + .into_iter() + .collect(), + enabled: true, + ..PluginConfig::default() + } +} + +/// A plugin that records every event, for a test that only needs to see what the +/// hub sent it. +pub(super) fn recorder(name: &str, log: &Path) -> PluginConfig { + shell_plugin(name, logging_script(), log, Path::new("/dev/null")) +} + +fn pane(name: &str, command: &str, plugin: Option<&str>) -> StartupCommand { + StartupCommand { + name: Some(name.to_string()), + command: command.to_string(), + plugin: plugin.map(str::to_string), + } +} + +/// Every complete event line the fake plugin has logged so far. A half-written +/// line simply fails to parse and is skipped until the next poll. +pub(super) fn logged(log: &Path) -> Vec { + std::fs::read_to_string(log) + .unwrap_or_default() + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect() +} + +/// Wait for the plugin to log an event `want` accepts. +pub(super) fn logged_event( + log: &Path, + want: impl Fn(&serde_json::Value) -> bool, +) -> Option { + wait_for(|| logged(log).into_iter().find(|event| want(event))) +} + +fn is(event: &serde_json::Value, kind: &str) -> bool { + event["event"] == kind +} + +fn token_of(event: &serde_json::Value) -> String { + event["token"].as_str().unwrap_or_default().to_string() +} + +/// A temp directory plus the paths the fake plugin writes inside it. +pub(super) struct Fixture { + dir: tempfile::TempDir, + pub(super) log: PathBuf, + pub(super) once: PathBuf, +} + +pub(super) fn fixture() -> Fixture { + let dir = tempfile::TempDir::new().unwrap(); + let log = dir.path().join("events.ndjson"); + let once = dir.path().join("relaunched"); + Fixture { dir, log, once } +} + +impl Fixture { + pub(super) fn cwd(&self) -> String { + self.dir.path().to_string_lossy().into_owned() + } +} + +#[test] +fn a_pane_that_did_not_opt_in_is_never_shown_to_a_plugin() { + // The plain pane is *first*, so if the hub ever announced it its line would + // precede the watched pane's on the plugin's single event stream. Seeing the + // watched pane's `pane_opened` therefore proves the plain one was not + // announced, rather than merely not announced yet. + let f = fixture(); + let hub = TerminalHub::spawn( + &f.cwd(), + vec![ + pane("plain", "printf plain-is-done", None), + pane("watched", "sleep 30", Some("watch")), + ], + vec![recorder("watch", &f.log)], + ); + let session = hub.connect(); + session.dispatch(ClientMessage::Start { sizes: Vec::new() }); + let ids = collect_created(&session, 2); + + let first = logged_event(&f.log, |e| is(e, "pane_opened")).expect("no pane_opened logged"); + assert_eq!( + first["title"], "watched", + "the first pane announced must be the one that opted in: {first}" + ); + + // And the plain pane's exit is the path it always was: the client is told, + // and the plugin is not. + assert!( + next_matching(&session, |frame| exited_pane(frame) == Some(ids[0])).is_some(), + "the plain pane's exit must still reach the client" + ); + let seen: Vec = logged(&f.log).iter().map(|e| e.to_string()).collect(); + assert!( + !seen.iter().any(|line| line.contains("plain")), + "a pane with no opt-in must not appear in a plugin's events: {seen:?}" + ); + hub.stop(); +} + +#[test] +fn two_opted_in_panes_are_tracked_under_separate_tokens_that_do_not_cross() { + let f = fixture(); + let hub = TerminalHub::spawn( + &f.cwd(), + vec![ + pane("alpha", "printf ALPHA-MARK; sleep 30", Some("watch")), + pane("beta", "printf BETA-MARK; sleep 30", Some("watch")), + ], + vec![recorder("watch", &f.log)], + ); + let session = hub.connect(); + session.dispatch(ClientMessage::Start { + sizes: vec![PaneSize { rows: 24, cols: 80 }; 2], + }); + + let alpha = logged_event(&f.log, |e| is(e, "pane_opened") && e["title"] == "alpha") + .map(|e| token_of(&e)) + .expect("alpha was not announced"); + let beta = logged_event(&f.log, |e| is(e, "pane_opened") && e["title"] == "beta") + .map(|e| token_of(&e)) + .expect("beta was not announced"); + assert_ne!(alpha, beta, "two panes must not share a token"); + + // Each pane's own output must arrive under its own token, or a plugin + // watching two panes could not tell which one spoke. + let carried = |mark: &'static str| { + logged_event(&f.log, move |e| { + is(e, "pane_output") && e["text"].as_str().is_some_and(|t| t.contains(mark)) + }) + .map(|e| token_of(&e)) + }; + assert_eq!(carried("ALPHA-MARK"), Some(alpha)); + assert_eq!(carried("BETA-MARK"), Some(beta)); + hub.stop(); +} + +#[test] +fn a_relaunch_reuses_the_token_advances_the_generation_and_lands_back_at_its_index() { + // The pane that exits is first in the order, so a replacement left at the + // end would show up as a wrong order rather than pass unnoticed. + let f = fixture(); + let hub = TerminalHub::spawn( + &f.cwd(), + vec![ + pane("recovered", "printf gone; exit 0", Some("watch")), + pane("other", "sleep 30", None), + ], + vec![shell_plugin("watch", relaunch_script(), &f.log, &f.once)], + ); + let session = hub.connect(); + session.dispatch(ClientMessage::Start { sizes: Vec::new() }); + let ids = collect_created(&session, 2); + + let opened = logged_event(&f.log, |e| is(e, "pane_opened")).expect("no pane_opened"); + let token = token_of(&opened); + assert_eq!(opened["generation"], 1); + + let reopened = logged_event(&f.log, |e| is(e, "pane_opened") && e["generation"] == 2) + .expect("the pane was never relaunched"); + assert_eq!( + token_of(&reopened), + token, + "a relaunch must keep the slot's token, or an observer loses its place" + ); + + // The replacement is a new pane to every client, so it arrives as a create + // and is then moved back to where its predecessor sat. + let replacement = next_matching(&session, |frame| { + created_pane(frame).is_some_and(|id| !ids.contains(&id)) + }) + .and_then(|frame| created_pane(&frame)) + .expect("the replacement pane was never announced"); + let order = next_matching(&session, |frame| reordered_order(frame).is_some()) + .and_then(|frame| reordered_order(&frame)) + .expect("the replacement was not put back in the order"); + assert_eq!( + order, + vec![replacement, ids[1]], + "the relaunched pane must land at its predecessor's index" + ); + hub.stop(); +} + +#[test] +fn a_plugin_that_cannot_be_launched_leaves_the_terminal_session_working() { + let f = fixture(); + let mut broken = recorder("watch", &f.log); + broken.command = "/nonexistent/nightcrow-test-plugin".to_string(); + let hub = TerminalHub::spawn( + &f.cwd(), + vec![pane( + "watched", + "printf STILL-HERE; sleep 30", + Some("watch"), + )], + vec![broken], + ); + let session = hub.connect(); + session.dispatch(ClientMessage::Start { sizes: Vec::new() }); + + let created = next_matching(&session, |frame| created_pane(frame).is_some()) + .and_then(|frame| created_pane(&frame)) + .expect("a pane whose plugin failed to launch must still open"); + assert!( + next_matching( + &session, + |frame| matches!(frame, TerminalFrame::Output { pane, data } + if *pane == created && String::from_utf8_lossy(data).contains("STILL-HERE")) + ) + .is_some(), + "the pane must still stream its output" + ); + + // And the hub keeps serving: a client can still open another terminal. + session.dispatch(ClientMessage::Create { rows: 24, cols: 80 }); + assert!( + next_matching(&session, |frame| created_pane(frame) + .is_some_and(|id| id != created)) + .is_some(), + "the hub stopped serving after a plugin failed to launch" + ); + hub.stop(); +} diff --git a/src/web/viewer/terminal/tests/recovery.rs b/src/web/viewer/terminal/tests/recovery.rs new file mode 100644 index 00000000..0060f16b --- /dev/null +++ b/src/web/viewer/terminal/tests/recovery.rs @@ -0,0 +1,257 @@ +//! What a person sees of a plugin's recovery, and what cancelling it does. +//! +//! Driven through a real hub and a real plugin child, like `plugins.rs`: what is +//! asserted is the frame that actually left the hub, not what it meant to send. + +use super::plugin_rules::{COLS, LONG_RUNNING, PLUGIN, ROWS, opt_in, token_of}; +use super::plugins::{Fixture, fixture, logged_event, shell_plugin}; +use super::{collect_created, created_pane, next_matching}; +use crate::backend::{PaneId, PtyBackend}; +use crate::config::{PluginConfig, StartupCommand}; +use crate::web::viewer::terminal::frame::{ClientMessage, TerminalFrame}; +use crate::web::viewer::terminal::hub_plugins::{PENDING_RELAUNCH_TTL, PaneSpot, Plugins}; +use crate::web::viewer::terminal::hub_recovery::RECOVERY_CANCELLED; +use crate::web::viewer::terminal::{TerminalHub, TerminalSession}; +use std::path::Path; +use std::time::{Duration, Instant}; + +/// How long a test watches for a frame it expects *not* to arrive. Long enough +/// for the 8 ms worker loop to have drained the cancel it was given several +/// hundred times over, and short enough that proving a negative is cheap. +const QUIET_WINDOW: Duration = Duration::from_millis(500); + +/// The state and detail the fake plugin reports, so the assertions name the same +/// strings the script prints. +const REPORTED_STATE: &str = "waiting_for_reset"; +const REPORTED_DETAIL: &str = "provider window closed"; +const REPORTED_DEADLINE: i64 = 1_700_000_000; +const REPORTED_ATTEMPT: u32 = 2; + +/// A plugin that answers the pane it is given with one status report. +/// +/// Once, on `pane_opened`: a report per event would make "the client saw one" +/// unfalsifiable, and the point is that a single report reaches every client. +fn status_script() -> String { + format!( + r#"while IFS= read -r line; do + printf '%s\n' "$line" >> "${{NC_TEST_PLUGIN_LOG}}" + case "$line" in + *pane_opened*) + t=$(printf '%s' "$line" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p') + g=$(printf '%s' "$line" | sed -n 's/.*"generation":\([0-9]*\).*/\1/p') + printf '{{"cmd":"status","v":%s,"token":"%s","generation":%s,"state":"{REPORTED_STATE}","detail":"{REPORTED_DETAIL}","deadline_epoch":{REPORTED_DEADLINE},"attempt":{REPORTED_ATTEMPT}}}\n' \ + "${{NC_TEST_PLUGIN_V}}" "$t" "$g" + ;; + esac +done"# + ) +} + +fn reporter(name: &str, f: &Fixture) -> PluginConfig { + shell_plugin(name, status_script(), &f.log, Path::new("/dev/null")) +} + +fn watched(name: &str, command: &str) -> StartupCommand { + StartupCommand { + name: Some(name.to_string()), + command: command.to_string(), + plugin: Some(PLUGIN.to_string()), + } +} + +/// One recovery report as it came off the wire. +#[derive(Debug, PartialEq, Eq)] +struct Report { + pane: PaneId, + state: String, + detail: Option, + deadline_epoch: Option, + attempt: u32, +} + +/// The whole recovery report a frame carries, or `None` for any other frame. +fn recovery(frame: &TerminalFrame) -> Option { + let TerminalFrame::Control(json) = frame else { + return None; + }; + let value: serde_json::Value = serde_json::from_str(json).ok()?; + if value["type"] != "recovery" { + return None; + } + Some(Report { + pane: value["pane"].as_u64()? as PaneId, + state: value["state"].as_str()?.to_string(), + detail: value["detail"].as_str().map(str::to_string), + deadline_epoch: value["deadline_epoch"].as_i64(), + attempt: value["attempt"].as_u64()? as u32, + }) +} + +#[test] +fn a_plugin_status_report_reaches_a_client_as_a_recovery_frame() { + let f = fixture(); + let hub = TerminalHub::spawn( + &f.cwd(), + vec![watched("watched", LONG_RUNNING)], + vec![reporter(PLUGIN, &f)], + ); + let session = hub.connect(); + session.dispatch(ClientMessage::Start { sizes: Vec::new() }); + let ids = collect_created(&session, 1); + + let report = next_matching(&session, |frame| recovery(frame).is_some()) + .and_then(|frame| recovery(&frame)) + .expect("the plugin's report never reached the client"); + + assert_eq!( + report, + Report { + pane: ids[0], + state: REPORTED_STATE.to_string(), + detail: Some(REPORTED_DETAIL.to_string()), + deadline_epoch: Some(REPORTED_DEADLINE), + attempt: REPORTED_ATTEMPT, + }, + "the report must reach the client exactly as the plugin gave it" + ); + hub.stop(); +} + +#[test] +fn cancelling_a_recovery_releases_the_hold_and_tells_every_client() { + // Two clients, because the point of broadcasting `cancelled` is that the one + // who did not press the key stops showing a deadline too. + let f = fixture(); + let hub = TerminalHub::spawn( + &f.cwd(), + vec![watched("watched", "printf gone; exit 0")], + vec![reporter(PLUGIN, &f)], + ); + let presser = hub.connect(); + let watcher = hub.connect(); + presser.dispatch(ClientMessage::Start { sizes: Vec::new() }); + let ids = collect_created(&presser, 1); + + // The hold exists once the plugin has been told its pane exited — that event + // is sent on the same path that creates it. + assert!( + logged_event(&f.log, |e| e["event"] == "pane_exited").is_some(), + "the pane never exited into a hold" + ); + + presser.dispatch(ClientMessage::CancelRecovery { pane: ids[0] }); + + for (who, session) in [("the presser", &presser), ("the other client", &watcher)] { + let report = next_matching(session, |frame| { + recovery(frame).is_some_and(|r| r.state == RECOVERY_CANCELLED) + }) + .and_then(|frame| recovery(&frame)) + .unwrap_or_else(|| panic!("{who} was never told the recovery was cancelled")); + assert_eq!(report.pane, ids[0]); + assert_eq!( + report.deadline_epoch, None, + "a cancelled report carries no deadline" + ); + } + + // The slot is gone, and the plugin was told before it went: `pane_closed` + // carries the slot's identity, so it cannot be sent afterwards. + assert!( + logged_event(&f.log, |e| e["event"] == "pane_closed").is_some(), + "the plugin must be told the slot it was holding is gone" + ); + hub.stop(); +} + +#[test] +fn cancelling_a_pane_with_nothing_pending_is_harmless() { + let f = fixture(); + let hub = TerminalHub::spawn( + &f.cwd(), + vec![watched("watched", "printf STILL-HERE; sleep 30")], + vec![reporter(PLUGIN, &f)], + ); + let session = hub.connect(); + session.dispatch(ClientMessage::Start { sizes: Vec::new() }); + let ids = collect_created(&session, 1); + + // A live pane has no hold, and neither has an id that never existed. + session.dispatch(ClientMessage::CancelRecovery { pane: ids[0] }); + session.dispatch(ClientMessage::CancelRecovery { pane: 4242 }); + + assert!( + !cancelled_within(&session, QUIET_WINDOW), + "a cancel with nothing pending must announce nothing" + ); + + // And the worker is still serving: an unknown or inapplicable cancel is a + // no-op, not an error that wedges the queue. + session.dispatch(ClientMessage::Create { + rows: ROWS, + cols: COLS, + }); + assert!( + next_matching(&session, |frame| created_pane(frame) + .is_some_and(|id| id != ids[0])) + .is_some(), + "the hub stopped serving after a cancel it had nothing to do with" + ); + hub.stop(); +} + +/// Whether a `cancelled` report arrives inside `window`. Frames that are not +/// recovery reports are drained and ignored — a real pane is streaming output the +/// whole time. +fn cancelled_within(session: &TerminalSession, window: Duration) -> bool { + let deadline = Instant::now() + window; + while Instant::now() < deadline { + let Some(frame) = session.next_frame(Duration::from_millis(20)) else { + continue; + }; + if recovery(&frame).is_some_and(|r| r.state == RECOVERY_CANCELLED) { + return true; + } + } + false +} + +#[test] +fn a_hold_that_runs_out_of_time_reports_the_pane_it_gave_up_on() { + // The pane id is what the worker turns into the final `cancelled` broadcast, + // so an expiry that retires the slot silently would leave every client + // counting down to a moment that has passed. + let f = fixture(); + let mut backend = PtyBackend::new(f.cwd()); + let mut plugins = Plugins::start(&f.cwd(), &[reporter(PLUGIN, &f)], &[opt_in()]); + let pane = backend + .open_pane(ROWS, COLS, Some(LONG_RUNNING)) + .expect("open a pane"); + let token = token_of(&backend, pane); + assert!(plugins.adopt(pane, PLUGIN)); + let exited_at = Instant::now(); + backend.release_process(pane); + plugins.hold_for_relaunch( + pane, + PaneSpot { + index: 0, + rows: ROWS, + cols: COLS, + title: None, + }, + exited_at, + ); + + assert!( + plugins + .expire_pending(&mut backend, exited_at + PENDING_RELAUNCH_TTL / 2) + .is_empty(), + "a hold still inside its window must not be reported" + ); + + assert_eq!( + plugins.expire_pending(&mut backend, exited_at + PENDING_RELAUNCH_TTL), + vec![pane], + "the expired pane must be named so its clients can be told" + ); + assert_eq!(backend.pane_for_token(&token), None); +} diff --git a/src/web/viewer/terminal/tests/size_owner.rs b/src/web/viewer/terminal/tests/size_owner.rs index 1a7fb167..48f51ff9 100644 --- a/src/web/viewer/terminal/tests/size_owner.rs +++ b/src/web/viewer/terminal/tests/size_owner.rs @@ -48,7 +48,7 @@ fn the_client_that_just_arrived_owns_the_sizing() { // tmux's `window-size latest`: the newest client is the one someone is // sitting at, so the panes should fit its screen. let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); let first = hub.connect(); assert!(verdict(&first), "the only client owns it"); @@ -65,7 +65,7 @@ fn the_sizing_passes_to_the_newest_client_still_attached() { // Somebody has to hold it, or the panes stay frozen at the size of a client // that has gone. let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); let first = hub.connect(); let second = hub.connect(); let third = hub.connect(); @@ -84,7 +84,7 @@ fn a_client_can_take_the_sizing_back_on_request() { // sizing, asking for it does. Otherwise glancing at a phone would repaint // everybody's screen. let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); let first = hub.connect(); let second = hub.connect(); assert!(verdict(&second)); @@ -100,7 +100,7 @@ fn a_client_can_take_the_sizing_back_on_request() { #[test] fn claiming_what_this_client_already_owns_says_nothing() { let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); let only = hub.connect(); assert!(verdict(&only)); @@ -119,7 +119,7 @@ fn only_the_owner_resizes_the_pty_and_everyone_is_told_the_size() { // Two clients fitting one PTY to two layouts would leave the child drawing // for a width neither of them has. let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); let first = hub.connect(); first.dispatch(ClientMessage::Start { sizes: vec![PaneSize { rows: 24, cols: 80 }], diff --git a/src/web/viewer/terminal/tests/startup.rs b/src/web/viewer/terminal/tests/startup.rs index 305614a5..992bf4ab 100644 --- a/src/web/viewer/terminal/tests/startup.rs +++ b/src/web/viewer/terminal/tests/startup.rs @@ -31,6 +31,7 @@ fn a_startup_terminal_is_offered_for_sizing_and_born_at_that_size() { let hub = TerminalHub::spawn( &dir.path().to_string_lossy(), vec![startup("printf hello")], + Vec::new(), ); let session = hub.connect(); @@ -60,7 +61,7 @@ fn a_startup_terminal_is_offered_for_sizing_and_born_at_that_size() { #[test] fn an_empty_startup_offers_one_shell() { let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); let session = hub.connect(); assert_eq!( @@ -84,7 +85,7 @@ fn a_startup_size_of_zero_is_clamped_rather_than_reaching_openpty() { // spent by then — the hub would hold `started` with no terminal to show // for it and never offer them again. let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); let session = hub.connect(); next_matching(&session, |f| pending_count(f).is_some()).expect("no offer"); @@ -114,7 +115,7 @@ fn a_startup_set_that_fills_the_cap_still_gets_every_terminal() { let configured: Vec = (0..limits::MAX_PTYS_PER_REPO) .map(|i| startup(&format!("printf startup{i}"))) .collect(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), configured); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), configured, Vec::new()); let session = hub.connect(); assert_eq!( next_matching(&session, |f| pending_count(f).is_some()).and_then(|f| pending_count(&f)), @@ -149,7 +150,7 @@ fn a_startup_command_the_cap_turned_away_is_named() { let configured: Vec = (0..limits::MAX_PTYS_PER_REPO) .map(|i| startup(&format!("printf startup{i}"))) .collect(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), configured); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), configured, Vec::new()); let session = hub.connect(); next_matching(&session, |f| pending_count(f).is_some()).expect("no offer"); @@ -181,7 +182,7 @@ fn an_unanswered_offer_is_made_again_to_the_next_client() { // Nothing consumes the offer but an answer, so the hub cannot end up with // no terminals and no way to ever open them. let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); let abandoned = hub.connect(); assert!( @@ -204,7 +205,7 @@ fn only_the_first_answer_opens_the_startup_terminals() { // Both clients were offered the panes, so both may answer. Creating them // twice would double every configured command. let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); let first = hub.connect(); let second = hub.connect(); @@ -245,6 +246,7 @@ fn a_configured_startup_terminal_is_announced_under_its_name() { command: "sleep 30".into(), plugin: None, }], + Vec::new(), ); let session = hub.connect(); session.dispatch(ClientMessage::Start { @@ -269,6 +271,7 @@ fn an_unnamed_startup_terminal_falls_back_to_its_command() { let hub = TerminalHub::spawn( &dir.path().to_string_lossy(), vec![startup("printf hello")], + Vec::new(), ); let session = hub.connect(); session.dispatch(ClientMessage::Start { @@ -285,7 +288,7 @@ fn a_pane_a_client_opened_is_left_unnamed_by_the_session() { // That client named it, or nothing did — either way the hub has nothing to // add, and stamping a name here would override a title the client chose. let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); let session = hub.connect(); session.dispatch(ClientMessage::Create { rows: 24, cols: 80 }); diff --git a/src/web/viewer/terminal/tests/wire.rs b/src/web/viewer/terminal/tests/wire.rs new file mode 100644 index 00000000..0c80cfc8 --- /dev/null +++ b/src/web/viewer/terminal/tests/wire.rs @@ -0,0 +1,220 @@ +//! Contracts that need no hub: the wire encodings, the size clamp, and the two +//! pure helpers the worker leans on. + +use crate::web::viewer::limits; +use crate::web::viewer::terminal::frame::{ + ClientMessage, PaneSize, ServerMessage, decode_output, encode_output, +}; +use crate::web::viewer::terminal::hub_helpers::{canonical_order, push_scrollback}; +use std::collections::VecDeque; + +#[test] +fn output_frames_round_trip_through_the_binary_encoding() { + // Raw PTY bytes are not always valid UTF-8; the framing must not care. + let payload = vec![0x1b, b'[', b'0', b'm', 0xff, 0xfe, 0x00]; + + let encoded = encode_output(7, &payload); + let (pane, data) = decode_output(&encoded).unwrap(); + + assert_eq!(pane, 7); + assert_eq!(data, &payload[..]); +} + +#[test] +fn decode_output_rejects_a_frame_too_short_to_carry_a_pane_id() { + assert!(decode_output(&[]).is_none()); + assert!(decode_output(&[1, 2, 3]).is_none()); + assert_eq!(decode_output(&[1, 0, 0, 0]), Some((1, &[][..]))); +} + +#[test] +fn client_messages_parse_from_the_wire_shape() { + let create: ClientMessage = + serde_json::from_str(r#"{"type":"create","rows":24,"cols":80}"#).unwrap(); + assert!(matches!( + create, + ClientMessage::Create { rows: 24, cols: 80 } + )); + + let input: ClientMessage = + serde_json::from_str(r#"{"type":"input","pane":3,"data":"ls\n"}"#).unwrap(); + assert!(matches!(input, ClientMessage::Input { pane: 3, .. })); + + let reorder: ClientMessage = + serde_json::from_str(r#"{"type":"reorder","order":[3,1,2]}"#).unwrap(); + assert!(matches!(reorder, ClientMessage::Reorder { order } if order == vec![3, 1, 2])); + + let start: ClientMessage = + serde_json::from_str(r#"{"type":"start","sizes":[{"rows":40,"cols":120}]}"#).unwrap(); + assert!( + matches!(start, ClientMessage::Start { sizes } if sizes == vec![PaneSize { rows: 40, cols: 120 }]) + ); + // A client that measured nothing still answers, so the panes open. + let empty: ClientMessage = serde_json::from_str(r#"{"type":"start","sizes":[]}"#).unwrap(); + assert!(matches!(empty, ClientMessage::Start { sizes } if sizes.is_empty())); + + let cancel: ClientMessage = + serde_json::from_str(r#"{"type":"cancel_recovery","pane":4}"#).unwrap(); + assert_eq!(cancel, ClientMessage::CancelRecovery { pane: 4 }); + // And out again: the daemon relays what a client sent, so the tag it writes + // has to be the tag it reads. + assert_eq!( + serde_json::to_string(&ClientMessage::CancelRecovery { pane: 4 }).unwrap(), + r#"{"type":"cancel_recovery","pane":4}"# + ); + + assert!(serde_json::from_str::(r#"{"type":"nope"}"#).is_err()); + assert!(serde_json::from_str::(r#"{"type":"create"}"#).is_err()); +} + +#[test] +fn server_messages_serialize_with_a_type_tag() { + let json = serde_json::to_string(&ServerMessage::Created { + pane: 2, + rows: 40, + cols: 120, + client: None, + title: None, + }) + .unwrap(); + // No requester, no field: the browser reads these already, and a pane + // nobody asked for must look to it exactly as it did before. + assert_eq!(json, r#"{"type":"created","pane":2,"rows":40,"cols":120}"#); + + let json = serde_json::to_string(&ServerMessage::Created { + pane: 2, + rows: 40, + cols: 120, + client: Some(7), + title: None, + }) + .unwrap(); + assert_eq!( + json, + r#"{"type":"created","pane":2,"rows":40,"cols":120,"client":7}"# + ); + + // And back, because the daemon reads these off a hub session to relay them. + let created: ServerMessage = + serde_json::from_str(r#"{"type":"created","pane":1,"rows":2,"cols":3}"#).unwrap(); + assert!(matches!( + created, + ServerMessage::Created { client: None, .. } + )); + + let json = serde_json::to_string(&ServerMessage::Reordered { order: vec![2, 1] }).unwrap(); + assert_eq!(json, r#"{"type":"reordered","order":[2,1]}"#); + + let json = serde_json::to_string(&ServerMessage::Pending { count: 2 }).unwrap(); + assert_eq!(json, r#"{"type":"pending","count":2}"#); +} + +#[test] +fn a_recovery_report_has_a_fixed_wire_shape() { + let json = serde_json::to_string(&ServerMessage::Recovery { + pane: 6, + state: "waiting_for_reset".to_string(), + detail: Some("provider window closed".to_string()), + deadline_epoch: Some(1_700_000_000), + attempt: 2, + }) + .unwrap(); + assert_eq!( + json, + r#"{"type":"recovery","pane":6,"state":"waiting_for_reset","detail":"provider window closed","deadline_epoch":1700000000,"attempt":2}"# + ); + + // Absent, not null and not zero: a client must be able to tell "no deadline" + // from "the epoch", and rendering a wrong wall-clock time reads as fact. + let json = serde_json::to_string(&ServerMessage::Recovery { + pane: 6, + state: "cancelled".to_string(), + detail: None, + deadline_epoch: None, + attempt: 0, + }) + .unwrap(); + assert_eq!( + json, + r#"{"type":"recovery","pane":6,"state":"cancelled","attempt":0}"# + ); + + // And back, because the daemon reads these off a hub session to relay them. + let parsed: ServerMessage = serde_json::from_str( + r#"{"type":"recovery","pane":6,"state":"backoff","deadline_epoch":-1,"attempt":9}"#, + ) + .unwrap(); + assert_eq!( + parsed, + ServerMessage::Recovery { + pane: 6, + state: "backoff".to_string(), + detail: None, + deadline_epoch: Some(-1), + attempt: 9, + } + ); +} + +#[test] +fn a_pane_size_is_clamped_into_the_bounds_a_pty_can_use() { + // These arrive from the client's own measurement, so they are input from + // outside. Zero gives the child a terminal it cannot draw in and can fail + // `openpty`; the far end asks a full-screen program for a screen buffer of + // rows * cells. + assert_eq!( + PaneSize { rows: 0, cols: 0 }.clamped(), + PaneSize { + rows: limits::MIN_PANE_DIMENSION, + cols: limits::MIN_PANE_DIMENSION + } + ); + assert_eq!( + PaneSize { + rows: u16::MAX, + cols: u16::MAX + } + .clamped(), + PaneSize { + rows: limits::MAX_PANE_ROWS, + cols: limits::MAX_PANE_COLS + } + ); + // A real display passes through untouched. + let real = PaneSize { + rows: 48, + cols: 210, + }; + assert_eq!(real.clamped(), real); +} + +#[test] +fn canonical_order_reconciles_a_request_against_the_live_panes() { + // A full permutation is honored verbatim. + assert_eq!(canonical_order(&[1, 2, 3], &[3, 1, 2]), vec![3, 1, 2]); + // A partial request moves the named panes; the rest keep their order. + assert_eq!(canonical_order(&[1, 2, 3], &[3]), vec![3, 1, 2]); + // An id that is no longer live (closed in a race) is dropped. + assert_eq!(canonical_order(&[1, 2], &[9, 2, 1]), vec![2, 1]); + // A repeated id is taken once, keeping the result a permutation. + assert_eq!(canonical_order(&[1, 2], &[2, 2, 1]), vec![2, 1]); + // An empty request leaves the order untouched. + assert_eq!(canonical_order(&[1, 2], &[]), vec![1, 2]); +} + +#[test] +fn scrollback_is_bounded_and_keeps_the_most_recent_bytes() { + let cap = limits::MAX_TERMINAL_SCROLLBACK_BYTES; + let mut buf = VecDeque::new(); + for _ in 0..(cap / 1000 + 5) { + push_scrollback(&mut buf, &vec![b'x'; 1000]); + } + assert_eq!(buf.len(), cap, "scrollback must be capped"); + + // The tail is what restores the visible screen, so the newest bytes must + // survive eviction. + push_scrollback(&mut buf, b"TAIL"); + assert_eq!(buf.len(), cap); + let contents: Vec = buf.iter().copied().collect(); + assert!(contents.ends_with(b"TAIL"), "newest bytes must be retained"); +} diff --git a/viewer-ui/src/components/terminal/PanelToolbar.tsx b/viewer-ui/src/components/terminal/PanelToolbar.tsx index 9397cfc5..2c5c8a9d 100644 --- a/viewer-ui/src/components/terminal/PanelToolbar.tsx +++ b/viewer-ui/src/components/terminal/PanelToolbar.tsx @@ -1,10 +1,18 @@ import { FitScreenIcon, MaximizeIcon, PlusIcon } from "../icons"; +import { RecoveryChip } from "./RecoveryChip"; +import { orphanRecovery, type RecoveryByPane } from "../../lib/recovery"; export interface PanelToolbarProps { /** Whether this page's layout is what sets the pane sizes. When it is not, * the button that takes the sizing back appears. */ ownsSize: boolean; maximized: boolean; + /** Every pane's recovery report, and the panes this page still lists. Reports + * for panes it does not — a process that ended while its slot is held for a + * relaunch — have no cell to sit in, so they surface here. */ + recovery: RecoveryByPane; + panes: number[]; + onCancelRecovery: (pane: number) => void; onClaimSize: () => void; onCreate: () => void; onToggleMaximized: () => void; @@ -16,6 +24,9 @@ export interface PanelToolbarProps { export function PanelToolbar({ ownsSize, maximized, + recovery, + panes, + onCancelRecovery, onClaimSize, onCreate, onToggleMaximized, @@ -23,7 +34,15 @@ export function PanelToolbar({ const button = "flex shrink-0 items-center rounded-sm px-1.5 py-0.5 text-ink-400 hover:text-accent"; return ( -
+
+ {orphanRecovery(recovery, panes).map((pane) => ( + onCancelRecovery(pane)} + /> + ))} {!ownsSize && ( + + ); +} diff --git a/viewer-ui/src/components/terminal/StartupSlots.tsx b/viewer-ui/src/components/terminal/StartupSlots.tsx index 98300f7a..db3372eb 100644 --- a/viewer-ui/src/components/terminal/StartupSlots.tsx +++ b/viewer-ui/src/components/terminal/StartupSlots.tsx @@ -40,6 +40,7 @@ export function StartupSlots({ isDragged={false} isDropTarget={false} reorderable={false} + onCancelRecovery={() => {}} onFocus={() => {}} onToggleZoom={() => {}} onClose={() => {}} diff --git a/viewer-ui/src/components/terminal/Terminal.tsx b/viewer-ui/src/components/terminal/Terminal.tsx index ce56a725..53fa0d12 100644 --- a/viewer-ui/src/components/terminal/Terminal.tsx +++ b/viewer-ui/src/components/terminal/Terminal.tsx @@ -10,6 +10,7 @@ import { usePaneDrag } from "../../hooks/terminal/usePaneDrag"; import { useTerminalSocket } from "../../hooks/terminal/useTerminalSocket"; import { useTerminalViews } from "../../hooks/terminal/useTerminalViews"; import { usePaneSizes } from "../../hooks/terminal/usePaneSizes"; +import { usePaneRecovery } from "../../hooks/terminal/usePaneRecovery"; import { useStartupSizes } from "../../hooks/terminal/useStartupSizes"; import { TerminalCell } from "./TerminalCell"; import { StartupSlots } from "./StartupSlots"; @@ -58,6 +59,7 @@ export function TerminalPanel({ // and the child cannot be re-flowed afterwards, so one client at a time // decides it; the rest render the grid they are given. const [ownsSize, setOwnsSize] = useState(true); + const { recovery, setRecovery, cancelRecovery } = usePaneRecovery(socketRef); useTerminalSocket({ repo, @@ -73,6 +75,7 @@ export function TerminalPanel({ setZoomed, setTitles, setOwnsSize, + setRecovery, }); useTerminalViews({ @@ -207,6 +210,9 @@ export function TerminalPanel({ cancelRecovery(pane)} onFocus={() => focusPane(pane)} onToggleZoom={() => setZoomed((z) => (z === pane ? null : pane)) diff --git a/viewer-ui/src/components/terminal/TerminalCell.tsx b/viewer-ui/src/components/terminal/TerminalCell.tsx index 7e063448..d6a7ea5e 100644 --- a/viewer-ui/src/components/terminal/TerminalCell.tsx +++ b/viewer-ui/src/components/terminal/TerminalCell.tsx @@ -1,5 +1,7 @@ import type { CSSProperties } from "react"; import { MaximizeIcon, XIcon } from "../icons"; +import { RecoveryChip } from "./RecoveryChip"; +import type { PaneRecovery } from "../../lib/recovery"; import { TAB_TITLE_MAX_CELLS, truncateCells } from "../../lib/terminalLayout"; interface TerminalCellProps { @@ -13,6 +15,9 @@ interface TerminalCellProps { isDragged: boolean; isDropTarget: boolean; reorderable: boolean; + /** What this pane's plugin last reported about recovering it, if anything. */ + recovery?: PaneRecovery; + onCancelRecovery: () => void; onFocus: () => void; onToggleZoom: () => void; onClose: () => void; @@ -34,6 +39,8 @@ export function TerminalCell({ isDragged, isDropTarget, reorderable, + recovery, + onCancelRecovery, onFocus, onToggleZoom, onClose, @@ -78,6 +85,9 @@ export function TerminalCell({ > {truncateCells(label, TAB_TITLE_MAX_CELLS)} + {recovery && ( + + )} {showZoom && (