Skip to content

handoff: continuation affinity — ReactorSynchronizationContext spec + session findings index #104

Description

@MDA2AV

Handoff/tracking issue: an analysis session (2026-07-02, repo at cf2fce344eea6d4dc023745c4d4d9687a720a448) produced a set of findings and one designed-but-unimplemented feature. This issue indexes the findings and carries the full spec so anyone — human or agent — can pick the work up with no other context. If you are an agent continuing this: read this issue top to bottom, then start at "The deliverable".

What already landed (do not redo)

The open thread: continuation affinity (work stealing vs the tick)

Experiment: setting RunContinuationsAsynchronously = true on the connection value-task sources collapsed performance. Diagnosis — the inline tick is a transaction (reap CQE batch → run handlers on-reactor → their SQEs land via fast paths → one SubmitAndWait), and offloading continuations to the ThreadPool dissolves it into four separable costs:

  1. Submit-batch collapse — reactor finishes dispatch with an empty SQ, parks, then gets dribbled flushes ≈ one io_uring_enter per response.
  2. Eventfd storm — off-reactor EnqueueFlush/EnqueueReturnQ do one write(2) per item (coalesce eventfd wakes across all cross-thread queues (only _postQ coalesces today) #96; only _postQ coalesces).
  3. ThreadPool global-queue hop — the reactor is not a pool thread, so every continuation goes through the pool's global queue.
  4. Locality loss — connection state touched on whatever core stole the continuation.

Two hard-won conclusions:

  • A strict "wait for all woken handlers to enqueue before submitting" barrier is unsafe: a handler may never enqueue (foreign await, CPU work, completion), and may await something that requires the reactor to advance → deadlock. Any rendezvous must be deadline-bounded.
  • The right mechanism already exists: ScheduleOnReactor + DrainPostQ (Reactor.Post.cs). Loop order runs drained callbacks on-reactor before SubmitAndWait, so posted continuations regain batch coherence by construction, with a coalesced wake (_postSignalPending). The Kestrel bridge (ReactorPipeSchedulerScheduleOnReactor; HopDuplexPipe reader schedulers; ReactorPinReader first-read pin) already routes pipe continuations this way. The remaining gap: awaits that don't go through the pipes (HttpClient, EF, Task.Delay, Task.Run results) resume on the pool and drift until the next pipe/connection await.

Also note when benchmarking any off-thread mode: check for #93 (-ENOBUFS teardowns) polluting the numbers, and fix #96 first or the comparison indicts the wake path, not work stealing.

The deliverable: ReactorSynchronizationContext

Close the gap with a per-reactor SynchronizationContext (the roadmap's "BCL bridge" item in IoxideRuntime.cs). Background for humans: SynchronizationContext.Current is a per-thread ambient slot read by the C# await machinery — captured at the await point, and the completing thread calls capturedContext.Post(continuation) instead of running it locally. Installing one on the reactor thread makes every await (not just pipe ones) resume on the reactor, transitively, unless explicitly opted out via ConfigureAwait(false).

Implementation, file by file:

1. New ioxide/Reactor/ReactorSynchronizationContext.cs

public sealed class ReactorSynchronizationContext : SynchronizationContext
{
    private readonly Reactor _reactor;
    internal ReactorSynchronizationContext(Reactor reactor) => _reactor = reactor;
    public Reactor Reactor => _reactor;

    public override void Post(SendOrPostCallback d, object? state)
        => _reactor.ScheduleOnReactor(d, state);          // MUST always queue, never invoke inline

    public override void Send(SendOrPostCallback d, object? state)
    {
        if (_reactor.OnReactorThread) { d(state); return; }
        using var done = new ManualResetEventSlim();
        Exception? ex = null;
        _reactor.ScheduleOnReactor(_ => { try { d(state); } catch (Exception e) { ex = e; } finally { done.Set(); } }, null);
        done.Wait();
        if (ex is not null) System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ex).Throw();
    }

    public override SynchronizationContext CreateCopy() => this;
}

2. Reactor.Runner.cs Run() — install right after _reactorThreadId is set: SynchronizationContext.SetSynchronizationContext(new ReactorSynchronizationContext(this));. Thread-lifetime; nothing to uninstall.

3. Reactor.Post.cs — two changes:

  • Add a ScheduleOnReactor(SendOrPostCallback, object?) overload. SendOrPostCallback and Action<object?> have identical signatures but are distinct delegate types; converting allocates per post. Let PostItem hold a Delegate and type-test at invoke.
  • Harden DrainPostQ: wrap each callback in try/catch (log + count). async void exceptions are delivered by rethrow inside Posted callbacks; unhandled, one unwinds Run() and kills the reactor (same failure class as incremental: accept beyond MaxConnections crashes the reactor (Stack.Pop on empty gid stack) #92). This becomes mandatory the moment arbitrary continuations flow through the queue.

4. The hot-path trap — Connection.Read.cs and Connection.Write.Flush.cs IValueTaskSource.OnCompleted: ManualResetValueTaskSourceCore captures SynchronizationContext.Current when the awaiter passes UseSchedulingContext (awaits always do), and on SetResult it unconditionally Posts to a captured contextRunContinuationsAsynchronously = false only covers the null-context case. Once the context is installed, every ReadAsync/FlushAsync resume would silently become SetResult → Post → postQ → next-loop drain instead of an inline call. Fix: strip the flag before forwarding —

_readSignal.OnCompleted(continuation, state, _readSignal.Version,
    flags & ~ValueTaskSourceOnCompletedFlags.UseSchedulingContext);

Safe because these sources always complete on the owning reactor thread (the continuation already runs where the context would post it), and the thread-wide slot keeps downstream awaits captured. Keep FlowExecutionContext. Consider a config toggle to retain the posted mode: "inline vs posted-but-on-reactor" isolates pure queue-deferral cost with zero core migration — a useful benchmark point (see below).

5. ioxide.Kestrel — make context installation a transport option, default on. Rationale: ASP.NET Core normally runs context-free, so legacy sync-over-async middleware (.Result) merely risks pool starvation there; under a reactor context it hard-deadlocks that reactor (the loop is blocked, the mailbox never drains). Document the ban; keep the escape hatch. Optional cleanup: IoxideReactor.TryCurrent() can be reimplemented as SynchronizationContext.Current is ReactorSynchronizationContext r ? r.Reactor : null.

Document: ConfigureAwait(false) in handler code opts that await out of affinity (library-internal CA(false) is desirable — their plumbing stays off-reactor; the boundary await comes home). Pipes keep useSynchronizationContext: false.

Acceptance criteria

  • Smoke: a handler asserting reactor.OnReactorThread after await Task.Delay(1), after an HttpClient call, and after await Task.Run(...); an async void-throwing callback does not kill the reactor.
  • Perf gate: wrk plaintext (scripts/static-bench.sh) with context ON vs OFF for pure-ioxide handlers must be flat — the §4 strip is what guarantees this; if it regresses, the flag strip is missing or wrong.
  • Decomposition benchmark (feeds the HTTP Workshop 2026 talk — keep modes as toggles, don't delete alternate paths): (a) inline baseline, (b) posted-on-reactor mode (§4 toggle), (c) RCA=true ThreadPool mode raw, (d) ThreadPool mode + coalesce eventfd wakes across all cross-thread queues (only _postQ coalesces today) #96 wake coalescing. The (a)–(b) gap = queue discipline; (b)–(d) gap = migration + pool hop; (c)–(d) = the eventfd storm.

Notes for whoever picks this up

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions