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 stream;
48mod supervisor;
49pub use stream::{stream, RemoteStreamError, RemoteStreamOutput};
50
51use std::ffi::OsString;
52use std::path::{Path, PathBuf};
53use std::time::Duration;
54use strop_core::worker::CancelToken;
55use strop_workspace::RemoteEndpoint;
56
57/// Program identity is distinct from argv. Built-in scripts reuse the already
58/// selected supervisor interpreter, never another PATH lookup for `python3`.
59#[derive(Debug, Clone)]
60pub enum RemoteProgram {
61    Executable(OsString),
62    SupervisorPython,
63}
64/// A checked description of one remote process. Pure data: nothing is
65/// spawned by constructing, cloning or inspecting it.
66#[derive(Debug, Clone)]
67pub struct RemoteCommand {
68    program: RemoteProgram,
69    args: Vec<OsString>,
70    cwd: PathBuf,
71    deadline: Duration,
72}
73
74impl RemoteCommand {
75    /// Admit one command. Refuses an empty program, NUL bytes in the
76    /// program, arguments or working directory, a non-absolute working
77    /// directory, and values that cannot be represented as native
78    /// POSIX bytes. The program may be a bare name (remote `PATH`
79    /// lookup) or contain `/` (direct path).
80    pub fn new(
81        program: impl Into<OsString>,
82        args: Vec<OsString>,
83        cwd: &Path,
84    ) -> Result<Self, RemoteCommandError> {
85        Self::admit(RemoteProgram::Executable(program.into()), args, cwd)
86    }
87
88    pub fn python(
89        script: &str,
90        args: Vec<OsString>,
91        cwd: &Path,
92    ) -> Result<Self, RemoteCommandError> {
93        let mut arguments = Vec::with_capacity(args.len() + 2);
94        arguments.push("-c".into());
95        arguments.push(script.into());
96        arguments.extend(args);
97        Self::admit(RemoteProgram::SupervisorPython, arguments, cwd)
98    }
99
100    fn admit(
101        program: RemoteProgram,
102        args: Vec<OsString>,
103        cwd: &Path,
104    ) -> Result<Self, RemoteCommandError> {
105        let command = Self {
106            program,
107            args,
108            cwd: cwd.to_path_buf(),
109            deadline: run::DEFAULT_DEADLINE,
110        };
111        // Re-run the full validation so later mutations can never
112        // bypass admission; it is pure and cheap.
113        spec::Spec::encode(
114            StdinMode::Finite,
115            [0u8; 16],
116            &command.program,
117            &command.args,
118            &command.cwd,
119        )
120        .map_err(|error| match error {
121            RemoteCommandError::ArgvTooLarge { .. } => RemoteCommandError::Invalid {
122                detail: "program and arguments are too large for a remote command line".into(),
123            },
124            other => other,
125        })?;
126        Ok(command)
127    }
128
129    pub fn program(&self) -> &RemoteProgram {
130        &self.program
131    }
132
133    pub fn args(&self) -> &[OsString] {
134        &self.args
135    }
136
137    pub fn cwd(&self) -> &Path {
138        &self.cwd
139    }
140
141    /// Wall-clock budget for [`run`]. Defaults to 120 seconds.
142    pub fn deadline(&self) -> Duration {
143        self.deadline
144    }
145
146    /// Override the wall-clock budget. Still a pure description.
147    pub fn with_deadline(mut self, deadline: Duration) -> Self {
148        self.deadline = deadline;
149        self
150    }
151}
152
153/// What the supervised ssh connection does with the local stdin lease.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum StdinMode {
156    /// The worker's stdin is `/dev/null`; the supervisor drains SSH
157    /// stdin only to observe the lease. Finite Git commands.
158    Finite,
159    /// SSH stdin is relayed byte-for-byte into the worker's stdin with
160    /// a bounded buffer. Language servers.
161    Relayed,
162}
163
164/// The remote worker's termination outcome, when the supervisor
165/// reported it. Non-zero codes are ordinary results — exit codes are
166/// data for Git (`diff --quiet` exits 1), not transport failures.
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum RemoteExitStatus {
169    /// The worker exited with code 0–255.
170    Exited(u32),
171    /// The worker was terminated by the named signal.
172    Signaled(u32),
173}
174
175impl RemoteExitStatus {
176    pub fn code(&self) -> Option<u32> {
177        match *self {
178            RemoteExitStatus::Exited(code) => Some(code),
179            RemoteExitStatus::Signaled(_) => None,
180        }
181    }
182
183    pub fn signal(&self) -> Option<u32> {
184        match *self {
185            RemoteExitStatus::Exited(_) => None,
186            RemoteExitStatus::Signaled(signal) => Some(signal),
187        }
188    }
189
190    pub fn success(&self) -> bool {
191        *self == RemoteExitStatus::Exited(0)
192    }
193}
194
195/// Bounded captured output of one finished remote command. `stdout`
196/// keeps its first `STDOUT_LIMIT` bytes; `stderr` its first
197/// `STDERR_LIMIT` bytes plus the last `STDERR_TAIL` bytes (where the
198/// supervisor's status record lives). The dropped counters say how
199/// many further bytes arrived; treat any non-zero counter as
200/// truncation, never as silence.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct CommandOutput {
203    pub status: RemoteExitStatus,
204    pub stdout: Vec<u8>,
205    pub stderr: Vec<u8>,
206    pub stdout_dropped: u64,
207    pub stderr_dropped: u64,
208    /// Any upload failure is retained even when the program returned diagnostics.
209    pub stdin_error: Option<String>,
210}
211
212/// Identifies one supervised session's status records: the supervisor
213/// writes `STROP-SUP-v1 <nonce> ...` lines to stderr, and only lines
214/// carrying this session's nonce are its. Not a secret — it travels
215/// inside the spec and is visible in remote process listings.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct SupervisionKey {
218    nonce: [u8; 16],
219}
220
221impl SupervisionKey {
222    pub(crate) fn generate() -> Self {
223        Self {
224            nonce: spec::nonce(),
225        }
226    }
227
228    pub(crate) fn nonce(&self) -> [u8; 16] {
229        self.nonce
230    }
231
232    /// Every supervisor record found in a captured stderr buffer, in
233    /// order. An empty result means the supervision layer never
234    /// reported; ssh's exit code is then the only evidence.
235    pub fn records(&self, stderr: &[u8]) -> Vec<SupervisionOutcome> {
236        supervisor::records(stderr, &self.hex())
237    }
238
239    /// Remove only this session's framing, including the delimiter that the
240    /// supervisor inserts before a record. Worker stderr remains byte-exact.
241    pub(crate) fn remove_records(&self, stderr: &mut Vec<u8>) {
242        let marker = format!("STROP-SUP-v1 {} ", self.hex());
243        let mut cursor = 0;
244        while cursor < stderr.len() {
245            let Some(relative) = stderr[cursor..]
246                .windows(marker.len())
247                .position(|bytes| bytes == marker.as_bytes())
248            else {
249                break;
250            };
251            let start = cursor + relative;
252            if start != 0 && stderr[start - 1] != b'\n' {
253                cursor = start + marker.len();
254                continue;
255            }
256            let Some(length) = stderr[start..].iter().position(|&byte| byte == b'\n') else {
257                break;
258            };
259            let end = start + length + 1;
260            if !self.records(&stderr[start..end]).is_empty() {
261                let first = start.saturating_sub(1);
262                stderr.drain(first..end);
263                cursor = first;
264            } else {
265                cursor = end;
266            }
267        }
268    }
269
270    fn hex(&self) -> String {
271        self.nonce
272            .iter()
273            .map(|byte| format!("{byte:02x}"))
274            .collect()
275    }
276}
277
278/// One parsed supervisor record. [`SupervisionOutcome::LaunchFailure`]
279/// outranks a later exit record: the program never started.
280#[derive(Debug, Clone, PartialEq, Eq)]
281pub enum SupervisionOutcome {
282    Exited(u32),
283    Signaled(u32),
284    Cancelled,
285    LaunchFailure(String),
286    SupervisorError(String),
287}
288
289/// Why a remote command could not be admitted, spawned, supervised or
290/// completed. Every variant is descriptive; none guesses success.
291#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
292pub enum RemoteCommandError {
293    #[error("remote command refused: {detail}")]
294    Invalid { detail: String },
295    #[error("cannot spawn ssh: {message}")]
296    Spawn { message: String },
297    #[error(
298        "remote command line cannot carry {bytes} encoded bytes (argv/cwd too large for one ssh command)"
299    )]
300    ArgvTooLarge { bytes: usize },
301    #[error(
302        "remote execution needs compatible Python 3.8+ on remote PATH or STROP_REMOTE_PYTHON: {diagnostics}"
303    )]
304    MissingPython { diagnostics: String },
305    #[error("remote program could not start: {diagnostics}")]
306    Launch { diagnostics: String },
307    #[error("remote supervisor failed at {stage}: {diagnostics}")]
308    Supervisor { stage: String, diagnostics: String },
309    #[error("ssh transport failed (exit {exit:?}): {diagnostics}")]
310    Transport {
311        exit: Option<i32>,
312        diagnostics: String,
313    },
314    #[error("remote command cancelled before completion: {diagnostics}")]
315    Cancelled { diagnostics: String },
316    #[error("remote command did not finish within {seconds} seconds")]
317    Timeout { seconds: u64 },
318    #[error("local process supervision failed: {message}")]
319    Local { message: String },
320}
321
322/// The ssh invocation for an owned stdio client — a language server.
323/// Relayed stdin, all three pipes piped, no spawn. The caller owns the
324/// local child (see the module docs for the lease and cancel story)
325/// and may parse the supervisor's stderr records via
326/// [`command_supervised`].
327pub fn command(
328    endpoint: &RemoteEndpoint,
329    command: &RemoteCommand,
330) -> Result<std::process::Command, RemoteCommandError> {
331    command_supervised(endpoint, command, StdinMode::Relayed).map(|(process, _)| process)
332}
333
334/// [`command`] with the leash visible: choose the stdin mode explicitly
335/// and keep the [`SupervisionKey`] for parsing status records out of
336/// the session's stderr tail.
337pub fn command_supervised(
338    endpoint: &RemoteEndpoint,
339    command: &RemoteCommand,
340    mode: StdinMode,
341) -> Result<(std::process::Command, SupervisionKey), RemoteCommandError> {
342    run::supervised(endpoint, command, mode)
343}
344
345/// Run one finite remote command to completion. Worker-only: it blocks
346/// for the whole exchange, bounded by the command's deadline. Each run
347/// uses its own dedicated, noninteractive ssh connection — no pooled
348/// session or lease is involved. Output is bounded per
349/// [`CommandOutput`]; cancellation kills the local ssh group and the
350/// remote supervisor tears down the remote group.
351pub fn run(
352    endpoint: &RemoteEndpoint,
353    command: &RemoteCommand,
354    token: &CancelToken,
355) -> Result<CommandOutput, RemoteCommandError> {
356    run::run(endpoint, command, token)
357}
358
359/// Worker-only framed input. Chunks are borrowed; no whole-rope copy is needed.
360/// The stdin lease remains open after the final chunk until the program exits.
361pub fn run_with_input(
362    endpoint: &RemoteEndpoint,
363    command: &RemoteCommand,
364    token: &CancelToken,
365    chunks: &[&[u8]],
366) -> Result<CommandOutput, RemoteCommandError> {
367    run::run_input(endpoint, command, token, Some(chunks))
368}
369
370#[cfg(all(test, unix))]
371mod tests;