This document proposes a better host loop for TerminalApp.RunCore.
The current implementation is intentionally simple:
Tick()Thread.Sleep(_options.UpdateWaitDuration)That design works, but it has two visible problems:
Thread.Sleep(1) is commonly limited by the system timer tick and can wake much later than requested.For a terminal UI framework we want something more deterministic, but still low-complexity and power-friendly.
The Thread.Yield() guidance in this document is an engineering recommendation inferred from the documented behavior of
.NET and Windows timing primitives, combined with common frame-pacing practice. Microsoft documents the primitive
semantics, but not a Terminal.UI-specific pacing policy.
This spec sits in the middle of several other framework mechanisms:
Tick() may trigger before render.The loop spec does not redefine those systems. It defines how quickly and predictably the host loop wakes up to run them.
This spec does not move UI work off the main/UI thread.
The following must continue to execute on the main/UI thread:
Tick()onUpdate callbackIf a background helper is introduced, it must be a passive wake proxy only:
This preserves the current single-threaded UI contract.
Post(...), render requests, and animation requests.BeginRun() / Tick() test hooks intact.timeBeginPeriod(1) or otherwise change system-wide timer resolution.Thread.Yield() / SpinWait() loop in the default path.XenoAtom.Terminal for V1.Thread.Sleep is only a minimum suspension request. On Windows, if the requested duration is below the system clock
resolution, the actual wake-up can land on a later scheduler tick. For UpdateWaitDuration = 1ms, that means the app
can feel much less responsive than intended, especially for:
The framework already uses Stopwatch timestamps for other timing-sensitive behavior, and animated visuals already
express their next desired update using absolute Stopwatch ticks (IAnimatedVisual.NextAnimationTick). The host loop
should use the same clock and schedule around absolute deadlines instead of repeated relative sleeps.
The proposed design has five parts:
Stopwatch as the canonical monotonic clock.Thread.Sleep(...) with an interruptible waiter that blocks until either:
CREATE_WAITABLE_TIMER_HIGH_RESOLUTION when supported.Thread.Yield() tail for the final sub-resolution window.Run path, and the RunAsync-style hosting paths that still execute the loop on the calling
thread, event-driven for input and async-update completion instead of pure polling.This keeps the implementation simple while removing the main source of jitter and unnecessary CPU wake-ups.
The current code has three distinct execution models and the spec should keep them separate:
Run():
RunAsync(CancellationToken) on TerminalApp:
Run(...) and returns Task.CompletedTask;BeginRun() / Tick() / EndRun():
There are also public Terminal.LiveAsync(...) / Terminal.RunAsync(...) hosting overloads. Their async aspect is the
update callback, not a separate UI thread. The loop itself still stays on the calling thread.
Therefore:
BeginRun() / Tick() deterministic tests,TerminalLoopMode applies to Run()-style host loops only, not to manual test stepping.Tick()Tick() should remain the unit that:
What changes is the wait before the next Tick().
Instead of always sleeping for a fixed duration, the loop computes the next wake deadline from its current state.
All scheduling decisions should be based on absolute Stopwatch timestamps:
_nextAnimationTicklastHeartbeat + heartbeatTicksnowWhen a deadline is reached, the next one should be computed from the previous scheduled time, not from "now", unless the loop is badly late and must resynchronize.
This avoids accumulated drift.
The spec intentionally separates two different concepts:
UpdateWaitDuration maps naturally to the first concept, not the second.
Therefore, UpdateWaitDuration should not be reinterpreted as "the app targets one frame every N milliseconds" or as an
implicit 60 FPS setting. It is better understood as a maximum coarse wait slice used by legacy/polling behavior.
If the framework later wants a public frame-cadence concept, it should be introduced explicitly with a separate option
such as TargetFrameInterval or MaxFrameRate.
The current code uses _nextAnimationTick = 0 as an internal sentinel meaning "wake immediately".
ComputeNextWakeDeadline(now) should preserve that contract:
_nextAnimationTick == 0 means return now,_nextAnimationTick == long.MaxValue means "no animation deadline pending".That keeps the existing RequestAnimation() behavior compatible with the new waiter design.
The loop needs a small explicit late-frame policy so it does not accumulate drift after an overrun.
Recommended policy:
Reasonable starting rule:
now - deadline > 2 * effectiveWaitResolution or now - deadline > 1 frame interval for animation-driven waits,
snap the next deadline to now.The exact threshold can be tuned later, but the spec should require explicit late-frame handling instead of letting drift grow silently.
The blocking run loop needs a synchronous wake handle that can interrupt a timed wait.
Recommended internal additions:
_wakeUp: AsyncAutoResetEvent for existing async-friendly wake behavior,_wakeSignal: AutoResetEvent or equivalent synchronous waitable primitive used only by the blocking run loop.The following should signal both wake mechanisms through a shared helper (for example SignalWakeUp()):
Post(...)RequestRender()RequestAnimation()The spec intentionally keeps both:
_wakeUp continues to serve existing async/cooperative paths,_wakeSignal gives the blocking wait code a real handle that can participate in kernel waits.The new handle is therefore a second wake channel, not a replacement.
Conceptual shape:
BeginRunCore(...);
StartInputRelayIfNeeded();
while (!token.IsCancellationRequested)
{
Tick();
var now = Stopwatch.GetTimestamp();
var deadline = ComputeNextWakeDeadline(now);
if (deadline <= now)
{
continue;
}
_waiter.WaitUntil(deadline, _wakeSignal, token);
}
ComputeNextWakeDeadline(now) returns the earliest of:
now if there is already work pending,In Polling mode, the coarse wait should be sliced:
min(deadline - now, UpdateWaitDuration),That preserves compatibility for callers that expect periodic update heartbeats, while still avoiding the drift of a pure relative-sleep loop.
Today Tick() calls _terminal.TryReadEvent(...) directly, so the blocking loop must poll to notice new input.
For the blocking run path, the recommended design is to add a lightweight background input relay:
_terminal.ReadEventAsync(token) on a background task.TerminalApp.Tick() drains that queue instead of depending on fixed-interval polling.Important:
Tick() on the main thread.BeginRun() / Tick() used by deterministic tests remain synchronous and continue to operate without background waits.This gives responsive input without requiring a 1 ms polling loop.
Resize should continue to be treated as a regular terminal event from the app's point of view.
In the sibling XenoAtom.Terminal implementation, both Windows and Unix backends publish TerminalResizeEvent into
the same event stream consumed by ReadEventAsync(...). Platform-specific detection remains a backend concern; the UI
loop only needs to wake when a resize event is enqueued.
The loop design applies to both fullscreen and inline interactive hosting, but inline mode has two practical differences:
_updateOutputBuilder and flush it before the next render.Therefore, the spec requires:
CaptureFlowOutput behavior of Post(...),The input relay, if used, may still be shared because it only enqueues raw terminal events and wakes the UI thread.
The current async update model stores _pendingUpdateTask and polls it each tick.
That should be improved in the same style:
_pendingUpdateTask is created, attach a continuation on TaskScheduler.Default that signals the shared wake
helper when the task completes.DispatcherSynchronizationContext, Dispatcher.Post(...) already wakes the
app; the completion continuation is still useful as a fallback for tasks that complete off-context.This removes another source of pointless polling.
TerminalLoopResult interactionThe wake-on-completion continuation is only responsible for making the UI loop observe task completion promptly.
It must not interpret the result off-thread.
The required flow is:
_pendingUpdateTask completes.Tick() runs on the UI thread.Tick() reads the TerminalLoopResult.Stop or StopAndKeepVisual, the existing shutdown behavior runs on the UI thread:
This keeps loop-exit behavior deterministic and single-threaded.
The default implementation should prioritize:
It should not rely on a permanent spin loop, but it may use a very small and adaptive Thread.Yield() tail when the
remaining time is below the effective resolution of the blocking wait backend.
For a TUI, the right default is:
SpinWait,Thread.Yield() only in the final short interval where a blocking wait is likely to overshoot.The framework should not enforce a single universal frame cadence for all situations.
Recommended default policy:
15 ms per frame (~66.7 Hz)33.333 ms per frame (~30 Hz)This should be interpreted as a ceiling for framework-driven pacing, not as a guarantee that every frame is rendered at those intervals.
The active cadence budget applies to the whole loop iteration, not only to the wait after user code completes. If
onUpdate, layout, or rendering already consumed part of the 15 ms budget, the remaining wait should shrink
accordingly.
These values are soft timing targets only.
If user code, layout, rendering, terminal I/O, or OS scheduling causes a tick to take longer than 15 ms or
33.333 ms, the loop must continue correctly:
Why this is the recommended default:
15 ms cap gives the loop a small amount of headroom so real-world wait overshoot still tends to land
closer to the user-visible ~60-61 FPS range.~30 Hz is often sufficient for spinners, progress indicators, and less critical motion while reducing wake-ups.The codebase already has a stronger primitive than a blanket FPS loop: IAnimatedVisual.NextAnimationTick.
That should remain the primary source of timing for animation:
This keeps the loop both more efficient and more correct:
The loop must tolerate overruns gracefully.
Examples of overruns:
onUpdate callback takes longer than the target cadence,Required behavior:
In practice, this means:
For V1:
Thread.SpinWait() in the regular loop,Thread.Yield() loop,Thread.Yield() phase for the final short interval near the deadline.This is a better fit than SpinWait for Terminal.UI:
SpinWait burns CPU cycles continuously.Thread.Yield() gives the scheduler a chance to run another ready thread and does not force a busy spin.The yield phase should not use a hardcoded constant only. It should be derived from the observed behavior of the current backend.
Recommended internal state:
overshoot = actualWakeTimestamp - requestedDeadlineRecommended derived values:
effectiveWaitResolution
yieldWindow
max(minYieldWindow, effectiveWaitResolution)Reasonable starting bounds:
minYieldWindow = 250usmaxYieldWindow = 2msBootstrap rule:
maxYieldWindow and converge downward.This is preferable to starting too small because early frames should fail safe toward responsiveness rather than toward coarse overshoot.
This keeps the system simple:
When remaining <= yieldWindow, the waiter should stop scheduling another blocking wait and enter a bounded yield phase:
while (Stopwatch.GetTimestamp() < deadline)
{
if (_wakeSignal.WaitOne(0))
{
break;
}
Thread.Yield();
}
Policy notes:
yieldWindow,SpinWait fallback is used.If profiling later shows that some platforms need a more explicit bound, the implementation can cap consecutive yields or
fall back to WaitOne(0) plus loop exit once the deadline is very near. That refinement is optional; the key rule is
that the yield phase stays short and adaptive.
On Windows 10 version 1803 and later, the framework should prefer a reusable waitable timer created with
CREATE_WAITABLE_TIMER_HIGH_RESOLUTION.
Recommended shape:
_wakeSignal,EndRunCore().Implementation notes:
CreateWaitableTimerExW(..., CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, ...).SetWaitableTimerEx or SetWaitableTimer with a relative due time computed from the remaining Stopwatch ticks.WaitForMultipleObjects or WaitForMultipleObjectsEx to wait on the timer handle and _wakeSignal together.Per project guidance, the Windows interop surface should remain AOT/trimmer-friendly.
Recommended implementation shape:
LibraryImport-based source-generated P/Invoke declarations,Why this path is recommended:
Sleep(1) on supported Windows versions,Thread.Yield() window, it avoids both coarse overshoot and SpinWait.On macOS/Linux, the implementation can stay simpler:
_wakeSignal with that timeout,This is expected to behave acceptably on Unix-like systems.
The same adaptive final-yield policy should still apply:
Thread.Yield() only in the final short interval,SpinWait by default.The loop change should be measurable.
DebugOverlayMetrics already tracks per-tick and per-render timings. The new loop should extend observability with:
These metrics are useful for:
Not every internal timing datum belongs in the real-time debug overlay.
At terminal refresh/update rates, a human can only read stable, aggregated indicators. Per-wake values such as the last exact wake reason or the last raw overshoot are too noisy and change too quickly to be meaningful on-screen.
Therefore the spec distinguishes two observability layers:
The real-time overlay should favor a few aggregated values over many rapidly changing raw samples.
Recommended display metrics:
Auto / PollingWindowsHighResolutionTimer, WindowsWaitableTimer, TimeoutWaitThese are readable because they answer human questions like:
The following are still useful internally, but should not be shown prominently in the live overlay unless a deeper diagnostic mode is enabled:
Those values are too volatile to be reliably read by a human and tend to create visual noise rather than insight.
For the default overlay, present loop metrics as a compact "health summary", for example:
If a deeper diagnostic mode is added later, it can expose raw last-sample values and more detailed counters separately without crowding the default overlay.
The current public option is:
UpdateWaitDurationThat property describes a polling sleep interval, which is no longer the right mental model once the loop becomes deadline-based and event-driven.
Recommended API direction:
UpdateWaitDuration for backward compatibility,Suggested shape:
public enum TerminalLoopMode
{
Auto, // default
Polling, // legacy behavior
}
Behavior:
Auto:
UpdateWaitDuration is not used as a frame cadence control.Polling:
UpdateWaitDuration as the maximum coarse wait slice between re-evaluations,Thread.Sleep.Why this split is useful:
onUpdate calls.This enum intentionally does not model the deterministic BeginRun() / Tick() test path. Manual test stepping
stays as an internal explicit mode outside the public host-loop options.
Some current samples increment state once per onUpdate call. Those samples implicitly depend on a polling loop.
Once Auto becomes the default, those scenarios should be rewritten to use one of:
IAnimatedVisual with explicit next-tick scheduling,TerminalRunningContext.Timestamp,TerminalLoopMode.Polling for legacy behavior.This is a worthwhile change because "one increment per host tick" is not a stable contract across machines anyway.
Thread.Sleep(...) with absolute-deadline waiting.Run() / RunAsync()._pendingUpdateTask.TerminalLoopMode.Auto publicly.UpdateWaitDuration once the new model is established.The implementation should be covered by tests for:
Post(...),RequestRender() / RequestAnimation(),BeginRun() / Tick() behavior remaining deterministic and sleep-free.Where practical, timing math should be tested behind small abstractions rather than by depending on real wall-clock delays.
Recommended abstractions:
ITimeSource
GetTimestamp() and FrequencyIWaitBackend
WaitUntil(deadline, wakeReasonToken, cancellationToken)That enables tests to:
Adopt a deadline-based, interruptible host loop with a two-phase wait and no default busy-spin behavior.
Concretely:
Stopwatch as the canonical clock,Thread.Sleep(...),Thread.Yield() window based on observed wait accuracy,This is simple enough to maintain, materially improves Windows responsiveness, and gives Terminal.UI better defaults for animations, resize handling, and general UI responsiveness.