收口桌面壳重放内部错误
桌面 HostBridge replay 内部锁失败返回稳定 host_error 桌面配置门禁反查 replay 失败日志和无 panic 边界 补充原生壳方案文档和共享决策记录
This commit is contained in:
@@ -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 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。
|
||||
|
||||
@@ -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 覆盖。
|
||||
|
||||
Reference in New Issue
Block a user