Suspended on a task that no longer exists
主线位置:首屏主线和更新主线共用的 suspense 一段。组件返回
Err(RenderError::Suspended(..)),run_scope之后的handle_element_return去任务表里找这个任务,把它挂到最近的 suspense 边界上;任务已经被cancel删掉了,找不到就 panic。资源的四种状态和这个 panic 的第一次复现见 异步:resource、future 与 spawn 的「cancel 之后suspend()?会 panic」,本页从用户代码的角度讲怎么撞上、怎么绕开。
stateDiagram-v2 state "Pending:任务在跑" as Pending state "Ready:有值" as Ready state "Stopped:任务已删除" as Stopped state "Paused:任务暂停" as Paused [*] --> Pending Pending --> Ready: 任务完成 Pending --> Stopped: cancel Ready --> Stopped: cancel Pending --> Paused: pause Stopped --> [*]: suspend 指向已删任务 panic Paused --> [*]: 没有值时读 unwrap panic
本页实验在 M2 Max、macOS 27、rustc 1.99.0 上运行,依赖 dioxus = "=0.7.10",原生 VirtualDom。dioxus 0.7.9 的运行时代码与 0.7.10 相同(两个 tag 之间只改了 CLI)。
现象
一个搜索结果组件,在 SuspenseBoundary 里用 suspend()? 等资源;页面上有一个「取消搜索」按钮,调资源的 cancel()。pit1-exp/src/bin/resource_cancel.rs(MODE 和 SHOW 是后面修法用的):
async fn search(q: &str) -> String {
tokio::time::sleep(Duration::from_millis(30)).await;
format!("results for {q}")
}
#[component]
fn Results() -> Element {
let res = use_resource(|| async { search("dioxus").await });
RES.set(Some(res));
if MODE.get() == 1 {
// fix: look at the state instead of suspending on it
return match &*res.read() {
Some(text) => rsx! { "{text}" },
None if *res.state().read() == UseResourceState::Stopped => rsx! { "cancelled" },
None => rsx! { "searching..." },
};
}
let text = res.suspend()?;
rsx! { "{text}" }
}
fn app() -> Element {
let show = use_signal(|| true);
SHOW.set(Some(show));
rsx! {
if show() {
SuspenseBoundary {
fallback: |_| rsx! { "searching..." },
Results {}
}
} else {
"search closed"
}
}
}
main 在搜索进行中(pending)或完成后(ready)用 dom.in_runtime(..) 调 cancel() 或 pause(),相当于按钮的回调,然后把 VirtualDom 跑到空闲。debug:
=== pending cancel
before cancel: searching...
thread 'main' (2245426) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-core-0.7.10/src/scope_arena.rs:123:22:
Suspended on a task that no longer exists
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
exit=101
=== ready cancel
before cancel: results for dioxus
thread 'main' (2245483) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-core-0.7.10/src/scope_arena.rs:123:22:
Suspended on a task that no longer exists
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
exit=101
=== pending pause
before pause: searching...
thread 'main' (2245594) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-hooks-0.7.10/src/use_resource.rs:437:54:
called `Option::unwrap()` on a `None` value
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
after pause: <div style="color:red;"><pre>Encountered panic: Any { .. }</pre></div>
exit=0
release 三种情况的输出除线程号外相同。
cancel,不管搜索完成没有:进程以 101 退出。panic 发生在 core 处理组件返回值的地方,不在组件函数里,没有catch_unwind,整个VirtualDom跟着没了;web 上就是应用停止响应。搜索已经出结果之后再点「取消」(比如按钮没有及时隐藏)也一样。- 加载中
pause:换了一个 panic。这次在组件函数里(读"{text}"时),被错误边界接住,整页换成Encountered panic。
出处
cancel 很短,Resource::cancel:
pub fn cancel(&mut self) {
self.state.set(UseResourceState::Stopped);
self.task.write().cancel();
}
state 是组件读过的 signal(suspend 里读了),写它让组件重跑;task.cancel() 把任务从 runtime 的任务表里删掉。组件重跑时 Resource::suspend:
pub fn suspend(&self) -> std::result::Result<MappedSignal<T, Signal<Option<T>>>, RenderError> {
match self.state.cloned() {
UseResourceState::Stopped | UseResourceState::Paused | UseResourceState::Pending => {
let task = self.task();
if task.paused() {
Ok(self.value.map(|v| v.as_ref().unwrap()))
} else {
Err(RenderError::Suspended(SuspendedFuture::new(task)))
}
}
_ => Ok(self.value.map(|v| v.as_ref().unwrap())),
}
}
两处问题都在这里:
Stopped和Pending走同一支。Stopped时任务已经不存在,返回的却是挂起在这个任务上的SuspendedFuture。suspend()?把它交出去,handle_element_return在任务表里找它:.get(task.id).expect("Suspended on a task that no longer exists")(第 123 行)。即使已经有值(ready cancel),Stopped也不返回值。Paused时直接返回self.value.map(|v| v.as_ref().unwrap()),假定已经有值。加载中暂停,value还是None;map是惰性的,读这个映射 signal 时(第 437 行的闭包)才unwrap到None。
git log -S 追到的现在这种写法出自引入 suspense 边界和乱序流式的提交 022e4ad20(#2365,首个包含它的正式版是 0.6.0),那时已经是 Stopped | Paused | Pending 共用一支,后来 d8d11db40(#4413)只把返回类型改成了带两个类型参数的 MappedSignal。两处在 0.7.10 上 debug 和 release 都能复现,可以报给上游(离线环境下没有核对上游是否已有 issue)。
修法
不要对用了 suspend()? 的资源调 cancel() 或在加载中 pause()。要「取消」,按想要的效果选:
- 让组件消失:卸载拥有资源的组件,scope 被删时它的任务一起取消,不经过
suspend。 - 组件留着、显示「已取消」:不用
suspend(),自己按value和state渲染(上面MODE == 1那一支)。这样也就不需要SuspenseBoundary。
同一个程序,unmount 是把 show 设成 false,第三个参数 1 是第 2 种写法:
=== pending cancel 1
before cancel: searching...
after cancel: cancelled
exit=0
=== ready cancel 1
before cancel: results for dioxus
after cancel: results for dioxus
exit=0
=== pending unmount
before unmount: searching...
after unmount: search closed
exit=0
=== ready unmount
before unmount: results for dioxus
after unmount: search closed
exit=0
cancel 不清空 value,所以已经有结果后再取消,第 2 种写法照样显示结果。要「取消后清空」,在同一个回调里再写一个自己的 signal 作为标记,渲染时先看它。
「搜索词变了,旧请求作废」这类需求不需要手动 cancel:use_resource 读的 signal 一变,它自己取消旧任务、重启新任务,状态回到 Pending,suspend()? 重新挂起,这条路是安全的(异步:resource、future 与 spawn 的「use_resource:两个任务、一个 ReactiveContext」)。
怎么确认没中招
- 搜
.cancel()和.pause(),看对应的Resource在哪个组件里被.suspend()过。同一个资源两者都有,就是本页的问题。 - 测试里在加载中和加载完各调一次取消逻辑,把
VirtualDom跑到空闲:上面的resource_cancel就是这样的测试,exit=101即中招。 - 线上 web 应用点了某个按钮后整页不再响应、控制台有
Suspended on a task that no longer exists,就是这个。
小结
cancel()把资源设成Stopped并删掉任务;suspend()对Stopped仍返回挂起在该任务上的错误,core 找不到任务时expectpanic,整个VirtualDom崩掉,不管之前有没有值。- 加载中
pause():suspend()返回一个对None做unwrap的映射 signal,读的时候 panic,被错误边界接住。 - 两处都在
Resource::suspend里,0.7.10 上 debug 和 release 都能复现,可以报给上游。 - 修法:卸载组件来取消;或者不用
suspend(),按value()/state()自己渲染;依赖变化交给use_resource自己重启。