fix(tui): register reset completion-pause timer before publishing step 3#1573
Merged
Aaronontheweb merged 4 commits intoJul 3, 2026
Merged
Conversation
RunResetAsync publishes CurrentProgressStep = 3 ("Purge complete") and then
evaluates the Task.Delay(CompletionPause, _timeProvider, ct) that registers the
completion-pause timer. Because RunResetAsync runs off the loop, step 3 is the
signal observers gate on to know deletion finished — but it fired BEFORE the
timer it vouches for existed. On a virtualized clock (FakeTimeProvider in tests,
and any consumer advancing the injected TimeProvider) this is a lost wakeup: an
Advance that lands between "step 3 published" and "timer registered" is dropped,
the delay never fires, and the reset hangs forever awaiting a wakeup that will
never come.
Fix: create the completion-pause timer before publishing step 3, then await the
captured task. This makes "step 3 observed" a happens-before guarantee that the
pause timer already exists, so a single clock advance always fires it
deterministically. On the real clock this shifts timer registration a few
microseconds earlier; the visible "Purge complete" dwell is unchanged.
Evidence: hang-dump forensics of CI run 28676578217 (PR netclaw-dev#1568, Netclaw.Cli.Tests
blame-hang after 300s). Test FullReset_AfterBothConfirmations_DeletesEverything
was parked at `await vm.ResetTask!.WaitAsync(ct)`; its CompleteResetAsync helper
had exhausted its advance loop (counter i == 10, all virtual-clock advances
spent) and FakeTimeProvider was left holding exactly one orphaned waiter — the
delay registered after the final Advance. Prior commits 850cf8c (netclaw-dev#1525) and
eb01b49 (netclaw-dev#1536) band-aided the symptom with advance/yield retry loops; this
removes the race at the source.
The test helper CompleteResetAsync is shared by all three sibling tests
(FullReset_AfterBothConfirmations_DeletesEverything,
SetupOnlyReset_DeletesConfigButKeepsMemoryAndSessions,
DaemonStopFailureResult_ShowsStatusAndContinuesReset); it now waits for step 3
then does exactly one advance, deleting the retry loop that only ever masked the
lost wakeup on loaded runners.
Aaronontheweb
commented
Jul 3, 2026
Aaronontheweb
left a comment
Collaborator
Author
There was a problem hiding this comment.
no real behavior changes, just a test bug fix. Hang dump analysis confirmed this.
Aaronontheweb
enabled auto-merge (squash)
July 3, 2026 19:51
Aaronontheweb
added a commit
that referenced
this pull request
Jul 3, 2026
The mcp-permissions-server-list frame intermittently captured the raw shell transcript instead of the rendered `netclaw mcp permissions` TUI (CI run 28680686756, job "Screenshot Regression (Linux)" on PR #1573). Root cause: both Wait+Screen anchors preceding the Frame 1 Screenshot matched text that is already sitting in the shell scrollback BEFORE the TUI paints, so both waits returned instantly: - `Wait+Screen@15s /smoke-math/` matched the setup output "Added MCP server 'smoke-math' (stdio)" and "...adjust approvals for 'smoke-math'." printed by `netclaw mcp add`. - `Wait+Screen@5s /3 tools/` matched the tape's OWN typed readiness loop, still visible on screen: `until netclaw mcp list 2>/dev/null | grep -q 'connected (3 tools)'; do sleep 2; done`. With both anchors pre-satisfied, the only remaining delay was the documented `Sleep 3s` settle guard. On a loaded CI runner the TUI took longer than 3s to paint, so Screenshot fired while the screen still showed the shell transcript, failing the byte-for-byte baseline comparison. This violates the tapes/README.md rule to anchor every step on stable substrings from the NEXT view. Fix: anchor Frame 1 on strings that only the rendered TUI produces: - `MCP Permissions` — the page title from McpToolPermissionsPage.BuildHeader; proves the alternate screen buffer painted. Nothing in the setup transcript prints this string. - `Connected, 3 tools` — the exact server-row text from McpToolPermissionsPage.BuildServerList, rendered as "{Name} ({Status}, {ToolCount} tools)". The daemon reports the state as PascalCase "Connected" (McpConnectionState.ToString()); the CLI transcript form is lowercase and comma-less ("connected (3 tools)"), so the two can never collide. The `Sleep 3s` settle guard is kept: it protects against a different hazard (daemon state refresh immediately after first render) and the committed baseline was captured with it in place. The tool-grid frame's anchors (/record-tasks/, /Server default:/) and the other screenshot tapes were audited for the same hazard: all of their pre-capture anchors match only post-TUI content, so no other changes were needed. Validation: `scripts/smoke/run-smoke.sh screenshots` passed 3 consecutive runs locally, all 9 frames matching baselines (mcp-permissions-server-list at AE=0 each run).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes a lost-wakeup race in the "existing install" reset flow that intermittently hangs
Netclaw.Cli.Testsin CI (300s blame-hang → dump). The fix is a signal-ordering change: register the completion-pause timer before publishing the step-3 "Purge complete" signal, so the signal never precedes the resource it vouches for.This was found via hang-dump forensics on an unrelated CI failure and is independent of the PR that surfaced it (#1568) — it fixes a pre-existing race, not that PR's changes.
Root cause — signal before resource
RunResetAsyncruns off the UI loop. Its old shape:PublishOnLoop(... CurrentProgressStep = 3 ...)— publishes "Purge complete"await Task.Delay(CompletionPause, _timeProvider, ct)— registers the completion-pause timer, then awaits itObservers (including the test helper) gate on step 3 to know deletion finished. But step 3 was published in step (1), before the timer that (2) creates even exists. On a virtualized clock, a clock
Advancethat lands in the window between "step 3 published" and "timer registered" is dropped — a classic lost wakeup. The delay never fires and the reset hangs forever awaiting a wakeup that will never arrive.Dump evidence (CI run 28676578217, PR #1568)
Netclaw.Cli.Testsblame-hung after 300s. Dump forensics:InitExistingInstallViewModelTests.FullReset_AfterBothConfirmations_DeletesEverythingwas parked atawait vm.ResetTask!.WaitAsync(ct).CompleteResetAsynchelper had spent every virtual-clock advance — loop counteri == 10(all 10 advances exhausted).FakeTimeProviderwas left holding exactly one registered waiter — i.e.RunResetAsyncregistered itsTask.Delay(CompletionPause, ...)timer after the finalAdvance. The advance-before-registration window is precisely the lost wakeup.The fix — happens-before, deterministic
Create the completion-pause timer first, then publish step 3, then await the captured task:
Determinism argument: creating the timer first makes "step 3 observed" a happens-before guarantee that the pause timer already exists. So a single clock advance always fires it — there is no window in which an advance can be lost. On the real clock this shifts timer registration a few microseconds earlier; the visible "Purge complete" dwell is unchanged.
The shared test helper
CompleteResetAsyncis simplified to match the new invariant — wait for step 3, then do exactly one advance:The old
for (i < 10) { Advance; yield }retry loop only ever masked the advance-before-registration lost wakeup on loaded CI runners; it is deleted, not retuned.Pre-existing status
This race is pre-existing. Prior commits band-aided the symptom with advance/yield retry loops rather than fixing the ordering:
This PR removes the race at the source, so the retry loop is no longer needed.
Coverage
All three sibling tests exercise the fixed code through the shared
CompleteResetAsyncpath:FullReset_AfterBothConfirmations_DeletesEverythingSetupOnlyReset_DeletesConfigButKeepsMemoryAndSessionsDaemonStopFailureResult_ShowsStatusAndContinuesResetInitExistingInstallPageTests.cswas analyzed and left untouched — it is immune (it completes via the cancellation path, not the completion-pause timer).Validation
dotnet build src/Netclaw.Cli.Tests/Netclaw.Cli.Tests.csproj -c Release— 0 warnings, 0 errors.--filter FullyQualifiedName~InitExistingInstallViewModelTests) — 20/20 passed, none hung (15 tests each, ~100 ms/run).dotnet test src/Netclaw.Cli.Tests -c Release --no-build— 1196 passed, 0 failed, 0 skipped../scripts/smoke/run-smoke.sh init-wizard— OK (all smoke checks passed; doctor exit 2 is expected WARN-only for the temp-dir session base and absent secrets.json in the smoke sandbox).dotnet slopwatch analyze— 0 issues found.scripts/Add-FileHeaders.ps1 -Verify— all files have headers.