Skip to content

Drain() can cancel pooled leaves that were accepted but still QUEUED for a slot — IoPoolTest grace-restart test failed 1 of 5 under CPU saturation #4541

Closed

Description

@rbuergi

What failed

IoPoolTest.Drain_restartsTheGraceOnEveryCompletion_SoABurstOfShortLeavesIsNeverCancelled (test/MeshWeaver.Hosting.Test/IoPoolTest.cs) failed once:

Expected value to be 3 … but found 1

That is the assertion completedLeaves.Should().Be(3, "every leaf finished on its own") — so two of three leaves that were subscribed before Drain() began did not complete.

Observed in a local run of #4533's branch on 2026-09-16. It passed in the four runs before it. It has not been re-run, deliberately.

🚨 This is unattributed, not a flake

Neither #4533's diff nor the probe edits made in that worktree can reach it — the test uses only Invoke and Drain. A test that fails on a diff that cannot reach it has a cause of its own.

The test, and how little room it has

var grace = TimeSpan.FromMilliseconds(500);
using var pool = new IoPool(1, IoPool.DefaultDrainTimeout, grace);   // ONE slot
// three leaves, each: await Task.Delay(300, ct)
await firstAdmitted.Should().Within(Timeout5).Emit();
var residual = pool.Drain();

With one slot the three 300 ms leaves run serially, so leaves 2 and 3 are queued waiting for the slot when Drain() starts. The grace restarts on each completion — so each queued leaf has about 200 ms (500 − 300) to be picked up and started, or the grace expires.

Condition at the time (measured)

The machine was CPU-saturated: load average ~29 on 18 cores, top reporting 79 % user + 10 % sys, 11 % idle, from seven concurrent sessions' Release builds and a local in-mesh tester container. Under that load, a ThreadPool work item can plausibly wait longer than 200 ms to start.

That is the condition, not the explanation. Load does not create a defect; it widens a window that is already there.

Probable cause — inferred from the code, NOT measured

Drain()'s grace is progress-based on the pool's gate users. A leaf that has been subscribed but is still queued for the slot — not yet started on the ThreadPool — appears to be invisible to that progress signal. If so, the drain treats "nothing has completed recently" as "stuck" and cancels work that was accepted before the drain began and was only waiting its turn.

If that reading is right, it conflicts with the principle #3291 established — teardown lets accepted work finish and names what it had to stop. A leaf queued for a slot is accepted work.

What is NOT the fix

  • Raising the grace, or lengthening the leaves' margin. That buys slack against load and leaves the question unanswered. A bound that holds only on an idle machine is a band-aid (AGENTS.md).
  • Re-running until it passes. It passed four times already; that is what hid it.

Suggested next step

Establish the mechanism by widening the window deterministically, not by sweeping: park a queued leaf's admission for longer than one grace (a seam, or a saturated ThreadPool), with no competing CPU, and assert whether Drain() cancels it. If it does, the decision is whether a queued-but-accepted leaf should count as progress for the grace, or be refused at the front door before it is accepted — either is coherent; cancelling it silently after acceptance is not.

Found while verifying #4533 (#4524). Related: #4530 (a refused leg terminating on the subscriber's thread — a different path), #3291, #4448.

Activity

  1. rbuergi commented on Sep 17, 2026

    @rbuergi
    ContributorAuthor

    Established, and the inference needs one correction — then fixed in #4556.

    Refuted: a queued leaf is not invisible to the progress signal. It has entered its gate region, so _gateUsers counts it.

    Established (worse): it is visible on the wrong side of the ledger. _gateUsers is a live census — it rises on an arrival exactly as far as it falls on a completion — and the grace measured progress as a fall below a baseline taken at the start of the drain. So an arrival cancels out a completion one for one, and the predicate cannot fire until every arrival has also finished: the per-completion grace silently becomes one total budget for the whole queue. In the repro the pool finishes four leaves inside the window and the drain calls it a stall.

    Reproduced deterministically on an idle machine (IoPoolDrainGraceTest), failing at 2s 001ms — the 2000 ms grace expiring to the millisecond. The ordering is structural, via a new drain seam, not arranged by load; the red is structural too (3 × 800 ms against a 2000 ms grace, and Task.Delay never fires early). Nothing was re-run to see, and no bound was widened.

    Design decision: of the two options this issue names, #4556 takes a queued-but-accepted leaf counts as progress for the grace — refusing work the pool has already accepted and is already running is what #3291 ruled out. Progress is now counted rather than inferred from the census. The cancel is unchanged, and a sibling control test proves a leaf that arrives and then wedges is still cancelled and still named.

    🚨 One window is deliberately left open, and it matters for reading this issue: #4555. Between Subscribe() returning and the ThreadPool running the prologue, an Invoke/InvokeStream leaf is counted by nothing at all. That is structurally evident but I did not measure it firing — and both mechanisms produce the identical symptom (Expected 3 … but found 1). So the original 1-in-5 sighting had two candidate causes and #4556 removes one of them. If that burst test fails again, #4555 is the suspect, not a regression of this fix.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions