Repository navigation
MNTR: make barrier failures actually fail the barrier - #8431
Merged
Aaronontheweb merged 4 commits intoJul 29, 2026
Merged
Aaronontheweb merged 4 commits into
Aaronontheweb merged 4 commits into
Conversation
ClientFSM replied to a failed barrier with 'new Failure(...)'. Because ClientFSM derives from FSM<,>, that unqualified name binds to the inherited nested FSMBase.Failure - an FSM termination reason - not Akka.Actor.Failure. Two things prove the binding: Akka.Actor.Failure declares no constructor taking an argument (only Exception/Timestamp properties), and it is [Obsolete], so binding to it would emit CS0618 and break the -warnaserror build. FutureActorRef's ask-completion switch faults only on ISystemMessage, Status.Failure and Akka.Actor.Failure. FSMBase.Failure matches none of them, so it fell through to 'case T t: TrySetResult(t)' and the ask COMPLETED SUCCESSFULLY. EnterBarrierAsync discarded the result and logged 'passed barrier', so every non-success outcome - timeout, wrong barrier, client lost, duplicate node - was silently swallowed and the node walked on unsynchronized. The damage is misattribution rather than fabricated passes: when one node fails and never reaches a barrier, the remaining nodes should fail cleanly on a broken rendezvous. Instead they continued into the next phase out of sync and produced their own unrelated failures, turning one localized error into a multi-node mess. Barrier failures are present in real CI runs (builds 129447, 129814, 129819: BarrierTimeout, ClientLost, BarrierEmpty), and every one of them was swallowed. Use Status.Failure at all three sites, matching the two places in this same file that already got it right. Also drop the unused ask result now that a failure propagates as an exception. Healthy barriers are unaffected - a successful BarrierResult still replies with the barrier name.
Aaronontheweb
commented
Jul 29, 2026
Aaronontheweb
left a comment
Member
Author
There was a problem hiding this comment.
LGTM - really simple fix that has probably been hiding / conflating some real failures in our MNTR test suite for years.
Aaronontheweb
enabled auto-merge (squash)
July 29, 2026 16:20
…r-node The spec wrapped cluster formation in RunOn(..., first). FormCluster already does its own per-role RunOn, so the inner RunOn(join, second/third) ran on `first`, where IsNode(second) is false: second and third never joined, and only `first` entered the two barriers inside FormCluster. The spec was an accidental one-node cluster, which is why all four daemon processes landed on `first` and the 'expect 4 events' assertion passed. FormCluster could not catch this on its own. Its wait asserted that the members it knows about are Up - trivially true for a one-member ring - rather than that the ring has three members. Replaced it with AwaitClusterUpAsync, the helper three sibling specs in this project already use, which waits for all three members Up plus leader convergence on every node. That deletes the duplicated helper and makes a silent single-node cluster impossible to miss again. With a real cluster the entities distribute, and the assertion breaks. Each node called Init with its own local probe captured in the Props closure, so a node only ever observed the entities it hosted; `first` waited for four events and saw one or two. Verified by keeping the local-probe assertion against a correctly formed three-node cluster: it fails with a fishForMessage timeout. Now one collector probe is created on `first` under a fixed name, and every node resolves that same ref (TestKit probes are created via SystemActorOf, so it lives under /system) and passes it into the entity Props. Props are built locally per node by the sharding entity factory and never serialized, so a remote ref in the closure is fine. The assertion fishes until all four distinct ids have reported rather than counting exactly four messages, since a rebalance can restart an entity and repeat its id. The event carries the hosting node's address so the run reports actual placement; placement is logged, not asserted, because a single-node placement is legal. Observed placement is three distinct nodes on every run. Two supporting fixes: - keep-alive-interval never applied. The config block used doubled braces, a leftover from a string.Format-style template that is not needed in a verbatim string. HOCON parsed them as nested anonymous objects and dropped keep-alive-interval, so the spec ran on the 10s default instead of 1s and a missed initial start waited 10s for the next ping. retry-interval survived, which is why this went unnoticed. Braces corrected. - barrier-timeout raised to 60s. `first` now sits in front of the trailing barrier while cross-node shard allocation completes, and the 30s default left no room above the assertion's own bound. Spec methods are async Task throughout so the barrier, await-assert and fish calls use the async TestKit API instead of blocking. 12 consecutive clean runs, 6 of them with all cores saturated (load average 41), zero barrier failures.
The runner awaited each node process with a bare 'await task', whose only cancellation was the run-wide xunit token that nothing on this path trips. A node that failed without exiting cleanly therefore hung the runner indefinitely - observed on a barrier-failure run as a spec failing at ~70s followed by tens of minutes of silence, turning a 15-25 minute job into 1h07m. Bound the wait: honour [MultiNodeFact(Timeout = ...)] when the spec sets it (the attribute already existed and was accepted but never used), otherwise fall back to a deliberately generous 20 minute ceiling - the longest specs here budget ~10 minutes of node runtime, so this only fires on a genuine hang. On expiry, kill the process tree and report the node as failed with the timeout in the message rather than waiting forever. Also fix the NodeCompletedSpecWithFail message, which reported '<spec> passed.' on the failure path.
Aaronontheweb
added a commit
that referenced
this pull request
Aug 26, 2026
Barrier failures now actually fail the barrier: three Player.cs sites replied with the FSM-inherited Failure type, so the barrier ask completed successfully and the failure was silently swallowed - the asking node walked on unsynchronized and the breakage surfaced elsewhere as an unrelated-looking flake. All three now reply Status.Failure, plus the discarded-ask cleanup and the runner mislabeling fix. Conductor keeps re-registered nodes: a restarting node's stale ClientDisconnected was matched by role name and evicted the fresh registration, then the barrier coordinator dropped the evicted node's arrivals with no reply, hanging it for the full ask timeout. Disconnects are now matched against the registered FSM identity, unregistered arrivals get an explicit BarrierResult(false), and ReleaseAll detaches the shared event-loop groups. Validated locally with revert-proven tests and three consecutive green ReDeployment MNTR runs. With barrier failures now honest, latent v1.5 spec failures may surface attributed to the node that actually broke - the intended effect. Dev's node-hang kill backstop was deliberately not taken: it needs Process.Kill(entireProcessTree:), unavailable on netstandard2.0; needs a netstandard-safe follow-up.
Aaronontheweb
added a commit
that referenced
this pull request
Aug 27, 2026
Backport of dev commit dcd9acb to v1.5, scoped to the ShardedDaemonProcessSpec rewrite. The Player.cs / MultiNodeTestRunner.cs portions of #8431 were already delivered to v1.5 in the MNTR conductor reliability backport (#8488), so only the spec change is taken here. Rewrite the ShardedDaemonProcess multinode spec to be async and resistant to slow-CI barrier timeouts: - task-returning TestKit methods throughout (no sync-over-async) - single cluster-wide collector probe on 'first' so shards reallocated during cluster settle are reported correctly - fish for a complete distinct-ID set instead of assuming 4 messages - raise testconductor barrier-timeout to 60s for slow agents - fix HOCON brace nesting that dropped keep-alive-interval Adapted to v1.5: AwaitClusterUpAsync(CancellationToken.None, ...) to match the v1.5 TestKit signature. (cherry picked from commit dcd9acb)
This was referenced Aug 27, 2026
Closed
This was referenced Aug 30, 2026
This was referenced Sep 9, 2026
Aaronontheweb
added a commit
that referenced
this pull request
Sep 12, 2026
…ia stdout sentinel (#8515) * Remove the MNTR conductor port race: bind port 0 and propagate The multi-node runner picked the TestConductor's port by binding a temporary socket, reading the port, closing the socket, and handing the number to node 1. The number is in the ephemeral range by construction, so any outbound connection on the machine can take it between the probe and the conductor's real bind. When that happens node 1 dies with "Address already in use" and every other node waits out a 30 second attach timeout against a conductor that never existed. The runner now starts node 1 alone with multinode.server-port=0. The node binds a free port, prints it as a sentinel line, and the runner starts the remaining nodes with the port the conductor actually holds. There is no window to lose. - ConductorPortSentinel: the stdout contract between a conductor node and the runner, with a strict parser. - Controller reports its bind result through a TaskCompletionSource instead of an ask. A failed bind now throws ConductorBindException naming the port, straight away, rather than surfacing as a query timeout. - MultiNodeSpec accepts server-port=0 on the conductor node and rejects it on client nodes, and emits the sentinel once the conductor is bound. - When the conductor node exits early or never publishes a port, the runner kills it and fails the nodes it never started with a message naming the cause. A spec that used to hang for minutes now fails in under a second. Explicitly configured ports are unchanged: the node binds that exact port and emits the sentinel anyway, so running node processes by hand still works. Applied to both the xUnit v3 and xUnit v2 adapters. * Model the conductor bind as a Controller behavior switch The Controller constructor blocked on the DotNetty bind, so a dispatcher thread sat in the actor's constructor waiting on I/O and a failed bind escaped as an exception out of construction. The constructor now only assigns fields. PreStart starts the bind and PipeTo's the outcome back into the mailbox as Bound or BindFailed, so the bind never blocks a thread and its result is serialized with every other message. - Binding (initial behavior): Bound assigns the connection, creates the BarrierCoordinator, completes the bind TCS - barrier first, report second, as before - and becomes Ready. BindFailed reports the failure and stops. It must stop rather than throw: a throw from a message handler runs the default supervision directive, Restart, which re-runs PreStart and re-binds the same dead port in a loop. - Anything else arriving during Binding is stashed. The socket starts accepting the moment the bind completes, which is before the actor processes Bound, so a client that connects in that window can get CreateServerFSM in ahead of it. - Ready is the previous receive, now entitled to a non-null connection and barrier by construction of the state machine. - The bind failure is wrapped into ConductorBindException at the pipe, and the single-exception AggregateException a piped failure arrives in is unwrapped, so the reported cause is the socket error itself. - PostStop guards a null connection and no longer blocks on the pool drain. ReleaseAll detaches the pools before it returns, so a later CreateConnection builds fresh ones either way. The caller contract is unchanged: StartControllerAsync still awaits the same TCS, still gets the endpoint only after the barrier exists, and still sees a failure carrying the named error. Node 1 emits its port sentinel at the same point, between the bind and the wait for players. (cherry picked from commit 2ece2c6) v1.5 backport note: the conflicting hunk in MultiNodeTestRunner.cs bundled this change's own _conductorPortSource field together with DefaultNodeExitTimeout, which was pure context here but is itself the payload of dev's earlier #8431 (a node-exit backstop that kills a hung node process after a timeout). v1.5's own #8492 backport of #8431 had dropped this file's hunk, so DefaultNodeExitTimeout did not exist on v1.5 before this pick. Taking dev's whole file for this merge - the only way to give the field a real consumer - therefore also lands the #8431 node-exit backstop on v1.5 for the first time, not just the #8515 port-sentinel change.
Aaronontheweb
added a commit
that referenced
this pull request
Sep 12, 2026
…ard2.0 has no process-tree kill overload) The #8515 cherry-pick's MultiNodeTestRunner.cs conflict was resolved by taking dev's full file (needed to bring in #8431's node-exit-timeout backstop alongside #8515's port-sentinel change, so the new DefaultNodeExitTimeout field would actually be used - see that commit's message). Building Akka.Cluster.Tests.MultiNode surfaced a genuine v1.5 incompatibility that dry-run text diffing could not catch: dev compiles Akka.MultiNode.TestAdapter against net10.0, where `Process.Kill(bool entireProcessTree)` exists, but this project's target on v1.5 is netstandard2.0 (a single TFM), whose Process surface only has the parameterless `Kill()`. Switched to `process.Kill()`, matching the convention already used for the same reason in Akka.MultiNode.RemoteHost/RemoteHost.cs. The only behavior difference is that a hung node's child processes (if it spawned any) are no longer force-killed alongside it - the backstop still kills the node process itself and still unblocks the runner.
Aaronontheweb
added a commit
that referenced
this pull request
Sep 12, 2026
…ard2.0 has no process-tree kill overload) The #8515 cherry-pick's MultiNodeTestRunner.cs conflict was resolved by taking dev's full file (needed to bring in #8431's node-exit-timeout backstop alongside #8515's port-sentinel change, so the new DefaultNodeExitTimeout field would actually be used - see that commit's message). Building Akka.Cluster.Tests.MultiNode surfaced a genuine v1.5 incompatibility that dry-run text diffing could not catch: dev compiles Akka.MultiNode.TestAdapter against net10.0, where `Process.Kill(bool entireProcessTree)` exists, but this project's target on v1.5 is netstandard2.0 (a single TFM), whose Process surface only has the parameterless `Kill()`. Switched to `process.Kill()`, matching the convention already used for the same reason in Akka.MultiNode.RemoteHost/RemoteHost.cs. The only behavior difference is that a hung node's child processes (if it spawned any) are no longer force-killed alongside it - the backstop still kills the node process itself and still unblocks the runner.
Aaronontheweb
added a commit
that referenced
this pull request
Sep 12, 2026
…ard2.0 has no process-tree kill overload) The #8515 cherry-pick's MultiNodeTestRunner.cs conflict was resolved by taking dev's full file (needed to bring in #8431's node-exit-timeout backstop alongside #8515's port-sentinel change, so the new DefaultNodeExitTimeout field would actually be used - see that commit's message). Building Akka.Cluster.Tests.MultiNode surfaced a genuine v1.5 incompatibility that dry-run text diffing could not catch: dev compiles Akka.MultiNode.TestAdapter against net10.0, where `Process.Kill(bool entireProcessTree)` exists, but this project's target on v1.5 is netstandard2.0 (a single TFM), whose Process surface only has the parameterless `Kill()`. Switched to `process.Kill()`, matching the convention already used for the same reason in Akka.MultiNode.RemoteHost/RemoteHost.cs. The only behavior difference is that a hung node's child processes (if it spawned any) are no longer force-killed alongside it - the backstop still kills the node process itself and still unblocks the runner.
This was referenced Sep 20, 2026
This was referenced Oct 4, 2026
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.
The bug
ClientFSMreplied to a failed barrier withnew Failure(...):ClientFSMderives fromFSM<,>, so the unqualified nameFailurebinds to the inherited nestedFSMBase.Failure— an FSM termination reason — notAkka.Actor.Failure.Two independent proofs of the binding:
Akka.Actor.Failuredeclares no constructor taking an argument (onlyExceptionandTimestampproperties), sonew Failure(new Exception(...))cannot bind to it.Akka.Actor.Failureis[Obsolete]. Binding to it would emit CS0618, which breaks this repo's-warnaserrorbuild. The file compiles clean.FutureActorRef's ask-completion switch faults on exactly three things —ISystemMessage,Status.Failure, and the obsoleteAkka.Actor.Failure:So the ask completed successfully.
EnterBarrierAsyncthen discarded the result and logged"passed barrier {0}". Every non-success outcome — timeout, wrong barrier, client lost, duplicate node, and the post-failure short-circuit — was silently swallowed, and the node continued through a rendezvous that never happened.Why it matters
It does not fabricate passing tests. It destroys attributability.
When one node fails an assertion and never reaches the next barrier, the remaining nodes should fail cleanly on a broken rendezvous, naming the barrier. Instead they walked into the following phase unsynchronized and generated their own unrelated failures. One node's localized, diagnosable error became a multi-node pile of mutually-confusing symptoms — which is a large part of why de-flaking this suite has been so difficult.
Barrier failures are not hypothetical. They appear in real CI runs:
ClientLost(node-9),BarrierEmpty(node-1),BarrierTimeout 'verified-first',BarrierEmpty(controller)ClientLost(node-9),BarrierEmpty(node-1)BarrierTimeout '5-up',BarrierEmpty(first),ClientLost(node-9),BarrierEmpty(node-1)Every one was swallowed.
The change
Use
Status.Failureat all three sites — matching the two places in this same file that already do it correctly (Player.cs:392and:570useStatus.Failurefor exactly this purpose, including one for the same "not connected yet" message the fixed line 424 got wrong).Also drops the now-unused ask result, since a failure propagates as an exception rather than needing inspection.
Healthy barriers are unaffected — a successful
BarrierResultstill replies with the barrier name, and the ask still completes normally.Expect the failure count to rise
This surfaces failures that were already happening and were being hidden. A CI run on this branch is expected to show more failures than before, and they should be considerably easier to attribute to the node and barrier that actually caused them. That is the intent.
Verification
Akka.Remote.TestKitbuilds clean (0 warnings, 0 errors).ClusterClientDiscoverySpec(barrier-heavy, 4 tests) passes 4/4 locally.