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