Overview
A concurrency fix at the boundary between Dart and Rust. A cancelled foreign-function call was tearing down the future that owned an in-progress SQLite migration, leaving the database locked and the schema half-applied. The refactor decouples initialisation from the lifetime of any single caller, de-duplicates concurrent attempts, and keeps the public FFI interface unchanged.
Problem
Database initialisation ran inside the async task created by the Dart FFI call. If that call was cancelled — the caller times out, the screen is disposed, the app is backgrounded — the future was dropped, and with it whatever the migration was doing.
In async Rust, a dropped future simply stops at its last suspension point. There is no unwinding and no chance to finish. If the drop happens between two migration statements, the schema is left partly applied and the SQLite connection is torn down while it still holds a lock. The next call then finds a database that is both locked and in an inconsistent state — and because that call runs the same code, it fails the same way.
Constraints
- The public FFI interface must not change; callers cannot be modified
- SQLite migrations are blocking work and must not be run on the async executor
- Cancellation is normal, not exceptional — the design has to assume it happens mid-migration
- After a failure the system must be able to retry cleanly rather than stay poisoned
Role and contribution
Software Engineer — concurrency and FFI
- Diagnosed the failure mode and traced it to future cancellation at the FFI boundary
- Redesigned initialisation so its lifetime is independent of any single caller
- Moved blocking SQLite work off the async executor
- Added de-duplication so concurrent callers share one initialisation
- Made retry safe after a failed attempt, without changing the public interface
System design
Architecture
Initialisation lifecycle, before and after
Before the refactor, each Dart FFI call created a Tokio task that ran the SQLite migrations directly, so cancelling the call dropped the future mid-migration and left the connection locked. After the refactor, an FFI call acquires a handle to a single shared initialisation operation. The migrations run inside a blocking task whose lifetime is owned by that shared operation rather than by the caller. Concurrent callers await the same operation, and a cancelled caller only drops its own handle — the migration continues to completion and releases its lock.
- Systems
- Rust
- Tokio
- Blocking task isolation
- Data
- SQLite
- Schema migrations
- Boundary
- Dart FFI
- Cancellation-safe initialisation
Why cancellation was dangerous here
Cancellation in async Rust is not an error path — it is the absence of one. Dropping a future stops it at whatever .await it last reached, with no notification and no opportunity to clean up beyond what Drop implementations happen to do.
For most work that is harmless. For a migration it is not, because a migration is a sequence of statements that is only meaningful as a whole. Stopping between two of them leaves the schema in a state that no version of the code expects, and the connection holding the lock disappears without a clean close.
The worst property was that the failure was self-perpetuating. The retry ran the same cancellable path and inherited both the lock and the inconsistent schema, so a single badly-timed cancellation could keep the database unusable indefinitely.
Decoupling lifecycle from the caller
The core change is that initialisation is no longer owned by the call that triggered it. A caller acquires a handle to a shared operation; the operation's own lifetime governs the migration. Cancelling a caller drops that caller's handle and nothing else — the migration runs to completion and releases its lock normally.
Moving the work to a blocking task serves the same goal from a different direction. SQLite migrations are synchronous, CPU- and I/O-bound work that has no business occupying an async executor thread, and a blocking task is not subject to the same drop-at-await semantics that made cancellation destructive in the first place.
De-duplication and retry
Once initialisation outlives its caller, several callers can arrive while it is still running. Letting each start its own migration would reintroduce the lock contention the refactor exists to remove, so concurrent attempts are collapsed into one: the first caller starts the work, everyone else waits on the same result.
Failure has to be handled separately from success. A completed initialisation is cached and never repeated; a failed one must not be, or a single transient error would poison every future attempt. The shared state therefore distinguishes 'done' from 'attempted and failed', and only the former short-circuits.
Validation
The behaviour worth testing is the one that caused the bug: cancel a caller mid-initialisation and assert that the migration still completes, the lock is released, and a subsequent call succeeds. Alongside that, concurrent callers should produce exactly one migration run, and a forced failure should leave the system retryable rather than permanently broken.
Because the public FFI interface is unchanged, existing callers exercise the new implementation without modification — which is the most useful validation available, since the original failure only appeared under real timing.
Key decisions and trade-offs
Decision
Move migrations onto a blocking task instead of running them on the async executor
- Context
- SQLite migrations are synchronous work that was being awaited inside a cancellable async task.
- Alternatives considered
- Keep the work async and guard it with a cancellation-safe wrapper
- Make each migration step individually idempotent so partial application is recoverable
- Chosen approach
- Run the migration inside a blocking task owned by the shared initialisation.
- Reason
- It matches the nature of the work and removes the drop-at-await hazard entirely rather than defending against it. Blocking work on an async executor is a problem on its own merits.
- Trade-off
- Initialisation occupies a thread from the blocking pool for its duration, and cancelling the caller no longer stops the work — which is the intended behaviour, but it does mean the operation cannot be aborted early.
Decision
De-duplicate concurrent initialisation into a single shared operation
- Context
- Once initialisation survives caller cancellation, multiple callers can arrive while it is in progress.
- Alternatives considered
- Let each caller initialise and rely on SQLite locking to serialise them
- A plain one-time initialisation primitive
- Chosen approach
- A shared operation that the first caller starts and subsequent callers await, with failures explicitly not cached.
- Reason
- Relying on database locking to serialise callers reintroduces exactly the contention the refactor removes. A plain one-time primitive would cache the first failure permanently.
- Trade-off
- More shared state to reason about, and the failure path needs its own handling so a transient error stays transient.
Decision
Preserve the public FFI interface
- Context
- Callers on the Dart side would otherwise need to change to match a new lifecycle.
- Alternative considered
- Expose the shared initialisation handle across the boundary
- Chosen approach
- Keep the exported signatures identical and confine the change to the Rust side.
- Reason
- The bug is a Rust-side lifecycle problem. Pushing it across the FFI boundary would export the complexity to every caller and make the fix a breaking change.
- Trade-off
- The Rust side absorbs all of the added complexity.
Result
- A cancelled FFI call no longer abandons a migration or leaves the database locked
- Concurrent callers produce exactly one migration run
- A failed attempt stays retryable instead of poisoning subsequent calls
- No changes required on the Dart side
Lessons learned
- In async Rust, cancellation is a design input rather than an error case. Any operation that must complete cannot have its lifetime owned by a caller that can vanish.
- Blocking work on an async executor is not only a performance issue — it inherits cancellation semantics that were never meant for it.
- Caching the outcome of a one-time operation is only correct for success. Caching failure converts a transient fault into a permanent one.
- Fixing this behind an unchanged interface kept the blast radius inside one language boundary.
Next improvements
- Timeout and back-off around initialisation so a genuinely stuck migration surfaces rather than hanging
- Structured logging of initialisation state transitions, which are currently hard to observe from the Dart side
- Property-based testing of cancellation timing across the migration sequence