Rust in Everything
基于 tauri 2.12.5

状态

主线位置:调用主线上「#[command] 生成的包装函数用 CommandArg 从 InvokeMessage 里逐个取参数」这一步里,参数类型是 State<T> 的那一种:它不从 JSON 里取,而是从 AppManager.state 里取。后半页的 ResourceTable 是同一条线上的另一种「把 Rust 对象留在后端、只给前端一个编号」的办法。包装函数本身怎么生成在 #[command] 与 generate_handler!。

flowchart TB
  mng["manage T"] --> setfn["StateManager::set"]
  setfn --> mapnode["Mutex HashMap TypeId 到 Box"]
  cmdarg["命令参数 State T"] --> fromcmd["CommandArg::from_command"]
  fromcmd --> tryget["try_state 即 try_get"]
  tryget --> mapnode
  tryget -->|有| stref["State 指向箱子"]
  tryget -->|没有| err["InvokeError: state not managed"]

源码在 state.rs(不到 300 行,一半是测试)和 resources/mod.rs。实验程序和平台同 Builder、App 与 AppHandle:M2 Max、macOS 27、rustc 1.99.0,tauri 2.12.5,真实 Wry 运行时,隐藏窗口,debug 构建。

StateManager:一张按 TypeId 索引的表

type TypeIdMap = HashMap<TypeId, Box<dyn Any + Sync + Send>, BuildHasherDefault<IdentHash>>;

/// The Tauri state manager.
// TODO: make private for v3
#[derive(Debug)]
pub struct StateManager {
  map: Mutex<TypeIdMap>,
}

StateManager 就是一个加了锁的 HashMap<TypeId, Box<dyn Any + Sync + Send>>。每种类型最多一个值,类型本身就是键。所以 manage 的类型参数要求 T: Send + Sync + 'static:'static 是 TypeId::of 和 dyn Any 的要求;Send + Sync 是因为命令可能在任何线程上拿到它。

哈希器 IdentHash 是从 state crate 抄来的,注释说得很直白:「This is a super stupid hash. It just uses its input as the hash value.」TypeId 本身已经是唯一的整数,再跑一遍 SipHash 没有意义。

set 在已有同类型值时什么也不做:

  pub(crate) fn set<T: Send + Sync + 'static>(&self, state: T) -> bool {
    let mut map = self.map.lock().unwrap();
    let type_id = TypeId::of::<T>();
    let already_set = map.contains_key(&type_id);
    if !already_set {
      let state = Box::new(state) as Box<dyn Any + Sync + Send>;
      map.insert(type_id, state);
    }
    !already_set
  }

不覆盖,是因为别处可能还拿着指向旧值的 State(下一节)。新值被原样丢掉(state 在函数结束时 drop)。

State:指向箱子的引用

pub struct State<'r, T>(&'r T);

State 只是一个引用,实现了 Deref<Target = T> 和 Clone,inner() 返回 &'r T。它从 try_get 来:

  pub fn try_get<T: 'static>(&self) -> Option<State<'_, T>> {
    let map = self.map.lock().unwrap();
    let type_id = TypeId::of::<T>();
    let state = map.get(&type_id)?;
    let value = state
      .downcast_ref::<T>()
      .expect("the type of the key should be same as the type of the value");
    // SAFETY: We ensure the lifetime of `value` is the same as [StateManager] and `value` will not be mutated/moved.
    let v_ref = unsafe { &*(value as *const T) };
    Some(State(v_ref))
  }

锁在函数返回时就释放了,返回的引用却活到 &self(也就是 StateManager)那么久。这一步 unsafe 成立靠两条约定,写在 TypeIdMap 上面的注释里:

  • 值在 Box 里,HashMap 扩容搬动的是 Box 指针,堆上的值地址不变。
  • 「Once you insert a value, you can't remove/mutated/move it anymore」:set 不覆盖、不删除。

所以拿到 State 之后不用持锁,多个线程可以同时读同一个状态;要改,只能靠 T 自己的内部可变性(Mutex、RwLock、原子类型)。实验里的计数器就是这样写的:

struct Counter(AtomicU32);

#[tauri::command]
fn sync_cmd(counter: State<'_, Counter>) -> u32 {
  log("app command sync_cmd");
  counter.0.fetch_add(1, Ordering::SeqCst) + 1
}

State 的生命周期 'r 最终挂在 AppManager 上。Manager::state 是 self.manager().state.try_get(),'_ 是 &self 的生命周期;命令参数里的 State<'_, T> 借的是 InvokeMessage 里那个 Webview,Webview 又拿着 Arc<AppManager>。async 命令里写 State<'_, T> 为什么能编译、借用参数为什么不行,是命令宏的事,见 #[command] 与 generate_handler!。

unmanage:违反了第二条约定

Manager::unmanage 恰好违反「不删除」:它把值从表里取出来、按值返回,之前拿到的 State 就指向了已释放的内存。它在 tauri-v2.0.0-rc.16 加入(#11071),2.3.0 被标成 #[deprecated](PR #12723,CHANGELOG:「Deprecate Manager::unmanage to fix use-after-free unsoundness」);同一个 PR 把 StateManager::unmanage 改成了 unsafe fn,Manager::unmanage 里留下一句注释「The caller decides to break the safety here, then OK, just let it go.」文档给的替代做法是把状态包成 Mutex<Option<T>>,用 take() 取走里面的值,外面的箱子不动。

两个 manage:一个返回 false,一个 panic

  fn manage<T>(&self, state: T) -> bool
  where
    T: Send + Sync + 'static,
  {
    self.manager().state.set(state)
  }
  pub fn manage<T>(self, state: T) -> Self
  where
    T: Send + Sync + 'static,
  {
    let type_name = std::any::type_name::<T>();
    assert!(
      self.state.set(state),
      "state for type '{type_name}' is already being managed",
    );
    self
  }

Manager::manage 把 set 的结果原样返回;Builder::manage 是链式调用,没有地方返回 bool,就 assert!。前者在 setup、插件、命令里调用,调用方可以看返回值决定怎么办;后者只能 panic。

实验在 Builder 上 manage(Counter(0)),在 setup 里再 app.manage(Counter(9)):

      log(format!(
        "manage(Counter) again -> {}, state = {}",
        app.manage(Counter(AtomicU32::new(9))),
        app.state::<Counter>().0.load(Ordering::SeqCst)
      ));

输出留给本页末尾的动脑筋。设了环境变量 DUP 时,Builder 上连着 manage 两次 Counter:

    0ms [main] main

thread 'main' (2368726) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.12.5/src/app.rs:2060:5:
state for type 'app_life::Counter' is already being managed
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
exit=101

Manager::state 在没有 manage 时 panic,信息是 state() called before manage() for {type_name}(类型名是 2.5.0 的 #13239 加上的);不想 panic 用 try_state,返回 Option。

命令里拿一个没 manage 的 State

State 的 CommandArg 实现:

impl<'r, 'de: 'r, T: Send + Sync + 'static, R: Runtime> CommandArg<'de, R> for State<'r, T> {
  /// Grabs the [`State`] from the [`CommandItem`]. This will never fail.
  fn from_command(command: CommandItem<'de, R>) -> Result<Self, InvokeError> {
    command.message.webview_ref().try_state().ok_or_else(|| {
      InvokeError::from_anyhow(anyhow::anyhow!(
        "state not managed for field `{}` on command `{}`. You must call `.manage()` before using this command",
        command.key, command.name
      ))
    })
  }
}

command.key 是参数在 JSON 里的键名,command.name 是命令名。实验里有一个要 State<Missing> 的命令,而 Missing 从没 manage 过:

struct Missing;

#[tauri::command]
fn need_state(_m: State<'_, Missing>) {}

页面 JS 调它,把 Promise 的拒绝原文送回 Rust 打印:

need_state err: state not managed for field `m` on command `need_state`. You must call `.manage()` before using this command

两处细节:参数名 _m 在消息里是 m,因为键名是参数名转成 camelCase 之后的样子,前导下划线被去掉了(键名规则见 #[command] 与 generate_handler!);文档注释还写着「This will never fail.」,函数体早就会返回 Err 了。这条错误在 debug 和 release 构建里一样。

📝NOTE
冷知识:缺状态以前是 panic

2.2.0 之前,这个 from_command 是 Ok(command.message.state_ref().try_get().unwrap_or_else(|| panic!(..))),同一句消息作为 panic 信息抛出,那句「This will never fail.」当时至少在 Result 的意义上是对的。issue #11949 报告了这个问题,PR #11958(「fix(core): return an error when accessing unmanaged state in command」)把 panic! 换成 InvokeError::from_anyhow(anyhow::anyhow!(..)),消息原文不变,JS 侧的 Promise 被拒绝、应用继续运行。CHANGELOG 原话:「Fix panic when invoking a command with an unmanaged state, an error will be returned instead.」第一个包含它的 tag 是 tauri-v2.2.0。在 macOS 上这个区别不小:同步命令跑在主线程、WebKit 回调的调用栈里,那里 panic 进程会直接 abort(见异步运行时的动脑筋)。

ResourceTable:给 JS 一个 rid

State 适合「每种类型一份、活到应用结束」的东西。另一类东西是一个命令创建、之后的命令按编号使用、用完要释放的,比如打开的文件、数据库连接、一个菜单。tauri 用 ResourceTable 管这类东西,代码改自 deno 的 resources.rs(文件头注明了出处):

pub trait Resource: Any + 'static + Send + Sync {
  fn name(&self) -> Cow<'_, str> {
    type_name::<Self>().into()
  }

  fn close(self: Arc<Self>) {}
}

pub type ResourceId = u32;

#[derive(Default)]
pub struct ResourceTable {
  index: BTreeMap<ResourceId, Arc<dyn Resource>>,
}

和 StateManager 对照:

StateManagerResourceTable
键TypeId随机 u32(getrandom,撞了就再取)
值Box<dyn Any>,不可删Arc<dyn Resource>,可删
同类型多个不行可以
取出&T(State)Arc<T>(get::<T>(rid),类型不对返回 BadResourceId)
有几张app 一张app、每个窗口、每个 webview 各一张

rid 以前是每张表各自递增的 next_rid;PR #9272(「refactor(core): scope JS resources to the webview」,tauri-v2.0.0-beta.14)把资源表拆到窗口和 webview 上时,提交里的一步是「use random rid」。几张表各自从 0 递增的话,同一个 rid 会同时出现在几张表里,下文的 close 命令按 webview → 窗口 → app 的顺序找,就可能关错对象;随机的 32 位 rid 让这种重叠几乎不会发生。三级表由 Manager::resources_table() 决定拿哪一张(见 AppManager 与 Manager trait);窗口和 webview 的表是 Window、Webview 结构体里的一个 Arc<Mutex<ResourceTable>> 字段,跟着这两个对象走,不在 app 级的表里。

释放有两种:

  • close(rid):从表里删掉,然后调 Resource::close。表里那一份 Arc 交给 close 后被 drop;如果别处没有克隆,值就在这里释放。
  • take、take_any、cleanup_before_exit 里的 clear:只删不调 close。

JS 侧的释放是内置插件 core:resources 的 close 命令,它依次在 webview、窗口、app 三张表里找这个 rid:

#[command(root = "crate")]
fn close<R: Runtime>(webview: Webview<R>, rid: ResourceId) -> crate::Result<()> {
  let mut result = webview.resources_table().close(rid);
  if result.is_err() {
    result = webview.window().resources_table().close(rid);
    if result.is_err() {
      result = webview.app_handle().resources_table().close(rid);
    }
  }
  result
}

实验里一个命令往 webview 的表里放一个资源、返回 rid,JS 用它调两次 plugin:resources|close(能力里加了 core:resources:allow-close):

struct Res(&'static str);

impl tauri::Resource for Res {
  fn close(self: std::sync::Arc<Self>) {
    log(format!("Resource::close {} (strong_count={})", self.0, std::sync::Arc::strong_count(&self)));
  }
}

impl Drop for Res {
  fn drop(&mut self) {
    log(format!("drop Res {}", self.0));
  }
}

#[tauri::command]
fn open_res(webview: tauri::Webview) -> tauri::ResourceId {
  let rid = webview.resources_table().add(Res("db-conn"));
  log(format!("open_res -> rid {rid}"));
  rid
}
  const rid = await I.invoke('open_res');
  await t(`plugin:resources|close rid=${rid}`, () => I.invoke('plugin:resources|close', { rid }));
  await t(`plugin:resources|close again`, () => I.invoke('plugin:resources|close', { rid }));
 1024ms [main] open_res -> rid 1076734987
 1025ms [main] Resource::close db-conn (strong_count=1)
 1025ms [main] drop Res db-conn
...
plugin:resources|close rid=1076734987 ok: null
plugin:resources|close again err: The resource id 1076734987 is invalid.

rid 是一个随机的大数;close 时表里那一份是唯一的引用(strong_count=1),close 返回后值立刻 drop;第二次 close 三张表都找不到,返回 Error::BadResourceId 的文本。

❗IMPORTANT
动脑筋:第二次 manage 返回什么,计数器读出来是几?

Builder 上 manage(Counter(AtomicU32::new(0))),setup 里 app.manage(Counter(AtomicU32::new(9))) 之后马上读 app.state::<Counter>()。此时还没有任何命令被调用。

动脑筋答案

  379ms [main] manage(Counter) again -> false, state = 0

false 和 0。Manager::manage 发现表里已经有 TypeId::of::<Counter>(),不插入、返回 false,新的 Counter(9) 在 set 返回时被 drop;读出来的还是 Builder 上那一份。同样的代码写在 Builder 上就是上文的 panic。

小结

  • StateManager 是 Mutex<HashMap<TypeId, Box<dyn Any + Send + Sync>>>,哈希器直接用 TypeId 的值。set 不覆盖已有值;try_get 在锁内取出引用、出锁后靠「值在 Box 里、永不移动」把引用延长到 StateManager 的生命周期。
  • State<'r, T> 只是 &'r T;修改靠 T 的内部可变性。unmanage 违反「永不移动」,2.3.0 起弃用。
  • Manager::manage 重复时返回 false,Builder::manage 重复时 panic「state for type '..' is already being managed」。
  • 命令要一个没 manage 的 State<T>,JS 收到「state not managed for field key on command name. You must call .manage() before using this command」;2.2.0(#11958)之前这里是 panic。
  • ResourceTable 用随机 u32 作 rid,app、窗口、webview 各一张;close 调 Resource::close 并释放表里的引用,JS 经 plugin:resources|close 按 webview → 窗口 → app 的顺序找。

💬 关联讨论

发起讨论
加载中...