状态
主线位置:调用主线上「
#[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
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 构建里一样。
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 对照:
StateManager | ResourceTable | |
|---|---|---|
| 键 | 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 的文本。
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 fieldkeyon commandname. 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 的顺序找。