Compze.Threading.Testing 0.5.0-alpha

This is a prerelease version of Compze.Threading.Testing.
dotnet add package Compze.Threading.Testing --version 0.5.0-alpha
                    
NuGet\Install-Package Compze.Threading.Testing -Version 0.5.0-alpha
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Compze.Threading.Testing" Version="0.5.0-alpha" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Compze.Threading.Testing" Version="0.5.0-alpha" />
                    
Directory.Packages.props
<PackageReference Include="Compze.Threading.Testing" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Compze.Threading.Testing --version 0.5.0-alpha
                    
#r "nuget: Compze.Threading.Testing, 0.5.0-alpha"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Compze.Threading.Testing@0.5.0-alpha
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Compze.Threading.Testing&version=0.5.0-alpha&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Compze.Threading.Testing&version=0.5.0-alpha&prerelease
                    
Install as a Cake Tool

Compze.Threading.Testing

Observe and deterministically coordinate threads in your tests. Trigger that race condition every single test run. Say goodby to Thread.Sleep + Prayer. IThreadGate gives complete control and observability.

ThreadGate

An IThreadGate is a point that threads pass through by calling AwaitPassThrough(). The gate can be open (threads pass immediately) or closed (threads block until released). Either way, every pass-through is counted and observable.

Creating Gates

// Closed gate — threads calling AwaitPassThrough() will block
var myGate = IThreadGate.NewClosed(WaitTimeout.Seconds(5), "myGate");

// Open gate — threads pass through immediately (useful as instrumentation)
var myGate = IThreadGate.NewOpen(WaitTimeout.Seconds(5), "myGate");

The timeout is how long awaiting operations will wait before throwing. The name appears in diagnostics and logging.

Two Mental Models

1. Gate as Barrier

A closed gate blocks threads. You control exactly when and how many pass.

var gate = IThreadGate.NewClosed(WaitTimeout.Seconds(5), "barrier");

// Threads block at `AwaitPassThrough`
runner.Run(() => { gate.AwaitPassThrough(); DoWork(); });
runner.Run(() => { gate.AwaitPassThrough(); DoWork(); });

// Wait until both threads are queued
gate.AwaitQueueLengthEqualTo(2);

// Release them one at a time
gate.AwaitLetOneThreadPassThrough();   // one runs DoWork
gate.AwaitLetOneThreadPassThrough();   // the second thread runs DoWork

// Or release all at once
gate.Open();

AwaitLetOneThreadPassThrough() deterministically lets exactly one thread pass the gate. The call blocks until that thread has actually passed and returns its ThreadSnapshot.

2. Gate as Instrumentation Point

An open gate lets threads through immediately but still records every pass-through. This lets you observe when code reaches a specific point without affecting its flow.

var beforeWork = IThreadGate.NewOpen(WaitTimeout.Seconds(5), "beforeWork");
var afterWork  = IThreadGate.NewOpen(WaitTimeout.Seconds(5), "afterWork");

runner.Run(() =>
{
   beforeWork.AwaitPassThrough();
   DoSomethingThatMightBlock();
   afterWork.AwaitPassThrough();
});

// Deterministically confirm the thread has entered DoSomethingThatMightBlock
beforeWork.AwaitPassedThroughCountEqualTo(1);

// Prove it hasn't finished yet (bounded negative assertion)
afterWork.TryAwaitPassedThroughCountEqualTo(1, WaitTimeout.Milliseconds(50)).Must().BeFalse();

// Now trigger whatever should unblock it
TriggerUnblock();

// Confirm it completed
afterWork.AwaitPassedThroughCountEqualTo(1);

This is the key pattern for testing blocking behavior deterministically:

  • beforeWork gate proves the thread entered the blocking call
  • afterWork.TryAwait...BeFalse() proves it's still blocking
  • After triggering the unblock, afterWork.AwaitPassedThroughCount... proves it completed

State Properties

Property Meaning
Requested Total threads that have called AwaitPassThrough()
Queued Threads currently blocked, waiting to pass
Passed Threads that have passed through
IsOpen Whether the gate is currently open

Requested == Queued + Passed always holds.

Awaiting State

You can await any condition of the gate using TryAwait or Await manually:

gate.TryAwait(it => it.Passed > 2, WaitTimeout.Seconds(1)).Must().BeTrue(); //All TryAwait* methods Returns false on failure
gate.Await(it => it.Passed > 2, WaitTimeout.Seconds(1)); //All Await* methods throw on failure

For all the common needs there are predefined convenience methods

gate.AwaitQueueLengthEqualTo(5);
gate.AwaitPassedThroughCountEqualTo(3);

gate.TryAwaitQueueLengthEqualTo(5, WaitTimeout.Milliseconds(50)).Must().BeTrue();
gate.TryAwaitPassedThroughCountEqualTo(3, WaitTimeout.Milliseconds(50)).Must().BeTrue();

Post-Pass-Through Actions

Inject behavior that runs immediately after a thread passes through the gate — inside the gate's lock, so it's guaranteed to execute before the next thread passes:

// Throw an exception in the handler
gate.ThrowPostPassThrough(new IntentionalException());

// Fail the current transaction (for testing exactly-once delivery)
gate.FailTransactionOnPreparePostPassThrough(new Exception("deliberate failure"));

// Custom action with access to the ThreadSnapshot
gate.SetPostPassThroughAction(snapshot => Log($"Thread passed: {snapshot}"));

ExecuteWithExclusiveLockWhen

Run an action while holding the gate's internal lock, but only when a condition is met. Useful for atomically inspecting and modifying gate state:

gate.ExecuteWithExclusiveLockWhen(
   condition: it => it.Queued == 3,
   action: () => { /* runs with exclusive access to gate state */ }
);

GatedCodeSection

Pairs an entrance gate and an exit gate around a block of code. Lets you control and observe both entry and exit independently.

var section = IGatedCodeSection.NewClosed(WaitTimeout.Seconds(5), "mySection");

// Threads enter the section by calling Enter()
runner.Run(() => section.Execute(() => DoWork()));

Since both gates start closed, the thread blocks at the entrance. You then control the flow:

// Let one thread enter, observe it reach the exit gate
section.LetOneThreadEnterAndReachExit();

// The thread is now inside DoWork(), blocked at the exit gate
section.EntranceGate.Passed.Must().Be(1);
section.ExitGate.Queued.Must().Be(1);

// Let it exit
section.ExitGate.AwaitLetOneThreadPassThrough();

Shared Lock and ExecuteWithExclusiveLock

Both gates in a GatedCodeSection share a single underlying monitor. ExecuteWithExclusiveLock lets you atomically inspect or mutate state across both gates:

// Atomically read state from both gates
var snapshot = section.ExecuteWithExclusiveLock(it => (it.EntranceGate.Passed, it.ExitGate.Queued));

// Atomically open both gates
section.ExecuteWithExclusiveLock(it =>
{
   it.EntranceGate.Open();
   it.ExitGate.Open();
});

WARNING: Awaiting operations (AwaitLetOneThreadPassThrough, AwaitQueueLengthEqualTo, etc.) release the lock while waiting — just like Monitor.Wait. This means ExecuteWithExclusiveLock does not provide exclusivity across awaiting calls. Use it for atomic reads and non-blocking mutations, not for wrapping operations that block waiting for threads to pass.

Or open both gates for transparent instrumentation:

var section = IGatedCodeSection.NewOpen(WaitTimeout.Seconds(5), "observe");

runner.Run(() => section.Execute(() => DoWork()));

// Wait for the thread to enter, then exit
section.EntranceGate.AwaitPassedThroughCountEqualTo(1);
section.ExitGate.AwaitPassedThroughCountEqualTo(1);

TestingTaskRunner

Runs actions on background threads and ensures they all complete successfully within a timeout. Exceptions from any task are rethrown on dispose.

using var runner = TestingTaskRunner.WithTimeout(10.Seconds());

runner.Run(() => DoWork());
runner.Run(
   () => DoFirstThing(),
   () => DoSecondThing()
);

// On Dispose: waits for all tasks to complete within timeout,
// throws AggregateException if any task failed or timed out

Putting It All Together

A realistic example testing that a blocking method does not return until explicitly unblocked:

[XF] public void does_not_return_until_signal_is_raised()
{
   using var signal = new InterprocessSignal(name, global: true);
   var beforeAwaitingGate = IThreadGate.NewOpen(WaitTimeout.Seconds(5));
   var afterAwaitingGate  = IThreadGate.NewOpen(WaitTimeout.Seconds(5));

   _runner.Run(() =>
   {
      beforeAwaitingGate.AwaitPassThrough();     // instrumentation: "I'm about to block"
      signal.TryAwait(TimeSpan.FromSeconds(2));  // the blocking call under test
      afterAwaitingGate.AwaitPassThrough();      // instrumentation: "I've unblocked"
   });

   // 1. Confirm the thread has entered TryAwait
   beforeAwaitingGate.AwaitPassedThroughCountEqualTo(1);

   // 2. Prove TryAwait is still blocking
   afterAwaitingGate.TryAwaitPassedThroughCountEqualTo(1, WaitTimeout.Milliseconds(50))
                    .Must().BeFalse();

   // 3. Trigger the unblock
   signal.Raise();

   // 4. Confirm TryAwait returned
   afterAwaitingGate.AwaitPassedThroughCountEqualTo(1);
}

Installation

dotnet add package Compze.Threading.Testing

License

MIT

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.5.0-alpha 76 6/4/2026
0.2.1-alpha 86 3/4/2026