1use std::error::Error as _;
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11
12use serde::{Deserialize, Serialize};
13use tokio::sync::mpsc;
14use tokio::sync::Mutex as AsyncMutex;
15use tokio_stream::wrappers::ReceiverStream;
16use tonic::metadata::{AsciiMetadataKey, AsciiMetadataValue};
17use tonic::transport::Channel;
18use tonic::{Code, Request, Status};
19
20use crate::channels::ChannelCache;
21use crate::error::SailError;
22use crate::pb::workerproxy::v1 as pb;
23use pb::worker_proxy_service_client::WorkerProxyServiceClient;
24
25const FILE_CHANNEL_CAP: usize = 4;
28
29pub const FILE_WRITE_CHUNK_BYTES: usize = 1 << 20;
33
34pub(crate) const EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS: f64 = 0.2;
37pub(crate) const EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS: f64 = 2.0;
39pub(crate) const EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS: f64 = 10.0;
43
44pub(crate) const TRANSIENT_TRANSPORT_FRAGMENTS: &[&str] = &[
48 "endpoint closing",
49 "error reading server preface",
50 "connection reset",
51 "socket closed",
52 "transport is closing",
53 "h2 protocol error",
54 "keep-alive timed out",
55];
56
57pub(crate) fn is_transient_transport_message(message: &str) -> bool {
60 let details = message.to_lowercase();
61 TRANSIENT_TRANSPORT_FRAGMENTS
62 .iter()
63 .any(|fragment| details.contains(fragment))
64}
65
66#[doc(hidden)]
71#[derive(Debug, Clone)]
72pub struct WaitOutcome {
73 pub status: i32,
75 pub stdout: String,
77 pub stderr: String,
79 pub exit_code: i32,
81 pub timed_out: bool,
83 pub stdout_truncated: bool,
86 pub stderr_truncated: bool,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct Listener {
96 pub guest_port: u32,
98 pub protocol: crate::sailbox::types::ListenerProtocol,
100 pub route_status: crate::sailbox::types::ListenerRouteStatus,
102 #[serde(default)]
104 pub public_url: String,
105 #[serde(default)]
107 pub public_host: String,
108 #[serde(default)]
110 pub public_port: u32,
111}
112
113impl Listener {
114 pub fn endpoint(&self) -> Option<crate::sailbox::types::ListenerEndpoint> {
116 use crate::sailbox::types::ListenerEndpoint;
117 if !self.public_url.is_empty() {
118 return Some(ListenerEndpoint::Http {
119 url: self.public_url.clone(),
120 });
121 }
122 if !self.public_host.is_empty() && self.public_port != 0 {
123 return Some(ListenerEndpoint::Tcp {
124 host: self.public_host.clone(),
125 port: self.public_port,
126 });
127 }
128 None
129 }
130
131 pub fn is_active(&self) -> bool {
133 self.route_status == crate::sailbox::types::ListenerRouteStatus::Active
134 }
135}
136
137#[derive(Debug, Clone)]
142pub struct WriteOptions {
143 pub create_parents: bool,
145 pub mode: Option<u32>,
147}
148
149impl Default for WriteOptions {
150 fn default() -> WriteOptions {
152 WriteOptions {
153 create_parents: true,
154 mode: None,
155 }
156 }
157}
158
159pub struct FileReader {
164 rx: AsyncMutex<mpsc::Receiver<Result<Vec<u8>, SailError>>>,
165 abort: tokio::task::AbortHandle,
166}
167
168impl FileReader {
169 pub async fn next(&self) -> Option<Result<Vec<u8>, SailError>> {
172 self.rx.lock().await.recv().await
173 }
174
175 pub fn close(&self) {
179 self.abort.abort();
180 }
181}
182
183#[doc(hidden)]
187pub struct WorkerProxy {
188 channels: ChannelCache,
189 authorization: AsciiMetadataValue,
190}
191
192pub(crate) fn retry_deadline(retry_timeout: f64) -> Instant {
197 let now = Instant::now();
198 if retry_timeout <= 0.0 {
199 return now;
200 }
201 now + Duration::from_secs_f64(retry_timeout.min(RETRY_FOREVER_SECS))
206}
207
208const RETRY_FOREVER_SECS: f64 = 100.0 * 365.0 * 24.0 * 60.0 * 60.0;
211
212pub(crate) fn rpc_attempt_timeout(deadline: Instant) -> Duration {
218 (deadline.saturating_duration_since(Instant::now()) / 2)
219 .min(Duration::from_secs_f64(EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS))
220 .max(Duration::from_millis(1))
221}
222
223pub(crate) fn is_workerproxy_draining(status: &Status) -> bool {
224 status.code() == Code::Unavailable && status.message().to_lowercase().contains("draining")
225}
226
227pub(crate) fn should_invalidate_channel(status: &Status) -> bool {
236 is_workerproxy_draining(status)
237 || status.code() == Code::DeadlineExceeded
238 || status.source().is_some()
239}
240
241pub(crate) fn should_retry_transient_exec_rpc(status: &Status, deadline: Instant) -> bool {
242 if Instant::now() >= deadline {
243 return false;
244 }
245 match status.code() {
246 Code::Unavailable | Code::DeadlineExceeded => true,
247 Code::Cancelled => status.source().is_some(),
252 Code::Unknown | Code::Internal => {
262 status.source().is_some() || is_transient_transport_message(status.message())
263 }
264 _ => false,
265 }
266}
267
268pub(crate) async fn sleep_before_retry(delay: f64, deadline: Instant) -> f64 {
271 let mut sleep_for = delay.min(EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS);
272 let remaining = deadline
273 .saturating_duration_since(Instant::now())
274 .as_secs_f64();
275 if remaining <= 0.0 {
276 return delay;
277 }
278 sleep_for = sleep_for.min(remaining);
279 tokio::time::sleep(Duration::from_secs_f64(sleep_for.max(0.0))).await;
280 (delay * 2.0).min(EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS)
281}
282
283pub(crate) fn bearer_metadata(api_key: &str) -> Result<AsciiMetadataValue, SailError> {
287 format!("Bearer {api_key}")
288 .parse()
289 .map_err(|_| SailError::Config {
290 message: "SAIL_API_KEY contains characters invalid in a gRPC metadata value"
291 .to_string(),
292 })
293}
294
295impl WorkerProxy {
296 pub fn new(api_key: &str) -> Result<WorkerProxy, SailError> {
299 let authorization = bearer_metadata(api_key)?;
300 Ok(WorkerProxy {
301 channels: ChannelCache::new(),
302 authorization,
303 })
304 }
305
306 pub(crate) fn channels(&self) -> &ChannelCache {
307 &self.channels
308 }
309
310 pub(crate) fn client_for(
311 &self,
312 endpoint: &str,
313 ) -> Result<WorkerProxyServiceClient<Channel>, SailError> {
314 let channel = self.channels.get(endpoint)?;
315 Ok(WorkerProxyServiceClient::new(channel))
316 }
317
318 pub(crate) fn request_for<T>(
319 &self,
320 message: T,
321 extra_metadata: &[(String, String)],
322 timeout: Option<Duration>,
323 ) -> Result<Request<T>, SailError> {
324 let mut request = Request::new(message);
325 request
326 .metadata_mut()
327 .insert("authorization", self.authorization.clone());
328 for (key, value) in extra_metadata {
329 let key: AsciiMetadataKey = key.parse().map_err(|_| SailError::Config {
330 message: format!("invalid gRPC metadata key {key:?}"),
331 })?;
332 let value: AsciiMetadataValue = value.parse().map_err(|_| SailError::Config {
333 message: format!("invalid gRPC metadata value for key {key:?}"),
334 })?;
335 request.metadata_mut().insert(key, value);
336 }
337 if let Some(timeout) = timeout {
338 request.set_timeout(timeout);
339 }
340 Ok(request)
341 }
342
343 pub async fn wait_exec(
348 &self,
349 endpoint: &str,
350 sailbox_id: &str,
351 exec_request_id: &str,
352 retry_timeout: f64,
353 ) -> Result<WaitOutcome, SailError> {
354 let mut deadline: Option<Instant> = None;
355 let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
356 loop {
357 let message = pb::WaitSailboxExecRequest {
358 sailbox_id: sailbox_id.to_string(),
359 exec_request_id: exec_request_id.to_string(),
360 };
361 let request = self.request_for(message, &[], None)?;
362 match self.client_for(endpoint)?.wait_sailbox_exec(request).await {
363 Ok(resp) => {
364 let resp = resp.into_inner();
365 return Ok(WaitOutcome {
366 status: resp.status,
367 stdout: resp.stdout,
368 stderr: resp.stderr,
369 exit_code: resp.return_code,
370 timed_out: resp.timed_out,
371 stdout_truncated: resp.stdout_truncated,
372 stderr_truncated: resp.stderr_truncated,
373 });
374 }
375 Err(status) => {
376 let deadline = *deadline.get_or_insert_with(|| retry_deadline(retry_timeout));
377 if !should_retry_transient_exec_rpc(&status, deadline) {
378 return Err(SailError::from_exec_status(&status));
379 }
380 tracing::warn!(code = ?status.code(), endpoint, "retrying transient worker-proxy RPC");
381 if should_invalidate_channel(&status) {
382 self.channels.invalidate(endpoint);
383 }
384 delay = sleep_before_retry(delay, deadline).await;
385 }
386 }
387 }
388 }
389
390 pub async fn cancel_exec(
395 &self,
396 endpoint: &str,
397 sailbox_id: &str,
398 exec_request_id: &str,
399 force: bool,
400 retry_timeout: f64,
401 ) -> Result<(), SailError> {
402 let deadline = retry_deadline(retry_timeout);
403 let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
404 loop {
405 let per_attempt_timeout = if retry_timeout > 0.0 {
406 Some(rpc_attempt_timeout(deadline))
411 } else {
412 None
413 };
414 let message = pb::CancelSailboxExecRequest {
415 sailbox_id: sailbox_id.to_string(),
416 exec_request_id: exec_request_id.to_string(),
417 force,
418 };
419 let request = self.request_for(message, &[], per_attempt_timeout)?;
420 match self
421 .client_for(endpoint)?
422 .cancel_sailbox_exec(request)
423 .await
424 {
425 Ok(_) => return Ok(()),
426 Err(status) => {
427 if !should_retry_transient_exec_rpc(&status, deadline) {
428 return Err(SailError::from_exec_status(&status));
429 }
430 tracing::warn!(code = ?status.code(), endpoint, "retrying transient worker-proxy RPC");
431 if should_invalidate_channel(&status) {
432 self.channels.invalidate(endpoint);
433 }
434 delay = sleep_before_retry(delay, deadline).await;
435 }
436 }
437 }
438 }
439
440 pub fn read_file(self: &Arc<Self>, endpoint: &str, sailbox_id: &str, path: &str) -> FileReader {
449 let (tx, rx) = mpsc::channel(FILE_CHANNEL_CAP);
450 let worker = Arc::clone(self);
451 let endpoint = endpoint.to_string();
452 let message = pb::ReadSailboxFileRequest {
453 sailbox_id: sailbox_id.to_string(),
454 path: path.to_string(),
455 };
456 let task = tokio::spawn(async move {
457 let request = match worker.request_for(message, &[], None) {
458 Ok(request) => request,
459 Err(err) => {
460 let _ = tx.send(Err(err)).await;
461 return;
462 }
463 };
464 let mut client = match worker.client_for(&endpoint) {
465 Ok(client) => client,
466 Err(err) => {
467 let _ = tx.send(Err(err)).await;
468 return;
469 }
470 };
471 let mut stream = match client.read_sailbox_file(request).await {
472 Ok(resp) => resp.into_inner(),
473 Err(status) => {
474 let _ = tx.send(Err(SailError::from_file_rpc_status(&status))).await;
475 return;
476 }
477 };
478 loop {
479 match stream.message().await {
480 Ok(Some(resp)) => {
481 if !resp.data.is_empty() && tx.send(Ok(resp.data)).await.is_err() {
482 return; }
484 }
485 Ok(None) => return,
486 Err(status) => {
487 let _ = tx.send(Err(SailError::from_file_rpc_status(&status))).await;
488 return;
489 }
490 }
491 }
492 });
493 FileReader {
494 rx: AsyncMutex::new(rx),
495 abort: task.abort_handle(),
496 }
497 }
498
499 pub fn write_file(
509 self: &Arc<Self>,
510 endpoint: &str,
511 sailbox_id: &str,
512 path: &str,
513 create_parents: bool,
514 mode: Option<u32>,
515 ) -> FileWriter {
516 let (tx, rx) = mpsc::channel(FILE_CHANNEL_CAP);
517 let worker = Arc::clone(self);
518 let endpoint = endpoint.to_string();
519 let task = tokio::spawn(async move {
522 let request =
523 worker.request_for(ReceiverStream::new(rx), &[], None)?;
524 worker
525 .client_for(&endpoint)?
526 .write_sailbox_file(request)
527 .await
528 .map(|_| ())
529 .map_err(|status| SailError::from_file_rpc_status(&status))
530 });
531 FileWriter {
532 tx: Some(tx),
533 task: Some(task),
534 aborted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
535 first: true,
536 sailbox_id: sailbox_id.to_string(),
537 path: path.to_string(),
538 create_parents,
539 mode,
540 }
541 }
542}
543
544pub struct FileWriter {
552 tx: Option<mpsc::Sender<pb::WriteSailboxFileRequest>>,
553 task: Option<tokio::task::JoinHandle<Result<(), SailError>>>,
554 aborted: Arc<std::sync::atomic::AtomicBool>,
555 first: bool,
556 sailbox_id: String,
557 path: String,
558 create_parents: bool,
559 mode: Option<u32>,
560}
561
562impl FileWriter {
563 fn build(&mut self, data: Vec<u8>) -> pb::WriteSailboxFileRequest {
564 let header = self.first;
565 self.first = false;
566 pb::WriteSailboxFileRequest {
567 sailbox_id: if header {
568 self.sailbox_id.clone()
569 } else {
570 String::new()
571 },
572 path: if header {
573 self.path.clone()
574 } else {
575 String::new()
576 },
577 data,
578 create_parents: header && self.create_parents,
579 mode: if header { self.mode } else { None },
580 }
581 }
582
583 async fn join(&mut self) -> Result<(), SailError> {
585 self.tx = None; match self.task.take() {
587 Some(task) => task.await.unwrap_or_else(|join_err| {
588 Err(SailError::Internal {
589 message: format!("file write task failed: {join_err}"),
590 })
591 }),
592 None => Ok(()),
593 }
594 }
595
596 pub async fn write(&mut self, data: &[u8]) -> Result<(), SailError> {
600 for chunk in data.chunks(FILE_WRITE_CHUNK_BYTES) {
601 self.write_chunk(chunk.to_vec()).await?;
602 }
603 Ok(())
604 }
605
606 pub async fn write_chunk(&mut self, data: Vec<u8>) -> Result<(), SailError> {
610 if self.aborted.load(std::sync::atomic::Ordering::Relaxed) {
611 return Err(aborted_write());
612 }
613 let request = self.build(data);
614 match &self.tx {
615 Some(tx) if tx.send(request).await.is_ok() => Ok(()),
617 _ => self.join().await,
618 }
619 }
620
621 pub async fn finish(&mut self) -> Result<(), SailError> {
624 if self.aborted.load(std::sync::atomic::Ordering::Relaxed) {
625 return Err(aborted_write());
626 }
627 if self.first {
628 let request = self.build(Vec::new());
631 if let Some(tx) = &self.tx {
632 let _ = tx.send(request).await;
633 }
634 }
635 self.join().await
636 }
637
638 pub fn abort(&mut self) {
642 if let Some(task) = self.task.take() {
646 self.aborted
647 .store(true, std::sync::atomic::Ordering::Relaxed);
648 task.abort();
649 }
650 self.tx = None;
651 }
652
653 pub fn abort_handle(&self) -> WriteAbortHandle {
657 WriteAbortHandle {
658 aborted: Arc::clone(&self.aborted),
659 task: self
660 .task
661 .as_ref()
662 .map(tokio::task::JoinHandle::abort_handle),
663 }
664 }
665}
666
667#[derive(Clone)]
671pub struct WriteAbortHandle {
672 aborted: Arc<std::sync::atomic::AtomicBool>,
673 task: Option<tokio::task::AbortHandle>,
674}
675
676impl WriteAbortHandle {
677 pub fn abort(&self) {
680 self.aborted
681 .store(true, std::sync::atomic::Ordering::Relaxed);
682 if let Some(task) = &self.task {
683 task.abort();
684 }
685 }
686}
687
688fn aborted_write() -> SailError {
689 SailError::InvalidArgument {
690 message: "the write was aborted; nothing was committed".to_string(),
691 }
692}
693
694impl Drop for FileWriter {
695 fn drop(&mut self) {
696 self.abort();
698 }
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704
705 #[tokio::test]
706 async fn write_splits_at_the_transport_chunk_size() {
707 let (tx, mut rx) = mpsc::channel(16);
708 let mut writer = FileWriter {
709 tx: Some(tx),
710 task: Some(tokio::spawn(async { Ok(()) })),
711 aborted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
712 first: true,
713 sailbox_id: "sb_1".to_string(),
714 path: "/f".to_string(),
715 create_parents: true,
716 mode: None,
717 };
718 writer
719 .write(&vec![7u8; FILE_WRITE_CHUNK_BYTES * 2 + 10])
720 .await
721 .expect("write succeeds");
722 writer.finish().await.expect("finish succeeds");
723 let mut sizes = Vec::new();
724 while let Some(message) = rx.recv().await {
725 sizes.push(message.data.len());
726 }
727 assert_eq!(
728 sizes,
729 vec![FILE_WRITE_CHUNK_BYTES, FILE_WRITE_CHUNK_BYTES, 10]
730 );
731 }
732
733 #[test]
734 fn transient_codes_retry_within_deadline() {
735 let deadline = Instant::now() + Duration::from_secs(5);
736 assert!(should_retry_transient_exec_rpc(
737 &Status::unavailable("x"),
738 deadline
739 ));
740 assert!(should_retry_transient_exec_rpc(
741 &Status::deadline_exceeded("x"),
742 deadline
743 ));
744 assert!(should_retry_transient_exec_rpc(
745 &Status::unknown("HTTP/2 connection reset by remote"),
746 deadline
747 ));
748 assert!(!should_retry_transient_exec_rpc(
749 &Status::unknown("guest exploded"),
750 deadline
751 ));
752 assert!(!should_retry_transient_exec_rpc(
753 &Status::not_found("x"),
754 deadline
755 ));
756 }
757
758 #[test]
759 fn h2_connection_failures_retry_within_deadline() {
760 let deadline = Instant::now() + Duration::from_secs(5);
761 let io = std::io::Error::new(
764 std::io::ErrorKind::ConnectionAborted,
765 "connection error: h2 protocol error: http2 error",
766 );
767 let transport = Status::from_error(Box::new(io));
768 assert!(transport.source().is_some());
769 assert!(should_retry_transient_exec_rpc(&transport, deadline));
770 assert!(should_retry_transient_exec_rpc(
773 &Status::internal("h2 protocol error: http2 error"),
774 deadline
775 ));
776 assert!(should_retry_transient_exec_rpc(
777 &Status::unknown("connection error: keep-alive timed out"),
778 deadline
779 ));
780 assert!(!should_retry_transient_exec_rpc(
783 &Status::internal("guest agent panicked"),
784 deadline
785 ));
786 }
787
788 #[test]
789 fn fired_attempt_timeout_retries_but_server_cancel_does_not() {
790 let deadline = Instant::now() + Duration::from_secs(5);
791 let timed_out = Status::from_error(Box::new(tonic::TimeoutExpired(())));
794 assert_eq!(timed_out.code(), Code::Cancelled);
795 assert!(should_retry_transient_exec_rpc(&timed_out, deadline));
796 assert!(!should_retry_transient_exec_rpc(
798 &Status::cancelled("client went away"),
799 deadline
800 ));
801 }
802
803 #[test]
804 fn expired_deadline_never_retries() {
805 let deadline = Instant::now();
806 assert!(!should_retry_transient_exec_rpc(
807 &Status::unavailable("x"),
808 deadline
809 ));
810 }
811
812 #[test]
813 fn draining_detection_is_case_insensitive_and_code_scoped() {
814 assert!(is_workerproxy_draining(&Status::unavailable(
815 "workerproxy DRAINING for deploy"
816 )));
817 assert!(!is_workerproxy_draining(&Status::internal("draining")));
818 assert!(!is_workerproxy_draining(&Status::unavailable("lameduck")));
819 }
820
821 #[test]
822 fn rpc_attempt_timeout_caps_and_leaves_retry_headroom() {
823 let now = Instant::now();
824 let far = rpc_attempt_timeout(now + Duration::from_mins(1));
826 assert!(far <= Duration::from_secs_f64(EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS));
827 assert!(far > Duration::from_secs(5));
828 let small = rpc_attempt_timeout(now + Duration::from_secs(5));
831 assert!(small < Duration::from_secs(5));
832 assert!(small <= Duration::from_secs(3));
833 assert_eq!(
835 rpc_attempt_timeout(now.checked_sub(Duration::from_secs(1)).unwrap()),
836 Duration::from_millis(1)
837 );
838 }
839
840 #[test]
841 fn invalidate_on_draining_or_transport_failure_only() {
842 assert!(should_invalidate_channel(&Status::unavailable(
844 "workerproxy is draining"
845 )));
846 let io = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "socket closed");
849 assert!(should_invalidate_channel(&Status::from_error(Box::new(io))));
850 assert!(!should_invalidate_channel(&Status::unavailable(
852 "try again"
853 )));
854 }
855}