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
LiveQueryHandoffDropTest fails post-warmup-fix (e2080c4fa) — new information per #2338's own ask #2377
MeshWeaver.Hosting.Test.LiveQueryHandoffDropTest.A_notification_snapshotted_before_Initial_and_delivered_after_still_reaches_the_query failed twice in a row on shard 3 of an unrelated PR — after the WarmUpJitAndTypeLoad cold-JIT fix (e2080c4fa, closing #2338/#2319) was already on the branch.
Per the closing comment on #2338: "If this test flakes again after e2080c4fa, that would be new information and worth a fresh ticket with the run link — the warm-up removes the known cost path, so a post-fix failure would not have this explanation." Filing that ticket.
Evidence this is unrelated to the PR
PR: #2366 (fix/2342-ai-internalsvisibleto) — the diff is exactly two files: src/MeshWeaver.AI/MeshWeaver.AI.csproj (one added <InternalsVisibleTo> item) and the deletion of src/MeshWeaver.AI/InternalsVisibleTo.cs. No runtime code touched at all — build-metadata only. It cannot plausibly affect a live-query-handoff race in MeshWeaver.Hosting.Test.
MeshWeaver.Hosting.Test.LiveQueryHandoffDropTest.A_notification_snapshotted_before_Initial_and_delivered_after_still_reaches_the_query [FAIL]
warm-up: Initial never arrived — the environment is too degraded to even JIT-prime this pipeline, so the gated assertions below cannot be trusted either
at MeshWeaver.Hosting.Test.LiveQueryHandoffDropTest.WarmUpJitAndTypeLoad()
at MeshWeaver.Hosting.Test.LiveQueryHandoffDropTest.A_notification_snapshotted_before_Initial_and_delivered_after_still_reaches_the_query()
This is WarmUpJitAndTypeLoad's own designed fail-fast for a degraded runner — not the handoff race #2337 fixed, and not (by the test's own docstring reasoning, reaffirmed in #2338) a defect its failure would diagnose. Both occurrences were on shard 3, ~18 minutes apart, while ~20 other PRs were open/running concurrently — consistent with runner resource contention exceeding even the warm-up's own budget, rather than a logic regression.
What I did, per repo policy (AGENTS.md: don't re-sample a flake on a stale branch)
Rather than a second manual rerun of the identical commit, merged current origin/main into the branch (clean, no conflicts; git diff origin/main...HEAD --stat shows only the two intended files) to get a fresh CI attempt on a new commit instead of hammering the same one.
Ask
If this keeps recurring, WarmUpJitAndTypeLoad's own timeout budget may need to be measured against current CI concurrency levels (more parallel PRs now than when e2080c4fa was tuned) — but that's a hypothesis, not a diagnosis; flagging per the "no band-aids" policy rather than raising the budget myself.
Consolidating #2319 (which I reopened on the same run, minutes apart) into this ticket. Three findings from that analysis, carried over:
The branch was not stale.fix/2342-ai-internalsvisibleto contains BOTH c4542d149 (the warm-up) and its review follow-up c65ba3936, and was 0 commits behind main. So this is the fixed code failing, not a re-sample of the pre-fix flake on an old branch.
The failure is inside the warm-up itself — the phase that exists to absorb cold-JIT cost. It cannot be explained by the thing it was built to absorb.
The 30 s budget is against an empty InMemoryStorageAdapter, and the empty-result path emits Initial unconditionally. JIT of an Rx pipeline does not take 30 s. So Initial was not slow — it did not arrive at all.
What the earlier investigation established still stands: an instrumented 400-iteration in-process loop at DOTNET_PROCESSOR_COUNT=4 failed only ever on iteration 0. That is real and it is why the warm-up helped. But that loop was structurally blind to any cause that only manifests in a cold process or via cross-test state — it could only ever observe causes that reproduce in a warm process. So it never ruled this out.
Unproven hypothesis, offered for someone to disprove rather than adopt: the Initial read is offloaded through IIoPool, and with no IoPoolRegistry in this test the provider falls back to IoPool.Unbounded (thread pool), with a further Scheduler.Default.Schedule hop at StorageAdapterMeshQueryProvider.cs:1440. MeshWeaver.Hosting.Test runs 232 tests in one process, and this class deliberately parks a thread in its gated scenario. Worth checking specifically: does that scenario release the parked thread and dispose its subscription on every exit path, including when an assertion fails? A leak there would poison whichever test runs next — and would look exactly like "too degraded to JIT".
That the rerun reproduced it identically on the same runner is a useful signal: it points away from a random race and toward something about that process/runner state.
🚨 Operational note: the fail-fast in c65ba3936 was correct (a silently-skipped warm-up lets the gated assertions run cold and misreport their own timeout as a dropped row), but it means a warm-up miss now fails the shard — so this test can red an unrelated PR, as it did to #2366 whose diff is two InternalsVisibleTo lines.
An agent is on this now with instructions to reproduce the way CI runs it (whole assembly, one cold process, DOTNET_PROCESSOR_COUNT=4 — an isolated single-test run discriminates nothing), name the exact mechanism, and fix the root cause. Explicitly NOT to raise the timeout and NOT to weaken the fail-fast.
Root-caused and fixed in #2389. It is a framework bug, and it reaches production.
Reproduced first, then diagnosed
The condition is cold process + whole assembly + a real 4-CPU budget. A Linux container at --cpus 4 with DOTNET_PROCESSOR_COUNT=4, running the whole MeshWeaver.Hosting.Test assembly
cold: 3 failures in 13 runs (~23%). It never reproduced on this Mac even at DOTNET_PROCESSOR_COUNT=4, because that only sets a number — the container's cgroup quota makes
threads genuinely contend.
The runner was not degraded. From the failing shard's own trx: the test consumed exactly 00:00:30.017 — its whole budget — while the other 231 tests took 6.8 s, against 5.8 s locally.
And the two attempts on that run failed at positions 75 and 158, with different neighbours, so it
was never adjacent-test pollution either.
The mechanism
Instrumenting the pipeline showed Subscribe returning having never walked — no error, no
completion — and Initial still absent after a further 15 s. The chain stopped at walkPairs.ToObservable().
The parameterless IEnumerable.ToObservable() defaults to SchedulerDefaults.Iteration = CurrentThreadScheduler, which does not mean "run it here now". It keeps a [ThreadStatic] bool for "a trampoline is already running on this thread"; while that is set, Scheduleonly enqueues, leaving the item for whoever owns the outer trampoline to drain.
Capturing Environment.StackTrace at the point of failure gave the owner — 558 frames, bottom-up:
MessageService.DrainOne() // the hub's own pump, on a ThreadPool thread
-> Producer.SubscribeRaw
-> CurrentThreadScheduler.Schedule // trampoline OPENED here; the flag is now set
-> ... ~500 Rx frames ...
-> ToTaskObserver.OnCompleted -> TaskCompletionSource.TrySetResult // a .ToTask() resolves
-> AwaitTaskContinuation.RunOrScheduleAction(allowInlining: true) // awaiter resumes INLINE
-> ... xUnit's runner, then WarmUpJitAndTypeLoad ...
A Task completed from inside an Rx pipeline resumes its awaiter inline, still inside the
trampoline, and everything it then calls inherits the flag. Here the resumed awaiter was a mesh
teardown, so xUnit carried on running tests on that stack. The warm-up subscribed there, its walk was
enqueued rather than run, and it then blocked 30 s on warmInitial — which is the deadlock: the
queue could not drain until the blocked frame returned, and it never would.
This is not a test artefact. The trampoline's owner is the hub message pump. Any caller resumed
from a .ToTask() inside a hub pipeline that subscribes a live query and waits is exposed — and the
symptom in the portal is a live children listing (chat token chip, notification bell, folder view)
that opens and silently stays empty forever while a point read returns the same content immediately.
Correcting the two hypotheses on this ticket
IIoPool / IoPool.Unbounded is not involved.StorageAdapterMeshQueryProvider._ioPool is
assigned in the constructor and never invoked — grep for _ioPool. in that file returns only
comments. The Scheduler.Default.Schedule hop near line 1440 is on the backlog path, which runs
only after Initial has been emitted. Neither is on the failing path.
The gated scenario does not leak its parked thread. Its finally sets ReleaseRead, joins and
disposes on every exit path, and the warm-up runs before it in any case. Not the cause.
Thread-pool starvation is ruled out by measurement: at the moment of failure the pool showed threads=4 pending=0 avail=32766/1000. Nothing was starved — the work was never queued to the
pool at all.
The 400-iteration warm-process finding is real (only ever iteration 0 failed) and WarmUpJitAndTypeLoad stays. It was incomplete, exactly as you said: a warm-process loop cannot
observe a cause that only manifests cold or via cross-test state.
The fix, proven both ways
ToInlineObservable() (ImmediateScheduler — no ambient per-thread state; its recursive form is
trampolined through a per-call AsyncLock, so long walks still iterate without growing the stack),
applied to the scope walk and the IStorageAdapter read paths that make the same promise.
LiveQueryForeignTrampolineTest is a deterministic repro — no container, no 4-CPU cap: it
subscribes from inside a real trampoline and blocks there.
Activity
rbuergi commentedon Aug 26, 2026
Consolidating #2319 (which I reopened on the same run, minutes apart) into this ticket. Three findings from that analysis, carried over:
fix/2342-ai-internalsvisibletocontains BOTHc4542d149(the warm-up) and its review follow-upc65ba3936, and was 0 commits behindmain. So this is the fixed code failing, not a re-sample of the pre-fix flake on an old branch.InMemoryStorageAdapter, and the empty-result path emitsInitialunconditionally. JIT of an Rx pipeline does not take 30 s. SoInitialwas not slow — it did not arrive at all.What the earlier investigation established still stands: an instrumented 400-iteration in-process loop at
DOTNET_PROCESSOR_COUNT=4failed only ever on iteration 0. That is real and it is why the warm-up helped. But that loop was structurally blind to any cause that only manifests in a cold process or via cross-test state — it could only ever observe causes that reproduce in a warm process. So it never ruled this out.Unproven hypothesis, offered for someone to disprove rather than adopt: the
Initialread is offloaded throughIIoPool, and with noIoPoolRegistryin this test the provider falls back toIoPool.Unbounded(thread pool), with a furtherScheduler.Default.Schedulehop atStorageAdapterMeshQueryProvider.cs:1440.MeshWeaver.Hosting.Testruns 232 tests in one process, and this class deliberately parks a thread in its gated scenario. Worth checking specifically: does that scenario release the parked thread and dispose its subscription on every exit path, including when an assertion fails? A leak there would poison whichever test runs next — and would look exactly like "too degraded to JIT".That the rerun reproduced it identically on the same runner is a useful signal: it points away from a random race and toward something about that process/runner state.
🚨 Operational note: the fail-fast in
c65ba3936was correct (a silently-skipped warm-up lets the gated assertions run cold and misreport their own timeout as a dropped row), but it means a warm-up miss now fails the shard — so this test can red an unrelated PR, as it did to #2366 whose diff is twoInternalsVisibleTolines.An agent is on this now with instructions to reproduce the way CI runs it (whole assembly, one cold process,
DOTNET_PROCESSOR_COUNT=4— an isolated single-test run discriminates nothing), name the exact mechanism, and fix the root cause. Explicitly NOT to raise the timeout and NOT to weaken the fail-fast.rbuergi commentedon Aug 26, 2026
Root-caused and fixed in #2389. It is a framework bug, and it reaches production.
Reproduced first, then diagnosed
The condition is cold process + whole assembly + a real 4-CPU budget. A Linux container at
--cpus 4withDOTNET_PROCESSOR_COUNT=4, running the wholeMeshWeaver.Hosting.Testassemblycold: 3 failures in 13 runs (~23%). It never reproduced on this Mac even at
DOTNET_PROCESSOR_COUNT=4, because that only sets a number — the container's cgroup quota makesthreads genuinely contend.
The runner was not degraded. From the failing shard's own trx: the test consumed exactly
00:00:30.017— its whole budget — while the other 231 tests took 6.8 s, against 5.8 s locally.And the two attempts on that run failed at positions 75 and 158, with different neighbours, so it
was never adjacent-test pollution either.
The mechanism
Instrumenting the pipeline showed
Subscribereturning having never walked — no error, nocompletion — and
Initialstill absent after a further 15 s. The chain stopped atwalkPairs.ToObservable().The parameterless
IEnumerable.ToObservable()defaults toSchedulerDefaults.Iteration=CurrentThreadScheduler, which does not mean "run it here now". It keeps a[ThreadStatic] boolfor "a trampoline is already running on this thread"; while that is set,Scheduleonly enqueues, leaving the item for whoever owns the outer trampoline to drain.Capturing
Environment.StackTraceat the point of failure gave the owner — 558 frames, bottom-up:A
Taskcompleted from inside an Rx pipeline resumes its awaiter inline, still inside thetrampoline, and everything it then calls inherits the flag. Here the resumed awaiter was a mesh
teardown, so xUnit carried on running tests on that stack. The warm-up subscribed there, its walk was
enqueued rather than run, and it then blocked 30 s on
warmInitial— which is the deadlock: thequeue could not drain until the blocked frame returned, and it never would.
This is not a test artefact. The trampoline's owner is the hub message pump. Any caller resumed
from a
.ToTask()inside a hub pipeline that subscribes a live query and waits is exposed — and thesymptom in the portal is a live children listing (chat token chip, notification bell, folder view)
that opens and silently stays empty forever while a point read returns the same content immediately.
Correcting the two hypotheses on this ticket
IIoPool/IoPool.Unboundedis not involved.StorageAdapterMeshQueryProvider._ioPoolisassigned in the constructor and never invoked — grep for
_ioPool.in that file returns onlycomments. The
Scheduler.Default.Schedulehop near line 1440 is on the backlog path, which runsonly after
Initialhas been emitted. Neither is on the failing path.finallysetsReleaseRead, joins anddisposes on every exit path, and the warm-up runs before it in any case. Not the cause.
threads=4 pending=0 avail=32766/1000. Nothing was starved — the work was never queued to thepool at all.
What stands from #2338
The 400-iteration warm-process finding is real (only ever iteration 0 failed) and
WarmUpJitAndTypeLoadstays. It was incomplete, exactly as you said: a warm-process loop cannotobserve a cause that only manifests cold or via cross-test state.
The fix, proven both ways
ToInlineObservable()(ImmediateScheduler— no ambient per-thread state; its recursive form istrampolined through a per-call
AsyncLock, so long walks still iterate without growing the stack),applied to the scope walk and the
IStorageAdapterread paths that make the same promise.LiveQueryForeignTrampolineTestis a deterministic repro — no container, no 4-CPU cap: itsubscribes from inside a real trampoline and blocks there.
No budget was widened and no fail-fast weakened.
Merge pull request #2389 from Systemorph/fix/2377-live-query-foreign-…
fix: no ToTask in the memex portal (#2751)
test: no ToTask in the test tree either — 1,538 bridges become the de…
fix(guard): the observable→Task bridge guard enforced a SPELLING — ma…
test(#3213): bridge every observable wait through .Await(), not Rx's …