Fixed in commit a6d064a - Replaced DateTime.UtcNow with Stopwatch.GetTimestamp() in TimedEvents.cs
The InvokeLeakTest stress test was failing only on x64 machines when running under a debugger:
The test passed in CI/CD environments and when run without a debugger.
InvokeLeakTest is a stress test (not a unit test) located in Tests/StressTests/ApplicationStressTests.cs. It:
Application.Invoke() from background threadsInterlocked.Increment// Main thread blocks in Application.Run()
Application.Run(top);
// Background thread spawns tasks
for (var j = 0; j < NUM_PASSES; j++) {
for (var i = 0; i < NUM_INCREMENTS; i++) {
Task.Run(() => {
Thread.Sleep(r.Next(2, 4)); // Random 2-4ms delay
Application.Invoke(() => {
tf.Text = $"index{r.Next()}";
Interlocked.Increment(ref _tbCounter);
});
});
}
// Wait for counter to reach expected value with 100ms polling
while (_tbCounter != expectedValue) {
_wakeUp.Wait(POLL_MS); // POLL_MS = 100ms
if (_tbCounter hasn't changed) {
throw new TimeoutException("Invoke lost");
}
}
}
Application.Invoke(action) → calls ApplicationImpl.Instance.Invoke(action)ApplicationImpl.Invoke() checks if on main thread:
_timedEvents with TimeSpan.ZeroTimedEvents.Add():
k = (DateTime.UtcNow + time).TicksTimeSpan.Zero, subtracts 100 ticks to ensure immediate execution: k -= 100_timeouts.Add(NudgeToUniqueKey(k), timeout)MainLoop.RunIteration() calls TimedEvents.RunTimers() every iterationTimedEvents.RunTimers():
_timeouts and creates a new list (under lock)k < nowpublic void Invoke (Action action)
{
// If we are already on the main UI thread
if (Application.MainThreadId == Thread.CurrentThread.ManagedThreadId)
{
action ();
return;
}
_timedEvents.Add (TimeSpan.Zero,
() =>
{
action ();
return false; // One-shot execution
}
);
}
private void AddTimeout (TimeSpan time, Timeout timeout)
{
lock (_timeoutsLockToken)
{
long k = (DateTime.UtcNow + time).Ticks;
// if user wants to run as soon as possible set timer such that it expires right away
if (time == TimeSpan.Zero)
{
k -= 100; // Subtract 100 ticks to ensure it's "in the past"
}
_timeouts.Add (NudgeToUniqueKey (k), timeout);
Added?.Invoke (this, new (timeout, k));
}
}
private void RunTimersImpl ()
{
long now = DateTime.UtcNow.Ticks;
SortedList<long, Timeout> copy;
lock (_timeoutsLockToken)
{
copy = _timeouts;
_timeouts = new ();
}
foreach ((long k, Timeout timeout) in copy)
{
if (k < now) // Execute if scheduled time is in the past
{
if (timeout.Callback ()) // Returns false for Invoke actions
{
AddTimeout (timeout.Span, timeout);
}
}
else // Future timeouts - add back to list
{
lock (_timeoutsLockToken)
{
_timeouts.Add (NudgeToUniqueKey (k), timeout);
}
}
}
}
The test failure likely occurs due to a combination of factors:
The code uses DateTime.UtcNow.Ticks for timing, which has platform-dependent resolution:
When TimeSpan.Zero invocations are added:
long k = (DateTime.UtcNow + TimeSpan.Zero).Ticks;
k -= 100; // Subtract 100 ticks (10 microseconds)
The problem: If two Invoke calls happen within the same timer tick (< ~15ms on Windows), they get the SAME DateTime.UtcNow value. The NudgeToUniqueKey function increments by 1 tick each collision, but this creates a sequence of timestamps like:
now - 100now - 99now - 98In RunTimersImpl, this check determines if a timeout should execute:
if (k < now) // k is scheduled time, now is current time
The race: Between when timeouts are added (with k = UtcNow - 100) and when they're checked (with fresh DateTime.UtcNow), time passes. However, if:
DateTime.UtcNow at an unlucky momentSome timeouts might have k >= now even though they were intended to be "immediate" (TimeSpan.Zero).
When running under a debugger:
a) Slower Main Loop Iterations
RunTimers callsb) Timer Resolution Changes
c) DateTime.UtcNow Sampling
k >= now race conditionFailure scenario:
Time T0: Background thread calls Invoke()
- k = UtcNow - 100 (let's say 1000 ticks - 100 = 900)
- Added to _timeouts with k=900
Time T1: MainLoop iteration samples UtcNow = 850 ticks (!)
- This can happen if system timer hasn't updated yet
- Check: is k < now? Is 900 < 850? NO!
- Timeout is NOT executed, added back to _timeouts
Time T2: Next iteration, UtcNow = 1100 ticks
- Check: is k < now? Is 900 < 1100? YES!
- Timeout executes
But if the test's 100ms polling window expires before T2, it throws TimeoutException.
UPDATE: @tig confirmed he can reproduce on his x64 Windows machine but NOT on his ARM Windows machine, validating this hypothesis.
Architecture-specific factors:
The test spawns tasks with Task.Run() and small random delays (2-4ms). Under a debugger:
CONFIRMED: @tig cannot reproduce on ARM Windows machine, only on x64 Windows.
ARM environments:
Test uses 100ms polling: _wakeUp.Wait(POLL_MS) where POLL_MS = 100
Test spawns 500 concurrent tasks per pass: Each with 2-4ms delay
Only fails under debugger: Strong indicator of timing-related issue
Architecture-specific (CONFIRMED): @tig reproduced on x64 Windows but NOT on ARM Windows
Replace DateTime.UtcNow.Ticks with Stopwatch.GetTimestamp() in TimedEvents:
Change the immediate execution buffer from -100 ticks to something more substantial:
if (time == TimeSpan.Zero)
{
k -= TimeSpan.TicksPerMillisecond * 10; // 10ms in the past instead of 0.01ms
}
When adding a TimeSpan.Zero timeout, explicitly wake up the main loop:
_timedEvents.Add(TimeSpan.Zero, ...);
MainLoop?.Wakeup(); // Force immediate processing
For the test itself:
POLL_MS from 100 to 200 or 500 for debugger scenariosif (Debugger.IsAttached) POLL_MS = 500;Add explicit memory barriers and volatile reads to ensure visibility:
volatile int _tbCounter;
// or
Interlocked.MemoryBarrier();
int currentCount = Interlocked.CompareExchange(ref _tbCounter, 0, 0);
To confirm hypothesis, @BDisp could:
Add diagnostics to test:
var sw = Stopwatch.StartNew();
while (_tbCounter != expectedValue) {
_wakeUp.Wait(pollMs);
if (_tbCounter != tbNow) continue;
// Log timing information
Console.WriteLine($"Timeout at {sw.ElapsedMilliseconds}ms");
Console.WriteLine($"Counter: {_tbCounter}, Expected: {expectedValue}");
Console.WriteLine($"Missing: {expectedValue - _tbCounter}");
// Check if invokes are still queued
Console.WriteLine($"TimedEvents count: {Application.TimedEvents?.Timeouts.Count}");
}
Test timer resolution:
var samples = new List<long>();
for (int i = 0; i < 100; i++) {
samples.Add(DateTime.UtcNow.Ticks);
}
var deltas = samples.Zip(samples.Skip(1), (a, b) => b - a).Where(d => d > 0);
Console.WriteLine($"Min delta: {deltas.Min()} ticks ({deltas.Min() / 10000.0}ms)");
Monitor TimedEvents queue:
Add logging in TimedEvents.RunTimersImpl to see when timeouts are deferred
Check if k >= now condition is being hit
The InvokeLeakTest failure under debugger is likely caused by:
k < now)The most robust fix is to use Stopwatch for timing instead of DateTime.UtcNow, providing:
This is a timing/performance issue in the stress test environment, not a functional bug in the production code. The test is correctly identifying edge cases in high-concurrency scenarios that are more likely to manifest under debugger overhead.