You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
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
vargrace=TimeSpan.FromMilliseconds(500);usingvarpool=newIoPool(1,IoPool.DefaultDrainTimeout,grace);// ONE slot// three leaves, each: await Task.Delay(300, ct)awaitfirstAdmitted.Should().Within(Timeout5).Emit();varresidual=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.
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.
Activity
rbuergi commentedon Sep 17, 2026
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
_gateUserscounts it.Established (worse): it is visible on the wrong side of the ledger.
_gateUsersis 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 at2s 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, andTask.Delaynever 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, anInvoke/InvokeStreamleaf 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.