Skip to content

feat(runtime): background driver so async work (fetch, timers) resolves on its own#12

Open
wytzepiet wants to merge 2 commits into
fluttercandies:mainfrom
wytzepiet:feat/job-pump-drive
Open

feat(runtime): background driver so async work (fetch, timers) resolves on its own#12
wytzepiet wants to merge 2 commits into
fluttercandies:mainfrom
wytzepiet:feat/job-pump-drive

Conversation

@wytzepiet
Copy link
Copy Markdown

@wytzepiet wytzepiet commented May 31, 2026

Problem

When JavaScript does async work — a fetch, a setTimeout — that work runs in the background. But it only moves forward when something pokes the runtime. If nothing pokes it, a finished result just sits there undelivered until some unrelated call (like an eval from Dart) happens to come along and poke it.

In a real app those pokes can be far apart. Measured on a physical phone:

time
fetch to a Convex endpoint, in the app ~15 s
the same request via curl ~100 ms
with a timer poking the runtime every 33 ms ~50 ms

The 15 s lined up exactly with the app's WebSocket ping — the only thing regularly poking the runtime. A 33 ms timer fixed it, which proved the cause — but that timer wakes 30 times a second forever, even when there's nothing to do.

Fix

rquickjs already has the right tool: AsyncRuntime::drive(). It's the background-friendly relative of the existing idle() — instead of running until all the work is done, it keeps running and keeps the background work moving. When there's nothing to do it sleeps instead of spinning, and wakes only when a piece of background work is actually ready. It also shares the runtime fairly, so regular calls like eval still get their turn and nothing gets stuck waiting.

This PR runs that drive() future on a background task, so async work resolves on its own — no polling, no wasteful timer:

  • start_drive() — starts the background task. Calling it again while it's already running does nothing. It's async because it has to run inside the async runtime to start the task — a sync version would have no runtime and panic.
  • stop_drive() — stops it.
  • JsEngine forwards both, and close() stops it on shutdown.

A host that used to poll executePendingJob() on a timer can now just call startDrive() once and let async work resolve as it's ready.

Notes

  • Only adds things — no existing method or behavior changes.
  • Regenerated the bindings with flutter_rust_bridge_codegen 2.12.0 (matching the runtime, see [Bug report] fjs's codegen version (2.11.1) should be the same as runtime version (2.12.0) #11). The big churn in frb_generated.* is just the auto-generated wiring shifting as new methods are added.
  • New test covers start / "a second start does nothing" / stop / still-usable-afterwards. The 74 engine and 32 async tests pass, and the ~15 s → ~100 ms result was confirmed on a physical iPhone.

🤖 Generated with Claude Code

…rive)

Async work spawned onto the rquickjs AsyncRuntime executor — timers
(llrt_timers) and fetch (llrt_fetch), both via Ctx::spawn — only advanced
when a Dart->JS bridge call happened to pump the runtime. With no recurring
bridge event, a ready continuation sat unpolled until something unrelated
drove the runtime. Measured in a real app: a fetch took ~15s (exactly the
WebSocket ping interval, the only recurring bridge event) vs ~100ms over the
network. A blind periodic pump dropped it to ~50ms, confirming the cause.

rquickjs already exposes the right primitive: AsyncRuntime::drive(), the
event-driven sibling of idle(). It drains ready work, registers its waker
with the schedular, then releases the runtime lock and parks until a spawned
future becomes runnable — so it resolves fetch/timer promises promptly
without the host polling, while cooperative yielding (ShouldYield) and the
fair async lock keep concurrent eval() calls interleaving.

This exposes that primitive across the bridge:

- JsAsyncRuntime::start_drive() spawns rt.drive() onto the ambient tokio
  runtime and stores the JoinHandle. Idempotent (no-op if already running).
  It is async because it must run inside FRB's tokio context for
  tokio::spawn; a #[frb(sync)] method has no reactor and would panic.
- JsAsyncRuntime::stop_drive() aborts the task.
- JsEngine forwards both; close() stops the driver before teardown.

Additive: no existing behavior or signatures change. Hosts that previously
polled execute_pending_job() on a timer can call start_drive() once instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@wytzepiet wytzepiet force-pushed the feat/job-pump-drive branch from 4ba4afb to f79923b Compare May 31, 2026 20:11
@wytzepiet wytzepiet changed the title feat(runtime): event-driven background driver so fetch/timers resolve without host polling feat(runtime): background driver so async work (fetch, timers) resolves on its own May 31, 2026
@wytzepiet wytzepiet marked this pull request as ready for review May 31, 2026 20:53
@iota9star
Copy link
Copy Markdown
Member

Review findings:

…work

The existing lifecycle test only checks that start_drive spawns and stops a
task. Add two tests that the driver does its job: a detached setTimeout and a
detached fetch round-trip (fetch(A).then(() => fetch(B))) each resolve on their
own under start_drive, and neither makes progress without it.

Both are observed without pumping the runtime — is_job_pending() is a state
read, and the fetch test's loopback servers only signal once they have read the
request bytes — so nothing can mask a missing wake. This is the first coverage
of what the driver is for: detached async work, the kind eval's own await loop
never sees.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

2 participants