命令执行了两次
主线位置:调用主线的两端。JS 侧
ipc-protocol.js用fetch POST ipc://localhost/<cmd>发请求、在.then里解析响应;这条链上任何一步失败,都会切到window.ipc.postMessage把同一条消息再发一次,Rust 侧又从on_message走一遍。
sequenceDiagram participant J as ipc-protocol.js participant R as Rust 命令 J->>R: fetch POST ipc 第一次 R-->>J: 应答 Note over J: 解析失败或请求被取消 J->>R: postMessage 同一条消息 R-->>J: 再执行一次 用 eval 应答
实验 crate pit2-app:从模板 base 复制,tauri =2.12.5,--features tauri/custom-protocol,隐藏窗口,debug 构建,MODE 选场景。页面把 console.warn 收集起来一起送回 report。平台 M2 Max、macOS 27、rustc 1.99.0。
现象
一:返回 ipc::Response::new(String)。 想直接返回一段文本,不经过 serde:
#[tauri::command]
fn not_json() -> Response {
let n = NOT_JSON.fetch_add(1, Ordering::SeqCst) + 1;
log(format!("not_json executed, count = {n}"));
Response::new("hello".to_string())
}
页面只调一次,MODE=notjson:
0ms [main] main
191ms [main] probe plugin setup
767ms [main] not_json executed, count = 1
767ms [main] not_json executed, count = 2
REPORT {
"mode": "notjson",
"notJson": {
"err": "JSON error: expected value at line 1 column 1"
},
"warns": [
"IPC custom protocol failed, Tauri will now use the postMessage interface instead SyntaxError: The string did not match the expected pattern."
]
}
执行了两次,前端最后还是拿到一个错误。
二:命令执行期间页面刷新。 一个 300 毫秒的「扣款」命令,页面发出后不等,立刻 location.reload():
#[tauri::command]
async fn charge(amount: u32, ms: u64) -> String {
log(format!("charge({amount}) executed"));
tokio::time::sleep(Duration::from_millis(ms)).await;
format!("charged {amount}")
}
async function reloadTest(cmd, extra) {
const h = location.hash;
if (h === '') {
inv(cmd, { amount: 100, ms: 300, ...extra });
location.hash = '#2';
location.reload();
} else {
await sleep(800);
await done({ warns });
}
}
MODE=reload:
0ms [main] main
225ms [main] probe plugin setup
733ms [tokio-rt-worker] charge(100) executed
743ms [tokio-rt-worker] charge(100) executed
REPORT {
"warns": [
"[TAURI] Couldn't find callback id 1732715746. This might happen when the app is reloaded while Rust is running an asynchronous operation."
]
}
用户只点了一次,扣了两次款。这个结果连跑三次都一样(pit2-reload-debug.txt、-2、-3,每次都是两行 executed)。新页面里那条警告,是第二次执行的应答经 eval 落进了新页面。
出处
两种情形走的是 ipc-protocol.js 的同一个分支(JS 侧:invoke 与回调 逐行讲过这段):
.then(
([callbackId, data]) => {
window.__TAURI_INTERNALS__.runCallback(callbackId, data)
},
(e) => {
console.warn(
'IPC custom protocol failed, Tauri will now use the postMessage interface instead',
e
)
// failed to use the custom protocol IPC (either the webview blocked a custom protocol or it was a CSP error)
// so we need to fallback to the postMessage interface
customProtocolIpcFailed = true
sendIpcMessage(message)
}
)
这个拒绝处理函数接住的是它前面整条链上的任何失败,message 是原来那条消息,两个回调 id 也不变。JS 不知道失败发生在请求到达 Rust 之前还是之后:
- 情形一:Rust 已经执行完、应答成功,
Tauri-Response: ok。InvokeResponseBody从String转来时(From<String> for InvokeResponseBody)是Json变体,协议处理函数据此写Content-Type: application/json;JS 按这个头调response.json(),hello不是 JSON,解析失败,落进上面的分支。重发时走postMessage,第二次执行的应答要拼进一段eval脚本,Rust 先校验 JSON,失败,于是改调错误回调,就是那句JSON error: expected value at line 1 column 1(见 ipc 协议)。 - 情形二:请求已经进了 Rust、命令在跑;刷新让 WebKit 取消了这个请求,旧页面在卸载前跑到了拒绝处理函数,经
postMessage重发。postMessage的消息进了 Rust,命令第二次执行。
另外,customProtocolIpcFailed 一旦置为真,这个页面以后的所有调用都走 postMessage,直到页面重新加载;两条路的差别(大消息慢一个数量级,原始字节的处理也不同)见 ipc 协议。
修法
情形一:返回值的类型要和它声明的内容一致。 要原始字节就给 Vec<u8>,得到 Raw 变体、application/octet-stream,JS 拿到 ArrayBuffer:
#[tauri::command]
fn bytes_ok() -> Response {
let n = BYTES.fetch_add(1, Ordering::SeqCst) + 1;
log(format!("bytes_ok executed, count = {n}"));
Response::new(b"hello".to_vec())
}
out.bytes = await attempt(() => inv('bytes_ok').then(b => new TextDecoder().decode(b)));
580ms [main] bytes_ok executed, count = 1
REPORT {
"mode": "notjson_fix",
"bytes": {
"ok": "hello"
},
"warns": []
}
要文本就直接返回 String(不包 Response),serde 会把它序列化成带引号的 JSON 字符串。Response::new(String) 只适合你手里已经是一段合法 JSON 的时候。
情形二:让命令能识别重发。 重发的是同一条消息,参数一字不差。前端为每次「用户意图」生成一个键,Rust 记住见过的键:
#[derive(Default)]
struct Seen(Mutex<HashSet<String>>);
#[tauri::command]
async fn charge_once(seen: State<'_, Seen>, key: String, amount: u32, ms: u64) -> Result<String, String> {
if !seen.0.lock().unwrap().insert(key.clone()) {
log(format!("charge_once({amount}) key {key} seen before, skipped"));
return Err(format!("duplicate request {key}"));
}
log(format!("charge_once({amount}) key {key} executed"));
tokio::time::sleep(Duration::from_millis(ms)).await;
Ok(format!("charged {amount}"))
}
return reloadTest('charge_once', { key: crypto.randomUUID() });
MODE=reload_fix:
0ms [main] main
245ms [main] probe plugin setup
854ms [tokio-rt-worker] charge_once(100) key 1405c084-eae2-4d7d-af3b-c484fbc1e5fe executed
858ms [tokio-rt-worker] charge_once(100) key 1405c084-eae2-4d7d-af3b-c484fbc1e5fe seen before, skipped
REPORT {
"warns": []
}
两次请求都到了,第二次带着同一个键,被跳过。三次运行结果相同(另两次是 pit2-reload_fix-debug-2.txt、-3)。这次新页面里没有警告;第一次执行的应答属于已经取消的请求,被 wry 丢弃。
真实应用里键要和操作绑在一起(订单号、草稿 id),存在会持久化的地方,并且有过期策略;内存里的 HashSet 只是演示。只读的、本来就幂等的命令(查询、读文件)执行两次只是多花一点时间,不用改。
怎么确认没中招
- 开发者工具的控制台里出现
IPC custom protocol failed, Tauri will now use the postMessage interface instead:之后看紧跟的错误对象,SyntaxError多半是响应类型和内容不符;页面刷新、跳转时出现,是情形二。 - 新页面里出现
[TAURI] Couldn't find callback id ...:有一个应答属于上一个页面,说明刷新时有命令在执行,而且很可能执行了两次。 - 在有副作用的命令开头打一行日志,刷新几次页面数一数。
- 搜
Response::new(,检查参数是Vec<u8>还是String;是String的,确认内容一定是合法 JSON。
小结
ipc-protocol.js在fetch链上任何一步失败时,用同一条消息经postMessage重发,并从此只走postMessage;请求已经到达 Rust 时,命令会执行第二次。- 两个常见的触发:
ipc::Response::new(String)声明 JSON 却不是 JSON;命令执行期间页面刷新或跳转。 - 修法:返回类型和内容一致(字节用
Vec<u8>,文本直接返回String);有副作用的命令接受一个幂等键,重发时识别出来。