Task dependencies of futures. How to design a common API?

I have an implementation of a Zigbee driver and want to now abstract away its builder via trait in the used Zigbee library (apis-saltans).

This is the current method in question:

I now face the issue that I have a goal that make this quite challenging, namely I want the API to be async runtime-agnostic.

As you can see from the linked code, the function currently internally spawns new tasks, on which the following operations depend. I.e. those futures must be awaited in order for some following code to be able to proceed. This is currently hard-coded via tokio::spawn. However, I want the user to be able to use a runtime of their choosing.
So how can I create arbitrary futures inside an async function, i.e. a function that returns a future itself and hand those dependecy-futures back to the caller while conveying, ideally by means of the type system, that those shall be spawned before awaiting the main future?

One possible solution that came to my mind, was to pass in an MPSC sender to the function as well, which receives those internally created futures of the task dependencies, then return some wrapper object, that, when awaited, first spawns the dependencies, before resolving to the actual return type of the built NCP / Event receiver tuple.

This, however also has the issue, that I just moved the problem. In order to spawn the tasks internally in such a return type wrapper, I must let the user pass in an appropriate spawn function of their chosen runtime. But there is currently no such thing as a common API for that. I.e. e.g. async_std::task::spawn and tokio::spawn don't implement a common stable API.

Furthermore, such a return type and the necessity to pass in such a sender makes the whole kerfuffle even more complicated than it already is.

So my question is, whether there are established patterns and / or crates to handle a situation in which I have a common API with a future that resolves to a type, which may internally spawn task-dependencies in an async runtime-agnostic manner.

You could let users of your crate decide the runtime by enabling the corresponding feature from your crate. This is afaik the most common pattern used to support multiple runtimes, since there's no common API used by all runtimes (such as for spawning tasks, as per your example).

I've "solved" this by making users implement a custom trait on something they have to pass into my lib anyways (if there's nothing of that kind in your lib, maybe have them pass a ZST that implements said trait). See nvim-rs/src/create/mod.rs at master · KillTheMule/nvim-rs · GitHub I've some convenience feature that implements the trait automatically on a type the users need to provide anyways, see e.g. nvim-rs/src/create/smol.rs at master · KillTheMule/nvim-rs · GitHub This has the downside of making the features non-additive, so I'm not fully happy with that...

Some crates return the future to the user and let the user spawn it. However, this only works if all task spawns occur in response to calls where it's convenient for the caller to receive it as a return value.

The other approach is to use a trait. For, example see hyper::rt::Executor.

Or you can try to put all of your futures within a sub-executor such as FuturesUnordered, but I do not recommend it and it brings a lot of downsides.

The problem is not really that the user need to "bring their own executor". They can already do that, because the future returned by your start can be polled by any executor. The problem is that your code relies on features provided by a specific executor, so that if a caller polls your future, but that executor was not initialized, your code panics.

You can be sure that the tokio executor exists by carrying around an Arc<tokio::runtime::Runtime> and making sure all your tokio-specific futures are spawned on that runtime, as opposed to the assumed "global" runtime.

Now there is the seperate issue that different executors may compete for resources or otherwise not play well together.

Note that this is only necessary if you specifically want to keep the runtime alive. If the runtime is someone else’s responsibility and you just want to call Tokio functions that use it, you should use a tokio::runtime::Handle instead (which is kind of like a Weak<Runtime> in how it behaves).

By changing the architecture appropriately, I now am runtime-independent.

I will still do some refactoring.