Invocation and Event Threads
This page explains the invocation system in the UI layer and the
EventThread helper.
It is written for contributors who need to extend, debug, or reason about cross-thread work delivery in the UI system without reverse-engineering the code from scratch.
After reading this page, you should be able to answer:
how
Application::invoke()reaches the UI threadhow
EventThreadreuses the same event transportwhat the difference is between
quit()andabort()how delayed invocations are stored and cancelled
how cooperative stop tokens are installed and requested
how
Application::createEventThread()tracks, prunes, and shuts down managed worker threads
This page complements Action Scheduling. That page explains surface-local scheduled UI callbacks. This page explains generic invocation delivery and reusable background event loops.
Relevant Source Files
You will find the implementation primarily in:
src/erbsland/cterm/ui/Application.hppsrc/erbsland/cterm/ui/Application.cppsrc/erbsland/cterm/ui/event/EventThread.hppsrc/erbsland/cterm/ui/event/EventThread.cppsrc/erbsland/cterm/ui/event/EventDriver.hppsrc/erbsland/cterm/ui/event/EventDriver.cppsrc/erbsland/cterm/ui/event/EventScheduler.hppsrc/erbsland/cterm/ui/event/EventScheduler.cppsrc/erbsland/cterm/ui/event/EventType.hppsrc/erbsland/cterm/ui/event/impl/InvocationEvent.hpptest/unittest/src/ui/event/UiApplicationInvokeTest.cpptest/unittest/src/ui/event/EventThreadTest.cpptest/unittest/src/ui/surface/UiSchedulerTest.cpp
Why This System Exists
The UI layer already had one important rule:
All UI work must be serialized on the application thread.
That rule is easy to keep for:
key input
surface-local scheduled actions
display updates
but it becomes harder once background work enters the picture.
Typical needs are:
a worker thread wants to hand a finished result back to the UI
a helper thread wants its own tiny event loop without exposing raw threading mechanics
application shutdown must stop these background workers in a predictable way
long-running worker callbacks should be able to stop cooperatively
The design goal was therefore:
Do not invent a second callback-delivery model. Reuse the existing event infrastructure and make background work feel like a natural extension of it.
High-Level Picture
There are two public entry points into the invocation system:
Application::invoke(fn)EventThread::invoke(fn)andEventThread::invokeDelayed(fn, delay)
Both use the same event type and the same internal payload, but they do not share one physical queue. Each owner feeds its own event loop.
The shared transport matters because it gives all three entry paths the same core behavior:
work is queued as an event, not executed directly by the caller
delivery is serialized by one event loop
delayed work is handled by
EventSchedulershutdown can be expressed as event-loop control rather than as ad-hoc thread interruption
Application::invoke()
What It Promises
Application::invoke(fn) is the generic way to marshal arbitrary work back onto the UI thread.
It is valid only while the application event system is active:
the application must already be running
shutdown must not have been requested
the event driver and scheduler must still exist
Outside that window it throws std::logic_error.
Why That Restriction Exists
The method is intentionally strict because a queued callback is only meaningful while the UI event loop still exists.
If the application already requested shutdown:
a newly queued callback might land behind the queued quit event
it would then never be executed
silently accepting it would create confusing race-dependent behavior
The implementation therefore treats this as programmer error instead of best-effort work submission.
Execution Path
The full delivery path is:
There is no extra dispatcher layer beyond the normal event loop.
Application Event Loop Integration
The application loop follows this shape:
while not abort requested:
poll terminal resize
poll scheduled UI events
drain queued events
poll render work
timeout = calculateInputTimeout()
key = readKey(timeout)
if key is valid:
queue KeyPress event
Two details matter here:
calculateInputTimeout()returns0mswhen work is already duereadKey(0ms)is used directly as the non-blocking poll
Visual Timeline
t0: background thread queues Application::invoke(...)
t1: application loop reaches EventDriver::processEvents(0ms)
t2: invocation event is dispatched
t3: callback runs on the UI thread
The key point is:
The callback is not run “from the posting thread”. It is run at the point where the application loop reaches queued event dispatch.
Note
readKey(0ms) made the loop logic simpler and more explicit.
It did not add an operating-system level wake-up primitive for a readKey(timeout)
call that is already waiting with a non-zero timeout.
In practice, a newly posted application invocation is still observed on the next loop cycle.
EventThread
What It Is
EventThread is a small convenience wrapper around:
one
EventDriverone
EventSchedulerone detached worker thread
one stop token for the currently running callback
The intention is:
Give library users a ready-to-use worker event loop without making them build queueing, delayed delivery, stop-token handling, and shutdown coordination themselves.
What It Is Not
EventThread is not:
a general-purpose thread pool
a preemptive interruption mechanism
a timer wheel with arbitrary cancellation handles
a second UI thread
It is a single-threaded event loop with one queue and a small delayed-event scheduler.
Construction Model
Instances are created with EventThread::create().
Internally, the object is started like this:
That last step is important.
It means the worker object stays alive even if the caller drops its last external shared_ptr
while the worker loop is still running.
Worker Loop
The worker loop is intentionally small:
poll delayed events into EventDriver
process queued events one by one
if abort requested:
stop immediately
otherwise:
wait for wake-up or next delayed event
In slightly more detail:
Why It Processes One Event at a Time
This is one of the most important design choices in the whole class.
EventThread does not drain the entire queue in one go.
It processes one queued event, then checks again whether it should continue.
That is what makes this possible:
callback A is running
callback B is already queued
abort() is called
callback A returns
callback B is skipped
Without single-event stepping, abort() would only be able to stop between large queue-drain batches.
Delayed Invocations
invokeDelayed(...) stores an Invocation event in EventScheduler.
The event does not enter the immediate queue until its due time arrives:
This is deliberately the same model used by surface action scheduling.
Wait Slicing
The worker loop does not sleep for the full remaining delayed interval in one go. It clamps each wait slice to a short maximum interval.
That helps in two ways:
a newly queued earlier event is noticed promptly
tests that override the internal event clock can still advance time deterministically
The slice limit is 5ms.
Stop Tokens
Per-Invocation Stop Source
Before each callback is executed, EventThread creates a fresh std::stop_source:
That means stop requests are scoped to the currently executing callback.
They are not a permanent thread-global cancellation state.
What quit() Does
quit() requests graceful shutdown.
Its steps are:
mark quit requested
cancel all delayed future work
request stop on the active callback
enqueue a Quit event at the tail of the immediate queue
wake the worker
The result is:
the current callback may finish cooperatively
already queued immediate callbacks before the quit event are still processed
delayed callbacks that were still waiting in
EventSchedulerare discarded
This is the key graceful-shutdown guarantee:
Drain work that is already in the immediate queue. Do not keep waiting for work that has not become due yet.
What abort() Does
abort() requests immediate shutdown.
Its steps are:
mark abort requested
cancel all delayed future work
request stop on the active callback
wake the worker
The important difference is:
There is no queued quit event.
Instead, the loop stops as soon as it notices the abort flag. That means queued callbacks that have not started yet are skipped.
Comparison Table
Operation |
Active callback |
Immediate queued callbacks |
Delayed not-yet-due callbacks |
Exit style |
|---|---|---|---|---|
|
Asked to stop cooperatively |
Drained until queued quit event is reached |
Cancelled |
graceful |
|
Asked to stop cooperatively |
skipped after the current callback returns |
Cancelled |
immediate-best-effort |
Important
Neither quit() nor abort() can forcibly terminate arbitrary C++ code in the middle of execution.
A long-running callback must check its stop token if it wants to react quickly.
Failure Handling
If a worker callback throws:
the exception escapes normal event dispatch
the worker loop catches it
the first
std::exception_ptris storedthe thread stops processing further work
waitForQuit()andquitAndWait()rethrow that stored exception once the worker has finished
That gives contributors and users one clear rule:
If waitForQuit() throws, the worker callback failed.
This is intentionally stricter than printing and continuing. Once a callback threw, the worker thread is considered failed.
Application-Managed Event Threads
What createEventThread() Adds
Application::createEventThread() is more than a convenience factory.
It adds lifecycle management on top of EventThread::create().
The application:
creates the thread
registers a finished callback that prunes the management list
stores a weak reference in the managed-thread registry
Why Weak References Are Enough
The registry stores weak_ptr<EventThread> entries, not owning shared_ptr entries.
That works because the detached worker thread already keeps the object alive through the startup shared_ptr capture.
This gives the registry two nice properties:
it can still lock and stop live workers during shutdown
finished workers naturally disappear once no strong reference remains
The finished callback then removes stale entries promptly so short-lived workers do not accumulate in the registry.
Visual Lifecycle
Managed Shutdown
During Application::shutdown(),
the application performs this sequence:
The shutdown timeout is intentionally shared across the whole group. It is not “timeout per thread”.
That keeps shutdown bounded even if many managed threads exist.
Warnings
Two situations generate developer-facing warnings on std::cerr:
a managed thread does not finish before the deadline
a managed thread already failed and rethrows during
waitForQuit()
This is not meant to recover silently. It is a signal to the developer that the worker callback ignored cooperative shutdown or that worker code failed outright.
How This Relates to Surface Scheduling
It is useful to compare the two systems side by side:
Topic |
Surface scheduling |
Invocation / EventThread |
|---|---|---|
Public owner |
|
|
Typical use |
UI-local delayed or repeated work |
generic cross-thread work delivery |
Execution thread |
application UI thread |
UI thread or dedicated worker thread |
Cancellation model |
action refs + generations |
thread-level |
Delayed transport |
|
|
This is why both pages belong in the same chapter:
They are two related applications of the same core event infrastructure.
Debugging Checklist
If you need to debug this system, start with these questions:
Callback never runs
Check:
was the application or event thread already stopping when the work was queued?
for delayed work: was it cleared by
quit()orabort()before it became due?for application work: is the UI event loop still running?
Callback runs on the wrong thread
Check:
did the code call the callback directly instead of using
invoke(...)?is the observed path
Application::invoke()orEventThread::invoke()?
waitForQuit() throws
Check:
which callback threw?
did the exception happen in the currently running invocation rather than in shutdown logic?
Shutdown warning appears
Check:
did the callback ignore
stop_token.stop_requested()?did it block in external code without any cooperative exit path?
did it enqueue delayed work and expect that work to survive
quit()?
Worker seems idle but delayed work does not fire
Check:
was the event scheduled beyond the scheduler limits?
is the internal event clock overridden in tests?
if you are debugging tests, remember that the worker loop waits in short slices rather than one long blocking wait
Summary
The main design idea is simple:
Use one event transport model for both UI-thread invocation and background worker loops.
Everything else follows from that choice:
Application::invoke()marshals work back onto the UI threadEventThreadprovides a reusable worker event loopEventSchedulerhandles delayed delivery in both systemsstop tokens provide cooperative early exit for running worker callbacks
managed worker threads integrate into application shutdown instead of floating outside it
If you keep that picture in mind, the code becomes much easier to navigate:
events carry work, owners decide where the work runs, and shutdown is expressed as event-loop control instead of ad-hoc thread tricks.