Skip to main content

strop_remote/
exec.rs

1//! Supervised remote process execution for Git commands and language
2//! servers (0036 "Remote execution and services").
3//!
4//! One [`RemoteCommand`] is a pure, cloned description: executable,
5//! native argv and absolute remote cwd. Filenames never become
6//! executable shell text — they travel base64-encoded inside a fixed
7//! Python supervisor's spec and are executed remotely through
8//! `os.chdir(bytes)` + `os.execvpe` with byte argv. Constructors have
9//! no spawn side effect:
10//!
11//! - [`command`] builds the ssh invocation for an owned stdio client
12//!   (language server): all three pipes are `Stdio::piped()` and the
13//!   caller owns spawning, the stdin lease and reaping.
14//! - [`command_supervised`] additionally hands back the
15//!   [`SupervisionKey`] that identifies this session's supervisor
16//!   records, and lets a caller pick finite stdin explicitly.
17//! - [`run`] executes one finite command to completion on a worker
18//!   under a [`CancelToken`], with bounded output and a deadline.
19//!
20//! ## Local + remote child ownership
21//!
22//! Local: spawn the returned command through
23//! `strop_core::process::OwnedProcess` (or set `process_group(0)`
24//! yourself when using another spawner). Cancellation SIGKILLs the
25//! local ssh group and revokes the PID before reaping it; nothing here
26//! holds editor state.
27//!
28//! Remote: SSH stdin is the lifetime lease. Keep the local stdin
29//! writer open while the remote process should live; dropping it (or
30//! the local ssh process dying) makes the remote supervisor SIGTERM
31//! the worker's whole process group, escalate after a bounded grace,
32//! SIGKILL it and reap — a worker that exited normally is cleaned up
33//! the same way, because finite commands can leave descendants. A
34//! graceful shutdown is therefore an application-level exchange first
35//! (LSP `shutdown`/`exit`), *then* a lease close.
36//!
37//! The remote guarantee is conditional and stated honestly: cleanup
38//! runs once the remote sshd observes the disconnection, so a network
39//! partition delays it until sshd's own dead-peer detection fires;
40//! descendants that create their own session escape a process-group
41//! kill; and nothing survives a remote SIGKILL of the supervisor
42//! itself. See `exec::supervisor` for the full topology and limits.
43
44mod python;
45mod run;
46mod spec;
47mod supervisor;
48
49use crate::address::RemoteEndpoint;
50use std::ffi::{OsStr, OsString};
51use std::path::{Path, PathBuf};
52use std::time::Duration;
53use strop_core::worker::CancelToken;
54
55/// A checked description of one remote process. Pure data: nothing is
56/// spawned by constructing, cloning or inspecting it.
57#[derive(Debug, Clone)]
58pub struct RemoteCommand {
59    program: OsString,
60    args: Vec<OsString>,
61    cwd: PathBuf,
62    deadline: Duration,
63}
64
65impl RemoteCommand {
66    /// Admit one command. Refuses an empty program, NUL bytes in the
67    /// program, arguments or working directory, a non-absolute working
68    /// directory, and values that cannot be represented as native
69    /// POSIX bytes. The program may be a bare name (remote `PATH`
70    /// lookup) or contain `/` (direct path).
71    pub fn new(
72        program: impl Into<OsString>,
73        args: Vec<OsString>,
74        cwd: &Path,
75    ) -> Result<Self, RemoteCommandError> {
76        let command = Self {
77            program: program.into(),
78            args,
79            cwd: cwd.to_path_buf(),
80            deadline: run::DEFAULT_DEADLINE,
81        };
82        // Re-run the full validation so later mutations can never
83        // bypass admission; it is pure and cheap.
84        spec::Spec::encode(
85            StdinMode::Finite,
86            [0u8; 16],
87            &command.program,
88            &command.args,
89            &command.cwd,
90        )
91        .map_err(|error| match error {
92            RemoteCommandError::ArgvTooLarge { .. } => RemoteCommandError::Invalid {
93                detail: "program and arguments are too large for a remote command line".into(),
94            },
95            other => other,
96        })?;
97        Ok(command)
98    }
99
100    pub fn program(&self) -> &OsStr {
101        &self.program
102    }
103
104    pub fn args(&self) -> &[OsString] {
105        &self.args
106    }
107
108    pub fn cwd(&self) -> &Path {
109        &self.cwd
110    }
111
112    /// Wall-clock budget for [`run`]. Defaults to 120 seconds.
113    pub fn deadline(&self) -> Duration {
114        self.deadline
115    }
116
117    /// Override the wall-clock budget. Still a pure description.
118    pub fn with_deadline(mut self, deadline: Duration) -> Self {
119        self.deadline = deadline;
120        self
121    }
122}
123
124/// What the supervised ssh connection does with the local stdin lease.
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum StdinMode {
127    /// The worker's stdin is `/dev/null`; the supervisor drains SSH
128    /// stdin only to observe the lease. Finite Git commands.
129    Finite,
130    /// SSH stdin is relayed byte-for-byte into the worker's stdin with
131    /// a bounded buffer. Language servers.
132    Relayed,
133}
134
135/// The remote worker's termination outcome, when the supervisor
136/// reported it. Non-zero codes are ordinary results — exit codes are
137/// data for Git (`diff --quiet` exits 1), not transport failures.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum RemoteExitStatus {
140    /// The worker exited with code 0–255.
141    Exited(u32),
142    /// The worker was terminated by the named signal.
143    Signaled(u32),
144}
145
146impl RemoteExitStatus {
147    pub fn code(&self) -> Option<u32> {
148        match *self {
149            RemoteExitStatus::Exited(code) => Some(code),
150            RemoteExitStatus::Signaled(_) => None,
151        }
152    }
153
154    pub fn signal(&self) -> Option<u32> {
155        match *self {
156            RemoteExitStatus::Exited(_) => None,
157            RemoteExitStatus::Signaled(signal) => Some(signal),
158        }
159    }
160
161    pub fn success(&self) -> bool {
162        *self == RemoteExitStatus::Exited(0)
163    }
164}
165
166/// Bounded captured output of one finished remote command. `stdout`
167/// keeps its first `STDOUT_LIMIT` bytes; `stderr` its first
168/// `STDERR_LIMIT` bytes plus the last `STDERR_TAIL` bytes (where the
169/// supervisor's status record lives). The dropped counters say how
170/// many further bytes arrived; treat any non-zero counter as
171/// truncation, never as silence.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct CommandOutput {
174    pub status: RemoteExitStatus,
175    pub stdout: Vec<u8>,
176    pub stderr: Vec<u8>,
177    pub stdout_dropped: u64,
178    pub stderr_dropped: u64,
179}
180
181/// Identifies one supervised session's status records: the supervisor
182/// writes `STROP-SUP-v1 <nonce> ...` lines to stderr, and only lines
183/// carrying this session's nonce are its. Not a secret — it travels
184/// inside the spec and is visible in remote process listings.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct SupervisionKey {
187    nonce: [u8; 16],
188}
189
190impl SupervisionKey {
191    pub(crate) fn generate() -> Self {
192        Self {
193            nonce: spec::nonce(),
194        }
195    }
196
197    pub(crate) fn nonce(&self) -> [u8; 16] {
198        self.nonce
199    }
200
201    /// Every supervisor record found in a captured stderr buffer, in
202    /// order. An empty result means the supervision layer never
203    /// reported; ssh's exit code is then the only evidence.
204    pub fn records(&self, stderr: &[u8]) -> Vec<SupervisionOutcome> {
205        supervisor::records(stderr, &self.hex())
206    }
207
208    /// Remove only this session's framing, including the delimiter that the
209    /// supervisor inserts before a record. Worker stderr remains byte-exact.
210    pub(crate) fn remove_records(&self, stderr: &mut Vec<u8>) {
211        let marker = format!("STROP-SUP-v1 {} ", self.hex());
212        let mut cursor = 0;
213        while cursor < stderr.len() {
214            let Some(relative) = stderr[cursor..]
215                .windows(marker.len())
216                .position(|bytes| bytes == marker.as_bytes())
217            else {
218                break;
219            };
220            let start = cursor + relative;
221            if start != 0 && stderr[start - 1] != b'\n' {
222                cursor = start + marker.len();
223                continue;
224            }
225            let Some(length) = stderr[start..].iter().position(|&byte| byte == b'\n') else {
226                break;
227            };
228            let end = start + length + 1;
229            if !self.records(&stderr[start..end]).is_empty() {
230                let first = start.saturating_sub(1);
231                stderr.drain(first..end);
232                cursor = first;
233            } else {
234                cursor = end;
235            }
236        }
237    }
238
239    fn hex(&self) -> String {
240        self.nonce
241            .iter()
242            .map(|byte| format!("{byte:02x}"))
243            .collect()
244    }
245}
246
247/// One parsed supervisor record. [`SupervisionOutcome::LaunchFailure`]
248/// outranks a later exit record: the program never started.
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub enum SupervisionOutcome {
251    Exited(u32),
252    Signaled(u32),
253    Cancelled,
254    LaunchFailure(String),
255    SupervisorError(String),
256}
257
258/// Why a remote command could not be admitted, spawned, supervised or
259/// completed. Every variant is descriptive; none guesses success.
260#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
261pub enum RemoteCommandError {
262    #[error("remote command refused: {detail}")]
263    Invalid { detail: String },
264    #[error("cannot spawn ssh: {message}")]
265    Spawn { message: String },
266    #[error(
267        "remote command line cannot carry {bytes} encoded bytes (argv/cwd too large for one ssh command)"
268    )]
269    ArgvTooLarge { bytes: usize },
270    #[error(
271        "remote execution needs compatible Python 3.8+ on remote PATH or STROP_REMOTE_PYTHON: {diagnostics}"
272    )]
273    MissingPython { diagnostics: String },
274    #[error("remote program could not start: {diagnostics}")]
275    Launch { diagnostics: String },
276    #[error("remote supervisor failed at {stage}: {diagnostics}")]
277    Supervisor { stage: String, diagnostics: String },
278    #[error("ssh transport failed (exit {exit:?}): {diagnostics}")]
279    Transport {
280        exit: Option<i32>,
281        diagnostics: String,
282    },
283    #[error("remote command cancelled before completion: {diagnostics}")]
284    Cancelled { diagnostics: String },
285    #[error("remote command did not finish within {seconds} seconds")]
286    Timeout { seconds: u64 },
287    #[error("local process supervision failed: {message}")]
288    Local { message: String },
289}
290
291/// The ssh invocation for an owned stdio client — a language server.
292/// Relayed stdin, all three pipes piped, no spawn. The caller owns the
293/// local child (see the module docs for the lease and cancel story)
294/// and may parse the supervisor's stderr records via
295/// [`command_supervised`].
296pub fn command(
297    endpoint: &RemoteEndpoint,
298    command: &RemoteCommand,
299) -> Result<std::process::Command, RemoteCommandError> {
300    command_supervised(endpoint, command, StdinMode::Relayed).map(|(process, _)| process)
301}
302
303/// [`command`] with the leash visible: choose the stdin mode explicitly
304/// and keep the [`SupervisionKey`] for parsing status records out of
305/// the session's stderr tail.
306pub fn command_supervised(
307    endpoint: &RemoteEndpoint,
308    command: &RemoteCommand,
309    mode: StdinMode,
310) -> Result<(std::process::Command, SupervisionKey), RemoteCommandError> {
311    run::supervised(endpoint, command, mode)
312}
313
314/// Run one finite remote command to completion. Worker-only: it blocks
315/// for the whole exchange, bounded by the command's deadline. Each run
316/// uses its own dedicated, noninteractive ssh connection — no pooled
317/// session or lease is involved. Output is bounded per
318/// [`CommandOutput`]; cancellation kills the local ssh group and the
319/// remote supervisor tears down the remote group.
320pub fn run(
321    endpoint: &RemoteEndpoint,
322    command: &RemoteCommand,
323    token: &CancelToken,
324) -> Result<CommandOutput, RemoteCommandError> {
325    run::run(endpoint, command, token)
326}
327
328#[cfg(all(test, unix))]
329mod tests;