Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 14 additions & 15 deletions TUnit.Core/EngineCancellationToken.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ public class EngineCancellationToken : IDisposable
/// Gets the cancellation token.
/// </summary>
public CancellationToken Token { get; private set; }

private CancellationTokenSource? _forcefulExitCts;

private volatile bool _forcefulExitStarted;

/// <summary>
Expand All @@ -27,6 +26,8 @@ internal void Initialise(CancellationToken cancellationToken)
CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Token = CancellationTokenSource.Token;

Token.Register(_ => Cancel(), this);
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

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

Registering a callback that calls Cancel() when the token is cancelled creates a potential issue. When the external cancellation token (passed to Initialise) is cancelled, it will trigger this callback, which calls Cancel(). The Cancel() method then calls CancellationTokenSource.Cancel() on line 56.

While CancellationTokenSource.CreateLinkedTokenSource already propagates cancellation automatically, this explicit registration adds the side effect of starting the forceful exit timer whenever ANY linked token is cancelled. This may be intentional for global timeout scenarios, but it means the forceful exit timer will start even for normal test completion if the parent token is cancelled.

Consider whether this behavior is intended for all cancellation scenarios, or if the forceful exit timer should only start for specific cancellation types (e.g., Ctrl+C). If this is intentional, add a comment explaining why the registration is needed despite the linked token source.

Suggested change
Token.Register(_ => Cancel(), this);
// Cancellation is already propagated via the linked token source above.
// We intentionally do not register a callback here that calls Cancel(),
// to avoid starting the forceful exit timer for every linked-token cancellation.
// The forceful exit timer is reserved for specific signals (e.g. Ctrl+C).

Copilot uses AI. Check for mistakes.

// Console.CancelKeyPress is not supported on browser platforms
#if NET5_0_OR_GREATER
if (!OperatingSystem.IsBrowser())
Expand All @@ -38,8 +39,16 @@ internal void Initialise(CancellationToken cancellationToken)
}
#endif
}

private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e)
{
Cancel();

// Prevent the default behavior (immediate termination)
e.Cancel = true;
}

private void Cancel()
{
// Cancel the test execution
if (!CancellationTokenSource.IsCancellationRequested)
Expand All @@ -51,14 +60,9 @@ private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e)
if (!_forcefulExitStarted)
{
_forcefulExitStarted = true;
Comment on lines 60 to 62
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

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

The _forcefulExitStarted volatile flag is checked and set without proper synchronization. While the volatile keyword ensures visibility across threads, it doesn't provide atomicity for the check-then-set operation. This creates a race condition where multiple threads could simultaneously check _forcefulExitStarted as false, both set it to true, and both start separate forceful exit timers.

This could result in multiple Environment.Exit(1) calls being scheduled, though only one would execute. More importantly, it violates the intent that the forceful exit timer should only be started once.

Use Interlocked.CompareExchange to atomically check and set the flag, ensuring only one thread can start the forceful exit timer.

Copilot uses AI. Check for mistakes.

// Cancel any previous forceful exit timer
_forcefulExitCts?.Cancel();
_forcefulExitCts?.Dispose();
_forcefulExitCts = new CancellationTokenSource();


// Start a new forceful exit timer
_ = Task.Delay(TimeSpan.FromSeconds(10), _forcefulExitCts.Token).ContinueWith(t =>
_ = Task.Delay(TimeSpan.FromSeconds(30), CancellationToken.None).ContinueWith(t =>
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

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

The forceful exit timeout has been changed from 10 seconds to 30 seconds, and the cancellation token has been changed from a cancellable token to CancellationToken.None. This means once the forceful exit is triggered, it cannot be cancelled or reset, even if the process exits gracefully.

The removal of _forcefulExitCts means there's no way to cancel this timer if the test run completes normally after cancellation is requested but before the 30-second timeout. This could lead to unnecessary process termination in scenarios where cleanup completes successfully.

Consider:

  1. Documenting why 30 seconds was chosen (was 10 seconds too short?)
  2. Keeping the ability to cancel the forceful exit timer if the process exits gracefully before the timeout
  3. Using the existing CancellationTokenSource.Token instead of CancellationToken.None so the timer can be cancelled during disposal

Copilot uses AI. Check for mistakes.
{
if (!t.IsCanceled)
{
Expand All @@ -67,9 +71,6 @@ private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e)
}
}, TaskScheduler.Default);
}

// Prevent the default behavior (immediate termination)
e.Cancel = true;
}

private void OnProcessExit(object? sender, EventArgs e)
Expand Down Expand Up @@ -102,8 +103,6 @@ public void Dispose()
#if NET5_0_OR_GREATER
}
#endif
_forcefulExitCts?.Cancel();
_forcefulExitCts?.Dispose();
CancellationTokenSource.Dispose();
}
}
Loading