From d4d6d0a686e208c5f1eee9f6d07ecde66b20fae7 Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Fri, 17 Jul 2026 07:30:20 -0700 Subject: [PATCH] =?UTF-8?q?fix(curl):=20=E5=B0=86=20ETXTBSY=20=E5=BD=92?= =?UTF-8?q?=E4=B8=BA=E5=8F=AF=E9=87=8D=E8=AF=95=E7=9A=84=E7=9E=AC=E6=97=B6?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 启动 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 --- infrastructure/src/curl_transfer.rs | 49 +++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/infrastructure/src/curl_transfer.rs b/infrastructure/src/curl_transfer.rs index 318bb5f..927e6aa 100644 --- a/infrastructure/src/curl_transfer.rs +++ b/infrastructure/src/curl_transfer.rs @@ -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" 之后取真正的状态码。