docs(worker): record the SemaphoreSlim/STA dispatch invariant + single-waiter contract; harden fake

This commit is contained in:
Joseph Doherty
2026-08-15 17:13:17 -04:00
parent 25cbe5cd3e
commit 896d81e286
4 changed files with 47 additions and 4 deletions
@@ -68,6 +68,11 @@ public interface IWorkerRuntimeSession : IDisposable
/// no default interface members, so every implementation supplies it. The wait's outcome is
/// not surfaced: the caller re-drains and re-checks <see cref="DrainFault"/> after every
/// wait, because the signal is coalesced and the timeout is a ceiling, not a poll period.
/// <para>
/// Precondition: <b>at most one waiter</b>. The implementation's wake signal carries a
/// single permit, which is sufficient only because a session has exactly one event
/// drain loop; a second concurrent waiter would degrade to the fallback timeout.
/// </para>
/// </remarks>
/// <param name="timeout">Maximum time to wait before the wait completes unsignalled.</param>
/// <param name="cancellationToken">Token cancelling the wait.</param>
@@ -75,6 +75,13 @@ public sealed class MxAccessEventQueue
// waiter, no wait handle is ever materialized (nothing touches AvailableWaitHandle), and making
// the queue IDisposable would only add a lifecycle race between the STA's Enqueue and the
// drain loop's wait. WorkerFrameWriter's _writeLock is held the same way.
//
// It must STAY a SemaphoreSlim: on .NET Framework 4.8, Release() hands an async waiter to the
// thread pool (the waiter is a TaskNode : IThreadPoolWorkItem) rather than completing it
// inline, so the STA thread's Enqueue never runs the drain loop's continuation. Swapping this
// for a TaskCompletionSource — a refactor that looks cosmetic — would complete the waiter
// inline on the caller and put the drain loop's pipe write on the apartment thread, which is a
// real STA/COM reentrancy hazard (and stalls the message pump behind the write).
private readonly SemaphoreSlim eventSignal = new SemaphoreSlim(initialCount: 0, maxCount: 1);
private ulong lastEventSequence;
@@ -257,6 +264,13 @@ public sealed class MxAccessEventQueue
/// enqueues. The timeout is therefore a ceiling on how long the caller may sleep, not a
/// poll period: it bounds how late a state change reaches the caller if some future path
/// mutates the queue without signalling.
/// <para>
/// Precondition: <b>at most one waiter</b>. One permit is sufficient only because the
/// queue has exactly one consumer (the worker's single event drain loop). A second
/// concurrent waiter would find no permit left for it and degrade to the fallback
/// timeout, so a design that needs multiple drainers must widen the signal rather than
/// reuse this one.
/// </para>
/// </remarks>
/// <param name="timeout">Maximum time to wait before the wait completes unsignalled.</param>
/// <param name="cancellationToken">Token cancelling the wait.</param>