use std::error::Error as _;
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use tokio::sync::Mutex as AsyncMutex;
use tokio_stream::wrappers::ReceiverStream;
use tonic::metadata::{AsciiMetadataKey, AsciiMetadataValue};
use tonic::transport::Channel;
use tonic::{Code, Request, Status};
use crate::channels::ChannelCache;
use crate::error::SailError;
use crate::pb::workerproxy::v1 as pb;
use pb::worker_proxy_service_client::WorkerProxyServiceClient;
const FILE_CHANNEL_CAP: usize = 4;
pub const FILE_WRITE_CHUNK_BYTES: usize = 1 << 20;
pub(crate) const EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS: f64 = 0.2;
pub(crate) const EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS: f64 = 2.0;
pub(crate) const EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS: f64 = 10.0;
pub(crate) const TRANSIENT_TRANSPORT_FRAGMENTS: &[&str] = &[
"endpoint closing",
"error reading server preface",
"connection reset",
"socket closed",
"transport is closing",
"h2 protocol error",
"keep-alive timed out",
];
pub(crate) fn is_transient_transport_message(message: &str) -> bool {
let details = message.to_lowercase();
TRANSIENT_TRANSPORT_FRAGMENTS
.iter()
.any(|fragment| details.contains(fragment))
}
#[doc(hidden)]
#[derive(Debug, Clone)]
pub struct WaitOutcome {
pub status: i32,
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub timed_out: bool,
pub stdout_truncated: bool,
pub stderr_truncated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Listener {
pub guest_port: u32,
pub protocol: crate::sailbox::types::ListenerProtocol,
pub route_status: crate::sailbox::types::ListenerRouteStatus,
#[serde(default)]
pub public_url: String,
#[serde(default)]
pub public_host: String,
#[serde(default)]
pub public_port: u32,
}
impl Listener {
pub fn endpoint(&self) -> Option<crate::sailbox::types::ListenerEndpoint> {
use crate::sailbox::types::ListenerEndpoint;
if !self.public_url.is_empty() {
return Some(ListenerEndpoint::Http {
url: self.public_url.clone(),
});
}
if !self.public_host.is_empty() && self.public_port != 0 {
return Some(ListenerEndpoint::Tcp {
host: self.public_host.clone(),
port: self.public_port,
});
}
None
}
pub fn is_active(&self) -> bool {
self.route_status == crate::sailbox::types::ListenerRouteStatus::Active
}
}
#[derive(Debug, Clone)]
pub struct WriteOptions {
pub create_parents: bool,
pub mode: Option<u32>,
}
impl Default for WriteOptions {
fn default() -> WriteOptions {
WriteOptions {
create_parents: true,
mode: None,
}
}
}
pub struct FileReader {
rx: AsyncMutex<mpsc::Receiver<Result<Vec<u8>, SailError>>>,
abort: tokio::task::AbortHandle,
}
impl FileReader {
pub async fn next(&self) -> Option<Result<Vec<u8>, SailError>> {
self.rx.lock().await.recv().await
}
pub fn close(&self) {
self.abort.abort();
}
}
#[doc(hidden)]
pub struct WorkerProxy {
channels: ChannelCache,
authorization: AsciiMetadataValue,
}
pub(crate) fn retry_deadline(retry_timeout: f64) -> Instant {
let now = Instant::now();
if retry_timeout <= 0.0 {
return now;
}
now + Duration::from_secs_f64(retry_timeout.min(RETRY_FOREVER_SECS))
}
const RETRY_FOREVER_SECS: f64 = 100.0 * 365.0 * 24.0 * 60.0 * 60.0;
pub(crate) fn rpc_attempt_timeout(deadline: Instant) -> Duration {
(deadline.saturating_duration_since(Instant::now()) / 2)
.min(Duration::from_secs_f64(EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS))
.max(Duration::from_millis(1))
}
pub(crate) fn is_workerproxy_draining(status: &Status) -> bool {
status.code() == Code::Unavailable && status.message().to_lowercase().contains("draining")
}
pub(crate) fn should_invalidate_channel(status: &Status) -> bool {
is_workerproxy_draining(status)
|| status.code() == Code::DeadlineExceeded
|| status.source().is_some()
}
pub(crate) fn should_retry_transient_exec_rpc(status: &Status, deadline: Instant) -> bool {
if Instant::now() >= deadline {
return false;
}
match status.code() {
Code::Unavailable | Code::DeadlineExceeded => true,
Code::Cancelled => status.source().is_some(),
Code::Unknown | Code::Internal => {
status.source().is_some() || is_transient_transport_message(status.message())
}
_ => false,
}
}
pub(crate) async fn sleep_before_retry(delay: f64, deadline: Instant) -> f64 {
let mut sleep_for = delay.min(EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS);
let remaining = deadline
.saturating_duration_since(Instant::now())
.as_secs_f64();
if remaining <= 0.0 {
return delay;
}
sleep_for = sleep_for.min(remaining);
tokio::time::sleep(Duration::from_secs_f64(sleep_for.max(0.0))).await;
(delay * 2.0).min(EXEC_TRANSIENT_RETRY_MAX_DELAY_SECONDS)
}
pub(crate) fn bearer_metadata(api_key: &str) -> Result<AsciiMetadataValue, SailError> {
format!("Bearer {api_key}")
.parse()
.map_err(|_| SailError::Config {
message: "SAIL_API_KEY contains characters invalid in a gRPC metadata value"
.to_string(),
})
}
impl WorkerProxy {
pub fn new(api_key: &str) -> Result<WorkerProxy, SailError> {
let authorization = bearer_metadata(api_key)?;
Ok(WorkerProxy {
channels: ChannelCache::new(),
authorization,
})
}
pub(crate) fn channels(&self) -> &ChannelCache {
&self.channels
}
pub(crate) fn client_for(
&self,
endpoint: &str,
) -> Result<WorkerProxyServiceClient<Channel>, SailError> {
let channel = self.channels.get(endpoint)?;
Ok(WorkerProxyServiceClient::new(channel))
}
pub(crate) fn request_for<T>(
&self,
message: T,
extra_metadata: &[(String, String)],
timeout: Option<Duration>,
) -> Result<Request<T>, SailError> {
let mut request = Request::new(message);
request
.metadata_mut()
.insert("authorization", self.authorization.clone());
for (key, value) in extra_metadata {
let key: AsciiMetadataKey = key.parse().map_err(|_| SailError::Config {
message: format!("invalid gRPC metadata key {key:?}"),
})?;
let value: AsciiMetadataValue = value.parse().map_err(|_| SailError::Config {
message: format!("invalid gRPC metadata value for key {key:?}"),
})?;
request.metadata_mut().insert(key, value);
}
if let Some(timeout) = timeout {
request.set_timeout(timeout);
}
Ok(request)
}
pub async fn wait_exec(
&self,
endpoint: &str,
sailbox_id: &str,
exec_request_id: &str,
retry_timeout: f64,
) -> Result<WaitOutcome, SailError> {
let mut deadline: Option<Instant> = None;
let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
loop {
let message = pb::WaitSailboxExecRequest {
sailbox_id: sailbox_id.to_string(),
exec_request_id: exec_request_id.to_string(),
};
let request = self.request_for(message, &[], None)?;
match self.client_for(endpoint)?.wait_sailbox_exec(request).await {
Ok(resp) => {
let resp = resp.into_inner();
return Ok(WaitOutcome {
status: resp.status,
stdout: resp.stdout,
stderr: resp.stderr,
exit_code: resp.return_code,
timed_out: resp.timed_out,
stdout_truncated: resp.stdout_truncated,
stderr_truncated: resp.stderr_truncated,
});
}
Err(status) => {
let deadline = *deadline.get_or_insert_with(|| retry_deadline(retry_timeout));
if !should_retry_transient_exec_rpc(&status, deadline) {
return Err(SailError::from_exec_status(&status));
}
tracing::warn!(code = ?status.code(), endpoint, "retrying transient worker-proxy RPC");
if should_invalidate_channel(&status) {
self.channels.invalidate(endpoint);
}
delay = sleep_before_retry(delay, deadline).await;
}
}
}
}
pub async fn cancel_exec(
&self,
endpoint: &str,
sailbox_id: &str,
exec_request_id: &str,
force: bool,
retry_timeout: f64,
) -> Result<(), SailError> {
let deadline = retry_deadline(retry_timeout);
let mut delay = EXEC_TRANSIENT_RETRY_INITIAL_DELAY_SECONDS;
loop {
let per_attempt_timeout = if retry_timeout > 0.0 {
Some(rpc_attempt_timeout(deadline))
} else {
None
};
let message = pb::CancelSailboxExecRequest {
sailbox_id: sailbox_id.to_string(),
exec_request_id: exec_request_id.to_string(),
force,
};
let request = self.request_for(message, &[], per_attempt_timeout)?;
match self
.client_for(endpoint)?
.cancel_sailbox_exec(request)
.await
{
Ok(_) => return Ok(()),
Err(status) => {
if !should_retry_transient_exec_rpc(&status, deadline) {
return Err(SailError::from_exec_status(&status));
}
tracing::warn!(code = ?status.code(), endpoint, "retrying transient worker-proxy RPC");
if should_invalidate_channel(&status) {
self.channels.invalidate(endpoint);
}
delay = sleep_before_retry(delay, deadline).await;
}
}
}
}
pub fn read_file(self: &Arc<Self>, endpoint: &str, sailbox_id: &str, path: &str) -> FileReader {
let (tx, rx) = mpsc::channel(FILE_CHANNEL_CAP);
let worker = Arc::clone(self);
let endpoint = endpoint.to_string();
let message = pb::ReadSailboxFileRequest {
sailbox_id: sailbox_id.to_string(),
path: path.to_string(),
};
let task = tokio::spawn(async move {
let request = match worker.request_for(message, &[], None) {
Ok(request) => request,
Err(err) => {
let _ = tx.send(Err(err)).await;
return;
}
};
let mut client = match worker.client_for(&endpoint) {
Ok(client) => client,
Err(err) => {
let _ = tx.send(Err(err)).await;
return;
}
};
let mut stream = match client.read_sailbox_file(request).await {
Ok(resp) => resp.into_inner(),
Err(status) => {
let _ = tx.send(Err(SailError::from_file_rpc_status(&status))).await;
return;
}
};
loop {
match stream.message().await {
Ok(Some(resp)) => {
if !resp.data.is_empty() && tx.send(Ok(resp.data)).await.is_err() {
return; }
}
Ok(None) => return,
Err(status) => {
let _ = tx.send(Err(SailError::from_file_rpc_status(&status))).await;
return;
}
}
}
});
FileReader {
rx: AsyncMutex::new(rx),
abort: task.abort_handle(),
}
}
pub fn write_file(
self: &Arc<Self>,
endpoint: &str,
sailbox_id: &str,
path: &str,
create_parents: bool,
mode: Option<u32>,
) -> FileWriter {
let (tx, rx) = mpsc::channel(FILE_CHANNEL_CAP);
let worker = Arc::clone(self);
let endpoint = endpoint.to_string();
let task = tokio::spawn(async move {
let request =
worker.request_for(ReceiverStream::new(rx), &[], None)?;
worker
.client_for(&endpoint)?
.write_sailbox_file(request)
.await
.map(|_| ())
.map_err(|status| SailError::from_file_rpc_status(&status))
});
FileWriter {
tx: Some(tx),
task: Some(task),
aborted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
first: true,
sailbox_id: sailbox_id.to_string(),
path: path.to_string(),
create_parents,
mode,
}
}
}
pub struct FileWriter {
tx: Option<mpsc::Sender<pb::WriteSailboxFileRequest>>,
task: Option<tokio::task::JoinHandle<Result<(), SailError>>>,
aborted: Arc<std::sync::atomic::AtomicBool>,
first: bool,
sailbox_id: String,
path: String,
create_parents: bool,
mode: Option<u32>,
}
impl FileWriter {
fn build(&mut self, data: Vec<u8>) -> pb::WriteSailboxFileRequest {
let header = self.first;
self.first = false;
pb::WriteSailboxFileRequest {
sailbox_id: if header {
self.sailbox_id.clone()
} else {
String::new()
},
path: if header {
self.path.clone()
} else {
String::new()
},
data,
create_parents: header && self.create_parents,
mode: if header { self.mode } else { None },
}
}
async fn join(&mut self) -> Result<(), SailError> {
self.tx = None; match self.task.take() {
Some(task) => task.await.unwrap_or_else(|join_err| {
Err(SailError::Internal {
message: format!("file write task failed: {join_err}"),
})
}),
None => Ok(()),
}
}
pub async fn write(&mut self, data: &[u8]) -> Result<(), SailError> {
for chunk in data.chunks(FILE_WRITE_CHUNK_BYTES) {
self.write_chunk(chunk.to_vec()).await?;
}
Ok(())
}
pub async fn write_chunk(&mut self, data: Vec<u8>) -> Result<(), SailError> {
if self.aborted.load(std::sync::atomic::Ordering::Relaxed) {
return Err(aborted_write());
}
let request = self.build(data);
match &self.tx {
Some(tx) if tx.send(request).await.is_ok() => Ok(()),
_ => self.join().await,
}
}
pub async fn finish(&mut self) -> Result<(), SailError> {
if self.aborted.load(std::sync::atomic::Ordering::Relaxed) {
return Err(aborted_write());
}
if self.first {
let request = self.build(Vec::new());
if let Some(tx) = &self.tx {
let _ = tx.send(request).await;
}
}
self.join().await
}
pub fn abort(&mut self) {
if let Some(task) = self.task.take() {
self.aborted
.store(true, std::sync::atomic::Ordering::Relaxed);
task.abort();
}
self.tx = None;
}
pub fn abort_handle(&self) -> WriteAbortHandle {
WriteAbortHandle {
aborted: Arc::clone(&self.aborted),
task: self
.task
.as_ref()
.map(tokio::task::JoinHandle::abort_handle),
}
}
}
#[derive(Clone)]
pub struct WriteAbortHandle {
aborted: Arc<std::sync::atomic::AtomicBool>,
task: Option<tokio::task::AbortHandle>,
}
impl WriteAbortHandle {
pub fn abort(&self) {
self.aborted
.store(true, std::sync::atomic::Ordering::Relaxed);
if let Some(task) = &self.task {
task.abort();
}
}
}
fn aborted_write() -> SailError {
SailError::InvalidArgument {
message: "the write was aborted; nothing was committed".to_string(),
}
}
impl Drop for FileWriter {
fn drop(&mut self) {
self.abort();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn write_splits_at_the_transport_chunk_size() {
let (tx, mut rx) = mpsc::channel(16);
let mut writer = FileWriter {
tx: Some(tx),
task: Some(tokio::spawn(async { Ok(()) })),
aborted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
first: true,
sailbox_id: "sb_1".to_string(),
path: "/f".to_string(),
create_parents: true,
mode: None,
};
writer
.write(&vec![7u8; FILE_WRITE_CHUNK_BYTES * 2 + 10])
.await
.expect("write succeeds");
writer.finish().await.expect("finish succeeds");
let mut sizes = Vec::new();
while let Some(message) = rx.recv().await {
sizes.push(message.data.len());
}
assert_eq!(
sizes,
vec![FILE_WRITE_CHUNK_BYTES, FILE_WRITE_CHUNK_BYTES, 10]
);
}
#[test]
fn transient_codes_retry_within_deadline() {
let deadline = Instant::now() + Duration::from_secs(5);
assert!(should_retry_transient_exec_rpc(
&Status::unavailable("x"),
deadline
));
assert!(should_retry_transient_exec_rpc(
&Status::deadline_exceeded("x"),
deadline
));
assert!(should_retry_transient_exec_rpc(
&Status::unknown("HTTP/2 connection reset by remote"),
deadline
));
assert!(!should_retry_transient_exec_rpc(
&Status::unknown("guest exploded"),
deadline
));
assert!(!should_retry_transient_exec_rpc(
&Status::not_found("x"),
deadline
));
}
#[test]
fn h2_connection_failures_retry_within_deadline() {
let deadline = Instant::now() + Duration::from_secs(5);
let io = std::io::Error::new(
std::io::ErrorKind::ConnectionAborted,
"connection error: h2 protocol error: http2 error",
);
let transport = Status::from_error(Box::new(io));
assert!(transport.source().is_some());
assert!(should_retry_transient_exec_rpc(&transport, deadline));
assert!(should_retry_transient_exec_rpc(
&Status::internal("h2 protocol error: http2 error"),
deadline
));
assert!(should_retry_transient_exec_rpc(
&Status::unknown("connection error: keep-alive timed out"),
deadline
));
assert!(!should_retry_transient_exec_rpc(
&Status::internal("guest agent panicked"),
deadline
));
}
#[test]
fn fired_attempt_timeout_retries_but_server_cancel_does_not() {
let deadline = Instant::now() + Duration::from_secs(5);
let timed_out = Status::from_error(Box::new(tonic::TimeoutExpired(())));
assert_eq!(timed_out.code(), Code::Cancelled);
assert!(should_retry_transient_exec_rpc(&timed_out, deadline));
assert!(!should_retry_transient_exec_rpc(
&Status::cancelled("client went away"),
deadline
));
}
#[test]
fn expired_deadline_never_retries() {
let deadline = Instant::now();
assert!(!should_retry_transient_exec_rpc(
&Status::unavailable("x"),
deadline
));
}
#[test]
fn draining_detection_is_case_insensitive_and_code_scoped() {
assert!(is_workerproxy_draining(&Status::unavailable(
"workerproxy DRAINING for deploy"
)));
assert!(!is_workerproxy_draining(&Status::internal("draining")));
assert!(!is_workerproxy_draining(&Status::unavailable("lameduck")));
}
#[test]
fn rpc_attempt_timeout_caps_and_leaves_retry_headroom() {
let now = Instant::now();
let far = rpc_attempt_timeout(now + Duration::from_mins(1));
assert!(far <= Duration::from_secs_f64(EXEC_RPC_ATTEMPT_TIMEOUT_SECONDS));
assert!(far > Duration::from_secs(5));
let small = rpc_attempt_timeout(now + Duration::from_secs(5));
assert!(small < Duration::from_secs(5));
assert!(small <= Duration::from_secs(3));
assert_eq!(
rpc_attempt_timeout(now.checked_sub(Duration::from_secs(1)).unwrap()),
Duration::from_millis(1)
);
}
#[test]
fn invalidate_on_draining_or_transport_failure_only() {
assert!(should_invalidate_channel(&Status::unavailable(
"workerproxy is draining"
)));
let io = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "socket closed");
assert!(should_invalidate_channel(&Status::from_error(Box::new(io))));
assert!(!should_invalidate_channel(&Status::unavailable(
"try again"
)));
}
}