Repository navigation
test(runtime): fail drain unit-test hangs instead of stalling PR CI - #8598
Conversation
Unbounded GetResult() gates in DrainOrchestrator/ExecutionCycleHandle tests can stall testhost forever if a release is missed. Timeout those gates, complete leftover snapshot tasks, and make the Nuke PR test target fail a hung testhost in 10 minutes with a blame dump. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
|
cursor review |
sfmskywalker
left a comment
There was a problem hiding this comment.
Elsa 3 Code Review: REQUEST_CHANGES + HIGH @ 050e445
Code Review, Round 1/4
Scope check: 3 files (+80/−39): build/Build.cs and two test files under Elsa.Workflows.Runtime.UnitTests/Quiescence. No production code changed. The branch is 1 commit ahead of release/3.9.0 and 2 behind; the 2 commits are a CI workflow-only change, and the branch is mergeable.
Summary
The blame-hang change is correct and valuable, and the bounded gates are an improvement. However, the hang still reproduces at this head, and it does not come from the tests this PR changes. As written, the PR would make the next hang fail after 10 minutes, but it would not stop it from happening.
Blocking
1. The real hang is in BookmarkQueueAmbientTenantTests, not in the drain/execution-cycle gates.
- I ran the full
Elsa.Workflows.Runtime.UnitTestsproject 5 times at this head with--blame-hang --blame-hang-timeout 10m(net10.0, Release). Runs 1, 2, 4 and 5 passed (435 tests, about 20 s each). Run 3 hung: 434 tests passed, then blame fired after 10 minutes. It namedServices.BookmarkQueueAmbientTenantTests.RecurringTask_ExecuteAsync_SignalsCapturedTenant_WhenAmbientTenantIsMissing. - The hang dump agrees. The async stacks show that test suspended at
await awaiter, itsAwaitTenantAsynclambda waiting onsignaler.AwaitAsync(), andBookmarkQueueWorker.AwaitSignalAsyncwaiting for its next signal. No Quiescence, Drain or ExecutionCycle frames appear, and no gate thread is blocked. - Root cause (a race in the test):
TriggerBookmarkQueueRecurringTask.StartAsynccallsworker.Start(), which runs the realAwaitSignalAsyncloop under tenant-a.- The test's
AwaitTenantAsyncreads the same tenant-a channel.BookmarkQueueSignaleruses a bounded(1)SingleReaderchannel, andExecuteAsyncwrites exactly one token. - When the worker wins that read,
await awaiternever completes, because nothing bounds it.
- A tight-loop reproduction of the test body, with a 1 s bound on the awaiter, missed the signal 13 of 40 times.
- Suggested fix:
- Don't let a live worker compete for the channel. Give
TriggerBookmarkQueueRecurringTaska substituteIBookmarkQueueWorker(the test only asserts which tenant the trigger is written under), or record the tenant in a fake signaler'sTriggerAsync. - Bound the await (
await awaiter.WaitAsync(TimeSpan.FromSeconds(5))) so any future regression fails fast. - It would be worth a quick audit of the other
AwaitTenantAsynccall sites and ofBookmarkQueueTenantIsolationTestsfor the same "worker plus test both read one tenant channel" pattern.
- Don't let a live worker compete for the channel. Give
- Please also update the PR description. The claim that the only unbounded waits are the drain gates doesn't hold.
2. A gate timeout is swallowed, so on its own it cannot fail the test.
WaitForReleasethrowsTimeoutException, but everycancelCallbackgate runs insideExecutionCycleHandle.TryCancel, which catches all non-fatal exceptions (catch (Exception ex) when (!ex.IsFatal())). The same applies to the inline copy inExecutionCycleRegistryTests.- The tests only fail on a missed release because their outer waits (5 s) are shorter than the gate's 10 s bound. I verified both directions with a deliberately broken gate:
- release forgotten, existing 5 s outer wait: fails at 5 s, for both the registry test and the drain test ✓;
- release forgotten, outer wait 20 s: passes after 10 s. The callback times out,
TryCancelswallows the exception and returnsfalse, andAssert.False(...)passes.
- That's latent today, but it's exactly the "timeout returns quietly" case. Make the gate record its own timeout and fail the test from the test thread. For example, give a small gate type a
TimedOutflag (or a captured exception) and check it in thefinally:Assert.False(gate.TimedOut, ...). Alternatively, document and centralise the invariant that the gate bound must exceed every outer wait.
3. Turn the duplicated gate code into one clear helper.
WaitForReleaseis a private helper inDrainOrchestratorWaitTestsbut is copied inline inExecutionCycleRegistryTests.- The cleanup observer is also duplicated, and the copies differ:
ObserveCleanupAsyncignoresTimeoutExceptionandOperationCanceledException, while the inline copy ignores onlyTimeoutException. - The 10 s and 5 s values are scattered magic numbers.
- A small shared test helper would fix this together with item 2, for example a
CallbackGatewithEntered,WaitForRelease(),Release()andAssertNotTimedOut(), plus a sharedObserveCleanupAsyncand named timeouts. It would also make thetry/finallyblocks shorter and easier to read.
Verified OK
- (b) Nuke:
build/_build.csprojpinsNuke.Components10.1.0, which brings inNuke.Common10.1.0. That version hasDotNetTestSettingsExtensions.EnableBlameHang<T>()andSetBlameHangTimeout<T>(string), and"10m"is a documented--blame-hang-timeoutformat. The CI log at this head shows--blame-hang --blame-hang-timeout 10mon every test project, and locally blame produced a hang dump and named the hung test exactly as intended. SnapshotQueueWaitDoesNotExcludeLaterFinds: completinghanginfinallyis good hygiene.- (c) No production code changed.
Non-blocking
- The new comment on the snapshot test says the pending
hangTCS can "keep testhost/coverlet alive". A pending task does not hold a thread, so consider rewording it to "so abandoned Finds complete deterministically". pr.ymldoesn't upload test results, so a CI hang dump (about 480 MB, full dump) is discarded. The console line naming the hung test is the useful part. Consider.SetBlameHangDumpType("mini"), or uploadingtestresults/**on failure.- The 10-minute per-test timeout applies to every test project, which seems fine for this repo.
- The PR template has "Bug fix (behavior change)" ticked, but this is a test and build-only change.
CI and bots @ head
- CI:
ubuntu-latest(Nuke Compile + Test) ✅ green (03:54–04:07 CEST), withElsa.Workflows.Runtime.UnitTestsat 435 passed. GitGuardian and license/cla are green. - Cursor Bugbot: no issues found at
050e4458. - CodeRabbit: skipped because the PR is a draft. Copilot: no review. Greptile: no review or score yet.
- No review threads.
The confirmed testhost hang is RecurringTask_ExecuteAsync_SignalsCapturedTenant_WhenAmbientTenantIsMissing: StartAsync started a live worker that raced the test for the one-slot tenant-a channel. Use a substitute worker and bound the await. Share CallbackGate so a swallowed gate timeout still fails the test, collect mini hang dumps, and upload them on PR CI failure. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
|
Addressing Code Review (round 1) at 050e445. Fixes are in e423ca4. B1 — confirmed hang is B2 — gate timeout must fail the test by itself. B3 — deduplicate. Non-blocking
Verify: |
|
cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit e423ca4. Configure here.
sfmskywalker
left a comment
There was a problem hiding this comment.
Elsa 3 Code Review: APPROVE + HIGH @ e423ca4
Code Review, Round 2/4
All three blocking items from Round 1 are fixed, and I re-verified each one at this head. The scope is still test and build/CI only: 7 files (+218/−107) and no production code. The branch is 2 commits ahead of release/3.9.0 and 2 behind; the 2 commits only touch packages.yml, and the branch is mergeable.
Round 1 items
B1 — bookmark-queue signal race: fixed.
RecurringTask_ExecuteAsync_SignalsCapturedTenant_WhenAmbientTenantIsMissingnow passesSubstitute.For<IBookmarkQueueWorker>(), so the test's awaiter is the only reader of the tenant-a channel.worker.Received(1).Start()keeps coverage thatStartAsyncstarts the worker.- Both
AwaitTenantAsyncsites now go throughWaitForSignalAsync(5 s, with a timeout message that names the tenant). - Real-worker tenant capture stays covered by
BookmarkQueueWorker_CanReadAmbientTenant_WhenStarted. - Verified: the
BookmarkQueueAmbientTenantTestsclass passed 40/40 runs (each a separatedotnet testwith blame-hang). The full project passed 10/10 runs with--blame-hang --blame-hang-timeout 10m --blame-hang-dump-type mini(435 tests, about 20 s each). Round 1 hung once in 5 runs.
B2 — gate timeout must fail the test by itself: fixed.
CallbackGate.WaitForReleaserecordsTimedOutbefore throwing, and every gated test callsAssertNotTimedOut()infinallyafter releasing and observing cleanup.- Verified with a deliberately broken gate (a temporary local probe that wasn't committed). The callback runs under
TryCancel, which swallows the exception:- release done (control): passes;
- release forgotten, 10 s outer wait: fails with "Callback gate '…' timed out waiting for release.";
- release forgotten, 20 s outer wait (passed silently in Round 1): now fails with the same message.
B3 — one clear helper: fixed.
Support/CallbackGate.csreplaces both copies of the gate code. It has oneTimeoutconstant, andObserveCleanupAsynchandlesTimeoutExceptionandOperationCanceledExceptionthe same way.- All five gates in
DrainOrchestratorWaitTestsandExecutionCycleRegistryTestsuse it, and each test'stry/finallyis now shorter and reads the same way.
Round 1 non-blocking items
- The snapshot-test comment is corrected.
.SetBlameHangDumpType("mini")is in place. The CI log at head shows--blame-hang --blame-hang-timeout 10m --blame-hang-dump-type mini.- Artifact upload:
pr.ymlparses as valid YAML. The new step isif: failure(),actions/upload-artifact@v4,if-no-files-found: ignore.- On this green run, GitHub Actions accepted the step and skipped it, as intended.
- I regenerated the workflow from
Build.CI.GitHubActions.cs(--generate-configuration GitHubActions_pr) after stripping the step. The output is byte-identical to the committedpr.yml, so the generator and the YAML stay consistent. - The paths are right. Nuke passes
--results-directory testresults, so blame dumps and Sequence XML land undertestresults/<guid>/. The extra**/*.dmpand**/*Sequence*.xmlglobs are redundant but a harmless safety net.
Non-blocking notes (no change required to merge)
-
One name, two jobs.
CallbackGate.Timeoutis both how long a callback waits for release and the test-side wait forEnteredand the drain/cancel tasks. That used to be 5 s and is now 10 s. It's safe now thatAssertNotTimedOutexists. Splitting it intoReleaseTimeoutand a test-side wait constant would make the intent obvious, and would avoid shadowingSystem.Threading.Timeoutinside the class. -
Repeated two-call pattern. Every callback body is
gate.SignalEntered(); gate.WaitForRelease();. A singlegate.Block()(orEnterAndWaitForRelease()) would remove that small repetition. -
Visibility of
TimedOut. It's written on the callback thread and read on the test thread. The read happens afterObserveCleanupAsyncawaits the task, so in practice it's ordered. AVolatile.Write/Volatile.Readwould make that explicit. -
Follow-up idea: other unbounded test-side awaits. These remain in files this PR doesn't touch:
QuiescenceSignalPersistFailureTests:78(await resumeTask);WorkflowActivationGateTests:98/149(evaluatorStarted,firstReady);LocalWorkflowClientTests:348(runnerStarted);WorkflowCommitNotificationBufferTests:48/50.
They wait on "started" signals that are set unconditionally, so they aren't races. They would only hang on a real regression, and blame-hang now caps that at 10 minutes with a named test. Adding
.WaitAsync(...)would make them fail fast. TheGetResult()atBookmarkQueueTenantIsolationTests:219is a synchronous in-memoryAddAsync, not a gate. -
The PR is still a draft. Please mark it ready for review so CodeRabbit and Greptile can run.
CI and bots @ head
- CI:
ubuntu-latest(Nuke Compile + Test) ✅ green (04:37–04:50 CEST), withElsa.Workflows.Runtime.UnitTestsat 435 passed. The upload step was skipped (success path). GitGuardian and license/cla are green. - Cursor Bugbot: "no new issues" at
e423ca4d. - CodeRabbit: skipped because the PR is a draft. Greptile: no review or score yet. Copilot: no review.
- No review threads.
|
| if-no-files-found: ignore | ||
| path: | | ||
| testresults/ | ||
| **/*.dmp | ||
| **/*Sequence*.xml |
There was a problem hiding this comment.
Ordinary failures lack results
On an ordinary test failure, this upload can find no files: the PR test run does not request a TRX logger, and blame-hang produces no dump or sequence file when tests finish. With if-no-files-found: ignore, the failure remains in the job log but has no test-results artifact for later inspection. Configure result output for PR runs and keep the upload path in sync with the workflow generator. This is a non-blocking diagnostic gap.
Artifacts
Full source of the executed test-results reproduction
- The executed script creates one failing xUnit test and runs the workflow-like and TRX-enabled control commands, showing exactly how the local behavior was tested.
Workflow and build configuration inspection
- A captured Git diff and configuration search identifies the upload paths and conditional results-directory setting, showing the code paths behind the finding.
Successful restore of the temporary xUnit project
- The captured restore command exited 0 before either test run, showing the test project was ready to execute.
Ordinary failing test without uploadable result files
- The workflow-like command exited 1 on an assertion failure and found zero TRX, dump, or Sequence XML files, showing the missing-output case.
Same failing test with explicit TRX output
- The control command also exited 1 but wrote one TRX file under `testresults/`, showing that the configured upload path can contain the failure result.
Completed reproduction script invocation
- The captured script invocation exited 0 after verifying both expected test failures and the zero-versus-one result-file comparison.
Post-test cleanup and tracked-file status
- The captured cleanup and status check shows the control TRX was removed and no tracked files were changed.
Ran code and verified through T-Rex
Prompt To Fix With AI
This is a comment left during a code review.
Path: .github/workflows/pr.yml
Line: 50-54
Comment:
**Ordinary failures lack results**
On an ordinary test failure, this upload can find no files: the PR test run does not request a TRX logger, and blame-hang produces no dump or sequence file when tests finish. With `if-no-files-found: ignore`, the failure remains in the job log but has no test-results artifact for later inspection. Configure result output for PR runs and keep the upload path in sync with the workflow generator. This is a non-blocking diagnostic gap.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Forward-merge of release/3.9.0 (e606a53) after the 3.9.0 release. Conflict resolutions: - packages.yml: keep main (base_version 3.10.0; OIDC push already on main via elsa-workflows#8597/elsa-workflows#8601). - doc/migrations/authorization-model.md, doc/wiki/identity-tenancy-security.md: keep main (forward-ported versions with later wording; refresh-token cross-link already present). - MemoryKeyValueStore.cs: take release (elsa-workflows#8434 tenant isolation, elsa-workflows#8539 TryDeleteAsync is visibility-filtered); main never had the elsa-workflows#8141 restore (elsa-workflows#8430). - QuiescenceSignalPauseKeyTenantAgnosticTests.cs: take release (store now tenant-isolated). - DrainOrchestratorWaitTests.cs, BookmarkQueueAmbientTenantTests.cs: take release (elsa-workflows#8598). - EmptyPermissionsClaimTests.cs: take release (reset FastEndpoints resolver before dispose, elsa-workflows#8565 rule). - AuthorizationTestHost.cs, ResilienceStrategiesAuthorizationTests.cs, InstalledFeaturesAuthorizationTests.cs: keep main (disposes HttpRequestMessage). - DrainRecoveryEndToEndTests.cs: keep main (stricter "second" exactly once). - RefreshTokenEndpointTests.cs, DefaultElsaTokenServiceTests.cs, DefaultIdentityRefreshTokenServiceTests.cs, Elsa.Identity.UnitTests.csproj, QuiescenceSignalPauseKeyTenantAgnosticEfTests.cs: keep main (same or superset coverage; main-specific comments).
Purpose
Stop
Elsa.Workflows.Runtime.UnitTestsfrom hanging the Nukeprworkflow, and make any remaining hang fail in 10 minutes with a mini dump uploaded on failure.Scope
Select one primary concern:
This is test- and CI-only. No production behavior change.
Description
Problem
On
release/3.9.0, Nukeubuntu-latesthung twice inElsa.Workflows.Runtime.UnitTestswith no testhost output until cancel. Code Review reproduced it at050e4458and named the hung test:BookmarkQueueAmbientTenantTests.RecurringTask_ExecuteAsync_SignalsCapturedTenant_WhenAmbientTenantIsMissingTriggerBookmarkQueueRecurringTask.StartAsyncstarted a liveBookmarkQueueWorkerthat reads tenant-a's one-slotSingleReaderchannel. The test'sawait awaiterreads the same channel.ExecuteAsyncwrites exactly one signal, so when the worker wins the read the unbounded await waits forever (13/40 in CR's tight loop). Drain/execution-cycle frames were not in the hang dump.Solution
IBookmarkQueueWorkerso nothing else consumes the signal, and the await is bounded to 5s with a clear timeout. The notification-handler test in the same class is bounded the same way.BookmarkQueueTenantIsolationTests/ distributed worker tests wait on processor call counts with a 3sWaitUntilAsyncand do not have a competing test-sideAwaitAsync.CallbackGaterecords a gate timeout andAssertNotTimedOut()infinally, so a swallowedTryCancelexception still fails the test. OneTimeoutconstant and sharedObserveCleanupAsync(TimeoutException + OCE).--blame-hang-dump-type mini, uploaded frompr.yml/ the Nuke GitHub Actions generator on failure.Verification
Local loops at
e423ca4d3:BookmarkQueueAmbientTenantTests: 40/40 passed (--blame-hang-timeout 2m)Elsa.Workflows.Runtime.UnitTestswith coverlet + blame-hang: 10/10 passed, 435 tests, 15–16s eachChecklist