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    "h2 protocol error",
54    "keep-alive timed out",
55];
56
57/// Whether a gRPC status message names a transient transport drop (see
58/// [`TRANSIENT_TRANSPORT_FRAGMENTS`]). Case-insensitive.
59pub(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/// Authoritative buffered result from polling `WaitSailboxExec`. Callers
67/// inspect `status` (e.g. a terminal failure) and shape the public result.
68/// Binding plumbing like its producer [`WorkerProxy`], hence doc-hidden: the
69/// typed public result is [`crate::exec::ExecResult`].
70#[doc(hidden)]
71#[derive(Debug, Clone)]
72pub struct WaitOutcome {
73    /// Terminal exec status as the `SailboxExecStatus` proto enum value.
74    pub status: i32,
75    /// Buffered stdout from the persisted row.
76    pub stdout: String,
77    /// Buffered stderr from the persisted row.
78    pub stderr: String,
79    /// The command's exit code.
80    pub exit_code: i32,
81    /// Whether the command was killed for exceeding its timeout.
82    pub timed_out: bool,
83    /// Whether stdout overflowed the server output ring and lost its oldest
84    /// bytes.
85    pub stdout_truncated: bool,
86    /// Whether stderr overflowed the server output ring and lost its oldest
87    /// bytes.
88    pub stderr_truncated: bool,
89}
90
91/// A sailbox ingress listener, parsed from the sailbox-API listener JSON. The
92/// backend always sends the port, protocol, and route status; the public
93/// address fields are absent until the route is active.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct Listener {
96    /// The in-guest port traffic is forwarded to.
97    pub guest_port: u32,
98    /// Wire protocol exposed, e.g. `tcp` or `http`.
99    pub protocol: crate::sailbox::types::ListenerProtocol,
100    /// Status of the listener's ingress route.
101    pub route_status: crate::sailbox::types::ListenerRouteStatus,
102    /// Publicly reachable URL for the listener.
103    #[serde(default)]
104    pub public_url: String,
105    /// Public hostname the listener is reachable at.
106    #[serde(default)]
107    pub public_host: String,
108    /// Public port the listener is reachable at.
109    #[serde(default)]
110    pub public_port: u32,
111}
112
113impl Listener {
114    /// The typed endpoint, or `None` until the listener is routable.
115    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    /// Whether the route is active and ready to carry traffic.
132    pub fn is_active(&self) -> bool {
133        self.route_status == crate::sailbox::types::ListenerRouteStatus::Active
134    }
135}
136
137/// Optional settings for [`Sailbox::write`](crate::Sailbox::write) and
138/// [`Sailbox::write_stream`](crate::Sailbox::write_stream).
139/// `Default` writes the file without creating missing parent directories and
140/// leaves its mode to the guest's default.
141#[derive(Debug, Clone)]
142pub struct WriteOptions {
143    /// Create missing parent directories before writing.
144    pub create_parents: bool,
145    /// Unix mode bits for the written file; `None` leaves the guest default.
146    pub mode: Option<u32>,
147}
148
149impl Default for WriteOptions {
150    /// Parents are created by default, matching every SDK surface.
151    fn default() -> WriteOptions {
152        WriteOptions {
153            create_parents: true,
154            mode: None,
155        }
156    }
157}
158
159/// A streaming reader over a guest-file read response. A background task
160/// pumps chunks into a bounded channel (so a slow consumer applies
161/// backpressure rather than buffering the whole file); [`FileReader::next`]
162/// yields the next chunk, `None` at end of file.
163pub struct FileReader {
164    rx: AsyncMutex<mpsc::Receiver<Result<Vec<u8>, SailError>>>,
165    abort: tokio::task::AbortHandle,
166}
167
168impl FileReader {
169    /// Yield the next file chunk, or `None` at end of file. A stream error
170    /// surfaces as `Some(Err(..))`.
171    pub async fn next(&self) -> Option<Result<Vec<u8>, SailError>> {
172        self.rx.lock().await.recv().await
173    }
174
175    /// Abort the background download, cancelling its gRPC stream even when a
176    /// `next()` is stalled awaiting a chunk. Idempotent; a pending or subsequent
177    /// `next()` then observes end of stream (`None`).
178    pub fn close(&self) {
179        self.abort.abort();
180    }
181}
182
183/// Client for the worker-proxy gRPC operations of a single sailbox, holding the
184/// shared lazily-dialed channel cache and the bearer credential applied to every
185/// request.
186#[doc(hidden)]
187pub struct WorkerProxy {
188    channels: ChannelCache,
189    authorization: AsciiMetadataValue,
190}
191
192/// A `retry_timeout <= 0` deadline is "now" so the very first transient
193/// failure short-circuits, preserving the no-retry-by-default contract
194/// for programmatic cancel callers. A non-finite or overflowing timeout
195/// (e.g. `float("inf")` from Python) means "retry forever".
196pub(crate) fn retry_deadline(retry_timeout: f64) -> Instant {
197    let now = Instant::now();
198    if retry_timeout <= 0.0 {
199        return now;
200    }
201    // Clamp before converting: `f64::min` returns the non-NaN argument, so a
202    // non-finite or astronomically large timeout (e.g. `float("inf")` from
203    // Python, meaning "retry forever") folds to the cap. A finite cap keeps
204    // `from_secs_f64` panic-free and `now + dur` within `Instant`'s range.
205    now + Duration::from_secs_f64(retry_timeout.min(RETRY_FOREVER_SECS))
206}
207
208/// Cap on the retry budget (~a century): large enough to act as "retry until
209/// success or cancel", small enough not to overflow `Instant`.
210const RETRY_FOREVER_SECS: f64 = 100.0 * 365.0 * 24.0 * 60.0 * 60.0;
211
212/// Bound a single unary-RPC attempt: at most [`EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS`]
213/// and at most half the remaining budget. Halving leaves headroom for at least
214/// one retry. Otherwise, on a budget smaller than the ceiling, a single attempt
215/// hanging on a half-open connection would consume the whole budget and the loop
216/// would give up without ever redialing.
217pub(crate) fn rpc_attempt_timeout(deadline: Instant) -> Duration {
218    (deadline.saturating_duration_since(Instant::now()) / 2)
219        .min(Duration::from_secs_f64(EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS))
220        .max(Duration::from_millis(1))
221}
222
223pub(crate) fn is_workerproxy_draining(status: &Status) -> bool {
224    status.code() == Code::Unavailable && status.message().to_lowercase().contains("draining")
225}
226
227/// Whether a retryable failure should also drop the cached channel before the
228/// next attempt. The connection itself is suspect when the target is draining,
229/// on a server-enforced deadline (`DeadlineExceeded`), or on any client-side
230/// transport failure (a half-open socket, keepalive timeout, failed connect, or
231/// a fired per-attempt `set_timeout`, all of which tonic surfaces as a status
232/// carrying a transport `source`). Reusing such a channel would burn every retry
233/// on the dead connection, so dial fresh instead. A server-sent transient (no
234/// source, not draining, not a deadline) keeps the connection.
235pub(crate) fn should_invalidate_channel(status: &Status) -> bool {
236    is_workerproxy_draining(status)
237        || status.code() == Code::DeadlineExceeded
238        || status.source().is_some()
239}
240
241pub(crate) fn should_retry_transient_exec_rpc(status: &Status, deadline: Instant) -> bool {
242    if Instant::now() >= deadline {
243        return false;
244    }
245    match status.code() {
246        Code::Unavailable | Code::DeadlineExceeded => true,
247        // A fired per-attempt `set_timeout` surfaces as `Cancelled` carrying the
248        // timeout as a source, as does a client-side transport cancel; both are
249        // transient and retryable. A server-sent `CANCELLED` (no source) is a
250        // deliberate cancellation and is left alone.
251        Code::Cancelled => status.source().is_some(),
252        // tonic maps client-side connection failures onto `Internal` (h2
253        // protocol errors: a GOAWAY, a stream reset, a dead keepalive) or
254        // `Unknown` (transport errors it cannot classify), and always attaches
255        // the underlying error as `source`. A status decoded from server
256        // response trailers never carries a source, so a source means the
257        // connection died, not that the server ruled — retry on a fresh channel
258        // (every RPC gated here is idempotent: launches dedupe on the
259        // idempotency key, stdin writes carry absolute offsets). The message
260        // fragments cover the same failures relayed as text without a source.
261        Code::Unknown | Code::Internal => {
262            status.source().is_some() || is_transient_transport_message(status.message())
263        }
264        _ => false,
265    }
266}
267
268/// Sleep before the next retry attempt, bounded by the max per-retry delay
269/// and the remaining budget; returns the doubled delay for the next round.
270pub(crate) async fn sleep_before_retry(delay: f64, deadline: Instant) -> f64 {
271    let mut sleep_for = delay.min(EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS);
272    let remaining = deadline
273        .saturating_duration_since(Instant::now())
274        .as_secs_f64();
275    if remaining <= 0.0 {
276        return delay;
277    }
278    sleep_for = sleep_for.min(remaining);
279    tokio::time::sleep(Duration::from_secs_f64(sleep_for.max(0.0))).await;
280    (delay * 2.0).min(EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS)
281}
282
283/// Build the `Bearer <key>` gRPC `authorization` metadata value, failing if the
284/// key has characters invalid in a metadata value. Shared by the worker-proxy
285/// and imagebuilder clients.
286pub(crate) fn bearer_metadata(api_key: &str) -> Result<AsciiMetadataValue, SailError> {
287    format!("Bearer {api_key}")
288        .parse()
289        .map_err(|_| SailError::Config {
290            message: "SAIL_API_KEY contains characters invalid in a gRPC metadata value"
291                .to_string(),
292        })
293}
294
295impl WorkerProxy {
296    /// Build a worker proxy that authenticates with `api_key`. Fails if the key
297    /// cannot form a valid gRPC `authorization` metadata value.
298    pub fn new(api_key: &str) -> Result<WorkerProxy, SailError> {
299        let authorization = bearer_metadata(api_key)?;
300        Ok(WorkerProxy {
301            channels: ChannelCache::new(),
302            authorization,
303        })
304    }
305
306    pub(crate) fn channels(&self) -> &ChannelCache {
307        &self.channels
308    }
309
310    pub(crate) fn client_for(
311        &self,
312        endpoint: &str,
313    ) -> Result<WorkerProxyServiceClient<Channel>, SailError> {
314        let channel = self.channels.get(endpoint)?;
315        Ok(WorkerProxyServiceClient::new(channel))
316    }
317
318    pub(crate) fn request_for<T>(
319        &self,
320        message: T,
321        extra_metadata: &[(String, String)],
322        timeout: Option<Duration>,
323    ) -> Result<Request<T>, SailError> {
324        let mut request = Request::new(message);
325        request
326            .metadata_mut()
327            .insert("authorization", self.authorization.clone());
328        for (key, value) in extra_metadata {
329            let key: AsciiMetadataKey = key.parse().map_err(|_| SailError::Config {
330                message: format!("invalid gRPC metadata key {key:?}"),
331            })?;
332            let value: AsciiMetadataValue = value.parse().map_err(|_| SailError::Config {
333                message: format!("invalid gRPC metadata value for key {key:?}"),
334            })?;
335            request.metadata_mut().insert(key, value);
336        }
337        if let Some(timeout) = timeout {
338            request.set_timeout(timeout);
339        }
340        Ok(request)
341    }
342
343    /// Wait for an exec to finish. The retry deadline starts at the FIRST
344    /// transient error, not at the call: a wait legitimately blocks for as
345    /// long as the guest command runs, so only consecutive failure time is
346    /// budgeted.
347    pub async fn wait_exec(
348        &self,
349        endpoint: &str,
350        sailbox_id: &str,
351        exec_request_id: &str,
352        retry_timeout: f64,
353    ) -> Result<WaitOutcome, SailError> {
354        let mut deadline: Option<Instant> = None;
355        let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
356        loop {
357            let message = pb::WaitSailboxExecRequest {
358                sailbox_id: sailbox_id.to_string(),
359                exec_request_id: exec_request_id.to_string(),
360            };
361            let request = self.request_for(message, &[], /* timeout */ None)?;
362            match self.client_for(endpoint)?.wait_sailbox_exec(request).await {
363                Ok(resp) => {
364                    let resp = resp.into_inner();
365                    return Ok(WaitOutcome {
366                        status: resp.status,
367                        stdout: resp.stdout,
368                        stderr: resp.stderr,
369                        exit_code: resp.return_code,
370                        timed_out: resp.timed_out,
371                        stdout_truncated: resp.stdout_truncated,
372                        stderr_truncated: resp.stderr_truncated,
373                    });
374                }
375                Err(status) => {
376                    let deadline = *deadline.get_or_insert_with(|| retry_deadline(retry_timeout));
377                    if !should_retry_transient_exec_rpc(&status, deadline) {
378                        return Err(SailError::from_exec_status(&status));
379                    }
380                    tracing::warn!(code = ?status.code(), endpoint, "retrying transient worker-proxy RPC");
381                    if should_invalidate_channel(&status) {
382                        self.channels.invalidate(endpoint);
383                    }
384                    delay = sleep_before_retry(delay, deadline).await;
385                }
386            }
387        }
388    }
389
390    /// Signal the guest command: SIGINT by default, SIGKILL if force.
391    /// `retry_timeout > 0` budgets retries across the saild
392    /// registration-gap window AND bounds each attempt with a per-call
393    /// gRPC deadline so a stuck connection cannot hang past the budget.
394    pub async fn cancel_exec(
395        &self,
396        endpoint: &str,
397        sailbox_id: &str,
398        exec_request_id: &str,
399        force: bool,
400        retry_timeout: f64,
401    ) -> Result<(), SailError> {
402        let deadline = retry_deadline(retry_timeout);
403        let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
404        loop {
405            let per_attempt_timeout = if retry_timeout > 0.0 {
406                // Cap each attempt so a half-open connection times out and the
407                // loop redials, instead of one attempt consuming the whole
408                // budget. retry_timeout == 0 keeps a single uncapped attempt
409                // (programmatic no-retry cancel).
410                Some(rpc_attempt_timeout(deadline))
411            } else {
412                None
413            };
414            let message = pb::CancelSailboxExecRequest {
415                sailbox_id: sailbox_id.to_string(),
416                exec_request_id: exec_request_id.to_string(),
417                force,
418            };
419            let request = self.request_for(message, &[], per_attempt_timeout)?;
420            match self
421                .client_for(endpoint)?
422                .cancel_sailbox_exec(request)
423                .await
424            {
425                Ok(_) => return Ok(()),
426                Err(status) => {
427                    if !should_retry_transient_exec_rpc(&status, deadline) {
428                        return Err(SailError::from_exec_status(&status));
429                    }
430                    tracing::warn!(code = ?status.code(), endpoint, "retrying transient worker-proxy RPC");
431                    if should_invalidate_channel(&status) {
432                        self.channels.invalidate(endpoint);
433                    }
434                    delay = sleep_before_retry(delay, deadline).await;
435                }
436            }
437        }
438    }
439
440    /// Open a streaming read of a guest file. The returned [`FileReader`] yields
441    /// chunks as they arrive; errors surface from its `next`.
442    ///
443    /// # Runtime
444    ///
445    /// Spawns the background pump on the calling task's tokio runtime, so call it
446    /// from within one (every binding does, via the shared runtime; an async host
447    /// from its own). The dialed channel co-locates on that runtime.
448    pub fn read_file(self: &Arc<Self>, endpoint: &str, sailbox_id: &str, path: &str) -> FileReader {
449        let (tx, rx) = mpsc::channel(FILE_CHANNEL_CAP);
450        let worker = Arc::clone(self);
451        let endpoint = endpoint.to_string();
452        let message = pb::ReadSailboxFileRequest {
453            sailbox_id: sailbox_id.to_string(),
454            path: path.to_string(),
455        };
456        let task = tokio::spawn(async move {
457            let request = match worker.request_for(message, &[], /* timeout */ None) {
458                Ok(request) => request,
459                Err(err) => {
460                    let _ = tx.send(Err(err)).await;
461                    return;
462                }
463            };
464            let mut client = match worker.client_for(&endpoint) {
465                Ok(client) => client,
466                Err(err) => {
467                    let _ = tx.send(Err(err)).await;
468                    return;
469                }
470            };
471            let mut stream = match client.read_sailbox_file(request).await {
472                Ok(resp) => resp.into_inner(),
473                Err(status) => {
474                    let _ = tx.send(Err(SailError::from_file_rpc_status(&status))).await;
475                    return;
476                }
477            };
478            loop {
479                match stream.message().await {
480                    Ok(Some(resp)) => {
481                        if !resp.data.is_empty() && tx.send(Ok(resp.data)).await.is_err() {
482                            return; // the reader was dropped
483                        }
484                    }
485                    Ok(None) => return,
486                    Err(status) => {
487                        let _ = tx.send(Err(SailError::from_file_rpc_status(&status))).await;
488                        return;
489                    }
490                }
491            }
492        });
493        FileReader {
494            rx: AsyncMutex::new(rx),
495            abort: task.abort_handle(),
496        }
497    }
498
499    /// Open a streaming write to a guest file. The caller feeds chunks via
500    /// [`FileWriter::write_chunk`] and ends with [`FileWriter::finish`], so a
501    /// large source is never buffered whole. The first chunk carries the
502    /// path/flags; the rest carry data only.
503    ///
504    /// # Runtime
505    ///
506    /// Spawns the streaming RPC on the calling task's tokio runtime, so call it
507    /// from within one. The dialed channel co-locates on that runtime.
508    pub fn write_file(
509        self: &Arc<Self>,
510        endpoint: &str,
511        sailbox_id: &str,
512        path: &str,
513        create_parents: bool,
514        mode: Option<u32>,
515    ) -> FileWriter {
516        let (tx, rx) = mpsc::channel(FILE_CHANNEL_CAP);
517        let worker = Arc::clone(self);
518        let endpoint = endpoint.to_string();
519        // Build the client and run the RPC inside the spawned task so the
520        // channel is dialed in the runtime's reactor context.
521        let task = tokio::spawn(async move {
522            let request =
523                worker.request_for(ReceiverStream::new(rx), &[], /* timeout */ None)?;
524            worker
525                .client_for(&endpoint)?
526                .write_sailbox_file(request)
527                .await
528                .map(|_| ())
529                .map_err(|status| SailError::from_file_rpc_status(&status))
530        });
531        FileWriter {
532            tx: Some(tx),
533            task: Some(task),
534            aborted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
535            first: true,
536            sailbox_id: sailbox_id.to_string(),
537            path: path.to_string(),
538            create_parents,
539            mode,
540        }
541    }
542}
543
544/// A streaming write to a guest file. Chunks feed a bounded channel that backs
545/// the client-streaming RPC, so a slow uplink applies backpressure rather than
546/// buffering the source. The RPC result surfaces from [`FileWriter::finish`],
547/// and only `finish` commits the write: dropping (or [`FileWriter::abort`]ing)
548/// an unfinished writer cancels the RPC instead of half-closing into what the
549/// server would treat as a completed write. The guest file state after an
550/// abort is unspecified (the write was never confirmed).
551pub struct FileWriter {
552    tx: Option<mpsc::Sender<pb::WriteSailboxFileRequest>>,
553    task: Option<tokio::task::JoinHandle<Result<(), SailError>>>,
554    aborted: Arc<std::sync::atomic::AtomicBool>,
555    first: bool,
556    sailbox_id: String,
557    path: String,
558    create_parents: bool,
559    mode: Option<u32>,
560}
561
562impl FileWriter {
563    fn build(&mut self, data: Vec<u8>) -> pb::WriteSailboxFileRequest {
564        let header = self.first;
565        self.first = false;
566        pb::WriteSailboxFileRequest {
567            sailbox_id: if header {
568                self.sailbox_id.clone()
569            } else {
570                String::new()
571            },
572            path: if header {
573                self.path.clone()
574            } else {
575                String::new()
576            },
577            data,
578            create_parents: header && self.create_parents,
579            mode: if header { self.mode } else { None },
580        }
581    }
582
583    /// Stop the stream and return the RPC's result.
584    async fn join(&mut self) -> Result<(), SailError> {
585        self.tx = None; // dropping the sender ends the client stream
586        match self.task.take() {
587            Some(task) => task.await.unwrap_or_else(|join_err| {
588                Err(SailError::Internal {
589                    message: format!("file write task failed: {join_err}"),
590                })
591            }),
592            None => Ok(()),
593        }
594    }
595
596    /// Write bytes to the file, splitting them into transport-sized chunks
597    /// ([`FILE_WRITE_CHUNK_BYTES`] each). This is the normal write path; use
598    /// [`FileWriter::write_chunk`] only to control message framing yourself.
599    pub async fn write(&mut self, data: &[u8]) -> Result<(), SailError> {
600        for chunk in data.chunks(FILE_WRITE_CHUNK_BYTES) {
601            self.write_chunk(chunk.to_vec()).await?;
602        }
603        Ok(())
604    }
605
606    /// Send one chunk of file data as a single transport message. Chunks must
607    /// not exceed [`FILE_WRITE_CHUNK_BYTES`]. If the RPC has already ended,
608    /// returns its result instead.
609    pub async fn write_chunk(&mut self, data: Vec<u8>) -> Result<(), SailError> {
610        if self.aborted.load(std::sync::atomic::Ordering::Relaxed) {
611            return Err(aborted_write());
612        }
613        let request = self.build(data);
614        match &self.tx {
615            // A send error means the RPC task already ended; surface its result.
616            Some(tx) if tx.send(request).await.is_ok() => Ok(()),
617            _ => self.join().await,
618        }
619    }
620
621    /// Finish the write and return the RPC's result, creating an empty file when
622    /// no chunks were sent.
623    pub async fn finish(&mut self) -> Result<(), SailError> {
624        if self.aborted.load(std::sync::atomic::Ordering::Relaxed) {
625            return Err(aborted_write());
626        }
627        if self.first {
628            // No chunks were written: send a header-only message so an empty
629            // file is still created.
630            let request = self.build(Vec::new());
631            if let Some(tx) = &self.tx {
632                let _ = tx.send(request).await;
633            }
634        }
635        self.join().await
636    }
637
638    /// Abort the write: cancel the RPC without the clean end-of-stream the
639    /// server would commit. Idempotent; a no-op after `finish`. Later `write`
640    /// or `finish` calls report the abort instead of succeeding.
641    pub fn abort(&mut self) {
642        // Cancel the RPC task before dropping the sender: dropping the
643        // channel first can wake the task into seeing end-of-stream and
644        // half-closing cleanly — exactly the commit an abort must prevent.
645        if let Some(task) = self.task.take() {
646            self.aborted
647                .store(true, std::sync::atomic::Ordering::Relaxed);
648            task.abort();
649        }
650        self.tx = None;
651    }
652
653    /// An out-of-band handle that aborts this write without borrowing the
654    /// writer, so a concurrent caller (the bindings' abort path) can cancel a
655    /// stalled or backpressured `write` instead of queueing behind it.
656    pub fn abort_handle(&self) -> WriteAbortHandle {
657        WriteAbortHandle {
658            aborted: Arc::clone(&self.aborted),
659            task: self
660                .task
661                .as_ref()
662                .map(tokio::task::JoinHandle::abort_handle),
663        }
664    }
665}
666
667/// Cancels a [`FileWriter`]'s RPC out of band. See [`FileWriter::abort`] for
668/// the semantics: only `finish` commits, and an aborted write reports the
669/// abort on later calls.
670#[derive(Clone)]
671pub struct WriteAbortHandle {
672    aborted: Arc<std::sync::atomic::AtomicBool>,
673    task: Option<tokio::task::AbortHandle>,
674}
675
676impl WriteAbortHandle {
677    /// Abort the write: cancel the RPC so the server does not commit it.
678    /// Idempotent; a no-op after `finish`.
679    pub fn abort(&self) {
680        self.aborted
681            .store(true, std::sync::atomic::Ordering::Relaxed);
682        if let Some(task) = &self.task {
683            task.abort();
684        }
685    }
686}
687
688fn aborted_write() -> SailError {
689    SailError::InvalidArgument {
690        message: "the write was aborted; nothing was committed".to_string(),
691    }
692}
693
694impl Drop for FileWriter {
695    fn drop(&mut self) {
696        // An unfinished writer must never half-close into a committed write.
697        self.abort();
698    }
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704
705    #[tokio::test]
706    async fn write_splits_at_the_transport_chunk_size() {
707        let (tx, mut rx) = mpsc::channel(16);
708        let mut writer = FileWriter {
709            tx: Some(tx),
710            task: Some(tokio::spawn(async { Ok(()) })),
711            aborted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
712            first: true,
713            sailbox_id: "sb_1".to_string(),
714            path: "/f".to_string(),
715            create_parents: true,
716            mode: None,
717        };
718        writer
719            .write(&vec![7u8; FILE_WRITE_CHUNK_BYTES * 2 + 10])
720            .await
721            .expect("write succeeds");
722        writer.finish().await.expect("finish succeeds");
723        let mut sizes = Vec::new();
724        while let Some(message) = rx.recv().await {
725            sizes.push(message.data.len());
726        }
727        assert_eq!(
728            sizes,
729            vec![FILE_WRITE_CHUNK_BYTES, FILE_WRITE_CHUNK_BYTES, 10]
730        );
731    }
732
733    #[test]
734    fn transient_codes_retry_within_deadline() {
735        let deadline = Instant::now() + Duration::from_secs(5);
736        assert!(should_retry_transient_exec_rpc(
737            &Status::unavailable("x"),
738            deadline
739        ));
740        assert!(should_retry_transient_exec_rpc(
741            &Status::deadline_exceeded("x"),
742            deadline
743        ));
744        assert!(should_retry_transient_exec_rpc(
745            &Status::unknown("HTTP/2 connection reset by remote"),
746            deadline
747        ));
748        assert!(!should_retry_transient_exec_rpc(
749            &Status::unknown("guest exploded"),
750            deadline
751        ));
752        assert!(!should_retry_transient_exec_rpc(
753            &Status::not_found("x"),
754            deadline
755        ));
756    }
757
758    #[test]
759    fn h2_connection_failures_retry_within_deadline() {
760        let deadline = Instant::now() + Duration::from_secs(5);
761        // A dropped HTTP/2 connection under exec-storm concurrency: tonic maps
762        // it to Internal/Unknown with the transport error attached as source.
763        let io = std::io::Error::new(
764            std::io::ErrorKind::ConnectionAborted,
765            "connection error: h2 protocol error: http2 error",
766        );
767        let transport = Status::from_error(Box::new(io));
768        assert!(transport.source().is_some());
769        assert!(should_retry_transient_exec_rpc(&transport, deadline));
770        // The same failure relayed as message text without a source (e.g. a
771        // proxy stringifying its upstream error) retries via the fragment list.
772        assert!(should_retry_transient_exec_rpc(
773            &Status::internal("h2 protocol error: http2 error"),
774            deadline
775        ));
776        assert!(should_retry_transient_exec_rpc(
777            &Status::unknown("connection error: keep-alive timed out"),
778            deadline
779        ));
780        // A genuine server-sent INTERNAL (trailer status, no source, no
781        // transport fragment) is a server verdict and is not retried.
782        assert!(!should_retry_transient_exec_rpc(
783            &Status::internal("guest agent panicked"),
784            deadline
785        ));
786    }
787
788    #[test]
789    fn fired_attempt_timeout_retries_but_server_cancel_does_not() {
790        let deadline = Instant::now() + Duration::from_secs(5);
791        // A fired per-attempt set_timeout reaches us as Cancelled carrying the
792        // timeout as a source: retry it.
793        let timed_out = Status::from_error(Box::new(tonic::TimeoutExpired(())));
794        assert_eq!(timed_out.code(), Code::Cancelled);
795        assert!(should_retry_transient_exec_rpc(&timed_out, deadline));
796        // A server-sent CANCELLED has no source: do not retry.
797        assert!(!should_retry_transient_exec_rpc(
798            &Status::cancelled("client went away"),
799            deadline
800        ));
801    }
802
803    #[test]
804    fn expired_deadline_never_retries() {
805        let deadline = Instant::now();
806        assert!(!should_retry_transient_exec_rpc(
807            &Status::unavailable("x"),
808            deadline
809        ));
810    }
811
812    #[test]
813    fn draining_detection_is_case_insensitive_and_code_scoped() {
814        assert!(is_workerproxy_draining(&Status::unavailable(
815            "workerproxy DRAINING for deploy"
816        )));
817        assert!(!is_workerproxy_draining(&Status::internal("draining")));
818        assert!(!is_workerproxy_draining(&Status::unavailable("lameduck")));
819    }
820
821    #[test]
822    fn rpc_attempt_timeout_caps_and_leaves_retry_headroom() {
823        let now = Instant::now();
824        // Far deadline: capped at the per-attempt ceiling.
825        let far = rpc_attempt_timeout(now + Duration::from_mins(1));
826        assert!(far <= Duration::from_secs_f64(EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS));
827        assert!(far > Duration::from_secs(5));
828        // Budget below the ceiling: a single attempt takes less than the whole
829        // budget (~half), so a retry can still land if it hangs.
830        let small = rpc_attempt_timeout(now + Duration::from_secs(5));
831        assert!(small < Duration::from_secs(5));
832        assert!(small <= Duration::from_secs(3));
833        // Past deadline: never zero, so the attempt still fires and fails fast.
834        assert_eq!(
835            rpc_attempt_timeout(now.checked_sub(Duration::from_secs(1)).unwrap()),
836            Duration::from_millis(1)
837        );
838    }
839
840    #[test]
841    fn invalidate_on_draining_or_transport_failure_only() {
842        // Draining: drop the channel even though it is a clean server status.
843        assert!(should_invalidate_channel(&Status::unavailable(
844            "workerproxy is draining"
845        )));
846        // A client-side transport failure carries a source: the connection is
847        // suspect, so dial fresh.
848        let io = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "socket closed");
849        assert!(should_invalidate_channel(&Status::from_error(Box::new(io))));
850        // A plain server-sent transient keeps the connection.
851        assert!(!should_invalidate_channel(&Status::unavailable(
852            "try again"
853        )));
854    }
855}