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 [`SailboxFs::write`](crate::SailboxFs::write) and
138/// [`SailboxFs::write_stream`](crate::SailboxFs::write_stream).
139/// `Default` creates missing parent directories and leaves the file's mode to
140/// 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        // The same source rule applies to EVERY code: h2 GOAWAY reasons map
265        // outside the set above — grpc-go's keepalive enforcement sends
266        // GOAWAY(ENHANCE_YOUR_CALM, "too_many_pings"), which tonic surfaces as
267        // RESOURCE_EXHAUSTED with the transport error as source. A genuine
268        // server-sent RESOURCE_EXHAUSTED (rate limit, message size) arrives in
269        // trailers with no source and is still surfaced, not retried.
270        _ => status.source().is_some(),
271    }
272}
273
274/// Sleep before the next retry attempt, bounded by the max per-retry delay
275/// and the remaining budget; returns the doubled delay for the next round.
276pub(crate) async fn sleep_before_retry(delay: f64, deadline: Instant) -> f64 {
277    let mut sleep_for = delay.min(EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS);
278    let remaining = deadline
279        .saturating_duration_since(Instant::now())
280        .as_secs_f64();
281    if remaining <= 0.0 {
282        return delay;
283    }
284    sleep_for = sleep_for.min(remaining);
285    tokio::time::sleep(Duration::from_secs_f64(sleep_for.max(0.0))).await;
286    (delay * 2.0).min(EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS)
287}
288
289/// Build the `Bearer <key>` gRPC `authorization` metadata value, failing if the
290/// key has characters invalid in a metadata value. Shared by the worker-proxy
291/// and imagebuilder clients.
292pub(crate) fn bearer_metadata(api_key: &str) -> Result<AsciiMetadataValue, SailError> {
293    format!("Bearer {api_key}")
294        .parse()
295        .map_err(|_| SailError::Config {
296            message: "SAIL_API_KEY contains characters invalid in a gRPC metadata value"
297                .to_string(),
298        })
299}
300
301impl WorkerProxy {
302    /// Build a worker proxy that authenticates with `api_key`. Fails if the key
303    /// cannot form a valid gRPC `authorization` metadata value.
304    pub fn new(api_key: &str) -> Result<WorkerProxy, SailError> {
305        let authorization = bearer_metadata(api_key)?;
306        Ok(WorkerProxy {
307            channels: ChannelCache::new(),
308            authorization,
309        })
310    }
311
312    pub(crate) fn channels(&self) -> &ChannelCache {
313        &self.channels
314    }
315
316    pub(crate) fn client_for(
317        &self,
318        endpoint: &str,
319    ) -> Result<WorkerProxyServiceClient<Channel>, SailError> {
320        let channel = self.channels.get(endpoint)?;
321        Ok(WorkerProxyServiceClient::new(channel))
322    }
323
324    pub(crate) fn request_for<T>(
325        &self,
326        message: T,
327        extra_metadata: &[(String, String)],
328        timeout: Option<Duration>,
329    ) -> Result<Request<T>, SailError> {
330        let mut request = Request::new(message);
331        request
332            .metadata_mut()
333            .insert("authorization", self.authorization.clone());
334        for (key, value) in extra_metadata {
335            let key: AsciiMetadataKey = key.parse().map_err(|_| SailError::Config {
336                message: format!("invalid gRPC metadata key {key:?}"),
337            })?;
338            let value: AsciiMetadataValue = value.parse().map_err(|_| SailError::Config {
339                message: format!("invalid gRPC metadata value for key {key:?}"),
340            })?;
341            request.metadata_mut().insert(key, value);
342        }
343        if let Some(timeout) = timeout {
344            request.set_timeout(timeout);
345        }
346        Ok(request)
347    }
348
349    /// Wait for an exec to finish. The retry deadline starts at the FIRST
350    /// transient error, not at the call: a wait legitimately blocks for as
351    /// long as the guest command runs, so only consecutive failure time is
352    /// budgeted.
353    pub async fn wait_exec(
354        &self,
355        endpoint: &str,
356        sailbox_id: &str,
357        exec_request_id: &str,
358        retry_timeout: f64,
359    ) -> Result<WaitOutcome, SailError> {
360        let mut deadline: Option<Instant> = None;
361        let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
362        loop {
363            let message = pb::WaitSailboxExecRequest {
364                sailbox_id: sailbox_id.to_string(),
365                exec_request_id: exec_request_id.to_string(),
366            };
367            let request = self.request_for(message, &[], /* timeout */ None)?;
368            match self.client_for(endpoint)?.wait_sailbox_exec(request).await {
369                Ok(resp) => {
370                    let resp = resp.into_inner();
371                    return Ok(WaitOutcome {
372                        status: resp.status,
373                        stdout: resp.stdout,
374                        stderr: resp.stderr,
375                        exit_code: resp.return_code,
376                        timed_out: resp.timed_out,
377                        stdout_truncated: resp.stdout_truncated,
378                        stderr_truncated: resp.stderr_truncated,
379                    });
380                }
381                Err(status) => {
382                    let deadline = *deadline.get_or_insert_with(|| retry_deadline(retry_timeout));
383                    if !should_retry_transient_exec_rpc(&status, deadline) {
384                        return Err(SailError::from_exec_status(&status));
385                    }
386                    tracing::warn!(code = ?status.code(), endpoint, "retrying transient worker-proxy RPC");
387                    if should_invalidate_channel(&status) {
388                        self.channels.invalidate(endpoint);
389                    }
390                    delay = sleep_before_retry(delay, deadline).await;
391                }
392            }
393        }
394    }
395
396    /// Signal the guest command: SIGINT by default, SIGKILL if force.
397    /// `retry_timeout > 0` budgets retries across the saild
398    /// registration-gap window AND bounds each attempt with a per-call
399    /// gRPC deadline so a stuck connection cannot hang past the budget.
400    pub async fn cancel_exec(
401        &self,
402        endpoint: &str,
403        sailbox_id: &str,
404        exec_request_id: &str,
405        force: bool,
406        retry_timeout: f64,
407    ) -> Result<(), SailError> {
408        let deadline = retry_deadline(retry_timeout);
409        let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
410        loop {
411            let per_attempt_timeout = if retry_timeout > 0.0 {
412                // Cap each attempt so a half-open connection times out and the
413                // loop redials, instead of one attempt consuming the whole
414                // budget. retry_timeout == 0 keeps a single uncapped attempt
415                // (programmatic no-retry cancel).
416                Some(rpc_attempt_timeout(deadline))
417            } else {
418                None
419            };
420            let message = pb::CancelSailboxExecRequest {
421                sailbox_id: sailbox_id.to_string(),
422                exec_request_id: exec_request_id.to_string(),
423                force,
424            };
425            let request = self.request_for(message, &[], per_attempt_timeout)?;
426            match self
427                .client_for(endpoint)?
428                .cancel_sailbox_exec(request)
429                .await
430            {
431                Ok(_) => return Ok(()),
432                Err(status) => {
433                    if !should_retry_transient_exec_rpc(&status, deadline) {
434                        return Err(SailError::from_exec_status(&status));
435                    }
436                    tracing::warn!(code = ?status.code(), endpoint, "retrying transient worker-proxy RPC");
437                    if should_invalidate_channel(&status) {
438                        self.channels.invalidate(endpoint);
439                    }
440                    delay = sleep_before_retry(delay, deadline).await;
441                }
442            }
443        }
444    }
445
446    /// Open a streaming read of a guest file. The returned [`FileReader`] yields
447    /// chunks as they arrive; errors surface from its `next`.
448    ///
449    /// # Runtime
450    ///
451    /// Spawns the background pump on the calling task's tokio runtime, so call it
452    /// from within one (every binding does, via the shared runtime; an async host
453    /// from its own). The dialed channel co-locates on that runtime.
454    pub fn read_file(self: &Arc<Self>, endpoint: &str, sailbox_id: &str, path: &str) -> FileReader {
455        let (tx, rx) = mpsc::channel(FILE_CHANNEL_CAP);
456        let worker = Arc::clone(self);
457        let endpoint = endpoint.to_string();
458        let message = pb::ReadSailboxFileRequest {
459            sailbox_id: sailbox_id.to_string(),
460            path: path.to_string(),
461        };
462        let task = tokio::spawn(async move {
463            let request = match worker.request_for(message, &[], /* timeout */ None) {
464                Ok(request) => request,
465                Err(err) => {
466                    let _ = tx.send(Err(err)).await;
467                    return;
468                }
469            };
470            let mut client = match worker.client_for(&endpoint) {
471                Ok(client) => client,
472                Err(err) => {
473                    let _ = tx.send(Err(err)).await;
474                    return;
475                }
476            };
477            let mut stream = match client.read_sailbox_file(request).await {
478                Ok(resp) => resp.into_inner(),
479                Err(status) => {
480                    let _ = tx.send(Err(SailError::from_file_rpc_status(&status))).await;
481                    return;
482                }
483            };
484            loop {
485                match stream.message().await {
486                    Ok(Some(resp)) => {
487                        if !resp.data.is_empty() && tx.send(Ok(resp.data)).await.is_err() {
488                            return; // the reader was dropped
489                        }
490                    }
491                    Ok(None) => return,
492                    Err(status) => {
493                        let _ = tx.send(Err(SailError::from_file_rpc_status(&status))).await;
494                        return;
495                    }
496                }
497            }
498        });
499        FileReader {
500            rx: AsyncMutex::new(rx),
501            abort: task.abort_handle(),
502        }
503    }
504
505    /// Open a streaming write to a guest file. The caller feeds chunks via
506    /// [`FileWriter::write_chunk`] and ends with [`FileWriter::finish`], so a
507    /// large source is never buffered whole. The first chunk carries the
508    /// path/flags; the rest carry data only.
509    ///
510    /// # Runtime
511    ///
512    /// Spawns the streaming RPC on the calling task's tokio runtime, so call it
513    /// from within one. The dialed channel co-locates on that runtime.
514    pub fn write_file(
515        self: &Arc<Self>,
516        endpoint: &str,
517        sailbox_id: &str,
518        path: &str,
519        create_parents: bool,
520        mode: Option<u32>,
521    ) -> FileWriter {
522        let (tx, rx) = mpsc::channel(FILE_CHANNEL_CAP);
523        let worker = Arc::clone(self);
524        let endpoint = endpoint.to_string();
525        // Build the client and run the RPC inside the spawned task so the
526        // channel is dialed in the runtime's reactor context.
527        let task = tokio::spawn(async move {
528            let request =
529                worker.request_for(ReceiverStream::new(rx), &[], /* timeout */ None)?;
530            worker
531                .client_for(&endpoint)?
532                .write_sailbox_file(request)
533                .await
534                .map(|_| ())
535                .map_err(|status| SailError::from_file_rpc_status(&status))
536        });
537        FileWriter {
538            tx: Some(tx),
539            task: Some(task),
540            aborted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
541            first: true,
542            sailbox_id: sailbox_id.to_string(),
543            path: path.to_string(),
544            create_parents,
545            mode,
546        }
547    }
548}
549
550/// A streaming write to a guest file. Chunks feed a bounded channel that backs
551/// the client-streaming RPC, so a slow uplink applies backpressure rather than
552/// buffering the source. The RPC result surfaces from [`FileWriter::finish`],
553/// and only `finish` commits the write: dropping (or [`FileWriter::abort`]ing)
554/// an unfinished writer cancels the RPC instead of half-closing into what the
555/// server would treat as a completed write. The guest file state after an
556/// abort is unspecified (the write was never confirmed).
557pub struct FileWriter {
558    tx: Option<mpsc::Sender<pb::WriteSailboxFileRequest>>,
559    task: Option<tokio::task::JoinHandle<Result<(), SailError>>>,
560    aborted: Arc<std::sync::atomic::AtomicBool>,
561    first: bool,
562    sailbox_id: String,
563    path: String,
564    create_parents: bool,
565    mode: Option<u32>,
566}
567
568impl FileWriter {
569    fn build(&mut self, data: Vec<u8>) -> pb::WriteSailboxFileRequest {
570        let header = self.first;
571        self.first = false;
572        pb::WriteSailboxFileRequest {
573            sailbox_id: if header {
574                self.sailbox_id.clone()
575            } else {
576                String::new()
577            },
578            path: if header {
579                self.path.clone()
580            } else {
581                String::new()
582            },
583            data,
584            create_parents: header && self.create_parents,
585            mode: if header { self.mode } else { None },
586        }
587    }
588
589    /// Stop the stream and return the RPC's result.
590    async fn join(&mut self) -> Result<(), SailError> {
591        self.tx = None; // dropping the sender ends the client stream
592        match self.task.take() {
593            Some(task) => task.await.unwrap_or_else(|join_err| {
594                Err(SailError::Internal {
595                    message: format!("file write task failed: {join_err}"),
596                })
597            }),
598            None => Ok(()),
599        }
600    }
601
602    /// Write bytes to the file, splitting them into transport-sized chunks
603    /// ([`FILE_WRITE_CHUNK_BYTES`] each). This is the normal write path; use
604    /// [`FileWriter::write_chunk`] only to control message framing yourself.
605    pub async fn write(&mut self, data: &[u8]) -> Result<(), SailError> {
606        for chunk in data.chunks(FILE_WRITE_CHUNK_BYTES) {
607            self.write_chunk(chunk.to_vec()).await?;
608        }
609        Ok(())
610    }
611
612    /// Send one chunk of file data as a single transport message. Chunks must
613    /// not exceed [`FILE_WRITE_CHUNK_BYTES`]. If the RPC has already ended,
614    /// returns its result instead.
615    pub async fn write_chunk(&mut self, data: Vec<u8>) -> Result<(), SailError> {
616        if self.aborted.load(std::sync::atomic::Ordering::Relaxed) {
617            return Err(aborted_write());
618        }
619        let request = self.build(data);
620        match &self.tx {
621            // A send error means the RPC task already ended; surface its result.
622            Some(tx) if tx.send(request).await.is_ok() => Ok(()),
623            _ => self.join().await,
624        }
625    }
626
627    /// Finish the write and return the RPC's result, creating an empty file when
628    /// no chunks were sent.
629    pub async fn finish(&mut self) -> Result<(), SailError> {
630        if self.aborted.load(std::sync::atomic::Ordering::Relaxed) {
631            return Err(aborted_write());
632        }
633        if self.first {
634            // No chunks were written: send a header-only message so an empty
635            // file is still created.
636            let request = self.build(Vec::new());
637            if let Some(tx) = &self.tx {
638                let _ = tx.send(request).await;
639            }
640        }
641        self.join().await
642    }
643
644    /// Abort the write: cancel the RPC without the clean end-of-stream the
645    /// server would commit. Idempotent; a no-op after `finish`. Later `write`
646    /// or `finish` calls report the abort instead of succeeding.
647    pub fn abort(&mut self) {
648        // Cancel the RPC task before dropping the sender: dropping the
649        // channel first can wake the task into seeing end-of-stream and
650        // half-closing cleanly — exactly the commit an abort must prevent.
651        if let Some(task) = self.task.take() {
652            self.aborted
653                .store(true, std::sync::atomic::Ordering::Relaxed);
654            task.abort();
655        }
656        self.tx = None;
657    }
658
659    /// An out-of-band handle that aborts this write without borrowing the
660    /// writer, so a concurrent caller (the bindings' abort path) can cancel a
661    /// stalled or backpressured `write` instead of queueing behind it.
662    pub fn abort_handle(&self) -> WriteAbortHandle {
663        WriteAbortHandle {
664            aborted: Arc::clone(&self.aborted),
665            task: self
666                .task
667                .as_ref()
668                .map(tokio::task::JoinHandle::abort_handle),
669        }
670    }
671}
672
673/// Cancels a [`FileWriter`]'s RPC out of band. See [`FileWriter::abort`] for
674/// the semantics: only `finish` commits, and an aborted write reports the
675/// abort on later calls.
676#[derive(Clone)]
677pub struct WriteAbortHandle {
678    aborted: Arc<std::sync::atomic::AtomicBool>,
679    task: Option<tokio::task::AbortHandle>,
680}
681
682impl WriteAbortHandle {
683    /// Abort the write: cancel the RPC so the server does not commit it.
684    /// Idempotent; a no-op after `finish`.
685    pub fn abort(&self) {
686        self.aborted
687            .store(true, std::sync::atomic::Ordering::Relaxed);
688        if let Some(task) = &self.task {
689            task.abort();
690        }
691    }
692}
693
694fn aborted_write() -> SailError {
695    SailError::InvalidArgument {
696        message: "the write was aborted; nothing was committed".to_string(),
697    }
698}
699
700impl Drop for FileWriter {
701    fn drop(&mut self) {
702        // An unfinished writer must never half-close into a committed write.
703        self.abort();
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710
711    #[tokio::test]
712    async fn write_splits_at_the_transport_chunk_size() {
713        let (tx, mut rx) = mpsc::channel(16);
714        let mut writer = FileWriter {
715            tx: Some(tx),
716            task: Some(tokio::spawn(async { Ok(()) })),
717            aborted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
718            first: true,
719            sailbox_id: "sb_1".to_string(),
720            path: "/f".to_string(),
721            create_parents: true,
722            mode: None,
723        };
724        writer
725            .write(&vec![7u8; FILE_WRITE_CHUNK_BYTES * 2 + 10])
726            .await
727            .expect("write succeeds");
728        writer.finish().await.expect("finish succeeds");
729        let mut sizes = Vec::new();
730        while let Some(message) = rx.recv().await {
731            sizes.push(message.data.len());
732        }
733        assert_eq!(
734            sizes,
735            vec![FILE_WRITE_CHUNK_BYTES, FILE_WRITE_CHUNK_BYTES, 10]
736        );
737    }
738
739    #[test]
740    fn transient_codes_retry_within_deadline() {
741        let deadline = Instant::now() + Duration::from_secs(5);
742        assert!(should_retry_transient_exec_rpc(
743            &Status::unavailable("x"),
744            deadline
745        ));
746        assert!(should_retry_transient_exec_rpc(
747            &Status::deadline_exceeded("x"),
748            deadline
749        ));
750        assert!(should_retry_transient_exec_rpc(
751            &Status::unknown("HTTP/2 connection reset by remote"),
752            deadline
753        ));
754        assert!(!should_retry_transient_exec_rpc(
755            &Status::unknown("guest exploded"),
756            deadline
757        ));
758        assert!(!should_retry_transient_exec_rpc(
759            &Status::not_found("x"),
760            deadline
761        ));
762    }
763
764    #[test]
765    fn h2_connection_failures_retry_within_deadline() {
766        let deadline = Instant::now() + Duration::from_secs(5);
767        // A dropped HTTP/2 connection under exec-storm concurrency: tonic maps
768        // it to Internal/Unknown with the transport error attached as source.
769        let io = std::io::Error::new(
770            std::io::ErrorKind::ConnectionAborted,
771            "connection error: h2 protocol error: http2 error",
772        );
773        let transport = Status::from_error(Box::new(io));
774        assert!(transport.source().is_some());
775        assert!(should_retry_transient_exec_rpc(&transport, deadline));
776        // The same failure relayed as message text without a source (e.g. a
777        // proxy stringifying its upstream error) retries via the fragment list.
778        assert!(should_retry_transient_exec_rpc(
779            &Status::internal("h2 protocol error: http2 error"),
780            deadline
781        ));
782        assert!(should_retry_transient_exec_rpc(
783            &Status::unknown("connection error: keep-alive timed out"),
784            deadline
785        ));
786        // A genuine server-sent INTERNAL (trailer status, no source, no
787        // transport fragment) is a server verdict and is not retried.
788        assert!(!should_retry_transient_exec_rpc(
789            &Status::internal("guest agent panicked"),
790            deadline
791        ));
792    }
793
794    #[test]
795    fn goaway_reason_statuses_retry_only_with_transport_source() {
796        let deadline = Instant::now() + Duration::from_secs(5);
797        // grpc-go keepalive enforcement (the workerproxy's 10s MinTime) sends
798        // GOAWAY(ENHANCE_YOUR_CALM, "too_many_pings") when a stalled client's
799        // queued ping timers fire in a burst. tonic maps that reason to
800        // RESOURCE_EXHAUSTED with the h2 error as source — a dead connection,
801        // not a server verdict. Reproduced live against a raw-framer server:
802        // code=ResourceExhausted, message="h2 protocol error: http2 error".
803        let mut goaway = Status::new(Code::ResourceExhausted, "h2 protocol error: http2 error");
804        goaway.set_source(std::sync::Arc::new(std::io::Error::new(
805            std::io::ErrorKind::ConnectionReset,
806            "transport error",
807        )));
808        assert!(should_retry_transient_exec_rpc(&goaway, deadline));
809        // A genuine server-sent RESOURCE_EXHAUSTED (rate limit, message-size
810        // cap) arrives via trailers with no source: surfaced, never retried.
811        assert!(!should_retry_transient_exec_rpc(
812            &Status::resource_exhausted("org concurrency limit reached"),
813            deadline
814        ));
815        // Same rule for the other GOAWAY-reason escape (INADEQUATE_SECURITY
816        // -> PermissionDenied) and any future reason mapping.
817        let mut sec = Status::new(Code::PermissionDenied, "h2 protocol error: http2 error");
818        sec.set_source(std::sync::Arc::new(std::io::Error::new(
819            std::io::ErrorKind::ConnectionReset,
820            "transport error",
821        )));
822        assert!(should_retry_transient_exec_rpc(&sec, deadline));
823        assert!(!should_retry_transient_exec_rpc(
824            &Status::permission_denied("invalid API key"),
825            deadline
826        ));
827    }
828
829    #[test]
830    fn fired_attempt_timeout_retries_but_server_cancel_does_not() {
831        let deadline = Instant::now() + Duration::from_secs(5);
832        // A fired per-attempt set_timeout reaches us as Cancelled carrying the
833        // timeout as a source: retry it.
834        let timed_out = Status::from_error(Box::new(tonic::TimeoutExpired(())));
835        assert_eq!(timed_out.code(), Code::Cancelled);
836        assert!(should_retry_transient_exec_rpc(&timed_out, deadline));
837        // A server-sent CANCELLED has no source: do not retry.
838        assert!(!should_retry_transient_exec_rpc(
839            &Status::cancelled("client went away"),
840            deadline
841        ));
842    }
843
844    #[test]
845    fn expired_deadline_never_retries() {
846        let deadline = Instant::now();
847        assert!(!should_retry_transient_exec_rpc(
848            &Status::unavailable("x"),
849            deadline
850        ));
851    }
852
853    #[test]
854    fn draining_detection_is_case_insensitive_and_code_scoped() {
855        assert!(is_workerproxy_draining(&Status::unavailable(
856            "workerproxy DRAINING for deploy"
857        )));
858        assert!(!is_workerproxy_draining(&Status::internal("draining")));
859        assert!(!is_workerproxy_draining(&Status::unavailable("lameduck")));
860    }
861
862    #[test]
863    fn rpc_attempt_timeout_caps_and_leaves_retry_headroom() {
864        let now = Instant::now();
865        // Far deadline: capped at the per-attempt ceiling.
866        let far = rpc_attempt_timeout(now + Duration::from_mins(1));
867        assert!(far <= Duration::from_secs_f64(EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS));
868        assert!(far > Duration::from_secs(5));
869        // Budget below the ceiling: a single attempt takes less than the whole
870        // budget (~half), so a retry can still land if it hangs.
871        let small = rpc_attempt_timeout(now + Duration::from_secs(5));
872        assert!(small < Duration::from_secs(5));
873        assert!(small <= Duration::from_secs(3));
874        // Past deadline: never zero, so the attempt still fires and fails fast.
875        assert_eq!(
876            rpc_attempt_timeout(now.checked_sub(Duration::from_secs(1)).unwrap()),
877            Duration::from_millis(1)
878        );
879    }
880
881    #[test]
882    fn invalidate_on_draining_or_transport_failure_only() {
883        // Draining: drop the channel even though it is a clean server status.
884        assert!(should_invalidate_channel(&Status::unavailable(
885            "workerproxy is draining"
886        )));
887        // A client-side transport failure carries a source: the connection is
888        // suspect, so dial fresh.
889        let io = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "socket closed");
890        assert!(should_invalidate_channel(&Status::from_error(Box::new(io))));
891        // A plain server-sent transient keeps the connection.
892        assert!(!should_invalidate_channel(&Status::unavailable(
893            "try again"
894        )));
895    }
896}