Background Tasks
Background work should enter the app the same way user input does: by dispatching typed events. The background worker may run on another task, sleep, poll a server, watch a database, or receive socket input, but it should not directly mutate the foreground app state.
The weather example uses a periodic refresh to demonstrate the pattern. It has two app-like components:
weather_app::App
handles Event and owns business state
weather_app::background::BackgroundApp
handles BackgroundEvent and owns refresh scheduling state
Both components have ordinary async update methods. The host owns queues,
timers, and task spawning.
Background Events
The background app has its own event type:
#![allow(unused)]
fn main() {
pub enum BackgroundEvent {
Started,
RefreshTick { generation: u64 },
Resume,
Suspend,
TargetChanged(RefreshTarget),
Stopped,
}
}
Resume and Suspend describe the background worker itself. They are not
named after the UI lifecycle. The foreground app still has
Event::Foregrounded and Event::Backgrounded, but it translates those into
background intent.
The refresh target is also background-local state:
#![allow(unused)]
fn main() {
pub enum RefreshTarget {
CurrentLocation,
ManualLocation(String),
Disabled,
}
}
That keeps periodic refresh behavior explicit and testable. A manual search can change the future refresh target without directly touching the background app.
Capabilities
The background module defines two narrow capabilities:
#![allow(unused)]
fn main() {
#[krucyfiks::effect]
#[async_trait::async_trait]
pub trait AppEventSink: Send + Sync {
async fn dispatch(&self, event: Event);
}
#[krucyfiks::effect]
#[async_trait::async_trait]
pub trait BackgroundEventSink: Send + Sync {
async fn dispatch(&self, event: BackgroundEvent);
async fn dispatch_after(&self, delay: Duration, event: BackgroundEvent);
}
}
AppEventSink lets the background app request a foreground app event. In the
weather example, a tick dispatches either Event::Refresh or
Event::Search { location }.
BackgroundEventSink lets any app request background events. The foreground app
uses immediate dispatch to change the target or suspend/resume work. The
background app uses dispatch_after to schedule its next tick.
The return value is only an acknowledgement that the host accepted the request. It is not a promise that the event has already been handled.
The Foreground App Owns Policy
The weather foreground app owns the policy that turns business events into background intent:
#![allow(unused)]
fn main() {
Event::Search { location } => {
let target = location.trim().to_owned();
if !target.is_empty() {
background_events
.dispatch(BackgroundEvent::TargetChanged(
RefreshTarget::ManualLocation(target),
))
.await;
}
self.search(location).await;
}
}
Other events follow the same rule:
Event::RefreshselectsRefreshTarget::CurrentLocation;Event::ClearselectsRefreshTarget::Disabled;Event::BackgroundeddispatchesBackgroundEvent::Suspend;Event::ForegroundeddispatchesBackgroundEvent::Resume;Event::Openeddoes not need a target change because the default target is current location.
This is domain behavior. A TUI, FFI host, or test should not need to duplicate the rule that a manual search changes future automatic refreshes.
No Inner Loops
The background app does not run an infinite loop:
#![allow(unused)]
fn main() {
loop {
sleep(interval).await;
refresh().await;
}
}
Instead, it handles one event at a time:
#![allow(unused)]
fn main() {
BackgroundEvent::RefreshTick { generation } => {
if let Some(target) = self.current_target_for_tick(generation) {
self.dispatch_target(target).await;
self.schedule_tick(generation).await;
}
}
}
Intervals are represented as self-scheduling one-shot events. The host owns sleeping. The background app owns the meaning of a tick.
This shape is easier to test because tests can inject RefreshTick directly.
They do not wait for real time to pass.
Stale Ticks
The weather background app uses an active flag and a generation counter. Every
start, resume, suspend, or stop increments the generation. A scheduled tick
contains the generation it was created with:
#![allow(unused)]
fn main() {
RefreshTick { generation }
}
When an old tick arrives after suspend/resume, it is ignored because its generation no longer matches. This is a small cancellation mechanism without task handles.
This plumbing is not business logic. It could eventually move into a helper crate, but keeping it in the example makes the pattern visible.
Host Runtime
The host wires queues and dispatchers. In the TUI example the host has:
EventQueue<UiEvent>
EventQueue<weather_app::Event>
EventQueue<BackgroundEvent>
The foreground dispatcher serializes local UI events and domain app events. The background dispatcher serializes background events:
ui queue -> TuiApp::update(UiEvent)
app queue -> TuiApp::update_domain(weather_app::Event)
background queue -> BackgroundApp::update(BackgroundEvent)
Delayed scheduling is a host adapter over the background queue:
#![allow(unused)]
fn main() {
async fn dispatch_after(&self, delay: Duration, event: BackgroundEvent) {
let queue = self.queue.clone();
self.handle.spawn(async move {
tokio::time::sleep(delay).await;
queue.dispatch(event);
});
}
}
A production host might use priority queues, cancellation handles, bounded channels, or a work-stealing executor. The app does not depend on those choices.
Testing
Tests should not sleep. They should inject events and record requested effects.
The background app tests drive BackgroundEvent directly:
#![allow(unused)]
fn main() {
app.update(BackgroundEvent::Started).await;
app.update(BackgroundEvent::RefreshTick { generation: 1 }).await;
assert_eq!(*app_events.events.lock(), vec![Event::Refresh]);
}
Foreground app tests record background requests:
#![allow(unused)]
fn main() {
app.update(Event::Search {
location: " Wroclaw ".to_owned(),
})
.await;
assert_eq!(
*background.events.lock(),
vec![BackgroundEvent::TargetChanged(
RefreshTarget::ManualLocation("Wroclaw".to_owned()),
)]
);
}
This gives deterministic tests for both directions:
- foreground app -> background event requests;
- background app -> foreground event requests;
- background app -> delayed self-scheduling requests.
The executor, timer, and queue implementation can be tested separately.
Same Rule For Other Inputs
This pattern is not only for timers. Database watchers, websocket clients, job queue consumers, and HTTP socket listeners can all use the same rule:
external activity -> typed event -> serialized update
If the external activity has its own state, model it as a small background app. If it is just an adapter, let the host enqueue a foreground event directly.
The important boundary stays the same: app state changes only by handling typed events, and time/concurrency remain host concerns.