Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 47 additions & 9 deletions crates/remote_server/src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,17 +96,51 @@ impl Error {
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])
let detail = if stderr.contains("Permission denied")
|| stderr.contains("Read-only file system")
{
format!(
"Cannot create install directory — check write permissions \
on your home directory (exit code {exit_code})"
)
} else if stderr.contains("No space left on device")
|| stderr.contains("Failure writing output to destination")
{
format!(
"Not enough disk space on the remote host — free up space \
and try again (exit code {exit_code})"
)
} else if *exit_code == 255 && stderr.trim().is_empty() {
"SSH connection was lost during installation — please \
reconnect and try again"
.to_string()
} else if stderr.contains("unsupported arch") {
let arch = stderr
.lines()
.find(|l| l.contains("unsupported arch"))
.and_then(|l| l.split("unsupported arch: ").nth(1))
.unwrap_or("unknown")
.trim();
format!(
"This remote host uses a {arch} processor, which is not \
supported. Warp SSH extension requires x86_64 or \
aarch64 (arm64)."
)
} else {
stderr.clone()
// Default: truncated 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()
};
format!("Script exited with code {exit_code}: {truncated}")
};
Some(format!("Script exited with code {exit_code}: {truncated}"))
Some(detail)
}
Self::Other(_) => None,
};
Expand Down Expand Up @@ -252,3 +286,7 @@ pub trait RemoteTransport: Send + Sync + std::fmt::Debug {
/// the reconnect loop entirely.
fn is_reconnectable(&self, exit_status: Option<&RemoteServerExitStatus>) -> bool;
}

#[cfg(test)]
#[path = "transport_tests.rs"]
mod tests;
208 changes: 208 additions & 0 deletions crates/remote_server/src/transport_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
use super::*;

#[test]
fn script_failed_permission_denied_produces_targeted_message() {
let err = Error::ScriptFailed {
exit_code: 1,
stderr: "mkdir: cannot create directory '/home/user/.warp': Permission denied".into(),
};
let ufe = err.user_facing_error(SetupStage::InstallBinary);
assert_eq!(ufe.body, "Failed to install SSH extension");
let detail = ufe.detail.unwrap();
assert!(
detail.contains("check write permissions"),
"expected permission-denied message, got: {detail}"
);
assert!(
detail.contains("exit code 1"),
"expected exit code in detail, got: {detail}"
);
}

#[test]
fn script_failed_read_only_fs_produces_targeted_message() {
let err = Error::ScriptFailed {
exit_code: 1,
stderr: "cp: cannot create regular file: Read-only file system".into(),
};
let ufe = err.user_facing_error(SetupStage::InstallBinary);
let detail = ufe.detail.unwrap();
assert!(
detail.contains("check write permissions"),
"expected read-only FS message, got: {detail}"
);
}

#[test]
fn script_failed_disk_full_produces_targeted_message() {
let err = Error::ScriptFailed {
exit_code: 2,
stderr: "No space left on device".into(),
};
let ufe = err.user_facing_error(SetupStage::InstallBinary);
assert_eq!(ufe.body, "Failed to install SSH extension");
let detail = ufe.detail.unwrap();
assert!(
detail.contains("free up space"),
"expected disk-full message, got: {detail}"
);
assert!(
detail.contains("exit code 2"),
"expected exit code in detail, got: {detail}"
);
}

#[test]
fn script_failed_curl_write_failure_produces_disk_full_message() {
// curl reports "Failure writing output to destination" when the
// download destination runs out of space (discovered via Docker test
// with a tiny tmpfs mount).
let err = Error::ScriptFailed {
exit_code: 23,
stderr: "curl: (23) Failure writing output to destination".into(),
};
let ufe = err.user_facing_error(SetupStage::InstallBinary);
let detail = ufe.detail.unwrap();
assert!(
detail.contains("free up space"),
"curl write-failure should produce disk-full message, got: {detail}"
);
}

#[test]
fn script_failed_ssh_disconnect_produces_targeted_message() {
let err = Error::ScriptFailed {
exit_code: 255,
stderr: "".into(),
};
let ufe = err.user_facing_error(SetupStage::InstallBinary);
assert_eq!(ufe.body, "Failed to install SSH extension");
let detail = ufe.detail.unwrap();
assert!(
detail.contains("SSH connection was lost"),
"expected SSH disconnect message, got: {detail}"
);
assert!(
detail.contains("reconnect"),
"expected reconnect advice, got: {detail}"
);
}

#[test]
fn script_failed_ssh_disconnect_only_whitespace_stderr() {
let err = Error::ScriptFailed {
exit_code: 255,
stderr: " \n ".into(),
};
let ufe = err.user_facing_error(SetupStage::InstallBinary);
let detail = ufe.detail.unwrap();
assert!(
detail.contains("SSH connection was lost"),
"whitespace-only stderr with code 255 should trigger SSH disconnect, got: {detail}"
);
}

#[test]
fn script_failed_ssh_255_with_stderr_uses_default() {
// Exit code 255 but with actual stderr content should NOT match
// the SSH disconnect pattern — it's a real script error.
let err = Error::ScriptFailed {
exit_code: 255,
stderr: "bash: some-command: not found".into(),
};
let ufe = err.user_facing_error(SetupStage::InstallBinary);
let detail = ufe.detail.unwrap();
assert!(
detail.contains("Script exited with code 255"),
"non-empty stderr with code 255 should use default format, got: {detail}"
);
}

#[test]
fn script_failed_unsupported_arch_produces_targeted_message() {
let err = Error::ScriptFailed {
exit_code: 2,
stderr: "unsupported arch: armv7l".into(),
};
let ufe = err.user_facing_error(SetupStage::InstallBinary);
assert_eq!(ufe.body, "Failed to install SSH extension");
let detail = ufe.detail.unwrap();
assert!(
detail.contains("armv7l"),
"expected arch name in message, got: {detail}"
);
assert!(
detail.contains("x86_64 or aarch64"),
"expected supported arch list, got: {detail}"
);
}

#[test]
fn script_failed_default_format_for_unrecognised_error() {
let err = Error::ScriptFailed {
exit_code: 6,
stderr: "curl: (6) Could not resolve host".into(),
};
let ufe = err.user_facing_error(SetupStage::InstallBinary);
let detail = ufe.detail.unwrap();
assert!(
detail.starts_with("Script exited with code 6:"),
"unrecognised error should use default format, got: {detail}"
);
assert!(
detail.contains("Could not resolve host"),
"default format should include stderr, got: {detail}"
);
}

#[test]
fn script_failed_truncates_long_stderr_in_default_format() {
let long_stderr = "x".repeat(600);
let err = Error::ScriptFailed {
exit_code: 1,
stderr: long_stderr,
};
let ufe = err.user_facing_error(SetupStage::InstallBinary);
let detail = ufe.detail.unwrap();
assert!(
detail.contains('…'),
"long stderr should be truncated, got length: {}",
detail.len()
);
// MAX_STDERR_DISPLAY_CHARS is 512, plus "Script exited with code N: " prefix + "…"
assert!(
detail.len() < 600,
"truncated detail should be shorter than full stderr, got: {}",
detail.len()
);
}

#[test]
fn script_failed_body_reflects_stage() {
let err = Error::ScriptFailed {
exit_code: 1,
stderr: "some error".into(),
};
let launch = err.user_facing_error(SetupStage::Launch);
assert_eq!(launch.body, "Failed to start SSH extension");

let check = err.user_facing_error(SetupStage::CheckBinary);
assert_eq!(check.body, "Failed to verify SSH extension");
}

#[test]
fn timed_out_error_message() {
let err = Error::TimedOut;
let ufe = err.user_facing_error(SetupStage::InstallBinary);
assert_eq!(ufe.body, "Failed to install SSH extension");
let detail = ufe.detail.unwrap();
assert!(detail.contains("timed out"));
}

#[test]
fn other_error_has_no_detail() {
let err = Error::Other(anyhow::anyhow!("something unexpected"));
let ufe = err.user_facing_error(SetupStage::InstallBinary);
assert_eq!(ufe.body, "Failed to install SSH extension");
assert!(ufe.detail.is_none());
}
Loading