Skip to content

"feat": feature flag to allow blocking work in Futures#213

Open
nullstalgia wants to merge 1 commit into
kevinmehall:mainfrom
nullstalgia:feat/blocking-in-async
Open

"feat": feature flag to allow blocking work in Futures#213
nullstalgia wants to merge 1 commit into
kevinmehall:mainfrom
nullstalgia:feat/blocking-in-async

Conversation

@nullstalgia

@nullstalgia nullstalgia commented Jun 27, 2026

Copy link
Copy Markdown

In theory, MaybeFuture could provide something like then as we used to use for Future in the pre-await era, but it would be pretty cumbersome to use.

I did read this originally, but only after the last two weeks do I actually understand the pain 😅

Being async runtime or even async/sync-agnostic is something I've desired in the past, today for my current project, and likely will in the future as well.

Even something as "simple" as sleeping for a short period of time is an issue, which I get! Sometimes you can keep it all in userspace, sometimes you're putting a microcontroller core to sleep until an interrupt hits, it's not as trivial to have a universal Sleep future... which is an absolute pain when the protocol documentation I'm dealing with says "wait 1/16th of a second between poll attempts" and now I'm pulling in DelayNs from the embedded-hal crate on my PC builds so that I can have a shared abstraction between it and my embedded builds and put it as a supertrait to a trait that also has all the SPI bus stuff and-

Anyway, X/Y problem that I've been working on is: I need to reliably poke a USB device with Ctrl and Bulk endpoints, and potentially serve a cdylib for Windows and Android. Ideally, I could freely drop in tokio or whatever when being directly included as a dependency, but also be able to run the whole thing synchronously when I'm compiling the dylib frontend, to minimize potential points of failure in an already-fragile context.

I started experimenting with combinators for MaybeFuture, starting with stuff like map_ok and map_err. I ended up getting really far with ones based off futures-rs's combinators, resulting in the creations of gems like EitherMaybeFuture, eventually (Try)FlattenMaybeFuture and sketch-out for a TryFold/Collect/ForEachMaybeFuture before realizing that I am just reinventing async/await with stone knives and bearskins. It took a single for loop in my actual application to break my will to continue there. Restructuring a couple functions to use async, it flows far more naturally while also being shorter to express... At least I learned a lot about async's internals and how to express stuff with traits/type bounds wrestle the trait solver into submission.


... [you] can use block_on from pollster or futures_lite ... It does add the overhead of ... running blocking syscalls like open, claim_interface on a thread pool thread ...

After staring at the recently-posted #212 and my own runtime panics while benchmarking pollster, here I am with this two-birds-one-stone "idea"; optionally allow running the BlockingTask closures directly, and changing the runtime .await panic into a compile-time error asking to choose one of the three possibilities. If any of the actual runtimes are enabled while blocking-in-async is enabled, they're preferred in the same order they were originally (smol > tokio > blocking-in-async > compile_error!).

I know this probably wouldn't fit well with any isochronous design, and I'm unaware if any of the .wait() vs IntoFuture implementations are significantly different enough to cause later issues, but it seems to work well enough in my naive tests. I'd love to hear if you've considered this or other ideas before.


I'm also considering also sending a PR to pollster itself, since their FutureExt is only blanket-impl'd for T: Future, not T: IntoFuture even though the actual fn is generic over T: IntoFuture. This boils down to invoking MaybeFutures with x.into_future().block_on() or block_on(x) (unless I choose to just make my entire API surface async and not have a single -> impl MaybeFuture public for consistency's sake).

Part of my intent with this PR and associated ramblings is also just me asking, "am I missing something?" Is there an easier way to be mostly agnostic with your core functionality of poking a known protocol, of which is both simple yet complex enough to be a genuine challenge to maintain a sync and async variant of? Am I just making things more complicated than they need to be? :P

@kevinmehall

Copy link
Copy Markdown
Owner

Seems like it might be worth adding as an option with appropriate cautions. The compile_error!() with no feature selected is a breaking change, and I don't think forcing the selection of an async runtime is necessarily ideal for users who just want a blocking API, because I'd still encourage users to use wait() over this if they don't also care about async.

One thing to note is that just because you're not using a "real" async executor doesn't make it more safe to block in async code. You can still run multiple futures concurrently with select or zip or race, and having access to that concurrency is a good reason to use a bit of async in an otherwise non-async codebase. Blocking might even be worse here because a single thread / single task executor means everything gets blocked, while tokio's default multi-threaded runtime could schedule another task on another un-blocked thread. The operations that use BlockingTask in nusb seem more like the kind of thing you'd run sequentially and I don't see why you'd use those future combinators on them, but...

Even something as "simple" as sleeping for a short period of time is an issue, which I get! Sometimes you can keep it all in userspace, sometimes you're putting a microcontroller core to sleep until an interrupt hits, it's not as trivial to have a universal Sleep future

How are you handling time along with this? Also have a fake async timer future that blocks too?

@nullstalgia

nullstalgia commented Jun 29, 2026

Copy link
Copy Markdown
Author

I don't think forcing the selection of an async runtime is necessarily ideal for users who just want a blocking API.

Agreed, which is why I intended to allow blocking-in-async as a naive stand-in/placebo for an actual runtime.

The compile_error!() with no feature selected is a breaking change

While I was drafting this PR I considered having blocking-in-async as a default feature, but that doesn't prevent the case where a user is using an actual executor without first setting one of the actual nusb runtime features and thus encounters blocking work in their executor, so I decided against it before submitting.

You can still run multiple futures concurrently with select or zip or race, and having access to that concurrency is a good reason to use a bit of async in an otherwise non-async codebase.

And I do absolutely see the value in that, but the mindset I'm coming from is: "I need to make a simple tool to do a simple job, more code == more complexity == more points of spurious failure." Serving a cdylib is uncharted territory for me, let alone one that kicks off an entire async runtime at my entrypoint, juggling the syscalls I give it. If possible, I'd like to build up to that, rather than start there.

The operations that use BlockingTask in nusb seem more like the kind of thing you'd run sequentially and I don't see why you'd use those future combinators on them, but...

To me it's less the BlockingTask itself, and more the inability to compose more than one fn() -> impl MaybeFuture (and technically fn() -> impl Future) within a function, and then be able to execute that within either sync or async contexts, without just providing a different function that is otherwise entirely identical but replacing .await with .wait().

To a provide a concrete example:

  • I have a newtype around nusb::DeviceInfo, marking a compatible device for my target protocol. I'd like to provide the method fn open(self: MyDeviceInfo) -> Result<MyDevice>. During this open method's execution, I'd also like to cache some data structures returned by an endpoint's control_in method, into my returned MyDevice.

  • I start by calling DeviceInfo::open, which returns an impl MaybeFuture (uses BlockingTask on Linux).

  • I then need to call Device::detach_and_claim_interface, which returns an impl MaybeFuture (uses BlockingTask on Linux).

  • I then need to do a variable number of Interface::control_in calls, which return an impl MaybeFuture (uses TransferFuture on Linux).

So if I want to be able to have a friendly API that can connect and do a little work, then my hands are tied between .wait()-ing everywhere and serving a sync API, or .await-ing everything and serving an async API. The logic between these ranges from trivial to complex, so having it duplicated (especially while I'm still feeling it out) sounds frustrating...

Thus why I came to the combinators idea, I was flattening these (indeed, sequential) MaybeFutures to be invoked with a single .wait() or .await, leaving the final choice of runtime to the user. However, the ergonomics go back down the toilet; doing any kind of loop is just a pain, even when simply pushing to a Vec to be returned in the final MyDevice output.

Thus, this PR to try to get the best of both worlds, just maybe.

EDIT: Another example that brings me pain, is if I want to return an Error instead of the actual MaybeFuture (in case the user passed in a command that is incompatible with the device's reported feature set). In that case, I run back into needing the EitherMaybeFuture combinator, or use an async fn that can handle being returned from early without giving me a expected type impl MaybeFuture<_>, found Result<_, _> error.


How are you handling time along with this? Also have a fake async timer future that blocks too?

Apologies, I threw in the mention of that older project since I had many of these same questions then too, but at that point I had decided to go fully sync to keep my sanity intact as I didn't want to find/make a pollster equivalent for the RP2040 (since pollster relies on std::thread OS abstractions).

As such, my impl for DelayNs just ran std::thread::sleep on PC, and I simply grabbed the relevant object from the RP2040 HAL when setting up that side.

I should note that there is a separate async version of DelayNs, but that was not what I used. I used simple blocking logic in non-async functions. So if I did have an async executor like embassy, you'd be correct that other tasks could not run, which I also find undesirable, but could not find a reasonable solution to at the time.


I hope this makes my intentions a little more clear.

If I'm missing something or this is just plain out of scope, let me know.

I came to nusb after poking libusb1-sys and rusb, and leaving dissatisfied with the safety, ergonomics, and pacing of keeping up with upstream libusb. nusb seems to check most of the boxes I have, but it runs into a snag I've encountered with Rust and likely will again.

@nullstalgia

nullstalgia commented Jul 15, 2026

Copy link
Copy Markdown
Author

So I got that pollster PR in, and after using it for a bit, I realize that I've rake-stepped back into #212. Luckily that PR isn't to blame, as a matter of fact I can blame this one far more.

If you have tokio enabled and then you consume certain MaybeFutures with pollster:

thread 'main' (573005) panicked at .../src/maybe_future.rs:106:18:

there is no reactor running, must be called from the context of a Tokio 1.x runtime

So while this PR does work for allowing any executor to consume my Futures containing several MaybeFutures, but (unsurpisingly) they often don't like co-existing. Plus it breaks the notion that crate features should only be additive, yet it contains #212 again but in a different coat.


So I'll have to rethink my library's approach, most likely. Though I do have to ask, in an ideal usage of this library, what does a good device wrapper look like?

As I've mentioned previously, returning early with an Err is difficult for any fn() -> impl MaybeFuture (with Blocking it'd be far easier, a. la nusb itself, but then we lose the async wins again), so how can I check for and prevent misuse? Returning a Result<impl MaybeFuture<Output = Result<...>>> works, but only for a single transfer...

And I'm unclear about doing transfers (i.e. getting status via N number of Control INs) before returning the Wrapper. Simply wrapping it in a parent function doesn't seem enough, since it also gets colored by the MaybeFuture.

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.

2 participants