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