embassy-rs/embassy · error

RpcService::run() must not be called concurrently

Error message

RpcService::run() must not be called concurrently

What it means

RpcService::run() is a single-consumer runner loop: the runner_state tracks a `running` flag and run() panics if invoked while a previous run() is still active. Only one run loop may drive the service at a time, since recovery and call dispatch state are singular.

Solutions

  1. Spawn run() exactly once and keep that task alive for the service lifetime.
  2. Await the existing run() future (or shut it down via RunGuard/Drop) before calling run() again.
  3. Use a supervisor task that owns run() so no other code path can start it.

Example fix

// before
spawner.spawn(run_rpc(service.clone()).unwrap());
spawner.spawn(run_rpc(service.clone()).unwrap()); // panics
// after
spawner.spawn(run_rpc(service).unwrap()); // single runner
Defensive patterns

Strategy: validation

Validate before calling

// Wrap the runner so run() can only ever be spawned once:
use embassy_sync::once_lock::OnceLock;
static SPAWNED: OnceLock<()> = OnceLock::new();
fn spawn_runner_once(spawner, service) {
    if SPAWNED.try_get().is_none() {
        SPAWNED.init(());
        spawner.must_spawn(service.run());
    }
}

Prevention

When it happens

Trigger: Calling service.run(...) from two tasks/spawns concurrently, calling run() again before the first loop returns, or spawning run() in a loop without awaiting/joining the previous invocation.

Common situations: Accidentally spawning the RPC runner in two Embassy tasks; restarting the runner after a hot path without awaiting shutdown; firmware update code that re-inits the service while the old task still runs.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of embassy-rs/embassy@463a07b963 (2026-09-10). Data as JSON: /api/errors/24e8eb28cedfd872. Report an issue: GitHub.

Appendix: source

Thrown at embassy-sync/src/rpc_service.rs:519

    /// This future is cancel-safe. A subsequent call to `run()` will recover the
    /// previous state and resume processing any in-flight call.
    pub async fn run(&self, state: &mut T) -> ! {
        struct RunGuard<'a, M: RawMutex> {
            runner_state: &'a Mutex<M, Cell<RunnerState>>,
        }
        impl<M: RawMutex> Drop for RunGuard<'_, M> {
            fn drop(&mut self) {
                self.runner_state.lock(|cell| {
                    let mut s = cell.get();
                    s.running = false;
                    cell.set(s);
                });
            }
        }

        let needs_recovery = self.with_runner_state(|s| {
            if s.running {
                panic!("RpcService::run() must not be called concurrently")
            }
            s.running = true;
            s.needs_recovery
        });
        let _guard = RunGuard {
            runner_state: &self.runner_state,
        };

        // If the previous runner was cancelled mid-job the caller might still
        // be interacting with the slot. We must wait for it to finish (the caller
        // always acks, either explicitly or via its Drop) and then clean up.
        if needs_recovery {
            self.slot.wait_ack_and_finish(&self.runner_state).await;
        }

        loop {
            // Wait for a caller to submit a closure.
            // This is a clean cancellation point because no job in flight

View on GitHub (pinned to 463a07b963)