Skip to content

Kernel: the idle-disconnect never disposes a hub whose submission is still running (#4422) - #4423

Merged
meshweaver-cloud[bot] merged 3 commits into
mainfrom
fix/kernel-idle-dispose-while-running
Sep 15, 2026
Merged

meshweaver-cloud[bot] merged 3 commits into
mainfrom
fix/kernel-idle-dispose-while-running

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #4422.

What was wrong. KernelContainer.DisposeOnTimeout re-armed its one-shot IdleDisconnectTimeout timer, 15 min, only when a message was delivered to the kernel-hosting hub. A running script delivers none. It executes on the hosted kernelExec/{path} child, its mesh reads answer elsewhere, and its log lines leave as outgoing writes. So a script still working 15 min after the last inbound message had its host hub, and the executor with it, disposed mid-run. That ended the run silently, with no SubmitCodeResponse and no terminal activity status.

Measured on memex.systemorph.com, 2026-09-15:

  • The approved OperationRequest rbuergi/Requests/provision-client-b-20260914 stopped reporting after 10:45:06Z.
  • The request's 20-minute silence watchdog failed it at 11:05:06Z.
  • In that window there was no host restart and no NodeType recompile.

Change.

  • State. KernelContainer.IdleState holds the hub weakly, the window, and the one-shot timer. It creates the timer from KernelHubOptions.TimeProvider, which is TimeProvider.System in every host. It also holds ONE immutable Snapshot(Closed, ImmutableHashSet<Claim> InFlight), and every transition is a single ImmutableInterlocked.Update on it: claim, release and close. There is no counter to go negative, and a claim and a close are always ordered.
  • Claim before setup. The forwarder runs each SubmitCodeRequest through IdleState.Track, which takes the claim BEFORE the executor is set up. GetOrCreateExecutor, binding the claim to the executor, and hub.Observe all run inside Observable.Defer(…).Finally(release). So a response, an error, a teardown, or a synchronous throw from the setup or the post each release the claim.
  • On the tick. Elapsed keeps the hub and re-arms while any claim is still being set up or sits on a live executor. Otherwise it closes and disposes, in the same compare-and-swap. A claim after the close is refused, nothing is set up, and the requester gets SubmitCodeResponse { Success = false }.
  • Release restarts the window. A finished hub is therefore still reclaimed one window after its run, so the Is the per-compile _Activity hub retention BOUNDED? DisposeOnTimeout(15 min) should now reach it — measure past the horizon #1324/fix: a finished activity was a KEEP-ALIVE, not something nobody had collected yet (#1324) #1435 reclamation is unchanged. A dead executor cannot pin the hub.

Tests. KernelIdleDisconnectWhileRunningTest uses real HubTestBase hubs, not NSubstitute: the kernel host and its executor are real hosted hubs, and a dead executor is really disposed. A manual TimeProvider steps through the production 15-min window, so every assertion is about WHEN the one-shot timer fires. 7/7 green locally.

Each row below is red when that production change alone is reverted: rebuilt, then run. On main the class does not compile, because IdleState does not exist there. M1 is the pre-#4422 behaviour inside the new structure.

Test Red when Green
ARunningSubmission_OutlivesTheWindow_AndTheWindowRestartsWhenItEnds (Response / Error / Teardown) M1 Elapsed ignores in-flight (pre-fix); M4 release does not re-arm ✅
ADeadExecutor_CannotKeepTheHubAliveForever M1; M5 a keep does not re-arm; M7 executor liveness ignored ✅
TheWindowElapsingDuringExecutorSetup_KeepsTheHub M1; M2 claim taken after setup ✅
ASetupThatThrows_ReleasesItsClaim M3 dispatch outside Defer/Finally ✅
AClaimAfterTheHubClosed_IsRefused_AndNothingIsSetUp M2; M6 a claim ignores Closed ✅

After the controls, the fix was restored byte-identical, rebuilt, and run green again.

Builds. Each of these builds with -c Release -warnaserror and reports 0 warnings and 0 errors:

  • MeshWeaver.Kernel
  • MeshWeaver.Kernel.Hub
  • MeshWeaver.Compiler.Pipeline.Test
  • MeshWeaver.Documentation

MeshWeaver.Documentation.Test Guard / Ratchet / WhatsNew / FrontMatter tests: 321/321 green.

Docs.

  • A What's New entry, Category: Fix.
  • The in-flight rule, added to the idle-disconnect section of Doc/Architecture/MeshNodeStreamCache.

Surface. KernelHubOptions.TimeProvider is a new init property on a public record. It is not a primary-constructor parameter, and no public type, member or interface is removed or added.

Pairs-with: none — nothing is removed from the public surface; the only addition is an init property with a default.

Companion: Systemorph/MeshWeaver.Plugins#1924 makes the request's watchdog stamp say what went quiet and when.

Kept as a draft for the maintainer's review.

🤖 Generated with Claude Code

…still running (#4422)

KernelContainer.DisposeOnTimeout re-armed its one-shot 15-minute timer only
on messages DELIVERED to the kernel-hosting hub. A running script delivers
none: it executes on the hosted kernelExec child, its mesh reads answer
elsewhere, and its log lines leave as outgoing writes. So a script still
working 15 min after the last inbound message had the hub, and its
executor, disposed mid-run with no SubmitCodeResponse and no terminal
status. Measured on memex.systemorph.com 2026-09-15: an approved
OperationRequest's follow loop went silent at 10:45:06Z and the request's
20-minute silence watchdog failed it at 11:05:06Z.

- IdleState (weak hub ref, window, in-flight count, weak executor ref):
  the forwarder Begins on every SubmitCodeRequest and Ends in Finally, so
  a response, an error or a torn-down subscription all release it.
- Elapsed re-arms while a submission is in flight on a LIVE executor and
  disposes otherwise, so a dead executor cannot pin the hub.
- End restarts the window, so a finished hub is reclaimed one full window
  after its run ends, which keeps the #1324/#1435 reclamation.
- KernelIdleDisconnectWhileRunningTest pins both directions.

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

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.

🟡 Changes recommended

Critical setup and concurrency races remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Fixes kernel hub disposal during long-running submissions by tracking active work and rearming idle timeouts.

Changes:

  • Tracks in-flight submissions and executor liveness.
  • Documents updated idle-disconnect behavior.
  • Adds regression tests for idle-state handling.
File summaries
File Summary
test/MeshWeaver.Compiler.Pipeline.Test/KernelIdleDisconnectWhileRunningTest.cs Adds idle-state regression tests.
src/MeshWeaver.Kernel/KernelHubOptions.cs Documents updated idle semantics.
src/MeshWeaver.Kernel.Hub/KernelContainer.cs Tracks submissions and controls idle-timeout disposal.
Review details

Suppressed comments (1)

test/MeshWeaver.Compiler.Pipeline.Test/KernelIdleDisconnectWhileRunningTest.cs:83

  • The only assertion is after a subsequent Begin(), so this test would pass if End() were a no-op; it never verifies the claimed zero clamp after the two unbalanced calls. Assert InFlight == 0 immediately after the End() calls, then separately assert the later Begin() reaches one.
    public void UnbalancedEnd_NeverCountsBelowZero()
    {
        var (state, _, _) = Arrange();
        state.End();
        state.End();
        state.Begin();

        Assert.Equal(1, state.InFlight);
  • Files reviewed: 3/3 changed files
  • Comments generated: 6
  • Review effort level: Lite

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +250 to +255
public void End()
{
if (Interlocked.Decrement(ref inFlight) < 0)
Interlocked.Exchange(ref inFlight, 0);
Rearm();
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5846d9c by restructuring, so there is no CAS loop and no counter left to clamp.

IdleState now holds one immutable Snapshot(Closed, ImmutableHashSet<Claim> InFlight). Every transition is a single ImmutableInterlocked.Update on that one reference:

  • claim, in Track;
  • release, in Release;
  • close, in Elapsed and Dispose.

A submission's slot is a Claim object, and only that claim removes it. So the count cannot go negative. There is also no gap between a decrement and a clamp for a concurrent claim to fall into. The old shape had one: Decrement→-1, then a concurrent Begin→0, then Exchange(0) erased the Begin. Elapsed decides on the snapshot it swaps, so it cannot act on a torn value.

Pinned by:

  • ARunningSubmission_OutlivesTheWindow_AndTheWindowRestartsWhenItEnds (×3), which asserts InFlight == 0 after each termination;
  • ASetupThatThrows_ReleasesItsClaim.

Forcing that interleaving deterministically would need a seam inside the swap, so this property holds by construction rather than by a test.

Comment on lines +339 to +343
var executor = GetOrCreateExecutor(hub);

// In flight until the executor answers — the idle-disconnect must not dispose this hub
// under a running script (MeshWeaver#4422). Finally runs on the response, on an error,
// and on a disposal that tears the subscription down, so the count cannot leak upward.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5846d9c.

  • Claim before setup. The claim is taken when IdleState.Track is subscribed, BEFORE dispatch runs. GetOrCreateExecutor now runs inside dispatch.
  • A claim being set up counts as working. A claim with no executor bound yet counts as working, so a tick during executor setup sees a submission being set up and keeps the hub.
  • Claim and close are one ordered decision. Both are a compare-and-swap on the same snapshot, so a claim lands either before the close or after it. A claim after the close is refused and nothing is set up on the closing hub. The forwarder answers the requester with SubmitCodeResponse { Success = false }.

Pinned by:

  • TheWindowElapsingDuringExecutorSetup_KeepsTheHub: the clock is advanced past the window from INSIDE dispatch;
  • AClaimAfterTheHubClosed_IsRefused_AndNothingIsSetUp.

Negative controls, each rebuilt and run:

  • moving the claim after the setup turns both tests red;
  • letting a claim ignore Closed turns the second one red.

Comment on lines +346 to +348
hub.Observe<SubmitCodeResponse>(request.Message, o => o.WithTarget(executor.Address))
.Take(1)
.Finally(() => state?.End())

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5846d9c. ForwardSubmitCodeRequest now hands the whole setup and post to IdleState.Track as Dispatch: GetOrCreateExecutor, bind the claim, then hub.Observe. Track runs it as Observable.Defer(() => dispatch(claim)).Finally(() => Release(claim)).

A synchronous throw from Observe's post, or from the executor setup, therefore becomes OnError of the deferred observable. Finally releases the claim, and the forwarder's onError answers the requester.

Pinned by ASetupThatThrows_ReleasesItsClaim, which makes dispatch throw the way Observe does on a refused post.

Negative control: calling dispatch(claim) outside the Defer turns it red, because InFlight stays at 1 and the hub is never reclaimed.

var hub = Substitute.For<IMessageHub>();
var executor = Substitute.For<IMessageHub>();
executor.IsDisposing.Returns(false);
var state = new KernelContainer.IdleState(new WeakReference<IMessageHub>(hub), TimeSpan.FromMinutes(15));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5846d9c. IdleState now creates its timer from an injected TimeProvider: KernelHubOptions.TimeProvider, which is TimeProvider.System in every host. The tests drive a manual clock through the production 15-minute window and assert WHEN the one-shot timer fires.

ARunningSubmission_OutlivesTheWindow_AndTheWindowRestartsWhenItEnds is a Theory over Response, Error and Teardown (the subscription disposed). Each case:

  1. keeps the hub at W and re-arms;
  2. ends the run at 1.5 W;
  3. requires the hub still alive at 2 W, which is the due time the keep had armed;
  4. requires it disposed at 2.5 W.

So each termination path must reset the due time. ADeadExecutor_CannotKeepTheHubAliveForever additionally needs the keep's re-arm, because without it the one-shot timer never fires again.

Negative controls, each rebuilt and run:

  • dropping the re-arm on release turns all three Theory cases red;
  • dropping the re-arm on a keep turns the dead-executor test red.

Comment on lines +29 to +33
/// <para>🚨 <b>Idle means no inbound message AND no submission in flight</b> (#4422). The
/// window is re-armed by delivered messages, and a running script delivers none, so without the
/// in-flight check a script still working 15 min after the last inbound message had its hub
/// disposed mid-run, silently. <c>KernelContainer.IdleState</c> keeps the hub while a forwarded
/// submission has not answered and restarts the window when it does.</para>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 5846d9c: src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-15-a-long-running-script-is-no-longer-stopped-after-15-quiet-minutes.md. It has Category: Fix, Order: -20260915, and the same front matter as the other entries.

I also added the in-flight rule to the idle-disconnect section of Doc/Architecture/MeshNodeStreamCache, where the reaper is documented.

Comment on lines +21 to +23
var hub = Substitute.For<IMessageHub>();
var executor = Substitute.For<IMessageHub>();
executor.IsDisposing.Returns(false);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5846d9c. NSubstitute is gone from this test, and the class now derives from HubTestBase:

  • the kernel host and its executor are real hosted hubs;
  • "disposed" is the hub's own IsDisposing;
  • the dead executor is a real Dispose() of the executor hub.

The only injected seam is the clock, a TimeProvider, not a hub interface. That is what lets the tests step through the production window instead of shrinking it.

@github-actions

github-actions Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

  2 files   -     4    2 suites   - 4   9s ⏱️ - 4m 38s
140 tests  - 2 072  140 ✅  - 2 072  0 💤 ±0  0 ❌ ±0 
140 runs   - 2 076  140 ✅  - 2 076  0 💤 ±0  0 ❌ ±0 

Results for commit efade15. ± Comparison against base commit b5ddb88.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

263 tests  ±0   263 ✅ ±0   2m 44s ⏱️ +36s
  1 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit efade15. ± Comparison against base commit b5ddb88.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

376 tests   - 1 100   376 ✅  - 1 100   52s ⏱️ - 2m 5s
  1 suites  -     1     0 💤 ±    0 
  1 files    -     1     0 ❌ ±    0 

Results for commit efade15. ± Comparison against base commit b5ddb88.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

    1 files   -   1      1 suites   - 1   5m 4s ⏱️ + 1m 27s
1 689 tests +764  1 689 ✅ +764  0 💤 ±0  0 ❌ ±0 
1 690 runs  +765  1 690 ✅ +765  0 💤 ±0  0 ❌ ±0 

Results for commit efade15. ± Comparison against base commit b5ddb88.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

1 698 tests  +581   1 698 ✅ +583   6m 20s ⏱️ -2s
    3 suites ±  0       0 💤  -   2 
    3 files   ±  0       0 ❌ ±  0 

Results for commit efade15. ± Comparison against base commit b5ddb88.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

    2 files   -   2      2 suites   - 2   7m 2s ⏱️ + 2m 1s
2 553 tests +637  2 553 ✅ +829  0 💤  - 192  0 ❌ ±0 
2 557 runs  +640  2 557 ✅ +832  0 💤  - 192  0 ❌ ±0 

Results for commit efade15. ± Comparison against base commit b5ddb88.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   10 files   -     8     10 suites   - 8   22m 14s ⏱️ - 2m 40s
6 719 tests  - 1 190  6 719 ✅  - 996  0 💤  - 194  0 ❌ ±0 
6 724 runs   - 1 190  6 724 ✅  - 996  0 💤  - 194  0 ❌ ±0 

Results for commit efade15. ± Comparison against base commit b5ddb88.

♻️ This comment has been updated with latest results.

rbuergi and others added 2 commits September 15, 2026 19:27
…setup, released on every path (#4422)

Answers the six #4423 review threads.

- One immutable Snapshot(Closed, ImmutableHashSet<Claim>) swapped with
  ImmutableInterlocked.Update replaces the counter plus clamp. A claim, a release and
  the close are each one CAS on the same reference, so a claim is ordered against
  the close. Nothing can go negative, and a concurrent claim can no longer be erased
  by a clamp.
- IdleState.Track takes the claim BEFORE the executor is set up. A claim with no
  executor bound yet counts as working, so a tick during setup keeps the hub. A
  claim after the close is refused and nothing is set up.
- The setup and the post run inside Observable.Defer(...).Finally(Release). A
  synchronous throw from Observe's post or from GetOrCreateExecutor releases the
  claim instead of pinning the hub.
- The timer comes from KernelHubOptions.TimeProvider (TimeProvider.System in every
  host). KernelIdleDisconnectWhileRunningTest now uses real HubTestBase hubs, not
  NSubstitute, and steps a manual clock through the production window. It asserts
  the due time moves on response, error and teardown, and that a dead executor
  cannot pin the hub.
- What's New entry (Category: Fix), and the in-flight rule in
  Doc/Architecture/MeshNodeStreamCache.

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

Closed is set by the idle tick AND by the hub's own disposal (a recycle, a host teardown),
so the refusal names both instead of asserting an idle reclamation it cannot know.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi
rbuergi marked this pull request as ready for review September 15, 2026 17:46
@meshweaver-cloud
meshweaver-cloud Bot added this pull request to the merge queue Sep 15, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 15, 2026
@meshweaver-cloud

Copy link
Copy Markdown
Contributor

⛔ Merge-queue steward: left dequeued — the group build failed on an assertion the flake catalogue does not know.
Group build: https://github.com/Systemorph/MeshWeaver/actions/runs/35003438933

  • MeshWeaver.Hosting.Test.IoPoolTest.Dispose_doesNotBlockOnASlowPooledSubscriptionTeardown — MeshWeaver.Reactive.Assertions.ObservableAssertionException : Expected the observable to emit a value within 5s because disposal must still terminate the subscription, but it did not. The observable emitted nothing at all. NOTE: if you filt

Not a catalogued flake. Fix the failure, or — with run URLs, an issue and an assertion-message pattern — add it to .github/known-flakes.json (see Doc/Architecture/MergeQueue). Re-queue with gh pr merge <n> --auto once the head is green; label queue-rejected marks this PR as needing a person.

@rbuergi

rbuergi commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Re-arming after the queue-rejected ejection, with the measurement behind it recorded on #4448.

The group build failed on IoPoolTest.Dispose_doesNotBlockOnASlowPooledSubscriptionTeardown. This PR's diff is the kernel's idle timer; it cannot reach IoPool, and this PR's own run was green on that test.

Measured since (details and numbers on #4448): on a fully saturated 18-core host the pooled subscription's terminal arrives in 0 ms, and the test did not fail in 8 isolated runs or in 4 class runs under that load. So the 5 s bound is not the problem, and widening it would be a band-aid for something that is not slow. The mechanism of the single CI occurrence is unexplained and stays open on #4448 — deliberately not catalogued as a flake, because one unexplained occurrence is not evidence.

Re-armed once. If the same assertion fails again, that is the second data point and it goes back to #4448 as a real lost-terminal hunt rather than a re-queue.

🤖 Generated with Claude Code

@rbuergi rbuergi removed the queue-rejected The merge queue rejected this PR on an uncatalogued failure; a person owns it now label Sep 15, 2026
@rbuergi
rbuergi added this pull request to the merge queue Sep 15, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 15, 2026
@meshweaver-cloud
meshweaver-cloud Bot added this pull request to the merge queue Sep 15, 2026
@meshweaver-cloud

Copy link
Copy Markdown
Contributor

🔀 Merge-queue steward: re-queued alone (bisecting) — bisecting: your group of 2 pull requests failed on an uncatalogued assertion while this PR's own run was green; re-queued ALONE — if the solo build fails too, the PR stays out (group: #4454).
Group build: https://github.com/Systemorph/MeshWeaver/actions/runs/35013529219

  • MeshWeaver.Hosting.Test.ReturningSourceConvergesTest.Reconcile_EvaluatesLiveSourceEvenWhenItsManifestStillMatches — MeshWeaver.Reactive.Assertions.AssertionException : Expected "# Existing

enqueued

Merged via the queue into main with commit 7fc9f9c Sep 15, 2026
39 checks passed
@rbuergi
rbuergi deleted the fix/kernel-idle-dispose-while-running branch October 10, 2026 13:10
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.

Kernel idle-disconnect disposes the Activity hub while a submitted script is still running (silent mid-run death after 15 min without inbound messages)

2 participants