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
74 changes: 66 additions & 8 deletions src/cli/commands/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,40 @@ fn build_tokio_runtime() -> Result<tokio::runtime::Runtime, ActualError> {
/// Inner async logic for [`check_auth_with_timeout`].
///
/// Separated for testability with `#[tokio::test]`.
/// `ETXTBSY`: exec of a file that some process still holds open for writing.
/// Same value (26) on Linux and macOS; Windows never produces it.
const ETXTBSY: i32 = 26;

/// Attempts for [`spawn_with_etxtbsy_retry`]; the race window is
/// microseconds, so a couple of short retries always clears it.
const SPAWN_ATTEMPTS: u32 = 3;
const SPAWN_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(20);

/// Spawn with a brief retry on ETXTBSY ("Text file busy").
///
/// Between an executable being written and exec'd, a subprocess forked by
/// any other thread briefly inherits the writer's file descriptor and the
/// exec fails with ETXTBSY. The condition is always transient (routine in
/// the parallel test suite, which writes fake binaries while other tests
/// spawn processes; possible in production when the claude binary is being
/// updated concurrently), so retry before giving up.
async fn spawn_with_etxtbsy_retry(
cmd: &mut tokio::process::Command,
attempts: u32,
delay: std::time::Duration,
) -> std::io::Result<tokio::process::Child> {
let mut attempt = 0;
loop {
attempt += 1;
match cmd.spawn() {
Err(e) if e.raw_os_error() == Some(ETXTBSY) && attempt < attempts => {
tokio::time::sleep(delay).await;
}
result => return result,
}
}
}

async fn check_auth_async(
binary_path: &Path,
timeout: std::time::Duration,
Expand All @@ -74,11 +108,11 @@ async fn check_auth_async(
// When the timeout future is dropped, the Child is dropped. With
// kill_on_drop(true) tokio sends SIGKILL, preventing orphaned processes.
cmd.kill_on_drop(true);
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());

let child = cmd
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
let child = spawn_with_etxtbsy_retry(&mut cmd, SPAWN_ATTEMPTS, SPAWN_RETRY_DELAY)
.await
.map_err(|e| ActualError::RunnerFailed {
message: format!("failed to spawn claude: {e}"),
stderr: String::new(),
Expand Down Expand Up @@ -142,11 +176,11 @@ async fn check_auth_async_no_json(
cmd.args(["auth", "status"]);
cmd.stdin(std::process::Stdio::null());
cmd.kill_on_drop(true);
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());

let child = cmd
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
let child = spawn_with_etxtbsy_retry(&mut cmd, SPAWN_ATTEMPTS, SPAWN_RETRY_DELAY)
.await
.map_err(|e| ActualError::RunnerFailed {
message: format!("failed to spawn claude: {e}"),
stderr: String::new(),
Expand Down Expand Up @@ -238,6 +272,30 @@ mod tests {
script
}

/// ETXTBSY retry: while any handle holds the script open for writing,
/// exec deterministically fails with ETXTBSY (checked against all open
/// write descriptions, including our own) — so the retry loop runs to
/// exhaustion and surfaces the original error.
#[cfg(unix)]
#[tokio::test]
async fn test_spawn_retry_exhausts_on_persistent_etxtbsy() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("busy-claude");
std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let _writer = std::fs::OpenOptions::new()
.append(true)
.open(&script)
.unwrap();

let mut cmd = tokio::process::Command::new(&script);
let result =
spawn_with_etxtbsy_retry(&mut cmd, 3, std::time::Duration::from_millis(1)).await;
let err = result.expect_err("spawn must fail while a writer holds the script");
assert_eq!(err.raw_os_error(), Some(ETXTBSY), "got: {err:?}");
}

#[test]
fn test_print_auth_status_authenticated() {
let status = ClaudeAuthStatus {
Expand Down
58 changes: 58 additions & 0 deletions src/cli/commands/sync/adr_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ pub(crate) fn fetch_error_message(e: &ActualError, api_url: &str) -> String {
ActualError::ApiResponseError { code, message } => {
format!("Actual AI returned an error ({code}): {message}")
}
// A quit key or SIGINT during the fetch is the user's decision, not
// an API failure — do not dress it up as one.
ActualError::UserCancelled => "Cancelled".to_string(),
_ => format!("API request failed: {e}"),
}
}
Expand Down Expand Up @@ -254,3 +257,58 @@ fn log_skip_invalid_project(project_path: &str, adr_id: &str) {
let clean = console::strip_ansi_codes(project_path);
tracing::warn!("skipping invalid project path '{clean}' for ADR '{adr_id}'");
}

#[cfg(test)]
mod tests {
use super::*;

// ── fetch_error_message ──

#[test]
fn test_fetch_error_message_service_unavailable() {
let msg = fetch_error_message(&ActualError::ServiceUnavailable, "https://api.test");
assert!(msg.contains("being updated"), "got: {msg}");
}

#[test]
fn test_fetch_error_message_connection_refused_mentions_url() {
let e = ActualError::ApiError("error trying to connect: Connection refused".to_string());
let msg = fetch_error_message(&e, "https://api.test");
assert!(msg.contains("https://api.test"), "got: {msg}");
assert!(msg.contains("network"), "got: {msg}");
}

#[test]
fn test_fetch_error_message_timeout() {
let e = ActualError::ApiError("operation timed out".to_string());
let msg = fetch_error_message(&e, "https://api.test");
assert!(msg.contains("timed out"), "got: {msg}");
}

#[test]
fn test_fetch_error_message_api_response_error() {
let e = ActualError::ApiResponseError {
code: "500".to_string(),
message: "boom".to_string(),
};
let msg = fetch_error_message(&e, "https://api.test");
assert!(msg.contains("500") && msg.contains("boom"), "got: {msg}");
}

#[test]
fn test_fetch_error_message_user_cancelled_is_not_an_api_failure() {
let msg = fetch_error_message(&ActualError::UserCancelled, "https://api.test");
assert_eq!(msg, "Cancelled");
assert!(
!msg.contains("API request failed"),
"a user cancel must not be presented as an API failure"
);
}

#[test]
fn test_fetch_error_message_generic_fallback() {
let e = ActualError::ApiError("something else".to_string());
let msg = fetch_error_message(&e, "https://api.test");
assert!(msg.starts_with("API request failed:"), "got: {msg}");
}
}
Loading
Loading