Premise
The UI thread does no work
There is exactly one rule the UI thread has to obey: it does not block. Not on
I/O, not on a long computation, not on a lock somebody else might hold for a
while. Anything that violates that rule turns into "the window stopped
repainting", and "the window stopped repainting" is the one thing a desktop
application is not allowed to do.
Stating it as a rule is the easy part. The hard part is making it
structurally true — making it a property of the architecture, not a
case-by-case promise the developer has to remember. The architecture in
Dendrite is built so that violating the rule is harder than obeying it.
Layers
The three layers
- UI — receives input, draws frames, asks the orchestration layer to do work, displays whatever the orchestration layer publishes.
- Orchestration — owns the background-task system. Spawns work, tracks progress, publishes results, handles cancellation.
- Core — the algorithms and data: scan, tree, search, sort, comparison, export. No threading, no UI. Pure functions over inputs where possible.
The UI never calls core directly. The UI never spawns a task on its own. Both
of those rules collapse to: "the UI talks to orchestration; orchestration
talks to core". Two arrows, no cycles.
Channels
What's actually on the wires
Two kinds of channels:
- Commands — UI to orchestration. "Start a scan of
D:", "compare folder A to folder B", "export the current view to CSV". Every command is a value, not a closure.
- Events — orchestration to UI. "Scan started, id=42", "scan progress 17%", "scan finished, here's the tree handle", "task 42 cancelled".
The reason commands are values rather than callbacks is that values can be
queued, logged, replayed, dropped on shutdown, and inspected in the debugger.
Closures cannot. Once you give yourself enums on the wire instead of function
pointers, a lot of "where is this work coming from?" questions become trivial.
Redraw discipline
The redraw path is allowed to do nothing
The UI's job each frame is: drain the event channel, update view-model state,
draw. Drain is bounded: process a few events, schedule another frame if more
arrived. Draw is bounded: read view-model state, paint widgets, return.
Neither step is allowed to compute anything that wasn't already computed by the
time the event arrived.
That's it. The UI thread spends its life moving values from a queue into a
framebuffer. If a frame is slow, it's because there are too many events to
drain, not because the UI is doing somebody else's work.
Coalescing
Coalescing progress events
A scan emits progress events constantly. Naively, that means the event channel
fills with thousands of "0.13%, 0.14%, 0.15%…" updates per second, which the UI
has to drain. Two cheap fixes turn this into a non-problem:
- Coalesce on the producer side. Orchestration only sends a progress event if the value changed by more than a small threshold or a small time has passed. The UI never sees the noise.
- Coalesce on the consumer side. If the UI drains six progress events in one tick, only the last one matters; the in-between ones are discarded.
Vec::drain + last(), basically.
Both layers participate. Neither layer trusts the other to be correct on its
own. That redundancy is fine because both fixes are tiny.
Cancellation
Cancellation falls out of the same shape
Cancellation in this architecture is not a feature; it's a side effect. Each
background task gets a CancellationToken at spawn time. The
orchestration layer keeps the token alongside the task handle. "Cancel task
42" sets the token and walks away.
The task itself checks the token at every natural breakpoint — between
directory entries, between batches of metadata, between work units. If the
token is set, the task stops, drops its scratch state, and emits a
"cancelled" event. The UI sees the event and updates the visible state
accordingly.
No one ever blocks waiting for cancellation to complete. Cancellation is a
request, not a guarantee that anything has unwound by the time the call
returns. That mental model is the same one you use for any cooperative
cancellation system; the only thing this architecture adds is that nothing on
the UI thread cares about the unwind anyway.
Shared state
The one piece of shared state
The big tree from a finished scan is shared between core (read-only) and
orchestration (read-only) for derivation. The UI does not see the tree
directly; it sees view-model snapshots. The tree itself lives behind an
Arc with no Mutex on top — it's immutable once a
scan finishes, and immutable shared state needs no synchronization beyond the
refcount.
Mutable state is more carefully kept: per-task scratch space lives inside the
task's stack and is never shared; orchestration's task table is owned by
orchestration and reached only by message; the UI's view-model is owned by the
UI thread and is touched only by the UI thread. There is no situation where
two threads are racing to update the same field.
Practical tips
Practical tips that fall out
- Type your events. An
enum Event with a variant per kind is cheaper than it looks and pays back the first time you have to add a new event.
- Bound your channels. Unbounded channels hide bugs and cost memory. A bounded channel makes backpressure visible.
- Make IDs explicit. Every task has an id. Every event references the id. "Cancel" by id; "lookup result" by id; "log" by id. Anonymous tasks become unmanageable the moment you have two of them.
- Treat the UI like a renderer. If the UI ever has to compute something nontrivial in the redraw path, that's a sign the orchestration layer should have computed it for you.
Related
Related reading