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];
54
55pub(crate) fn is_transient_transport_message(message: &str) -> bool {
58 let details = message.to_lowercase();
59 TRANSIENT_TRANSPORT_FRAGMENTS
60 .iter()
61 .any(|fragment| details.contains(fragment))
62}
63
64#[doc(hidden)]
69#[derive(Debug, Clone)]
70pub struct WaitOutcome {
71 pub status: i32,
73 pub stdout: String,
75 pub stderr: String,
77 pub exit_code: i32,
79 pub timed_out: bool,
81 pub stdout_truncated: bool,
84 pub stderr_truncated: bool,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct Listener {
94 pub guest_port: u32,
96 pub protocol: crate::sailbox::types::ListenerProtocol,
98 pub route_status: crate::sailbox::types::ListenerRouteStatus,
100 #[serde(default)]
102 pub public_url: String,
103 #[serde(default)]
105 pub public_host: String,
106 #[serde(default)]
108 pub public_port: u32,
109}
110
111impl Listener {
112 pub fn endpoint(&self) -> Option<crate::sailbox::types::ListenerEndpoint> {
114 use crate::sailbox::types::ListenerEndpoint;
115 if !self.public_url.is_empty() {
116 return Some(ListenerEndpoint::Http {
117 url: self.public_url.clone(),
118 });
119 }
120 if !self.public_host.is_empty() && self.public_port != 0 {
121 return Some(ListenerEndpoint::Tcp {
122 host: self.public_host.clone(),
123 port: self.public_port,
124 });
125 }
126 None
127 }
128
129 pub fn is_active(&self) -> bool {
131 self.route_status == crate::sailbox::types::ListenerRouteStatus::Active
132 }
133}
134
135#[derive(Debug, Clone)]
140pub struct WriteOptions {
141 pub create_parents: bool,
143 pub mode: Option<u32>,
145}
146
147impl Default for WriteOptions {
148 fn default() -> WriteOptions {
150 WriteOptions {
151 create_parents: true,
152 mode: None,
153 }
154 }
155}
156
157pub struct FileReader {
162 rx: AsyncMutex<mpsc::Receiver<Result<Vec<u8>, SailError>>>,
163 abort: tokio::task::AbortHandle,
164}
165
166impl FileReader {
167 pub async fn next(&self) -> Option<Result<Vec<u8>, SailError>> {
170 self.rx.lock().await.recv().await
171 }
172
173 pub fn close(&self) {
177 self.abort.abort();
178 }
179}
180
181#[doc(hidden)]
185pub struct WorkerProxy {
186 channels: ChannelCache,
187 authorization: AsciiMetadataValue,
188}
189
190pub(crate) fn retry_deadline(retry_timeout: f64) -> Instant {
195 let now = Instant::now();
196 if retry_timeout <= 0.0 {
197 return now;
198 }
199 now + Duration::from_secs_f64(retry_timeout.min(RETRY_FOREVER_SECS))
204}
205
206const RETRY_FOREVER_SECS: f64 = 100.0 * 365.0 * 24.0 * 60.0 * 60.0;
209
210pub(crate) fn rpc_attempt_timeout(deadline: Instant) -> Duration {
216 (deadline.saturating_duration_since(Instant::now()) / 2)
217 .min(Duration::from_secs_f64(EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS))
218 .max(Duration::from_millis(1))
219}
220
221pub(crate) fn is_workerproxy_draining(status: &Status) -> bool {
222 status.code() == Code::Unavailable && status.message().to_lowercase().contains("draining")
223}
224
225pub(crate) fn should_invalidate_channel(status: &Status) -> bool {
234 is_workerproxy_draining(status)
235 || status.code() == Code::DeadlineExceeded
236 || status.source().is_some()
237}
238
239pub(crate) fn should_retry_transient_exec_rpc(status: &Status, deadline: Instant) -> bool {
240 if Instant::now() >= deadline {
241 return false;
242 }
243 match status.code() {
244 Code::Unavailable | Code::DeadlineExceeded => true,
245 Code::Cancelled => status.source().is_some(),
250 Code::Unknown => is_transient_transport_message(status.message()),
251 _ => false,
252 }
253}
254
255pub(crate) async fn sleep_before_retry(delay: f64, deadline: Instant) -> f64 {
258 let mut sleep_for = delay.min(EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS);
259 let remaining = deadline
260 .saturating_duration_since(Instant::now())
261 .as_secs_f64();
262 if remaining <= 0.0 {
263 return delay;
264 }
265 sleep_for = sleep_for.min(remaining);
266 tokio::time::sleep(Duration::from_secs_f64(sleep_for.max(0.0))).await;
267 (delay * 2.0).min(EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS)
268}
269
270pub(crate) fn bearer_metadata(api_key: &str) -> Result<AsciiMetadataValue, SailError> {
274 format!("Bearer {api_key}")
275 .parse()
276 .map_err(|_| SailError::Config {
277 message: "SAIL_API_KEY contains characters invalid in a gRPC metadata value"
278 .to_string(),
279 })
280}
281
282impl WorkerProxy {
283 pub fn new(api_key: &str) -> Result<WorkerProxy, SailError> {
286 let authorization = bearer_metadata(api_key)?;
287 Ok(WorkerProxy {
288 channels: ChannelCache::new(),
289 authorization,
290 })
291 }
292
293 pub(crate) fn channels(&self) -> &ChannelCache {
294 &self.channels
295 }
296
297 pub(crate) fn client_for(
298 &self,
299 endpoint: &str,
300 ) -> Result<WorkerProxyServiceClient<Channel>, SailError> {
301 let channel = self.channels.get(endpoint)?;
302 Ok(WorkerProxyServiceClient::new(channel))
303 }
304
305 pub(crate) fn request_for<T>(
306 &self,
307 message: T,
308 extra_metadata: &[(String, String)],
309 timeout: Option<Duration>,
310 ) -> Result<Request<T>, SailError> {
311 let mut request = Request::new(message);
312 request
313 .metadata_mut()
314 .insert("authorization", self.authorization.clone());
315 for (key, value) in extra_metadata {
316 let key: AsciiMetadataKey = key.parse().map_err(|_| SailError::Config {
317 message: format!("invalid gRPC metadata key {key:?}"),
318 })?;
319 let value: AsciiMetadataValue = value.parse().map_err(|_| SailError::Config {
320 message: format!("invalid gRPC metadata value for key {key:?}"),
321 })?;
322 request.metadata_mut().insert(key, value);
323 }
324 if let Some(timeout) = timeout {
325 request.set_timeout(timeout);
326 }
327 Ok(request)
328 }
329
330 pub async fn wait_exec(
335 &self,
336 endpoint: &str,
337 sailbox_id: &str,
338 exec_request_id: &str,
339 retry_timeout: f64,
340 ) -> Result<WaitOutcome, SailError> {
341 let mut deadline: Option<Instant> = None;
342 let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
343 loop {
344 let message = pb::WaitSailboxExecRequest {
345 sailbox_id: sailbox_id.to_string(),
346 exec_request_id: exec_request_id.to_string(),
347 };
348 let request = self.request_for(message, &[], None)?;
349 match self.client_for(endpoint)?.wait_sailbox_exec(request).await {
350 Ok(resp) => {
351 let resp = resp.into_inner();
352 return Ok(WaitOutcome {
353 status: resp.status,
354 stdout: resp.stdout,
355 stderr: resp.stderr,
356 exit_code: resp.return_code,
357 timed_out: resp.timed_out,
358 stdout_truncated: resp.stdout_truncated,
359 stderr_truncated: resp.stderr_truncated,
360 });
361 }
362 Err(status) => {
363 let deadline = *deadline.get_or_insert_with(|| retry_deadline(retry_timeout));
364 if !should_retry_transient_exec_rpc(&status, deadline) {
365 return Err(SailError::from_exec_status(&status));
366 }
367 tracing::warn!(code = ?status.code(), endpoint, "retrying transient worker-proxy RPC");
368 if should_invalidate_channel(&status) {
369 self.channels.invalidate(endpoint);
370 }
371 delay = sleep_before_retry(delay, deadline).await;
372 }
373 }
374 }
375 }
376
377 pub async fn cancel_exec(
382 &self,
383 endpoint: &str,
384 sailbox_id: &str,
385 exec_request_id: &str,
386 force: bool,
387 retry_timeout: f64,
388 ) -> Result<(), SailError> {
389 let deadline = retry_deadline(retry_timeout);
390 let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
391 loop {
392 let per_attempt_timeout = if retry_timeout > 0.0 {
393 Some(rpc_attempt_timeout(deadline))
398 } else {
399 None
400 };
401 let message = pb::CancelSailboxExecRequest {
402 sailbox_id: sailbox_id.to_string(),
403 exec_request_id: exec_request_id.to_string(),
404 force,
405 };
406 let request = self.request_for(message, &[], per_attempt_timeout)?;
407 match self
408 .client_for(endpoint)?
409 .cancel_sailbox_exec(request)
410 .await
411 {
412 Ok(_) => return Ok(()),
413 Err(status) => {
414 if !should_retry_transient_exec_rpc(&status, deadline) {
415 return Err(SailError::from_exec_status(&status));
416 }
417 tracing::warn!(code = ?status.code(), endpoint, "retrying transient worker-proxy RPC");
418 if should_invalidate_channel(&status) {
419 self.channels.invalidate(endpoint);
420 }
421 delay = sleep_before_retry(delay, deadline).await;
422 }
423 }
424 }
425 }
426
427 pub fn read_file(self: &Arc<Self>, endpoint: &str, sailbox_id: &str, path: &str) -> FileReader {
436 let (tx, rx) = mpsc::channel(FILE_CHANNEL_CAP);
437 let worker = Arc::clone(self);
438 let endpoint = endpoint.to_string();
439 let message = pb::ReadSailboxFileRequest {
440 sailbox_id: sailbox_id.to_string(),
441 path: path.to_string(),
442 };
443 let task = tokio::spawn(async move {
444 let request = match worker.request_for(message, &[], None) {
445 Ok(request) => request,
446 Err(err) => {
447 let _ = tx.send(Err(err)).await;
448 return;
449 }
450 };
451 let mut client = match worker.client_for(&endpoint) {
452 Ok(client) => client,
453 Err(err) => {
454 let _ = tx.send(Err(err)).await;
455 return;
456 }
457 };
458 let mut stream = match client.read_sailbox_file(request).await {
459 Ok(resp) => resp.into_inner(),
460 Err(status) => {
461 let _ = tx.send(Err(SailError::from_file_rpc_status(&status))).await;
462 return;
463 }
464 };
465 loop {
466 match stream.message().await {
467 Ok(Some(resp)) => {
468 if !resp.data.is_empty() && tx.send(Ok(resp.data)).await.is_err() {
469 return; }
471 }
472 Ok(None) => return,
473 Err(status) => {
474 let _ = tx.send(Err(SailError::from_file_rpc_status(&status))).await;
475 return;
476 }
477 }
478 }
479 });
480 FileReader {
481 rx: AsyncMutex::new(rx),
482 abort: task.abort_handle(),
483 }
484 }
485
486 pub fn write_file(
496 self: &Arc<Self>,
497 endpoint: &str,
498 sailbox_id: &str,
499 path: &str,
500 create_parents: bool,
501 mode: Option<u32>,
502 ) -> FileWriter {
503 let (tx, rx) = mpsc::channel(FILE_CHANNEL_CAP);
504 let worker = Arc::clone(self);
505 let endpoint = endpoint.to_string();
506 let task = tokio::spawn(async move {
509 let request = worker.request_for(ReceiverStream::new(rx), &[], None)?;
510 worker
511 .client_for(&endpoint)?
512 .write_sailbox_file(request)
513 .await
514 .map(|_| ())
515 .map_err(|status| SailError::from_file_rpc_status(&status))
516 });
517 FileWriter {
518 tx: Some(tx),
519 task: Some(task),
520 aborted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
521 first: true,
522 sailbox_id: sailbox_id.to_string(),
523 path: path.to_string(),
524 create_parents,
525 mode,
526 }
527 }
528}
529
530pub struct FileWriter {
538 tx: Option<mpsc::Sender<pb::WriteSailboxFileRequest>>,
539 task: Option<tokio::task::JoinHandle<Result<(), SailError>>>,
540 aborted: Arc<std::sync::atomic::AtomicBool>,
541 first: bool,
542 sailbox_id: String,
543 path: String,
544 create_parents: bool,
545 mode: Option<u32>,
546}
547
548impl FileWriter {
549 fn build(&mut self, data: Vec<u8>) -> pb::WriteSailboxFileRequest {
550 let header = self.first;
551 self.first = false;
552 pb::WriteSailboxFileRequest {
553 sailbox_id: if header {
554 self.sailbox_id.clone()
555 } else {
556 String::new()
557 },
558 path: if header {
559 self.path.clone()
560 } else {
561 String::new()
562 },
563 data,
564 create_parents: header && self.create_parents,
565 mode: if header { self.mode } else { None },
566 }
567 }
568
569 async fn join(&mut self) -> Result<(), SailError> {
571 self.tx = None; match self.task.take() {
573 Some(task) => task.await.unwrap_or_else(|join_err| {
574 Err(SailError::Internal {
575 message: format!("file write task failed: {join_err}"),
576 })
577 }),
578 None => Ok(()),
579 }
580 }
581
582 pub async fn write(&mut self, data: &[u8]) -> Result<(), SailError> {
586 for chunk in data.chunks(FILE_WRITE_CHUNK_BYTES) {
587 self.write_chunk(chunk.to_vec()).await?;
588 }
589 Ok(())
590 }
591
592 pub async fn write_chunk(&mut self, data: Vec<u8>) -> Result<(), SailError> {
596 if self.aborted.load(std::sync::atomic::Ordering::Relaxed) {
597 return Err(aborted_write());
598 }
599 let request = self.build(data);
600 match &self.tx {
601 Some(tx) if tx.send(request).await.is_ok() => Ok(()),
603 _ => self.join().await,
604 }
605 }
606
607 pub async fn finish(&mut self) -> Result<(), SailError> {
610 if self.aborted.load(std::sync::atomic::Ordering::Relaxed) {
611 return Err(aborted_write());
612 }
613 if self.first {
614 let request = self.build(Vec::new());
617 if let Some(tx) = &self.tx {
618 let _ = tx.send(request).await;
619 }
620 }
621 self.join().await
622 }
623
624 pub fn abort(&mut self) {
628 if let Some(task) = self.task.take() {
632 self.aborted
633 .store(true, std::sync::atomic::Ordering::Relaxed);
634 task.abort();
635 }
636 self.tx = None;
637 }
638
639 pub fn abort_handle(&self) -> WriteAbortHandle {
643 WriteAbortHandle {
644 aborted: Arc::clone(&self.aborted),
645 task: self
646 .task
647 .as_ref()
648 .map(tokio::task::JoinHandle::abort_handle),
649 }
650 }
651}
652
653#[derive(Clone)]
657pub struct WriteAbortHandle {
658 aborted: Arc<std::sync::atomic::AtomicBool>,
659 task: Option<tokio::task::AbortHandle>,
660}
661
662impl WriteAbortHandle {
663 pub fn abort(&self) {
666 self.aborted
667 .store(true, std::sync::atomic::Ordering::Relaxed);
668 if let Some(task) = &self.task {
669 task.abort();
670 }
671 }
672}
673
674fn aborted_write() -> SailError {
675 SailError::InvalidArgument {
676 message: "the write was aborted; nothing was committed".to_string(),
677 }
678}
679
680impl Drop for FileWriter {
681 fn drop(&mut self) {
682 self.abort();
684 }
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690
691 #[tokio::test]
692 async fn write_splits_at_the_transport_chunk_size() {
693 let (tx, mut rx) = mpsc::channel(16);
694 let mut writer = FileWriter {
695 tx: Some(tx),
696 task: Some(tokio::spawn(async { Ok(()) })),
697 aborted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
698 first: true,
699 sailbox_id: "sb_1".to_string(),
700 path: "/f".to_string(),
701 create_parents: true,
702 mode: None,
703 };
704 writer
705 .write(&vec![7u8; FILE_WRITE_CHUNK_BYTES * 2 + 10])
706 .await
707 .expect("write succeeds");
708 writer.finish().await.expect("finish succeeds");
709 let mut sizes = Vec::new();
710 while let Some(message) = rx.recv().await {
711 sizes.push(message.data.len());
712 }
713 assert_eq!(
714 sizes,
715 vec![FILE_WRITE_CHUNK_BYTES, FILE_WRITE_CHUNK_BYTES, 10]
716 );
717 }
718
719 #[test]
720 fn transient_codes_retry_within_deadline() {
721 let deadline = Instant::now() + Duration::from_secs(5);
722 assert!(should_retry_transient_exec_rpc(
723 &Status::unavailable("x"),
724 deadline
725 ));
726 assert!(should_retry_transient_exec_rpc(
727 &Status::deadline_exceeded("x"),
728 deadline
729 ));
730 assert!(should_retry_transient_exec_rpc(
731 &Status::unknown("HTTP/2 connection reset by remote"),
732 deadline
733 ));
734 assert!(!should_retry_transient_exec_rpc(
735 &Status::unknown("guest exploded"),
736 deadline
737 ));
738 assert!(!should_retry_transient_exec_rpc(
739 &Status::not_found("x"),
740 deadline
741 ));
742 }
743
744 #[test]
745 fn fired_attempt_timeout_retries_but_server_cancel_does_not() {
746 let deadline = Instant::now() + Duration::from_secs(5);
747 let timed_out = Status::from_error(Box::new(tonic::TimeoutExpired(())));
750 assert_eq!(timed_out.code(), Code::Cancelled);
751 assert!(should_retry_transient_exec_rpc(&timed_out, deadline));
752 assert!(!should_retry_transient_exec_rpc(
754 &Status::cancelled("client went away"),
755 deadline
756 ));
757 }
758
759 #[test]
760 fn expired_deadline_never_retries() {
761 let deadline = Instant::now();
762 assert!(!should_retry_transient_exec_rpc(
763 &Status::unavailable("x"),
764 deadline
765 ));
766 }
767
768 #[test]
769 fn draining_detection_is_case_insensitive_and_code_scoped() {
770 assert!(is_workerproxy_draining(&Status::unavailable(
771 "workerproxy DRAINING for deploy"
772 )));
773 assert!(!is_workerproxy_draining(&Status::internal("draining")));
774 assert!(!is_workerproxy_draining(&Status::unavailable("lameduck")));
775 }
776
777 #[test]
778 fn rpc_attempt_timeout_caps_and_leaves_retry_headroom() {
779 let now = Instant::now();
780 let far = rpc_attempt_timeout(now + Duration::from_mins(1));
782 assert!(far <= Duration::from_secs_f64(EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS));
783 assert!(far > Duration::from_secs(5));
784 let small = rpc_attempt_timeout(now + Duration::from_secs(5));
787 assert!(small < Duration::from_secs(5));
788 assert!(small <= Duration::from_secs(3));
789 assert_eq!(
791 rpc_attempt_timeout(now.checked_sub(Duration::from_secs(1)).unwrap()),
792 Duration::from_millis(1)
793 );
794 }
795
796 #[test]
797 fn invalidate_on_draining_or_transport_failure_only() {
798 assert!(should_invalidate_channel(&Status::unavailable(
800 "workerproxy is draining"
801 )));
802 let io = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "socket closed");
805 assert!(should_invalidate_channel(&Status::from_error(Box::new(io))));
806 assert!(!should_invalidate_channel(&Status::unavailable(
808 "try again"
809 )));
810 }
811}