1use 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
25const FILE_CHANNEL_CAP: usize = 4;
28
29pub const FILE_WRITE_CHUNK_BYTES: usize = 1 << 20;
33
34pub(crate) const EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS: f64 = 0.2;
37pub(crate) const EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS: f64 = 2.0;
39pub(crate) const EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS: f64 = 10.0;
43
44pub(crate) const TRANSIENT_TRANSPORT_FRAGMENTS: &[&str] = &[
48 "endpoint closing",
49 "error reading server preface",
50 "connection reset",
51 "socket closed",
52 "transport is closing",
53];
54
55pub(crate) fn is_transient_transport_message(message: &str) -> bool {
58 let details = message.to_lowercase();
59 TRANSIENT_TRANSPORT_FRAGMENTS
60 .iter()
61 .any(|fragment| details.contains(fragment))
62}
63
64#[derive(Debug, Clone)]
67pub struct WaitOutcome {
68 pub status: i32,
70 pub stdout: String,
72 pub stderr: String,
74 pub return_code: i32,
76 pub timed_out: bool,
78 pub stdout_truncated: bool,
81 pub stderr_truncated: bool,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct Listener {
91 pub guest_port: u32,
93 pub protocol: crate::sailbox::types::Protocol,
95 pub route_status: crate::sailbox::types::ListenerRouteStatus,
97 #[serde(default)]
99 pub public_url: String,
100 #[serde(default)]
102 pub public_host: String,
103 #[serde(default)]
105 pub public_port: u32,
106}
107
108impl Listener {
109 pub fn is_active(&self) -> bool {
111 self.route_status == crate::sailbox::types::ListenerRouteStatus::Active
112 }
113}
114
115#[derive(Debug, Clone, Default)]
119pub struct UploadOptions {
120 pub create_parents: bool,
122 pub mode: Option<u32>,
124}
125
126pub struct FileReader {
131 rx: AsyncMutex<mpsc::Receiver<Result<Vec<u8>, SailError>>>,
132}
133
134impl FileReader {
135 pub async fn next(&self) -> Option<Result<Vec<u8>, SailError>> {
138 self.rx.lock().await.recv().await
139 }
140}
141
142#[doc(hidden)]
146pub struct WorkerProxy {
147 channels: ChannelCache,
148 authorization: AsciiMetadataValue,
149}
150
151pub(crate) fn retry_deadline(retry_timeout: f64) -> Instant {
156 let now = Instant::now();
157 if retry_timeout <= 0.0 {
158 return now;
159 }
160 now + Duration::from_secs_f64(retry_timeout.min(RETRY_FOREVER_SECS))
165}
166
167const RETRY_FOREVER_SECS: f64 = 100.0 * 365.0 * 24.0 * 60.0 * 60.0;
170
171pub(crate) fn rpc_attempt_timeout(deadline: Instant) -> Duration {
177 (deadline.saturating_duration_since(Instant::now()) / 2)
178 .min(Duration::from_secs_f64(EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS))
179 .max(Duration::from_millis(1))
180}
181
182pub(crate) fn is_workerproxy_draining(status: &Status) -> bool {
183 status.code() == Code::Unavailable && status.message().to_lowercase().contains("draining")
184}
185
186pub(crate) fn should_invalidate_channel(status: &Status) -> bool {
195 is_workerproxy_draining(status)
196 || status.code() == Code::DeadlineExceeded
197 || status.source().is_some()
198}
199
200pub(crate) fn should_retry_transient_exec_rpc(status: &Status, deadline: Instant) -> bool {
201 if Instant::now() >= deadline {
202 return false;
203 }
204 match status.code() {
205 Code::Unavailable | Code::DeadlineExceeded => true,
206 Code::Cancelled => status.source().is_some(),
211 Code::Unknown => is_transient_transport_message(status.message()),
212 _ => false,
213 }
214}
215
216pub(crate) async fn sleep_before_retry(delay: f64, deadline: Instant) -> f64 {
219 let mut sleep_for = delay.min(EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS);
220 let remaining = deadline
221 .saturating_duration_since(Instant::now())
222 .as_secs_f64();
223 if remaining <= 0.0 {
224 return delay;
225 }
226 sleep_for = sleep_for.min(remaining);
227 tokio::time::sleep(Duration::from_secs_f64(sleep_for.max(0.0))).await;
228 (delay * 2.0).min(EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS)
229}
230
231pub(crate) fn bearer_metadata(api_key: &str) -> Result<AsciiMetadataValue, SailError> {
235 format!("Bearer {api_key}")
236 .parse()
237 .map_err(|_| SailError::Config {
238 message: "SAIL_API_KEY contains characters invalid in a gRPC metadata value"
239 .to_string(),
240 })
241}
242
243impl WorkerProxy {
244 pub fn new(api_key: &str) -> Result<WorkerProxy, SailError> {
247 let authorization = bearer_metadata(api_key)?;
248 Ok(WorkerProxy {
249 channels: ChannelCache::new(),
250 authorization,
251 })
252 }
253
254 pub(crate) fn channels(&self) -> &ChannelCache {
255 &self.channels
256 }
257
258 pub(crate) fn client_for(
259 &self,
260 endpoint: &str,
261 ) -> Result<WorkerProxyServiceClient<Channel>, SailError> {
262 let channel = self.channels.get(endpoint)?;
263 Ok(WorkerProxyServiceClient::new(channel))
264 }
265
266 pub(crate) fn request_for<T>(
267 &self,
268 message: T,
269 extra_metadata: &[(String, String)],
270 timeout: Option<Duration>,
271 ) -> Result<Request<T>, SailError> {
272 let mut request = Request::new(message);
273 request
274 .metadata_mut()
275 .insert("authorization", self.authorization.clone());
276 for (key, value) in extra_metadata {
277 let key: AsciiMetadataKey = key.parse().map_err(|_| SailError::Config {
278 message: format!("invalid gRPC metadata key {key:?}"),
279 })?;
280 let value: AsciiMetadataValue = value.parse().map_err(|_| SailError::Config {
281 message: format!("invalid gRPC metadata value for key {key:?}"),
282 })?;
283 request.metadata_mut().insert(key, value);
284 }
285 if let Some(timeout) = timeout {
286 request.set_timeout(timeout);
287 }
288 Ok(request)
289 }
290
291 pub async fn wait_exec(
296 &self,
297 endpoint: &str,
298 sailbox_id: &str,
299 exec_request_id: &str,
300 retry_timeout: f64,
301 ) -> Result<WaitOutcome, SailError> {
302 let mut deadline: Option<Instant> = None;
303 let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
304 loop {
305 let message = pb::WaitSailboxExecRequest {
306 sailbox_id: sailbox_id.to_string(),
307 exec_request_id: exec_request_id.to_string(),
308 };
309 let request = self.request_for(message, &[], None)?;
310 match self.client_for(endpoint)?.wait_sailbox_exec(request).await {
311 Ok(resp) => {
312 let resp = resp.into_inner();
313 return Ok(WaitOutcome {
314 status: resp.status,
315 stdout: resp.stdout,
316 stderr: resp.stderr,
317 return_code: resp.return_code,
318 timed_out: resp.timed_out,
319 stdout_truncated: resp.stdout_truncated,
320 stderr_truncated: resp.stderr_truncated,
321 });
322 }
323 Err(status) => {
324 let deadline = *deadline.get_or_insert_with(|| retry_deadline(retry_timeout));
325 if !should_retry_transient_exec_rpc(&status, deadline) {
326 return Err(SailError::from_exec_status(&status));
327 }
328 tracing::warn!(code = ?status.code(), endpoint, "retrying transient worker-proxy RPC");
329 if should_invalidate_channel(&status) {
330 self.channels.invalidate(endpoint);
331 }
332 delay = sleep_before_retry(delay, deadline).await;
333 }
334 }
335 }
336 }
337
338 pub async fn cancel_exec(
343 &self,
344 endpoint: &str,
345 sailbox_id: &str,
346 exec_request_id: &str,
347 force: bool,
348 retry_timeout: f64,
349 ) -> Result<(), SailError> {
350 let deadline = retry_deadline(retry_timeout);
351 let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
352 loop {
353 let per_attempt_timeout = if retry_timeout > 0.0 {
354 Some(rpc_attempt_timeout(deadline))
359 } else {
360 None
361 };
362 let message = pb::CancelSailboxExecRequest {
363 sailbox_id: sailbox_id.to_string(),
364 exec_request_id: exec_request_id.to_string(),
365 force,
366 };
367 let request = self.request_for(message, &[], per_attempt_timeout)?;
368 match self
369 .client_for(endpoint)?
370 .cancel_sailbox_exec(request)
371 .await
372 {
373 Ok(_) => return Ok(()),
374 Err(status) => {
375 if !should_retry_transient_exec_rpc(&status, deadline) {
376 return Err(SailError::from_exec_status(&status));
377 }
378 tracing::warn!(code = ?status.code(), endpoint, "retrying transient worker-proxy RPC");
379 if should_invalidate_channel(&status) {
380 self.channels.invalidate(endpoint);
381 }
382 delay = sleep_before_retry(delay, deadline).await;
383 }
384 }
385 }
386 }
387
388 pub fn read_file(self: &Arc<Self>, endpoint: &str, sailbox_id: &str, path: &str) -> FileReader {
397 let (tx, rx) = mpsc::channel(FILE_CHANNEL_CAP);
398 let worker = Arc::clone(self);
399 let endpoint = endpoint.to_string();
400 let message = pb::ReadSailboxFileRequest {
401 sailbox_id: sailbox_id.to_string(),
402 path: path.to_string(),
403 };
404 tokio::spawn(async move {
405 let request = match worker.request_for(message, &[], None) {
406 Ok(request) => request,
407 Err(err) => {
408 let _ = tx.send(Err(err)).await;
409 return;
410 }
411 };
412 let mut client = match worker.client_for(&endpoint) {
413 Ok(client) => client,
414 Err(err) => {
415 let _ = tx.send(Err(err)).await;
416 return;
417 }
418 };
419 let mut stream = match client.read_sailbox_file(request).await {
420 Ok(resp) => resp.into_inner(),
421 Err(status) => {
422 let _ = tx.send(Err(SailError::from_file_rpc_status(&status))).await;
423 return;
424 }
425 };
426 loop {
427 match stream.message().await {
428 Ok(Some(resp)) => {
429 if !resp.data.is_empty() && tx.send(Ok(resp.data)).await.is_err() {
430 return; }
432 }
433 Ok(None) => return,
434 Err(status) => {
435 let _ = tx.send(Err(SailError::from_file_rpc_status(&status))).await;
436 return;
437 }
438 }
439 }
440 });
441 FileReader {
442 rx: AsyncMutex::new(rx),
443 }
444 }
445
446 pub fn write_file(
456 self: &Arc<Self>,
457 endpoint: &str,
458 sailbox_id: &str,
459 path: &str,
460 create_parents: bool,
461 mode: Option<u32>,
462 ) -> FileWriter {
463 let (tx, rx) = mpsc::channel(FILE_CHANNEL_CAP);
464 let worker = Arc::clone(self);
465 let endpoint = endpoint.to_string();
466 let task = tokio::spawn(async move {
469 let request = worker.request_for(ReceiverStream::new(rx), &[], None)?;
470 worker
471 .client_for(&endpoint)?
472 .write_sailbox_file(request)
473 .await
474 .map(|_| ())
475 .map_err(|status| SailError::from_file_rpc_status(&status))
476 });
477 FileWriter {
478 tx: Some(tx),
479 task: Some(task),
480 first: true,
481 sailbox_id: sailbox_id.to_string(),
482 path: path.to_string(),
483 create_parents,
484 mode,
485 }
486 }
487}
488
489pub struct FileWriter {
493 tx: Option<mpsc::Sender<pb::WriteSailboxFileRequest>>,
494 task: Option<tokio::task::JoinHandle<Result<(), SailError>>>,
495 first: bool,
496 sailbox_id: String,
497 path: String,
498 create_parents: bool,
499 mode: Option<u32>,
500}
501
502impl FileWriter {
503 fn build(&mut self, data: Vec<u8>) -> pb::WriteSailboxFileRequest {
504 let header = self.first;
505 self.first = false;
506 pb::WriteSailboxFileRequest {
507 sailbox_id: if header {
508 self.sailbox_id.clone()
509 } else {
510 String::new()
511 },
512 path: if header {
513 self.path.clone()
514 } else {
515 String::new()
516 },
517 data,
518 create_parents: header && self.create_parents,
519 mode: if header { self.mode } else { None },
520 }
521 }
522
523 async fn join(&mut self) -> Result<(), SailError> {
525 self.tx = None; match self.task.take() {
527 Some(task) => task.await.unwrap_or_else(|join_err| {
528 Err(SailError::Internal {
529 message: format!("file write task failed: {join_err}"),
530 })
531 }),
532 None => Ok(()),
533 }
534 }
535
536 pub async fn write_chunk(&mut self, data: Vec<u8>) -> Result<(), SailError> {
539 let request = self.build(data);
540 match &self.tx {
541 Some(tx) if tx.send(request).await.is_ok() => Ok(()),
543 _ => self.join().await,
544 }
545 }
546
547 pub async fn finish(&mut self) -> Result<(), SailError> {
550 if self.first {
551 let request = self.build(Vec::new());
554 if let Some(tx) = &self.tx {
555 let _ = tx.send(request).await;
556 }
557 }
558 self.join().await
559 }
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565
566 #[test]
567 fn transient_codes_retry_within_deadline() {
568 let deadline = Instant::now() + Duration::from_secs(5);
569 assert!(should_retry_transient_exec_rpc(
570 &Status::unavailable("x"),
571 deadline
572 ));
573 assert!(should_retry_transient_exec_rpc(
574 &Status::deadline_exceeded("x"),
575 deadline
576 ));
577 assert!(should_retry_transient_exec_rpc(
578 &Status::unknown("HTTP/2 connection reset by remote"),
579 deadline
580 ));
581 assert!(!should_retry_transient_exec_rpc(
582 &Status::unknown("guest exploded"),
583 deadline
584 ));
585 assert!(!should_retry_transient_exec_rpc(
586 &Status::not_found("x"),
587 deadline
588 ));
589 }
590
591 #[test]
592 fn fired_attempt_timeout_retries_but_server_cancel_does_not() {
593 let deadline = Instant::now() + Duration::from_secs(5);
594 let timed_out = Status::from_error(Box::new(tonic::TimeoutExpired(())));
597 assert_eq!(timed_out.code(), Code::Cancelled);
598 assert!(should_retry_transient_exec_rpc(&timed_out, deadline));
599 assert!(!should_retry_transient_exec_rpc(
601 &Status::cancelled("client went away"),
602 deadline
603 ));
604 }
605
606 #[test]
607 fn expired_deadline_never_retries() {
608 let deadline = Instant::now();
609 assert!(!should_retry_transient_exec_rpc(
610 &Status::unavailable("x"),
611 deadline
612 ));
613 }
614
615 #[test]
616 fn draining_detection_is_case_insensitive_and_code_scoped() {
617 assert!(is_workerproxy_draining(&Status::unavailable(
618 "workerproxy DRAINING for deploy"
619 )));
620 assert!(!is_workerproxy_draining(&Status::internal("draining")));
621 assert!(!is_workerproxy_draining(&Status::unavailable("lameduck")));
622 }
623
624 #[test]
625 fn rpc_attempt_timeout_caps_and_leaves_retry_headroom() {
626 let now = Instant::now();
627 let far = rpc_attempt_timeout(now + Duration::from_mins(1));
629 assert!(far <= Duration::from_secs_f64(EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS));
630 assert!(far > Duration::from_secs(5));
631 let small = rpc_attempt_timeout(now + Duration::from_secs(5));
634 assert!(small < Duration::from_secs(5));
635 assert!(small <= Duration::from_secs(3));
636 assert_eq!(
638 rpc_attempt_timeout(now.checked_sub(Duration::from_secs(1)).unwrap()),
639 Duration::from_millis(1)
640 );
641 }
642
643 #[test]
644 fn invalidate_on_draining_or_transport_failure_only() {
645 assert!(should_invalidate_channel(&Status::unavailable(
647 "workerproxy is draining"
648 )));
649 let io = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "socket closed");
652 assert!(should_invalidate_channel(&Status::from_error(Box::new(io))));
653 assert!(!should_invalidate_channel(&Status::unavailable(
655 "try again"
656 )));
657 }
658}