307 lines
13 KiB
Rust
307 lines
13 KiB
Rust
//! Transport abstraction for [`RemoteServerManager`].
|
|
//!
|
|
//! Separates SSH-specific concerns (ControlMaster sockets, binary install,
|
|
//! process spawning) from the transport-agnostic session lifecycle managed
|
|
//! by [`RemoteServerManager`]. Alternative transports (Docker exec,
|
|
//! in-process for tests) implement the same trait without touching the
|
|
//! manager.
|
|
//!
|
|
//! Returns boxed futures for object safety — the manager stores
|
|
//! `Arc<dyn RemoteTransport>` for reconnection.
|
|
//!
|
|
//! [`RemoteServerManager`]: crate::manager::RemoteServerManager
|
|
use std::future::Future;
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use std::path::PathBuf;
|
|
use std::pin::Pin;
|
|
|
|
use async_channel::Receiver;
|
|
use galaxyui_core::r#async::executor;
|
|
use serde::Serialize;
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use crate::client::RemoteServerLog;
|
|
use crate::client::{ClientEvent, RemoteServerClient};
|
|
use crate::manager::RemoteServerExitStatus;
|
|
use crate::setup::{PreinstallCheckResult, RemotePlatform};
|
|
|
|
/// How the remote server binary was installed. Used for telemetry to
|
|
/// distinguish direct remote downloads from client-side SCP uploads.
|
|
#[derive(Clone, Copy, Debug, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum InstallSource {
|
|
/// The remote host downloaded the binary directly from the CDN.
|
|
Server,
|
|
/// The client downloaded the binary locally and uploaded it via SCP.
|
|
Client,
|
|
}
|
|
|
|
/// Result of [`RemoteTransport::install_binary`], bundling the install
|
|
/// result with the source that was attempted. The source is always set
|
|
/// once the install path is determined, regardless of whether the
|
|
/// install succeeded or failed.
|
|
pub struct InstallOutcome {
|
|
/// Which install path was attempted.
|
|
pub source: Option<InstallSource>,
|
|
/// Whether the install succeeded.
|
|
pub result: Result<(), Error>,
|
|
}
|
|
|
|
/// Structured error for user-facing display in the SSH remote-server
|
|
/// failed banner. Separates the always-visible body from an optional set of
|
|
/// details.
|
|
#[derive(Clone, Debug)]
|
|
pub struct UserFacingError {
|
|
/// Always-visible explanation of what went wrong,
|
|
/// e.g. "Failed to install SSH extension".
|
|
pub body: String,
|
|
/// Optional technical detail shown to the user (stderr,
|
|
/// timeout duration, unsupported OS/arch). `None` when the
|
|
/// underlying error doesn't carry anything useful for the user.
|
|
pub detail: Option<String>,
|
|
}
|
|
|
|
/// The setup stage that failed, used to generate context-appropriate
|
|
/// user-facing messages from a [`Error`].
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub enum SetupStage {
|
|
DetectPlatform,
|
|
PreinstallCheck,
|
|
CheckBinary,
|
|
InstallBinary,
|
|
Launch,
|
|
}
|
|
|
|
impl SetupStage {
|
|
fn action_description(self) -> &'static str {
|
|
match self {
|
|
Self::DetectPlatform => "detect remote platform",
|
|
Self::PreinstallCheck => "run preinstall check",
|
|
Self::CheckBinary => "verify SSH extension",
|
|
Self::InstallBinary => "install SSH extension",
|
|
Self::Launch => "start SSH extension",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum Error {
|
|
/// The operation timed out.
|
|
#[error("timed out")]
|
|
TimedOut,
|
|
/// The remote host reported an OS not supported by the prebuilt binary.
|
|
#[error("unsupported OS: {os}")]
|
|
UnsupportedOs { os: String },
|
|
/// The remote host reported a CPU architecture not supported by the prebuilt binary.
|
|
#[error("unsupported architecture: {arch}")]
|
|
UnsupportedArch { arch: String },
|
|
/// A remote script ran but exited with a non-zero code.
|
|
#[error("script failed (exit {exit_code}): {stderr}")]
|
|
ScriptFailed { exit_code: i32, stderr: String },
|
|
/// Any other transport-level or unexpected failure.
|
|
#[error(transparent)]
|
|
Other(anyhow::Error),
|
|
}
|
|
|
|
/// Maximum number of stderr characters to include in the user-facing
|
|
/// detail for `ScriptFailed` errors. Keeps the banner reasonable even
|
|
/// when a remote script dumps a large amount of output.
|
|
const MAX_STDERR_DISPLAY_CHARS: usize = 512;
|
|
|
|
impl Error {
|
|
/// Converts this error into a [`UserFacingError`] suitable for the
|
|
/// SSH remote-server failed banner, using `stage` to provide
|
|
/// context-appropriate copy.
|
|
pub fn user_facing_error(&self, stage: SetupStage) -> UserFacingError {
|
|
let body = format!("Failed to {}", stage.action_description());
|
|
let detail = match self {
|
|
Self::TimedOut => {
|
|
Some("The operation timed out — check your network connection".into())
|
|
}
|
|
Self::UnsupportedOs { os } => Some(format!("Unsupported OS: {os}")),
|
|
Self::UnsupportedArch { arch } => Some(format!("Unsupported architecture: {arch}")),
|
|
Self::ScriptFailed { exit_code, stderr } => {
|
|
let truncated = if stderr.chars().count() > MAX_STDERR_DISPLAY_CHARS {
|
|
let end: usize = stderr
|
|
.char_indices()
|
|
.nth(MAX_STDERR_DISPLAY_CHARS)
|
|
.map(|(i, _)| i)
|
|
.unwrap_or(stderr.len());
|
|
format!("{}…", &stderr[..end])
|
|
} else {
|
|
stderr.clone()
|
|
};
|
|
Some(format!("Script exited with code {exit_code}: {truncated}"))
|
|
}
|
|
Self::Other(_) => None,
|
|
};
|
|
UserFacingError { body, detail }
|
|
}
|
|
}
|
|
|
|
/// The SSH `ControlMaster` socket (if any) behind a connection, tagged
|
|
/// with who owns the master process. Ownership decides teardown
|
|
/// behavior: only `WarpManaged` masters are stopped with `ssh -O exit`
|
|
/// on explicit teardown (see [`crate::ssh::stop_control_master`]);
|
|
/// `UserOwned` masters must be left running.
|
|
#[cfg(not(target_family = "wasm"))]
|
|
#[derive(Debug, Clone)]
|
|
pub enum ControlPath {
|
|
/// Warp created the ControlMaster at this socket path and is
|
|
/// responsible for tearing it down on session exit.
|
|
WarpManaged(PathBuf),
|
|
/// The SSH wrapper attached to a ControlMaster the user already had
|
|
/// running at this socket path. Warp must never tear it down.
|
|
UserOwned(PathBuf),
|
|
/// No ControlMaster socket (e.g. in-process test transports).
|
|
None,
|
|
}
|
|
|
|
/// A successful return from [`RemoteTransport::connect`].
|
|
///
|
|
/// Bundles the live [`RemoteServerClient`] and its [`ClientEvent`]
|
|
/// receiver together with any transport-specific resources whose
|
|
/// lifetime must match the session (notably an owning `Child` for
|
|
/// subprocess-backed transports). The caller -- typically
|
|
/// [`RemoteServerManager`] -- stashes the whole `Connection` on its
|
|
/// per-session state so that dropping the state cleans everything up at
|
|
/// once.
|
|
///
|
|
/// [`RemoteServerManager`]: crate::manager::RemoteServerManager
|
|
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
|
pub struct Connection {
|
|
pub client: RemoteServerClient,
|
|
pub event_rx: Receiver<ClientEvent>,
|
|
/// Receiver for request-failure telemetry events. Separate from
|
|
/// `event_rx` so the failure sender on the client doesn't keep the
|
|
/// lifecycle event channel alive.
|
|
pub failure_rx: async_channel::Receiver<crate::client::RequestFailedEvent>,
|
|
/// Receiver for host-scoped responses whose `request_id` was not in
|
|
/// this client's `pending_requests`. The manager drains this to match
|
|
/// against its `pending_host_requests`.
|
|
pub host_response_rx: async_channel::Receiver<crate::proto::ServerMessage>,
|
|
/// The subprocess whose stdio backs the client (e.g.
|
|
/// `ssh … remote-server-proxy`). Spawned with `kill_on_drop(true)`
|
|
/// by the transport, so dropping this `Child` sends SIGKILL to the
|
|
/// subprocess. The [`RemoteServerManager`] holds it for the
|
|
/// lifetime of the session and drops it on teardown.
|
|
///
|
|
/// [`RemoteServerManager`]: crate::manager::RemoteServerManager
|
|
#[cfg(not(target_family = "wasm"))]
|
|
pub child: async_process::Child,
|
|
/// For transports that multiplex through a local SSH
|
|
/// `ControlMaster` socket: the socket path tagged with master
|
|
/// ownership, which decides whether explicit teardown (after the
|
|
/// user's shell exits) runs `ssh -O exit` against it. See
|
|
/// [`ControlPath`].
|
|
#[cfg(not(target_family = "wasm"))]
|
|
pub control_path: ControlPath,
|
|
/// Tail buffer of the last N stderr lines from the SSH subprocess.
|
|
/// Drained on connection failure and attached to telemetry.
|
|
#[cfg(not(target_family = "wasm"))]
|
|
pub stderr_tail: RemoteServerLog,
|
|
}
|
|
|
|
/// Transport abstraction for remote server connections.
|
|
///
|
|
/// Object-safe: returns boxed futures so implementations can be stored
|
|
/// as `Arc<dyn RemoteTransport>` for reconnection.
|
|
pub trait RemoteTransport: Send + Sync + std::fmt::Debug {
|
|
/// Detects the remote host's OS and architecture by running `uname -sm`.
|
|
///
|
|
/// Returns the parsed [`RemotePlatform`] on success, or a
|
|
/// [`Error`] if the command fails or the output cannot
|
|
/// be parsed.
|
|
fn detect_platform(
|
|
&self,
|
|
) -> Pin<Box<dyn Future<Output = Result<RemotePlatform, Error>> + Send>>;
|
|
|
|
/// Runs the preinstall check script ([`crate::setup::PREINSTALL_CHECK_SCRIPT`])
|
|
/// over the existing connection and parses its structured stdout into
|
|
/// a [`PreinstallCheckResult`].
|
|
///
|
|
/// This runs **before** any user-visible install affordance (the
|
|
/// install choice block, auto-install, auto-update, or connect) and
|
|
/// is the gate that decides whether to proceed with the install
|
|
/// pipeline or fall back to the wrapper-only SSH flow.
|
|
///
|
|
/// Returns `Ok(_)` on success (including when the script reported
|
|
/// `Unknown` — that's a parser-level outcome, not a transport-level
|
|
/// failure). Returns `Err(_)` only on transport-level failure (timeout,
|
|
/// broken pipe, non-zero exit with no parseable summary), which the
|
|
/// caller treats as inconclusive (fail open).
|
|
fn run_preinstall_check(
|
|
&self,
|
|
) -> Pin<Box<dyn Future<Output = Result<PreinstallCheckResult, Error>> + Send>>;
|
|
|
|
/// Checks whether the remote server binary is present on the remote host.
|
|
///
|
|
/// Pure I/O — does not emit any events. The caller
|
|
/// ([`RemoteServerManager::check_binary`]) is responsible for emitting
|
|
/// [`SetupStateChanged`] and [`BinaryCheckComplete`].
|
|
///
|
|
/// Returns `Ok(true)` if the binary is installed and executable,
|
|
/// `Ok(false)` if it is definitively not installed, and
|
|
/// `Err(_)` if the check failed (e.g. timeout or unreachable).
|
|
fn check_binary(&self) -> Pin<Box<dyn Future<Output = Result<bool, Error>> + Send>>;
|
|
|
|
/// Checks whether the remote host already has an existing install
|
|
/// of the remote server binary.
|
|
///
|
|
/// Used by the manager to distinguish a fresh install (no prior
|
|
/// install on disk, user should be prompted) from an update (prior
|
|
/// install present, install should happen automatically).
|
|
///
|
|
/// Returns `Ok(true)` if a prior install was detected, `Ok(false)`
|
|
/// if not, and `Err(_)` on SSH failure.
|
|
fn check_has_old_binary(&self) -> Pin<Box<dyn Future<Output = anyhow::Result<bool>> + Send>>;
|
|
|
|
/// Installs the remote server binary on the remote host.
|
|
///
|
|
/// Pure I/O — does not emit any events. The caller
|
|
/// ([`RemoteServerManager::install_binary`]) is responsible for emitting
|
|
/// [`SetupStateChanged`] and [`BinaryInstallComplete`].
|
|
///
|
|
/// Returns an [`InstallOutcome`] containing the install result and
|
|
/// the [`InstallSource`] that was attempted (if known).
|
|
fn install_binary(&self) -> Pin<Box<dyn Future<Output = InstallOutcome> + Send>>;
|
|
|
|
/// Establish a new connection to the remote server.
|
|
///
|
|
/// Called on both the initial connect and every subsequent reconnect
|
|
/// attempt. Returns a [`Connection`] carrying the live client, its
|
|
/// event channel, and any transport-specific resources (e.g. an
|
|
/// owning `Child`) whose lifetime must match the session.
|
|
///
|
|
/// The implementation is responsible for any transport-specific setup
|
|
/// required before messages can flow (e.g. spawning a process, connecting
|
|
/// a socket). Stderr forwarding to local logging should also happen here.
|
|
fn connect(
|
|
&self,
|
|
executor: std::sync::Arc<executor::Background>,
|
|
) -> Pin<Box<dyn Future<Output = anyhow::Result<Connection>> + Send>>;
|
|
|
|
/// Remove the remote server binary, forcing a reinstall on the next
|
|
/// [`install_binary`] call.
|
|
///
|
|
/// Called by the manager after the initialize handshake reports a
|
|
/// version that disagrees with the client's: the file at the expected
|
|
/// path is stale/wrong, so we remove it so the next setup sees a miss
|
|
/// and reinstalls from the CDN instead of looping on the same bad
|
|
/// binary.
|
|
///
|
|
/// [`install_binary`]: RemoteTransport::install_binary
|
|
fn remove_remote_server_binary(
|
|
&self,
|
|
) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>>;
|
|
|
|
/// Returns `true` if the transport considers a reconnect viable after
|
|
/// a spontaneous disconnect with the given exit status.
|
|
///
|
|
/// Transports that can determine the underlying connection is
|
|
/// unrecoverable (e.g. SSH detecting a dead ControlMaster via exit
|
|
/// code 255) should return `false`, which tells the manager to skip
|
|
/// the reconnect loop entirely.
|
|
fn is_reconnectable(&self, exit_status: Option<&RemoteServerExitStatus>) -> bool;
|
|
}
|