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;