Skip to content

MNTR: make barrier failures actually fail the barrier - #8431

Merged
Aaronontheweb merged 4 commits into
akkadotnet:devfrom
Aaronontheweb:fix/mntr-barrier-failure-swallowed
Jul 29, 2026
Merged

Aaronontheweb merged 4 commits into
akkadotnet:devfrom
Aaronontheweb:fix/mntr-barrier-failure-swallowed

Conversation

@Aaronontheweb

Copy link
Copy Markdown
Member

The bug

ClientFSM replied to a failed barrier with new Failure(...):

else if (!barrierResult.Success)
{
    response = new Failure(new Exception("barrier failed:" + ...));
}
...
@event.StateData.RunningOp.Value.Item2.Tell(response);

ClientFSM derives from FSM<,>, so the unqualified name Failure binds to the inherited nested FSMBase.Failure — an FSM termination reason — not Akka.Actor.Failure.

Two independent proofs of the binding:

  • Akka.Actor.Failure declares no constructor taking an argument (only Exception and Timestamp properties), so new Failure(new Exception(...)) cannot bind to it.
  • Akka.Actor.Failure is [Obsolete]. Binding to it would emit CS0618, which breaks this repo's -warnaserror build. The file compiles clean.

FutureActorRef's ask-completion switch faults on exactly three things — ISystemMessage, Status.Failure, and the obsolete Akka.Actor.Failure:

case T t:
    handled = _result.TrySetResult(t);   // ← FSMBase.Failure lands HERE
    break;

So the ask completed successfully. EnterBarrierAsync then 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:

Build Barrier failures in the MNTR log
129447 ClientLost(node-9), BarrierEmpty(node-1), BarrierTimeout 'verified-first', BarrierEmpty(controller)
129814 ClientLost(node-9), BarrierEmpty(node-1)
129819 BarrierTimeout '5-up', BarrierEmpty(first), ClientLost(node-9), BarrierEmpty(node-1)

Every one was swallowed.

The change

Use Status.Failure at all three sites — matching the two places in this same file that already do it correctly (Player.cs:392 and :570 use Status.Failure for 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 BarrierResult still 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.TestKit builds clean (0 warnings, 0 errors).
  • Happy path unchanged: ClusterClientDiscoverySpec (barrier-heavy, 4 tests) passes 4/4 locally.

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 Aaronontheweb left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

LGTM - really simple fix that has probably been hiding / conflating some real failures in our MNTR test suite for years.

@Aaronontheweb
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
Aaronontheweb merged commit dcd9acb into akkadotnet:dev Jul 29, 2026
12 checks passed
@Aaronontheweb
Aaronontheweb deleted the fix/mntr-barrier-failure-swallowed branch July 29, 2026 19:51
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)
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant