fix(curl): 将 ETXTBSY 归为可重试的瞬时启动失败

启动 curl 子进程时命中的 ETXTBSY(可执行文件忙)是多线程程序里
fork/exec 与"刚写出的可执行文件"之间的瞬时竞态:另一线程 fork 出的
子进程在 fork 与 execve 之间短暂持有目标文件的可写句柄,导致本次
exec 被内核判为忙。此前它被并入 ProcessFailed 且 retryable=false,
per-URL 层直接放弃。

新增 CurlFailureKind::ProcessBusy(retryable=true),在 process_failed
中按 io::ErrorKind::ExecutableFileBusy 精确识别,并在 busy 重试前做
极短退避让兄弟进程完成 execve。生产中 curl 是固定系统二进制、从不
被并发改写,故此变更对真实下载无影响,仅消除集成测试在高并发下偶发的
"Text file busy" flake(fetch_bootstrap unwrap 失败)。

验证:以 8-10 并发 worker × 8 线程 / 4 核(≈64-80 线程超载)复现,
修复前首轮即挂;修复后 200+ 次超压运行零 ETXTBSY、零失败。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 07:30:20 -07:00
co-authored by Claude Opus 4.8
parent ab11cc899d
commit d4d6d0a686
+47 -2
View File
@@ -205,6 +205,11 @@ pub(crate) enum CurlFailureKind {
/// 代理自身故障:无法解析/连接代理,或代理返回 407 认证失败等。
Proxy,
ProcessFailed,
/// 启动 curl 进程时命中 `ETXTBSY`(可执行文件忙)。这是多线程程序里
/// fork/exec 与"刚写出的可执行文件"之间的瞬时竞态:另一线程 fork 出的
/// 子进程在 fork 与 execve 之间短暂持有该文件的可写句柄,导致本次 exec
/// 被内核判为忙。属瞬时错误,可重试。
ProcessBusy,
Unknown,
}
@@ -225,6 +230,7 @@ impl CurlFailureKind {
Self::Network => "network",
Self::Proxy => "proxy",
Self::ProcessFailed => "process_failed",
Self::ProcessBusy => "process_busy",
Self::Unknown => "unknown",
}
}
@@ -245,7 +251,7 @@ impl CurlFailureKind {
Self::Interrupted => ErrorCode::NETWORK_INTERRUPTED,
Self::Proxy => ErrorCode::PROXY_FAILURE,
Self::HttpUnknown | Self::Network | Self::Unknown => ErrorCode::NETWORK_OTHER,
Self::ProcessFailed => ErrorCode::INTERNAL,
Self::ProcessFailed | Self::ProcessBusy => ErrorCode::INTERNAL,
}
}
@@ -268,6 +274,7 @@ impl CurlFailureKind {
| Self::Tls
| Self::Interrupted
| Self::Network
| Self::ProcessBusy
| Self::Unknown => true,
}
}
@@ -305,13 +312,20 @@ impl CurlFailure {
curl_command: &Path,
error: std::io::Error,
) -> Self {
// ETXTBSY(可执行文件忙)是 fork/exec 的瞬时竞态,可重试;其余启动失败
// (如命令不存在、权限不足)是确定性错误,不重试。
let kind = if error.kind() == std::io::ErrorKind::ExecutableFileBusy {
CurlFailureKind::ProcessBusy
} else {
CurlFailureKind::ProcessFailed
};
Self {
url: url.to_string(),
destination: destination.map(Path::to_path_buf),
exit_code: None,
stderr: format!("启动 curl 命令失败 {}{error}", curl_command.display()),
http_status: None,
kind: CurlFailureKind::ProcessFailed,
kind,
}
}
@@ -434,6 +448,9 @@ pub(crate) fn run_curl_with_retry_with_proxy(
Err(error) => CurlFailure::process_failed(url, destination, curl_command, error),
};
let retryable = failure.retryable();
// ETXTBSY 是 fork/exec 竞态窗口造成的瞬时忙,立刻重试往往仍落在同一窗口内。
// 让出 CPU 并做一次极短退避,使持有可写句柄的兄弟进程完成其 execve。
let busy = failure.kind == CurlFailureKind::ProcessBusy;
failures.push(CurlAttemptFailure {
attempt,
attempts,
@@ -442,6 +459,9 @@ pub(crate) fn run_curl_with_retry_with_proxy(
if !retryable {
break;
}
if busy && attempt < attempts {
std::thread::sleep(std::time::Duration::from_millis(5 * attempt as u64));
}
}
Err(CurlRetryError { attempts: failures })
@@ -588,6 +608,31 @@ mod tests {
assert_eq!(CurlFailureKind::Network.error_code().id(), "BAT-ERR-300015");
}
#[test]
fn etxtbsy_spawn_failure_is_retryable() {
// ETXTBSY(可执行文件忙)来自 fork/exec 竞态,应归为可重试的 ProcessBusy
// 其余启动失败(如命令不存在)保持不可重试的 ProcessFailed。
let busy = std::io::Error::from(std::io::ErrorKind::ExecutableFileBusy);
let failure = CurlFailure::process_failed(
"https://example.test/a",
None,
Path::new("/tmp/fake-curl"),
busy,
);
assert_eq!(failure.kind, CurlFailureKind::ProcessBusy);
assert!(failure.retryable());
let missing = std::io::Error::from(std::io::ErrorKind::NotFound);
let failure = CurlFailure::process_failed(
"https://example.test/a",
None,
Path::new("/tmp/fake-curl"),
missing,
);
assert_eq!(failure.kind, CurlFailureKind::ProcessFailed);
assert!(!failure.retryable());
}
#[test]
fn parse_http_status_anchors_on_curl_error_context() {
// 状态码前出现无关三位数字(如端口 443)时,仍从 "error" 之后取真正的状态码。