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