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