Rust in Everything
基于 tauri 2.12.5

命令执行了两次

主线位置:调用主线的两端。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);有副作用的命令接受一个幂等键,重发时识别出来。

💬 关联讨论

发起讨论
加载中...