保留浏览器与模型目录进程错误正文
保留浏览器 stderr、退出状态和 DevTools 握手原因 保留 Codex model-catalog 子进程 stderr 与解析错误 补充浏览器错误诊断回归验证
This commit is contained in:
@@ -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、退出状态、解析原因和阶段信息,机器码只作为分类字段。
|
||||
|
||||
## 实现顺序
|
||||
|
||||
|
||||
Reference in New Issue
Block a user