From 7d0282898fcc5130a5da18c1ab8da6f5a74d6a95 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards Date: Tue, 27 Oct 2026 19:42:40 +0100 Subject: [PATCH 1/2] feat(actor): add opt-in timer-discard shutdown policy --- docs/architecture/arc42-CanKit.Pro-en.md | 6 +- src/CanKit.Pro.Actor/ActorShutdownPolicy.cs | 18 ++ src/CanKit.Pro.Actor/ProtocolActor.cs | 52 +++- src/CanKit.Pro.Actor/README.md | 17 +- .../CanKit.Pro.Actor.approved.txt | 6 + .../ProtocolActorShutdownPolicyTests.cs | 239 ++++++++++++++++++ 6 files changed, 329 insertions(+), 9 deletions(-) create mode 100644 src/CanKit.Pro.Actor/ActorShutdownPolicy.cs create mode 100644 tests/CanKit.Pro.Tests/TestCases/ProtocolActorShutdownPolicyTests.cs diff --git a/docs/architecture/arc42-CanKit.Pro-en.md b/docs/architecture/arc42-CanKit.Pro-en.md index 1a2102cf..a898fad6 100644 --- a/docs/architecture/arc42-CanKit.Pro-en.md +++ b/docs/architecture/arc42-CanKit.Pro-en.md @@ -1141,7 +1141,11 @@ new gap", not "everything proven"; the open items are listed in the script itsel (FR-RAW-023) — `PostAsync`/`PostAsync` failures instead go through the returned task, since the caller observes it via `await` anyway. `Dispose` rejects new work immediately (`ObjectDisposedException`) but still runs already queued work to completion instead of leaving - a `PostAsync` task waiting at dispose time hanging indefinitely. The ISO-TP prototype + a `PostAsync` task waiting at dispose time hanging indefinitely. Its original constructor keeps + draining due timers; the additive `ActorShutdownPolicy.DiscardPendingTimers` constructor option + instead discards pending timers, including due ones, while completing accepted mailbox work. + Timer selection is ordered against disposal; an already selected callback may still finish. + The ISO-TP prototype (functionally defective, Review §1.1, including a 100 % CPU busy loop and unsynchronised `List` states) no longer exists; its successor `IsoTpChannel` and the J1939, J1939-TP and CANopen packages run on `ProtocolActor`. Secured via unit/concurrency test diff --git a/src/CanKit.Pro.Actor/ActorShutdownPolicy.cs b/src/CanKit.Pro.Actor/ActorShutdownPolicy.cs new file mode 100644 index 00000000..f359578b --- /dev/null +++ b/src/CanKit.Pro.Actor/ActorShutdownPolicy.cs @@ -0,0 +1,18 @@ +namespace CanKit.Pro.Actor +{ + /// Chooses which pending timers a executes during shutdown. + public enum ActorShutdownPolicy + { + /// + /// Preserves the original behavior: finish accepted mailbox work, then execute due timers. + /// Timers that are not yet due are discarded. Rearming after disposal is rejected. + /// + DrainDueTimers = 0, + + /// + /// Finish accepted mailbox work and discard pending timers, including already due ones. + /// A timer selected for execution before disposal may still complete. No new work is accepted. + /// + DiscardPendingTimers = 1, + } +} diff --git a/src/CanKit.Pro.Actor/ProtocolActor.cs b/src/CanKit.Pro.Actor/ProtocolActor.cs index ae6ef75c..21f21456 100644 --- a/src/CanKit.Pro.Actor/ProtocolActor.cs +++ b/src/CanKit.Pro.Actor/ProtocolActor.cs @@ -158,6 +158,7 @@ public sealed class ProtocolActor : IProtocolActor }); private readonly TimeSpan _shutdownTimeout; + private readonly ActorShutdownPolicy _shutdownPolicy; private int _disposedFlag; @@ -280,9 +281,23 @@ public ProtocolActor(ActorExecutionMode mode = ActorExecutionMode.DedicatedThrea { } + /// + /// Creates an actor with an explicitly selected shutdown policy. The original two-parameter + /// constructor continues to use . + /// + /// Execution context for the loop (FR-RAW-024). + /// Required for SynchronizationContext mode; null otherwise. + /// Whether pending due timers are executed or discarded at shutdown. + /// The shutdown policy is not defined. + public ProtocolActor(ActorExecutionMode mode, SynchronizationContext? synchronizationContext, + ActorShutdownPolicy shutdownPolicy) + : this(mode, synchronizationContext, timeSource: null, shutdownTimeout: null, shutdownPolicy: shutdownPolicy) + { + } + // Test seam. Kept internal (and as a separate overload rather than extra optional // parameters on the public constructor, which would change that constructor's compiled - // signature) so the published surface stays exactly as it was: substituting the tick source + // signature) so the original constructor signature stays as it was: substituting the tick source // is what makes timer behaviour testable without sleeping, and shortening the shutdown // timeout is what makes the "Dispose gave up" path testable without a five-second wait. internal ProtocolActor( @@ -291,8 +306,12 @@ internal ProtocolActor( ITimeSource? timeSource, TimeSpan? shutdownTimeout, Action? beforeLoopStart = null, - Action? beforeStopRequested = null) + Action? beforeStopRequested = null, + ActorShutdownPolicy shutdownPolicy = ActorShutdownPolicy.DrainDueTimers) { + if (shutdownPolicy != ActorShutdownPolicy.DrainDueTimers && shutdownPolicy != ActorShutdownPolicy.DiscardPendingTimers) + throw new ArgumentOutOfRangeException(nameof(shutdownPolicy)); + _shutdownPolicy = shutdownPolicy; _beforeStopRequested = beforeStopRequested; if (mode == ActorExecutionMode.SynchronizationContext) { @@ -779,7 +798,8 @@ private async Task RunLoopAsync() // from that point on), but run everything already queued at the moment of Dispose to // completion -- so a caller awaiting PostAsync exactly when Dispose happens still gets a // real result/exception instead of a task that hangs forever. Never waits for or fires - // not-yet-due timers; those are simply discarded. + // not-yet-due timers; those are simply discarded. The opt-in DiscardPendingTimers policy + // also discards due timers instead of calling FireDueTimers after draining the mailbox. private void FinalDrain() { // Unlike the live loop, this drains to genuine emptiness: the batching in DrainMailbox @@ -793,7 +813,15 @@ private void FinalDrain() DrainPendingTimerInserts(); } - FireDueTimers(); + if (_shutdownPolicy == ActorShutdownPolicy.DiscardPendingTimers) + { + foreach (var entry in _timers) Retire(entry); + _timers.Clear(); + } + else + { + FireDueTimers(); + } } private int NextWaitTimeoutMilliseconds() @@ -900,13 +928,23 @@ private void FireDueTimers() if (_mailbox.TryPeek(out var older) && older.PostedAt < entry.DueTimestamp) break; - _timers.RemoveAt(0); + // Selecting a callback and accepting disposal have one ordering point. Do not + // hold this gate while invoking user code or marshaling to a context: callbacks + // may dispose this actor or wait for a thread that calls Post/Schedule. + bool cancelled; + lock (_disposeGate) + { + if (_shutdownPolicy == ActorShutdownPolicy.DiscardPendingTimers && Volatile.Read(ref _disposedFlag) != 0) + break; + _timers.RemoveAt(0); + cancelled = entry.Retire(); + } // Retire() reports whether this entry had been cancelled, and does so atomically // with taking it out of circulation -- so a Dispose racing us either wins (we skip // the callback) or loses and finds nothing left to cancel, matching Schedule's // documented "a callback already in flight may still complete". - if (!entry.Retire()) + if (!cancelled) RunSafely(entry.Callback); else Interlocked.Decrement(ref _cancelledTimerCount); @@ -1067,7 +1105,7 @@ private int ToTimeoutMilliseconds(long remainingTicks) // Loop-thread-only view of the pending timer list, for tests that need to prove cancelled // entries are actually reclaimed rather than merely never fired. Reading _timers.Count is - // only safe from the loop, hence the accessor rather than exposing the list. + // only safe from the loop (or after joining it), hence the accessor rather than exposing the list. internal int PendingTimerCount => _timers.Count; // OnDispatchFailure is null for plain Post() items (BackgroundExceptionOccurred is their diff --git a/src/CanKit.Pro.Actor/README.md b/src/CanKit.Pro.Actor/README.md index 32abd4be..0f69053e 100644 --- a/src/CanKit.Pro.Actor/README.md +++ b/src/CanKit.Pro.Actor/README.md @@ -75,7 +75,22 @@ using var timeout = actor.Schedule(TimeSpan.FromMilliseconds(150), () => channel Disposing an actor stops it from accepting new work (`Post`/`Schedule` throw `ObjectDisposedException`) but runs whatever was already queued to completion first, so a caller awaiting `PostAsync` right as `Dispose` happens still gets a real result instead of hanging. -Not-yet-due `Schedule` callbacks are discarded, not fired. `Dispose` waits up to five seconds for +The original constructor keeps `ActorShutdownPolicy.DrainDueTimers`: due callbacks still run; +not-yet-due `Schedule` callbacks are discarded. To finish accepted mailbox work while discarding +all pending timers, including due ones, explicitly select the additive policy: + +```csharp +using var actor = new ProtocolActor(ActorExecutionMode.DedicatedThread, null, + ActorShutdownPolicy.DiscardPendingTimers); +``` + +Timer selection and disposal are ordered under the same gate. A callback already selected before +disposal may still complete, including a callback waiting to marshal through a synchronization +context. The gate is released before invoking or marshaling user code. This policy does not cancel +accepted mailbox requests or complete tasks that depend on discarded timers: callers must still +link their own lifetime cancellation. Neither policy permits rearming after disposal. + +`Dispose` waits up to five seconds for the loop to actually finish; if a callback is still running when that elapses it returns anyway (so `Dispose` never becomes the thing that hangs) and reports a `TimeoutException` through `BackgroundExceptionOccurred` — a silent give-up would leave the caller unable to tell a clean diff --git a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Actor.approved.txt b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Actor.approved.txt index a559ce6d..e7f46b54 100644 --- a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Actor.approved.txt +++ b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.Actor.approved.txt @@ -6,6 +6,11 @@ namespace CanKit.Pro.Actor ThreadPool = 1, SynchronizationContext = 2, } + public enum ActorShutdownPolicy + { + DrainDueTimers = 0, + DiscardPendingTimers = 1, + } public interface IProtocolActor : System.IDisposable { event System.EventHandler BackgroundExceptionOccurred; @@ -17,6 +22,7 @@ namespace CanKit.Pro.Actor public sealed class ProtocolActor : CanKit.Pro.Actor.IProtocolActor, System.IDisposable { public ProtocolActor(CanKit.Pro.Actor.ActorExecutionMode mode = 0, System.Threading.SynchronizationContext? synchronizationContext = null) { } + public ProtocolActor(CanKit.Pro.Actor.ActorExecutionMode mode, System.Threading.SynchronizationContext? synchronizationContext, CanKit.Pro.Actor.ActorShutdownPolicy shutdownPolicy) { } public bool IsOnCurrentActor { get; } public event System.EventHandler? BackgroundExceptionOccurred; public void Dispose() { } diff --git a/tests/CanKit.Pro.Tests/TestCases/ProtocolActorShutdownPolicyTests.cs b/tests/CanKit.Pro.Tests/TestCases/ProtocolActorShutdownPolicyTests.cs new file mode 100644 index 00000000..f2e4ec89 --- /dev/null +++ b/tests/CanKit.Pro.Tests/TestCases/ProtocolActorShutdownPolicyTests.cs @@ -0,0 +1,239 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using CanKit.Pro.Actor; +using CanKit.Pro.Tests.Infrastructure; +using Xunit; + +namespace CanKit.Pro.Tests.TestCases; + +public class ProtocolActorShutdownPolicyTests +{ + [Theory] + [InlineData(ActorExecutionMode.DedicatedThread, null)] + [InlineData(ActorExecutionMode.ThreadPool, null)] + [InlineData(ActorExecutionMode.SynchronizationContext, null)] + [InlineData(ActorExecutionMode.DedicatedThread, ActorShutdownPolicy.DrainDueTimers)] + [InlineData(ActorExecutionMode.ThreadPool, ActorShutdownPolicy.DrainDueTimers)] + [InlineData(ActorExecutionMode.SynchronizationContext, ActorShutdownPolicy.DrainDueTimers)] + [InlineData(ActorExecutionMode.DedicatedThread, ActorShutdownPolicy.DiscardPendingTimers)] + [InlineData(ActorExecutionMode.ThreadPool, ActorShutdownPolicy.DiscardPendingTimers)] + [InlineData(ActorExecutionMode.SynchronizationContext, ActorShutdownPolicy.DiscardPendingTimers)] + public async Task Reentrant_Stop_Finishes_Accepted_Mailbox_Work_And_Applies_Only_The_Selected_Timer_Policy( + ActorExecutionMode mode, ActorShutdownPolicy? policy) + { + var context = ContextFor(mode); + using var actor = policy.HasValue ? new ProtocolActor(mode, context, policy.Value) : new ProtocolActor(mode, context); + var seen = new List(); + var errors = new List(); + actor.BackgroundExceptionOccurred += (_, ex) => errors.Add(ex); + Task? queued = null; + IDisposable? cancelled = null; + try + { + await actor.PostAsync(() => + { + queued = actor.PostAsync(() => seen.Add("accepted")); + actor.Schedule(TimeSpan.Zero, () => + { + seen.Add("due"); + actor.Schedule(TimeSpan.Zero, () => seen.Add("rearmed")); + }); + actor.Schedule(TimeSpan.FromDays(1), () => seen.Add("future")); + cancelled = actor.Schedule(TimeSpan.Zero, () => seen.Add("cancelled")); + cancelled.Dispose(); + actor.Dispose(); + ((Action)(() => actor.Post(() => { }))).Should().Throw(); + ((Action)(() => actor.Schedule(TimeSpan.Zero, () => { }))).Should().Throw(); + }).WaitAsync(TimeSpan.FromSeconds(10)); + actor.Dispose(); // Joins the loop, witnessing every callback and the final drain. + await queued!.WaitAsync(TimeSpan.FromSeconds(10)); + if (policy == ActorShutdownPolicy.DiscardPendingTimers) + { + seen.Should().Equal("accepted"); + errors.Should().BeEmpty(); + actor.PendingTimerCount.Should().Be(0, "the joined loop has retired and released pending timers"); + } + else + { + seen.Should().Equal("accepted", "due"); + errors.Should().ContainSingle().Which.Should().BeOfType(); + } + } + finally + { + cancelled?.Dispose(); // Retired timer handles remain safe to dispose again. + } + } + + [Theory] + [InlineData(ActorExecutionMode.DedicatedThread)] + [InlineData(ActorExecutionMode.ThreadPool)] + [InlineData(ActorExecutionMode.SynchronizationContext)] + public async Task OptIn_Allows_Live_Timers_But_Stops_Selecting_Timers_After_A_Timer_Disposes_The_Actor(ActorExecutionMode mode) + { + using var actor = new ProtocolActor(mode, ContextFor(mode), ActorShutdownPolicy.DiscardPendingTimers); + var seen = new List(); + var firstTimer = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Task? accepted = null; + await actor.PostAsync(() => + { + actor.Schedule(TimeSpan.Zero, () => + { + seen.Add("first"); + accepted = actor.PostAsync(() => seen.Add("accepted")); + actor.Dispose(); + firstTimer.TrySetResult(true); + }); + actor.Schedule(TimeSpan.Zero, () => seen.Add("second")); + }).WaitAsync(TimeSpan.FromSeconds(10)); + await firstTimer.Task.WaitAsync(TimeSpan.FromSeconds(10)); + actor.Dispose(); + await accepted!.WaitAsync(TimeSpan.FromSeconds(10)); + seen.Should().Equal("first", "accepted"); + } + + [Theory] + [InlineData(ActorExecutionMode.DedicatedThread)] + [InlineData(ActorExecutionMode.ThreadPool)] + [InlineData(ActorExecutionMode.SynchronizationContext)] + public async Task OffLoop_Stop_Drains_Accepted_Requests_Without_Firing_A_Timer_That_Becomes_Due_During_A_Callback(ActorExecutionMode mode) + { + var time = new ManualTimeSource(); + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var stopping = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var actor = new ProtocolActor(mode, ContextFor(mode), time, null, + beforeStopRequested: _ => stopping.TrySetResult(true), shutdownPolicy: ActorShutdownPolicy.DiscardPendingTimers); + var seen = new List(); + actor.Post(() => + { + actor.Schedule(TimeSpan.FromSeconds(1), () => seen.Add("due")); + entered.Set(); + release.Wait(); // No automatic release can make the ordering assertion vacuous. + seen.Add("running-completed"); + }); + Task? disposal = null; + try + { + entered.Wait(TimeSpan.FromSeconds(10)).Should().BeTrue(); + var plain = actor.PostAsync(() => { seen.Add("plain"); }); + var generic = actor.PostAsync(() => { seen.Add("generic"); return 42; }); + using var cancellation = new CancellationTokenSource(); + var withdrawn = actor.PostAsync(() => seen.Add("withdrawn"), cancellation.Token); + cancellation.Cancel(); + time.Advance(TimeSpan.FromSeconds(2)); + disposal = Task.Run(actor.Dispose); + await stopping.Task.WaitAsync(TimeSpan.FromSeconds(10)); + release.Set(); + await disposal.WaitAsync(TimeSpan.FromSeconds(10)); + await plain.WaitAsync(TimeSpan.FromSeconds(10)); + (await generic.WaitAsync(TimeSpan.FromSeconds(10))).Should().Be(42); + withdrawn.IsCanceled.Should().BeTrue(); + seen.Should().Equal("running-completed", "plain", "generic"); + } + finally + { + release.Set(); + if (disposal is not null) await disposal.WaitAsync(TimeSpan.FromSeconds(10)); + } + } + + [Theory] + [InlineData(ActorExecutionMode.DedicatedThread)] + [InlineData(ActorExecutionMode.ThreadPool)] + [InlineData(ActorExecutionMode.SynchronizationContext)] + public async Task OptIn_Discards_Pending_Inserts_When_Stopped_Before_The_First_Loop_Pass(ActorExecutionMode mode) + { + var seen = new List(); + Task? accepted = null; + using var actor = new ProtocolActor(mode, ContextFor(mode), new ManualTimeSource(), null, + beforeLoopStart: a => + { + a.Schedule(TimeSpan.Zero, () => seen.Add("due")); + a.Schedule(TimeSpan.FromDays(1), () => seen.Add("future")); + var cancelled = a.Schedule(TimeSpan.Zero, () => seen.Add("cancelled")); + cancelled.Dispose(); + accepted = a.PostAsync(() => seen.Add("accepted")); + a.Dispose(); + }, shutdownPolicy: ActorShutdownPolicy.DiscardPendingTimers); + actor.Dispose(); + await accepted!.WaitAsync(TimeSpan.FromSeconds(10)); + seen.Should().Equal("accepted"); + actor.PendingTimerCount.Should().Be(0, "the joined loop released due and future timer entries"); + } + + [Fact] + public async Task OptIn_Completes_A_Timer_Already_Selected_For_Context_Dispatch_Without_Holding_The_Disposal_Gate() + { + using var context = new HeldTimerContext(); + var stopping = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var actor = new ProtocolActor(ActorExecutionMode.SynchronizationContext, context, new ManualTimeSource(), null, + beforeStopRequested: _ => stopping.TrySetResult(true), shutdownPolicy: ActorShutdownPolicy.DiscardPendingTimers); + var seen = new List(); + Task? disposal = null; + Task? accepted = null; + try + { + await actor.PostAsync(() => + { + // All timestamps are zero, so this accepted request is not older than the timer + // and remains for the next mailbox batch while Send holds the selected callback. + accepted = actor.PostAsync(() => seen.Add("accepted")); + actor.Schedule(TimeSpan.Zero, () => seen.Add("selected")); + actor.Schedule(TimeSpan.Zero, () => seen.Add("pending")); + }).WaitAsync(TimeSpan.FromSeconds(10)); + await context.TimerSelected.Task.WaitAsync(TimeSpan.FromSeconds(10)); + disposal = Task.Run(actor.Dispose); + await stopping.Task.WaitAsync(TimeSpan.FromSeconds(10)); + context.Release.Set(); + await disposal.WaitAsync(TimeSpan.FromSeconds(10)); + await accepted!.WaitAsync(TimeSpan.FromSeconds(10)); + seen.Should().Equal("selected", "accepted"); + } + finally + { + context.Release.Set(); + if (disposal is not null) await disposal.WaitAsync(TimeSpan.FromSeconds(10)); + } + } + + [Theory] + [InlineData(-1)] + [InlineData(2)] + public void Undefined_Policies_Are_Rejected(int value) + { + var create = () => new ProtocolActor(ActorExecutionMode.DedicatedThread, null, (ActorShutdownPolicy)value); + create.Should().Throw().WithParameterName("shutdownPolicy"); + } + + private static SynchronizationContext? ContextFor(ActorExecutionMode mode) => + mode == ActorExecutionMode.SynchronizationContext ? new InlineContext() : null; + + private sealed class HeldTimerContext : SynchronizationContext, IDisposable + { + private int _sends; + internal TaskCompletionSource TimerSelected { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + internal ManualResetEventSlim Release { get; } = new(); + + public override void Send(SendOrPostCallback d, object? state) + { + if (Interlocked.Increment(ref _sends) == 2) + { + TimerSelected.TrySetResult(true); + Release.Wait(); + } + d(state); + } + + public void Dispose() => Release.Dispose(); + } + + // The actor must still execute all callbacks through Send, including the shutdown drain. + private sealed class InlineContext : SynchronizationContext + { + public override void Send(SendOrPostCallback d, object? state) => d(state); + } +} From 9f6441a8cc962341514c8dbc34373405d31be60f Mon Sep 17 00:00:00 2001 From: Dietmar Borgards Date: Tue, 27 Oct 2026 19:59:32 +0100 Subject: [PATCH 2/2] test(actor): own shutdown timer handle with using --- .../ProtocolActorShutdownPolicyTests.cs | 57 +++++++++---------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/tests/CanKit.Pro.Tests/TestCases/ProtocolActorShutdownPolicyTests.cs b/tests/CanKit.Pro.Tests/TestCases/ProtocolActorShutdownPolicyTests.cs index f2e4ec89..ee7e2148 100644 --- a/tests/CanKit.Pro.Tests/TestCases/ProtocolActorShutdownPolicyTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/ProtocolActorShutdownPolicyTests.cs @@ -30,42 +30,37 @@ public async Task Reentrant_Stop_Finishes_Accepted_Mailbox_Work_And_Applies_Only var errors = new List(); actor.BackgroundExceptionOccurred += (_, ex) => errors.Add(ex); Task? queued = null; - IDisposable? cancelled = null; - try + using var cancelled = await actor.PostAsync(() => { - await actor.PostAsync(() => - { - queued = actor.PostAsync(() => seen.Add("accepted")); - actor.Schedule(TimeSpan.Zero, () => - { - seen.Add("due"); - actor.Schedule(TimeSpan.Zero, () => seen.Add("rearmed")); - }); - actor.Schedule(TimeSpan.FromDays(1), () => seen.Add("future")); - cancelled = actor.Schedule(TimeSpan.Zero, () => seen.Add("cancelled")); - cancelled.Dispose(); - actor.Dispose(); - ((Action)(() => actor.Post(() => { }))).Should().Throw(); - ((Action)(() => actor.Schedule(TimeSpan.Zero, () => { }))).Should().Throw(); - }).WaitAsync(TimeSpan.FromSeconds(10)); - actor.Dispose(); // Joins the loop, witnessing every callback and the final drain. - await queued!.WaitAsync(TimeSpan.FromSeconds(10)); - if (policy == ActorShutdownPolicy.DiscardPendingTimers) - { - seen.Should().Equal("accepted"); - errors.Should().BeEmpty(); - actor.PendingTimerCount.Should().Be(0, "the joined loop has retired and released pending timers"); - } - else + queued = actor.PostAsync(() => seen.Add("accepted")); + actor.Schedule(TimeSpan.Zero, () => { - seen.Should().Equal("accepted", "due"); - errors.Should().ContainSingle().Which.Should().BeOfType(); - } + seen.Add("due"); + actor.Schedule(TimeSpan.Zero, () => seen.Add("rearmed")); + }); + actor.Schedule(TimeSpan.FromDays(1), () => seen.Add("future")); + var handle = actor.Schedule(TimeSpan.Zero, () => seen.Add("cancelled")); + handle.Dispose(); + actor.Dispose(); + ((Action)(() => actor.Post(() => { }))).Should().Throw(); + ((Action)(() => actor.Schedule(TimeSpan.Zero, () => { }))).Should().Throw(); + return handle; + }).WaitAsync(TimeSpan.FromSeconds(10)); + actor.Dispose(); // Joins the loop, witnessing every callback and the final drain. + await queued!.WaitAsync(TimeSpan.FromSeconds(10)); + if (policy == ActorShutdownPolicy.DiscardPendingTimers) + { + seen.Should().Equal("accepted"); + errors.Should().BeEmpty(); + actor.PendingTimerCount.Should().Be(0, "the joined loop has retired and released pending timers"); } - finally + else { - cancelled?.Dispose(); // Retired timer handles remain safe to dispose again. + seen.Should().Equal("accepted", "due"); + errors.Should().ContainSingle().Which.Should().BeOfType(); } + // The using declaration disposes the retired handle a second time, including on assertion failure. + } [Theory]