1use std::{
4 fs::File,
5 io::{self, Read, Write},
6 panic::{self, AssertUnwindSafe},
7 process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus, Stdio},
8 sync::{
9 Arc,
10 atomic::{AtomicU8, Ordering},
11 mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TrySendError},
12 },
13 thread,
14 time::{Duration, Instant},
15};
16
17use camino::Utf8Path;
18use rustferry_remote::{
19 CancellationToken, ProtocolPath, RemoteBuildEvent, SnapshotArtifactDescriptor,
20 WorkerDataPlaneFrameKind, write_worker_data_plane_frame,
21};
22use thiserror::Error;
23
24use crate::{
25 session::{
26 CreateOnlyArtifactSpool, SnapshotSessionClientError, SnapshotSessionOutcome,
27 SnapshotSessionProgress, SnapshotSessionRequest, run_snapshot_session_deferred,
28 },
29 transport::{ProcessSshRunner, SshInvocation, SshTransportError, terminate_child_bounded},
30};
31
32const SESSION_IO_POLL_INTERVAL: Duration = Duration::from_millis(10);
33const SESSION_CANCEL_WRITE_TIMEOUT: Duration = Duration::from_millis(100);
34const SESSION_EXIT_OBSERVATION_TIMEOUT: Duration = Duration::from_millis(100);
35const SESSION_STDERR_BUFFER_BYTES: usize = 16 * 1024;
36const SESSION_PIPE_CHUNK_BYTES: usize = 16 * 1024;
37
38#[derive(Clone, Debug, Eq, Error, PartialEq)]
40pub enum SshSnapshotSessionError {
41 #[error(transparent)]
43 Session(#[from] SnapshotSessionClientError),
44 #[error(transparent)]
46 Transport(#[from] SshTransportError),
47}
48
49impl ProcessSshRunner {
50 #[allow(clippy::too_many_arguments)]
60 pub fn run_snapshot_session<EventSink, Verifier, VerifyError>(
61 &self,
62 invocation: &SshInvocation,
63 request: SnapshotSessionRequest<'_>,
64 artifact_spool: &mut CreateOnlyArtifactSpool,
65 cancellation: &CancellationToken,
66 on_event: EventSink,
67 verify_artifact: Verifier,
68 ) -> Result<SnapshotSessionOutcome, SshSnapshotSessionError>
69 where
70 EventSink: FnMut(RemoteBuildEvent),
71 Verifier: FnOnce(
72 &mut File,
73 &Utf8Path,
74 &SnapshotArtifactDescriptor,
75 ) -> Result<ProtocolPath, VerifyError>,
76 {
77 let deadline = Instant::now() + invocation.timeout();
78 if cancellation.is_cancelled() {
79 return transport_error(artifact_spool, SshTransportError::Cancelled);
80 }
81 if invocation.revalidate_identity_file().is_err() {
82 return transport_error(artifact_spool, SshTransportError::IdentityFileChanged);
83 }
84 if invocation.revalidate_trust_snapshot().is_err() {
85 return transport_error(artifact_spool, SshTransportError::TrustSnapshotChanged);
86 }
87
88 let Ok(mut child) = Command::new(invocation.program())
89 .args(invocation.arguments())
90 .stdin(Stdio::piped())
91 .stdout(Stdio::piped())
92 .stderr(Stdio::piped())
93 .spawn()
94 else {
95 return transport_error(artifact_spool, SshTransportError::SpawnFailed);
96 };
97 let Some(stdin) = child.stdin.take() else {
98 terminate_child_bounded(child, deadline);
99 return transport_error(artifact_spool, SshTransportError::IoFailed);
100 };
101 let Some(stdout) = child.stdout.take() else {
102 terminate_child_bounded(child, deadline);
103 return transport_error(artifact_spool, SshTransportError::IoFailed);
104 };
105 let Some(stderr) = child.stderr.take() else {
106 terminate_child_bounded(child, deadline);
107 return transport_error(artifact_spool, SshTransportError::IoFailed);
108 };
109
110 let abort = AbortController::new(cancellation.clone(), deadline);
111 if spawn_stderr_drain(stderr).is_err() {
112 terminate_child_bounded(child, deadline);
113 return transport_error(artifact_spool, SshTransportError::IoFailed);
114 }
115 let Ok(stdout_events) = spawn_stdout_reader(stdout) else {
116 terminate_child_bounded(child, deadline);
117 return transport_error(artifact_spool, SshTransportError::IoFailed);
118 };
119 let Ok(mut writer) = InterruptibleWriter::spawn(stdin, abort.clone()) else {
120 terminate_child_bounded(child, deadline);
121 return transport_error(artifact_spool, SshTransportError::IoFailed);
122 };
123 let mut reader = InterruptibleReader::new(stdout_events, abort.clone());
124 let mut progress = SnapshotSessionProgress::default();
125
126 let session = panic::catch_unwind(AssertUnwindSafe(|| {
127 run_snapshot_session_deferred(
128 &mut reader,
129 &mut writer,
130 request,
131 artifact_spool,
132 on_event,
133 verify_artifact,
134 &mut progress,
135 )
136 }));
137 let session = match session {
138 Ok(session) => session,
139 Err(payload) => {
140 if progress.can_send_cancel() && writer.best_effort_cancel() {
141 progress.mark_cancel_sent();
142 }
143 drop(writer);
144 terminate_child_bounded(child, deadline);
145 let _ = artifact_spool.cleanup_failure();
146 panic::resume_unwind(payload);
147 }
148 };
149 let outcome = match session {
150 Ok(outcome) => outcome,
151 Err(error) => {
152 if progress.can_send_cancel() && writer.best_effort_cancel() {
153 progress.mark_cancel_sent();
154 }
155 drop(writer);
156 return finish_failed_session(child, deadline, &abort, error);
157 }
158 };
159
160 if writer.close().is_err() {
161 let error = classify_abort(&abort).unwrap_or(SshTransportError::IoFailed);
162 terminate_child_bounded(child, deadline);
163 return transport_error(artifact_spool, error);
164 }
165 wait_for_successful_exit(child, deadline, &abort)
166 .or_else(|error| transport_error(artifact_spool, error))?;
167 if let Err(error) = artifact_spool.authorize_commit() {
168 return session_error(artifact_spool, error);
169 }
170 Ok(outcome)
171 }
172}
173
174fn session_error<T>(
175 spool: &mut CreateOnlyArtifactSpool,
176 error: SnapshotSessionClientError,
177) -> Result<T, SshSnapshotSessionError> {
178 spool.cleanup_failure()?;
179 Err(SshSnapshotSessionError::Session(error))
180}
181
182fn transport_error<T>(
183 spool: &mut CreateOnlyArtifactSpool,
184 error: SshTransportError,
185) -> Result<T, SshSnapshotSessionError> {
186 spool.cleanup_failure()?;
187 Err(SshSnapshotSessionError::Transport(error))
188}
189
190fn finish_failed_session<T>(
191 mut child: Child,
192 deadline: Instant,
193 abort: &AbortController,
194 session_error: SnapshotSessionClientError,
195) -> Result<T, SshSnapshotSessionError> {
196 if !matches!(session_error, SnapshotSessionClientError::DataPlane(_)) {
197 terminate_child_bounded(child, deadline);
198 return Err(SshSnapshotSessionError::Session(session_error));
199 }
200 if let Some(error) = classify_cancel_or_timeout(abort) {
201 terminate_child_bounded(child, deadline);
202 return Err(SshSnapshotSessionError::Transport(error));
203 }
204 if let Some(status) = observe_exit_status(&mut child, deadline, &session_error) {
205 return if status.success() {
206 Err(SshSnapshotSessionError::Session(session_error))
207 } else {
208 Err(SshSnapshotSessionError::Transport(
209 SshTransportError::ProcessFailed {
210 status: status.code(),
211 },
212 ))
213 };
214 }
215 let transport = classify_abort(abort);
216 terminate_child_bounded(child, deadline);
217 match transport {
218 Some(error) => Err(SshSnapshotSessionError::Transport(error)),
219 None => Err(SshSnapshotSessionError::Session(session_error)),
220 }
221}
222
223fn classify_cancel_or_timeout(abort: &AbortController) -> Option<SshTransportError> {
224 if abort.cancellation.is_cancelled() {
225 abort.mark(AbortReason::Cancelled);
226 } else if Instant::now() >= abort.deadline {
227 abort.mark(AbortReason::TimedOut);
228 }
229 match abort.reason() {
230 AbortReason::Cancelled => Some(SshTransportError::Cancelled),
231 AbortReason::TimedOut => Some(SshTransportError::TimedOut),
232 AbortReason::Active | AbortReason::IoFailed => None,
233 }
234}
235
236fn observe_exit_status(
237 child: &mut Child,
238 deadline: Instant,
239 error: &SnapshotSessionClientError,
240) -> Option<ExitStatus> {
241 let observe_until = if matches!(error, SnapshotSessionClientError::DataPlane(_)) {
242 deadline.min(Instant::now() + SESSION_EXIT_OBSERVATION_TIMEOUT)
243 } else {
244 Instant::now()
245 };
246 loop {
247 match child.try_wait() {
248 Ok(Some(status)) => return Some(status),
249 Ok(None) => {}
250 Err(_) => return None,
251 }
252 let remaining = observe_until.saturating_duration_since(Instant::now());
253 if remaining.is_zero() {
254 return None;
255 }
256 thread::sleep(SESSION_IO_POLL_INTERVAL.min(remaining));
257 }
258}
259
260fn wait_for_successful_exit(
261 mut child: Child,
262 deadline: Instant,
263 abort: &AbortController,
264) -> Result<(), SshTransportError> {
265 loop {
266 if let Some(error) = classify_abort(abort) {
267 terminate_child_bounded(child, deadline);
268 return Err(error);
269 }
270 match child.try_wait() {
271 Ok(Some(status)) if status.success() => return Ok(()),
272 Ok(Some(status)) => {
273 return Err(SshTransportError::ProcessFailed {
274 status: status.code(),
275 });
276 }
277 Ok(None) => {}
278 Err(_) => {
279 terminate_child_bounded(child, deadline);
280 return Err(SshTransportError::IoFailed);
281 }
282 }
283 let remaining = deadline.saturating_duration_since(Instant::now());
284 if remaining.is_zero() {
285 abort.mark(AbortReason::TimedOut);
286 terminate_child_bounded(child, deadline);
287 return Err(SshTransportError::TimedOut);
288 }
289 thread::sleep(SESSION_IO_POLL_INTERVAL.min(remaining));
290 }
291}
292
293#[derive(Clone)]
294struct AbortController {
295 state: Arc<AtomicU8>,
296 cancellation: CancellationToken,
297 deadline: Instant,
298}
299
300#[derive(Clone, Copy, Debug, Eq, PartialEq)]
301#[repr(u8)]
302enum AbortReason {
303 Active = 0,
304 Cancelled = 1,
305 TimedOut = 2,
306 IoFailed = 3,
307}
308
309impl AbortController {
310 fn new(cancellation: CancellationToken, deadline: Instant) -> Self {
311 Self {
312 state: Arc::new(AtomicU8::new(AbortReason::Active as u8)),
313 cancellation,
314 deadline,
315 }
316 }
317
318 fn checkpoint(&self) -> io::Result<()> {
319 if self.cancellation.is_cancelled() {
320 self.mark(AbortReason::Cancelled);
321 } else if Instant::now() >= self.deadline {
322 self.mark(AbortReason::TimedOut);
323 }
324 match self.reason() {
325 AbortReason::Active => Ok(()),
326 AbortReason::Cancelled => Err(io::Error::new(
327 io::ErrorKind::ConnectionAborted,
328 "snapshot session cancelled",
329 )),
330 AbortReason::TimedOut => Err(io::Error::new(
331 io::ErrorKind::TimedOut,
332 "snapshot session timed out",
333 )),
334 AbortReason::IoFailed => Err(io::Error::new(
335 io::ErrorKind::BrokenPipe,
336 "snapshot session pipe failed",
337 )),
338 }
339 }
340
341 fn wait_interval(&self) -> Duration {
342 SESSION_IO_POLL_INTERVAL.min(self.deadline.saturating_duration_since(Instant::now()))
343 }
344
345 fn mark(&self, reason: AbortReason) {
346 let _ = self.state.compare_exchange(
347 AbortReason::Active as u8,
348 reason as u8,
349 Ordering::AcqRel,
350 Ordering::Acquire,
351 );
352 }
353
354 fn reason(&self) -> AbortReason {
355 match self.state.load(Ordering::Acquire) {
356 1 => AbortReason::Cancelled,
357 2 => AbortReason::TimedOut,
358 3 => AbortReason::IoFailed,
359 _ => AbortReason::Active,
360 }
361 }
362}
363
364fn classify_abort(abort: &AbortController) -> Option<SshTransportError> {
365 if abort.cancellation.is_cancelled() {
366 abort.mark(AbortReason::Cancelled);
367 } else if Instant::now() >= abort.deadline {
368 abort.mark(AbortReason::TimedOut);
369 }
370 match abort.reason() {
371 AbortReason::Active => None,
372 AbortReason::Cancelled => Some(SshTransportError::Cancelled),
373 AbortReason::TimedOut => Some(SshTransportError::TimedOut),
374 AbortReason::IoFailed => Some(SshTransportError::IoFailed),
375 }
376}
377
378enum StdoutEvent {
379 Bytes(Vec<u8>),
380 Eof,
381 Failed,
382}
383
384fn spawn_stdout_reader(mut stdout: ChildStdout) -> Result<Receiver<StdoutEvent>, ()> {
385 let (sender, receiver) = mpsc::sync_channel(2);
386 thread::Builder::new()
387 .name("rustferry-ssh-session-stdout".to_owned())
388 .spawn(move || {
389 let mut buffer = [0_u8; SESSION_PIPE_CHUNK_BYTES];
390 loop {
391 match stdout.read(&mut buffer) {
392 Ok(0) => {
393 let _ = sender.send(StdoutEvent::Eof);
394 return;
395 }
396 Ok(count) => {
397 if sender
398 .send(StdoutEvent::Bytes(buffer[..count].to_vec()))
399 .is_err()
400 {
401 return;
402 }
403 }
404 Err(_) => {
405 let _ = sender.send(StdoutEvent::Failed);
406 return;
407 }
408 }
409 }
410 })
411 .map(drop)
412 .map_err(|_| ())?;
413 Ok(receiver)
414}
415
416fn spawn_stderr_drain(mut stderr: ChildStderr) -> io::Result<()> {
417 thread::Builder::new()
418 .name("rustferry-ssh-session-stderr".to_owned())
419 .spawn(move || {
420 let mut buffer = [0_u8; SESSION_STDERR_BUFFER_BYTES];
421 while let Ok(count) = stderr.read(&mut buffer) {
422 if count == 0 {
423 return;
424 }
425 }
426 })
427 .map(drop)
428}
429
430struct InterruptibleReader {
431 events: Receiver<StdoutEvent>,
432 pending: Vec<u8>,
433 offset: usize,
434 eof: bool,
435 abort: AbortController,
436}
437
438impl InterruptibleReader {
439 fn new(events: Receiver<StdoutEvent>, abort: AbortController) -> Self {
440 Self {
441 events,
442 pending: Vec::new(),
443 offset: 0,
444 eof: false,
445 abort,
446 }
447 }
448}
449
450impl Read for InterruptibleReader {
451 fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
452 if buffer.is_empty() {
453 return Ok(0);
454 }
455 loop {
456 if self.offset < self.pending.len() {
457 let count = buffer.len().min(self.pending.len() - self.offset);
458 buffer[..count].copy_from_slice(&self.pending[self.offset..self.offset + count]);
459 self.offset += count;
460 if self.offset == self.pending.len() {
461 self.pending.clear();
462 self.offset = 0;
463 }
464 return Ok(count);
465 }
466 if self.eof {
467 return Ok(0);
468 }
469 self.abort.checkpoint()?;
470 match self.events.recv_timeout(self.abort.wait_interval()) {
471 Ok(StdoutEvent::Bytes(bytes)) => self.pending = bytes,
472 Ok(StdoutEvent::Eof) => self.eof = true,
473 Ok(StdoutEvent::Failed) | Err(RecvTimeoutError::Disconnected) => {
474 self.abort.mark(AbortReason::IoFailed);
475 return Err(io::Error::new(
476 io::ErrorKind::BrokenPipe,
477 "snapshot session stdout failed",
478 ));
479 }
480 Err(RecvTimeoutError::Timeout) => {}
481 }
482 }
483 }
484}
485
486enum StdinCommand {
487 Write {
488 bytes: Vec<u8>,
489 flush: bool,
490 result: SyncSender<io::Result<()>>,
491 },
492 Close {
493 result: SyncSender<io::Result<()>>,
494 },
495}
496
497struct InterruptibleWriter {
498 commands: SyncSender<StdinCommand>,
499 abort: AbortController,
500}
501
502impl InterruptibleWriter {
503 fn spawn(mut stdin: ChildStdin, abort: AbortController) -> Result<Self, ()> {
504 let (commands, receiver) = mpsc::sync_channel(1);
505 thread::Builder::new()
506 .name("rustferry-ssh-session-stdin".to_owned())
507 .spawn(move || {
508 while let Ok(command) = receiver.recv() {
509 match command {
510 StdinCommand::Write {
511 bytes,
512 flush,
513 result,
514 } => {
515 let written = stdin
516 .write_all(&bytes)
517 .and_then(|()| if flush { stdin.flush() } else { Ok(()) });
518 let failed = written.is_err();
519 let _ = result.send(written);
520 if failed {
521 return;
522 }
523 }
524 StdinCommand::Close { result } => {
525 let flushed = stdin.flush();
526 drop(stdin);
527 let _ = result.send(flushed);
528 return;
529 }
530 }
531 }
532 })
533 .map(drop)
534 .map_err(|_| ())?;
535 Ok(Self { commands, abort })
536 }
537
538 fn request(&self, bytes: Vec<u8>, flush: bool) -> io::Result<()> {
539 self.abort.checkpoint()?;
540 let (sender, receiver) = mpsc::sync_channel(1);
541 self.send_command(StdinCommand::Write {
542 bytes,
543 flush,
544 result: sender,
545 })?;
546 self.wait_result(&receiver)
547 }
548
549 fn send_command(&self, mut command: StdinCommand) -> io::Result<()> {
550 loop {
551 self.abort.checkpoint()?;
552 match self.commands.try_send(command) {
553 Ok(()) => return Ok(()),
554 Err(TrySendError::Full(returned)) => command = returned,
555 Err(TrySendError::Disconnected(_)) => {
556 self.abort.mark(AbortReason::IoFailed);
557 return self.abort.checkpoint();
558 }
559 }
560 thread::sleep(self.abort.wait_interval());
561 }
562 }
563
564 fn wait_result(&self, receiver: &Receiver<io::Result<()>>) -> io::Result<()> {
565 loop {
566 self.abort.checkpoint()?;
567 match receiver.recv_timeout(self.abort.wait_interval()) {
568 Ok(Ok(())) => return Ok(()),
569 Ok(Err(_)) | Err(RecvTimeoutError::Disconnected) => {
570 self.abort.mark(AbortReason::IoFailed);
571 return self.abort.checkpoint();
572 }
573 Err(RecvTimeoutError::Timeout) => {}
574 }
575 }
576 }
577
578 fn best_effort_cancel(&self) -> bool {
579 let mut bytes = Vec::new();
580 if write_worker_data_plane_frame(&mut bytes, WorkerDataPlaneFrameKind::Cancel, 3, &[])
581 .is_err()
582 {
583 return false;
584 }
585 let deadline = Instant::now() + SESSION_CANCEL_WRITE_TIMEOUT;
586 let (sender, receiver) = mpsc::sync_channel(1);
587 let mut command = StdinCommand::Write {
588 bytes,
589 flush: true,
590 result: sender,
591 };
592 loop {
593 match self.commands.try_send(command) {
594 Ok(()) => break,
595 Err(TrySendError::Full(returned)) => command = returned,
596 Err(TrySendError::Disconnected(_)) => return false,
597 }
598 let remaining = deadline.saturating_duration_since(Instant::now());
599 if remaining.is_zero() {
600 return false;
601 }
602 thread::sleep(SESSION_IO_POLL_INTERVAL.min(remaining));
603 }
604 loop {
605 let remaining = deadline.saturating_duration_since(Instant::now());
606 if remaining.is_zero() {
607 return false;
608 }
609 match receiver.recv_timeout(SESSION_IO_POLL_INTERVAL.min(remaining)) {
610 Ok(Ok(())) => return true,
611 Ok(Err(_)) | Err(RecvTimeoutError::Disconnected) => return false,
612 Err(RecvTimeoutError::Timeout) => {}
613 }
614 }
615 }
616
617 fn close(self) -> io::Result<()> {
618 let (sender, receiver) = mpsc::sync_channel(1);
619 self.send_command(StdinCommand::Close { result: sender })?;
620 self.wait_result(&receiver)
621 }
622}
623
624impl Write for InterruptibleWriter {
625 fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
626 self.request(buffer.to_vec(), false)?;
627 Ok(buffer.len())
628 }
629
630 fn flush(&mut self) -> io::Result<()> {
631 self.request(Vec::new(), true)
632 }
633}