Skip to content

Akka.Hosting.TestKit.Xunit2: implicit-sender ambient-context leak — the #733/#735 fix was not ported to the xUnit2 variant #764

Description

@Aaronontheweb

Version Information

Describe the bug

Akka.Hosting.TestKit sets Akka.Actor.Internal.InternalCurrentActorCellKeeper.Current = TestActor.Underlying (via EnsureImplicitSender / test init) so that inside a test body the single-argument actor.Tell(msg) picks up TestActor as the implicit sender. That field is [ThreadStatic] and does not flow through ExecutionContext.

#733 reported that under parallel class execution this leaks across ActorSystems — an await continuation resumes on a ThreadPool thread another test pinned, so Tell() uses the wrong implicit sender and replies cross ActorSystem boundaries. #735 fixed it for the xUnit v3 package (Akka.Hosting.TestKit) by adding a wrapping ActorCellKeepingSynchronizationContext plus a BeforeAfterTestAttribute (HostingCleanAmbientContext) that saves/pins/restores Current around continuations and cleans it up in After().

That fix was never ported to Akka.Hosting.TestKit.Xunit2. PR #735 explicitly notes the xUnit2 variant as "unaffected," and it can be confirmed directly in the shipped 1.5.70 assemblies:

  • Akka.Hosting.TestKit.dll (v3) — contains AkkaCleanAmbientContextAttribute (the cleanup). ✅
  • Akka.Hosting.TestKit.Xunit2.dll — contains EnsureImplicitSender (the seeding) but none of the cleanup types (ActorCellKeepingSynchronizationContext / HostingCleanAmbientContext / AkkaCleanAmbientContext). ❌

xUnit2 still runs test collections in parallel by default, so the original #733 leak is fully live for xUnit2 consumers.

Two consequences

  1. Same as Akka.Hosting.TestKit implicit-sender leaks across ActorSystems under xUnit v3 parallel class execution #733, in xUnit2: intermittent ExpectMsg<T> timeouts (a test's reply went to a sibling's TestActor) and ExpectNoMsg/ExpectTerminated failures (a sibling receives a stray reply from a foreign ActorSystem).
  2. Broader: because the seeded cell is never cleared and Mailbox.Run restores Current to its previous value (not null), a contaminated ThreadPool thread stays contaminated for the life of the process. Any later work item on that physical thread that reads ActorCell.Current — including non-TestKit code sharing the process ThreadPool (e.g. a WebApplicationFactory/TestServer request handler, or any .Ask<T>() issued from pooled code) — observes a stale actor cell from a finished test's /system/testActor. Code that branches on "am I currently inside an actor?" then does the wrong thing on a thread that is not, in fact, running an actor.

To Reproduce

Two Akka.Hosting.TestKit.Xunit2.TestKit-derived specs in one assembly (distinct classes → separate collections → run in parallel), each awaiting before relying on the implicit sender:

using System;
using System.Threading.Tasks;
using Akka.Actor;
using Akka.Actor.Dsl;
using Akka.Hosting;
using Xunit;

public abstract class ImplicitSenderReproBase : Akka.Hosting.TestKit.TestKit
{
    protected ImplicitSenderReproBase() : base(actorSystemName: "repro-" + Guid.NewGuid().ToString("N")) { }

    protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) { }

    protected async Task ImplicitSenderShouldBeOwnTestActor()
    {
        var echo = Sys.ActorOf(act => act.ReceiveAny((_, ctx) => ctx.Sender.Tell("reply")));

        // force a continuation onto a pool thread a sibling test may have pinned
        await Task.Yield();
        await Task.Delay(Random.Shared.Next(5, 30));

        echo.Tell("ping");                // relies on implicit sender == this test's TestActor
        await ExpectMsgAsync("reply");    // intermittently times out: reply crossed to a sibling ActorSystem
    }

    [Fact] public Task Test1() => ImplicitSenderShouldBeOwnTestActor();
    [Fact] public Task Test2() => ImplicitSenderShouldBeOwnTestActor();
}

public class ImplicitSenderRepro01 : ImplicitSenderReproBase { }
public class ImplicitSenderRepro02 : ImplicitSenderReproBase { }
public class ImplicitSenderRepro03 : ImplicitSenderReproBase { }
public class ImplicitSenderRepro04 : ImplicitSenderReproBase { }

Run repeatedly under load (or on a low-core agent). The failure rate rises with ThreadPool contention and drops to ~0 with parallelization disabled — the signature of a [ThreadStatic] shared-pool leak. The same specs pass on the xUnit v3 Akka.Hosting.TestKit (which has the #735 cleanup).

Proposed fix

Port #735's approach to the xUnit2 variant:

  • Reuse the (xUnit-agnostic) wrapping ActorCellKeepingSynchronizationContext.
  • Add an xUnit2 BeforeAfterTestAttribute equivalent to HostingCleanAmbientContext that installs the decorator, pins the cell for the test, and restores/clears Current in After() so no seeded cell survives onto a pooled thread.
  • Keep the EnsureImplicitSender Current == null guard consistent across both variants.
  • Add parallel-execution regression tests mirroring ParallelAmbientContextSpec for the xUnit2 package.

Defense-in-depth worth considering: guarantee Current is cleared at test teardown regardless of the SC path, so a leaked cell can never outlive the test onto a thread reused by unrelated code.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions