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)]
72#[non_exhaustive]
73pub struct WaitOutcome {
74 pub status: i32,
76 pub stdout: String,
78 pub stderr: String,
80 pub exit_code: i32,
82 pub timed_out: bool,
84 pub stdout_truncated: bool,
87 pub stderr_truncated: bool,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
96#[non_exhaustive]
97pub struct Listener {
98 pub guest_port: u32,
100 pub protocol: crate::sailbox::types::ListenerProtocol,
102 pub route_status: crate::sailbox::types::ListenerRouteStatus,
104 #[serde(default)]
106 pub public_url: String,
107 #[serde(default)]
109 pub public_host: String,
110 #[serde(default)]
112 pub public_port: u32,
113}
114
115impl Listener {
116 pub fn endpoint(&self) -> Option<crate::sailbox::types::ListenerEndpoint> {
118 use crate::sailbox::types::ListenerEndpoint;
119 if !self.public_url.is_empty() {
120 return Some(ListenerEndpoint::Http {
121 url: self.public_url.clone(),
122 });
123 }
124 if !self.public_host.is_empty() && self.public_port != 0 {
125 return Some(ListenerEndpoint::Tcp {
126 host: self.public_host.clone(),
127 port: self.public_port,
128 });
129 }
130 None
131 }
132
133 pub fn is_active(&self) -> bool {
135 self.route_status == crate::sailbox::types::ListenerRouteStatus::Active
136 }
137}
138
139#[derive(Debug, Clone)]
144pub struct WriteOptions {
145 pub create_parents: bool,
147 pub mode: Option<u32>,
149}
150
151impl Default for WriteOptions {
152 fn default() -> WriteOptions {
154 WriteOptions {
155 create_parents: true,
156 mode: None,
157 }
158 }
159}
160
161pub struct FileReader {
167 rx: AsyncMutex<mpsc::Receiver<Result<Vec<u8>, SailError>>>,
168 abort: tokio::task::AbortHandle,
169 closed: std::sync::atomic::AtomicBool,
170}
171
172impl FileReader {
173 pub async fn next(&self) -> Option<Result<Vec<u8>, SailError>> {
176 if self.closed.load(std::sync::atomic::Ordering::Relaxed) {
177 return None;
178 }
179 self.rx.lock().await.recv().await
180 }
181
182 pub fn close(&self) {
187 self.closed
188 .store(true, std::sync::atomic::Ordering::Relaxed);
189 self.abort.abort();
190 }
191
192 pub fn into_stream(self) -> futures::stream::BoxStream<'static, Result<Vec<u8>, SailError>> {
196 Box::pin(futures::stream::unfold(self, |reader| async move {
197 reader.next().await.map(|chunk| (chunk, reader))
198 }))
199 }
200}
201
202impl std::fmt::Debug for FileReader {
203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204 f.debug_struct("FileReader")
205 .field(
206 "closed",
207 &self.closed.load(std::sync::atomic::Ordering::Relaxed),
208 )
209 .finish_non_exhaustive()
210 }
211}
212
213impl Drop for FileReader {
214 fn drop(&mut self) {
215 self.abort.abort();
218 }
219}
220
221fn override_reason_metadata(reason: String) -> Result<AsciiMetadataValue, SailError> {
228 crate::http::validate_override_reason(&reason)?;
229 AsciiMetadataValue::try_from(reason).map_err(|_| SailError::Config {
230 message: "SAIL_OWNER_OVERRIDE_REASON must be printable ASCII (no control or non-ASCII characters)"
231 .to_string(),
232 })
233}
234
235#[doc(hidden)]
239pub struct WorkerProxy {
240 channels: ChannelCache,
241 authorization: AsciiMetadataValue,
242}
243
244pub(crate) fn retry_deadline(retry_timeout: f64) -> Instant {
249 let now = Instant::now();
250 if retry_timeout <= 0.0 {
251 return now;
252 }
253 now + Duration::from_secs_f64(retry_timeout.min(RETRY_FOREVER_SECS))
258}
259
260const RETRY_FOREVER_SECS: f64 = 100.0 * 365.0 * 24.0 * 60.0 * 60.0;
263
264pub(crate) fn rpc_attempt_timeout(deadline: Instant) -> Duration {
270 (deadline.saturating_duration_since(Instant::now()) / 2)
271 .min(Duration::from_secs_f64(EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS))
272 .max(Duration::from_millis(1))
273}
274
275pub(crate) fn is_workerproxy_draining(status: &Status) -> bool {
276 status.code() == Code::Unavailable && status.message().to_lowercase().contains("draining")
277}
278
279pub(crate) fn should_invalidate_channel(status: &Status) -> bool {
288 is_workerproxy_draining(status)
289 || status.code() == Code::DeadlineExceeded
290 || status.source().is_some()
291}
292
293pub(crate) fn should_retry_transient_exec_rpc(status: &Status, deadline: Instant) -> bool {
294 if Instant::now() >= deadline {
295 return false;
296 }
297 match status.code() {
298 Code::Unavailable | Code::DeadlineExceeded => true,
299 Code::Cancelled => status.source().is_some(),
304 Code::Unknown | Code::Internal => {
314 status.source().is_some() || is_transient_transport_message(status.message())
315 }
316 _ => status.source().is_some(),
323 }
324}
325
326pub(crate) async fn sleep_before_retry(delay: f64, deadline: Instant) -> f64 {
329 let mut sleep_for = delay.min(EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS);
330 let remaining = deadline
331 .saturating_duration_since(Instant::now())
332 .as_secs_f64();
333 if remaining <= 0.0 {
334 return delay;
335 }
336 sleep_for = sleep_for.min(remaining);
337 tokio::time::sleep(Duration::from_secs_f64(sleep_for.max(0.0))).await;
338 (delay * 2.0).min(EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS)
339}
340
341pub(crate) fn bearer_metadata(api_key: &str) -> Result<AsciiMetadataValue, SailError> {
345 format!("Bearer {api_key}")
346 .parse()
347 .map_err(|_| SailError::Config {
348 message: "SAIL_API_KEY contains characters invalid in a gRPC metadata value"
349 .to_string(),
350 })
351}
352
353impl WorkerProxy {
354 pub fn new(api_key: &str) -> Result<WorkerProxy, SailError> {
357 let authorization = bearer_metadata(api_key)?;
358 Ok(WorkerProxy {
359 channels: ChannelCache::new(),
360 authorization,
361 })
362 }
363
364 pub(crate) fn channels(&self) -> &ChannelCache {
365 &self.channels
366 }
367
368 pub(crate) fn client_for(
369 &self,
370 endpoint: &str,
371 ) -> Result<WorkerProxyServiceClient<Channel>, SailError> {
372 let channel = self.channels.get(endpoint)?;
373 Ok(WorkerProxyServiceClient::new(channel))
374 }
375
376 pub(crate) fn request_for<T>(
377 &self,
378 message: T,
379 extra_metadata: &[(String, String)],
380 timeout: Option<Duration>,
381 ) -> Result<Request<T>, SailError> {
382 let mut request = Request::new(message);
383 request
384 .metadata_mut()
385 .insert("authorization", self.authorization.clone());
386 if let Some(reason) = crate::http::owner_override_reason() {
390 request.metadata_mut().insert(
391 "x-sail-owner-override-reason",
392 override_reason_metadata(reason)?,
393 );
394 }
395 for (key, value) in extra_metadata {
396 let key: AsciiMetadataKey = key.parse().map_err(|_| SailError::Config {
397 message: format!("invalid gRPC metadata key {key:?}"),
398 })?;
399 let value: AsciiMetadataValue = value.parse().map_err(|_| SailError::Config {
400 message: format!("invalid gRPC metadata value for key {key:?}"),
401 })?;
402 request.metadata_mut().insert(key, value);
403 }
404 if let Some(timeout) = timeout {
405 request.set_timeout(timeout);
406 }
407 Ok(request)
408 }
409
410 pub async fn wait_exec(
415 &self,
416 endpoint: &str,
417 sailbox_id: &str,
418 exec_request_id: &str,
419 retry_timeout: f64,
420 ) -> Result<WaitOutcome, SailError> {
421 let mut deadline: Option<Instant> = None;
422 let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
423 loop {
424 let message = pb::WaitSailboxExecRequest {
425 sailbox_id: sailbox_id.to_string(),
426 exec_request_id: exec_request_id.to_string(),
427 };
428 let request = self.request_for(message, &[], None)?;
429 match self.client_for(endpoint)?.wait_sailbox_exec(request).await {
430 Ok(resp) => {
431 let resp = resp.into_inner();
432 return Ok(WaitOutcome {
433 status: resp.status,
434 stdout: resp.stdout,
435 stderr: resp.stderr,
436 exit_code: resp.return_code,
437 timed_out: resp.timed_out,
438 stdout_truncated: resp.stdout_truncated,
439 stderr_truncated: resp.stderr_truncated,
440 });
441 }
442 Err(status) => {
443 let deadline = *deadline.get_or_insert_with(|| retry_deadline(retry_timeout));
444 if !should_retry_transient_exec_rpc(&status, deadline) {
445 return Err(SailError::from_exec_status(&status));
446 }
447 tracing::warn!(code = ?status.code(), endpoint, "retrying transient worker-proxy RPC");
448 if should_invalidate_channel(&status) {
449 self.channels.invalidate(endpoint);
450 }
451 delay = sleep_before_retry(delay, deadline).await;
452 }
453 }
454 }
455 }
456
457 pub async fn cancel_exec(
462 &self,
463 endpoint: &str,
464 sailbox_id: &str,
465 exec_request_id: &str,
466 force: bool,
467 retry_timeout: f64,
468 ) -> Result<(), SailError> {
469 let deadline = retry_deadline(retry_timeout);
470 let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
471 loop {
472 let per_attempt_timeout = if retry_timeout > 0.0 {
473 Some(rpc_attempt_timeout(deadline))
478 } else {
479 None
480 };
481 let message = pb::CancelSailboxExecRequest {
482 sailbox_id: sailbox_id.to_string(),
483 exec_request_id: exec_request_id.to_string(),
484 force,
485 };
486 let request = self.request_for(message, &[], per_attempt_timeout)?;
487 match self
488 .client_for(endpoint)?
489 .cancel_sailbox_exec(request)
490 .await
491 {
492 Ok(_) => return Ok(()),
493 Err(status) => {
494 if !should_retry_transient_exec_rpc(&status, deadline) {
495 return Err(SailError::from_exec_status(&status));
496 }
497 tracing::warn!(code = ?status.code(), endpoint, "retrying transient worker-proxy RPC");
498 if should_invalidate_channel(&status) {
499 self.channels.invalidate(endpoint);
500 }
501 delay = sleep_before_retry(delay, deadline).await;
502 }
503 }
504 }
505 }
506
507 pub fn read_file(self: &Arc<Self>, endpoint: &str, sailbox_id: &str, path: &str) -> FileReader {
516 let (tx, rx) = mpsc::channel(FILE_CHANNEL_CAP);
517 let worker = Arc::clone(self);
518 let endpoint = endpoint.to_string();
519 let message = pb::ReadSailboxFileRequest {
520 sailbox_id: sailbox_id.to_string(),
521 path: path.to_string(),
522 };
523 let task = tokio::spawn(async move {
524 let request = match worker.request_for(message, &[], None) {
525 Ok(request) => request,
526 Err(err) => {
527 let _ = tx.send(Err(err)).await;
528 return;
529 }
530 };
531 let mut client = match worker.client_for(&endpoint) {
532 Ok(client) => client,
533 Err(err) => {
534 let _ = tx.send(Err(err)).await;
535 return;
536 }
537 };
538 let mut stream = match client.read_sailbox_file(request).await {
539 Ok(resp) => resp.into_inner(),
540 Err(status) => {
541 let _ = tx.send(Err(SailError::from_file_rpc_status(&status))).await;
542 return;
543 }
544 };
545 loop {
546 match stream.message().await {
547 Ok(Some(resp)) => {
548 if !resp.data.is_empty() && tx.send(Ok(resp.data)).await.is_err() {
549 return; }
551 }
552 Ok(None) => return,
553 Err(status) => {
554 let _ = tx.send(Err(SailError::from_file_rpc_status(&status))).await;
555 return;
556 }
557 }
558 }
559 });
560 FileReader {
561 rx: AsyncMutex::new(rx),
562 abort: task.abort_handle(),
563 closed: std::sync::atomic::AtomicBool::new(false),
564 }
565 }
566
567 pub fn write_file(
577 self: &Arc<Self>,
578 endpoint: &str,
579 sailbox_id: &str,
580 path: &str,
581 create_parents: bool,
582 mode: Option<u32>,
583 ) -> FileWriter {
584 let (tx, rx) = mpsc::channel(FILE_CHANNEL_CAP);
585 let worker = Arc::clone(self);
586 let endpoint = endpoint.to_string();
587 let task = tokio::spawn(async move {
590 let request =
591 worker.request_for(ReceiverStream::new(rx), &[], None)?;
592 worker
593 .client_for(&endpoint)?
594 .write_sailbox_file(request)
595 .await
596 .map(|_| ())
597 .map_err(|status| SailError::from_file_rpc_status(&status))
598 });
599 FileWriter {
600 tx: Some(tx),
601 task: Some(task),
602 aborted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
603 first: true,
604 sailbox_id: sailbox_id.to_string(),
605 path: path.to_string(),
606 create_parents,
607 mode,
608 }
609 }
610}
611
612#[must_use = "dropping a FileWriter aborts the upload; call finish() to commit it"]
620pub struct FileWriter {
621 tx: Option<mpsc::Sender<pb::WriteSailboxFileRequest>>,
622 task: Option<tokio::task::JoinHandle<Result<(), SailError>>>,
623 aborted: Arc<std::sync::atomic::AtomicBool>,
624 first: bool,
625 sailbox_id: String,
626 path: String,
627 create_parents: bool,
628 mode: Option<u32>,
629}
630
631impl std::fmt::Debug for FileWriter {
632 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
633 f.debug_struct("FileWriter")
634 .field("sailbox_id", &self.sailbox_id)
635 .field("path", &self.path)
636 .field(
637 "aborted",
638 &self.aborted.load(std::sync::atomic::Ordering::Relaxed),
639 )
640 .finish_non_exhaustive()
641 }
642}
643
644impl FileWriter {
645 fn build(&mut self, data: Vec<u8>) -> pb::WriteSailboxFileRequest {
646 let header = self.first;
647 self.first = false;
648 pb::WriteSailboxFileRequest {
649 sailbox_id: if header {
650 self.sailbox_id.clone()
651 } else {
652 String::new()
653 },
654 path: if header {
655 self.path.clone()
656 } else {
657 String::new()
658 },
659 data,
660 create_parents: header && self.create_parents,
661 mode: if header { self.mode } else { None },
662 }
663 }
664
665 async fn join(&mut self) -> Result<(), SailError> {
667 self.tx = None; match self.task.take() {
669 Some(task) => task.await.unwrap_or_else(|join_err| {
670 Err(SailError::Internal {
671 message: format!("file write task failed: {join_err}"),
672 })
673 }),
674 None => Ok(()),
675 }
676 }
677
678 pub async fn write(&mut self, data: &[u8]) -> Result<(), SailError> {
682 for chunk in data.chunks(FILE_WRITE_CHUNK_BYTES) {
683 self.write_chunk(chunk.to_vec()).await?;
684 }
685 Ok(())
686 }
687
688 pub async fn write_chunk(&mut self, data: Vec<u8>) -> Result<(), SailError> {
692 if self.aborted.load(std::sync::atomic::Ordering::Relaxed) {
693 return Err(aborted_write());
694 }
695 let request = self.build(data);
696 match &self.tx {
697 Some(tx) if tx.send(request).await.is_ok() => Ok(()),
699 _ => self.join().await,
700 }
701 }
702
703 pub async fn finish(&mut self) -> Result<(), SailError> {
706 if self.aborted.load(std::sync::atomic::Ordering::Relaxed) {
707 return Err(aborted_write());
708 }
709 if self.first {
710 let request = self.build(Vec::new());
713 if let Some(tx) = &self.tx {
714 let _ = tx.send(request).await;
715 }
716 }
717 self.join().await
718 }
719
720 pub fn abort(&mut self) {
724 if let Some(task) = self.task.take() {
728 self.aborted
729 .store(true, std::sync::atomic::Ordering::Relaxed);
730 task.abort();
731 }
732 self.tx = None;
733 }
734
735 pub fn abort_handle(&self) -> WriteAbortHandle {
739 WriteAbortHandle {
740 aborted: Arc::clone(&self.aborted),
741 task: self
742 .task
743 .as_ref()
744 .map(tokio::task::JoinHandle::abort_handle),
745 }
746 }
747}
748
749#[derive(Debug, Clone)]
753pub struct WriteAbortHandle {
754 aborted: Arc<std::sync::atomic::AtomicBool>,
755 task: Option<tokio::task::AbortHandle>,
756}
757
758impl WriteAbortHandle {
759 pub fn abort(&self) {
762 self.aborted
763 .store(true, std::sync::atomic::Ordering::Relaxed);
764 if let Some(task) = &self.task {
765 task.abort();
766 }
767 }
768}
769
770fn aborted_write() -> SailError {
771 SailError::InvalidArgument {
772 message: "the write was aborted; nothing was committed".to_string(),
773 }
774}
775
776impl Drop for FileWriter {
777 fn drop(&mut self) {
778 self.abort();
780 }
781}
782
783#[cfg(test)]
784mod tests {
785 use super::*;
786
787 #[tokio::test]
788 async fn write_splits_at_the_transport_chunk_size() {
789 let (tx, mut rx) = mpsc::channel(16);
790 let mut writer = FileWriter {
791 tx: Some(tx),
792 task: Some(tokio::spawn(async { Ok(()) })),
793 aborted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
794 first: true,
795 sailbox_id: "sb_1".to_string(),
796 path: "/f".to_string(),
797 create_parents: true,
798 mode: None,
799 };
800 writer
801 .write(&vec![7u8; FILE_WRITE_CHUNK_BYTES * 2 + 10])
802 .await
803 .expect("write succeeds");
804 writer.finish().await.expect("finish succeeds");
805 let mut sizes = Vec::new();
806 while let Some(message) = rx.recv().await {
807 sizes.push(message.data.len());
808 }
809 assert_eq!(
810 sizes,
811 vec![FILE_WRITE_CHUNK_BYTES, FILE_WRITE_CHUNK_BYTES, 10]
812 );
813 }
814
815 #[test]
816 fn override_reason_metadata_accepts_ascii_and_rejects_non_ascii() {
817 let ok = override_reason_metadata("incident inc-42".to_string())
818 .expect("printable ASCII reason builds gRPC metadata");
819 assert_eq!(ok.to_str().unwrap(), "incident inc-42");
820
821 let err = override_reason_metadata("incident café".to_string())
822 .expect_err("non-ASCII reason must be rejected, not silently dropped");
823 match err {
824 SailError::Config { message } => {
825 assert!(
826 message.contains("SAIL_OWNER_OVERRIDE_REASON"),
827 "message = {message}"
828 );
829 }
830 other => panic!("expected Config error, got {other:?}"),
831 }
832 }
833
834 #[test]
835 fn transient_codes_retry_within_deadline() {
836 let deadline = Instant::now() + Duration::from_secs(5);
837 assert!(should_retry_transient_exec_rpc(
838 &Status::unavailable("x"),
839 deadline
840 ));
841 assert!(should_retry_transient_exec_rpc(
842 &Status::deadline_exceeded("x"),
843 deadline
844 ));
845 assert!(should_retry_transient_exec_rpc(
846 &Status::unknown("HTTP/2 connection reset by remote"),
847 deadline
848 ));
849 assert!(!should_retry_transient_exec_rpc(
850 &Status::unknown("guest exploded"),
851 deadline
852 ));
853 assert!(!should_retry_transient_exec_rpc(
854 &Status::not_found("x"),
855 deadline
856 ));
857 }
858
859 #[test]
860 fn h2_connection_failures_retry_within_deadline() {
861 let deadline = Instant::now() + Duration::from_secs(5);
862 let io = std::io::Error::new(
865 std::io::ErrorKind::ConnectionAborted,
866 "connection error: h2 protocol error: http2 error",
867 );
868 let transport = Status::from_error(Box::new(io));
869 assert!(transport.source().is_some());
870 assert!(should_retry_transient_exec_rpc(&transport, deadline));
871 assert!(should_retry_transient_exec_rpc(
874 &Status::internal("h2 protocol error: http2 error"),
875 deadline
876 ));
877 assert!(should_retry_transient_exec_rpc(
878 &Status::unknown("connection error: keep-alive timed out"),
879 deadline
880 ));
881 assert!(!should_retry_transient_exec_rpc(
884 &Status::internal("guest agent panicked"),
885 deadline
886 ));
887 }
888
889 #[test]
890 fn goaway_reason_statuses_retry_only_with_transport_source() {
891 let deadline = Instant::now() + Duration::from_secs(5);
892 let mut goaway = Status::new(Code::ResourceExhausted, "h2 protocol error: http2 error");
899 goaway.set_source(std::sync::Arc::new(std::io::Error::new(
900 std::io::ErrorKind::ConnectionReset,
901 "transport error",
902 )));
903 assert!(should_retry_transient_exec_rpc(&goaway, deadline));
904 assert!(!should_retry_transient_exec_rpc(
907 &Status::resource_exhausted("org concurrency limit reached"),
908 deadline
909 ));
910 let mut sec = Status::new(Code::PermissionDenied, "h2 protocol error: http2 error");
913 sec.set_source(std::sync::Arc::new(std::io::Error::new(
914 std::io::ErrorKind::ConnectionReset,
915 "transport error",
916 )));
917 assert!(should_retry_transient_exec_rpc(&sec, deadline));
918 assert!(!should_retry_transient_exec_rpc(
919 &Status::permission_denied("invalid API key"),
920 deadline
921 ));
922 }
923
924 #[test]
925 fn fired_attempt_timeout_retries_but_server_cancel_does_not() {
926 let deadline = Instant::now() + Duration::from_secs(5);
927 let timed_out = Status::from_error(Box::new(tonic::TimeoutExpired(())));
930 assert_eq!(timed_out.code(), Code::Cancelled);
931 assert!(should_retry_transient_exec_rpc(&timed_out, deadline));
932 assert!(!should_retry_transient_exec_rpc(
934 &Status::cancelled("client went away"),
935 deadline
936 ));
937 }
938
939 #[test]
940 fn expired_deadline_never_retries() {
941 let deadline = Instant::now();
942 assert!(!should_retry_transient_exec_rpc(
943 &Status::unavailable("x"),
944 deadline
945 ));
946 }
947
948 #[test]
949 fn draining_detection_is_case_insensitive_and_code_scoped() {
950 assert!(is_workerproxy_draining(&Status::unavailable(
951 "workerproxy DRAINING for deploy"
952 )));
953 assert!(!is_workerproxy_draining(&Status::internal("draining")));
954 assert!(!is_workerproxy_draining(&Status::unavailable("lameduck")));
955 }
956
957 #[test]
958 fn rpc_attempt_timeout_caps_and_leaves_retry_headroom() {
959 let now = Instant::now();
960 let far = rpc_attempt_timeout(now + Duration::from_mins(1));
962 assert!(far <= Duration::from_secs_f64(EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS));
963 assert!(far > Duration::from_secs(5));
964 let small = rpc_attempt_timeout(now + Duration::from_secs(5));
967 assert!(small < Duration::from_secs(5));
968 assert!(small <= Duration::from_secs(3));
969 assert_eq!(
971 rpc_attempt_timeout(now.checked_sub(Duration::from_secs(1)).unwrap()),
972 Duration::from_millis(1)
973 );
974 }
975
976 #[test]
977 fn invalidate_on_draining_or_transport_failure_only() {
978 assert!(should_invalidate_channel(&Status::unavailable(
980 "workerproxy is draining"
981 )));
982 let io = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "socket closed");
985 assert!(should_invalidate_channel(&Status::from_error(Box::new(io))));
986 assert!(!should_invalidate_channel(&Status::unavailable(
988 "try again"
989 )));
990 }
991}