Rust in Everything
基于 dioxus 0.7.10

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()。要「取消」,按想要的效果选:

  1. 让组件消失:卸载拥有资源的组件,scope 被删时它的任务一起取消,不经过 suspend。
  2. 组件留着、显示「已取消」:不用 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 找不到任务时 expect panic,整个 VirtualDom 崩掉,不管之前有没有值。
  • 加载中 pause():suspend() 返回一个对 None 做 unwrap 的映射 signal,读的时候 panic,被错误边界接住。
  • 两处都在 Resource::suspend 里,0.7.10 上 debug 和 release 都能复现,可以报给上游。
  • 修法:卸载组件来取消;或者不用 suspend(),按 value() / state() 自己渲染;依赖变化交给 use_resource 自己重启。

💬 关联讨论

发起讨论
加载中...