原生壳补齐Tauri请求超时

H5 Tauri HostBridge transport 按 timeoutMs 释放卡住的 invoke 请求

补充 Tauri 超时、计时器清理和迟到回包不改写结果的单测

桌面壳配置门禁锁定 Tauri transport 超时封装

同步宿主壳方案文档和共享决策日志
This commit is contained in:
2026-06-18 23:02:07 +08:00
parent aca596a441
commit 6c17b4917f
5 changed files with 174 additions and 10 deletions
@@ -119,6 +119,110 @@ describe('nativeAppHostBridge', () => {
});
});
test('Tauri invoke 超时时按 HostBridge 超时错误拒绝请求', async () => {
vi.useFakeTimers();
const invoke = vi.fn(
() =>
new Promise<HostBridgeResponse>(() => {
// 保持宿主命令挂起,让 H5 超时先返回。
}),
);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
const pending = requestNativeAppHostBridge('share.open', undefined, {
timeoutMs: 10,
});
const assertion = expect(pending).rejects.toMatchObject({
name: 'timeout',
message: 'host_bridge_timeout',
});
await vi.advanceTimersByTimeAsync(11);
await assertion;
expect(vi.getTimerCount()).toBe(0);
});
test('Tauri invoke 成功回包后清理超时计时器', async () => {
vi.useFakeTimers();
const invoke = vi.fn(async (_command: string, args?: Record<string, unknown>) => {
const request = (args as { request: HostBridgeRequest }).request;
return {
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: request.id,
ok: true,
result: {
handled: true,
},
} satisfies HostBridgeResponse;
});
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(
requestNativeAppHostBridge('share.open', undefined, {
timeoutMs: 1000,
}),
).resolves.toEqual({
handled: true,
});
expect(vi.getTimerCount()).toBe(0);
});
test('Tauri invoke 超时后的迟到回包不改变已拒绝结果', async () => {
vi.useFakeTimers();
let resolveInvoke:
| ((response: HostBridgeResponse<{ handled: boolean }>) => void)
| null = null;
const invoke = vi.fn(
() =>
new Promise<HostBridgeResponse<{ handled: boolean }>>((resolve) => {
resolveInvoke = resolve;
}),
);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
const pending = requestNativeAppHostBridge('share.open', undefined, {
timeoutMs: 10,
});
const assertion = expect(pending).rejects.toMatchObject({
name: 'timeout',
message: 'host_bridge_timeout',
});
await vi.advanceTimersByTimeAsync(11);
await assertion;
const request = (invoke.mock.calls[0]?.[1] as { request: HostBridgeRequest })
.request;
resolveInvoke?.({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: request.id,
ok: true,
result: {
handled: true,
},
});
await Promise.resolve();
await expect(pending).rejects.toMatchObject({
name: 'timeout',
message: 'host_bridge_timeout',
});
});
test('没有原生宿主 bridge 时返回 null', async () => {
await expect(
requestNativeAppHostBridge('host.getRuntime'),
@@ -32,6 +32,11 @@ type PendingNativeRequest = {
timeoutId: ReturnType<typeof setTimeout>;
};
type NativeTauriInvoke = <Result = unknown>(
command: string,
args?: Record<string, unknown>,
) => Promise<Result>;
const pendingNativeRequests = new Map<string, PendingNativeRequest>();
const nativeEventListeners = new Map<string, Set<(payload: unknown) => void>>();
@@ -66,6 +71,13 @@ function createHostBridgeError(error: HostBridgeError) {
return result;
}
function createNativeHostBridgeTimeoutError() {
return createHostBridgeError({
code: 'timeout',
message: 'host_bridge_timeout',
});
}
function isHostBridgeResponse(value: unknown): value is HostBridgeResponse {
if (!value || typeof value !== 'object') {
return false;
@@ -174,6 +186,32 @@ function ensureNativeBridgeListener() {
nativeBridgeListenerInstalled = true;
}
async function invokeTauriHostBridgeWithTimeout<Result>(
tauriInvoke: NativeTauriInvoke,
request: HostBridgeRequest,
timeoutMs: number,
) {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(createNativeHostBridgeTimeoutError());
}, timeoutMs);
});
try {
return await Promise.race([
tauriInvoke<HostBridgeResponse<Result>>(HOST_BRIDGE_TAURI_COMMAND, {
request,
}),
timeoutPromise,
]);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
export function canUseReactNativeHostBridge() {
return (
typeof resolveNativeWindow()?.ReactNativeWebView?.postMessage ===
@@ -233,9 +271,10 @@ export async function requestNativeAppHostBridge<Result = unknown>(
const tauriInvoke = nativeWindow.__TAURI__?.core?.invoke;
if (typeof tauriInvoke === 'function') {
const response = await tauriInvoke<HostBridgeResponse<Result>>(
HOST_BRIDGE_TAURI_COMMAND,
{ request },
const response = await invokeTauriHostBridgeWithTimeout<Result>(
tauriInvoke,
request,
timeoutMs,
);
if (!isHostBridgeResponse(response)) {
throw new Error('host_bridge_invalid_response');
@@ -256,10 +295,7 @@ export async function requestNativeAppHostBridge<Result = unknown>(
return await new Promise<Result>((resolve, reject) => {
const timeoutId = setTimeout(() => {
pendingNativeRequests.delete(request.id);
reject(createHostBridgeError({
code: 'timeout',
message: 'host_bridge_timeout',
}));
reject(createNativeHostBridgeTimeoutError());
}, timeoutMs);
pendingNativeRequests.set(request.id, {