Skip to content

A nested DoInvokes() can't run the item the outer pump has already dequeued, so "pump until the worker finishes" hangs (regression from #80 / f81899e) #85

Open

Description

@matt-edmondson

What's wrong

Since f81899e ("Run InvokeAsync and TryBeginInvoke work in submission order", #80), DoInvokes() takes the next item from each queue before running the current one (Invoker/Invoker.cs:289-314):

bool haveAction = TryTakeAction(ref beginInvokeBudget, out nextAction);
bool haveTask   = TryTakeTask(ref taskBudget, out nextTask);
while (haveAction || haveTask)
{
    if (haveAction && (...))
    {
        nextAction.Action();                                           // runs A
        haveAction = TryTakeAction(ref beginInvokeBudget, out nextAction);
    }
    ...

While action A runs, the head of the other queue (e.g. an Invoke task X) has already been dequeued into the outer pump's local nextTask. It's in neither queue. If A calls invoker.DoInvokes() itself (a DoEvents-style "pump until X is done" loop on the owner thread), the inner pump can't see X, so X can't run until A returns. A is waiting for X, so it never returns.

Repro (net10.0, HEAD 6ca1060)

var invoker = new Invoker(); bool workerDone = false;
invoker.TryBeginInvoke(() => {                       // A, queued first
    var sw = Stopwatch.StartNew();
    while (!Volatile.Read(ref workerDone)) {
        invoker.DoInvokes();
        if (sw.ElapsedMilliseconds > 3000) { Console.WriteLine("A: TIMED OUT - X never ran"); return; }
        Thread.Sleep(1);
    }
    Console.WriteLine("A: worker finished");
});
new Thread(() => { invoker.Invoke(() => Console.WriteLine("X: ran on owner")); workerDone = true; }).Start();
Thread.Sleep(200);           // X is now queued behind A
invoker.DoInvokes();         // owner thread

At HEAD:

A: TIMED OUT - X never ran
X: ran on owner

Without the timeout, A spins forever and the worker stays blocked in Invoke forever.

With the pre-#80 Invoker.cs/BoundedMpscQueue.cs (git show f81899e^:...), the same program prints X: ran on owner then A: worker finished.

The reverse case hangs the same way: an Invoke delegate that pumps while waiting on a later TryBeginInvoke action. Nested pumps also run later items before the one the outer pump is holding, which breaks the submission order #80 was meant to guarantee.

Why it matters

Pumping DoInvokes() from inside owner-thread work while waiting for a background result is a common pattern, for example a modal loop or a synchronous wait in UI code. It worked before #80 and now deadlocks whenever the awaited item has a higher sequence number than the running item and sits in the other queue.

Suggested fix

Don't dequeue ahead of time. Each iteration, peek at both heads, dequeue only the one with the lower number, and run it straight away:

  • ConcurrentQueue.TryPeek for the task queue
  • a TryPeek on BoundedMpscQueue that reads the cell when seq == pos + 1

Charge the per-queue budgets per item, as now.

Acceptance: a test where a TryBeginInvoke action pumps DoInvokes() until an earlier-queued-from-another-thread Invoke completes finishes promptly, and the mirror case (an Invoke delegate pumping for a TryBeginInvoke action) does too. Existing ordering tests from #60/#80 still pass.

Related, but a different problem: #83 (the budgets are read at different moments).

Activity

  1. matt-edmondson commented on Oct 29, 2026

    @matt-edmondson
    ContributorAuthor

    Triage


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions