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 _ => status.source().is_some(),
271 }
272}
273
274pub(crate) async fn sleep_before_retry(delay: f64, deadline: Instant) -> f64 {
277 let mut sleep_for = delay.min(EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS);
278 let remaining = deadline
279 .saturating_duration_since(Instant::now())
280 .as_secs_f64();
281 if remaining <= 0.0 {
282 return delay;
283 }
284 sleep_for = sleep_for.min(remaining);
285 tokio::time::sleep(Duration::from_secs_f64(sleep_for.max(0.0))).await;
286 (delay * 2.0).min(EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS)
287}
288
289pub(crate) fn bearer_metadata(api_key: &str) -> Result<AsciiMetadataValue, SailError> {
293 format!("Bearer {api_key}")
294 .parse()
295 .map_err(|_| SailError::Config {
296 message: "SAIL_API_KEY contains characters invalid in a gRPC metadata value"
297 .to_string(),
298 })
299}
300
301impl WorkerProxy {
302 pub fn new(api_key: &str) -> Result<WorkerProxy, SailError> {
305 let authorization = bearer_metadata(api_key)?;
306 Ok(WorkerProxy {
307 channels: ChannelCache::new(),
308 authorization,
309 })
310 }
311
312 pub(crate) fn channels(&self) -> &ChannelCache {
313 &self.channels
314 }
315
316 pub(crate) fn client_for(
317 &self,
318 endpoint: &str,
319 ) -> Result<WorkerProxyServiceClient<Channel>, SailError> {
320 let channel = self.channels.get(endpoint)?;
321 Ok(WorkerProxyServiceClient::new(channel))
322 }
323
324 pub(crate) fn request_for<T>(
325 &self,
326 message: T,
327 extra_metadata: &[(String, String)],
328 timeout: Option<Duration>,
329 ) -> Result<Request<T>, SailError> {
330 let mut request = Request::new(message);
331 request
332 .metadata_mut()
333 .insert("authorization", self.authorization.clone());
334 for (key, value) in extra_metadata {
335 let key: AsciiMetadataKey = key.parse().map_err(|_| SailError::Config {
336 message: format!("invalid gRPC metadata key {key:?}"),
337 })?;
338 let value: AsciiMetadataValue = value.parse().map_err(|_| SailError::Config {
339 message: format!("invalid gRPC metadata value for key {key:?}"),
340 })?;
341 request.metadata_mut().insert(key, value);
342 }
343 if let Some(timeout) = timeout {
344 request.set_timeout(timeout);
345 }
346 Ok(request)
347 }
348
349 pub async fn wait_exec(
354 &self,
355 endpoint: &str,
356 sailbox_id: &str,
357 exec_request_id: &str,
358 retry_timeout: f64,
359 ) -> Result<WaitOutcome, SailError> {
360 let mut deadline: Option<Instant> = None;
361 let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
362 loop {
363 let message = pb::WaitSailboxExecRequest {
364 sailbox_id: sailbox_id.to_string(),
365 exec_request_id: exec_request_id.to_string(),
366 };
367 let request = self.request_for(message, &[], None)?;
368 match self.client_for(endpoint)?.wait_sailbox_exec(request).await {
369 Ok(resp) => {
370 let resp = resp.into_inner();
371 return Ok(WaitOutcome {
372 status: resp.status,
373 stdout: resp.stdout,
374 stderr: resp.stderr,
375 exit_code: resp.return_code,
376 timed_out: resp.timed_out,
377 stdout_truncated: resp.stdout_truncated,
378 stderr_truncated: resp.stderr_truncated,
379 });
380 }
381 Err(status) => {
382 let deadline = *deadline.get_or_insert_with(|| retry_deadline(retry_timeout));
383 if !should_retry_transient_exec_rpc(&status, deadline) {
384 return Err(SailError::from_exec_status(&status));
385 }
386 tracing::warn!(code = ?status.code(), endpoint, "retrying transient worker-proxy RPC");
387 if should_invalidate_channel(&status) {
388 self.channels.invalidate(endpoint);
389 }
390 delay = sleep_before_retry(delay, deadline).await;
391 }
392 }
393 }
394 }
395
396 pub async fn cancel_exec(
401 &self,
402 endpoint: &str,
403 sailbox_id: &str,
404 exec_request_id: &str,
405 force: bool,
406 retry_timeout: f64,
407 ) -> Result<(), SailError> {
408 let deadline = retry_deadline(retry_timeout);
409 let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
410 loop {
411 let per_attempt_timeout = if retry_timeout > 0.0 {
412 Some(rpc_attempt_timeout(deadline))
417 } else {
418 None
419 };
420 let message = pb::CancelSailboxExecRequest {
421 sailbox_id: sailbox_id.to_string(),
422 exec_request_id: exec_request_id.to_string(),
423 force,
424 };
425 let request = self.request_for(message, &[], per_attempt_timeout)?;
426 match self
427 .client_for(endpoint)?
428 .cancel_sailbox_exec(request)
429 .await
430 {
431 Ok(_) => return Ok(()),
432 Err(status) => {
433 if !should_retry_transient_exec_rpc(&status, deadline) {
434 return Err(SailError::from_exec_status(&status));
435 }
436 tracing::warn!(code = ?status.code(), endpoint, "retrying transient worker-proxy RPC");
437 if should_invalidate_channel(&status) {
438 self.channels.invalidate(endpoint);
439 }
440 delay = sleep_before_retry(delay, deadline).await;
441 }
442 }
443 }
444 }
445
446 pub fn read_file(self: &Arc<Self>, endpoint: &str, sailbox_id: &str, path: &str) -> FileReader {
455 let (tx, rx) = mpsc::channel(FILE_CHANNEL_CAP);
456 let worker = Arc::clone(self);
457 let endpoint = endpoint.to_string();
458 let message = pb::ReadSailboxFileRequest {
459 sailbox_id: sailbox_id.to_string(),
460 path: path.to_string(),
461 };
462 let task = tokio::spawn(async move {
463 let request = match worker.request_for(message, &[], None) {
464 Ok(request) => request,
465 Err(err) => {
466 let _ = tx.send(Err(err)).await;
467 return;
468 }
469 };
470 let mut client = match worker.client_for(&endpoint) {
471 Ok(client) => client,
472 Err(err) => {
473 let _ = tx.send(Err(err)).await;
474 return;
475 }
476 };
477 let mut stream = match client.read_sailbox_file(request).await {
478 Ok(resp) => resp.into_inner(),
479 Err(status) => {
480 let _ = tx.send(Err(SailError::from_file_rpc_status(&status))).await;
481 return;
482 }
483 };
484 loop {
485 match stream.message().await {
486 Ok(Some(resp)) => {
487 if !resp.data.is_empty() && tx.send(Ok(resp.data)).await.is_err() {
488 return; }
490 }
491 Ok(None) => return,
492 Err(status) => {
493 let _ = tx.send(Err(SailError::from_file_rpc_status(&status))).await;
494 return;
495 }
496 }
497 }
498 });
499 FileReader {
500 rx: AsyncMutex::new(rx),
501 abort: task.abort_handle(),
502 }
503 }
504
505 pub fn write_file(
515 self: &Arc<Self>,
516 endpoint: &str,
517 sailbox_id: &str,
518 path: &str,
519 create_parents: bool,
520 mode: Option<u32>,
521 ) -> FileWriter {
522 let (tx, rx) = mpsc::channel(FILE_CHANNEL_CAP);
523 let worker = Arc::clone(self);
524 let endpoint = endpoint.to_string();
525 let task = tokio::spawn(async move {
528 let request =
529 worker.request_for(ReceiverStream::new(rx), &[], None)?;
530 worker
531 .client_for(&endpoint)?
532 .write_sailbox_file(request)
533 .await
534 .map(|_| ())
535 .map_err(|status| SailError::from_file_rpc_status(&status))
536 });
537 FileWriter {
538 tx: Some(tx),
539 task: Some(task),
540 aborted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
541 first: true,
542 sailbox_id: sailbox_id.to_string(),
543 path: path.to_string(),
544 create_parents,
545 mode,
546 }
547 }
548}
549
550pub struct FileWriter {
558 tx: Option<mpsc::Sender<pb::WriteSailboxFileRequest>>,
559 task: Option<tokio::task::JoinHandle<Result<(), SailError>>>,
560 aborted: Arc<std::sync::atomic::AtomicBool>,
561 first: bool,
562 sailbox_id: String,
563 path: String,
564 create_parents: bool,
565 mode: Option<u32>,
566}
567
568impl FileWriter {
569 fn build(&mut self, data: Vec<u8>) -> pb::WriteSailboxFileRequest {
570 let header = self.first;
571 self.first = false;
572 pb::WriteSailboxFileRequest {
573 sailbox_id: if header {
574 self.sailbox_id.clone()
575 } else {
576 String::new()
577 },
578 path: if header {
579 self.path.clone()
580 } else {
581 String::new()
582 },
583 data,
584 create_parents: header && self.create_parents,
585 mode: if header { self.mode } else { None },
586 }
587 }
588
589 async fn join(&mut self) -> Result<(), SailError> {
591 self.tx = None; match self.task.take() {
593 Some(task) => task.await.unwrap_or_else(|join_err| {
594 Err(SailError::Internal {
595 message: format!("file write task failed: {join_err}"),
596 })
597 }),
598 None => Ok(()),
599 }
600 }
601
602 pub async fn write(&mut self, data: &[u8]) -> Result<(), SailError> {
606 for chunk in data.chunks(FILE_WRITE_CHUNK_BYTES) {
607 self.write_chunk(chunk.to_vec()).await?;
608 }
609 Ok(())
610 }
611
612 pub async fn write_chunk(&mut self, data: Vec<u8>) -> Result<(), SailError> {
616 if self.aborted.load(std::sync::atomic::Ordering::Relaxed) {
617 return Err(aborted_write());
618 }
619 let request = self.build(data);
620 match &self.tx {
621 Some(tx) if tx.send(request).await.is_ok() => Ok(()),
623 _ => self.join().await,
624 }
625 }
626
627 pub async fn finish(&mut self) -> Result<(), SailError> {
630 if self.aborted.load(std::sync::atomic::Ordering::Relaxed) {
631 return Err(aborted_write());
632 }
633 if self.first {
634 let request = self.build(Vec::new());
637 if let Some(tx) = &self.tx {
638 let _ = tx.send(request).await;
639 }
640 }
641 self.join().await
642 }
643
644 pub fn abort(&mut self) {
648 if let Some(task) = self.task.take() {
652 self.aborted
653 .store(true, std::sync::atomic::Ordering::Relaxed);
654 task.abort();
655 }
656 self.tx = None;
657 }
658
659 pub fn abort_handle(&self) -> WriteAbortHandle {
663 WriteAbortHandle {
664 aborted: Arc::clone(&self.aborted),
665 task: self
666 .task
667 .as_ref()
668 .map(tokio::task::JoinHandle::abort_handle),
669 }
670 }
671}
672
673#[derive(Clone)]
677pub struct WriteAbortHandle {
678 aborted: Arc<std::sync::atomic::AtomicBool>,
679 task: Option<tokio::task::AbortHandle>,
680}
681
682impl WriteAbortHandle {
683 pub fn abort(&self) {
686 self.aborted
687 .store(true, std::sync::atomic::Ordering::Relaxed);
688 if let Some(task) = &self.task {
689 task.abort();
690 }
691 }
692}
693
694fn aborted_write() -> SailError {
695 SailError::InvalidArgument {
696 message: "the write was aborted; nothing was committed".to_string(),
697 }
698}
699
700impl Drop for FileWriter {
701 fn drop(&mut self) {
702 self.abort();
704 }
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710
711 #[tokio::test]
712 async fn write_splits_at_the_transport_chunk_size() {
713 let (tx, mut rx) = mpsc::channel(16);
714 let mut writer = FileWriter {
715 tx: Some(tx),
716 task: Some(tokio::spawn(async { Ok(()) })),
717 aborted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
718 first: true,
719 sailbox_id: "sb_1".to_string(),
720 path: "/f".to_string(),
721 create_parents: true,
722 mode: None,
723 };
724 writer
725 .write(&vec![7u8; FILE_WRITE_CHUNK_BYTES * 2 + 10])
726 .await
727 .expect("write succeeds");
728 writer.finish().await.expect("finish succeeds");
729 let mut sizes = Vec::new();
730 while let Some(message) = rx.recv().await {
731 sizes.push(message.data.len());
732 }
733 assert_eq!(
734 sizes,
735 vec![FILE_WRITE_CHUNK_BYTES, FILE_WRITE_CHUNK_BYTES, 10]
736 );
737 }
738
739 #[test]
740 fn transient_codes_retry_within_deadline() {
741 let deadline = Instant::now() + Duration::from_secs(5);
742 assert!(should_retry_transient_exec_rpc(
743 &Status::unavailable("x"),
744 deadline
745 ));
746 assert!(should_retry_transient_exec_rpc(
747 &Status::deadline_exceeded("x"),
748 deadline
749 ));
750 assert!(should_retry_transient_exec_rpc(
751 &Status::unknown("HTTP/2 connection reset by remote"),
752 deadline
753 ));
754 assert!(!should_retry_transient_exec_rpc(
755 &Status::unknown("guest exploded"),
756 deadline
757 ));
758 assert!(!should_retry_transient_exec_rpc(
759 &Status::not_found("x"),
760 deadline
761 ));
762 }
763
764 #[test]
765 fn h2_connection_failures_retry_within_deadline() {
766 let deadline = Instant::now() + Duration::from_secs(5);
767 let io = std::io::Error::new(
770 std::io::ErrorKind::ConnectionAborted,
771 "connection error: h2 protocol error: http2 error",
772 );
773 let transport = Status::from_error(Box::new(io));
774 assert!(transport.source().is_some());
775 assert!(should_retry_transient_exec_rpc(&transport, deadline));
776 assert!(should_retry_transient_exec_rpc(
779 &Status::internal("h2 protocol error: http2 error"),
780 deadline
781 ));
782 assert!(should_retry_transient_exec_rpc(
783 &Status::unknown("connection error: keep-alive timed out"),
784 deadline
785 ));
786 assert!(!should_retry_transient_exec_rpc(
789 &Status::internal("guest agent panicked"),
790 deadline
791 ));
792 }
793
794 #[test]
795 fn goaway_reason_statuses_retry_only_with_transport_source() {
796 let deadline = Instant::now() + Duration::from_secs(5);
797 let mut goaway = Status::new(Code::ResourceExhausted, "h2 protocol error: http2 error");
804 goaway.set_source(std::sync::Arc::new(std::io::Error::new(
805 std::io::ErrorKind::ConnectionReset,
806 "transport error",
807 )));
808 assert!(should_retry_transient_exec_rpc(&goaway, deadline));
809 assert!(!should_retry_transient_exec_rpc(
812 &Status::resource_exhausted("org concurrency limit reached"),
813 deadline
814 ));
815 let mut sec = Status::new(Code::PermissionDenied, "h2 protocol error: http2 error");
818 sec.set_source(std::sync::Arc::new(std::io::Error::new(
819 std::io::ErrorKind::ConnectionReset,
820 "transport error",
821 )));
822 assert!(should_retry_transient_exec_rpc(&sec, deadline));
823 assert!(!should_retry_transient_exec_rpc(
824 &Status::permission_denied("invalid API key"),
825 deadline
826 ));
827 }
828
829 #[test]
830 fn fired_attempt_timeout_retries_but_server_cancel_does_not() {
831 let deadline = Instant::now() + Duration::from_secs(5);
832 let timed_out = Status::from_error(Box::new(tonic::TimeoutExpired(())));
835 assert_eq!(timed_out.code(), Code::Cancelled);
836 assert!(should_retry_transient_exec_rpc(&timed_out, deadline));
837 assert!(!should_retry_transient_exec_rpc(
839 &Status::cancelled("client went away"),
840 deadline
841 ));
842 }
843
844 #[test]
845 fn expired_deadline_never_retries() {
846 let deadline = Instant::now();
847 assert!(!should_retry_transient_exec_rpc(
848 &Status::unavailable("x"),
849 deadline
850 ));
851 }
852
853 #[test]
854 fn draining_detection_is_case_insensitive_and_code_scoped() {
855 assert!(is_workerproxy_draining(&Status::unavailable(
856 "workerproxy DRAINING for deploy"
857 )));
858 assert!(!is_workerproxy_draining(&Status::internal("draining")));
859 assert!(!is_workerproxy_draining(&Status::unavailable("lameduck")));
860 }
861
862 #[test]
863 fn rpc_attempt_timeout_caps_and_leaves_retry_headroom() {
864 let now = Instant::now();
865 let far = rpc_attempt_timeout(now + Duration::from_mins(1));
867 assert!(far <= Duration::from_secs_f64(EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS));
868 assert!(far > Duration::from_secs(5));
869 let small = rpc_attempt_timeout(now + Duration::from_secs(5));
872 assert!(small < Duration::from_secs(5));
873 assert!(small <= Duration::from_secs(3));
874 assert_eq!(
876 rpc_attempt_timeout(now.checked_sub(Duration::from_secs(1)).unwrap()),
877 Duration::from_millis(1)
878 );
879 }
880
881 #[test]
882 fn invalidate_on_draining_or_transport_failure_only() {
883 assert!(should_invalidate_channel(&Status::unavailable(
885 "workerproxy is draining"
886 )));
887 let io = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "socket closed");
890 assert!(should_invalidate_channel(&Status::from_error(Box::new(io))));
891 assert!(!should_invalidate_channel(&Status::unavailable(
893 "try again"
894 )));
895 }
896}