Skip to content

ADR: Async bridges in Rust - #7657

Open
bendk wants to merge 1 commit into
mozilla:mainfrom
bendk:bdk/push-kpsuykzvswpp
Open

bendk wants to merge 1 commit into
mozilla:mainfrom
bendk:bdk/push-kpsuykzvswpp

Conversation

@bendk

@bendk bendk commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Rendered version

Pull Request checklist

  • Breaking changes: This PR follows our breaking change policy
    • This PR follows the breaking change policy:
      • This PR has no breaking API changes, or
      • There are corresponding PRs for our consumer applications that resolve the breaking changes and have been approved
  • Quality: This PR builds and tests run cleanly
    • Note:
      • For changes that need extra cross-platform testing, consider adding [ci full] to the PR title.
      • If this pull request includes a breaking change, consider cutting a new release after merging.
  • Tests: This PR includes thorough tests or an explanation of why it does not
  • Changelog: This PR includes a changelog entry in CHANGELOG.md or an explanation of why it does not need one
    • Any breaking changes to Swift or Kotlin binding APIs are noted explicitly
  • Dependencies: This PR follows our dependency management guidelines
    • Any new dependencies are accompanied by a summary of the due diligence applied in selecting them.

// Rust code here
}.await
}
```

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.

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.

@bendk
bendk force-pushed the bdk/push-kpsuykzvswpp branch 2 times, most recently from dea3bb6 to 8dd3182 Compare October 14, 2026 19:21
@bendk bendk mentioned this pull request Oct 14, 2026
5 tasks done

@jonalmeida jonalmeida left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This largely looks fine to me with a small hold out on the hard-coding of Dispatchers.IO in the KotlinWorkQueue. I originally understood it as a placeholder in the pseudo-code, so I want to make sure that it isn't a specific Dispatcher that the embedder cannot specify.

```kotlin
class KotlinWorkQueue : WorkQueue {
fun run(task: () -> Unit) {
withContext(Dispatchers.IO) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The layer above or even this one can have a default of Dispatchers.IO as long as we allow for it to be overridden by the embedder.

The current design would not let me supply my own CoroutineContext where needed (example).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+1 to this

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

+1 to that too.
I think we might have some tasks that are long running but not IO bound?

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.

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:

  • Each component inputs its own work queue
  • Tasks get a type tag that specifies what kind of work they're doing. IO-bound work gets scheduled with Dispatchers.IO, CPU-bound work gets scheduled with a separate dispatcher.

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.

Any further thoughts on this one? I'm mainly wondering if my analysis of the custom couroutine scope in the comment above is correct. That's the part I'm least sure about.

RE IO-bound tasks vs other kinds of tasks: Would you use a different Dispatcher for the two cases? AFAICT, the current code always uses Dispatchers.IO, but if you want more flexibility we can build it in, but it's hard to propose something without more concrete details.


```kotlin
class KotlinWorkQueue : WorkQueue {
fun run(task: () -> Unit) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 Continuation when we can describe what that means at the rust layer so that we can inform the dispatcher that this task is not cancellable, like the sqlite tasks.

I mention this here because it might be worth thinking about the API design for the task if it helps you in the future.

Comment thread docs/adr/0010-async-bridges-in-rust.md Outdated
Comment thread docs/adr/0010-async-bridges-in-rust.md Outdated
```kotlin
class KotlinWorkQueue : WorkQueue {
fun run(task: () -> Unit) {
withContext(Dispatchers.IO) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+1 to this

The Kotlin and Swift implementations are straightforward:

```kotlin
class KotlinWorkQueue : WorkQueue {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ potential bikeshedding:

The name WorkQueue implies 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"

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.

Good point. I like TaskRunner. TaskExecutor could also work, but "executor" sometimes means "async runtime" which could be slightly confusing.

Comment thread docs/adr/0010-async-bridges-in-rust.md Outdated
Comment on lines +174 to +178
pub fn add(&self, entry: LoginEntry) -> ApiResult<Login> {
spawn_blocking(move || {
// code to do the work
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Question: Should add() become async add() now?


One of the goals of this document is to alleviate the sync/async ambiguities to Kotlin/Swift/JS developers.

As a Kotlin developer using these APIs, one of the things that make it unclear to me is that the APIs do not clearly look async, so I have to look at the API whether it's async or not.


With this work, it seems to me that we can now employ async constructs in the function definitions.

This add() function is not a suspend function in Kotlin, and so to me, it could be potentially blocking, and I wouldn't know for sure until I check the implementation.

If add became a suspend function, then I would know for sure that it is suspending, and I would only be able to call it in scenarios where I am prepared for handling asynchronous operations.

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.

Oops, I just forgot to add the async (and the .await at the end). I agree that seeing the async / suspend keyword is useful as a developers since you have a better idea what's happening inside the function.

@bendk
bendk force-pushed the bdk/push-kpsuykzvswpp branch from 8dd3182 to 8b01486 Compare October 15, 2026 14:01

@titooan titooan left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry, I didn't intend to "Request changes". Trying to dismiss this request for changes here.

Comment on lines +96 to +101
func run(task: () -> ()) {
queue.async {
task()
}
}
}

@Cramsden Cramsden Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This seems fine to me. I guess we might want to leverage a continuation here to make this interface use swift concurrency for callers. I take that back we would want to be extremely thoughtful about where and how we bridge this back to the Swift concurrency world since these calls are blocking. It should still be possible as long as they are guaranteed to complete. @ih-codes what do you think?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yes, after reading more closely, bridging back to Swift Concurrency feels like it'll be delicate if we use GCD here. 🤔

Can we instead use Swift Concurrency here do you think @Cramsden ? Like you said, for that we need to know whether this work is always guaranteed to complete first. At the SwiftRockies Concurrency workshop we discussed this a bit with Matt and he suggested a custom actor with a custom executor, which uses a custom thread. He also mentioned not to worry about starving the thread pool unless we have work that is not finishing or evidence of a problem. 🤔


So, I have two questions for the Rust side (cc @bendk):

  • Can we guarantee all calls will complete?
  • Must all work be serialized on a single thread?

@bendk bendk Oct 22, 2026 •

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.

Can we guarantee all calls will complete?

Yes, with the caveat that buggy code might not complete.

Must all work be serialized on a single thread?

No, in fact you might want a few threads for this work. The only thing that's important is that it's a background thread, not the main thread and not one of the non-blocking Swift Concurrency threads.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks @bendk! The answer to question 1 was most reassuring to @Cramsden and I when we chatted more about this on Monday. 👍 Her and I discussed more about the GCD vs. Concurrency tradeoffs here and gave this part a thumbs up. GCD queues seems the most straightforward implementation.

Comment on lines +96 to +101
func run(task: () -> ()) {
queue.async {
task()
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yes, after reading more closely, bridging back to Swift Concurrency feels like it'll be delicate if we use GCD here. 🤔

Can we instead use Swift Concurrency here do you think @Cramsden ? Like you said, for that we need to know whether this work is always guaranteed to complete first. At the SwiftRockies Concurrency workshop we discussed this a bit with Matt and he suggested a custom actor with a custom executor, which uses a custom thread. He also mentioned not to worry about starving the thread pool unless we have work that is not finishing or evidence of a problem. 🤔


So, I have two questions for the Rust side (cc @bendk):

  • Can we guarantee all calls will complete?
  • Must all work be serialized on a single thread?

getStorage().add(entry.toLoginEntry()).toLogin()
```

* Swift would have similar changes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It would be nice to see an example for the Swift side too maybe! I'm not familiar enough with the Android side/Kotlin to grok this immediately.

Here's the current Swift code for adding a LoginEntry:

open func add(login: LoginEntry) throws -> Login {
        return try queue.sync {
            try self.store.add(login: login)
        }
    }

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.

Great question and I'm not sure the answer, maybe we can work it out. From what I can tell, there's a few layers of complexity around that add() function that I think we can remove, but I'm not sure what the final result should be.

Here's how I think it works right now:

If the Rust add function was async, then I think we could refactor that code to something like this (apologies for my bad Swift):

class RustLogins {
    public async func addLogin(login: LoginEntry) -> Login throws {
        // getStoredKey refactored to not need a completion handler.
        // Does it also need to be async?
        // BTW, do we need to call this at all?  It doesn't seem like the key is ever used
        let key = try self.getStoredKey()

        // I think this can replace the old `guard self.isOpen` code
        if let storage = self.storage {
            // One benefit is the return value never null
            return try await storage.add(login: login)
        } else {
            throw LoginsStoreError.UnexpectedLoginsApiError(reason: "Database is closed")
        }

    }
}

class PasswordManagerViewModule {
    // Save now either returns the login ID or throws.  Before it called the completion handler
    // with either the login ID or an error message, which doesn't seem useful to me
    public async func save(loginRecord: LoginEntry) -> String throws {
        let encrypted = try loginProvider.addLogin(login: loginRecord)
        self.sendLoginsSavedTelemetry()
        return encrypted.id
    }
}

class PasswordManagerListViewController {
    @objc
    func presentAddCredential() {
        // Assuming, we don't refactor `pressedAddPassword`, we still need a completion
        // handler for that result
        let completion: (LoginEntry) -> Void = { [weak self] record in
            // Since the completion handler is sync, we need a bridge to call async code
            // I'm not sure what the best way to do this is, maybe we just construct a Task?
            Task {
                do {
                    self?.viewModel.save(loginRecord: record) 
                    // Since we're in an async task, we can't probably shouldn't call
                    // our sync methods.
                    // This could be refactored somehow, but for now let's just use
                    // `DispatchQueue.main.async` like before
                    DispatchQueue.main.async {
                        self?.loadLogins()
                        self?.tableView.reloadData()
                    }
                } catch {
                    // You could put error handling here or just ignore the error.
                    // The previous code reloaded the data which doesn't seem right to me
                }
            }
        }
        sendLoginsManagementAddTappedTelemetry()
        coordinator?.pressedAddPassword(completion: completion)
    }
}

Each new version seems significantly simpler to me than the old one. I don't think you need to do all that refactoring at once though. Wherever you decide to stop refactoring, you can keep the function sync and use Task to call the async function.

  • Does that seem right to you?
  • Is Task the right way to call async code from a sync function?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the detailed walkthrough @bendk ! Sorry, I didn't expect it to be quite so much work for you!

Wherever you decide to stop refactoring, you can keep the function sync and use Task to call the async function. [...] Is Task the right way to call async code from a sync function?

The short answer is "yes", but the longer answer is that we should probably take a slightly different approach to avoid the aforementioned refactoring as much as possible. 🤔

A big portion of the Firefox for iOS Client codebase uses GCD (i.e. queues, callbacks) and not modern Swift Concurrency (i.e. async/await). PasswordManagerListViewController is one of them. So, in the case of your example, we would be best not making the RustLogins addLogin method async (which removes the problem of needing the Task later in the presentAddCredential() method).

Given your proposed SwiftWorkQueue implementation (which uses an underlying GCD queue), I suspect our interface between the Swift MozillaRustComponents <> Generated uniFFI calls should exclusively be in GCD and callbacks. 🤔 Does that sound right to you @bendk? (and CC @Cramsden if you want to double check me here too to make sure I'm not way off base)

If that's the case, making a method async or not later is a trivial Client-side detail. We would be able to add a simple wrapping continuation to any calls we want to convert from GCD callbacks to Concurrency async-await.


I think what I'm most curious to understand now is what the API looks like between the Firefox iOS Client and the package we import from AS. The original snippet I posted was from the LoginsStorage.swift file, which is what the RustLogins.swift's addLogin method you dissected calls into.

I just want to check my understanding on that LoginsStorage code:

    open func add(login: LoginEntry) throws -> Login {
        return try queue.sync { // --> [A]
            try self.store.add(login: login).  // --> [B]
        }
    }

Am I correct in understanding that:

  • [A] This queue.sync is how Firefox for iOS Client currently manages the threading of the rust calls. Is this the async bridge we're proposing to move in this ADR?
  • [B] This is the call into the uniFFI code (e.g. return try FfiConverterTypeLogin_lift()). With this proposal, would we instead call a method wrapped by the new SwiftWorkQueue, which in turn wraps this uniFFI call?

I might be completely off base here so I just wanted to make sure I'm on the same page. 😅

@bendk bendk Oct 28, 2026 •

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.

[A] is really mysterious to me. AFAICT, it doesn't have any benefit -- it still needs to be wrapped in queue.async call the next level up. It also spins up an extra thread. My best guess is this is leftover code from a refactor or code that was copy-and-pasted without thinking it all though.

I may be misunderstanding you on [B], but the way I see it everything would be in the UniFFI call: the top-level add method wraps an internal Rust method with SwiftWorkQueue and the whole thing is exposed by UniFFI. If we kept the current proposal and you wanted to keep GCD callbacks and make the changes as small as possible then I think it would look something like this:

class LoginsStorage {
    open func add(
        login: LoginEntry,
        completionHandler: @escaping @Sendable (Result<Login?, Error>) -> Void)
    {
       Task {
            do {
                let login = try await self.store.add(login: login)
                completionHandler(.success(login))
            } catch {
                completionHandler(.failure(error))
            }
        }
    }
}

Alternatively, maybe we don't need LoginsStorage at all and the change could be made at the level of RustLogins.addLogin. All LoginsStorage seems to be doing is the wrapping things in queue.sync and I don't see what that's accomplishing.

For the ADR, I'm thinking I can suggest something like the snippet above and mention that we don't know exactly how the Swift code should be refactored.

Stepping back a bit. If you really want to keep with the GCD + completionHandler approach, then there's an argument not to use async Rust at all. Maybe UniFFI could wrap things such that the Rust code stayed sync, but it was scheduled in a queue and called the completionHandler at the end. The advantage is you don't need the Task to wrap the async Rust function, but there's a lot of disadvantages: a) if you wanted to switch add() to be async in the future, there's no path to do that b) I'm not sure how to implement the system and c) I think Android does want suspend functions and it seems hard to integrate both of those things. The only downside of constructing the Task that I see is it might be slightly less efficient, but that seems okay to me.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hi again @bendk, I had a chance to chat with @Cramsden today. I have a better understanding of where we're at now (I was missing some context earlier).

Regarding [A], I also find this code pretty mysterious. I think you're likely right that it could be cleaned up.

Regarding [B], Carson and I are going to come to the AS Sync tomorrow in hopes of talking through this a bit more in person. We have a few questions from our discussion today. It sounds like there's no reason not to make all the synchronous blocking rust calls surface as async on the Swift side, so you can ignore my earlier ramblings about that (I misunderstood the level we were solving for; sounds like we'll still be queuing work on the Client side one way or another).

As a preview, some of the questions we'll bring to the meeting:

  • Do we use SwiftWorkQueue only for blocking synchronous rust calls so we can control the threading from the Swift Client?
  • How do we define where work is happening from the Client side? (e.g. how do we choose the thread, and can we choose a thread per module, like logins vs. places)
  • Will simple (non blocking) synchronous work remain unchanged (i.e. no async)?

@bendk

bendk commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Hi all, thanks for all the feedback on this one. The official close date was today, but there's still a couple open questions. From what I can tell, none of the open questions point to us not making this decision though.

I'm thinking we keep this open another week and see if there aren't any new concerns raised or someone points out what I'm missing in the current threads. If not, then we approve/merge -- maybe with additional notes based on the discussions. Is that okay with you all?

@ih-codes

Copy link
Copy Markdown
Contributor

Hi all, thanks for all the feedback on this one. The official close date was today, but there's still a couple open questions. From what I can tell, none of the open questions point to us not making this decision though.

I'm thinking we keep this open another week and see if there aren't any new concerns raised or someone points out what I'm missing in the current threads. If not, then we approve/merge -- maybe with additional notes based on the discussions. Is that okay with you all?

Thanks @bendk, the extra week would be great RE: our discussion thread. I'm going to chat with @Cramsden to confirm my understanding on something and get back to you soon. 🙏

This branch has not been deployed

No deployments
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.

7 participants