remote_server: gate install on glibc preinstall check + tolerate cleanup races (#9681)

## Description

Two related fixes to the remote-server setup flow:

### 1. Gate remote-server install on a glibc preinstall check (APP-4281)

The prebuilt remote-server binary is linked against glibc 2.31. On older
Linux hosts (e.g. RHEL/Rocky/CentOS 8 with glibc 2.28) the binary
downloads fine but fails at runtime, leaving users stuck. This PR adds a
lightweight, fail-open preinstall probe that runs over the existing SSH
`ControlMaster` socket *before* any user-visible install affordance.


### 2. Don't fail the install when staging-dir cleanup races

`install_remote_server.sh` ran `trap 'rm -rf "$tmpdir"' EXIT` under `set
-e`. When `rm -rf` hit a "Directory not empty" / `EBUSY` race after the
binary had already been moved into place, the trap's non-zero exit
replaced the script's success exit, surfacing as `install script failed
(exit 1)` in the manager. The cleanup is now best-effort (`rm -rf
"$tmpdir" 2>/dev/null || true`) so post-success races no longer break
the install. Real install failures (curl, tar, missing binary,
unsupported arch/OS) still propagate normally because they happen before
the trap fires.



Fixes APP-4281

## Testing
Tested locally with an RHEL 7 docker container (which uses glibc 2.28)

## Agent Mode

- [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode


[Conversation](https://staging.warp.dev/conversation/86cbc3ea-d9de-4b23-832a-fa8b23c0f295)
· [Tech
spec](https://staging.warp.dev/drive/notebook/oGmQYNHrHzuGx3FegfsEuQ)

<!--
CHANGELOG-IMPROVEMENT: Warp now silently falls back to a regular SSH
session on remote hosts where the prebuilt remote-server binary is
incompatible (e.g. glibc < 2.31), instead of attempting an install that
would fail at runtime.
CHANGELOG-BUG-FIX: Remote-server installs no longer fail when the
staging-directory cleanup hits a "Directory not empty" race after the
binary has already been moved into place.
-->

Co-Authored-By: Oz <oz-agent@warp.dev>
This commit is contained in:
Aloke Desai
2026-04-30 22:17:31 -05:00
committed by GitHub
parent 99b287ff07
commit e75b315534
14 changed files with 1190 additions and 10 deletions

View File

@@ -29,7 +29,16 @@ install_dir="${install_dir/#\~/"$HOME"}"
mkdir -p "$install_dir"
tmpdir=$(mktemp -d "$install_dir/.install.XXXXXX")
trap 'rm -rf "$tmpdir"' EXIT
# Best-effort cleanup of the staging directory. A failure here (e.g.
# EBUSY or "Directory not empty" races on some filesystems/mounts)
# must not fail the install: by the time this fires the binary has
# either already been moved into its final location, or the script
# has already failed for an unrelated reason that we want to surface
# instead of clobbering with the cleanup's exit code.
cleanup() {
rm -rf "$tmpdir" 2>/dev/null || true
}
trap cleanup EXIT
curl -fSL "{download_base_url}?package=tar&os=$os_name&arch=$arch_name&channel={channel}{version_query}" \
-o "$tmpdir/oz.tar.gz"

View File

@@ -9,8 +9,12 @@ use crate::auth::RemoteServerAuthContext;
#[cfg(not(target_family = "wasm"))]
use crate::client::ClientEvent;
use crate::client::RemoteServerClient;
use crate::setup::PreinstallCheckResult;
#[cfg(not(target_family = "wasm"))]
use crate::setup::RemoteOs;
use crate::setup::RemotePlatform;
use crate::setup::RemoteServerSetupState;
use crate::setup::UnsupportedReason;
#[cfg(not(target_family = "wasm"))]
use crate::transport::Connection;
use crate::transport::RemoteTransport;
@@ -314,6 +318,12 @@ pub enum RemoteServerManagerEvent {
/// The detected remote platform (OS + arch) from `uname -sm`.
/// `None` if detection failed or was not attempted.
remote_platform: Option<RemotePlatform>,
/// Outcome of the preinstall check script. Populated when the
/// script ran successfully against a Linux host. `None` when the
/// host is not Linux (the script is skipped) or when the SSH-level
/// invocation failed (the controller treats that as inconclusive
/// and falls open).
preinstall_check: Option<PreinstallCheckResult>,
/// `true` if the remote already has an existing install of the
/// remote-server binary, detected by probing whether the install
/// directory exists (see `RemoteTransport::check_has_old_binary`).
@@ -499,6 +509,25 @@ impl RemoteServerManager {
false
}
};
// Run the preinstall check after platform detection
// resolves, only on Linux. macOS hosts pay zero extra
// round-trips. SSH-level failures are logged and
// surfaced as `None`, which the controller treats as
// inconclusive (fail open).
let preinstall = match &platform {
Some(p) if matches!(p.os, RemoteOs::Linux) => {
match transport.run_preinstall_check().await {
Ok(r) => Some(r),
Err(e) => {
log::warn!(
"Preinstall check failed for session {session_id:?}: {e}"
);
None
}
}
}
_ => None,
};
let _ = spawner
.spawn(move |me, ctx| {
if let Some(p) = &platform {
@@ -516,6 +545,7 @@ impl RemoteServerManager {
session_id,
result: check_result,
remote_platform: platform,
preinstall_check: preinstall,
has_old_binary,
});
})
@@ -525,6 +555,39 @@ impl RemoteServerManager {
}
}
/// Marks a session as unsupported by the prebuilt remote-server
/// binary, based on a positive classification from the preinstall
/// check. The setup state transitions to `Unsupported`, which the
/// downstream UI treats as a clean fall-back to the legacy SSH flow.
///
/// No-op on WASM (remote server connections use a different transport).
#[cfg(target_family = "wasm")]
pub fn mark_setup_unsupported(
&mut self,
_session_id: SessionId,
_reason: UnsupportedReason,
_ctx: &mut ModelContext<Self>,
) {
log::warn!("mark_setup_unsupported is a no-op on WASM");
}
/// Marks a session as unsupported by the prebuilt remote-server
/// binary, based on a positive classification from the preinstall
/// check. The setup state transitions to `Unsupported`, which the
/// downstream UI treats as a clean fall-back to the legacy SSH flow.
#[cfg(not(target_family = "wasm"))]
pub fn mark_setup_unsupported(
&mut self,
session_id: SessionId,
reason: UnsupportedReason,
ctx: &mut ModelContext<Self>,
) {
ctx.emit(RemoteServerManagerEvent::SetupStateChanged {
session_id,
state: RemoteServerSetupState::Unsupported { reason },
});
}
/// Installs the remote server binary.
/// Emits `BinaryInstallComplete { result }`.
///

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Preinstall check for the Warp remote-server binary.
#
# Emits a structured key=value summary on stdout. Exits 0 on success.
# A non-zero exit indicates a probe-level failure; the client treats
# those as `status=unknown` (fail open).
set -u
# The minimum glibc the prebuilt Linux CLI requires. The Linux CLI is
# built on Ubuntu 20.04 (see `.github/workflows/create_release.yml`),
# which ships glibc 2.31. Bump this when the runner image is bumped.
required_glibc="2.31"
echo "required_glibc=${required_glibc}"
# 1. Detect libc family and (when glibc) its version.
libc_family="unknown"
libc_version=""
if version=$(getconf GNU_LIBC_VERSION 2>/dev/null); then
# Output: "glibc 2.31"
libc_family="glibc"
libc_version="${version##* }"
elif ldd_out=$(ldd --version 2>&1 | head -n1); then
case "$ldd_out" in
*musl*) libc_family="musl" ;;
*uClibc*) libc_family="uclibc" ;;
*)
v=$(printf '%s\n' "$ldd_out" | grep -oE '[0-9]+\.[0-9]+' | head -n1)
if [ -n "$v" ]; then
libc_family="glibc"
libc_version="$v"
fi
;;
esac
fi
echo "libc_family=${libc_family}"
[ -n "$libc_version" ] && echo "libc_version=${libc_version}"
# 2. Decide status from the gathered facts.
status="unknown"
reason=""
if [ "$libc_family" = "glibc" ] && [ -n "$libc_version" ]; then
have_major="${libc_version%%.*}"
have_minor="${libc_version#*.}"
have_minor="${have_minor%%.*}"
req_major="${required_glibc%%.*}"
req_minor="${required_glibc#*.}"
if [ "$have_major" -gt "$req_major" ] \
|| { [ "$have_major" -eq "$req_major" ] && [ "$have_minor" -ge "$req_minor" ]; }; then
status="supported"
else
status="unsupported"
reason="glibc_too_old"
fi
elif [ "$libc_family" = "musl" ] || [ "$libc_family" = "bionic" ] || [ "$libc_family" = "uclibc" ]; then
status="unsupported"
reason="non_glibc"
fi
echo "status=${status}"
[ -n "$reason" ] && echo "reason=${reason}"

View File

@@ -1,3 +1,7 @@
mod glibc;
pub use glibc::{GlibcVersion, RemoteLibc};
use std::time::Duration;
use anyhow::{anyhow, Result};
@@ -20,6 +24,11 @@ pub enum RemoteServerSetupState {
Ready,
/// Something failed. Fall back to ControlMaster.
Failed { error: String },
/// Preinstall check classified the host as incompatible with the
/// prebuilt remote-server binary. The controller treats this as a
/// clean fall-back to the legacy ControlMaster-backed SSH flow,
/// distinct from `Failed` (which is rendered as a real error).
Unsupported { reason: UnsupportedReason },
}
impl RemoteServerSetupState {
@@ -31,8 +40,12 @@ impl RemoteServerSetupState {
matches!(self, Self::Failed { .. })
}
pub fn is_unsupported(&self) -> bool {
matches!(self, Self::Unsupported { .. })
}
pub fn is_terminal(&self) -> bool {
self.is_ready() || self.is_failed()
self.is_ready() || self.is_failed() || self.is_unsupported()
}
pub fn is_in_progress(&self) -> bool {
@@ -50,6 +63,142 @@ impl RemoteServerSetupState {
}
}
/// Outcome of [`crate::transport::RemoteTransport::run_preinstall_check`].
///
/// The script runs over the existing SSH socket before any install UI
/// surfaces and reports whether the host can run the prebuilt
/// remote-server binary. The Rust side is intentionally a thin parser
/// over the script's structured stdout (see `preinstall_check.sh`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PreinstallCheckResult {
pub status: PreinstallStatus,
pub libc: RemoteLibc,
/// Verbatim, trimmed script stdout. Forwarded to telemetry for
/// diagnosing `Unknown` outcomes on exotic distros.
pub raw: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PreinstallStatus {
Supported,
Unsupported {
reason: UnsupportedReason,
},
/// Probe ran but couldn't classify the host. Treated as supported
/// (fail open) by [`PreinstallCheckResult::is_supported`] so we keep
/// today's install-and-try behavior on hosts where the probe is
/// unreliable.
Unknown,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum UnsupportedReason {
GlibcTooOld {
detected: GlibcVersion,
required: GlibcVersion,
},
NonGlibc {
name: String,
},
}
impl PreinstallCheckResult {
/// Whether the host is supported. Both `Supported` and `Unknown`
/// return true — only positive detection of an incompatible libc
/// triggers the silent fall-back.
pub fn is_supported(&self) -> bool {
match self.status {
PreinstallStatus::Supported | PreinstallStatus::Unknown => true,
PreinstallStatus::Unsupported { .. } => false,
}
}
/// Parses the structured `key=value` stdout emitted by
/// `preinstall_check.sh`. Tolerates unknown keys and lines without
/// `=` (forward-compatibility): future versions of the script can
/// add new keys without coordinating a client release.
pub fn parse(stdout: &str) -> Self {
let mut status_str: Option<&str> = None;
let mut reason_str: Option<&str> = None;
let mut libc_family: Option<&str> = None;
let mut libc_version: Option<&str> = None;
let mut required_glibc: Option<&str> = None;
for line in stdout.lines() {
let Some((key, value)) = line.split_once('=') else {
continue;
};
match key.trim() {
"status" => status_str = Some(value.trim()),
"reason" => reason_str = Some(value.trim()),
"libc_family" => libc_family = Some(value.trim()),
"libc_version" => libc_version = Some(value.trim()),
"required_glibc" => required_glibc = Some(value.trim()),
_ => {} // ignore unknown keys
}
}
let libc = glibc::parse_libc(libc_family, libc_version);
let status = parse_status(status_str, reason_str, &libc, required_glibc);
Self {
status,
libc,
raw: stdout.trim().to_string(),
}
}
}
fn parse_status(
status: Option<&str>,
reason: Option<&str>,
libc: &RemoteLibc,
required_glibc: Option<&str>,
) -> PreinstallStatus {
match status {
Some("supported") => PreinstallStatus::Supported,
Some("unsupported") => match reason {
Some("glibc_too_old") => {
let detected = match libc {
RemoteLibc::Glibc(v) => Some(*v),
_ => None,
};
let required = required_glibc.and_then(GlibcVersion::parse);
match (detected, required) {
(Some(detected), Some(required)) => PreinstallStatus::Unsupported {
reason: UnsupportedReason::GlibcTooOld { detected, required },
},
// The script said `unsupported` + `glibc_too_old` but we
// can't recover the numbers — fail open rather than
// surface a malformed reason.
_ => PreinstallStatus::Unknown,
}
}
Some("non_glibc") => {
let name = match libc {
RemoteLibc::NonGlibc { name } => name.clone(),
_ => "unknown".to_string(),
};
PreinstallStatus::Unsupported {
reason: UnsupportedReason::NonGlibc { name },
}
}
_ => PreinstallStatus::Unknown,
},
// status=unknown, missing, or anything else → fail open.
_ => PreinstallStatus::Unknown,
}
}
/// The bundled preinstall check script. Loaded as a string so the SSH
/// transport can pipe it through the existing ControlMaster socket via
/// [`crate::ssh::run_ssh_script`].
///
/// The script is intentionally self-contained — the supported-glibc
/// floor is hardcoded inside the script (see `preinstall_check.sh`)
/// rather than templated from Rust.
pub const PREINSTALL_CHECK_SCRIPT: &str = include_str!("preinstall_check.sh");
/// Detected remote platform from `uname -sm` output.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RemotePlatform {

View File

@@ -0,0 +1,143 @@
//! Parsing helpers for the libc family + version reported by the
//! `preinstall_check.sh` script.
//!
//! This is split into its own submodule so the libc-specific logic
//! (version parsing, family classification) can evolve independently
//! from the rest of [`crate::setup`].
use std::fmt;
/// A glibc `(major, minor)` version pair, e.g. `2.31`.
///
/// Wraps a labelled struct rather than a raw `(u32, u32)` so the meaning
/// of each field is obvious at call sites and in event payloads.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct GlibcVersion {
pub major: u32,
pub minor: u32,
}
impl GlibcVersion {
pub const fn new(major: u32, minor: u32) -> Self {
Self { major, minor }
}
/// Parses a `<major>.<minor>` (or `<major>.<minor>.<patch>`) string.
/// Only the first two segments are consumed; trailing components
/// (e.g. patch versions, distro suffixes) are ignored.
///
/// Returns `None` if either segment is missing or non-numeric.
pub fn parse(value: &str) -> Option<Self> {
let value = value.trim();
let (major, rest) = value.split_once('.')?;
// Allow `2.31`, `2.31.0`, `2.31-foo`, etc.
let minor = rest.split(|c: char| !c.is_ascii_digit()).next()?;
Some(Self {
major: major.parse().ok()?,
minor: minor.parse().ok()?,
})
}
}
impl fmt::Display for GlibcVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}", self.major, self.minor)
}
}
/// Detected libc on the remote host, derived from the `libc_family` /
/// `libc_version` keys emitted by `preinstall_check.sh`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RemoteLibc {
Glibc(GlibcVersion),
NonGlibc { name: String },
Unknown,
}
/// Builds a [`RemoteLibc`] from the raw `libc_family` / `libc_version`
/// values pulled out of the script's `key=value` stdout.
pub(super) fn parse_libc(family: Option<&str>, version: Option<&str>) -> RemoteLibc {
match family {
Some("glibc") => match version.and_then(GlibcVersion::parse) {
Some(v) => RemoteLibc::Glibc(v),
None => RemoteLibc::Unknown,
},
Some(name) if !name.is_empty() && name != "unknown" => RemoteLibc::NonGlibc {
name: name.to_string(),
},
_ => RemoteLibc::Unknown,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_major_minor() {
assert_eq!(GlibcVersion::parse("2.31"), Some(GlibcVersion::new(2, 31)));
}
#[test]
fn parses_major_minor_patch() {
assert_eq!(
GlibcVersion::parse("2.35.0"),
Some(GlibcVersion::new(2, 35))
);
}
#[test]
fn parses_distro_suffix() {
// e.g. "2.35-0ubuntu3.4"
assert_eq!(
GlibcVersion::parse("2.35-0ubuntu3.4"),
Some(GlibcVersion::new(2, 35))
);
}
#[test]
fn returns_none_on_garbage() {
assert_eq!(GlibcVersion::parse("garbage"), None);
assert_eq!(GlibcVersion::parse(""), None);
assert_eq!(GlibcVersion::parse("2.x"), None);
}
#[test]
fn parse_libc_glibc() {
assert_eq!(
parse_libc(Some("glibc"), Some("2.31")),
RemoteLibc::Glibc(GlibcVersion::new(2, 31))
);
}
#[test]
fn parse_libc_glibc_unparseable_version() {
assert_eq!(
parse_libc(Some("glibc"), Some("garbage")),
RemoteLibc::Unknown
);
assert_eq!(parse_libc(Some("glibc"), None), RemoteLibc::Unknown);
}
#[test]
fn parse_libc_non_glibc() {
assert_eq!(
parse_libc(Some("musl"), None),
RemoteLibc::NonGlibc {
name: "musl".to_string()
}
);
}
#[test]
fn parse_libc_unknown_family_treated_as_unknown() {
assert_eq!(parse_libc(Some("unknown"), None), RemoteLibc::Unknown);
assert_eq!(parse_libc(None, None), RemoteLibc::Unknown);
assert_eq!(parse_libc(Some(""), None), RemoteLibc::Unknown);
}
#[test]
fn glibc_version_displays_as_dotted() {
assert_eq!(format!("{}", GlibcVersion::new(2, 31)), "2.31");
}
}

View File

@@ -99,6 +99,12 @@ fn state_is_terminal() {
error: "test".into()
}
.is_terminal());
assert!(RemoteServerSetupState::Unsupported {
reason: UnsupportedReason::NonGlibc {
name: "musl".into()
}
}
.is_terminal());
assert!(!RemoteServerSetupState::Checking.is_terminal());
assert!(!RemoteServerSetupState::Installing {
progress_percent: None,
@@ -107,3 +113,71 @@ fn state_is_terminal() {
assert!(!RemoteServerSetupState::Updating.is_terminal());
assert!(!RemoteServerSetupState::Initializing.is_terminal());
}
#[test]
fn parse_preinstall_supported_glibc() {
let stdout = "required_glibc=2.31\n\
libc_family=glibc\n\
libc_version=2.35\n\
status=supported\n";
let result = PreinstallCheckResult::parse(stdout);
assert_eq!(result.status, PreinstallStatus::Supported);
assert_eq!(result.libc, RemoteLibc::Glibc(GlibcVersion::new(2, 35)));
assert!(result.is_supported());
}
#[test]
fn parse_preinstall_unsupported_glibc_too_old() {
let stdout = "required_glibc=2.31\n\
libc_family=glibc\n\
libc_version=2.17\n\
status=unsupported\n\
reason=glibc_too_old\n";
let result = PreinstallCheckResult::parse(stdout);
assert_eq!(
result.status,
PreinstallStatus::Unsupported {
reason: UnsupportedReason::GlibcTooOld {
detected: GlibcVersion::new(2, 17),
required: GlibcVersion::new(2, 31),
}
}
);
assert!(!result.is_supported());
}
#[test]
fn parse_preinstall_unsupported_non_glibc() {
let stdout = "required_glibc=2.31\n\
libc_family=musl\n\
status=unsupported\n\
reason=non_glibc\n";
let result = PreinstallCheckResult::parse(stdout);
assert_eq!(
result.status,
PreinstallStatus::Unsupported {
reason: UnsupportedReason::NonGlibc {
name: "musl".to_string()
}
}
);
assert_eq!(
result.libc,
RemoteLibc::NonGlibc {
name: "musl".to_string()
}
);
assert!(!result.is_supported());
}
#[test]
fn parse_preinstall_missing_status_falls_open() {
// Garbled / partial script output — missing status field. Confirms
// the fail-open invariant: anything we can't positively classify as
// unsupported degrades to Unknown and is treated as supported, so a
// flaky probe doesn't block the install.
let stdout = "libc_family=glibc\nlibc_version=2.35\n";
let result = PreinstallCheckResult::parse(stdout);
assert_eq!(result.status, PreinstallStatus::Unknown);
assert!(result.is_supported());
}

View File

@@ -18,7 +18,7 @@ use async_channel::Receiver;
use warpui::r#async::executor;
use crate::client::{ClientEvent, RemoteServerClient};
use crate::setup::RemotePlatform;
use crate::setup::{PreinstallCheckResult, RemotePlatform};
/// A successful return from [`RemoteTransport::connect`].
///
@@ -69,6 +69,24 @@ pub trait RemoteTransport: Send + Sync + std::fmt::Debug {
&self,
) -> Pin<Box<dyn std::future::Future<Output = Result<RemotePlatform, String>> + 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 legacy 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 SSH-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 std::future::Future<Output = Result<PreinstallCheckResult, String>> + Send>>;
/// Checks whether the remote server binary is present on the remote host.
///
/// Pure I/O — does not emit any events. The caller