feat(runtime): background driver so async work (fetch, timers) resolves on its own#12
feat(runtime): background driver so async work (fetch, timers) resolves on its own#12wytzepiet wants to merge 2 commits into
Conversation
ed2bb2f to
4ba4afb
Compare
…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>
4ba4afb to
f79923b
Compare
|
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>
Problem
When JavaScript does async work — a
fetch, asetTimeout— 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 anevalfrom Dart) happens to come along and poke it.In a real app those pokes can be far apart. Measured on a physical phone:
fetchto a Convex endpoint, in the appcurlThe 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 existingidle()— 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 likeevalstill 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'sasyncbecause 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.JsEngineforwards both, andclose()stops it on shutdown.A host that used to poll
executePendingJob()on a timer can now just callstartDrive()once and let async work resolve as it's ready.Notes
flutter_rust_bridge_codegen2.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 infrb_generated.*is just the auto-generated wiring shifting as new methods are added.🤖 Generated with Claude Code