Skip to content
Merged
Show file tree
Hide file tree
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,8 @@ Three conventions hold across the subtree:

Feature flags live in `WebApiFeatures`, whose bit layout is fixed ahead of the implementations so a persisted value keeps its meaning — but a flag is **declared only once the feature behind it exists**, so naming one can never compile into an engine that silently lacks it. `WebApiFeatures.Default` is every non-network feature and will never include fetch.

**Timers are the one web API that changed engine infrastructure, and the contract is narrow enough to state in full.** `TimerQueue` (`Jint/WebApi/Timers/TimerQueue.cs`) is a min-heap of due times plus an id map, engine-thread-only and lock-free, reached from `Engine._webApi` (`Jint/Engine.WebApi.cs`) — null on any engine that did not enable the feature, which is what every hot path consults first. Four rules hold it together. **The single job queue is the microtask queue:** `EventLoop.RunAvailableContinuations` promotes exactly *one* due timer, and only when `TryDequeue` has failed, so everything a job queues transitively runs before the next timer is looked at (`Promise.resolve().then` beats `setTimeout(0)`; each timer gets its own checkpoint; an interval cannot starve reactions). Promoting more than one, or promoting before the dequeue, breaks HTML ordering — both mutations are caught by `TimerTests.EachTimerGetsItsOwnMicrotaskCheckpoint`. **There are no background threads:** the clock is `TimeProvider.GetTimestamp`/`GetElapsedTime` and never `TimeProvider.CreateTimer`, so a timer fires on the next pump at or after its due time and an engine nobody pumps never fires one. Since a timer coming due enqueues nothing and therefore wakes nobody, the two wait loops bound themselves by `TimeUntilNextDue` instead — `Engine.DrainEventLoopUntil` clamps its poll interval, and `AwaitPromiseSettlementAsync` takes `EventLoop.WaitForEventAsync(TimeSpan, CancellationToken)`, one `Task.Delay` per idle wait and only while a timer pends. Both skip the wait entirely when a timer is due already, *unless* `IsRunningJob` says the queue is unrunnable from here, where an early return would spin hot for the caller's whole timeout. **A restore clears the queue:** `ResetTransientEvaluationState` calls `_webApi?.ResetTransientState()` beside `Modules?.DiscardPendingLoads()`, and each entry additionally carries its registration-time `EventLoopGeneration` into the job it is promoted as, so the generation fence catches one already in flight. **An interval is re-armed before its callback runs**, which is both what the specification says (step 8.2 precedes 8.4) and what stops a throwing callback silently killing it — pinned by `TimerTests.AThrowingCallbackDoesNotStopTheInterval`. Timer callbacks are jobs like promise reactions: same constraint budget, and a throw erupts from whatever is pumping.

## Modules

```csharp
Expand Down
7 changes: 7 additions & 0 deletions Jint.Repl/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@
{
cfg.AllowClr();
cfg.UseConsole(Console.Out);

// Timers, so a script pasted from the web behaves. Note that the REPL pumps the event loop only as part
// of evaluating each input, so a timer fires on the evaluation it is already due for or on a later one —
// a `setTimeout(f, 1000)` typed at the prompt runs f on the next line you enter, and one scheduled by a
// `-f script.js` run that outlives the script never runs at all. That is the engine's contract, not a
// REPL limitation: nothing anywhere in Jint pumps an engine on its own.
cfg.UseWebApis(WebApiFeatures.Timers);
if (timeoutSeconds.HasValue)
{
cfg.TimeoutInterval(TimeSpan.FromSeconds(timeoutSeconds.Value));
Expand Down
212 changes: 212 additions & 0 deletions Jint.Tests.PublicInterface/WebApiTimerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
#if NET8_0_OR_GREATER
using System.Diagnostics;
using Jint;
using Jint.Native;
using Jint.Runtime;

namespace Jint.Tests.PublicInterface;

/// <summary>
/// The timer globals seen from outside the assembly: what a host has to write to get them, what it gets when
/// it writes nothing, and the promises it can build on.
/// </summary>
/// <remarks>
/// This project has no <c>InternalsVisibleTo</c>, so nothing here can reach the timer queue directly — which
/// is the point. Everything is asserted through script and through the public options surface, exactly as an
/// embedder would have to.
/// </remarks>
public class WebApiTimerTests
{
/// <summary>
/// A host-supplied clock, to show that a suite exercising timers need not sleep. Only
/// <see cref="TimeProvider.GetTimestamp"/> is ever asked for, so this is all it takes.
/// </summary>
private sealed class ManualClock : TimeProvider
{
private long _timestamp;

public override long TimestampFrequency => TimeSpan.TicksPerSecond;

public override long GetTimestamp() => _timestamp;

public override DateTimeOffset GetUtcNow() => DateTimeOffset.UnixEpoch.AddTicks(_timestamp);

internal void Advance(int milliseconds) => _timestamp += milliseconds * TimeSpan.TicksPerMillisecond;
}

[Fact]
public void ADefaultEngineHasNoTimers()
{
var engine = new Engine();

foreach (var name in new[] { "setTimeout", "setInterval", "clearTimeout", "clearInterval", "queueMicrotask" })
{
engine.Evaluate($"typeof {name}").AsString().Should().Be("undefined");
engine.Evaluate($"'{name}' in globalThis").AsBoolean().Should().BeFalse();
}
}

[Fact]
public void UseWebApisInstallsTheTimers()
{
var engine = new Engine(options => options.UseWebApis());

foreach (var name in new[] { "setTimeout", "setInterval", "clearTimeout", "clearInterval", "queueMicrotask" })
{
engine.Evaluate($"typeof {name}").AsString().Should().Be("function");
}

// The default set is what UseWebApis() means, and it now names timers.
new Options().UseWebApis().WebApi.Features.Should().HaveFlag(WebApiFeatures.Timers);
}

[Fact]
public void AskingForConsoleAloneDoesNotBringTimers()
{
var engine = new Engine(options => options.UseWebApis(WebApiFeatures.Console));

engine.Evaluate("typeof console").AsString().Should().Be("object");
engine.Evaluate("typeof setTimeout").AsString().Should().Be("undefined");
}

[Fact]
public void AHostRegisteredTimerGlobalWins()
{
var marker = new JsString("the host's own setTimeout");

var engine = new Engine(options => options
.AddLazyGlobal("setTimeout", _ => marker)
.UseWebApis());

// The host's configuration runs first and the install is non-clobbering.
engine.Evaluate("setTimeout").Should().BeSameAs(marker);

// The names it did not claim are still installed.
engine.Evaluate("typeof setInterval").AsString().Should().Be("function");
}

[Fact]
public void AHostSuppliedClockDrivesTheTimers()
{
var clock = new ManualClock();
var engine = new Engine(options => options.UseWebApis(webApi => webApi.Timers.TimeProvider = clock));

engine.Execute("var log = []; setTimeout(() => log.push('fired'), 25);");

// No thread exists to notice time passing: the host's own pump is what runs a timer.
clock.Advance(10);
engine.Advanced.ProcessTasks();
engine.Evaluate("log.length").AsNumber().Should().Be(0);

clock.Advance(15);
engine.Advanced.ProcessTasks();
engine.Evaluate("log.join(',')").AsString().Should().Be("fired");
}

[Fact]
public void MaxActiveTimersIsEnforcedAsAQuotaExceededError()
{
var engine = new Engine(options => options.UseWebApis(webApi => webApi.Timers.MaxActiveTimers = 1));

engine.Execute("setTimeout(function () {}, 60000);");

engine.Evaluate("""
(() => {
try { setTimeout(function () {}, 60000); }
catch (e) { return e.name; }
return 'no error';
})()
""").AsString().Should().Be("QuotaExceededError");
}

[Fact]
public void OneOptionsInstanceServesSeveralEnginesWithIndependentTimerQueues()
{
var options = new Options().UseWebApis(webApi => webApi.Timers.MaxActiveTimers = 1);

var first = new Engine(options);
var second = new Engine(options);

first.Execute("setTimeout(function () {}, 60000);");

// The second engine's single slot is its own: a queue shared through the options would refuse this.
second.Execute("setTimeout(function () {}, 60000);");

first.Evaluate("""
(() => { try { setTimeout(function () {}, 60000); } catch (e) { return e.name; } return 'no error'; })()
""").AsString().Should().Be("QuotaExceededError");
}

[Fact]
public void ABlockingUnwrapRunsTheTimersItIsWaitingFor()
{
var engine = new Engine(options => options.UseWebApis());

var stopwatch = Stopwatch.StartNew();
var result = engine
.Evaluate("(async () => { await new Promise(r => setTimeout(r, 100)); return 42; })()")
.UnwrapIfPromise();

result.AsNumber().Should().Be(42);
stopwatch.Elapsed.Should().BeGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(80));
}

[Fact]
public async Task AnAwaitedEvaluationRunsTheTimersItIsWaitingFor()
{
var engine = new Engine(options => options.UseWebApis());

var result = await engine.EvaluateAsync("(async () => { await new Promise(r => setTimeout(r, 100)); return 42; })()");

result.AsNumber().Should().Be(42);
}

[Fact]
public void AHostPumpIsEnoughToRunTimers()
{
var engine = new Engine(options => options.UseWebApis());
var done = false;
engine.SetValue("markDone", new Action(() => done = true));

engine.Execute("setTimeout(markDone, 50);");

var deadline = Stopwatch.StartNew();
while (!done && deadline.Elapsed < TimeSpan.FromSeconds(5))
{
engine.Advanced.ProcessTasks();
Thread.Sleep(5);
}

done.Should().BeTrue();
}

[Fact]
public void ATimerOutlivingTheUnwrapTimeoutTimesOutByDesign()
{
var engine = new Engine(options => options.UseWebApis());

var pending = engine.Evaluate("new Promise(r => setTimeout(r, 5000))");

Assert.Throws<PromiseRejectedException>(() => pending.UnwrapIfPromise(TimeSpan.FromMilliseconds(200)));
}

[Fact]
public void ARestoredEngineNeverRunsTheTimersOfTheCycleThatEnded()
{
var clock = new ManualClock();
var engine = new Engine(options => options.UseWebApis(webApi => webApi.Timers.TimeProvider = clock));

var ran = false;
engine.SetValue("mark", new Action(() => ran = true));

var snapshot = engine.Advanced.CaptureGlobalSnapshot();
engine.Execute("setTimeout(mark, 10);");
engine.Advanced.RestoreGlobalSnapshot(snapshot);

clock.Advance(1000);
engine.Advanced.ProcessTasks();

ran.Should().BeFalse();
}
}
#endif
Loading