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