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); +}