Repository navigation
ADR: Async bridges in Rust #7657
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,258 @@ | ||
| # Async bridges in Rust codes | ||
|
|
||
| * Status: proposed | ||
| * Deciders: ? | ||
| * Date: October 14, 2026 | ||
| * Feedback deadline: October 28, 2026. | ||
|
|
||
| ## Context and Problem Statement | ||
|
|
||
| Many Rust APIs are both synchronous and blocking. | ||
| `rusqlite` in the most common example since queries execute synchronously. | ||
| There are other sources too, for example File IO and long-running computations. | ||
| On the consumer side, components are usually called from | ||
| the application main thread where blocking is not allowed. | ||
| How can we call these functions from the consumer application, without blocking the main thread? | ||
|
|
||
| A typical solution for this is an async bridge function, | ||
| for example | ||
| [withContext(Dispatchers.IO)](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/with-context.html) in Kotlin | ||
| or [spawn_blocking](https://docs.rs/tokio/latest/tokio/task/fn.spawn_blocking.html) in tokio. | ||
| These functions schedule the blocking operation in a background thread | ||
| then wait for the result asynchronously. | ||
| Our current approach is to expose a sync/blocking Rust function via UniFFI, | ||
| then wrap that function at the application layer. | ||
| However, that approach has several issues. | ||
|
|
||
| The main issue with this approach is that it's not clear which functions | ||
| need to be wrapped with the async bridge. | ||
| Most functions should be wrapped, but not all. For example: | ||
|
|
||
| * Cancel functions shouldn't be wrapped since we want them to run immediately. | ||
| * Builder functions shouldn't be wrapped because in makes them less convenient to use. | ||
| * Constructors may or may not need wrapping. | ||
| LoginStore does since it eagerly opens the SQLite database, but SuggestStore doesn't since it opens its database lazily. | ||
|
|
||
| This ambiguity adds extra work for application teams | ||
| and requires cross-team collaboration to ensure the components are used correctly. | ||
| This has been a steady source of issues on the iOS. | ||
|
|
||
| A second issue is that Desktop needs to be special cased. | ||
| JavaScript can't natively schedule work in a background thread, | ||
| so the async bridging happens in the C++ glue layer. | ||
| This is configured by a TOML file that specifies how each function is wrapped. | ||
| Having a separate, desktop-only, system creates extra work | ||
| and can make the desktop bindings feel like an afterthought. | ||
|
|
||
| Lastly, wrapping the entire operation in an async bridge means that | ||
| we're always using a thread for duration of the operation. | ||
| This includes times when the component is waiting on a mutex, user input, etc. | ||
| If we want to avoid using a thread during those times, we need to rework the system. | ||
|
|
||
| ## Proposal: Move the async bridges to Rust | ||
|
|
||
| UniFFI has had the ability to generate async code for a couple years now | ||
| and we've been slowly introducing async code to application-services. | ||
| We can use this functionality to implement an async bridge in Rust | ||
| and move it out of the application layer. | ||
| This section describes the steps needed to do that. | ||
|
|
||
| ### 1: Define the WorkQueue interface | ||
|
|
||
| Define a UniFFI trait interface for running tasks in a work queue: | ||
|
|
||
| ```rust | ||
| #[uniffi::export] | ||
| pub trait WorkQueue { | ||
| fn run(&self, task: Arc<dyn Fn()>); | ||
| } | ||
| ``` | ||
|
|
||
| Notes: | ||
| - This depends on UniFFI support for closures (`Arc<dyn Fn()>`) which doesn't exist yet. | ||
| However, it seems possible to implement and it would be a nice UniFFI feature. | ||
| If we don't want to block on this feature, then we can use trait interface with a single method instead. | ||
| - Task doesn't return a value which simplifies the foreign implementations. | ||
| We can still handle return values in our async bridge function by using `oneshot::Channel` | ||
|
|
||
| ### 2: Implement WorkQueue for all applications | ||
|
|
||
| The Kotlin and Swift implementations are straightforward: | ||
|
|
||
| ```kotlin | ||
| class KotlinWorkQueue : WorkQueue { | ||
| fun run(task: () -> Unit) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For this future ADR when we think about cancellation, a task could be a I mention this here because it might be worth thinking about the API design for the |
||
| withContext(Dispatchers.IO) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The layer above or even this one can have a default of The current design would not let me supply my own CoroutineContext where needed (example).
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1 to this There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1 to that too.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the discussion, I'd love to figure this out. I think there are 2 things here: You might want to wrap the entire operation in a custom coroutine scope, like in the autopush example where you want to add the exception handler and the supervisor job. I don't the WorkQueue is the right place to do this, since only the blocking parts of the Rust function are going to be scheduled with the work queue. This will often be the entire operation, but not always -- see the potential future work section. Instead, I think you'd need to continue to wrap the Rust call in the custom scope which should be okay, you'd just be wrapping an async function instead of a sync function. You also might want to schedule the blocking work with a different dispatcher like in the IO bound example. I think there's a couple ways we could handle that:
|
||
| task() | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ```swift | ||
| class SwiftWorkQueue : WorkQueue { | ||
| let queue = DispatchQueue.global(qos: .background) | ||
|
|
||
| func run(task: () -> ()) { | ||
| queue.async { | ||
| task() | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+96
to
+101
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Apologies for the delay, I ran out of time this week but I will read through the ADR on Monday and get back to you with my thoughts. |
||
| ``` | ||
|
|
||
| WorkQueue can't be implemented in JS. | ||
| However UniFFI traits can also be implemented in Rust, | ||
| which we can leverage for the Desktop implementation: | ||
|
|
||
| ```rust | ||
| pub struct DesktopWorkQueue; | ||
|
|
||
| impl WorkQueue for DesktopWorkQueue { | ||
| fn run(&self, task: Arc<dyn Fn()>) { | ||
| xpcom::moz_task::dispatch_background_task( | ||
| "rust-components-desktop-work-queue", | ||
| task, | ||
| ); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### 3: Implement spawn_blocking | ||
|
|
||
| ```rust | ||
| pub async fn spawn_blocking<F, T>(task: F) -> T | ||
| where F: FnOnce() -> T | ||
| { | ||
| // On startup, we register a single global work queue for the application and store it in | ||
| // `GLOBAL_WORK_QUEUE` | ||
| let work_queue = GLOBAL_WORK_QUEUE.get().expect("GLOBAL_WORK_QUEUE to be set"); | ||
|
|
||
| // Run the task in the work queue. | ||
| // Use a `oneshot::Channel` to asynchronously wait for the result. | ||
| let (sender, receiver) = oneshot::channel(); | ||
| work_queue.run(Arc::new(move || { | ||
| let task_result = task(); | ||
| sender.send(task_result); | ||
| })); | ||
| receiver.await | ||
| } | ||
| ``` | ||
| ### 4: Remove async bridges from the application layer | ||
|
|
||
| Before: | ||
| ```kotlin | ||
| override suspend fun add(entry: LoginEntry) = | ||
| withContext(coroutineContext) { | ||
| getStorage().add(entry.toLoginEntry()).toLogin() | ||
| } | ||
| ``` | ||
|
|
||
| After: | ||
| ```kotlin | ||
| override suspend fun add(entry: LoginEntry) = | ||
| getStorage().add(entry.toLoginEntry()).toLogin() | ||
| ``` | ||
|
|
||
| * Swift would have similar changes. | ||
| * Desktop would delete `AsyncWrapped` entries in the TOML file | ||
| since the Rust functions would now be `async` and not need configuration. | ||
| In the short-term, we'd still need to explicity configure `sync` functions as `Sync`. | ||
| Eventually we can make that the default. | ||
|
|
||
| ### 5: Add async bridges to the Rust code | ||
|
|
||
| Before: | ||
| ```rust | ||
| pub fn add(&self, entry: LoginEntry) -> ApiResult<Login> { | ||
| // code to do the work | ||
| } | ||
| ``` | ||
|
|
||
| After: | ||
| ```rust | ||
| pub async fn add(&self, entry: LoginEntry) -> ApiResult<Login> { | ||
| spawn_blocking(move || { | ||
| // code to do the work | ||
| }).await | ||
| } | ||
| ``` | ||
|
|
||
| ### Potential future work: consider refactorings to block threads less often | ||
|
|
||
| We could consider refactoring the Rust code to block threads less often: | ||
|
|
||
| ```rust | ||
| pub fn add(&self, entry: LoginEntry) -> ApiResult<Login> { | ||
| // Unlock the DB and get the encryption key asynchronously | ||
| let db = self.db_mutex.lock().await; | ||
| let key = self.key_manager.get_key().await; | ||
| spawn_blocking(move || { | ||
| // code to do the work, refactor functions to input `db` and `key` | ||
| }) | ||
| } | ||
| ``` | ||
|
|
||
| The benefit here is that we won't block a thread while waiting for the DB mutex | ||
| or getting the local encryption key. | ||
| The latter means we could update `get_key` to display the primary password dialog if needed | ||
| which would reduce some complexity in the JS code. | ||
|
|
||
| ### Bikeshedding: consider making `spawn_blocking` more ergonomic | ||
|
|
||
| Mark points out that calling spawn_blocking | ||
| and passing it a move closure might be off-putting for new Rust developers. | ||
| We could mitigate this by defining a `spawn_blocking` macro. | ||
| With that macro, the code might feel more natural to Kotlin/Swift/JS developers: | ||
|
|
||
| ```rust | ||
| pub fn add(&self, entry: LoginEntry) -> ApiResult<Login> { | ||
| spawn_blocking! { | ||
| // Rust code here | ||
| }.await | ||
| } | ||
| ``` | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think this needs to block the ADR, but I'm happy to bikeshed on this. I agree with Mark that we should be keeping the Rust learning curve as gradual as possible. |
||
|
|
||
| ## The big picture: a new API contract for Rust components | ||
|
|
||
| This ADR is part of a larger strategy to clarify and simplify the API contract for Rust components. | ||
| We hope to end up with a simple contract: Rust calls never block the current thread. | ||
| This simplifies the work for application developers, | ||
| since they will be free to call Rust component functions without worrying about threading issues. | ||
|
|
||
| Breaking this down a bit more: | ||
|
|
||
| | Call type | Examples | Changes | | ||
| |-------|-----|------------| | ||
| | Sync/non-blocking | checking against the `filter_adult` list, constructing SuggestStore, builder methods | stays sync, no changes needed | | ||
| | Sync/blocking | Adding a login, finding a credit card entry | changes to async, blocking work happens inside `spawn_blocking` | | ||
| | Currently sync, but essentially async | Fetching a REST endpoint | changes to async by switching to `ViaductClient::send` rather than `send_sync` | | ||
|
|
||
| This section is meant to contextualize this decision inside the larger plan, | ||
| but the details and timeline are out-of-scope for this ADR. | ||
|
|
||
| ## Out of scope: Cancellation | ||
|
|
||
| A related question that comes up often is how async cancellation should work. | ||
| This ADR does not address that issue since it's orthogonal to the async bridge question. | ||
| However, this would be a great topic for a future ADR. | ||
|
|
||
| ## Considered Options | ||
|
|
||
| * Keep current behavior | ||
| * Move async bridges to Rust | ||
|
|
||
| ## Decision Outcome: Move async bridges to Rust | ||
|
|
||
| ### Comparison to other options | ||
|
|
||
| Pros: | ||
| - Application teams don't need write async bridging code | ||
| and application developers don't need to think about threading so much. | ||
| - Rust developers have a clear threading contract to follow. | ||
| - Desktop no longer needs to be special-cased. | ||
| - Allows for future refactorings to reduce thread usage. | ||
|
|
||
| Cons: | ||
| - Requires 1-2 extra FFI calls. | ||
| - Adds some complexity to the Rust code, which could be intimidating to new Rust devs. | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The name
WorkQueueimplies to me, that it is a queue of tasks, but I didn't see that in the implementation.It seems to me more like a "runner" / or "executor" than a "work queue"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point. I like
TaskRunner.TaskExecutorcould also work, but "executor" sometimes means "async runtime" which could be slightly confusing.