diff --git a/src/libraries/System.Private.Runtime.InteropServices.JavaScript/tests/System.Private.Runtime.InteropServices.JavaScript.Tests.csproj b/src/libraries/System.Private.Runtime.InteropServices.JavaScript/tests/System.Private.Runtime.InteropServices.JavaScript.Tests.csproj
index d7b18fc65e1505..f1afdecab341e2 100644
--- a/src/libraries/System.Private.Runtime.InteropServices.JavaScript/tests/System.Private.Runtime.InteropServices.JavaScript.Tests.csproj
+++ b/src/libraries/System.Private.Runtime.InteropServices.JavaScript/tests/System.Private.Runtime.InteropServices.JavaScript.Tests.csproj
@@ -17,6 +17,11 @@
+
+
+
+
+
diff --git a/src/libraries/System.Private.Runtime.InteropServices.JavaScript/tests/System/Runtime/InteropServices/JavaScript/TimerTests.cs b/src/libraries/System.Private.Runtime.InteropServices.JavaScript/tests/System/Runtime/InteropServices/JavaScript/TimerTests.cs
new file mode 100644
index 00000000000000..2bf8dee0b82d9d
--- /dev/null
+++ b/src/libraries/System.Private.Runtime.InteropServices.JavaScript/tests/System/Runtime/InteropServices/JavaScript/TimerTests.cs
@@ -0,0 +1,118 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace System.Runtime.InteropServices.JavaScript.Tests
+{
+ // V8's implementation of setTimer ignores delay parameter and always run immediately. So it could not be used to test this.
+ [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsBrowserDomSupported))]
+ public class TimerTests : IAsyncLifetime
+ {
+ static JSObject _timersHelper;
+ static Function _installWrapper;
+ static Function _getRegisterCount;
+ static Function _getHitCount;
+ static Function _cleanupWrapper;
+ static Function _log;
+
+ public static IEnumerable TestCases()
+ {
+ yield return new object[] { new int[0], 0, null, null };
+ yield return new object[] { new[] { 10 }, 1, null, null };
+ yield return new object[] { new[] { 10, 5 }, 2, null, null };
+ yield return new object[] { new[] { 10, 20 }, 1, null, null };
+ yield return new object[] { new[] { 800, 600, 400, 200, 100 }, 5, 13, 9 };
+ }
+
+ [Theory]
+ [MemberData(nameof(TestCases))]
+ public async Task TestTimers(int[] timeouts, int? expectedSetCounter, int? expectedSetCounterAfterCleanUp, int? expectedHitCount)
+ {
+ int wasCalled = 0;
+ Timer[] timers = new Timer[timeouts.Length];
+ try
+ {
+ _log.Call(_timersHelper, $"Waiting for runtime to settle");
+ // the test is quite sensitive to timing and order of execution. Here we are giving time to timers of XHarness and previous tests to finish.
+ await Task.Delay(2000);
+ _installWrapper.Call(_timersHelper);
+ _log.Call(_timersHelper, $"Ready!");
+
+ for (int i = 0; i < timeouts.Length; i++)
+ {
+ int index = i;
+ _log.Call(_timersHelper, $"Registering {index} delay {timeouts[i]}");
+ timers[i] = new Timer((_) =>
+ {
+ _log.Call(_timersHelper, $"In timer{index}");
+ wasCalled++;
+ }, null, timeouts[i], 0);
+ }
+
+ var setCounter = (int)_getRegisterCount.Call(_timersHelper);
+ Assert.True(0 == wasCalled, $"wasCalled: {wasCalled}");
+ Assert.True((expectedSetCounter ?? timeouts.Length) == setCounter, $"setCounter: actual {setCounter} expected {expectedSetCounter}");
+
+ }
+ finally
+ {
+ // the test is quite sensitive to timing and order of execution.
+ // Here we are giving time to our timers to finish.
+ var afterLastTimer = timeouts.Length == 0 ? 500 : 500 + timeouts.Max();
+
+ _log.Call(_timersHelper, "wait for timers to run");
+ // this delay is also implemented as timer, so it counts to asserts
+ await Task.Delay(afterLastTimer);
+ _log.Call(_timersHelper, "cleanup");
+ _cleanupWrapper.Call(_timersHelper);
+
+ Assert.True(timeouts.Length == wasCalled, $"wasCalled: actual {wasCalled} expected {timeouts.Length}");
+
+ if (expectedSetCounterAfterCleanUp != null)
+ {
+ var setCounter = (int)_getRegisterCount.Call(_timersHelper);
+ Assert.True(expectedSetCounterAfterCleanUp.Value == setCounter, $"setCounter: actual {setCounter} expected {expectedSetCounterAfterCleanUp.Value}");
+ }
+
+ if (expectedHitCount != null)
+ {
+ var hitCounter = (int)_getHitCount.Call(_timersHelper);
+ Assert.True(expectedHitCount == hitCounter, $"hitCounter: actual {hitCounter} expected {expectedHitCount}");
+ }
+
+ for (int i = 0; i < timeouts.Length; i++)
+ {
+ timers[i].Dispose();
+ }
+ }
+ }
+
+ public async Task InitializeAsync()
+ {
+ if (_timersHelper == null)
+ {
+ Function helper = new Function(@"
+ const loadTimersJs = async () => {
+ await import('./timers.js');
+ };
+ return loadTimersJs();
+ ");
+ await (Task)helper.Call(_timersHelper);
+
+ _timersHelper = (JSObject)Runtime.GetGlobalObject("timersHelper");
+ _installWrapper = (Function)_timersHelper.GetObjectProperty("install");
+ _getRegisterCount = (Function)_timersHelper.GetObjectProperty("getRegisterCount");
+ _getHitCount = (Function)_timersHelper.GetObjectProperty("getHitCount");
+ _cleanupWrapper = (Function)_timersHelper.GetObjectProperty("cleanup");
+ _log = (Function)_timersHelper.GetObjectProperty("log");
+ }
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+ }
+}
diff --git a/src/libraries/System.Private.Runtime.InteropServices.JavaScript/tests/timers.js b/src/libraries/System.Private.Runtime.InteropServices.JavaScript/tests/timers.js
new file mode 100644
index 00000000000000..ca2b7f31d52954
--- /dev/null
+++ b/src/libraries/System.Private.Runtime.InteropServices.JavaScript/tests/timers.js
@@ -0,0 +1,47 @@
+class TimersHelper {
+ log(message) {
+ // uncomment for debugging
+ // console.log(message);
+ }
+ install() {
+ const measuredCallbackName = "mono_wasm_set_timeout_exec";
+ globalThis.registerCount = 0;
+ globalThis.hitCount = 0;
+ this.log("install")
+ if (!globalThis.originalSetTimeout) {
+ globalThis.originalSetTimeout = globalThis.setTimeout;
+ }
+ globalThis.setTimeout = (cb, time) => {
+ var start = Date.now().valueOf();
+ if (cb.name === measuredCallbackName) {
+ globalThis.registerCount++;
+ this.log(`registerCount: ${globalThis.registerCount} now:${start} delay:${time}`)
+ }
+ return globalThis.originalSetTimeout(() => {
+ if (cb.name === measuredCallbackName) {
+ var hit = Date.now().valueOf();
+ globalThis.hitCount++;
+ this.log(`hitCount: ${globalThis.hitCount} now:${hit} delay:${time} delta:${hit - start}`)
+ }
+ cb();
+ }, time);
+ };
+ }
+
+ getRegisterCount() {
+ this.log(`registerCount: ${globalThis.registerCount} `)
+ return globalThis.registerCount;
+ }
+
+ getHitCount() {
+ this.log(`hitCount: ${globalThis.hitCount} `)
+ return globalThis.hitCount;
+ }
+
+ cleanup() {
+ this.log(`cleanup registerCount: ${globalThis.registerCount} hitCount: ${globalThis.hitCount} `)
+ globalThis.setTimeout = globalThis.originalSetTimeout;
+ }
+}
+
+globalThis.timersHelper = new TimersHelper();
diff --git a/src/mono/System.Private.CoreLib/src/System/Threading/TimerQueue.Browser.Mono.cs b/src/mono/System.Private.CoreLib/src/System/Threading/TimerQueue.Browser.Mono.cs
index 5ad4ea2e23cead..4187cfcb829089 100644
--- a/src/mono/System.Private.CoreLib/src/System/Threading/TimerQueue.Browser.Mono.cs
+++ b/src/mono/System.Private.CoreLib/src/System/Threading/TimerQueue.Browser.Mono.cs
@@ -17,7 +17,9 @@ internal partial class TimerQueue
{
private static List? s_scheduledTimers;
private static List? s_scheduledTimersToFire;
+ private static long s_shortestDueTimeMs = long.MaxValue;
+ // this means that it's in the s_scheduledTimers collection, not that it's the one which would run on the next TimeoutCallback
private bool _isScheduled;
private long _scheduledDueTimeMs;
@@ -27,24 +29,25 @@ private TimerQueue(int id)
[DynamicDependency("TimeoutCallback")]
// The id argument is unused in netcore
+ // This replaces the current pending setTimeout with shorter one
[MethodImplAttribute(MethodImplOptions.InternalCall)]
private static extern void SetTimeout(int timeout, int id);
// Called by mini-wasm.c:mono_set_timeout_exec
private static void TimeoutCallback()
{
- int shortestWaitDurationMs = PumpTimerQueue();
+ // always only have one scheduled at a time
+ s_shortestDueTimeMs = long.MaxValue;
- if (shortestWaitDurationMs != int.MaxValue)
- {
- SetTimeout((int)shortestWaitDurationMs, 0);
- }
+ long currentTimeMs = TickCount64;
+ ReplaceNextSetTimeout(PumpTimerQueue(currentTimeMs), currentTimeMs);
}
+ // this is called with shortest of timers scheduled on the particular TimerQueue
private bool SetTimer(uint actualDuration)
{
Debug.Assert((int)actualDuration >= 0);
- long dueTimeMs = TickCount64 + (int)actualDuration;
+ long currentTimeMs = TickCount64;
if (!_isScheduled)
{
s_scheduledTimers ??= new List(Instances.Length);
@@ -52,24 +55,65 @@ private bool SetTimer(uint actualDuration)
s_scheduledTimers.Add(this);
_isScheduled = true;
}
- _scheduledDueTimeMs = dueTimeMs;
- SetTimeout((int)actualDuration, 0);
+
+ _scheduledDueTimeMs = currentTimeMs + (int)actualDuration;
+
+ ReplaceNextSetTimeout(ShortestDueTime(), currentTimeMs);
return true;
}
- private static int PumpTimerQueue()
+ // shortest time of all TimerQueues
+ private static void ReplaceNextSetTimeout(long shortestDueTimeMs, long currentTimeMs)
{
- if (s_scheduledTimersToFire == null)
+ if (shortestDueTimeMs == int.MaxValue)
+ {
+ return;
+ }
+
+ // this also covers s_shortestDueTimeMs = long.MaxValue when none is scheduled
+ if (s_shortestDueTimeMs > shortestDueTimeMs)
+ {
+ s_shortestDueTimeMs = shortestDueTimeMs;
+ int shortestWait = Math.Max((int)(shortestDueTimeMs - currentTimeMs), 0);
+ // this would cancel the previous schedule and create shorter one
+ // it is expensive call
+ SetTimeout(shortestWait, 0);
+ }
+ }
+
+ private static long ShortestDueTime()
+ {
+ if (s_scheduledTimers == null)
{
return int.MaxValue;
}
+ long shortestDueTimeMs = long.MaxValue;
+ var timers = s_scheduledTimers!;
+ for (int i = timers.Count - 1; i >= 0; --i)
+ {
+ TimerQueue timer = timers[i];
+ if (timer._scheduledDueTimeMs < shortestDueTimeMs)
+ {
+ shortestDueTimeMs = timer._scheduledDueTimeMs;
+ }
+ }
+
+ return shortestDueTimeMs;
+ }
+
+ private static long PumpTimerQueue(long currentTimeMs)
+ {
+ if (s_scheduledTimersToFire == null)
+ {
+ return ShortestDueTime();
+ }
+
List timersToFire = s_scheduledTimersToFire!;
List timers;
timers = s_scheduledTimers!;
- long currentTimeMs = TickCount64;
- int shortestWaitDurationMs = int.MaxValue;
+ long shortestDueTimeMs = int.MaxValue;
for (int i = timers.Count - 1; i >= 0; --i)
{
TimerQueue timer = timers[i];
@@ -88,9 +132,9 @@ private static int PumpTimerQueue()
continue;
}
- if (waitDurationMs < shortestWaitDurationMs)
+ if (timer._scheduledDueTimeMs < shortestDueTimeMs)
{
- shortestWaitDurationMs = (int)waitDurationMs;
+ shortestDueTimeMs = timer._scheduledDueTimeMs;
}
}
@@ -103,7 +147,7 @@ private static int PumpTimerQueue()
timersToFire.Clear();
}
- return shortestWaitDurationMs;
+ return shortestDueTimeMs;
}
}
}
diff --git a/src/mono/wasm/runtime/rollup.config.js b/src/mono/wasm/runtime/rollup.config.js
index 4d9617f214c838..bb9a984721d874 100644
--- a/src/mono/wasm/runtime/rollup.config.js
+++ b/src/mono/wasm/runtime/rollup.config.js
@@ -35,7 +35,8 @@ const terserConfig = {
},
mangle: {
// because of stack walk at src/mono/wasm/debugger/BrowserDebugProxy/MonoProxy.cs
- keep_fnames: /(mono_wasm_runtime_ready|mono_wasm_fire_debugger_agent_message)/,
+ // and unit test at src\libraries\System.Private.Runtime.InteropServices.JavaScript\tests\timers.js
+ keep_fnames: /(mono_wasm_runtime_ready|mono_wasm_fire_debugger_agent_message|mono_wasm_set_timeout_exec)/,
},
};
const plugins = isDebug ? [writeOnChangePlugin()] : [terser(terserConfig), writeOnChangePlugin()];
diff --git a/src/mono/wasm/runtime/scheduling.ts b/src/mono/wasm/runtime/scheduling.ts
index 8ab55fd045a9fa..b2b5616738e78f 100644
--- a/src/mono/wasm/runtime/scheduling.ts
+++ b/src/mono/wasm/runtime/scheduling.ts
@@ -3,7 +3,6 @@
import cwraps from "./cwraps";
-const timeout_queue: Function[] = [];
let spread_timers_maximum = 0;
export let isChromium = false;
let pump_count = 0;
@@ -19,23 +18,14 @@ if (globalThis.navigator) {
}
function pump_message() {
- while (timeout_queue.length > 0) {
- --pump_count;
- const cb: Function = timeout_queue.shift()!;
- cb();
- }
while (pump_count > 0) {
--pump_count;
cwraps.mono_background_exec();
}
}
-function mono_wasm_set_timeout_exec(id: number) {
- cwraps.mono_set_timeout_exec(id);
-}
-
export function prevent_timer_throttling(): void {
- if (isChromium) {
+ if (!isChromium) {
return;
}
@@ -48,7 +38,7 @@ export function prevent_timer_throttling(): void {
for (let schedule = next_reach_time; schedule < desired_reach_time; schedule += light_throttling_frequency) {
const delay = schedule - now;
setTimeout(() => {
- mono_wasm_set_timeout_exec(0);
+ cwraps.mono_set_timeout_exec(0);
pump_count++;
pump_message();
}, delay);
@@ -58,21 +48,17 @@ export function prevent_timer_throttling(): void {
export function schedule_background_exec(): void {
++pump_count;
- if (typeof globalThis.setTimeout === "function") {
- globalThis.setTimeout(pump_message, 0);
- }
+ setTimeout(pump_message, 0);
}
+let lastScheduledTimeoutId: any = undefined;
export function mono_set_timeout(timeout: number, id: number): void {
-
- if (typeof globalThis.setTimeout === "function") {
- globalThis.setTimeout(function () {
- mono_wasm_set_timeout_exec(id);
- }, timeout);
- } else {
- ++pump_count;
- timeout_queue.push(function () {
- mono_wasm_set_timeout_exec(id);
- });
+ function mono_wasm_set_timeout_exec() {
+ cwraps.mono_set_timeout_exec(id);
}
-}
\ No newline at end of file
+ if (lastScheduledTimeoutId) {
+ clearTimeout(lastScheduledTimeoutId);
+ lastScheduledTimeoutId = undefined;
+ }
+ lastScheduledTimeoutId = setTimeout(mono_wasm_set_timeout_exec, timeout);
+}