Skip to content

Threading and ConfigureAwait

Yasunobu edited this page Jun 26, 2026 · 1 revision

Threading & ConfigureAwait

This is the most subtle part of the codebase. Getting it right is what guarantees a held key/button is always released — even on shutdown or an unhandled exception.

The PressEngine thread contract

From PressEngine's documented contract:

  • Start / StopAsync / DisposeAsync / State are called from a single thread (the UI thread in practice). The engine holds no locks — it relies on this discipline instead.
  • StateChanged and Faulted may fire on any thread. Because Core uses ConfigureAwait(false) throughout, continuations run on the thread pool, so these events are not marshaled for you.

The consumer is responsible for marshaling. MainPageViewModel does exactly this: every engine and hotkey event is wrapped in _dispatcher.Post(...) to hop back onto the UI thread before touching observable state.

_hotkeys.Pressed     += id => _dispatcher.Post(() => OnHotkey(id));
_engine.StateChanged += s  => _dispatcher.Post(() => OnEngineState(s));
_engine.Faulted      += ex => _dispatcher.Post(async () => { await _engine.StopAsync(); ... });

The "Up" guarantee

The contract is: when StopAsync completes, any held key/button has already been released. Both modes implement this with try/finally, and StopAsync awaits the loop to completion:

// HoldAsync
_synthesizer.Press(target);
try     { await Task.Delay(Timeout.InfiniteTimeSpan, _timeProvider, ct).ConfigureAwait(false); }
finally { _synthesizer.Release(target); }   // released on cancel OR exception

// StopAsync
await _cts!.CancelAsync().ConfigureAwait(false);
await _loop!.ConfigureAwait(false);          // wait for finally's Release to run = the Up guarantee
Transition(EngineState.Idle.Instance);

If the loop dies on a synthesis exception, Faulted fires (the finally already released the input), and the owner calls StopAsync to return to Idle — the engine never transitions itself on fault.

Why CA2007=error in Core

KeepPressing.Core/.editorconfig:

[*.cs]
dotnet_diagnostic.CA2007.severity = error   # mechanically enforce ConfigureAwait(false)

Core must not depend on the caller's SynchronizationContext. Forcing ConfigureAwait(false) everywhere makes "the loop runs on the thread pool" a guaranteed fact rather than an accident. This is a precondition for the shutdown path below not to deadlock. (The rule is scoped to Core only — the UI layer deliberately keeps plain await so it resumes on the UI thread.)

Shutdown: two paths, both release input

Normal window close (App.OnWindowClosed) disposes the DI container asynchronously so PressEngine.DisposeAsyncStopAsync runs the finally and releases before the process exits:

e.Handled = true;
await ((IAsyncDisposable)_serviceProvider).DisposeAsync();

Unhandled exception (App.UnhandledException) is the last-resort net — it synchronously waits (.Wait(500)) for the engine to stop before the process dies:

UnhandledException += (_, _) => {
    try { _serviceProvider.GetRequiredService<PressEngine>().StopAsync().Wait(500); }
    catch { /* nothing more we can do on the way out */ }
};

That synchronous .Wait() is safe only because Core uses ConfigureAwait(false): the engine's continuations don't need the (now-blocked) UI thread, so there's no deadlock. This is the concrete reason CA2007=error is load-bearing rather than stylistic.

The one case this can't cover is a hard taskkill /F, where no cleanup runs at all — see Known Limitations #2.


Related: Domain Model & ADTs · Win32 Interop · Architecture

Clone this wiki locally