mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 01:15:14 +08:00
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:
@@ -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" 之后取真正的状态码。
|
||||
|
||||
Reference in New Issue
Block a user