Skip to content

Two more pending timers nobody owned — one discarded, one only the next keystroke could cancel - #1007

Merged
rbuergi merged 5 commits into
mainfrom
fix/995-two-live-timer-leaks
Aug 9, 2026
Merged

rbuergi merged 5 commits into
mainfrom
fix/995-two-live-timer-leaks

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Closes the two live defects the #995 audit found — and only those. The 40 verified-safe sites and the 4 ambiguous ones are untouched; the audit measured them, and widening this diff would only add regression surface.

Both are the class #996 fixed: an Rx timer whose IDisposable never reaches an owner's disposal chain. Observable.Timer parks its entry on the process-wide TimerQueue, a strong GC root, so while the timer is pending it holds the tick closure and everything the closure captured — past the owner's disposal.

The two sites

ActivityLogLogger.cs:116 — a discarded Subscribe. Structurally identical to the four in #996, at a tenth of the window. A burst of script log calls is coalesced by arming a 100 ms tail-flush timer; the subscription it returned was dropped on the floor, so nothing could cancel a flush still pending when the kernel executor hub tore down. The pending flush held the logger, and the logger holds the IMessageHub it posts to.

Fix: the timer goes into a SerialDisposable registered on that hub (RegisterForDisposal). Teardown disposes the hub's composite, which cancels the timer. Assignment keeps the existing "arm one at a time" semantics — _publishScheduled already guarantees at most one is pending, so a live flush can never be clobbered — and a schedule handed over after disposal is disposed on assignment, so there is no dispose race either.

MeshNodePickerView.razor.cs:232 — capture without ownership. This one reads correct: the subscription IS assigned to a field, and a discarded-Subscribe analyzer passes the file clean. But the only code that ever disposed that field was the next keystroke. The component never overrode DisposeAsync and the field was not in BlazorView.Disposables, so a user who typed into the picker and navigated away left the last 200 ms timer on the TimerQueue, rooting the component → its injected IMeshService → the mesh services.

Fix: same SerialDisposable, registered once in OnInitialized into the framework's existing BlazorView.Disposables (released by DisposeAsync) rather than a bespoke override. Registered there and not in BindData, which re-runs on every binding-relevant parameter change — that would both duplicate the entry and cancel a debounce the user is still mid-typing.

Does this eliminate the window or narrow it?

Eliminated in both cases, not narrowed — the fix is ownership, not a shorter timer.

  • Logger: the pending flush is cancelled synchronously as part of the hub's ShutDown-phase disposables.Dispose(). There is no interval in which a pending timer outlives the hub, and no race with a concurrent arm (SerialDisposable disposes an incoming value when already disposed).
  • Picker: DisposeAsync releases Disposables. OnInitialized is guaranteed by Blazor to run before any event handler can fire, so there is no keystroke that can arm a timer outside the registration.

What remains bounded rather than zero is normal operation: while a hub or component is alive, a pending timer legitimately holds it for its 100/200 ms. That is the feature.

Verification

Two targeted tests, one per site. Deliberately not MeshHubDisposalLeakTest — as its own remarks now say, it samples (a bounded-window root is caught only if the forced GC lands inside the window), it cannot attribute, and it SKIPs on macOS (#674). Both new tests assert the ownership property directly and timing-free: the handle holding the pending timer is disposed by the owner's teardown, which holds whether or not the timer has already fired. Neither can flake, and neither can pass by accident.

Negative control run, per site — revert only the ownership line, signature unchanged:

Reverted Result
hub.RegisterForDisposal(pending) in ActivityLogLogger.RegisterPendingFlush DisposingTheHub_CancelsThePendingTailFlush FAILS: "Expected value to be true because tearing down the hub must cancel a tail flush that is still pending … but found False"
Disposables.Add(_debounceSub) in MeshNodePickerView.OnInitialized DisposeAsync_CancelsTheLastPendingDebounce FAILS: "Expected collection to contain a single item because OnInitialized must register the debounce handle in BlazorView.Disposables … but found 0"

Both restored, both green. The picker test also asserts that the keystroke path arms into the registered handle — without that, the registration could guard a slot nothing ever writes to and the test would still pass.

One thing I measured rather than reasoned about, since it decides whether RegisterForDisposal trades one leak for another: a fired Observable.Timer(…).Subscribe(…) handle, still held, does not retain its callback closure (Rx's sink detaches its observer on completion). Verified with a WeakReference probe alongside three controls, including "pending + discarded ⇒ ROOTED", which confirms the probe can see the defect shape. So the hub retains only the SerialDisposable itself (~32 B per logger); a settled logger and its message list stay collectable while the hub lives on.

Also green: ActivityTerminalSettleTest, ActivityLogStreamTest, MultiLanguageCodeExecutionTest (20 tests) — the kernel/activity-log paths through the changed logger. Full solution builds clean under CI's flags (-c Release -warnaserror -p:CIRun=true), 0 warnings.

Notes

Refs #995

rbuergi and others added 4 commits August 9, 2026 14:09
Both sites arm an Rx timer and then have no way to cancel it. Observable.Timer
parks on the process-wide TimerQueue — a strong GC root — so for as long as the
timer is pending it holds the tick closure and everything the closure captured,
past the owner's disposal.

ActivityLogLogger discarded the tail-flush Subscribe result outright (the same
shape as the four watcher timers fixed in #996, at 100 ms instead of 1 s). The
pending flush held the logger, and the logger holds the hub it posts to.

MeshNodePickerView looked correct — the subscription IS assigned to a field —
but the only thing that ever disposed that field was the NEXT keystroke. Type,
then navigate away, and the last 200 ms timer outlives the component and roots
it through the injected IMeshService. Ownership, not capture, was missing, which
is why a static analyzer reads the file clean.

Both now hold the pending timer in a SerialDisposable that the owner's disposal
chain disposes: the hub's composite for the logger, BlazorView.Disposables for
the picker. Assignment keeps the existing 'a new one cancels the old one'
semantics.

Refs #995

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neither test is a GC sampling probe. Both assert the ownership property
directly and timing-free — the handle holding the pending timer is disposed by
the owner's teardown — so they hold whether or not the timer has already fired,
which is exactly what MeshHubDisposalLeakTest cannot do.

The picker test also asserts that the keystroke path arms INTO the registered
handle. Without it, the registration could guard a slot nothing writes to and
the test would still pass.

Refs #995

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The probe posts a real DataChangeRequest to its own address, as KernelExecutor
does; without a handler and type registrations every run left a DeliveryFailure
warning plus six auto-short-name warnings. Accept the message the way the
activity hub does in production.

Refs #995

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Refs #995

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 9, 2026 12:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes two retention defects where Observable.Timer(...).Subscribe(...) was not owned by an appropriate disposal chain, allowing pending timers (rooted by the process-wide TimerQueue) to keep hubs/components alive past teardown.

Changes:

  • Fix ActivityLogLogger tail-flush scheduling by storing the timer subscription in a SerialDisposable registered via hub.RegisterForDisposal(...).
  • Fix MeshNodePickerView search debounce ownership by using a component-lifetime SerialDisposable registered once in BlazorView.Disposables (OnInitialized), so DisposeAsync cancels the last pending debounce.
  • Add two targeted ownership tests to assert teardown cancels pending timers for both sites.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
test/MeshWeaver.Hosting.Blazor.Test/MeshNodePickerDebounceOwnershipTest.cs Adds a test asserting the picker’s debounce timer is owned by the component teardown path.
test/MeshWeaver.AI.Test/ActivityLogTailFlushOwnershipTest.cs Adds a test asserting the activity-log tail-flush timer is cancelled when the owning hub is disposed.
src/MeshWeaver.Kernel.Hub/ActivityLogLogger.cs Owns the tail-flush Observable.Timer via a SerialDisposable registered in the hub disposal chain.
src/MeshWeaver.Blazor/Components/MeshNodePickerView.razor.cs Owns the debounce Observable.Timer via a component-lifetime SerialDisposable registered in Disposables during OnInitialized.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

The keystroke arms a real 200 ms Observable.Timer, and this component has no
render handle — a tick would reach InvokeAsync/StateHasChanged and throw on the
timer thread. The test finished in microseconds, but 'finishes before the timer'
is a timing assumption, and this test exists precisely to stop relying on those.

Swap the armed timer for a stand-in once it has served its purpose (proving the
keystroke writes into the component-owned handle). Teardown then cancels the
stand-in, which asks the same question with no live timer anywhere.

Refs #995

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

685 tests  ±0   685 ✅ ±0   5m 8s ⏱️ +8s
 10 suites ±0     0 💤 ±0 
 10 files   ±0     0 ❌ ±0 

Results for commit 0776bce. ± Comparison against base commit 3e18426.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 271 tests  +76   1 266 ✅ +81   7m 17s ⏱️ -31s
   11 suites ± 0       5 💤  -  5 
   11 files   ± 0       0 ❌ ± 0 

Results for commit 0776bce. ± Comparison against base commit 3e18426.

This pull request removes 536 and adds 612 tests. Note that renamed tests count towards both.
MeshWeaver.AI.Test.ActivityTerminalSettleTest ‑ HammeredAppends_NeverRevertTerminalStatus
MeshWeaver.AI.Test.ActivityTerminalSettleTest ‑ LateSubscribeCallbackLog_DoesNotRevertTerminalStatus
MeshWeaver.AI.Test.ActivityTerminalSettleTest ‑ NonSerializableReturnValue_SettlesFailed_AndPumpAdvances
MeshWeaver.AI.Test.ActivityTerminalSettleTest ‑ SubscribeCallbackThrow_SettlesFailed_WithExceptionMessageInLog
MeshWeaver.AI.Test.AgentChatClientTest ‑ AgentChatClient_Initialize_SurfacesPartitionAgents_NotNodeTypeNamespaceAgents
MeshWeaver.AI.Test.AgentChatClientTest ‑ AgentChatClient_Initialize_SurfacesPlatformAgentsForAnyContext
MeshWeaver.AI.Test.AgentContextPromptTest ‑ ToPromptContext_resolves_namespace_id_nodePath_path_and_parameters
MeshWeaver.AI.Test.AgentContextPromptTest ‑ ToPromptContext_without_node_still_ships_address_and_null_node
MeshWeaver.AI.Test.AgentPickerQueriesTest ‑ BuildAgentQuery_ExcludeUtility_AppendsUtilityFilter
MeshWeaver.AI.Test.AgentPickerQueriesTest ‑ BuildAgentQuery_NeitherSet_PlatformDefaultsOnly
…
MeshWeaver.AI.Test.ActivityLogTailFlushOwnershipTest ‑ DisposingTheHub_CancelsThePendingTailFlush
MeshWeaver.AI.Test.AgentChatClientDeadlockTest ‑ GetOrderedAgentsAsync_WithContextPath_ConcurrentCallers_DoNotDeadlock
MeshWeaver.AI.Test.AgentChatClientDeadlockTest ‑ GetOrderedAgentsAsync_WithContextPath_SingleCaller_ResolvesQuickly
MeshWeaver.AI.Test.AgentChatClientDeadlockTest ‑ GetOrderedAgentsAsync_WithMarkdownContext_DoesNotDeadlock
MeshWeaver.AI.Test.AgentChatClientUnitTest ‑ FindCyclicDelegations_Chain_ReturnsEmpty
MeshWeaver.AI.Test.AgentChatClientUnitTest ‑ FindCyclicDelegations_DelegationsWithNoMatchingTarget_ReturnsEmpty
MeshWeaver.AI.Test.AgentChatClientUnitTest ‑ FindCyclicDelegations_EmptyInput_ReturnsEmpty
MeshWeaver.AI.Test.AgentChatClientUnitTest ‑ FindCyclicDelegations_MixedCyclicAndNonCyclic_ReturnsOnlyCyclic
MeshWeaver.AI.Test.AgentChatClientUnitTest ‑ FindCyclicDelegations_MutualDelegation_ReturnsBoth
MeshWeaver.AI.Test.AgentChatClientUnitTest ‑ FindCyclicDelegations_NoDelegations_ReturnsEmpty
…

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 898 tests  ±0   1 896 ✅ ±0   6m 17s ⏱️ +2s
   11 suites ±0       2 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 0776bce. ± Comparison against base commit 3e18426.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 725 tests  +1   1 533 ✅ +1   6m 10s ⏱️ -8s
   11 suites ±0     192 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 0776bce. ± Comparison against base commit 3e18426.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   11 files  ± 0     11 suites  ±0   5m 45s ⏱️ + 1m 7s
1 919 tests  - 62  1 811 ✅  - 67  108 💤 +5  0 ❌ ±0 
2 050 runs   - 62  1 942 ✅  - 67  108 💤 +5  0 ❌ ±0 

Results for commit 0776bce. ± Comparison against base commit 3e18426.

This pull request removes 598 and adds 536 tests. Note that renamed tests count towards both.
MeshWeaver.AI.Test.AgentChatClientDeadlockTest ‑ GetOrderedAgentsAsync_WithContextPath_ConcurrentCallers_DoNotDeadlock
MeshWeaver.AI.Test.AgentChatClientDeadlockTest ‑ GetOrderedAgentsAsync_WithContextPath_SingleCaller_ResolvesQuickly
MeshWeaver.AI.Test.AgentChatClientDeadlockTest ‑ GetOrderedAgentsAsync_WithMarkdownContext_DoesNotDeadlock
MeshWeaver.AI.Test.AgentChatClientUnitTest ‑ FindCyclicDelegations_Chain_ReturnsEmpty
MeshWeaver.AI.Test.AgentChatClientUnitTest ‑ FindCyclicDelegations_DelegationsWithNoMatchingTarget_ReturnsEmpty
MeshWeaver.AI.Test.AgentChatClientUnitTest ‑ FindCyclicDelegations_EmptyInput_ReturnsEmpty
MeshWeaver.AI.Test.AgentChatClientUnitTest ‑ FindCyclicDelegations_MixedCyclicAndNonCyclic_ReturnsOnlyCyclic
MeshWeaver.AI.Test.AgentChatClientUnitTest ‑ FindCyclicDelegations_MutualDelegation_ReturnsBoth
MeshWeaver.AI.Test.AgentChatClientUnitTest ‑ FindCyclicDelegations_NoDelegations_ReturnsEmpty
MeshWeaver.AI.Test.AgentChatClientUnitTest ‑ FindCyclicDelegations_OneWayDelegation_ReturnsEmpty
…
MeshWeaver.AI.Test.ActivityTerminalSettleTest ‑ HammeredAppends_NeverRevertTerminalStatus
MeshWeaver.AI.Test.ActivityTerminalSettleTest ‑ LateSubscribeCallbackLog_DoesNotRevertTerminalStatus
MeshWeaver.AI.Test.ActivityTerminalSettleTest ‑ NonSerializableReturnValue_SettlesFailed_AndPumpAdvances
MeshWeaver.AI.Test.ActivityTerminalSettleTest ‑ SubscribeCallbackThrow_SettlesFailed_WithExceptionMessageInLog
MeshWeaver.AI.Test.AgentChatClientTest ‑ AgentChatClient_Initialize_SurfacesPartitionAgents_NotNodeTypeNamespaceAgents
MeshWeaver.AI.Test.AgentChatClientTest ‑ AgentChatClient_Initialize_SurfacesPlatformAgentsForAnyContext
MeshWeaver.AI.Test.AgentContextPromptTest ‑ ToPromptContext_resolves_namespace_id_nodePath_path_and_parameters
MeshWeaver.AI.Test.AgentContextPromptTest ‑ ToPromptContext_without_node_still_ships_address_and_null_node
MeshWeaver.AI.Test.AgentPickerQueriesTest ‑ BuildAgentQuery_ExcludeUtility_AppendsUtilityFilter
MeshWeaver.AI.Test.AgentPickerQueriesTest ‑ BuildAgentQuery_NeitherSet_PlatformDefaultsOnly
…

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 190 tests  +2   1 190 ✅ +2   7m 18s ⏱️ -1s
   11 suites ±0       0 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 0776bce. ± Comparison against base commit 3e18426.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Test Results

   65 files  ± 0     65 suites  ±0   37m 58s ⏱️ +37s
8 688 tests +17  8 381 ✅ +17  307 💤 ±0  0 ❌ ±0 
8 819 runs  +17  8 512 ✅ +17  307 💤 ±0  0 ❌ ±0 

Results for commit 0776bce. ± Comparison against base commit 3e18426.

@rbuergi
rbuergi merged commit 7c43272 into main Aug 9, 2026
16 checks passed
@rbuergi
rbuergi deleted the fix/995-two-live-timer-leaks branch August 19, 2026 10:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants