From e563ae0a81c180609669505ce75e4be76b9f387e Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 20 Jun 2026 20:23:22 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E6=A1=8C=E9=9D=A2=E5=A3=B3?= =?UTF-8?q?=E9=87=8D=E6=94=BE=E5=86=85=E9=83=A8=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 桌面 HostBridge replay 内部锁失败返回稳定 host_error 桌面配置门禁反查 replay 失败日志和无 panic 边界 补充原生壳方案文档和共享决策记录 --- apps/desktop-shell/scripts/check-config.mjs | 27 +++- .../src-tauri/src/host_bridge/mod.rs | 9 +- .../src-tauri/src/host_bridge/protocol.rs | 126 +++++++++++++++--- .../shared-memory/decision-log.md | 1 + ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 2 + 5 files changed, 140 insertions(+), 25 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 7c0427abb..233a00996 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -126,6 +126,14 @@ const desktopHostBridgeNotificationsSource = fs.readFileSync( desktopHostBridgeNotificationsPath, 'utf8', ); +const desktopHostBridgeProtocolPath = new URL( + '../src-tauri/src/host_bridge/protocol.rs', + import.meta.url, +); +const desktopHostBridgeProtocolSource = fs.readFileSync( + desktopHostBridgeProtocolPath, + 'utf8', +); const desktopHostBridgeRuntimePath = new URL( '../src-tauri/src/host_bridge/runtime.rs', import.meta.url, @@ -2561,10 +2569,16 @@ const requiredRustHostSnippets = [ 'HostBridgeReplayState', 'HostBridgeReplayReservation', 'HOST_BRIDGE_RESPONSE_CACHE_MAX', + 'Result', + 'log_desktop_replay_failure("cache.lock", &error.to_string())', + 'desktop host bridge replay failed for {label}: {error}', + 'replay_unavailable_response(request_id)', + 'host_bridge_replay_state_returns_stable_error_when_cache_lock_is_unavailable', '.manage(HostBridgeReplayState::default())', 'fn prepare_host_bridge_request(', 'prepare_host_bridge_request(&mut request)', - 'replay_state.reserve(&request.id)', + 'match replay_state.reserve(&request.id)', + 'Err(response) => response', 'HostBridgeReplayState::wait_for_response', 'execute_host_bridge_request(app, request).await', 'replay_state.complete(slot, response)', @@ -2590,6 +2604,17 @@ for (const snippet of [ } } +for (const snippet of [ + 'expect("host bridge replay cache lock")', + 'expect("host bridge replay slot lock")', + 'expect("host bridge replay slot wait")', + 'expect("host bridge replay response")', +]) { + if (desktopHostBridgeProtocolSource.includes(snippet)) { + throw new Error(`desktop shell HostBridge replay path must not panic on ${snippet}`); + } +} + assertSameList( readDirectoryEntryList(rustSourceDir, 'desktop shell Rust root entries'), expectedRustRootEntries, diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/mod.rs b/apps/desktop-shell/src-tauri/src/host_bridge/mod.rs index 5de749be0..e1f3cb94b 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/mod.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/mod.rs @@ -43,8 +43,11 @@ pub(crate) async fn host_bridge_request( } let response = match replay_state.reserve(&request.id) { - HostBridgeReplayReservation::Wait(slot) => HostBridgeReplayState::wait_for_response(slot), - HostBridgeReplayReservation::Execute(slot) => { + Err(response) => response, + Ok(HostBridgeReplayReservation::Wait(slot)) => { + HostBridgeReplayState::wait_for_response(slot) + } + Ok(HostBridgeReplayReservation::Execute(slot)) => { let response = execute_host_bridge_request(app, request).await; replay_state.complete(slot, response) } @@ -72,7 +75,7 @@ mod tests { assert_eq!(response.id, "request-1"); assert_eq!(response.error.expect("error").code, "invalid_request"); - match replay_state.reserve("request-1") { + match replay_state.reserve("request-1").expect("replay reservation") { HostBridgeReplayReservation::Execute(_) => {} HostBridgeReplayReservation::Wait(_) => { panic!("invalid request must not reserve replay slot") diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs b/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs index 66345aa2c..f129ec660 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs @@ -34,6 +34,7 @@ pub(crate) const HOST_BRIDGE_METHODS: [&str; 25] = [ ]; pub(crate) const HOST_BRIDGE_REQUEST_ID_MAX_LENGTH: usize = 120; const HOST_BRIDGE_RESPONSE_CACHE_MAX: usize = 128; +const DESKTOP_HOST_BRIDGE_REQUEST_FAILED: &str = "desktop host bridge request failed"; const HOST_BRIDGE_ERROR_CODES: [&str; 6] = [ "invalid_request", "unsupported_method", @@ -93,8 +94,9 @@ struct HostBridgeReplayCache { slots: HashMap>, } -#[derive(Debug, Default)] +#[derive(Debug)] pub(crate) struct HostBridgeReplaySlot { + request_id: String, response: Mutex>, ready: Condvar, } @@ -106,13 +108,19 @@ pub(crate) enum HostBridgeReplayReservation { } impl HostBridgeReplayState { - pub(crate) fn reserve(&self, request_id: &str) -> HostBridgeReplayReservation { - let mut cache = self.cache.lock().expect("host bridge replay cache lock"); + pub(crate) fn reserve( + &self, + request_id: &str, + ) -> Result { + let mut cache = self.cache.lock().map_err(|error| { + log_desktop_replay_failure("cache.lock", &error.to_string()); + replay_unavailable_response(request_id) + })?; if let Some(slot) = cache.slots.get(request_id) { - return HostBridgeReplayReservation::Wait(slot.clone()); + return Ok(HostBridgeReplayReservation::Wait(slot.clone())); } - let slot = Arc::new(HostBridgeReplaySlot::default()); + let slot = Arc::new(HostBridgeReplaySlot::new(request_id)); cache.order.push(request_id.to_string()); cache.slots.insert(request_id.to_string(), slot.clone()); while cache.order.len() > HOST_BRIDGE_RESPONSE_CACHE_MAX { @@ -122,7 +130,7 @@ impl HostBridgeReplayState { } } - HostBridgeReplayReservation::Execute(slot) + Ok(HostBridgeReplayReservation::Execute(slot)) } pub(crate) fn complete( @@ -130,27 +138,69 @@ impl HostBridgeReplayState { slot: Arc, response: HostBridgeResponse, ) -> HostBridgeResponse { - let mut stored_response = slot.response.lock().expect("host bridge replay slot lock"); + let mut stored_response = match slot.response.lock() { + Ok(response) => response, + Err(error) => { + log_desktop_replay_failure("slot.complete", &error.to_string()); + return replay_unavailable_response(&response.id); + } + }; *stored_response = Some(response.clone()); slot.ready.notify_all(); response } pub(crate) fn wait_for_response(slot: Arc) -> HostBridgeResponse { - let mut stored_response = slot.response.lock().expect("host bridge replay slot lock"); + let mut stored_response = match slot.response.lock() { + Ok(response) => response, + Err(error) => { + log_desktop_replay_failure("slot.wait", &error.to_string()); + return replay_unavailable_response(&slot.request_id); + } + }; while stored_response.is_none() { - stored_response = slot - .ready - .wait(stored_response) - .expect("host bridge replay slot wait"); + stored_response = match slot.ready.wait(stored_response) { + Ok(response) => response, + Err(error) => { + log_desktop_replay_failure("slot.ready", &error.to_string()); + return replay_unavailable_response(&slot.request_id); + } + }; } - stored_response - .clone() - .expect("host bridge replay response") + match stored_response.clone() { + Some(response) => response, + None => replay_unavailable_response(&slot.request_id), + } } } +impl HostBridgeReplaySlot { + fn new(request_id: &str) -> Self { + Self { + request_id: request_id.to_string(), + response: Mutex::new(None), + ready: Condvar::new(), + } + } +} + +fn log_desktop_replay_failure(label: &str, error: &str) -> bool { + if !error.is_empty() { + eprintln!("desktop host bridge replay failed for {label}: {error}"); + } + + false +} + +fn replay_unavailable_response(request_id: &str) -> HostBridgeResponse { + failed( + request_id.to_string(), + "host_error", + DESKTOP_HOST_BRIDGE_REQUEST_FAILED, + ) +} + pub(crate) fn ok(id: String, result: Value) -> HostBridgeResponse { HostBridgeResponse { bridge: HOST_BRIDGE_PROTOCOL, @@ -170,7 +220,7 @@ pub(crate) fn failed( let (code, message) = if HOST_BRIDGE_ERROR_CODES.contains(&code) { (code, message.into()) } else { - ("host_error", "desktop host bridge request failed".to_string()) + ("host_error", DESKTOP_HOST_BRIDGE_REQUEST_FAILED.to_string()) }; HostBridgeResponse { @@ -270,6 +320,7 @@ pub(crate) fn request(method: &str) -> HostBridgeRequest { mod tests { use super::*; use serde_json::json; + use std::thread; #[test] fn invalid_envelope_is_rejected() { @@ -333,7 +384,9 @@ mod tests { let replay_state = HostBridgeReplayState::default(); let mut side_effect_count = 0; - let first_reservation = replay_state.reserve("request-1"); + let first_reservation = replay_state + .reserve("request-1") + .expect("first replay reservation"); let first_response = match first_reservation { HostBridgeReplayReservation::Execute(slot) => { side_effect_count += 1; @@ -341,7 +394,10 @@ mod tests { } HostBridgeReplayReservation::Wait(_) => panic!("first request must execute"), }; - let second_response = match replay_state.reserve("request-1") { + let second_response = match replay_state + .reserve("request-1") + .expect("second replay reservation") + { HostBridgeReplayReservation::Execute(_) => panic!("duplicate request must not execute"), HostBridgeReplayReservation::Wait(slot) => { HostBridgeReplayState::wait_for_response(slot) @@ -357,7 +413,10 @@ mod tests { fn host_bridge_replay_state_evicts_oldest_response_after_cache_limit() { let replay_state = HostBridgeReplayState::default(); - match replay_state.reserve("request-0") { + match replay_state + .reserve("request-0") + .expect("initial replay reservation") + { HostBridgeReplayReservation::Execute(slot) => { replay_state.complete(slot, ok("request-0".to_string(), json!(0))); } @@ -366,7 +425,7 @@ mod tests { for index in 1..=HOST_BRIDGE_RESPONSE_CACHE_MAX { let request_id = format!("request-{index}"); - match replay_state.reserve(&request_id) { + match replay_state.reserve(&request_id).expect("replay reservation") { HostBridgeReplayReservation::Execute(slot) => { replay_state.complete(slot, ok(request_id, json!(index))); } @@ -374,7 +433,10 @@ mod tests { } } - match replay_state.reserve("request-0") { + match replay_state + .reserve("request-0") + .expect("evicted replay reservation") + { HostBridgeReplayReservation::Execute(_) => {} HostBridgeReplayReservation::Wait(_) => { panic!("oldest request must be evicted after cache limit") @@ -382,6 +444,28 @@ mod tests { } } + #[test] + fn host_bridge_replay_state_returns_stable_error_when_cache_lock_is_unavailable() { + let replay_state = Arc::new(HostBridgeReplayState::default()); + let poison_state = replay_state.clone(); + let poison_result = thread::spawn(move || { + let _cache = poison_state.cache.lock().expect("cache lock"); + panic!("poison replay cache"); + }) + .join(); + assert!(poison_result.is_err()); + + let response = replay_state + .reserve("request-1") + .expect_err("poisoned replay cache returns stable response"); + + assert!(!response.ok); + assert_eq!(response.id, "request-1"); + let error = response.error.expect("replay error"); + assert_eq!(error.code, "host_error"); + assert_eq!(error.message, DESKTOP_HOST_BRIDGE_REQUEST_FAILED); + } + #[test] fn invalid_string_payload_is_rejected() { let mut invalid = request("clipboard.writeText"); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 06fe24ce1..c1e1c968e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -170,6 +170,7 @@ - 2026-06-18 HostBridge request id replay:Expo 和 Tauri 壳都必须按 request id 回放首次完成结果;同 id 进行中的请求共享同一执行结果,已完成请求直接回放缓存响应,避免系统分享、外链、剪贴板、文件选择 / 保存、本地通知、窗口导航等宿主副作用被重复触发。两端配置检查和测试会锁住 replay 结构。 - 2026-06-18 HostBridge request envelope 校验:共享契约提供 `isHostBridgeMethod` 与 `normalizeHostBridgeRequestId`,Expo 壳直接复用,Tauri 壳镜像同一白名单和 id 规则;空 id、控制字符 id、超长 id 和未知 method 都必须在 replay / 能力分发前返回 `invalid_request`,已知但当前壳未实现的登录 / 支付等 method 才返回 `unsupported_method`。Expo 壳捕获原生异常时只透传共享 `HostBridgeError.code` 白名单内且 `message` 为字符串的协议错误,Tauri 壳的 `failed(...)` 出口也必须先校验同一错误码白名单;未知原生错误对象或非法错误码统一归一为 `host_error` 和固定失败文案,不把 native 私有字段、任意错误码或非字符串 message 回传给 H5。 - 2026-06-20 桌面 HostBridge command facade 单测边界:Tauri 唯一 `host_bridge_request` command 必须先通过 `prepare_host_bridge_request(...)` 做 envelope、method 和 request id 校验,再进入 `HostBridgeReplayState` reserve / wait / execute;`apps/desktop-shell/src-tauri/src/host_bridge/mod.rs` 的单测必须覆盖非法 envelope 在 replay 前返回 `invalid_request` 且不会占用对应 request id 的 replay slot,桌面配置检查会反查该测试存在。 +- 2026-06-20 桌面 HostBridge replay 内部失败边界:Tauri `HostBridgeReplayState` 的 cache lock、slot lock 和 condvar wait 异常不得 panic,也不得把 Rust 内部错误细节回传给 H5;桌面壳只写 `desktop host bridge replay failed for ...` stderr 观测日志,并统一返回 `host_error: desktop host bridge request failed`。桌面配置检查反查 `reserve(...)` 的 `Result` 出口、稳定错误响应和 poison lock 单测。 - 2026-06-20 移动壳协议 helper 单测边界:`apps/mobile-shell/src/host-bridge/protocol.test.ts` 直接覆盖 Expo 移动壳 HostBridge JSON 解析、envelope 和 request id 校验、未知 method 拒绝、ok / failure 响应包装、unsupported / invalid_request 错误构造,以及 native helper 错误归一时只透传共享错误码与字符串 message,不泄露非法错误码、nativeStack 或其它私有字段;根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,避免协议边界只靠完整 bridge 流程间接覆盖。 - 2026-06-20 移动扫码 overlay 单测边界:`apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx` 直接覆盖移动扫码 overlay 的相机权限请求、二维码扫码成功、权限拒绝失败和关闭取消;单端配置检查会反查该组件测试存在,根级 `npm run check:native-shells` 会把该测试文件列入移动 shell 层结构清单,避免扫码 UI 容器只靠 `ShellApp.test.tsx` 的完整 HostBridge 流程间接覆盖。 - 2026-06-18 HostBridge method 白名单跨壳门禁:`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_METHODS` 是唯一协议来源;Expo 壳 HostBridge 分发不得处理共享契约外 method,Tauri 壳 Rust `HOST_BRIDGE_METHODS` 必须与共享契约逐项一致。新增宿主 method 必须先更新共享契约,再落两端壳实现或明确 unsupported。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index ed6217cdf..43644f4da 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -493,6 +493,8 @@ GameBridge 禁止: 2026-06-18 追加:HostBridge request envelope 校验收紧。共享契约提供 `isHostBridgeMethod` 和 `normalizeHostBridgeRequestId`;Expo 壳直接复用,Tauri 壳镜像同一 method 白名单和 request id 规则。空 id、控制字符 id、超长 id 和未知 method 都在进入 replay / 能力分发前返回 `invalid_request`,已知但当前壳未实现的登录 / 支付等 method 才返回 `unsupported_method`。Tauri 唯一 `host_bridge_request` command 入口必须先经过 `prepare_host_bridge_request(...)`,再进入 `HostBridgeReplayState` reserve / wait / execute;非法 envelope 不得占用 replay slot,也不得触发任何宿主能力分发。 +2026-06-20 追加:Tauri 桌面壳 HostBridge replay 的内部锁、等待和缓存状态异常只写入桌面壳 stderr 观测日志,H5 侧统一收到 `host_error` 与固定文案 `desktop host bridge request failed`;`HostBridgeReplayState::reserve(...)` 返回 `Result`,`complete(...)` / `wait_for_response(...)` 也不得用 `expect` 让宿主进程 panic。桌面配置检查会反查 replay 失败日志、稳定错误响应和 poison lock 单测,避免后续把内部 mutex / condvar 细节或 Rust panic 泄露到 H5 调用链。 + 2026-06-18 追加:HostBridge method 白名单进入跨壳门禁。`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_METHODS` 是唯一协议来源;Expo 壳的 HostBridge 分发 case 不得处理共享契约外 method,Tauri 壳 Rust `HOST_BRIDGE_METHODS` 必须与共享契约逐项一致。两端配置检查会在 `npm run check:native-shells` 中拒绝 method 白名单漂移,新增宿主能力必须先更新共享契约,再落壳实现。 2026-06-19 追加:HostBridge event 白名单进入跨壳门禁。`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_EVENTS` 是宿主注入事件名的唯一来源,当前只包含 `app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 和 `file.imageDropped`;事件名必须存在于 capability 白名单,各宿主壳只声明自身真实发射的事件 capability。Expo 壳事件注入函数使用 `HostBridgeEventName`,Tauri 壳 `shell/events.rs` 镜像同一清单并在脚本生成前拒绝未知事件,H5 `nativeAppHostBridge` 只分发 `isHostBridgeEventName()` 认可的事件。H5 `subscribeHostAppLifecycle()`、`subscribeHostNetworkStatusChange()`、`subscribeHostNavigationCanGoBack()` 和 `subscribeHostImageDrop()` 必须同时校验 `host.events` 与对应事件 capability,缺任一能力时不绑定事件监听;根级 `npm run check:native-shells` 会反查这四个订阅 facade 都通过 `canUseNativeHostEventCapability(...)` 进入同一双能力门控,并拒绝新增事件后漏补 H5 facade 覆盖。