Skip to content

Handle subsecond updates asynchronously in Dioxus#4905

Closed
tekacs wants to merge 1 commit into
DioxusLabs:mainfrom
tekacs:async-subsecond-handler
Closed

Handle subsecond updates asynchronously in Dioxus#4905
tekacs wants to merge 1 commit into
DioxusLabs:mainfrom
tekacs:async-subsecond-handler

Conversation

@tekacs

@tekacs tekacs commented Nov 3, 2025

Copy link
Copy Markdown
Contributor

Try to resolve #4902.

@tekacs tekacs marked this pull request as ready for review November 3, 2025 23:33
@tekacs tekacs requested a review from a team as a code owner November 3, 2025 23:33
@tekacs tekacs force-pushed the async-subsecond-handler branch 4 times, most recently from c59446f to 3eb51fe Compare November 23, 2025 02:28
@tekacs tekacs force-pushed the async-subsecond-handler branch from 3eb51fe to da87dd3 Compare December 1, 2025 01:21
@jkelleyrtp

Copy link
Copy Markdown
Member

I closed the other PR since we definitely don't want to run all handlers async, but you said this is the branch you're using to dev against. I don't see how this change would fix an issue where we're borrowing the dom twice, unless an event is actively being handled while we process the hotpatch. That being said, I do see this issue pop up with use_resource so there might be an issue with the async polling and setting the runtime.

Do you have a more concrete repro I could work against to see what's actually happening here?

@tekacs

tekacs commented Dec 9, 2025

Copy link
Copy Markdown
Contributor Author

Hullo! I haven't seen an instance of this error pop up since I put this patch on the branch I use for dev.

It's been a moment since I opened this PR, but if I recall correctly, the gist is that hot patching is somehow happening while this RefCell in wasm-bindgen-futures is borrowed. You can see that in the panic on the first line:

wasm-bindgen-futures-0.4.54/src/task/singlethreadRefCell already borrowed

In my setup I'm likely spawning a lot more futures than a base Dioxus app, because I have various future loops (akin to setTimeout), which run forever. So it's almost a given that every few hot patches it'll panic like this.

As for the other PR – I would guess that any uses of Subsecond on WASM using futures would run into this issue, so I'm not certain that a Dioxus-specific fix is the way?

I can't pause to produce a repro right this second, but I suspect that this should show up very rapidly if trying to hot patch an app that creates a simple future loop with gloo's timers (really just setTimeout) or something akin. I'll try to supply a proper repro if I get a spare moment though!

@FrankReh

Copy link
Copy Markdown

I've created a repro for this but I suspect the underlying problem is that wasm-bindgen-futures doesn't guard against re-entrant calls to its task run function from the browser's micro task scheduler so am working on building a reproducer for that, not using Dioxus at all.

@FrankReh

FrankReh commented Feb 6, 2026

Copy link
Copy Markdown

I've created a small reproducer - but I can't attest to it being minimal.

I went down a rabbit hole or two trying to create a repro that was pure wasm-bindgen-futures and wasn't successful but I think only because I hadn't figured out all that Dioxus is doing to support hotpatch. But I will say learning about the browser's ability to indirectly call into another WASM module was worth it - that is a very cool technology that has been there a long time but I hadn't heard about it before.

The Dioxus app is small but there are a number of things it is doing and I've run out of time so don't know if they are all necessary.

With the code in place:

- Run `cargo clean; dx serve --hotpatch` in one window.

- From another terminal
    - open http://localhost:8080/index.html
    - ./hammer.sh # This script is shown below

Below are the app files, and a hammer script.

Even with these, the web page on my M2 Macbook Safari didn't hit the RefCell borrow panic unless I moved to another virtual desktop for a moment, clicked on other terminals, and then moved back to the desktop with the page on it; and sometimes a few times, back and forth between virtual desktops, before the race was triggered.

I have to assume the browser's microtask scheduler is woken up differently in that scenario, letting the race proceed - and that without letting the web page sleep, the browser's microtask scheduler only kicks in when the event loop loops around normally. I've read that the JS spec gives some latitude as to when the microtask scheduler is woken - different browsers may not all do the same thing.

Will also say this is very easy to hit with Safari in my setup but so far I haven't hit it using the Chrome browser. Take that for what it's worth.

*RefCell already borrowed* stack
RefCell already borrowed

Stack:

@http://127.0.0.1:8080/wasm/subsecond_refcell_repro.js:7040:34
logError@http://127.0.0.1:8080/wasm/subsecond_refcell_repro.js:14757:23
<?>.wasm-function[39072]@[wasm code]
<?>.wasm-function[24251]@[wasm code]
<?>.wasm-function[9315]@[wasm code]
<?>.wasm-function[30554]@[wasm code]
<?>.wasm-function[10259]@[wasm code]
subsecond_refcell_repro.wasm.wasm-function[_ZN3std9panicking13panic_handler28_$u7b$$u7b$closure$u7d$$u7d$17he5eb13023b639916E]@[wasm code]
subsecond_refcell_repro.wasm.wasm-function[_ZN3std3sys9backtrace26__rust_end_short_backtrace17hdaee13ec4dd4f348E]@[wasm code]
subsecond_refcell_repro.wasm.wasm-function[_RNvCsiGVaDesi5rv_7___rustc17rust_begin_unwind]@[wasm code]
subsecond_refcell_repro.wasm.wasm-function[_ZN4core9panicking9panic_fmt17hcb6b2b4be1f4be38E]@[wasm code]
subsecond_refcell_repro.wasm.wasm-function[_ZN4core4cell22panic_already_borrowed8do_panic7runtime17h5120f5d632e3deefE]@[wasm code]
subsecond_refcell_repro.wasm.wasm-function[_ZN4core4cell22panic_already_borrowed17heeb094e5c4b1bc01E]@[wasm code]
subsecond_refcell_repro.wasm.wasm-function[_ZN4core4cell16RefCell$LT$T$GT$10borrow_mut17h5e26716810926b71E]@[wasm code]
subsecond_refcell_repro.wasm.wasm-function[_ZN20wasm_bindgen_futures4task12singlethread4Task3run17h854d0d8a767b7363E]@[wasm code]
subsecond_refcell_repro.wasm.wasm-function[_ZN20wasm_bindgen_futures5queue10QueueState7run_all17h244fd529fe89e8e6E]@[wasm code]
subsecond_refcell_repro.wasm.wasm-function[_ZN20wasm_bindgen_futures5queue5Queue3new28_$u7b$$u7b$closure$u7d$$u7d$17ha91d9ecf197f29a4E]@[wasm code]
subsecond_refcell_repro.wasm.wasm-function[_ZN12wasm_bindgen7convert8closures1_6invoke17hc65c9b62e7167ec4E]@[wasm code]
subsecond_refcell_repro.wasm.wasm-function[_ZN12wasm_bindgen7convert8closures1_6invoke17hc65c9b62e7167ec4E externref shim]@[wasm code]
_ZN12wasm_bindgen7convert8closures1_6invoke17hc65c9b62e7167ec4E@http://127.0.0.1:8080/wasm/subsecond_refcell_repro.js:14297:73
real@http://127.0.0.1:8080/wasm/subsecond_refcell_repro.js:14807:21

[Error] RuntimeError: Unreachable code should not be executed (evaluating 'wasm._ZN12wasm_bindgen7convert8closures1_6invoke17hc65c9b62e7167ec4E(arg0, arg1, arg2)')
	<?>.wasm-function[44964] (subsecond_refcell_repro.js:14297)
	<?>.wasm-function[43560]
	<?>.wasm-function[39498]
	<?>.wasm-function[10258]
	subsecond_refcell_repro.wasm.wasm-function[_ZN3std9panicking13panic_handler28_$u7b$$u7b$closure$u7d$$u7d$17he5eb13023b639916E]
	subsecond_refcell_repro.wasm.wasm-function[_ZN3std3sys9backtrace26__rust_end_short_backtrace17hdaee13ec4dd4f348E]
	subsecond_refcell_repro.wasm.wasm-function[_RNvCsiGVaDesi5rv_7___rustc17rust_begin_unwind]
	subsecond_refcell_repro.wasm.wasm-function[_ZN4core9panicking9panic_fmt17hcb6b2b4be1f4be38E]
	subsecond_refcell_repro.wasm.wasm-function[_ZN4core4cell22panic_already_borrowed8do_panic7runtime17h5120f5d632e3deefE]
	subsecond_refcell_repro.wasm.wasm-function[_ZN4core4cell22panic_already_borrowed17heeb094e5c4b1bc01E]
	subsecond_refcell_repro.wasm.wasm-function[_ZN4core4cell16RefCell$LT$T$GT$10borrow_mut17h5e26716810926b71E]
	subsecond_refcell_repro.wasm.wasm-function[_ZN20wasm_bindgen_futures4task12singlethread4Task3run17h854d0d8a767b7363E]
	subsecond_refcell_repro.wasm.wasm-function[_ZN20wasm_bindgen_futures5queue10QueueState7run_all17h244fd529fe89e8e6E]
	subsecond_refcell_repro.wasm.wasm-function[_ZN20wasm_bindgen_futures5queue5Queue3new28_$u7b$$u7b$closure$u7d$$u7d$17ha91d9ecf197f29a4E]
	subsecond_refcell_repro.wasm.wasm-function[_ZN12wasm_bindgen7convert8closures1_6invoke17hc65c9b62e7167ec4E]
	subsecond_refcell_repro.wasm.wasm-function[_ZN12wasm_bindgen7convert8closures1_6invoke17hc65c9b62e7167ec4E externref shim]
	_ZN12wasm_bindgen7convert8closures1_6invoke17hc65c9b62e7167ec4E (subsecond_refcell_repro.js:14297)
	real (subsecond_refcell_repro.js:14807)
Cargo.toml
[package]
name = "subsecond_refcell_repro"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
dioxus = { version = "0.7.1", features = [] }
gloo-timers = { version = "0.3", features = ["futures"] }
js-sys = "0.3"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"

[features]
default = ["web"]
web = ["dioxus/web"]
desktop = ["dioxus/desktop"]
mobile = ["dioxus/mobile"]

[profile.release]
debug = 0
Dioxus.toml
[application]

[web.app]

# HTML title tag content
title = "subsecond_refcell_repro"

# include `assets` in web platform
[web.resource]

# Additional CSS style files
style = []

# Additional JavaScript files
script = []

[web.resource.dev]

# Javascript code file
# serve: [dev-server] only
script = []

[web.wasm_opt]
debug = false
src/main.rs
#![allow(non_snake_case)]

use dioxus::prelude::*;
use gloo_timers::future::TimeoutFuture;

use wasm_bindgen::JsValue;
use wasm_bindgen_futures::JsFuture;
use wasm_bindgen_futures::spawn_local;

// The hammer script toggles this between "A" and "B"
const FLIP: &str = "A";

// Didn't happen on my M2 mac at 200, but one time at 400.
const WORKERS: usize = 1600;

fn label() -> &'static str {
    FLIP
}

#[component]
fn Worker(id: usize, gen: Signal<u64>) -> Element {
    // This resource is dependency-tracked on gen(), so every gen tick
    // cancels and restarts it (lots of churn).
    let _r = use_resource(move || {
        let v = gen();
        async move {
            TimeoutFuture::new(0).await;
            v
        }
    });

    rsx! { div { "worker {id}" } }
}

fn App() -> Element {
    let gen = use_signal(|| 0u64);

    // Track flip changes (hotpatch) and burst microtasks right after a flip is observed.
    // This increases the odds the patch handler overlaps with wasm-bindgen-futures queue activity.
    let mut last_flip = use_signal(|| "");
    use_effect(move || {
        let now = label();
        if last_flip() != now {
            last_flip.set(now);

            // Burst size knobs: start high; reduce if Safari becomes unusable.
            const BURST: usize = 10_000;
            spawn_local(async move {
                for _ in 0..BURST {
                    //yield_microtask().await;
                    // Spawn many recursive chains simultaneously
                    spawn_recursive_churn(10);
                }
            });
        }
    });

    // Drive gen forward as fast as setTimeout(0) will allow.
    use_future(move || {
        let mut gen = gen;
        async move {
            loop {
                gen.set(gen() + 1);
                TimeoutFuture::new(0).await;
            }
        }
    });

    rsx! {
        div {
            h1 { "flip: {label()}" }
            div { "gen: {gen()}" }
            div {
                for id in 0..WORKERS {
                    Worker { key: "{id}", id, gen }
                }
            }
        }
    }
}

fn main() {
    launch(App);
}

fn spawn_recursive_churn(depth: usize) {
    if depth == 0 { return; }

    spawn_local(async move {
        // This spawn happens DURING the outer spawn's poll
        spawn_local(async move {
            spawn_recursive_churn(depth - 1);
        });

        // Keep this future alive longer
        for _ in 0..100 {
            yield_microtask().await;
        }
    });
}

async fn yield_microtask() {
    // Promise.resolve().then(...) -> microtask turn
    let p = js_sys::Promise::resolve(&JsValue::UNDEFINED);
    let _ = JsFuture::from(p).await;
}

The script that triggers the --hotpatch very quickly. Be warned that each
hotpatch seems to add more assets to the target directory so don't let this run
for too long unwatched as disk space get eaten up. That's why I start with a
'cargo clean' between trying different things.

hammer.sh
#!/usr/bin/env bash
#
# This hammer.sh is run in a separate terminal
# so we have `dx serve --hotpatch` in one terminal
# and `./hammer.sh` in another.
set -e
set -f

FILE="src/main.rs"

while true; do
  perl -0777 -pe 's/const FLIP: &str = "A";/const FLIP: &str = "B";/g' -i "$FILE"
  sleep 0.05
  perl -0777 -pe 's/const FLIP: &str = "B";/const FLIP: &str = "A";/g' -i "$FILE"
  sleep 0.05
done

@jkelleyrtp

Copy link
Copy Markdown
Member

I think I tracked down the bug recently and fixed it - there was a race condition in apply_patch on wasm where we'd be waiting for the new wasm to compile while the current memory space had been adjusted. If you see the issue crop up again, please LMK because we might need to make apply_patch async with locks to fix it.

@jkelleyrtp jkelleyrtp closed this May 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

When hot patching on WASM, wasm_bindgen_futures cause RefCell: Already borrowed

3 participants