保留浏览器与模型目录进程错误正文

保留浏览器 stderr、退出状态和 DevTools 握手原因

保留 Codex model-catalog 子进程 stderr 与解析错误

补充浏览器错误诊断回归验证
This commit is contained in:
kdletters
2026-10-05 17:15:22 +08:00
parent d9802bfa22
commit 520dfc443c
4 changed files with 107 additions and 42 deletions
@@ -105,7 +105,7 @@ async fn read_bounded(mut stream: impl AsyncRead + Unpin, limit: usize) -> Resul
let count = stream
.read(&mut buffer)
.await
.map_err(|_| "model-catalog-read-failed")?;
.map_err(|error| format!("model-catalog-read-failed: {error}"))?;
if count == 0 {
return Ok(output);
}
@@ -132,31 +132,46 @@ async fn export_catalog(
if cancel.is_some_and(|flag| flag.load(Ordering::Acquire)) {
return Err("model-catalog-cancelled".into());
}
let mut child = command.spawn().map_err(|_| "model-catalog-spawn-failed")?;
let mut child = command
.spawn()
.map_err(|error| format!("model-catalog-spawn-failed: {error}"))?;
let tree = match OwnedProcessTree::attach(&child) {
Ok(tree) => tree,
Err(_) => {
Err(error) => {
let _ = child.start_kill();
let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
return Err("model-catalog-process-owner-unavailable".into());
return Err(format!("model-catalog-process-owner-unavailable: {error}"));
}
};
let result = if let (Some(stdout), Some(stderr)) = (child.stdout.take(), child.stderr.take()) {
let collected = async {
let (stdout, _, status) = tokio::try_join!(
let (stdout, stderr, status) = tokio::try_join!(
read_bounded(stdout, MAX_CATALOG_BYTES),
read_bounded(stderr, 64 * 1024),
async {
child
.wait()
.await
.map_err(|_| "model-catalog-wait-failed".to_string())
.map_err(|error| format!("model-catalog-wait-failed: {error}"))
},
)?;
if !status.success() {
return Err("model-catalog-export-failed".into());
let detail = String::from_utf8_lossy(&stderr).trim().to_string();
let detail = crate::sanitize_diagnostic_message(
&detail,
Some(Path::new("__agc_no_project_root__")),
);
return Err(if detail.is_empty() {
format!("model-catalog-export-failed: exitStatus={status}")
} else {
format!(
"model-catalog-export-failed: exitStatus={status}; stderr={}",
detail.chars().take(1200).collect::<String>()
)
});
}
serde_json::from_slice(&stdout).map_err(|_| "model-catalog-json-invalid".into())
serde_json::from_slice(&stdout)
.map_err(|error| format!("model-catalog-json-invalid: {error}"))
};
tokio::select! {
biased;
@@ -111,38 +111,47 @@ fn parse_devtools_ws_url(line: &str) -> Option<String> {
Some(ws.to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
enum OwnedBrowserLaunchError {
SpawnFailed,
SpawnFailed(String),
WsTimeout,
WsFailed,
WsFailed(String),
ConnectTimeout,
ConnectFailed,
ConnectFailed(String),
}
impl OwnedBrowserLaunchError {
fn stage(self) -> BrowserLaunchStage {
fn stage(&self) -> BrowserLaunchStage {
match self {
Self::SpawnFailed => BrowserLaunchStage::Spawn,
Self::WsTimeout | Self::WsFailed => BrowserLaunchStage::WsHandshake,
Self::ConnectTimeout | Self::ConnectFailed => BrowserLaunchStage::CdpConnect,
Self::SpawnFailed(_) => BrowserLaunchStage::Spawn,
Self::WsTimeout | Self::WsFailed(_) => BrowserLaunchStage::WsHandshake,
Self::ConnectTimeout | Self::ConnectFailed(_) => BrowserLaunchStage::CdpConnect,
}
}
fn is_recoverable(self) -> bool {
fn is_recoverable(&self) -> bool {
matches!(
self,
Self::WsTimeout | Self::WsFailed | Self::ConnectTimeout | Self::ConnectFailed
Self::WsTimeout | Self::WsFailed(_) | Self::ConnectTimeout | Self::ConnectFailed(_)
)
}
fn code(self) -> &'static str {
fn code(&self) -> &'static str {
match self {
Self::SpawnFailed => "browser-spawn-failed",
Self::SpawnFailed(_) => "browser-spawn-failed",
Self::WsTimeout => "browser-ws-timeout",
Self::WsFailed => "browser-ws-failed",
Self::WsFailed(_) => "browser-ws-failed",
Self::ConnectTimeout => "browser-cdp-connect-timeout",
Self::ConnectFailed => "browser-cdp-connect-failed",
Self::ConnectFailed(_) => "browser-cdp-connect-failed",
}
}
fn detail(&self) -> Option<&str> {
match self {
Self::SpawnFailed(detail) | Self::WsFailed(detail) | Self::ConnectFailed(detail) => {
Some(detail)
}
Self::WsTimeout | Self::ConnectTimeout => None,
}
}
}
@@ -166,13 +175,14 @@ impl BrowserLaunchStage {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
struct BrowserLaunchFailure {
code: &'static str,
stage: BrowserLaunchStage,
recoverable: bool,
subprocess_exited: bool,
cleanup_confirmed: bool,
detail: Option<String>,
}
impl BrowserLaunchFailure {
@@ -183,6 +193,7 @@ impl BrowserLaunchFailure {
recoverable: false,
subprocess_exited: true,
cleanup_confirmed: true,
detail: None,
}
}
@@ -197,11 +208,17 @@ impl BrowserLaunchFailure {
recoverable: error.is_recoverable(),
subprocess_exited,
cleanup_confirmed,
detail: error.detail().map(|detail| {
crate::sanitize_diagnostic_message(detail, None)
.chars()
.take(1200)
.collect()
}),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
struct BrowserRecoveryFailure {
first: BrowserLaunchFailure,
final_attempt: Option<BrowserLaunchFailure>,
@@ -211,16 +228,26 @@ impl BrowserRecoveryFailure {
fn diagnostic(self) -> String {
match self.final_attempt {
Some(final_attempt) => format!(
"browser-recovery-failed: initial-stage={} final-stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=true initial-cause={} final-cause={}",
"browser-recovery-failed: initial-stage={} final-stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=true initial-cause={} final-cause={}{}{}",
self.first.stage.as_str(),
final_attempt.stage.as_str(),
final_attempt.subprocess_exited,
final_attempt.cleanup_confirmed,
self.first.code,
final_attempt.code,
self.first
.detail
.as_deref()
.map(|detail| format!(" initial-detail={detail}"))
.unwrap_or_default(),
final_attempt
.detail
.as_deref()
.map(|detail| format!(" final-detail={detail}"))
.unwrap_or_default(),
),
None => format!(
"{}: stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=false cause={}",
"{}: stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=false cause={}{}",
if self.first.recoverable {
"browser-recovery-blocked"
} else {
@@ -230,6 +257,11 @@ impl BrowserRecoveryFailure {
self.first.subprocess_exited,
self.first.cleanup_confirmed,
self.first.code,
self.first
.detail
.as_deref()
.map(|detail| format!(" detail={detail}"))
.unwrap_or_default(),
),
}
}
@@ -271,7 +303,7 @@ async fn devtools_ws_url_from_stderr(
let stderr = child
.stderr
.take()
.ok_or(OwnedBrowserLaunchError::SpawnFailed)?;
.ok_or_else(|| OwnedBrowserLaunchError::SpawnFailed("browser stderr 未建立".into()))?;
let mut reader = futures::io::BufReader::new(stderr);
let mut captured: Vec<u8> = Vec::new();
let mut exited = Box::pin(child.wait()).fuse();
@@ -279,18 +311,18 @@ async fn devtools_ws_url_from_stderr(
loop {
let mut line = Vec::new();
futures::select! {
_status = exited => return Err(OwnedBrowserLaunchError::WsFailed),
_status = exited => return Err(OwnedBrowserLaunchError::WsFailed(safe_browser_detail(&captured))),
result = reader.read_until(b'\n', &mut line).fuse() => {
let count = result.map_err(|_| OwnedBrowserLaunchError::WsFailed)?;
let count = result.map_err(|error| OwnedBrowserLaunchError::WsFailed(format!("读取 browser stderr 失败:{error};{}", safe_browser_detail(&captured))))?;
if count == 0 {
return Err(OwnedBrowserLaunchError::WsFailed);
return Err(OwnedBrowserLaunchError::WsFailed(safe_browser_detail(&captured)));
}
captured.extend_from_slice(&line);
if captured.len() > MAX_LAUNCH_STDERR_BYTES {
return Err(OwnedBrowserLaunchError::WsFailed);
return Err(OwnedBrowserLaunchError::WsFailed(format!("browser stderr 超过大小上限;{}", safe_browser_detail(&captured))));
}
let Ok(text) = std::str::from_utf8(&line) else {
return Err(OwnedBrowserLaunchError::WsFailed);
return Err(OwnedBrowserLaunchError::WsFailed(format!("browser stderr 不是 UTF-8;{}", safe_browser_detail(&captured))));
};
if let Some(url) = parse_devtools_ws_url(text) {
return Ok(url);
@@ -306,6 +338,17 @@ async fn devtools_ws_url_from_stderr(
}
}
fn safe_browser_detail(bytes: &[u8]) -> String {
let text = String::from_utf8_lossy(bytes).trim().to_string();
if text.is_empty() {
return "未收到 browser stderr 具体正文".to_string();
}
crate::sanitize_diagnostic_message(&text, None)
.chars()
.take(1200)
.collect()
}
fn spawn_stderr_drain(mut reader: BrowserStderrReader) -> tokio::task::JoinHandle<()> {
// 持续排空 stderr:浏览器日志写满管道会整体 stall。内容有界、不留存。
tokio::spawn(async move {
@@ -484,9 +527,9 @@ async fn launch_owned_browser(
) -> Result<OwnedBrowser, BrowserLaunchFailure> {
let child = match config.launch() {
Ok(child) => child,
Err(_) => {
Err(error) => {
return Err(BrowserLaunchFailure::from_launch_error(
OwnedBrowserLaunchError::SpawnFailed,
OwnedBrowserLaunchError::SpawnFailed(error.to_string()),
true,
true,
));
@@ -511,7 +554,9 @@ async fn launch_owned_browser(
return Err(cleanup_failed_launch(
process,
temp,
OwnedBrowserLaunchError::SpawnFailed,
OwnedBrowserLaunchError::SpawnFailed(
"browser root identity unavailable".into(),
),
false,
)
.await);
@@ -528,13 +573,15 @@ async fn launch_owned_browser(
return Err(cleanup_failed_launch(
process,
temp,
OwnedBrowserLaunchError::SpawnFailed,
OwnedBrowserLaunchError::SpawnFailed(
"browser process tree not covered by Job".into(),
),
tree_reaped,
)
.await);
}
}
Err(_) => {
Err(error) => {
let tree_reaped = match (child.inner.id(), root_identity.as_deref()) {
(Some(root_pid), Some(root_identity)) => {
super::sweep::kill_browser_process_tree(root_pid, root_identity).is_ok()
@@ -545,7 +592,7 @@ async fn launch_owned_browser(
return Err(cleanup_failed_launch(
process,
temp,
OwnedBrowserLaunchError::SpawnFailed,
OwnedBrowserLaunchError::SpawnFailed(format!("创建 browser Job 失败:{error}")),
tree_reaped,
)
.await);
@@ -575,12 +622,12 @@ async fn launch_owned_browser(
.await;
let (browser, mut handler) = match connected {
Ok(Ok(pair)) => pair,
Ok(Err(_)) => {
Ok(Err(error)) => {
drain_task.abort();
return Err(cleanup_failed_launch(
process,
temp,
OwnedBrowserLaunchError::ConnectFailed,
OwnedBrowserLaunchError::ConnectFailed(error.to_string()),
true,
)
.await);
@@ -828,7 +875,7 @@ mod health_tests {
if attempt == 0 {
drop(temporary);
Err(BrowserLaunchFailure::from_launch_error(
OwnedBrowserLaunchError::WsFailed,
OwnedBrowserLaunchError::WsFailed("fixture ws failure".into()),
true,
true,
))
@@ -876,7 +923,7 @@ mod health_tests {
async fn consecutive_browser_failures_keep_both_recovery_stages_and_safe_diagnostics() {
let failure = launch_with_one_recovery(|| async {
Err::<(), BrowserLaunchFailure>(BrowserLaunchFailure::from_launch_error(
OwnedBrowserLaunchError::WsFailed,
OwnedBrowserLaunchError::WsFailed("fixture ws failure".into()),
true,
true,
))
@@ -889,6 +936,7 @@ mod health_tests {
assert!(diagnostic.contains("subprocess-exited=true"));
assert!(diagnostic.contains("cleanup-confirmed=true"));
assert!(diagnostic.contains("recovery-retried=true"));
assert!(diagnostic.contains("fixture ws failure"));
assert!(!diagnostic.contains("/tmp"));
}
@@ -109,6 +109,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接
- Claude Code(cc)侧车的非零退出、RPC `error`、stdout JSON/UTF-8 解析失败、stderr 和静默超时也走同一映射;侧车 stderr 只在有界收口窗口内读取并进入同一脱敏 detail,不再只写裸 `eprintln!`。
- Codex CLI / Claude sidecar 的 JSONL/JSON 解析失败、失败终态、缺失终态、超时和空回执都保留解析错误、失败事件正文与有界安全片段;`LlmError` 没有 detail 字段的超时/空回执由 Direct `ModelCallFailed.detail` 补回,不再把这些协议事实统一压成单一类别。
- 账户、模型目录、External Editor/资源编辑、发布、素材上传、错误报告与客户端受控工具桥共用同一原则:网络错误保留底层因链,JSON/协议解析保留 serde 原因,HTTP 错误保留状态码与安全正文;只有响应完全没有正文时才说明“未提供 error/code/message”,不得退成“无法连接/格式无效/服务器未返回错误信息”。
- 浏览器启动/DevTools 握手与 Codex model-catalog 子进程失败保留 stderr、退出状态、解析错误和阶段;稳定机器码仍用于分类,但不能单独成为用户可见正文。
- HTTP 409 只有明确包含泥点不足事实时才映射为 `paidCreditsInsufficient`;Claude Code 的普通 409 冲突保留为 `upstreamFailed`。
- `hostDropped` 携带可选的脱敏 `detail`:panic hook 能取得的负载和位置随原回合占用进入失败事件;普通 Drop 仅记录“退出时未写终态、未观察到 panic”,不推断为网络中断。旧版本无 `detail` 的事件继续可读,界面明确标注旧事件未记录原因。
- DirectProject 控制器的取消待发、终止、附件上传、项目历史读取和发送前置异常也复用同一份前端精确脱敏出口;这些非回合错误不得直接把 `Error.message` 原文写入状态栏、composer 或历史错误行。
@@ -19,6 +19,7 @@ Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md`
10. 资产导入、邀请码、策划工作区、发布封面/截图和素材命令的 UI 错误沿用同一共享出口。
11. Codex CLI / Claude sidecar 的解析失败、失败终态、缺失终态、超时和空回执保留有界脱敏正文;即使平台 `LlmError` 变体没有 detail 字段,也由统一 Direct `ModelCallFailed.detail` 继续携带原始原因。
12. 扩展统一正文口径到账户、模型目录、External Editor/资源编辑、发布、素材上传、错误报告与客户端受控工具桥,保留 HTTP 状态、响应安全片段、JSON 解析原因、网络因链和 IPC/桥接原因。
13. 浏览器启动/DevTools 握手与 Codex model-catalog 子进程错误保留 stderr、退出状态、解析原因和阶段信息,机器码只作为分类字段。
## 实现顺序