传播插件 IPC 和媒体解析错误

插件 stdout 读取错误送达挂起 RPC

保留 UTF-8 图片和账本 JSON 解析原因

同步错误传播约定文档
This commit is contained in:
kdletters
2026-10-05 18:35:52 +08:00
parent 8b063c607f
commit cecc26d024
5 changed files with 39 additions and 19 deletions
@@ -1968,7 +1968,7 @@ fn read_validated_platform_art_reference_at(
}
let bytes = fs::read(&source_path).map_err(|error| format!("读取参考素材失败:{error}"))?;
let decoded = image::load_from_memory(&bytes)
.map_err(|_| format!("参考素材不是可解析图片:{}", source.local_path))?;
.map_err(|error| format!("参考素材不是可解析图片:{}:{error}", source.local_path))?;
Ok((bytes, decoded))
}
@@ -160,7 +160,7 @@ struct RunningPlugin {
#[cfg(windows)]
_job: crate::process_session::WindowsProcessJob,
stdin: Arc<Mutex<ChildStdin>>,
lines: Option<Receiver<String>>,
lines: Option<Receiver<Result<String, String>>>,
pending: PendingRpc,
registrations: Arc<Mutex<PluginRegistrations>>,
next_request_id: u64,
@@ -627,9 +627,18 @@ fn spawn_plugin(manifest: &PluginManifest, root: &Path) -> Result<RunningPlugin,
let (sender, receiver) = mpsc::sync_channel(16);
thread::spawn(move || {
let mut reader = BufReader::new(stdout);
while let Ok(Some(line)) = read_bounded_rpc_line(&mut reader) {
if sender.send(line).is_err() {
break;
loop {
match read_bounded_rpc_line(&mut reader) {
Ok(Some(line)) => {
if sender.send(Ok(line)).is_err() {
break;
}
}
Ok(None) => break,
Err(error) => {
let _ = sender.send(Err(error));
break;
}
}
}
});
@@ -652,7 +661,7 @@ fn read_bounded_rpc_line(reader: &mut impl BufRead) -> Result<Option<String>, St
loop {
let buffer = reader
.fill_buf()
.map_err(|_| "读取插件输出失败".to_string())?;
.map_err(|error| format!("读取插件输出失败:{error}"))?;
if buffer.is_empty() {
return if bytes.is_empty() {
Ok(None)
@@ -670,7 +679,7 @@ fn read_bounded_rpc_line(reader: &mut impl BufRead) -> Result<Option<String>, St
if newline.is_some() {
return String::from_utf8(bytes)
.map(Some)
.map_err(|_| "插件输出不是 UTF-8".to_string());
.map_err(|error| format!("插件输出不是 UTF-8:{error}"));
}
}
}
@@ -1437,10 +1446,18 @@ impl PluginHost {
let manifest = record.manifest.clone();
let root = root.to_path_buf();
thread::spawn(move || {
while let Ok(line) = lines.recv() {
let mut terminal_error = None;
while let Ok(line_result) = lines.recv() {
if !active.load(Ordering::SeqCst) {
break;
}
let line = match line_result {
Ok(line) => line,
Err(error) => {
terminal_error = Some(error);
break;
}
};
let Ok(envelope) = serde_json::from_str::<RpcEnvelope>(&line) else {
continue;
};
@@ -1495,8 +1512,9 @@ impl PluginHost {
}
if let Ok(mut waiting) = pending.lock() {
let remaining = std::mem::take(&mut *waiting);
let terminal_error = terminal_error.unwrap_or_else(|| "插件进程已退出".to_string());
for (_, sender) in remaining {
let _ = sender.send(Err("插件进程已退出".to_string()));
let _ = sender.send(Err(terminal_error.clone()));
}
}
});
@@ -1454,8 +1454,8 @@ fn resolve_resource_edit_source(
RESOURCE_EDIT_TEXT_MAX_BYTES,
"源文本资源",
)?;
let text =
String::from_utf8(bytes).map_err(|_| "文本资源必须使用 UTF-8 编码".to_string())?;
let text = String::from_utf8(bytes)
.map_err(|error| format!("文本资源必须使用 UTF-8 编码:{error}"))?;
return Ok(ResourceEditSourceSnapshot {
canonical_resource_id,
source_path: Some(path),
@@ -1507,7 +1507,7 @@ fn resolve_resource_edit_source(
let (source_width, source_height) =
if input.edit_kind == LocalProjectResourceEditKind::CharacterAnimation {
let decoded = image::load_from_memory(&bytes)
.map_err(|_| "生成角色动画前无法解析源图片尺寸".to_string())?;
.map_err(|error| format!("生成角色动画前无法解析源图片尺寸:{error}"))?;
(Some(decoded.width()), Some(decoded.height()))
} else {
(None, None)
@@ -2029,8 +2029,8 @@ async fn register_resource_edit_source_image(
.bytes
.as_deref()
.ok_or_else(|| "登记源图片缺少文件内容".to_string())?;
let decoded =
image::load_from_memory(bytes).map_err(|_| "登记源图片前无法解析图片尺寸".to_string())?;
let decoded = image::load_from_memory(bytes)
.map_err(|error| format!("登记源图片前无法解析图片尺寸:{error}"))?;
let endpoint = format!(
"/api/external/v1/editor/projects/{}/resources",
percent_encode_query_component(remote_project_id)
@@ -2313,7 +2313,7 @@ async fn ensure_resource_edit_source_reference(
.as_deref()
.ok_or_else(|| "登记源图片缺少文件内容".to_string())?;
let decoded = image::load_from_memory(bytes)
.map_err(|_| "登记源图片前无法解析图片尺寸".to_string())?;
.map_err(|error| format!("登记源图片前无法解析图片尺寸:{error}"))?;
let resource_id = if let Some(resource_id) = ledger.source_remote_resource_id.clone() {
resource_id
} else {
@@ -2755,7 +2755,7 @@ async fn submit_resource_edit_remote(
.as_deref()
.ok_or_else(|| "资源编辑账本缺少请求正文".to_string())?;
let mut body_value = serde_json::from_str::<serde_json::Value>(body)
.map_err(|_| "资源编辑账本请求正文无效".to_string())?;
.map_err(|error| format!("资源编辑账本请求正文无效:{error}"))?;
if editor_api_mode() == EditorApiMode::PlatformAccount {
if let Some(inputs) = body_value.as_object_mut().and_then(|body| {
body.entry("generationInputs")
@@ -3165,7 +3165,7 @@ fn validate_downloaded_media(
return Err("抠图结果必须是带透明通道的 PNG".to_string());
}
let decoded = image::load_from_memory_with_format(bytes, image::ImageFormat::Png)
.map_err(|_| "抠图结果不是有效的 PNG".to_string())?;
.map_err(|error| format!("抠图结果不是有效的 PNG:{error}"))?;
if !decoded.color().has_alpha() {
return Err("抠图结果缺少透明通道".to_string());
}
@@ -4745,7 +4745,7 @@ fn write_resource_edit_result_binding(
| LocalProjectResourceEditKind::BackgroundRemoval
) {
let decoded = image::load_from_memory(&bytes)
.map_err(|_| "派生图片 binding 无法解析尺寸".to_string())?;
.map_err(|error| format!("派生图片 binding 无法解析尺寸:{error}"))?;
(Some(decoded.width()), Some(decoded.height()))
} else {
(None, None)
@@ -5878,7 +5878,7 @@ pub(crate) async fn derive_local_project_resource_typed(
Err(error) => Err(error),
Ok(bytes) => (|| {
let content = std::str::from_utf8(&bytes)
.map_err(|_| "派生文本不是 UTF-8".to_string())
.map_err(|error| format!("派生文本不是 UTF-8:{error}"))
.map_err(ResourceEditError::Other)?;
let (media_type, extension) = validate_text_derivative(
&input.edit_kind,
@@ -112,6 +112,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接
- Codex CLI / Claude sidecar 的 JSONL/JSON 解析失败、失败终态、缺失终态、超时和空回执都保留解析错误、失败事件正文与有界安全片段;`LlmError` 没有 detail 字段的超时/空回执由 Direct `ModelCallFailed.detail` 补回,不再把这些协议事实统一压成单一类别。
- 账户、模型目录、External Editor/资源编辑、发布、素材上传、错误报告与客户端受控工具桥共用同一原则:网络错误保留底层因链,JSON/协议解析保留 serde 原因,HTTP 错误保留状态码与安全正文;只有响应完全没有正文时才说明“未提供 error/code/message”,不得退成“无法连接/格式无效/服务器未返回错误信息”。
- 素材直传、参考图直传、资源编辑上传/轮询、模型目录初始化和 Direct MCP stdio 的 `send`/读写/序列化失败,不得用 `map_err(|_| 固定句)` 丢掉 `reqwest`、serde 或 OS/IPC 正文;固定句只能作为事实前缀,底层原因必须接在后面。
- 插件 stdout RPC 读取线程不得吞掉 `fill_buf`、UTF-8 或大小上限错误后伪装成“插件进程已退出”;读线程通过挂起 RPC 的错误通道传播真实原因。
- 浏览器启动/DevTools 握手与 Codex model-catalog 子进程失败保留 stderr、退出状态、解析错误和阶段;稳定机器码仍用于分类,但不能单独成为用户可见正文。
- Node/npm 环境探测和 Web scaffold 构建持续排空并保存有界 stdout/stderr 尾部,失败返回退出状态、超时阶段和脱敏正文;环境预检回执同时下发 `code` 与 `diagnostic`。
- HTTP 409 只有明确包含泥点不足事实时才映射为 `paidCreditsInsufficient`;Claude Code 的普通 409 冲突保留为 `upstreamFailed`。
@@ -15,6 +15,7 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时,
- Rust 侧错误文本脱敏:只替换敏感值、URL、绝对路径和私钥内容,保留 HTTP 状态、错误码、字段名和可行动描述。
- app-server `codexErrorInfo` 只有嵌套机器字段时,保留经有界脱敏的结构化值;`error` 是字符串或未知 JSON 形状时也保留正文;`fields=codexErrorInfo` 不能成为唯一正文。
- 素材/参考图/资源编辑上传、资源编辑轮询、模型目录初始化和 Direct MCP stdio 的网络、JSON、写入、读写失败保留底层错误正文;不能由 `map_err(|_| 固定句)` 把它们压成无因的连接或失败提示。
- 插件 stdout RPC 的 `fill_buf`、UTF-8 和响应大小错误通过挂起 RPC 传播,不能在读取线程里吞掉后统一说“插件进程已退出”。
- Transport / Stream / IPC / host process / memory exhaustion 的回归测试与错误事件证据。
## 不做