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
183fn override_reason_metadata(reason: String) -> Result<AsciiMetadataValue, SailError> {
190 crate::http::validate_override_reason(&reason)?;
191 AsciiMetadataValue::try_from(reason).map_err(|_| SailError::Config {
192 message: "SAIL_OWNER_OVERRIDE_REASON must be printable ASCII (no control or non-ASCII characters)"
193 .to_string(),
194 })
195}
196
197#[doc(hidden)]
201pub struct WorkerProxy {
202 channels: ChannelCache,
203 authorization: AsciiMetadataValue,
204}
205
206pub(crate) fn retry_deadline(retry_timeout: f64) -> Instant {
211 let now = Instant::now();
212 if retry_timeout <= 0.0 {
213 return now;
214 }
215 now + Duration::from_secs_f64(retry_timeout.min(RETRY_FOREVER_SECS))
220}
221
222const RETRY_FOREVER_SECS: f64 = 100.0 * 365.0 * 24.0 * 60.0 * 60.0;
225
226pub(crate) fn rpc_attempt_timeout(deadline: Instant) -> Duration {
232 (deadline.saturating_duration_since(Instant::now()) / 2)
233 .min(Duration::from_secs_f64(EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS))
234 .max(Duration::from_millis(1))
235}
236
237pub(crate) fn is_workerproxy_draining(status: &Status) -> bool {
238 status.code() == Code::Unavailable && status.message().to_lowercase().contains("draining")
239}
240
241pub(crate) fn should_invalidate_channel(status: &Status) -> bool {
250 is_workerproxy_draining(status)
251 || status.code() == Code::DeadlineExceeded
252 || status.source().is_some()
253}
254
255pub(crate) fn should_retry_transient_exec_rpc(status: &Status, deadline: Instant) -> bool {
256 if Instant::now() >= deadline {
257 return false;
258 }
259 match status.code() {
260 Code::Unavailable | Code::DeadlineExceeded => true,
261 Code::Cancelled => status.source().is_some(),
266 Code::Unknown | Code::Internal => {
276 status.source().is_some() || is_transient_transport_message(status.message())
277 }
278 _ => status.source().is_some(),
285 }
286}
287
288pub(crate) async fn sleep_before_retry(delay: f64, deadline: Instant) -> f64 {
291 let mut sleep_for = delay.min(EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS);
292 let remaining = deadline
293 .saturating_duration_since(Instant::now())
294 .as_secs_f64();
295 if remaining <= 0.0 {
296 return delay;
297 }
298 sleep_for = sleep_for.min(remaining);
299 tokio::time::sleep(Duration::from_secs_f64(sleep_for.max(0.0))).await;
300 (delay * 2.0).min(EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS)
301}
302
303pub(crate) fn bearer_metadata(api_key: &str) -> Result<AsciiMetadataValue, SailError> {
307 format!("Bearer {api_key}")
308 .parse()
309 .map_err(|_| SailError::Config {
310 message: "SAIL_API_KEY contains characters invalid in a gRPC metadata value"
311 .to_string(),
312 })
313}
314
315impl WorkerProxy {
316 pub fn new(api_key: &str) -> Result<WorkerProxy, SailError> {
319 let authorization = bearer_metadata(api_key)?;
320 Ok(WorkerProxy {
321 channels: ChannelCache::new(),
322 authorization,
323 })
324 }
325
326 pub(crate) fn channels(&self) -> &ChannelCache {
327 &self.channels
328 }
329
330 pub(crate) fn client_for(
331 &self,
332 endpoint: &str,
333 ) -> Result<WorkerProxyServiceClient<Channel>, SailError> {
334 let channel = self.channels.get(endpoint)?;
335 Ok(WorkerProxyServiceClient::new(channel))
336 }
337
338 pub(crate) fn request_for<T>(
339 &self,
340 message: T,
341 extra_metadata: &[(String, String)],
342 timeout: Option<Duration>,
343 ) -> Result<Request<T>, SailError> {
344 let mut request = Request::new(message);
345 request
346 .metadata_mut()
347 .insert("authorization", self.authorization.clone());
348 if let Some(reason) = crate::http::owner_override_reason() {
352 request.metadata_mut().insert(
353 "x-sail-owner-override-reason",
354 override_reason_metadata(reason)?,
355 );
356 }
357 for (key, value) in extra_metadata {
358 let key: AsciiMetadataKey = key.parse().map_err(|_| SailError::Config {
359 message: format!("invalid gRPC metadata key {key:?}"),
360 })?;
361 let value: AsciiMetadataValue = value.parse().map_err(|_| SailError::Config {
362 message: format!("invalid gRPC metadata value for key {key:?}"),
363 })?;
364 request.metadata_mut().insert(key, value);
365 }
366 if let Some(timeout) = timeout {
367 request.set_timeout(timeout);
368 }
369 Ok(request)
370 }
371
372 pub async fn wait_exec(
377 &self,
378 endpoint: &str,
379 sailbox_id: &str,
380 exec_request_id: &str,
381 retry_timeout: f64,
382 ) -> Result<WaitOutcome, SailError> {
383 let mut deadline: Option<Instant> = None;
384 let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
385 loop {
386 let message = pb::WaitSailboxExecRequest {
387 sailbox_id: sailbox_id.to_string(),
388 exec_request_id: exec_request_id.to_string(),
389 };
390 let request = self.request_for(message, &[], None)?;
391 match self.client_for(endpoint)?.wait_sailbox_exec(request).await {
392 Ok(resp) => {
393 let resp = resp.into_inner();
394 return Ok(WaitOutcome {
395 status: resp.status,
396 stdout: resp.stdout,
397 stderr: resp.stderr,
398 exit_code: resp.return_code,
399 timed_out: resp.timed_out,
400 stdout_truncated: resp.stdout_truncated,
401 stderr_truncated: resp.stderr_truncated,
402 });
403 }
404 Err(status) => {
405 let deadline = *deadline.get_or_insert_with(|| retry_deadline(retry_timeout));
406 if !should_retry_transient_exec_rpc(&status, deadline) {
407 return Err(SailError::from_exec_status(&status));
408 }
409 tracing::warn!(code = ?status.code(), endpoint, "retrying transient worker-proxy RPC");
410 if should_invalidate_channel(&status) {
411 self.channels.invalidate(endpoint);
412 }
413 delay = sleep_before_retry(delay, deadline).await;
414 }
415 }
416 }
417 }
418
419 pub async fn cancel_exec(
424 &self,
425 endpoint: &str,
426 sailbox_id: &str,
427 exec_request_id: &str,
428 force: bool,
429 retry_timeout: f64,
430 ) -> Result<(), SailError> {
431 let deadline = retry_deadline(retry_timeout);
432 let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
433 loop {
434 let per_attempt_timeout = if retry_timeout > 0.0 {
435 Some(rpc_attempt_timeout(deadline))
440 } else {
441 None
442 };
443 let message = pb::CancelSailboxExecRequest {
444 sailbox_id: sailbox_id.to_string(),
445 exec_request_id: exec_request_id.to_string(),
446 force,
447 };
448 let request = self.request_for(message, &[], per_attempt_timeout)?;
449 match self
450 .client_for(endpoint)?
451 .cancel_sailbox_exec(request)
452 .await
453 {
454 Ok(_) => return Ok(()),
455 Err(status) => {
456 if !should_retry_transient_exec_rpc(&status, deadline) {
457 return Err(SailError::from_exec_status(&status));
458 }
459 tracing::warn!(code = ?status.code(), endpoint, "retrying transient worker-proxy RPC");
460 if should_invalidate_channel(&status) {
461 self.channels.invalidate(endpoint);
462 }
463 delay = sleep_before_retry(delay, deadline).await;
464 }
465 }
466 }
467 }
468
469 pub fn read_file(self: &Arc<Self>, endpoint: &str, sailbox_id: &str, path: &str) -> FileReader {
478 let (tx, rx) = mpsc::channel(FILE_CHANNEL_CAP);
479 let worker = Arc::clone(self);
480 let endpoint = endpoint.to_string();
481 let message = pb::ReadSailboxFileRequest {
482 sailbox_id: sailbox_id.to_string(),
483 path: path.to_string(),
484 };
485 let task = tokio::spawn(async move {
486 let request = match worker.request_for(message, &[], None) {
487 Ok(request) => request,
488 Err(err) => {
489 let _ = tx.send(Err(err)).await;
490 return;
491 }
492 };
493 let mut client = match worker.client_for(&endpoint) {
494 Ok(client) => client,
495 Err(err) => {
496 let _ = tx.send(Err(err)).await;
497 return;
498 }
499 };
500 let mut stream = match client.read_sailbox_file(request).await {
501 Ok(resp) => resp.into_inner(),
502 Err(status) => {
503 let _ = tx.send(Err(SailError::from_file_rpc_status(&status))).await;
504 return;
505 }
506 };
507 loop {
508 match stream.message().await {
509 Ok(Some(resp)) => {
510 if !resp.data.is_empty() && tx.send(Ok(resp.data)).await.is_err() {
511 return; }
513 }
514 Ok(None) => return,
515 Err(status) => {
516 let _ = tx.send(Err(SailError::from_file_rpc_status(&status))).await;
517 return;
518 }
519 }
520 }
521 });
522 FileReader {
523 rx: AsyncMutex::new(rx),
524 abort: task.abort_handle(),
525 }
526 }
527
528 pub fn write_file(
538 self: &Arc<Self>,
539 endpoint: &str,
540 sailbox_id: &str,
541 path: &str,
542 create_parents: bool,
543 mode: Option<u32>,
544 ) -> FileWriter {
545 let (tx, rx) = mpsc::channel(FILE_CHANNEL_CAP);
546 let worker = Arc::clone(self);
547 let endpoint = endpoint.to_string();
548 let task = tokio::spawn(async move {
551 let request =
552 worker.request_for(ReceiverStream::new(rx), &[], None)?;
553 worker
554 .client_for(&endpoint)?
555 .write_sailbox_file(request)
556 .await
557 .map(|_| ())
558 .map_err(|status| SailError::from_file_rpc_status(&status))
559 });
560 FileWriter {
561 tx: Some(tx),
562 task: Some(task),
563 aborted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
564 first: true,
565 sailbox_id: sailbox_id.to_string(),
566 path: path.to_string(),
567 create_parents,
568 mode,
569 }
570 }
571}
572
573pub struct FileWriter {
581 tx: Option<mpsc::Sender<pb::WriteSailboxFileRequest>>,
582 task: Option<tokio::task::JoinHandle<Result<(), SailError>>>,
583 aborted: Arc<std::sync::atomic::AtomicBool>,
584 first: bool,
585 sailbox_id: String,
586 path: String,
587 create_parents: bool,
588 mode: Option<u32>,
589}
590
591impl FileWriter {
592 fn build(&mut self, data: Vec<u8>) -> pb::WriteSailboxFileRequest {
593 let header = self.first;
594 self.first = false;
595 pb::WriteSailboxFileRequest {
596 sailbox_id: if header {
597 self.sailbox_id.clone()
598 } else {
599 String::new()
600 },
601 path: if header {
602 self.path.clone()
603 } else {
604 String::new()
605 },
606 data,
607 create_parents: header && self.create_parents,
608 mode: if header { self.mode } else { None },
609 }
610 }
611
612 async fn join(&mut self) -> Result<(), SailError> {
614 self.tx = None; match self.task.take() {
616 Some(task) => task.await.unwrap_or_else(|join_err| {
617 Err(SailError::Internal {
618 message: format!("file write task failed: {join_err}"),
619 })
620 }),
621 None => Ok(()),
622 }
623 }
624
625 pub async fn write(&mut self, data: &[u8]) -> Result<(), SailError> {
629 for chunk in data.chunks(FILE_WRITE_CHUNK_BYTES) {
630 self.write_chunk(chunk.to_vec()).await?;
631 }
632 Ok(())
633 }
634
635 pub async fn write_chunk(&mut self, data: Vec<u8>) -> Result<(), SailError> {
639 if self.aborted.load(std::sync::atomic::Ordering::Relaxed) {
640 return Err(aborted_write());
641 }
642 let request = self.build(data);
643 match &self.tx {
644 Some(tx) if tx.send(request).await.is_ok() => Ok(()),
646 _ => self.join().await,
647 }
648 }
649
650 pub async fn finish(&mut self) -> Result<(), SailError> {
653 if self.aborted.load(std::sync::atomic::Ordering::Relaxed) {
654 return Err(aborted_write());
655 }
656 if self.first {
657 let request = self.build(Vec::new());
660 if let Some(tx) = &self.tx {
661 let _ = tx.send(request).await;
662 }
663 }
664 self.join().await
665 }
666
667 pub fn abort(&mut self) {
671 if let Some(task) = self.task.take() {
675 self.aborted
676 .store(true, std::sync::atomic::Ordering::Relaxed);
677 task.abort();
678 }
679 self.tx = None;
680 }
681
682 pub fn abort_handle(&self) -> WriteAbortHandle {
686 WriteAbortHandle {
687 aborted: Arc::clone(&self.aborted),
688 task: self
689 .task
690 .as_ref()
691 .map(tokio::task::JoinHandle::abort_handle),
692 }
693 }
694}
695
696#[derive(Clone)]
700pub struct WriteAbortHandle {
701 aborted: Arc<std::sync::atomic::AtomicBool>,
702 task: Option<tokio::task::AbortHandle>,
703}
704
705impl WriteAbortHandle {
706 pub fn abort(&self) {
709 self.aborted
710 .store(true, std::sync::atomic::Ordering::Relaxed);
711 if let Some(task) = &self.task {
712 task.abort();
713 }
714 }
715}
716
717fn aborted_write() -> SailError {
718 SailError::InvalidArgument {
719 message: "the write was aborted; nothing was committed".to_string(),
720 }
721}
722
723impl Drop for FileWriter {
724 fn drop(&mut self) {
725 self.abort();
727 }
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733
734 #[tokio::test]
735 async fn write_splits_at_the_transport_chunk_size() {
736 let (tx, mut rx) = mpsc::channel(16);
737 let mut writer = FileWriter {
738 tx: Some(tx),
739 task: Some(tokio::spawn(async { Ok(()) })),
740 aborted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
741 first: true,
742 sailbox_id: "sb_1".to_string(),
743 path: "/f".to_string(),
744 create_parents: true,
745 mode: None,
746 };
747 writer
748 .write(&vec![7u8; FILE_WRITE_CHUNK_BYTES * 2 + 10])
749 .await
750 .expect("write succeeds");
751 writer.finish().await.expect("finish succeeds");
752 let mut sizes = Vec::new();
753 while let Some(message) = rx.recv().await {
754 sizes.push(message.data.len());
755 }
756 assert_eq!(
757 sizes,
758 vec![FILE_WRITE_CHUNK_BYTES, FILE_WRITE_CHUNK_BYTES, 10]
759 );
760 }
761
762 #[test]
763 fn override_reason_metadata_accepts_ascii_and_rejects_non_ascii() {
764 let ok = override_reason_metadata("incident inc-42".to_string())
765 .expect("printable ASCII reason builds gRPC metadata");
766 assert_eq!(ok.to_str().unwrap(), "incident inc-42");
767
768 let err = override_reason_metadata("incident café".to_string())
769 .expect_err("non-ASCII reason must be rejected, not silently dropped");
770 match err {
771 SailError::Config { message } => {
772 assert!(
773 message.contains("SAIL_OWNER_OVERRIDE_REASON"),
774 "message = {message}"
775 );
776 }
777 other => panic!("expected Config error, got {other:?}"),
778 }
779 }
780
781 #[test]
782 fn transient_codes_retry_within_deadline() {
783 let deadline = Instant::now() + Duration::from_secs(5);
784 assert!(should_retry_transient_exec_rpc(
785 &Status::unavailable("x"),
786 deadline
787 ));
788 assert!(should_retry_transient_exec_rpc(
789 &Status::deadline_exceeded("x"),
790 deadline
791 ));
792 assert!(should_retry_transient_exec_rpc(
793 &Status::unknown("HTTP/2 connection reset by remote"),
794 deadline
795 ));
796 assert!(!should_retry_transient_exec_rpc(
797 &Status::unknown("guest exploded"),
798 deadline
799 ));
800 assert!(!should_retry_transient_exec_rpc(
801 &Status::not_found("x"),
802 deadline
803 ));
804 }
805
806 #[test]
807 fn h2_connection_failures_retry_within_deadline() {
808 let deadline = Instant::now() + Duration::from_secs(5);
809 let io = std::io::Error::new(
812 std::io::ErrorKind::ConnectionAborted,
813 "connection error: h2 protocol error: http2 error",
814 );
815 let transport = Status::from_error(Box::new(io));
816 assert!(transport.source().is_some());
817 assert!(should_retry_transient_exec_rpc(&transport, deadline));
818 assert!(should_retry_transient_exec_rpc(
821 &Status::internal("h2 protocol error: http2 error"),
822 deadline
823 ));
824 assert!(should_retry_transient_exec_rpc(
825 &Status::unknown("connection error: keep-alive timed out"),
826 deadline
827 ));
828 assert!(!should_retry_transient_exec_rpc(
831 &Status::internal("guest agent panicked"),
832 deadline
833 ));
834 }
835
836 #[test]
837 fn goaway_reason_statuses_retry_only_with_transport_source() {
838 let deadline = Instant::now() + Duration::from_secs(5);
839 let mut goaway = Status::new(Code::ResourceExhausted, "h2 protocol error: http2 error");
846 goaway.set_source(std::sync::Arc::new(std::io::Error::new(
847 std::io::ErrorKind::ConnectionReset,
848 "transport error",
849 )));
850 assert!(should_retry_transient_exec_rpc(&goaway, deadline));
851 assert!(!should_retry_transient_exec_rpc(
854 &Status::resource_exhausted("org concurrency limit reached"),
855 deadline
856 ));
857 let mut sec = Status::new(Code::PermissionDenied, "h2 protocol error: http2 error");
860 sec.set_source(std::sync::Arc::new(std::io::Error::new(
861 std::io::ErrorKind::ConnectionReset,
862 "transport error",
863 )));
864 assert!(should_retry_transient_exec_rpc(&sec, deadline));
865 assert!(!should_retry_transient_exec_rpc(
866 &Status::permission_denied("invalid API key"),
867 deadline
868 ));
869 }
870
871 #[test]
872 fn fired_attempt_timeout_retries_but_server_cancel_does_not() {
873 let deadline = Instant::now() + Duration::from_secs(5);
874 let timed_out = Status::from_error(Box::new(tonic::TimeoutExpired(())));
877 assert_eq!(timed_out.code(), Code::Cancelled);
878 assert!(should_retry_transient_exec_rpc(&timed_out, deadline));
879 assert!(!should_retry_transient_exec_rpc(
881 &Status::cancelled("client went away"),
882 deadline
883 ));
884 }
885
886 #[test]
887 fn expired_deadline_never_retries() {
888 let deadline = Instant::now();
889 assert!(!should_retry_transient_exec_rpc(
890 &Status::unavailable("x"),
891 deadline
892 ));
893 }
894
895 #[test]
896 fn draining_detection_is_case_insensitive_and_code_scoped() {
897 assert!(is_workerproxy_draining(&Status::unavailable(
898 "workerproxy DRAINING for deploy"
899 )));
900 assert!(!is_workerproxy_draining(&Status::internal("draining")));
901 assert!(!is_workerproxy_draining(&Status::unavailable("lameduck")));
902 }
903
904 #[test]
905 fn rpc_attempt_timeout_caps_and_leaves_retry_headroom() {
906 let now = Instant::now();
907 let far = rpc_attempt_timeout(now + Duration::from_mins(1));
909 assert!(far <= Duration::from_secs_f64(EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS));
910 assert!(far > Duration::from_secs(5));
911 let small = rpc_attempt_timeout(now + Duration::from_secs(5));
914 assert!(small < Duration::from_secs(5));
915 assert!(small <= Duration::from_secs(3));
916 assert_eq!(
918 rpc_attempt_timeout(now.checked_sub(Duration::from_secs(1)).unwrap()),
919 Duration::from_millis(1)
920 );
921 }
922
923 #[test]
924 fn invalidate_on_draining_or_transport_failure_only() {
925 assert!(should_invalidate_channel(&Status::unavailable(
927 "workerproxy is draining"
928 )));
929 let io = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "socket closed");
932 assert!(should_invalidate_channel(&Status::from_error(Box::new(io))));
933 assert!(!should_invalidate_channel(&Status::unavailable(
935 "try again"
936 )));
937 }
938}