Async tasks and background work
Use day::task to await work without blocking the interface. Its futures run on Day’s main
loop, so they can read and write signals before and after an .await. Day provides this
executor itself; apps do not need Tokio to use it.
Use day::spawn_blocking for CPU-heavy or blocking work: it runs the job on a pool thread and
resolves back in the task, where the result goes into a signal like any other value. A worker
thread of your own returns its results through Setter or on_main, because signals cannot
move between threads.
For app examples, see HTTP requests and Local storage. This reference covers task lifetimes, cancellation, and the rules for implementing asynchronous parts.
The UI snippets use generated res::str accessors from an app’s Fluent catalog; see the
example messages and catalog setup.
day::task and TaskHandle
button(res::str::save()).action(move || {
let req = req.clone();
day::task(async move {
if confirm(res::str::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; dropping
the future invokes its cleanup. For fetch_future, that requests cancellation where the
platform supports it; other implementations finish in the background and discard the result.
See the HTTP cancellation contract. Aborting a finished task is a no-op; is_finished() reports
completed-or-aborted. Task ids are never reused, so stale handles are harmless.
A task is not owned by the scope that starts it. Leaving a page does not abort its tasks.
Keep the handle and abort it explicitly, or use Resource for work that should end with a scope.
On WillTerminate, Day stops accepting async work and drops pending tasks before calling app
lifecycle handlers. Their destructors can still update signals; handlers can then synchronously
close databases and other dependencies. DidExit performs the same cleanup for backends whose
loop returns without WillTerminate. New tasks submitted during shutdown are dropped without
polling, and late wakes are ignored. This does not wait for detached worker threads or cancel
already accepted database writes; the owning service’s shutdown must drain those writes.
OS process kills without a lifecycle notification cannot run this cleanup.
spawn_blocking
day::task(async move {
let parsed = day::spawn_blocking(move || parse_feed(&bytes)).await; // a pool thread
match parsed { // the UI thread again
Ok(Ok(feed)) => items.set(feed.items),
Ok(Err(_)) => status.set(res::str::request_failed().format()),
Err(_) => status.set(res::str::operation_failed(res::str::request_failed().format()).format()), // panicked, or never ran
}
});
spawn_blocking(job) takes a Send closure and returns a future for its value. The job runs on
Day’s blocking pool: process-wide, started by the first job, at most day::blocking_limit()
threads (the machine’s parallelism, between four and thirty-two), idle threads leaving after a
few seconds. Jobs beyond the limit queue in order. The future resolves to
Err(BlockingError::Panicked) when the job panicked and Err(BlockingError::Closed) when no
pool thread could ever run it.
Dropping the future before the job starts cancels it: the job never runs, which is what a
timeout racing a slow read wants. Dropping it while the job runs lets the job finish and
discards the value, since a blocking call cannot be interrupted. Blocking::detach keeps the
job without the value, for work whose effect is its own completion callback.
Use it for a file read, a parse, a hash, a database open, or a block_on into a runtime some
dependency insists on. It is not a replacement for DatabaseWorker, which keeps one
connection with ordered writes and reactive reads (docs/persistence.md), nor
for work that reports progress partway: that wants a channel, not polling. A part that
exposes a future should await spawn_blocking rather than start a thread, so its test’s
block_on and an app’s day::task get the same behavior.
On the web there is no thread, so the job runs inline at the future’s first poll. Keep web jobs
short, or branch on cfg!(target_arch = "wasm32"). The mock toolkit runs jobs inline as well,
because its posts run on the test thread (day_async::set_blocking_inline).
sleep
day::sleep(ms) resolves after at least ms milliseconds, on the UI thread. It is the
toolkit’s own timer, not a parked thread: dispatch_after on Apple, Handler.postDelayed on
Android, g_timeout_add on GTK, QTimer::singleShot on Qt, a Win32 SetTimer on XAML, and
setTimeout on ArkUI and the web. A loop that ticks every second, a debounce, or a timeout raced
against a fetch costs nothing but the timer. Dropping the sleep (aborting its task) lets the
native timer fire into nothing. The platform may round a short delay up: Windows timers tick
every ten milliseconds or so. XAML and WinUI check a monotonic deadline when a timer signals
and rearm an early signal for the remaining time, so clock granularity cannot shorten a sleep.
Resource and Load (day::reactive)
Resource is the declarative layer: a tracked source whose value feeds an async fetcher,
and the result is stored 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>
);
load(stations, |s| station_list(s.get())) // the three states as subtrees
.loading(spinner)
.failed(|_| label(res::str::request_failed()).secondary());
stations.refetch(); // force, even if region is unchanged
load(state, ready) shows the three states as three subtrees: the ready arm, and optional
.loading(build) and .failed(|err| build) arms (a state without an arm builds nothing).
state is a Resource<T>, a Signal<Load<T>> or a closure returning a Load<T>. Arms rebuild
only when the variant changes; the ready arm is handed a Loaded<T>, whose get() is a tracked
read that follows Ready(a) to Ready(b) in place, so a refetch updates the list without
rebuilding it. Whether a refetch passes through Loading is the Resource’s policy (below); an
app that wants its stale content shown during a refresh keeps the last value in its own state
and the ready arm reads it.
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 requests cancellation where supported) and a completion that slips through writes
nothing.
refetch()always fetches; a rerun with an unchanged source value fetches nothing. - Scope cleanup cancels the fetch. When the owning scope is disposed, its task is aborted. A late write to the disposed signal has no effect.
- 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’sMaybeSendbound was removed 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).
The policy
These rules keep UI state on the main thread and let parts work without depending on a particular executor:
- Piece builders and event handlers are synchronous. 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, without aSetteror marshaling. Futures that never touch signals may run anywhere. Blocking work inside such a future goes throughday::spawn_blocking, never astd::thread::spawnplus a oneshot of your own, and never inline: a parse or a file read in aday::taskbody stalls the interface for its whole duration.- 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). - Keep other async runtimes in app-specific 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. - Return to the UI thread through
Setteroron_main(DESIGN §3.3). Completion callbacks that run on background threads (e.g.fetch_async) deliver through them; futures onday::taskdon’t need them.
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.day-async(2026-09) holds that plumbing once, std-only:oneshot()— aDeliver<T>any thread may send from and aOneshot<T>any executor can await, resolving toDroppedrather than pending forever when the sender goes away — andTokenRegistry<T>, the register-before-call, remove-by-token map behind every platform completion that crosses an FFI boundary as a number. The bridge’s callback tier (docs/bridge.md “Callbacks”) is its first user; the parts’ hand-rolled copies migrate onto it.day-asyncalso owns the process’s one timer thread:schedule(delay, job)runs a job after a delay andunschedule(id)forgets it, so a part’s time limits and backoff wait there instead of parking a thread each. day-part-http’s total-time limits and question timeouts, and day-part-downloads’ retry backoff, use it. The web has no threads, so a timer never fires there. It stays separate fromday::sleep’s native timer because a part must work in a plain binary and undercargo test, where no backend has installed a timer door.day-asyncholds the blocking pool behindspawn_blocking(2026-10): aVecDequeof jobs, aCondvar, and threads that start on demand up toblocking_limit()and exit after two idle seconds. A job is the user’s closure undercatch_unwind, delivering through aoneshot; a cancelled flag, set by the future’sDrop, skips a job that has not started. day-part-fs’s async operations run on it, detached.blocking_threads()reports the live count.day::sleepridesPlatform::post_delayed, which every backend implements with the loop’s own timer; the trait default (a thread that sleeps and posts) logs once when a backend without one falls back to it. The conformance app’s headless casessleep-*,an-aborted-task-never-wakes-from-its-sleepand*-blocking-*run on every toolkit in CI (docs/testing.md) and prove the timer resolves after its delay, in delay order, yields on zero, and that blocking jobs leave the UI thread, resolve on it, report panics and honor cancellation.
Test hooks
- 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.
Reading and checking these examples
The Rust blocks above are integration fragments with app-defined values and functions; they
are not standalone apps. Use the compiled HTTP recipe
for complete imports, catalog keys, and the validation harness. request_failed is defined in
that recipe’s catalog; other generated messages come from the snippet catalog.
on_main takes a Send + 'static closure and requires an installed main poster. It cannot
capture a Signal; pass data through a Setter instead. day::task accepts a non-Send
Future<Output = ()> and eagerly polls it once. Keep CPU or blocking IO work out of that poll.
Dropping a task handle does not abort its task; call abort or use a scope-owned Resource.
See executor and shutdown, Resource and Setter, and blocking pool for implementation details.