Skip to content

[WASM] remove MaybeSend, ensure 'timeout' is Send#362

Merged
slawlor merged 1 commit intoslawlor:mainfrom
0x53A:dev-wasm-send-call
Aug 4, 2025
Merged

[WASM] remove MaybeSend, ensure 'timeout' is Send#362
slawlor merged 1 commit intoslawlor:mainfrom
0x53A:dev-wasm-send-call

Conversation

@0x53A
Copy link
Copy Markdown
Contributor

@0x53A 0x53A commented Jun 16, 2025

I wanted to open this PR mainly to push additional fuel into the flames of the discussion in #361.

Specifically, I believe this makes the whole MaybeSync machinery obsolete so that it will be easier to share code between WASM and non-WASM.


I have changed the wasm tests to run twice - once without, and once with the feature async-trait. This ensures that both configurations are valid in WASM.

I have added a unit test that demonstrates something that failed before, that is, invoking call! from inside an actor (see #361 (comment)).

First I added the unit test, which failed in my CI (https://github.com/0x53A/ractor/actions/runs/15676933563/job/44159323493) with error: future cannot be sent between threads safely.

Then I applied the changes, which made timeout Send, which fixed the failing test (https://github.com/0x53A/ractor/actions/runs/15679737485).


The implementation inside tokio_with_wasm_primitives.rs is way too complex - I just copy-pasted a bunch of code to make it work. I'll clean that up.


Next step would be to also reenable other code that's currently disabled for wasm and align it with non-wasm, for example the thread_local module with ThreadLocalActor, I see no reason why this shouldn't be able to run on WASM.

Comment thread ractor/Cargo.toml
"rt",
"time",
] }
wasm-bindgen-futures = "0.4.50"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we just use tokio_with_wasm? It's got futures under the hood to make it work with wasm as well using promises. The less we've got to bundle in wasm, the better 🙏.

Copy link
Copy Markdown
Contributor Author

@0x53A 0x53A Jun 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tokio_with_wasm already uses wasm-bindgen-futures so this doesn't add any dependency, it just exposes it for direct usage by ractor

tokio_with_wasm::spawn_local calls exactly wasm-bindgen-futures::spawn_local, so, I can replace that, but also, it literally doesn't change anything w.r.t. binary size or else

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, that's why was thinking, should we use the tokio with wasm one 🤔? At least one less dependency for cargo to process, and for the ractor to maintain.

@codecov
Copy link
Copy Markdown

codecov Bot commented Jun 16, 2025

Codecov Report

❌ Patch coverage is 95.80420% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.44%. Comparing base (4b2f000) to head (4d02ece).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
ractor/src/actor.rs 71.42% 2 Missing ⚠️
ractor/src/factory/worker.rs 50.00% 2 Missing ⚠️
ractor/src/macros/tests.rs 98.52% 1 Missing ⚠️
ractor/src/rpc.rs 80.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #362      +/-   ##
==========================================
+ Coverage   82.28%   82.44%   +0.15%     
==========================================
  Files          70       71       +1     
  Lines       12765    12891     +126     
==========================================
+ Hits        10504    10628     +124     
- Misses       2261     2263       +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@slawlor
Copy link
Copy Markdown
Owner

slawlor commented Jun 19, 2025

So just to be clear, this is going to require that no one accidently use a timeout implementation that's not strictly Send right? They will have to use the aliased timeout inside of ractor in WASM environments, since all the futures will need to be Send, correct?

I think that's fine for WASM environments, especially if we keep iterating on it, but I don't specifically work much in WASM, so are you sure that timeout is the only limitation on making the bulk of WASM futures Send safe?

@0x53A
Copy link
Copy Markdown
Contributor Author

0x53A commented Jun 19, 2025

Unfortunately it's not just timeout. Anything that directly touches a JS Promise is NOT Send. I just don't want to throw out the baby with the bathwater and make everything non-Send, just because of that! (timeout just surfaced the issue because it was used internally by ractor, "poisoning" call! etc)

If you have just a few interactions with JS, and the rest is pure rust, then you can wrap a non-Send async into a Send async (as long as the return value itself is Send).

Details
fn wrap_future_as_send<F, T>(f: F) -> impl Future<Output = Result<T, RecvError>> + Send
where
    F: Future<Output = T> + 'static,
    T: Send + 'static,
{
    let (tx, rx) = crate::concurrency::oneshot();
    // The spawn_local ensures that the non-Send async stays on exactly this thread, and the return value will be dispatched into the Send async.
    tokio_with_wasm::spawn_local(async move {
        let result = f.await;
        let _ = tx.send(result); // note: failures here are ignored, the most likely reason would be a dropped receiver
    });
    async { rx.await }
}

That's what I did to wrap timeout, and this is also something the user could do themselves.

That's fine for like an egui web app that maybe uses a websocket for communication, but not really reasonable for something that heavily interacts with JS.

But I still want to emphasize that WASM is not categorically different from native! Even though non-Send'ness might be a little bit more pervasive here than there, WASM supports multithreading (native wasm threads or JS browser web-workers), making Send relevant, and non-Send-ness is also potentially an issue in Desktop, just less common.


The solution to all this might be as simple as porting thread_local to WASM:

https://github.com/slawlor/ractor/blob/38faea5de8347f6a02fdea45d7480625ab3b5f79/ractor/src/lib.rs#L184C1-L188C22

If you have an actor that interacts with JS, great, just use a ThreadLocalActor, and it'll stay pinned to the main thread. I'd expect ThreadLocalActor to be the default for WASM - but - if you don't actually interact with JS, you can 100% reuse existing code from native using a Send normal Actor.

That's what I'll be looking at soon (I'm away on the weekend, so likely some time next week), and why this PR is still on Draft. I just wanted to jump into the conversation as early as possible, because I believe it was going the wrong direction with making everything non-Send by default, and which would inhibit code-sharing between WASM and native.

@0x53A
Copy link
Copy Markdown
Contributor Author

0x53A commented Jun 19, 2025

To elaborate on call! being poisoned by timeout:

In rust, you can not infer whether an async fn is Send or not by looking at the code!

async fn foo(input: TIn) -> TOut {
    bar().await
}

foo is automatically Send if, and only if, all of TIn, TOut and bar are Send. If you have a generic fn, then Send'ness depends on the generic parameters and can be different between different instantiations.

This also means that if you change bar from Send to non-Send, you do NOT get a compilation error in foo, but the non-Send'ness will silently propagate upwards through the call tree and make all dependents non-Send. You'll likely get a confusing error many functions upwards!

In the case of ractor, timeout is used internally by call. In native, it's Send, so call is Send.

In WASM, timeout silently changed to be non-Send, making call and everything using call non-Send.


This is different when using the proc-macro async-trait.

async-trait will transform and desugar the async fn into a normal fn, and make it explicitly Send. (Or explicitly non-Send when using #[async_trait(?Send)], but then it is hard non-Send, even if it could be Send.)

@0x53A
Copy link
Copy Markdown
Contributor Author

0x53A commented Jul 29, 2025

This is more or less ready. (I'll check over it once more then rebase it.) I've had to copy and modify the 'tokio_with_wasm::time' implementation to make it properly Send.

It's out of scope for this PR, but it would be great to formalize the backend system.

I renamed tokio_with_wasm_primitves.rs to wasm_browser_primitives.rs, because it's now no longer purely based on tokio_with_wasm.

As the name suggests, this only works with wasm in the browser, but would NOT work with wasm in the cloud.
It currently also only works single-threaded. We would need to add a multithreaded backend based on WebWorkers and SharedArrayBuffer.

Unfortunately this is something that needs to be implemented in ractor directly (at least for now) and can not be implemented by the user.

Add to this the possibility of adding a native backend based on smol, to replace the obsolete async-std, and we get a whole explosion of new backends.

@0x53A 0x53A marked this pull request as ready for review July 29, 2025 19:13
@0x53A 0x53A changed the title [WIP] [WASM] remove MaybeSync, ensure 'timeout' is Sync [WIP] [WASM] remove MaybeSend, ensure 'timeout' is Send Jul 29, 2025
@0x53A 0x53A force-pushed the dev-wasm-send-call branch from e14ac30 to 710115f Compare July 29, 2025 20:57
@0x53A 0x53A changed the title [WIP] [WASM] remove MaybeSend, ensure 'timeout' is Send [WASM] remove MaybeSend, ensure 'timeout' is Send Jul 29, 2025
@slawlor
Copy link
Copy Markdown
Owner

slawlor commented Jul 30, 2025

hmm looks like some of the workflows need fixing :/ Can you address when you have some time?

@0x53A
Copy link
Copy Markdown
Contributor Author

0x53A commented Jul 30, 2025

@slawlor can you please restart CI?

@0x53A 0x53A force-pushed the dev-wasm-send-call branch from 44d0b81 to 5ff3234 Compare July 30, 2025 21:22
@slawlor
Copy link
Copy Markdown
Owner

slawlor commented Jul 31, 2025

i'm trying to figure out how to auto-approve CI for your updates, so you aren't blocked on my seeing the notifications. apologies for the delays!

@0x53A
Copy link
Copy Markdown
Contributor Author

0x53A commented Aug 1, 2025

I can run CI by opening a dummy PR on my own fork: 0x53A#1

with the exception of code coverage, which fails because of a missing token, everything is now green

image

Remove MaybeSend, all functions take and return Send futures just like native.
Enable 'ThreadLocalActor' on WASM.
The WASM concurrency backend was partially copied from tokio_with_wasm and adapted to ensure Send'ness.
Note: ./ractor/src/macros/tests.rs was copied from slawlor#363
@0x53A 0x53A force-pushed the dev-wasm-send-call branch from 9962146 to 4d02ece Compare August 1, 2025 20:42
Copy link
Copy Markdown
Owner

@slawlor slawlor left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Nice work :)

Comment thread .github/workflows/wasm.yaml
let (tx, rx) = crate::concurrency::oneshot();
tokio_with_wasm::spawn_local(async move {
let result = f.await;
let _ = tx.send(result); // note: failures here are ignored, the most likely reason would be a dropped receiver
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess for WASM there's no real risk of panic! as today, all panic's abort the process, correct? Like we don't need to specifically try and catch the panic

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the value ignored by let _ is a Result, so I don't think it would panic at all - and I also believe ignoring the error is more or less correct since dropping a future (which drops the receiver) is allowed.

Regarding panics, today, wasm is panic=abort, but they are hard at work implementing panic=unwind (tracking issue), so "soon" wasm will work just like native.

Comment thread ractor/src/thread_local.rs Outdated
@slawlor slawlor merged commit 9d77eca into slawlor:main Aug 4, 2025
20 checks passed
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.

3 participants