收口桌面壳重放内部错误

桌面 HostBridge replay 内部锁失败返回稳定 host_error

桌面配置门禁反查 replay 失败日志和无 panic 边界

补充原生壳方案文档和共享决策记录
This commit is contained in:
2026-06-20 20:23:22 +08:00
parent b8287bbf65
commit e563ae0a81
5 changed files with 140 additions and 25 deletions
+26 -1
View File
@@ -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<HostBridgeReplayReservation, HostBridgeResponse>',
'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,
@@ -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")
@@ -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<String, Arc<HostBridgeReplaySlot>>,
}
#[derive(Debug, Default)]
#[derive(Debug)]
pub(crate) struct HostBridgeReplaySlot {
request_id: String,
response: Mutex<Option<HostBridgeResponse>>,
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<HostBridgeReplayReservation, HostBridgeResponse> {
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<HostBridgeReplaySlot>,
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<HostBridgeReplaySlot>) -> 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");
@@ -170,6 +170,7 @@
- 2026-06-18 HostBridge request id replayExpo 和 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 分发不得处理共享契约外 methodTauri 壳 Rust `HOST_BRIDGE_METHODS` 必须与共享契约逐项一致。新增宿主 method 必须先更新共享契约,再落两端壳实现或明确 unsupported。
@@ -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 不得处理共享契约外 methodTauri 壳 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 覆盖。