Skip to main content

sail/
worker.rs

1//! Per-sailbox worker-proxy client: the gRPC operations that terminate at a
2//! sailbox's own worker proxy (exec wait/cancel, listeners, files), sharing one
3//! lazily-dialed, drain-aware channel cache and the API-key credential.
4//!
5//! Transient-failure retry and drain-aware channel eviction live here; the
6//! interrupt UX (Ctrl-C cascades, AbortSignal) stays in the language wrappers.
7
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11use serde::{Deserialize, Serialize};
12use tokio::sync::mpsc;
13use tokio::sync::Mutex as AsyncMutex;
14use tokio_stream::wrappers::ReceiverStream;
15use tonic::metadata::{AsciiMetadataKey, AsciiMetadataValue};
16use tonic::transport::Channel;
17use tonic::{Code, Request, Status};
18
19use crate::channels::ChannelCache;
20use crate::error::SailError;
21use crate::pb::workerproxy::v1 as pb;
22use pb::worker_proxy_service_client::WorkerProxyServiceClient;
23
24/// How many file chunks to buffer between the caller and the wire, for
25/// backpressure on both read and write.
26const FILE_READ_CHANNEL_CAP: usize = 4;
27
28/// Chunk size for streaming a file write: each `FileWriter::write_chunk` call
29/// becomes one gRPC message, so this keeps a chunk under the transport's message
30/// limit. Bindings stream their source in pieces of this size.
31pub const FILE_WRITE_CHUNK_BYTES: usize = 1 << 20;
32
33/// Initial backoff in seconds before the first transient-RPC retry; doubled on
34/// each subsequent attempt.
35pub const EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS: f64 = 0.2;
36/// Ceiling in seconds for the exponential backoff between transient-RPC retries.
37pub const EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS: f64 = 2.0;
38/// Per-attempt deadline for a unary exec RPC. Caps a single attempt so a
39/// stalled (not dead) connection times out and the retry loop runs again
40/// within the overall budget, instead of one await consuming it all.
41pub const EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS: f64 = 10.0;
42
43/// gRPC status-message fragments that mark a mid-flight connection drop (a peer
44/// rolling/lameducking during a deploy) rather than a permanent failure, so the
45/// RPC is worth retrying. Shared by the exec and imagebuilder retry paths.
46pub(crate) const TRANSIENT_TRANSPORT_FRAGMENTS: &[&str] = &[
47    "endpoint closing",
48    "error reading server preface",
49    "connection reset",
50    "socket closed",
51    "transport is closing",
52];
53
54/// Whether a gRPC status message names a transient transport drop (see
55/// [`TRANSIENT_TRANSPORT_FRAGMENTS`]). Case-insensitive.
56pub(crate) fn is_transient_transport_message(message: &str) -> bool {
57    let details = message.to_lowercase();
58    TRANSIENT_TRANSPORT_FRAGMENTS
59        .iter()
60        .any(|fragment| details.contains(fragment))
61}
62
63/// Authoritative buffered result from polling `WaitSailboxExec`. Callers
64/// inspect `status` (e.g. a terminal failure) and shape the public result.
65#[derive(Debug, Clone)]
66pub struct WaitOutcome {
67    /// Terminal exec status as the `SailboxExecStatus` proto enum value.
68    pub status: i32,
69    /// Buffered stdout from the persisted row.
70    pub stdout: String,
71    /// Buffered stderr from the persisted row.
72    pub stderr: String,
73    /// The command's exit code.
74    pub return_code: i32,
75    /// Whether the command was killed for exceeding its timeout.
76    pub timed_out: bool,
77    /// Whether stdout overflowed the server output ring and lost its oldest
78    /// bytes.
79    pub stdout_truncated: bool,
80    /// Whether stderr overflowed the server output ring and lost its oldest
81    /// bytes.
82    pub stderr_truncated: bool,
83}
84
85/// A sailbox ingress listener. Parsed from the sailbox-API listener JSON, whose
86/// optional address fields are absent until the route is active.
87#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88#[serde(default)]
89pub struct Listener {
90    /// The in-guest port traffic is forwarded to.
91    pub guest_port: u32,
92    /// Publicly reachable URL for the listener.
93    pub public_url: String,
94    /// Wire protocol exposed, e.g. `tcp` or `http`.
95    pub protocol: String,
96    /// Route status as the proto enum name, e.g. `LISTENER_ROUTE_STATUS_ACTIVE`.
97    pub route_status: String,
98    /// Public hostname the listener is reachable at.
99    pub public_host: String,
100    /// Public port the listener is reachable at.
101    pub public_port: u32,
102}
103
104/// Optional settings for [`Client::upload_file`](crate::Client::upload_file).
105/// `Default` writes the file without creating missing parent directories and
106/// leaves its mode to the guest's default.
107#[derive(Debug, Clone, Default)]
108pub struct UploadOptions {
109    /// Create missing parent directories before writing.
110    pub create_parents: bool,
111    /// Unix mode bits for the written file; `None` leaves the guest default.
112    pub mode: Option<u32>,
113}
114
115/// A streaming reader over a `ReadSailboxFile` response. A background task
116/// pumps chunks into a bounded channel (so a slow consumer applies
117/// backpressure rather than buffering the whole file); [`FileReader::next`]
118/// yields the next chunk, `None` at end of file.
119pub struct FileReader {
120    rx: AsyncMutex<mpsc::Receiver<Result<Vec<u8>, SailError>>>,
121}
122
123impl FileReader {
124    /// Yield the next file chunk, or `None` at end of file. A stream error
125    /// surfaces as `Some(Err(..))`.
126    pub async fn next(&self) -> Option<Result<Vec<u8>, SailError>> {
127        self.rx.lock().await.recv().await
128    }
129}
130
131/// Client for the worker-proxy gRPC operations of a single sailbox, holding the
132/// shared lazily-dialed channel cache and the bearer credential applied to every
133/// request.
134#[doc(hidden)]
135pub struct WorkerProxy {
136    channels: ChannelCache,
137    authorization: AsciiMetadataValue,
138}
139
140/// A `retry_timeout <= 0` deadline is "now" so the very first transient
141/// failure short-circuits, preserving the no-retry-by-default contract
142/// for programmatic cancel callers. A non-finite or overflowing timeout
143/// (e.g. `float("inf")` from Python) means "retry forever".
144pub(crate) fn retry_deadline(retry_timeout: f64) -> Instant {
145    let now = Instant::now();
146    if retry_timeout <= 0.0 {
147        return now;
148    }
149    Duration::try_from_secs_f64(retry_timeout)
150        .ok()
151        .and_then(|timeout| now.checked_add(timeout))
152        .unwrap_or(now + RETRY_FOREVER_FALLBACK)
153}
154
155/// A ~100-year stand-in for "retry forever" when the requested deadline is so
156/// large it overflows `Instant`.
157const RETRY_FOREVER_FALLBACK: Duration = Duration::from_hours(876_000);
158
159/// Bound a single unary-RPC attempt: at most [`EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS`]
160/// and at most half the remaining budget. Halving leaves headroom for at least
161/// one retry. Otherwise, on a budget smaller than the ceiling, a single attempt
162/// hanging on a half-open connection would consume the whole budget and the loop
163/// would give up without ever redialing.
164pub(crate) fn rpc_attempt_timeout(deadline: Instant) -> Duration {
165    (deadline.saturating_duration_since(Instant::now()) / 2)
166        .min(Duration::from_secs_f64(EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS))
167        .max(Duration::from_millis(1))
168}
169
170pub(crate) fn is_workerproxy_draining(status: &Status) -> bool {
171    status.code() == Code::Unavailable && status.message().to_lowercase().contains("draining")
172}
173
174/// Whether a retryable failure should also drop the cached channel before the
175/// next attempt. The connection itself is suspect when the target is draining,
176/// on a server-enforced deadline (`DeadlineExceeded`), or on any client-side
177/// transport failure (a half-open socket, keepalive timeout, failed connect, or
178/// a fired per-attempt `set_timeout`, all of which tonic surfaces as a status
179/// carrying a transport `source`). Reusing such a channel would burn every retry
180/// on the dead connection, so dial fresh instead. A server-sent transient (no
181/// source, not draining, not a deadline) keeps the connection.
182pub(crate) fn should_invalidate_channel(status: &Status) -> bool {
183    use std::error::Error;
184    is_workerproxy_draining(status)
185        || status.code() == Code::DeadlineExceeded
186        || status.source().is_some()
187}
188
189pub(crate) fn should_retry_transient_exec_rpc(status: &Status, deadline: Instant) -> bool {
190    use std::error::Error;
191    if Instant::now() >= deadline {
192        return false;
193    }
194    match status.code() {
195        Code::Unavailable | Code::DeadlineExceeded => true,
196        // A fired per-attempt `set_timeout` surfaces as `Cancelled` carrying the
197        // timeout as a source, as does a client-side transport cancel; both are
198        // transient and retryable. A server-sent `CANCELLED` (no source) is a
199        // deliberate cancellation and is left alone.
200        Code::Cancelled => status.source().is_some(),
201        Code::Unknown => is_transient_transport_message(status.message()),
202        _ => false,
203    }
204}
205
206/// Sleep before the next retry attempt, bounded by the max per-retry delay
207/// and the remaining budget; returns the doubled delay for the next round.
208pub(crate) async fn sleep_before_retry(delay: f64, deadline: Instant) -> f64 {
209    let mut sleep_for = delay.min(EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS);
210    let remaining = deadline
211        .saturating_duration_since(Instant::now())
212        .as_secs_f64();
213    if remaining <= 0.0 {
214        return delay;
215    }
216    sleep_for = sleep_for.min(remaining);
217    tokio::time::sleep(Duration::from_secs_f64(sleep_for.max(0.0))).await;
218    (delay * 2.0).min(EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS)
219}
220
221impl WorkerProxy {
222    /// Build a worker proxy that authenticates with `api_key`. Fails if the key
223    /// cannot form a valid gRPC `authorization` metadata value.
224    pub fn new(api_key: &str) -> Result<WorkerProxy, SailError> {
225        let authorization = format!("Bearer {api_key}")
226            .parse()
227            .map_err(|_| SailError::Config {
228                message: "SAIL_API_KEY contains characters invalid in a gRPC metadata value"
229                    .to_string(),
230            })?;
231        Ok(WorkerProxy {
232            channels: ChannelCache::new(),
233            authorization,
234        })
235    }
236
237    pub(crate) fn channels(&self) -> &ChannelCache {
238        &self.channels
239    }
240
241    pub(crate) fn client_for(
242        &self,
243        endpoint: &str,
244    ) -> Result<WorkerProxyServiceClient<Channel>, SailError> {
245        let channel = self.channels.get(endpoint)?;
246        Ok(WorkerProxyServiceClient::new(channel))
247    }
248
249    pub(crate) fn request_for<T>(
250        &self,
251        message: T,
252        extra_metadata: &[(String, String)],
253        timeout: Option<Duration>,
254    ) -> Result<Request<T>, SailError> {
255        let mut request = Request::new(message);
256        request
257            .metadata_mut()
258            .insert("authorization", self.authorization.clone());
259        for (key, value) in extra_metadata {
260            let key: AsciiMetadataKey = key.parse().map_err(|_| SailError::Config {
261                message: format!("invalid gRPC metadata key {key:?}"),
262            })?;
263            let value: AsciiMetadataValue = value.parse().map_err(|_| SailError::Config {
264                message: format!("invalid gRPC metadata value for key {key:?}"),
265            })?;
266            request.metadata_mut().insert(key, value);
267        }
268        if let Some(timeout) = timeout {
269            request.set_timeout(timeout);
270        }
271        Ok(request)
272    }
273
274    /// Wait for an exec to finish. The retry deadline starts at the FIRST
275    /// transient error, not at the call: a wait legitimately blocks for as
276    /// long as the guest command runs, so only consecutive failure time is
277    /// budgeted.
278    pub async fn wait_exec(
279        &self,
280        endpoint: &str,
281        sailbox_id: &str,
282        exec_request_id: &str,
283        retry_timeout: f64,
284    ) -> Result<WaitOutcome, SailError> {
285        let mut deadline: Option<Instant> = None;
286        let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
287        loop {
288            let message = pb::WaitSailboxExecRequest {
289                sailbox_id: sailbox_id.to_string(),
290                exec_request_id: exec_request_id.to_string(),
291            };
292            let request = self.request_for(message, &[], None)?;
293            match self.client_for(endpoint)?.wait_sailbox_exec(request).await {
294                Ok(resp) => {
295                    let resp = resp.into_inner();
296                    return Ok(WaitOutcome {
297                        status: resp.status,
298                        stdout: resp.stdout,
299                        stderr: resp.stderr,
300                        return_code: resp.return_code,
301                        timed_out: resp.timed_out,
302                        stdout_truncated: resp.stdout_truncated,
303                        stderr_truncated: resp.stderr_truncated,
304                    });
305                }
306                Err(status) => {
307                    let deadline = *deadline.get_or_insert_with(|| retry_deadline(retry_timeout));
308                    if !should_retry_transient_exec_rpc(&status, deadline) {
309                        return Err(SailError::from_exec_status(&status));
310                    }
311                    tracing::warn!(code = ?status.code(), endpoint, "retrying transient worker-proxy RPC");
312                    if should_invalidate_channel(&status) {
313                        self.channels.invalidate(endpoint);
314                    }
315                    delay = sleep_before_retry(delay, deadline).await;
316                }
317            }
318        }
319    }
320
321    /// Signal the guest command: SIGINT by default, SIGKILL if force.
322    /// `retry_timeout > 0` budgets retries across the saild
323    /// registration-gap window AND bounds each attempt with a per-call
324    /// gRPC deadline so a stuck connection cannot hang past the budget.
325    pub async fn cancel_exec(
326        &self,
327        endpoint: &str,
328        sailbox_id: &str,
329        exec_request_id: &str,
330        force: bool,
331        retry_timeout: f64,
332    ) -> Result<(), SailError> {
333        let deadline = retry_deadline(retry_timeout);
334        let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
335        loop {
336            let per_attempt_timeout = if retry_timeout > 0.0 {
337                // Cap each attempt so a half-open connection times out and the
338                // loop redials, instead of one attempt consuming the whole
339                // budget. retry_timeout == 0 keeps a single uncapped attempt
340                // (programmatic no-retry cancel).
341                Some(rpc_attempt_timeout(deadline))
342            } else {
343                None
344            };
345            let message = pb::CancelSailboxExecRequest {
346                sailbox_id: sailbox_id.to_string(),
347                exec_request_id: exec_request_id.to_string(),
348                force,
349            };
350            let request = self.request_for(message, &[], per_attempt_timeout)?;
351            match self
352                .client_for(endpoint)?
353                .cancel_sailbox_exec(request)
354                .await
355            {
356                Ok(_) => return Ok(()),
357                Err(status) => {
358                    if !should_retry_transient_exec_rpc(&status, deadline) {
359                        return Err(SailError::from_exec_status(&status));
360                    }
361                    tracing::warn!(code = ?status.code(), endpoint, "retrying transient worker-proxy RPC");
362                    if should_invalidate_channel(&status) {
363                        self.channels.invalidate(endpoint);
364                    }
365                    delay = sleep_before_retry(delay, deadline).await;
366                }
367            }
368        }
369    }
370
371    /// Open a streaming read of a guest file. The returned [`FileReader`] yields
372    /// chunks as they arrive; errors surface from its `next`.
373    ///
374    /// # Runtime
375    ///
376    /// Spawns the background pump on the calling task's tokio runtime, so call it
377    /// from within one (every binding does, via the shared runtime; an async host
378    /// from its own). The dialed channel co-locates on that runtime.
379    pub fn read_file(self: &Arc<Self>, endpoint: &str, sailbox_id: &str, path: &str) -> FileReader {
380        let (tx, rx) = mpsc::channel(FILE_READ_CHANNEL_CAP);
381        let worker = Arc::clone(self);
382        let endpoint = endpoint.to_string();
383        let message = pb::ReadSailboxFileRequest {
384            sailbox_id: sailbox_id.to_string(),
385            path: path.to_string(),
386        };
387        tokio::spawn(async move {
388            let request = match worker.request_for(message, &[], None) {
389                Ok(request) => request,
390                Err(err) => {
391                    let _ = tx.send(Err(err)).await;
392                    return;
393                }
394            };
395            let mut client = match worker.client_for(&endpoint) {
396                Ok(client) => client,
397                Err(err) => {
398                    let _ = tx.send(Err(err)).await;
399                    return;
400                }
401            };
402            let mut stream = match client.read_sailbox_file(request).await {
403                Ok(resp) => resp.into_inner(),
404                Err(status) => {
405                    let _ = tx.send(Err(SailError::from_file_rpc_status(&status))).await;
406                    return;
407                }
408            };
409            loop {
410                match stream.message().await {
411                    Ok(Some(resp)) => {
412                        if !resp.data.is_empty() && tx.send(Ok(resp.data)).await.is_err() {
413                            return; // the reader was dropped
414                        }
415                    }
416                    Ok(None) => return,
417                    Err(status) => {
418                        let _ = tx.send(Err(SailError::from_file_rpc_status(&status))).await;
419                        return;
420                    }
421                }
422            }
423        });
424        FileReader {
425            rx: AsyncMutex::new(rx),
426        }
427    }
428
429    /// Open a streaming write to a guest file. The caller feeds chunks via
430    /// [`FileWriter::write_chunk`] and ends with [`FileWriter::finish`], so a
431    /// large source is never buffered whole. The first chunk carries the
432    /// path/flags; the rest carry data only.
433    ///
434    /// # Runtime
435    ///
436    /// Spawns the streaming RPC on the calling task's tokio runtime, so call it
437    /// from within one. The dialed channel co-locates on that runtime.
438    pub fn write_file(
439        self: &Arc<Self>,
440        endpoint: &str,
441        sailbox_id: &str,
442        path: &str,
443        create_parents: bool,
444        mode: Option<u32>,
445    ) -> FileWriter {
446        let (tx, rx) = mpsc::channel(FILE_READ_CHANNEL_CAP);
447        let worker = Arc::clone(self);
448        let endpoint = endpoint.to_string();
449        // Build the client and run the RPC inside the spawned task so the
450        // channel is dialed in the runtime's reactor context.
451        let task = tokio::spawn(async move {
452            let request = worker.request_for(ReceiverStream::new(rx), &[], None)?;
453            worker
454                .client_for(&endpoint)?
455                .write_sailbox_file(request)
456                .await
457                .map(|_| ())
458                .map_err(|status| SailError::from_file_rpc_status(&status))
459        });
460        FileWriter {
461            tx: Some(tx),
462            task: Some(task),
463            first: true,
464            sailbox_id: sailbox_id.to_string(),
465            path: path.to_string(),
466            create_parents,
467            mode,
468        }
469    }
470}
471
472/// A streaming write to a guest file. Chunks feed a bounded channel that backs
473/// the client-streaming RPC, so a slow uplink applies backpressure rather than
474/// buffering the source. The RPC result surfaces from [`FileWriter::finish`].
475pub struct FileWriter {
476    tx: Option<mpsc::Sender<pb::WriteSailboxFileRequest>>,
477    task: Option<tokio::task::JoinHandle<Result<(), SailError>>>,
478    first: bool,
479    sailbox_id: String,
480    path: String,
481    create_parents: bool,
482    mode: Option<u32>,
483}
484
485impl FileWriter {
486    fn build(&mut self, data: Vec<u8>) -> pb::WriteSailboxFileRequest {
487        let header = self.first;
488        self.first = false;
489        pb::WriteSailboxFileRequest {
490            sailbox_id: if header {
491                self.sailbox_id.clone()
492            } else {
493                String::new()
494            },
495            path: if header {
496                self.path.clone()
497            } else {
498                String::new()
499            },
500            data,
501            create_parents: header && self.create_parents,
502            mode: if header { self.mode } else { None },
503        }
504    }
505
506    /// Stop the stream and return the RPC's result.
507    async fn join(&mut self) -> Result<(), SailError> {
508        self.tx = None; // dropping the sender ends the client stream
509        match self.task.take() {
510            Some(task) => task.await.unwrap_or_else(|join_err| {
511                Err(SailError::Internal {
512                    message: format!("file write task failed: {join_err}"),
513                })
514            }),
515            None => Ok(()),
516        }
517    }
518
519    /// Send the next chunk of file data. If the RPC has already ended, returns
520    /// its result instead.
521    pub async fn write_chunk(&mut self, data: Vec<u8>) -> Result<(), SailError> {
522        let request = self.build(data);
523        match &self.tx {
524            // A send error means the RPC task already ended; surface its result.
525            Some(tx) if tx.send(request).await.is_ok() => Ok(()),
526            _ => self.join().await,
527        }
528    }
529
530    /// Finish the write and return the RPC's result, creating an empty file when
531    /// no chunks were sent.
532    pub async fn finish(&mut self) -> Result<(), SailError> {
533        if self.first {
534            // No chunks were written: send a header-only message so an empty
535            // file is still created.
536            let request = self.build(Vec::new());
537            if let Some(tx) = &self.tx {
538                let _ = tx.send(request).await;
539            }
540        }
541        self.join().await
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548
549    #[test]
550    fn transient_codes_retry_within_deadline() {
551        let deadline = Instant::now() + Duration::from_secs(5);
552        assert!(should_retry_transient_exec_rpc(
553            &Status::unavailable("x"),
554            deadline
555        ));
556        assert!(should_retry_transient_exec_rpc(
557            &Status::deadline_exceeded("x"),
558            deadline
559        ));
560        assert!(should_retry_transient_exec_rpc(
561            &Status::unknown("HTTP/2 connection reset by remote"),
562            deadline
563        ));
564        assert!(!should_retry_transient_exec_rpc(
565            &Status::unknown("guest exploded"),
566            deadline
567        ));
568        assert!(!should_retry_transient_exec_rpc(
569            &Status::not_found("x"),
570            deadline
571        ));
572    }
573
574    #[test]
575    fn fired_attempt_timeout_retries_but_server_cancel_does_not() {
576        let deadline = Instant::now() + Duration::from_secs(5);
577        // A fired per-attempt set_timeout reaches us as Cancelled carrying the
578        // timeout as a source: retry it.
579        let timed_out = Status::from_error(Box::new(tonic::TimeoutExpired(())));
580        assert_eq!(timed_out.code(), Code::Cancelled);
581        assert!(should_retry_transient_exec_rpc(&timed_out, deadline));
582        // A server-sent CANCELLED has no source: do not retry.
583        assert!(!should_retry_transient_exec_rpc(
584            &Status::cancelled("client went away"),
585            deadline
586        ));
587    }
588
589    #[test]
590    fn expired_deadline_never_retries() {
591        let deadline = Instant::now();
592        assert!(!should_retry_transient_exec_rpc(
593            &Status::unavailable("x"),
594            deadline
595        ));
596    }
597
598    #[test]
599    fn draining_detection_is_case_insensitive_and_code_scoped() {
600        assert!(is_workerproxy_draining(&Status::unavailable(
601            "workerproxy DRAINING for deploy"
602        )));
603        assert!(!is_workerproxy_draining(&Status::internal("draining")));
604        assert!(!is_workerproxy_draining(&Status::unavailable("lameduck")));
605    }
606
607    #[test]
608    fn rpc_attempt_timeout_caps_and_leaves_retry_headroom() {
609        let now = Instant::now();
610        // Far deadline: capped at the per-attempt ceiling.
611        let far = rpc_attempt_timeout(now + Duration::from_mins(1));
612        assert!(far <= Duration::from_secs_f64(EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS));
613        assert!(far > Duration::from_secs(5));
614        // Budget below the ceiling: a single attempt takes less than the whole
615        // budget (~half), so a retry can still land if it hangs.
616        let small = rpc_attempt_timeout(now + Duration::from_secs(5));
617        assert!(small < Duration::from_secs(5));
618        assert!(small <= Duration::from_secs(3));
619        // Past deadline: never zero, so the attempt still fires and fails fast.
620        assert_eq!(
621            rpc_attempt_timeout(now.checked_sub(Duration::from_secs(1)).unwrap()),
622            Duration::from_millis(1)
623        );
624    }
625
626    #[test]
627    fn invalidate_on_draining_or_transport_failure_only() {
628        // Draining: drop the channel even though it is a clean server status.
629        assert!(should_invalidate_channel(&Status::unavailable(
630            "workerproxy is draining"
631        )));
632        // A client-side transport failure carries a source: the connection is
633        // suspect, so dial fresh.
634        let io = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "socket closed");
635        assert!(should_invalidate_channel(&Status::from_error(Box::new(io))));
636        // A plain server-sent transient keeps the connection.
637        assert!(!should_invalidate_channel(&Status::unavailable(
638            "try again"
639        )));
640    }
641}