using Jint; using Jint.Native; using Jint.Native.Object; using Jint.Runtime.Descriptors; using Jint.Runtime.Interop; namespace XFESystem.Browser; /// A single-threaded, explicitly pumped browser task queue; never sleeps or starts host threads. internal sealed class BrowserEventLoop { private sealed record Task(int Id, JsValue Callback, JsValue[] Arguments, long Due, long Interval, bool Animation); private readonly Engine engine; private readonly BrowserLimits limits; private readonly Dictionary tasks = []; private readonly Func isCallable; private int nextId; private int pendingMicrotasks; public BrowserEventLoop(Engine engine, BrowserLimits limits) { this.engine = engine; this.limits = limits; isCallable = (Func)engine.Evaluate("(function(value){return typeof value==='function';})").ToObject()!; Install("setTimeout", args => Schedule(args, false, false)); Install("setInterval", args => Schedule(args, true, false)); Install("requestAnimationFrame", args => Schedule(args, false, true)); foreach (var name in new[] { "clearTimeout", "clearInterval", "cancelAnimationFrame" }) Install(name, args => { tasks.Remove(Integer(Value(args, 0))); return JsValue.Undefined; }); Install("__xfeMicrotaskEnter", _ => { if (++pendingMicrotasks > limits.MaximumPendingTasks) { --pendingMicrotasks; throw new BrowserLimitException("The browser microtask queue is full."); } return JsValue.Undefined; }); Install("__xfeMicrotaskLeave", _ => { if (pendingMicrotasks > 0) --pendingMicrotasks; return JsValue.Undefined; }); _ = new BrowserPerformance(engine, limits, () => Now); engine.Execute(""" (function(enter,leave){ globalThis.queueMicrotask = function(callback) { if (typeof callback !== 'function') throw new TypeError('queueMicrotask requires a function'); enter(); Promise.resolve().then(function() { try { callback(); } finally { leave(); } }); }; delete globalThis.__xfeMicrotaskEnter;delete globalThis.__xfeMicrotaskLeave;})(__xfeMicrotaskEnter,__xfeMicrotaskLeave); """); } public long Now { get; private set; } public int PendingCount => tasks.Count + pendingMicrotasks; public int Pump(long monotonicMs, int maximumCallbacks) { if (monotonicMs < 0) throw new ArgumentOutOfRangeException(nameof(monotonicMs)); Now = Math.Max(Now, monotonicMs); var maximum = Math.Clamp(maximumCallbacks, 1, limits.MaximumEventCallbacksPerPump); // Snapshot: tasks queued by a callback wait for the next pump (RAF never recursively runs in one frame). var due = tasks.Values.Where(task => task.Due <= Now).OrderBy(task => task.Due).ThenBy(task => task.Id) .Take(maximum).ToArray(); var executed = 0; foreach (var task in due) { if (!tasks.Remove(task.Id)) continue; if (task.Interval > 0) tasks.Add(task.Id, task with { Due = checked(Now + task.Interval) }); engine.Invoke(task.Callback, engine.Global, task.Animation ? [(JsValue)(double)Now] : task.Arguments); // Evaluate drains Jint's actual promise job queue under statement/time/memory constraints. engine.Evaluate("void 0"); ++executed; } return executed; } private JsValue Schedule(JsValue[] args, bool repeat, bool animation) { var callback = Value(args, 0); if (!isCallable(JsValue.Undefined, [callback]).AsBoolean()) throw new ArgumentException("Browser timers require a function; source-string timers are disabled."); if (tasks.Count + pendingMicrotasks >= limits.MaximumPendingTasks) throw new BrowserLimitException("The browser task queue is full."); var delay = animation ? 1 : Math.Clamp(Number(Value(args, 1)), 0, int.MaxValue); var interval = repeat ? Math.Max(1, (long)delay) : 0; var id = nextId == int.MaxValue ? nextId = 1 : ++nextId; while (tasks.ContainsKey(id)) id = ++nextId; tasks.Add(id, new(id, callback, animation ? [] : args.Skip(2).ToArray(), checked(Now + (long)delay), interval, animation)); return id; } private void Install(string name, Func callback) => engine.SetValue(name, new ClrFunction(engine, name, (_, args) => callback(args))); private static JsValue Value(JsValue[] args, int index) => index < args.Length ? args[index] : JsValue.Undefined; private static double Number(JsValue value) => value.IsNumber() && double.IsFinite(value.AsNumber()) ? value.AsNumber() : 0; private static int Integer(JsValue value) => (int)Math.Clamp(Number(value), int.MinValue, int.MaxValue); }