Async: futures without a runtime
Status: implemented (DESIGN.md §4.5, revised 2026-07). Day runs futures on its own main-loop executor (
day::task) without an async runtime: nothing brings in tokio, and there is no reactor or thread pool. The executor polls!Sendfutures on the UI thread; wakers re-poll through the sameon_mainposter everything else rides. On top of it sitpresent().await(docs/dialogs.md),day_part_http::fetch_future(docs/http.md), andday::reactive::Resource(below).
The policy
Five rules keep async at the edges and the reactive core single-threaded. They are the
contract for every day-* crate and the recommended shape for apps:
- Async never appears in the authoring surface. No
async fninPiece::build, actions, or event handlers.day::task(async { … })is the one explicit bridge from a sync action into a sequential flow. day::taskis the only executor for signal-touching futures. Its futures run on the UI thread, so after an.awaitthey read and write signals directly: noSetter, no marshaling. Futures that never touch signals may run anywhere.- Parts expose a callback and a future, never a runtime-bound API.
fetch_async(req, cb)plusfetch_future(req); both must work in a plain-mainbinary and undercargo test(so a part never callson_mainitself, per docs/http.md’s contract). - Foreign runtimes are quarantined in app-private crates. A dependency that demands tokio
(matrix-rust-sdk) gets a headless core crate owning that runtime on background threads;
results cross back only through
Setter/on_main, and!Sendhandles never leave the main thread. The Day-Matrix app’smatrix-corecrate (a standalone Day app) is the reference; its bridge rule is documented at the top of its lib.rs. Noday-*crate depends on an async runtime. Setterandon_mainremain the only cross-thread doors (DESIGN §3.3). Completion callbacks that run on background threads (e.g.fetch_async) deliver through them; futures onday::taskdon’t need them.
day::task and TaskHandle
button("Save").action(move || {
day::task(async move {
if confirm("Overwrite?").await { // native modal (docs/dialogs.md)
let resp = day_part_http::fetch_future(req).await;
status.set(render(resp)); // UI thread — a plain signal write
}
});
});
task(fut) polls the future once before returning and hands back a TaskHandle (Copy,
!Send, freely discardable). handle.abort() removes and drops the task’s future; an
in-flight .await cancels via Drop, so aborting a task that awaits a fetch_future cancels
the platform request. Aborting a finished task is a no-op; is_finished() reports
completed-or-aborted. Task ids are never reused, so stale handles are harmless.
Resource and Load (day::reactive)
The declarative layer: a tracked source whose value feeds an async fetcher; the result
lands in a Signal<Load<T>>.
use day::reactive::{Load, Resource};
let stations = Resource::new(
move || region.get(), // tracked — refetch on change
|region| async move { fetch_stations(region).await }, // Result<T, E: Error + Send + Sync>
);
when(move || stations.ready(), move || station_list(stations));
stations.refetch(); // force, even if region is unchanged
Load<T>isLoading | Ready(T) | Failed(Arc<dyn Error + Send + Sync>),Clone, withready()/is_loading()/is_ready()/error()accessors.Resourceis aCopyhandle:signal(),get(),with(),loading(),ready()(all tracked),refetch().- Latest wins. A source change supersedes the in-flight fetch: its task is aborted (the
drop cancels any platform request inside) and a completion that slips through writes
nothing.
refetch()always fetches; a rerun with an unchanged source value fetches nothing. - Disposal is clean. The owning scope’s death aborts the in-flight fetch; a late write hits the disposed-signal no-op.
- The fetcher runs on the main-loop executor, so it may read and write signals after its
awaits, and its source value needs no
Sendbound. §4.5’sMaybeSendseam collapsed for this reason. See the DESIGN status note. - Namespacing: the prelude’s
Resourceis the ASSET handle (docs/resources.md), which predates this type; the async one lives atday::reactive::Resource, or depend onday-reactivedirectly.
day-part-http pairs with it for the common case (see the showcase’s Platform-services page:
the loopback Resource demo, the PATCH fetch_future demo, and the URL checker that aborts
its previous in-flight task on re-tap).
Under the hood
- The executor (
crates/day-core/src/present.rs) stores boxed futures in a thread-local map; waking posts a re-poll throughday_reactive::on_main. It is std-only, ~100 lines. - day-reactive reaches the executor through an installed hook (
install_spawner, the poster/scheduler pattern) because day-core depends on day-reactive, not the reverse.day_core::launch_withwires it on every backend (including mock). The spawner returns an abort closure that MUST be a no-op after completion: the spawner polls eagerly, so a synchronously-ready fetcher finishes beforeResourcecan store the abort. FetchFuture(docs/http.md) is oneshot plumbing overfetch_async’s completion callback; itsDropruns the platform cancel. It has no executor dependency; any executor can await it, including a test’sblock_on.
Testing seams
- day-core executor tests: install an inline poster once
(
day_reactive::install_main_poster(|f| f())) and every wake re-polls synchronously on the test thread (present.rs’stask_tests). - day-reactive Resource tests:
install_spawnera miniature executor (poll-once at spawn + an explicitpump()), and resolve hand-rolled manual futures (resource_tests). - day-part-http future tests: a ~25-line park/unpark
block_on(tests/http.rs). The completion’s wake from the delegate queue is exactly the cross-thread path production uses. - Missing installs fail loudly:
on_mainand the spawner panic with “backend not started” rather than dropping work.