Skip to content

Backport v1.5.71: ClusterClientDiscovery fix, PromiseActorRef torn read, Remote throttler, TestKit AutoDilate - #8466

Merged
Aaronontheweb merged 6 commits into
akkadotnet:v1.5from
Aaronontheweb:backport/v1.5.71
Aug 11, 2026
Merged

Aaronontheweb merged 6 commits into
akkadotnet:v1.5from
Aaronontheweb:backport/v1.5.71

Conversation

@Aaronontheweb

Copy link
Copy Markdown
Member

Backport of 5 merged dev PRs to v1.5 for the v1.5.71 maintenance release.

Changes

API approval

Includes the AutoDilateAttribute additions to CoreAPISpec.ApproveTestKit.DotNet/Net.verified.txt (new public type from #8441). Verified locally: all 15 net10.0 approval tests pass.

Notes

…ss rediscovery (akkadotnet#8426)

* Fix ClusterClientDiscovery: preserve contact-point subscriptions across rediscovery

ClusterClientDiscovery supervises the real ClusterClient as a child and recreates that child from
scratch on every rediscovery (when the current child exhausts its reconnect-timeout and stops). The
contact-point subscriber list lives on the child, so a SubscribeContactPoints subscriber silently
stopped receiving ContactPoints/ContactPointAdded/ContactPointRemoved once the client rediscovered —
the fresh child had no record of it.

Track contact-point subscribers at the supervisor level and re-subscribe them onto each newly-created
child (on the subscriber's behalf, so the child registers them and replies with the current snapshot).
DeathWatch each subscriber so a terminated subscriber is dropped from tracking (auto-unsubscribe).
Subscribe/Unsubscribe are still forwarded to the current child, so live behavior is unchanged; only the
across-rediscovery gap is closed.

* ClusterClientDiscoverySpec: verify contact points via subscription events

Replace the three GetContactPoints polling blocks (each with an orphaned, hand-tightened 1s inner
ExpectMsg that spuriously timed out on loaded CI agents) with a single event-driven helper: subscribe
via SubscribeContactPoints and wait on the client's own ContactPoints/ContactPointAdded/ContactPointRemoved
stream until the contact points settle to exactly the expected node. No per-attempt reply timeout to race.

This also covers the ClusterClientDiscovery subscription-survival fix: the second and third phases exercise
rediscovery after a graceful down and a hard shutdown, so the subscription must survive the child being
recreated for the helper to observe the new node. Verified locally, 4/4 across repeated runs.
…ol>, ...) (akkadotnet#8430)

This overload resolved its bound as RemainingOrDilated(RemainingOrDilated(timeout)), applying
akka.test.timefactor twice. Every sibling overload applies it once.

With timefactor = 3 an explicit 5s wait became 45s. The null case is worse: the inner call resolves
to RemainingOrDefault - the time actually left in the enclosing Within - and the outer call then
dilates that, producing a bound larger than the remaining budget it was derived from.

Apply RemainingOrDilated once, matching every other overload. No in-repo caller uses this overload,
so nothing here was relying on the inflated bound; Akka.TestKit.Tests (320) and
Akka.TestKit.Xunit2.Tests (4) pass.
…kkadotnet#8436)

* Fix torn read in PromiseActorRef.GetPath that made Path return null

`PromiseActorRef.GetPath()` matched on its `State` field and then re-read
that same field to build the return value:

    switch (State)                      // read #1 - matches ActorPath
    {
        ...
        case ActorPath _:
            return State as ActorPath;  // read #2 - re-reads the field

`State` is an `AtomicReference<object>` whose getter is a `Volatile.Read`, so
those are two genuinely separate loads of a field any thread can mutate. A
concurrent `Stop()` flipping the state from `ActorPath` to `StoppedWithPath`
between the two reads makes the `as` cast fail, and `Path` returns **null** -
violating the method's own contract ("Must always return the same ActorPath").
The sibling `case StoppedWithPath stoppedWithPath:` was already correct because
it binds the pattern variable instead of re-reading.

A null path then dereferences twice, unguarded, in `ActorRefBase.GetHashCode()`
and blows up. Observed in CI:

    System.NullReferenceException
       at Akka.Actor.ActorRefBase.GetHashCode()
       at System.Collections.Generic.HashSet`1.Remove(T item)
       at Akka.Actor.FullActorState.RemoveWatchedBy(IActorRef)
       at Akka.Actor.ActorCell.RemWatcher(...)
       at Akka.Actor.ActorCell.SysMsgInvokeAll(...)

The interleaving comes from `AskModeWithDeathCompletion` in
ThrottleTransportAdapter, which does Watch -> Tell -> SendSystemMessage(Unwatch)
while the promise completes and stops on another thread. Throwing out of
`SysMsgInvokeAll` kills an arbitrary actor mid-Unwatch; in the observed case it
restarted a transport throttler into a permanently broken state.

Fix, in two parts:

* `GetPath()` now snapshots `State` into a local at the top of each loop
  iteration and switches on the local, binding the pattern variable
  (`case ActorPath path: return path;`). The snapshot is per-iteration, not
  hoisted out of the loop, so the `Stopped`/`Registering`/`null` cases still
  re-read after the state transition they wait on. All other case semantics
  are unchanged.

* `ActorRefBase.ToString/GetHashCode/Equals/CompareTo` cache `Path` (and the
  other ref's `Path`) into locals instead of dereferencing the virtual property
  two to four times per call. No null guard is added: after the `GetPath()`
  fix, every return path is non-null - a failing `Provider.TempPath()` throws
  rather than returning null - so null is impossible at the source and
  swallowing it here would only hide the next such bug. The local cache is
  still correct practice: it removes the torn-read window for any `IActorRef`
  whose `Path` is computed from mutable state, and drops redundant virtual
  calls (`GetPath()` is a CAS loop, not a field read).

Regression test: `PromiseActorRefSpec` pins one `PromiseActorRef` between a
reader thread spinning on `GetHashCode`/`Path` and a stopper thread that waits
until the reader is demonstrably inside that spin before calling `Stop()`, so
the state flip always lands with a read in flight. Against the unfixed code it
reproduced 5/5 runs, 100-240 null paths and 90-290 NREs per 2,000 rounds, with
the failure stack matching the CI report. It passes 8/8 runs after the fix and
takes ~250ms.

Akka.Tests: 1306 passed, 0 failed, 23 skipped.
Akka.Remote.Tests: 645 passed, 0 failed, 5 skipped.
Akka.API.Tests: 18 passed - no public API surface change.

* Narrow to the actual fix: drop the ActorRef caching and the race-repro spec

Two things did not earn their place in this change.

The ActorRef.cs Path caching was presented as protection against a torn read, but it protects against
nothing: PromiseActorRef.Stop() transitions ActorPath -> StoppedWithPath(p) carrying the SAME path
instance (Futures.cs:608), so two consecutive Path reads cannot disagree. Reverting Futures.cs while
keeping the caching also still reproduced the NRE, proving the caching was never the fix. What
remained was a micro-optimization with a misleading safety comment, widening a change that should be
trivially reviewable and backportable.

PromiseActorRefSpec reproduced the race by spinning thousands of rounds hoping to hit an
interleaving. It did its job proving the fix during development, but shipping a probabilistic,
CPU-heavy timing test into the permanent suite adds exactly the kind of flake this suite is being
cleaned of.

What is left is the one defect: GetPath() matched on State and then re-read the field, so a
concurrent Stop() made 'State as ActorPath' return null.
…et#8433, akkadotnet#8434) (akkadotnet#8437)

ThrottlerManager never managed the lifecycle of its ThrottledAssociation
children, which produced two related failures.

1. No SupervisorStrategy was declared, so the default Restart applied. A
   restarted inbound ThrottledAssociation re-enters WaitExposedHandle, and the
   Handle message that leaves that state is only ever sent once - at creation.
   The association then sat there for the life of the process, logging
   "unhandled event InboundPayload" and dropping every inbound packet, with no
   disassociation and no quarantine to tell remoting anything was wrong.
   ThrottlerManager now stops failed children, matching AkkaProtocolManager.

2. _handleTable entries were only removed on the ForceDisassociate paths, so a
   throttler that died any other way left a stale entry behind. Later
   SetThrottle/PassThrough walks messaged dead refs - the throttle mode
   dead-lettered and the change silently did not apply. Children are now
   watched at their single creation site and purged from the table on
   Terminated.

Belt and braces: an InboundPayload arriving in WaitExposedHandle is impossible
during a healthy startup - the read handler is not registered until we leave
that state - so it now disassociates and stops instead of dropping packets
quietly.

Scope: the throttle adapter is only in the transport pipeline when
applied-adapters includes trttl/gremlin, i.e. multi-node test runs rather than
production deployments.
@Aaronontheweb
Aaronontheweb merged commit ea6d0f6 into akkadotnet:v1.5 Aug 11, 2026
10 of 12 checks passed
@Aaronontheweb
Aaronontheweb deleted the backport/v1.5.71 branch August 11, 2026 00:44
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.

1 participant