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