Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
<Compile Include="System\Runtime\InteropServices\JavaScript\HelperMarshal.cs" />
<Compile Include="System\Runtime\InteropServices\JavaScript\Http\HttpRequestMessageTest.cs" />
<Compile Include="System\Runtime\InteropServices\JavaScript\ParallelTests.cs" />
<Compile Include="System\Runtime\InteropServices\JavaScript\TimerTests.cs" />
</ItemGroup>

<ItemGroup>
<WasmExtraFilesToDeploy Include="timers.js" />
</ItemGroup>
<ItemGroup>
<!-- Part of the shared framework but not exposed. -->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// 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.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<object[]> 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, 050 }, 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(null, $"Waiting for runtime to settle");
await Task.Delay(2000);
_installWrapper.Call();
// _log.Call(null, $"Ready!");

for (int i = 0; i < timeouts.Length; i++)
{
int index = i;
// _log.Call(null, $"Registering {index} delay {timeouts[i]}");
timers[i] = new Timer((_) =>
{
// _log.Call(null, $"In timer{index}");
wasCalled++;
}, null, timeouts[i], 0);
}

var setCounter = (int)_getRegisterCount.Call();
Assert.True(0 == wasCalled, $"wasCalled: {wasCalled}");
Assert.True((expectedSetCounter ?? timeouts.Length) == setCounter, $"setCounter: actual {setCounter} expected {expectedSetCounter}");

}
finally
{
await WaitForCleanup();
Assert.True(timeouts.Length == wasCalled, $"wasCalled: actual {wasCalled} expected {timeouts.Length}");

if (expectedSetCounterAfterCleanUp != null)
{
var setCounter = (int)_getRegisterCount.Call();
Assert.True(expectedSetCounterAfterCleanUp.Value == setCounter, $"setCounter: actual {setCounter} expected {expectedSetCounterAfterCleanUp.Value}");
}

if (expectedHitCount != null)
{
var hitCounter = (int)_getHitCount.Call();
Assert.True(expectedHitCount == hitCounter, $"hitCounter: actual {hitCounter} expected {expectedHitCount}");
}

for (int i = 0; i < timeouts.Length; i++)
{
timers[i].Dispose();
}
}
}

private async Task WaitForCleanup()
{
// _log.Call(null, "wait for cleanup begin");
await Task.Delay(1200);
_cleanupWrapper.Call();
// _log.Call(null, "wait for cleanup end");
}

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 = (JSObject)Runtime.GetGlobalObject("timersHelper");
_installWrapper = (Function)_timersHelper.GetObjectProperty("install");
_getRegisterCount = (Function)_timersHelper.GetObjectProperty("getRegisterCount");
_getHitCount = (Function)_timersHelper.GetObjectProperty("getHitCount");
_cleanupWrapper = (Function)_timersHelper.GetObjectProperty("cleanup");
// var console = (JSObject)Runtime.GetGlobalObject("console");
// _log = (Function)console.GetObjectProperty("log");
}
}

public Task DisposeAsync() => Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
class TimersHelper {
install() {
const measuredCallbackName = "mono_wasm_set_timeout_exec";
globalThis.registerCount = 0;
globalThis.hitCount = 0;
// console.log("install")
if (!globalThis.originalSetTimeout) {
globalThis.originalSetTimeout = globalThis.setTimeout;
}
globalThis.setTimeout = (cb, time) => {
var start = Date.now().valueOf();
if (cb.name === measuredCallbackName) {
globalThis.registerCount++;
// console.log(`registerCount: ${globalThis.registerCount} now:${start} delay:${time}`)
}
return globalThis.originalSetTimeout(() => {
if (cb.name === measuredCallbackName) {
var hit = Date.now().valueOf();
globalThis.hitCount++;
var delta = hit - start;
// console.log(`hitCount: ${globalThis.hitCount} now:${hit} delay:${time} delta:${delta}`)
}
cb();
}, time);
};
}

getRegisterCount() {
// console.log(`registerCount: ${globalThis.registerCount} `)
return globalThis.registerCount;
}

getHitCount() {
// console.log(`hitCount: ${globalThis.hitCount} `)
return globalThis.hitCount;
}

cleanup() {
// console.log(`cleanup registerCount: ${globalThis.registerCount} hitCount: ${globalThis.hitCount} `)
globalThis.setTimeout = globalThis.originalSetTimeout;
}
}

globalThis.timersHelper = new TimersHelper();
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ internal partial class TimerQueue
{
private static List<TimerQueue>? s_scheduledTimers;
private static List<TimerQueue>? 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;

Expand All @@ -27,49 +29,91 @@ 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<TimerQueue>(Instances.Length);
s_scheduledTimersToFire ??= new List<TimerQueue>(Instances.Length);
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<TimerQueue> timersToFire = s_scheduledTimersToFire!;
List<TimerQueue> 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];
Expand All @@ -88,9 +132,9 @@ private static int PumpTimerQueue()
continue;
}

if (waitDurationMs < shortestWaitDurationMs)
if (timer._scheduledDueTimeMs < shortestDueTimeMs)
{
shortestWaitDurationMs = (int)waitDurationMs;
shortestDueTimeMs = timer._scheduledDueTimeMs;
}
}

Expand All @@ -103,7 +147,7 @@ private static int PumpTimerQueue()
timersToFire.Clear();
}

return shortestWaitDurationMs;
return shortestDueTimeMs;
}
}
}
3 changes: 2 additions & 1 deletion src/mono/wasm/runtime/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()];
Expand Down
38 changes: 12 additions & 26 deletions src/mono/wasm/runtime/scheduling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand All @@ -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);
Expand All @@ -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);
}
}
if (lastScheduledTimeoutId) {
clearTimeout(lastScheduledTimeoutId);
lastScheduledTimeoutId = undefined;
}
lastScheduledTimeoutId = setTimeout(mono_wasm_set_timeout_exec, timeout);
}