diff --git a/docs/adr/0112-native-async-local-storage.md b/docs/adr/0112-native-async-local-storage.md index 06c8174b1..ecb60bf38 100644 --- a/docs/adr/0112-native-async-local-storage.md +++ b/docs/adr/0112-native-async-local-storage.md @@ -99,6 +99,12 @@ Deliberately out of scope: travel into. When a host-scheduled callback surface is added, its scheduling point becomes a third seam; nothing about the snapshot representation has to change for that. + + **Superseded by [ADR 0113](0113-deterministic-virtual-timer-queue.md).** The + virtual timer queue is that surface, and the prediction held: timer + registration captures the current snapshot and the queue installs it around + the callback through the same `EnterAsyncContext` / `LeaveAsyncContext` pair, + with nothing in `Goccia.AsyncContext` changed. - **The `async_hooks` observer API.** `createHook`, `executionAsyncId`, `triggerAsyncId`, and the `init`/`before`/`after`/`destroy` callbacks are not provided. They describe an async-resource lifecycle GocciaScript does not diff --git a/docs/adr/0113-deterministic-virtual-timer-queue.md b/docs/adr/0113-deterministic-virtual-timer-queue.md new file mode 100644 index 000000000..565f03cac --- /dev/null +++ b/docs/adr/0113-deterministic-virtual-timer-queue.md @@ -0,0 +1,301 @@ +# Deterministic virtual timer queue + +**Date:** 2026-08-22 +**Area:** `runtime` + +GocciaScript implements `setTimeout`, `clearTimeout`, `setInterval` and +`clearInterval` over a virtual timer queue in the engine, and builds the Vitest +fake-timer family — `vi.useFakeTimers` and the rest — on top of it in the +test-runner profile. No timer ever waits on wall time: the queue holds a due +time on a virtual clock, and a timer fires only when something advances that +clock. + +This supersedes the scope cut in +[ADR 0112](0112-native-async-local-storage.md), which recorded that +"GocciaScript has no timer task queue and no general event loop, so there is no +`setTimeout` continuation for a context to travel into", and predicted that a +host-scheduled callback surface would become a third async-context seam without +changing the snapshot representation. That is exactly what happened: timer +registration captures the current snapshot and the queue installs it around the +callback, through the existing `EnterAsyncContext` / `LeaveAsyncContext` pair. +Nothing in `Goccia.AsyncContext` changed. + +## Why this is the engine's problem + +The forcing evidence is a corpus sweep of 82 real `convex-test` application +suites. Fake timers were the single dominant engine blocker: 418 of 494 +remaining failures, spread across 23 files. `convex-test`'s scheduler is built +on `setTimeout(fn, 0)`, and the suites written against it drain that scheduler +by calling `vi.advanceTimersByTime` / `vi.runAllTimers`. Every one of those +files failed on the first `vi.useFakeTimers()`. + +The one corpus file re-run after this landed — `convex/askFeedback.test.ts`, +whose only blocker was `vi.useFakeTimers` — went from 0 of 10 passing to 9 of +10. The one remaining failure is unrelated (`crypto.subtle.digest` is not +provided). + +A userland shim cannot supply this. Fake timers are not a wrapper around a real +timer API — they *are* the timer API for the duration of the test, and the code +under test calls the global `setTimeout` directly. Without a `setTimeout` in the +engine there is nothing for a shim to intercept, and with one there is no way +for JavaScript to make the engine's own `await` and end-of-run draining aware of +it. + +Declining was the alternative, and it was rejected for the same reason ADR 0112 +rejected declining `AsyncLocalStorage`: the gap is not something a suite author +can work around. + +## What is virtual, and what real timers mean here + +There is one queue and one virtual clock, in two modes. + +Under **fake timers** the clock moves only when the suite moves it. That is the +Vitest contract, and it is why `await` deliberately does *not* run timers in +this mode: a suite that turned the clock over to `vi` decided when its timers +run, and an engine that quietly advanced it would take that decision back. + +Under **real timers** the clock still never tracks wall time. It jumps forward +to the next timer's due time at the points where the engine would otherwise have +nothing left to do: + +- **an `await` on a promise a timer will settle.** GocciaScript drains awaits + synchronously rather than parking, so `await new Promise(r => setTimeout(r, + 10))` had nowhere to get its continuation from. The queue is consulted in the + two places that drive a promise to settlement: `AwaitValue` + (`Goccia.Values.Await`) for a synchronous await, and `WaitForFetchPromise` + (`Goccia.FetchManager`), which despite its name is the host's general + settle-this-promise wait and is what the test runner calls on every async + test's returned promise. +- **the end of each test**, through the runner's own per-test lifecycle + (`Goccia.Builtins.TestingLibrary`). This one is not optional. The engine's + runtime-idle point is reached when the *entry module* finishes evaluating, + which under the test runner is before a single test body has run — so a + `setTimeout` written inside a `test()` was never drained by it and was + discarded on the way out, silently. Draining where the test that scheduled it + is still the current one is also what lets a throwing callback be attributed + to that test. +- **the engine's idle point**, through the timer extension's `WaitForIdle`, + which covers timers the entry module itself scheduled. + +Real outstanding work outranks virtual time. A timer costs nothing to advance +to, so consulting the queue before polling fetch made +`Promise.race([fetch(url), timeoutAfter(ms)])` resolve to the timeout every +single time, whatever `ms` was, and let a live interval spend a whole budget +before a response could arrive. Fetch and `Atomics.waitAsync` are therefore +polled first, and the clock only moves when nothing real is outstanding. + +An exception from a real-mode callback does **not** surface at whichever frame +was waiting. In Node a throwing timer is an uncaught top-level error and the +awaiting frame is untouched; raising it at the wait instead made it catchable by +an unrelated `try` around the `await` and left the awaited promise pending as +well. The queue parks it and the runner attributes it to the test that scheduled +it, reported as `uncaught exception in a timer callback`. + +Two consequences are worth stating plainly, because they are divergences from +Node rather than from Vitest: + +- **A delay is an ordering key, not a duration.** `setTimeout(fn, 5000)` inside + an `await` resolves instantly; only the virtual clock moved. This is what + makes a timer-driven suite fast and reproducible, and it is the whole point. +- **A self-rescheduling timer cannot hang the run.** Every drain is bounded by + the same 10000-timer limit `runAllTimers` uses. The bound was not + precautionary: without it, `convex-test`'s scheduler — which re-arms a + `setTimeout(fn, 0)` after every batch — kept a promise wait alive forever + during the corpus suite's cleanup hook. A wait that spends the whole budget + with timers still runnable *names that* rather than reporting an unsettled + promise, which read as a missing `await` and sent the reader looking in the + wrong place. +- **Intervals are excluded from the idle drains entirely.** An uncleared one is + by construction never exhausted, so running it there would spend the whole + budget and finish no sooner than skipping it — and a throwing one propagating + out of a teardown path would turn a passing file into a failing one. Whatever + a callback threw is parked for the host, never raised from a shutdown path. +- **Leftovers do not cross a test boundary.** Whatever a bounded drain did not + reach is dropped when the test ends, so a strand cannot fire inside the next + test. Fake-timer state is exempt: that queue belongs to the suite, and Vitest + does not reset it between tests either. + +`useFakeTimers()` installs a fresh clock and discards whatever was pending on +the previous one, which is what a second `useFakeTimers()` does in Vitest. For +GocciaScript that also means a timer scheduled before the switch is dropped +rather than left to a real event loop, because there is no real event loop for +it to be left to. + +## Semantics, probed rather than read + +Vitest's fake timers wrap `@sinonjs/fake-timers`, so the oracle is the pinned +Vitest 4.1.10 in `scripts/differential/node_modules`, not its documentation. +Each row below was probed against it and is locked in by +`scripts/differential/r-faketimers.test.js`, which vitest gates. + +| Behaviour | Probed result | +|---|---| +| `advanceTimersByTime` and microtasks | Runs due timers with **no** microtask draining between them; a promise callback a timer queued waits until the advance returns | +| `advanceTimersByTimeAsync` | Drains microtasks before the first timer and again after each one — `["pending", "first", "after-first", "second", "after-second"]` | +| Ordering of equal due times | Due time, then creation time, then id, so registration order breaks ties | +| Zero, missing or negative delay | Clamped to 0 and due at the current instant | +| Zero delay scheduled *during* an advance | Due at now **+ 1ms**, not now — `delay \|\| (duringTick ? 1 : 0)` — so a zero-delay chain cannot loop inside one advance | +| Interval rescheduling | `callAt += interval` *before* the callback runs, so intervals do not drift; one advance fires every tick it crossed | +| `runAllTimers` with a self-rescheduling timer | Runs exactly 10000 timers, then throws `Aborting after running 10000 timers, assuming an infinite loop!` | +| `runOnlyPendingTimers` | Ticks to the latest due time among the timers pending at the call, so a timer one of them scheduled inside that window fires and one beyond it stays pending | +| `advanceTimersToNextTimer` | `clock.next()` followed by a zero-length tick, so **every** timer due at the instant it landed on fires, not just the first | +| A throwing callback under `advanceTimersByTime` / `runOnlyPendingTimers` | The first exception is recorded, the remaining timers still run, the clock still reaches the requested instant, and the error is rethrown when the advance ends | +| A throwing callback under `runAllTimers` / `advanceTimersToNextTimer` | Stops there — the clock's single-timer step has no handler of its own, so everything behind it stays pending. Probed per member, because the three do not agree | +| `advanceTimersByTime(-1)` | `Negative ticks are not supported` | +| Advancing without fake timers | ``A function to advance timers was called but the timers APIs are not mocked. Call `vi.useFakeTimers()` in the test file first.`` | +| `useFakeTimers()` start instant | The date already in effect — the current fake now if already faking, else a frozen `setSystemTime` date, else real time | +| `useFakeTimers()` called twice | Fresh clock at the current instant; pending timers discarded | +| `setSystemTime` while faking | Moves the wall clock and shifts every pending timer's due time and creation time by the same delta, so remaining delays and ordering are preserved — forwards and backwards | +| `setSystemTime` **without** `useFakeTimers` | Freezes `Date` only; timers and monotonic time are untouched, and `getMockedSystemTime()` reports the frozen date | +| `performance.now()` under fake timers | Elapsed virtual time from the moment fake timers were installed — starts at 0, advances with a tick, and is **not** moved by `setSystemTime` | +| A fractional **delay** | Truncated — the clock computes a due time with `parseInt`, so `setTimeout(fn, 1.5)` is due at 1 and a delay of 0.4 is due immediately | +| A fractional **advance** | Banked, not truncated: `advanceTimersByTime(1.5)` then `(0.5)` moves the clock a full 2ms and fires a timer due there. The pairing with the row above is the opposite of the natural guess, which is why both were probed | +| A `setInterval` with period 0 | Every tick lands on the instant the clock is already on, and the advance still finishes where it was asked to. Node clamps such a period to 1ms; the fake clock does not | +| A nested advance called from inside a timer callback | Sees nothing of the enclosing advance's recorded exception — the record is per operation, not per clock | +| `setSystemTime` with a string | Supported: anything not already a `Date` goes through the `Date` constructor | +| `performance.now()` across the transition | 0 at install, elapsed virtual time while faked, back on the real timeline after `useRealTimers()` | +| `getMockedSystemTime()` | A `Date` while mocked, `null` otherwise | +| `getRealSystemTime()` | The real clock, even while one is mocked | +| Every `vi` timer member | Returns `vi`, so calls chain | +| Fake-timer state between tests | **Not** reset — Vitest leaves the clock installed across tests in a file, and resets only between files | + +Two shapes are refused where Vitest admits them, and both refusals exist because +this clock is not a JavaScript number: + +- **A non-finite system time.** Vitest lets `setSystemTime(NaN)` — or a string + `Date` cannot parse — through, and `Date.now()` then reports `NaN` harmlessly. + Here the mocked clock reaches JavaScript as an `Int64` nanosecond count on the + host environment, and every consumer of the virtual clock is arithmetic: once + `NaN` was admitted, every due-time comparison was false, the range test + selected arbitrarily, and the trailing re-check in the tick recursed on `NaN` + until the process segfaulted. It is refused at the door instead, in the queue + rather than at either JavaScript boundary — there are two, and only one of + them goes through the Vitest shim's `Date` conversion. +- **An advance that can never finish.** A zero-period interval re-arms at the + instant it just ran, so the clock cannot move past it; Vitest hangs forever. + The per-advance bound is far above anything a real suite reaches — a 10ms + interval advanced by an hour fires 360000 times — so it only ever catches that + shape. + +Two shapes were probed and deliberately **not** matched: + +- **The timer id.** Vitest runs in Node, where the fake clock hands back a + Node-shaped `Timeout` object with `ref`/`unref`/`refresh`. GocciaScript hands + back a number, as the web platform does; it has no Node timer object to + imitate and no event loop for `ref`/`unref` to mean anything to. `clearTimeout` + takes either, which is the part suites depend on, and the differential suite + therefore asserts on clearing rather than on the id's type. +- **`vi.useFakeTimers(config)` beyond `now`.** `toFake` has nothing to select + from — there is one queue and it is always the faked one — and + `shouldAdvanceTime` / `advanceTimeDelta` describe real elapsed time, which no + GocciaScript clock measures. Both are ignored rather than rejected, so a suite + that passes them still runs. + +Three members of the family stay unsupported, and keep the shim's rule of being +a function that throws a named reason rather than an absent property: +`advanceTimersToNextFrame` (no `requestAnimationFrame`, and no display to pace a +frame against), `runAllTicks` (no `process.nextTick` — promise jobs run on the +engine's microtask queue, which the `Async` advance members already drain), and +`setTimerTickMode` (every mode but the default advances against real elapsed +time). Each message says the timer queue itself is present, so the reason points +at the actual gap rather than at a clock that now exists. + +## How it is built + +`Goccia.Timers` holds the queue: entries, the virtual clock, and the advance +operations. It is shared machinery below both executors, so the interpreter and +the bytecode VM get identical behaviour by construction rather than by +maintenance — the parity requirement ADR 0112 states for the async-context +seams applies unchanged here. + +`Goccia.Builtins.Timers` is the JavaScript surface: the four globals plus the +`goccia:timers` module. That module is the low-level control surface and speaks +in numbers — `setSystemTime` takes epoch milliseconds, `getMockedSystemTime` +returns them or `null`. Wrapping those in `Date` and returning `vi` for +chaining is the Vitest shim's job, which keeps the engine surface free of a +dependency on the `Date` shim and leaves `goccia:timers` usable from a suite +that never imports `vitest`. + +A mocked clock reaches JavaScript through the engine's host environment +(`TGocciaHostEnvironment.OverrideClock`) rather than by patching a global. That +is the layer `Date`, `Temporal.Now` and `performance` already read — the `Date` +shim is written in JavaScript on top of `Temporal`, so there is nothing to patch +there anyway — and one override keeps every reader consistent. The epoch and +monotonic halves are independent, which is what lets `setSystemTime` outside +`useFakeTimers` freeze the date while leaving `performance.now()` real. It is +not inherited by `ConfigureAsChildOf`, so a ShadowRealm child sees the real +clock until something mocks its own. + +`performance.now()` needed one adjustment for this: it normally subtracts its +own time origin, captured from the real monotonic clock at engine boot. A mocked +monotonic clock counts from the mock's own origin instead, so under an override +the elapsed value is used directly. That is what reproduces the probed +behaviour — 0 at install, +250 after a 250ms advance — rather than clamping to 0 +forever. + +A timer callback is a continuation, so registration captures +`CurrentAsyncContext` and the queue installs it with +`EnterAsyncContext`/`LeaveAsyncContext` around the call, exactly as the +microtask queue does for a job. A pending timer's callback, arguments and +captured snapshot are reachable from nothing else, so the queue publishes them +through a `TGCRootSource` that is rebuilt when the thread's collector changes — +the same rule `Goccia.AsyncContext` follows. + +Three properties of that machinery are load-bearing and were each wrong first. + +**The queue is a thread singleton, and a realm is not.** A ShadowRealm child +runs on the same thread and shares it, so the drains ask whether the realm +currently executing is the one whose timers the queue carries. Without that, an +`await` inside the child ran the *parent's* callbacks with the child's realm +installed — parent code against child intrinsics, reported by nothing. It is +reachable: an ordinary `async` function suspends and resumes through a promise +reaction, but `Array.fromAsync` awaits on the caller's own stack, so +`realm.evaluate` of that shape reaches the drain. + +**A recorded exception belongs to one advance, not to the clock.** A tick drains +microtasks between timers, and guest code reached from there can start another +advance re-entrantly. With a single queue-wide slot the outer tick's exception +was raised at the inner call and the outer tick then believed it had succeeded — +so each operation pushes its own slot and the enclosing ones stay saved, and +stay marked, until it pops. Vitest keeps it per operation too; that was probed +rather than inferred. + +**A root source has to be keyed on the collector it is registered with**, not on +one remembered beside it. A `Shutdown`/`Initialize` pair can put the next +thread-local collector at the address the previous one had, and a bare pointer +compare then reports "same collector" for a source registered with the dead one, +leaving everything it publishes unmarked. `TGCRootSource.RegisteredCollector` +reads the registration the collector's destructor nils, which cannot match a +destroyed one. `Goccia.AsyncContext` had the same latent compare and is fixed +with it. + +## Availability + +The timer globals and `goccia:timers` are installed by the **test-runner +profile only**, not by the loader profile. They are deterministic and carry no +ambient authority — no I/O, no real clock, no way to observe anything the +program did not already have — but they are still a scheduling surface a +sandboxed script does not otherwise get, and the acceptance target for them is +the runner. Widening this to `GocciaScriptLoader` is a later decision with its +own evidence; nothing in the design depends on where the extension is +installed. + +Per-file isolation comes from the runner's existing lifecycle rather than from a +reset hook: `GocciaTestRunner` builds a fresh engine per test file, and the +extension clears the queue when it attaches. Per-*test* state is deliberately +not reset, because Vitest does not reset it either. + +## Consequences + +`vi.waitFor` and `vi.waitUntil` still throw, and their message had to be +rewritten rather than deleted. They are async polling APIs, not timer APIs: each +needs execution to suspend and resume between attempts, and `await` in +GocciaScript is a synchronous drain. The old message pointed at a missing fake +clock, which is no longer the gap; the new one names the suspension point and +the fact that the virtual clock only moves when a test moves it. + +Adding the timer step to `WaitForFetchPromise` widens what that function is, +and its name now under-describes it. It was already the host's general +"drive this promise to settlement" wait — the test runner, `expect().resolves` +and `expect().rejects` all call it — so the alternative was a second, nearly +identical wait beside it. The comment at the call site records the widening. diff --git a/docs/adr/README.md b/docs/adr/README.md index 263f00f97..57f2a88d6 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -122,3 +122,4 @@ Durable architecture and implementation decisions for GocciaScript. New ADRs use - [0110 — The growth gate collects before refusing, and store paths root their temporaries](0110-growth-gate-collects-before-refusing.md) - [0111 — Opt-in node_modules resolution](0111-opt-in-node-modules-resolution.md) - [0112 — Native AsyncLocalStorage over continuation snapshots](0112-native-async-local-storage.md) +- [0113 — Deterministic virtual timer queue](0113-deterministic-virtual-timer-queue.md) diff --git a/docs/built-ins-async-context.md b/docs/built-ins-async-context.md index fec4326ba..a572d760f 100644 --- a/docs/built-ins-async-context.md +++ b/docs/built-ins-async-context.md @@ -89,14 +89,19 @@ An async generator body observes the context of whichever call resumed it, in both executors and as in Node — a `for await` inside a `run` sees that run's store, and a generator resumed outside one sees no store. -It does not travel into host-scheduled callbacks, because there are none: -GocciaScript has no timer task queue and no general event loop, so there is no -`setTimeout` continuation for a context to reach. The `async_hooks` observer API -(`createHook`, `executionAsyncId`, and the `init` / `before` / `after` / -`destroy` callbacks) is not provided either — it describes an async-resource -lifecycle this engine does not have. [ADR -0112](adr/0112-native-async-local-storage.md) records both cuts and the -snapshot mechanism behind the propagation. +It travels into timer callbacks too. A `setTimeout` scheduled inside a `run` +captures the snapshot at registration and runs under it, even though the `run` +returned long before the timer fired — see [Fake +timers](testing-api.md#fake-timers) for the queue itself. That is the third +propagation seam, and it needed nothing new from the snapshot mechanism. + +The `async_hooks` observer API (`createHook`, `executionAsyncId`, and the `init` +/ `before` / `after` / `destroy` callbacks) is not provided — it describes an +async-resource lifecycle this engine does not have. [ADR +0112](adr/0112-native-async-local-storage.md) records that cut and the snapshot +mechanism behind the propagation; [ADR +0113](adr/0113-deterministic-virtual-timer-queue.md) records the timer seam it +predicted. ## Availability diff --git a/docs/built-ins.md b/docs/built-ins.md index 9f63de4d9..a60468ec7 100644 --- a/docs/built-ins.md +++ b/docs/built-ins.md @@ -23,6 +23,8 @@ Core language built-ins (Math, Object, Array, Number, JSON, Symbol, Set, Map, We Runtime globals (Console, Performance, TextEncoder/TextDecoder, URL, fetch, Headers, Response, AbortController/AbortSignal, EventTarget/Event) are registered by the loader runtime profile and runtime extension classes under `source/units/Goccia.RuntimeExtensions.*.pas`. The same runtime profile also installs named-export-only Goccia modules for non-standard data-format APIs and SemVer: `goccia:csv`, `goccia:json5`, `goccia:jsonl`, `goccia:toml`, `goccia:tsv`, `goccia:yaml`, and `goccia:semver`. It additionally registers the `goccia:test` module namespace without injecting any testing global, so the testing API is importable from every host that applies the profile. CLI hosts such as `GocciaScriptLoader` and `GocciaREPL` call `ApplyLoaderRuntimeProfile`; `GocciaTestRunner` applies the loader runtime profile with the module-only testing install suppressed and installs `TGocciaTestingLibraryRuntimeExtension` with global injection enabled instead, which is why it is the only binary with global `describe`/`test`/`expect`; `GocciaBenchmarkRunner` applies the loader runtime profile plus `TGocciaBenchmarkRuntimeExtension`. See [Test Framework API](testing-api.md#availability-per-binary) for the per-binary table. `GocciaScriptLoaderBare` does not attach a runtime and exposes only a CLI-local `print(...args)` helper by default; the test262 conformance runner may opt into private test262 host capabilities with `--test262-host`. +Timers are runner-only. `GocciaTestRunner` installs `TGocciaTimersRuntimeExtension`, which registers the `setTimeout`, `clearTimeout`, `setInterval` and `clearInterval` globals plus the `goccia:timers` control module over a deterministic virtual timer queue — no timer ever waits on wall time. The loader runtime profile does not install it: the timers carry no ambient authority, but a scheduling surface is one a sandboxed script does not otherwise get. See [Fake timers](testing-api.md#fake-timers) and [ADR 0113](adr/0113-deterministic-virtual-timer-queue.md). + `GocciaSandboxRunner` applies the loader runtime profile and then installs `TGocciaSandboxRuntimeExtension`. That extension registers sandbox capabilities as import-only runtime modules named `"fs"` and `"goccia"`; it does not create global `fs`, `$`, or `runScript` bindings. FFI is not part of the loader runtime profile. CLI tools install `TGocciaFFIRuntimeExtension` when `--unsafe-ffi` is passed or `"unsafe-ffi": true` is set in config. diff --git a/docs/differential-testing.md b/docs/differential-testing.md index d90a4144d..b332e3002 100644 --- a/docs/differential-testing.md +++ b/docs/differential-testing.md @@ -69,6 +69,7 @@ oracle instead of inheriting a default. | `o-asynccontext.test.js` | language | skip | gate | | `p-callintrinsics.test.js` | language | skip | gate | | `q-reflectconstruct.test.js` | language | skip | gate | +| `r-faketimers.test.js` | timers | gate | skip | `o-asynccontext.test.js` covers `node:async_hooks` propagation only, and stops there on purpose. Bun 1.3.14 does not honour the `defaultValue` or `name` @@ -126,6 +127,19 @@ script, but under `bun test` 1.4.0 the same class body reports it defined, so gating would report bun's transpile as a goccia divergence. It is covered against node in `tests/built-ins/Reflect/construct/instance-elements.js`. +`r-faketimers.test.js` covers the `vi` fake-timer family, and Vitest is the only +possible oracle for it: Vitest's fake timers wrap `@sinonjs/fake-timers`, so what +a tick does — whether microtasks interleave, how a nested zero-delay timer is +scheduled, when `runOnlyPendingTimers` stops, what the loop guard says — is +decided by that clock rather than by ECMAScript. Bun is skipped for the reason +`e-mocks.test.js` records. Two shapes are deliberately absent. The **type of a +timer id** is a documented divergence: Vitest runs in Node, whose fake clock +returns a `Timeout` object, while GocciaScript returns a number as the web +platform does, so the suite asserts on clearing rather than on the id. **Real-mode +timers** are absent because there is nothing to compare — under Vitest they run +on a real event loop and under GocciaScript the clock jumps to them; they are +covered against the intended behaviour in `tests/built-ins/Timers` instead. + `h-modulemock.test.js` and `i-modulemock-isolation.test.js` are a pair: the first mocks `./mods/mockable.js` with a `vi.mock` factory, the second mocks nothing and must still see the real module. Under Vitest both files run in one diff --git a/docs/host-environment.md b/docs/host-environment.md index a6b43370a..e687d09ee 100644 --- a/docs/host-environment.md +++ b/docs/host-environment.md @@ -8,6 +8,7 @@ - Pascal embedders inject implementations of `IGocciaHostClock` and `IGocciaHostRandom` before attaching runtime extensions or executing source. - `GocciaScriptLoader --host-environment=` accepts the same providers as callable named JavaScript exports. - Child engines share the clock and receive derived random stream identifiers, so realms remain reproducible without replaying the parent stream. +- A **clock override** layers a mocked epoch and/or monotonic time over the configured providers, which is how fake timers reach `Date`, `Temporal.Now`, and `performance` at once. - Timeouts, profiling, benchmarks, and other infrastructure continue to use the real clocks in `TimingUtils`. ## JavaScript Provider Modules @@ -70,6 +71,14 @@ Engine.Execute; For a fixed built-in profile, call `Engine.HostEnvironment.UseDeterministicProfile` instead of implementing providers. It supplies epoch and monotonic time `0`, `UTC`, and portable seeded SplitMix64 randomness. +## Mocked Clocks + +A host environment can carry a **clock override**: a layer over the configured providers rather than a replacement for them. `OverrideClock` sets a mocked epoch time, a mocked monotonic time, or both; `ClearClockOverride` removes it; `RealEpochNanoseconds` still reads the provider underneath. This is the layer the [virtual timer queue](adr/0113-deterministic-virtual-timer-queue.md) installs a fake clock on, so `Date`, `Temporal.Now`, and `performance` all report the same simulated instant without any of them being patched. + +The two halves are independent. Freezing the date alone — `vi.setSystemTime()` outside `vi.useFakeTimers()` — leaves monotonic time real, so `performance.now()` keeps measuring elapsed wall time. Under fake timers both are mocked, and `performance.now()` reports elapsed **virtual** time from the moment the clock was installed, which a `setSystemTime` jump does not move. + +An override is not inherited by `ConfigureAsChildOf`, so a ShadowRealm child reads the real clock until something mocks one of its own. + ## Default Compatibility Without an explicit provider, the system host environment preserves each API's established default behavior. Temporal and Date observe the system time zone; `Intl.DateTimeFormat` retains GocciaScript's historical `UTC` default. An explicitly configured host time zone overrides both surfaces. diff --git a/docs/interpreter.md b/docs/interpreter.md index 00653d201..da18b79f0 100644 --- a/docs/interpreter.md +++ b/docs/interpreter.md @@ -159,7 +159,9 @@ In the ECMAScript specification, the entire script is one macrotask. Microtasks 2. All `.then()` callbacks fire in FIFO order. 3. New microtasks enqueued during draining (e.g., chained `.then()` handlers) are processed in the same drain cycle. -This follows the ECMAScript specification's microtask ordering semantics. Thenable adoption (resolving a Promise with another Promise) is deferred by one microtask tick, matching the spec's PromiseResolveThenableJob. When `Resolve(innerPromise)` is called, instead of synchronously calling `SubscribeTo`, a `prtThenableResolve` microtask is enqueued. When this microtask drains, it calls `SubscribeTo` to adopt the inner Promise's state — resulting in a 2-tick deferral (one for the thenable resolve job, one for the settlement reaction). This ensures correct ordering relative to other microtasks. The only scenario where timing would differ from a full engine is with multiple macrotask sources (`setTimeout`, I/O callbacks, event handlers), which GocciaScript does not implement. If these are added in the future, they would require an event loop that repeatedly: (1) dequeues one macrotask, (2) drains the microtask queue, (3) repeats. +This follows the ECMAScript specification's microtask ordering semantics. Thenable adoption (resolving a Promise with another Promise) is deferred by one microtask tick, matching the spec's PromiseResolveThenableJob. When `Resolve(innerPromise)` is called, instead of synchronously calling `SubscribeTo`, a `prtThenableResolve` microtask is enqueued. When this microtask drains, it calls `SubscribeTo` to adopt the inner Promise's state — resulting in a 2-tick deferral (one for the thenable resolve job, one for the settlement reaction). This ensures correct ordering relative to other microtasks. + +There is one macrotask source, and it is deliberately not an event loop: `Goccia.Timers.pas` holds a [virtual timer queue](adr/0113-deterministic-virtual-timer-queue.md) behind `setTimeout` and `setInterval`, installed in the test-runner profile. Nothing there waits on wall time. A timer runs only when a test advances the virtual clock (`vi.advanceTimersByTime` and friends) or, without fake timers, when the engine would otherwise have nothing left to do — an `await` on a promise a timer will settle, or the end-of-run idle drain. Each such step runs one timer and then drains this microtask queue, which is the macrotask-then-microtask ordering an event loop provides, minus the loop and minus real elapsed time. Other macrotask sources — I/O callbacks, event handlers — remain unimplemented. **Integration points:** diff --git a/docs/testing-api.md b/docs/testing-api.md index 6e4fb4009..1ac51592f 100644 --- a/docs/testing-api.md +++ b/docs/testing-api.md @@ -63,6 +63,8 @@ The module namespace and the globals install independently. A host that applies | `GocciaBenchmarkRunner` | Yes | No | | `GocciaScriptLoaderBare` | No — attaches no runtime | No | +The [timer surface](#fake-timers) — `goccia:timers` and the `setTimeout` family — is narrower still: `GocciaTestRunner` only. The timers are deterministic and carry no ambient authority, but a scheduling surface is one a sandboxed script does not otherwise get, and the acceptance target for them is the runner. An embedder that wants them installs `TGocciaTimersRuntimeExtension`. + An embedder gets the same split: `ApplyLoaderRuntimeProfile` with its default `ATestingModule = True` registers the module only, and `TGocciaTestingLibraryRuntimeExtension.CreateModuleOnly` is the direct spelling for hosts that assemble their own profile. Passing `AInjectGlobals = True` to that extension's ordinary constructor is what makes the globals appear, and the runner is the only host that does it. Outside the runner the assertions object is built lazily, on the first import that resolves. A script that never imports `goccia:test` pays nothing for its availability. @@ -418,7 +420,9 @@ test("async error handling", () => { Both patterns work because GocciaScript's `await` is a synchronous drain --- the entire async function body executes within a single `.Call()`, and fetch-backed Promises are settled by pumping fetch completions while waiting. Place assertions inside `.then()` or `.catch()` handlers when using the Promise-return pattern. -**Important:** If a test returns a Promise that is still pending after the microtask queue drains and all pending fetch completions have been pumped, the test **fails** with "Promise still pending after microtask drain". Since GocciaScript has no general event loop, a non-fetch pending Promise after drain will never settle --- this catches tests with missing assertions or broken async chains. This mirrors how Jest/Vitest fail tests with a timeout when the returned Promise never resolves. +A Promise a timer will settle also works, and settles instantly: the [virtual timer queue](#fake-timers) is run to the next due timer wherever the engine would otherwise be waiting, so `await new Promise((r) => setTimeout(r, 5000))` returns without 5000 milliseconds passing. Under `vi.useFakeTimers()` it does not — the suite owns the clock there, and the test advances it explicitly. + +**Important:** If a test returns a Promise that is still pending after the microtask queue drains, all pending fetch completions have been pumped, and every real-mode timer has run, the test **fails** with "Promise still pending after microtask drain". Since GocciaScript has no event loop beyond those sources, such a Promise will never settle --- this catches tests with missing assertions or broken async chains. This mirrors how Jest/Vitest fail tests with a timeout when the returned Promise never resolves. When a returned Promise rejects, the failure line reports the reason as `Returned Promise rejected: `. An `Error` is named and described --- `Error: boom`, or the class name for a subclass that does not set its own `name`, such as `MyError: boom` --- because its `name` lives on the prototype and its `message` is non-enumerable, so serializing the object alone would render it as `{}`. Any other reason is serialized as a value. @@ -612,8 +616,8 @@ Further divergences from Vitest worth knowing: | `vi.stubGlobal`, `vi.unstubAllGlobals` | Supported | | `vi.stubEnv`, `vi.unstubAllEnvs` | Supported, over an injected `process.env` | | `vi.clearAllMocks`, `vi.resetAllMocks`, `vi.restoreAllMocks` | Supported | -| `vi.useFakeTimers` and the rest of the timer family | Throws | -| `vi.waitFor`, `vi.waitUntil` | Throws — async polling, not the timer gap above | +| The fake-timer family | Supported — see [Fake timers](#fake-timers) | +| `vi.waitFor`, `vi.waitUntil` | Throws — async polling, which needs a suspension point | | `vi.hoisted` | Throws | | `vi.doMock`, `vi.doUnmock`, `vi.resetModules` | Throws | | `vi.importActual`, `vi.importMock` | Throws | @@ -621,6 +625,78 @@ Further divergences from Vitest worth knowing: `vi.stubGlobal` records the value a name held before its **first** stub, so restubbing the same name repeatedly still unwinds to the original, and `vi.unstubAllGlobals` deletes a name that did not exist rather than leaving it behind as an undefined global. `vi.stubEnv` and `vi.unstubAllEnvs` behave the same way over `process.env`, and match Vitest's remaining details: the value is coerced with `String()`, and an `undefined` value deletes the variable instead of setting it. +#### Fake timers + +`GocciaTestRunner` provides `setTimeout`, `clearTimeout`, `setInterval` and `clearInterval` over a **virtual timer queue**, and the whole Vitest fake-timer family on top of it: + +| Member | Notes | +|---|---| +| `vi.useFakeTimers([config])` | Only `config.now` is read; see below | +| `vi.useRealTimers()`, `vi.isFakeTimers()` | | +| `vi.setSystemTime(dateOrMs)`, `vi.getMockedSystemTime()`, `vi.getRealSystemTime()` | | +| `vi.advanceTimersByTime(ms)` / `vi.advanceTimersByTimeAsync(ms)` | | +| `vi.advanceTimersToNextTimer()` / `vi.advanceTimersToNextTimerAsync()` | | +| `vi.runAllTimers()` / `vi.runAllTimersAsync()` | | +| `vi.runOnlyPendingTimers()` / `vi.runOnlyPendingTimersAsync()` | | +| `vi.getTimerCount()`, `vi.clearAllTimers()` | | +| `vi.advanceTimersToNextFrame`, `vi.runAllTicks` | Throw — no `requestAnimationFrame`, and no `process.nextTick` queue (promise jobs run on the engine microtask queue, which the `…Async` members already drain) | +| `vi.setTimerTickMode` | `"manual"` is accepted and does nothing — it names the only behaviour there is. Every other mode throws: they advance the clock against real elapsed time, which no GocciaScript clock measures | + +Every implemented member returns `vi`, so calls chain; the three that throw are listed in the last row above. The semantics were probed against the pinned Vitest 4.1.10 — whose fake timers wrap `@sinonjs/fake-timers` — rather than read off its documentation, and are locked in by a [vitest-gated differential suite](differential-testing.md). The details worth knowing: + +- **`advanceTimersByTime` runs no microtasks between timers.** A promise callback a timer queued waits until the advance returns. The `…Async` variants drain the microtask queue before the first timer and again after each one, which is the ordering a suite awaiting between ticks depends on. +- **Timers due at the same instant fire in registration order.** Ties break on creation time, then on id. +- **A zero-delay timer scheduled from inside a running timer is due one virtual millisecond later**, not at the current instant. That is what keeps a `setTimeout(f, 0)` chain from looping forever inside one advance. +- **An interval reschedules from its previous due time**, so it does not drift, and one advance fires every tick it crossed. +- **`runAllTimers` gives up after 10000 timers** with `Aborting after running 10000 timers, assuming an infinite loop!`. +- **`runOnlyPendingTimers` ticks to the latest due time among the timers pending when it was called** — so a timer one of them schedules inside that window still fires, and one scheduled beyond it stays pending. +- **A throwing timer callback stops the run only for some members.** Under `advanceTimersByTime` and `runOnlyPendingTimers` the first exception is recorded, the remaining timers still run, the clock reaches the instant it was asked for, and the error is rethrown when the advance ends. Under `runAllTimers` and `advanceTimersToNextTimer` it stops there and everything behind it stays pending. The three genuinely differ in Vitest, and each was probed on its own. +- **`setSystemTime` moves the wall clock without letting time pass.** Every pending timer keeps its remaining delay, forwards and backwards. It takes a number, a `Date`, or **anything else `new Date(...)` accepts** — a date string included. +- **A fractional delay is truncated; a fractional advance is banked.** `setTimeout(fn, 1.5)` is due at 1ms, but `advanceTimersByTime(1.5)` followed by `advanceTimersByTime(0.5)` moves the clock by a full 2ms and fires a timer due there. Both halves are Vitest's, and the pairing is unintuitive enough to be worth stating. +- **`Date`, `new Date()`, `Temporal.Now` and `performance.now()` all follow the mocked clock**, because it is installed on the [host environment](host-environment.md) rather than patched onto a global. `performance.now()` reports elapsed *virtual* time from the moment fake timers were installed, and a `setSystemTime` jump does not move it. +- **`vi.setSystemTime` works without `vi.useFakeTimers`**, freezing `Date` only and leaving timers and monotonic time alone. +- **Fake-timer state is not reset between tests.** Vitest leaves the clock installed for the rest of the file, and so does GocciaScript; each test file gets a fresh engine, so nothing leaks across files. +- **`AbortSignal.timeout()` is not faked.** It runs on the infrastructure monotonic clock, so advancing the virtual clock will not fire it. That is parity, not a gap: Vitest does not fake it either, because it is not one of the globals its clock replaces. A suite that needs an abort under fake timers should drive an `AbortController` from a timer callback instead. + +Four shapes deliberately diverge from Vitest: + +- **A timer id is a number**, as it is on the web. Vitest runs in Node, where the fake clock hands back a `Timeout` object with `ref`/`unref`/`refresh`; GocciaScript has no Node timer object to imitate and no event loop for those methods to mean anything to. `clearTimeout` takes either, which is what suites actually depend on. +- **`vi.useFakeTimers(config)` honours only `now`.** `toFake` has nothing to select from — there is one timer queue and it is always the faked one — and `shouldAdvanceTime` / `advanceTimeDelta` describe real elapsed time, which no GocciaScript clock measures. Both are ignored rather than rejected, so a suite that passes them still runs. +- **A non-finite system time is refused.** `vi.setSystemTime(NaN)`, an out-of-range date, or a string `Date` cannot parse, all throw a `TypeError`. Vitest admits them and leaves `Date.now()` reporting `NaN`; here the mocked clock reaches JavaScript as an integer nanosecond count on the [host environment](host-environment.md), so there is nothing for a `NaN` to be, and every consumer of the virtual clock quietly stops working once one is admitted. +- **An advance that can never finish is aborted.** A `setInterval` with a period of `0` re-arms at the instant it just ran, so the clock can never move past it; Vitest hangs forever on that shape and GocciaScript throws instead. Short of that the behaviour matches: every tick lands on the same instant, and the advance still finishes where it was asked to. + +##### Without fake timers + +The queue is still virtual when `vi.useFakeTimers()` was never called: no wall time passes, and the clock jumps to the next timer's due time whenever the engine would otherwise have nothing left to do. In practice that means an `await` on a promise a timer will settle: + +```javascript +test("a timer settles the awaited promise", async () => { + const value = await new Promise((resolve) => setTimeout(() => resolve(42), 5000)); + expect(value).toBe(42); // instantly — only the virtual clock moved +}); +``` + +A delay is therefore an ordering key, not a duration. A timer-driven suite runs at full speed and reproducibly, and an uncleared `setInterval` cannot hang the run: the drains skip intervals entirely, and every one of them is bounded. + +Four more rules make real mode predictable: + +- **Timers a test body schedules run at the end of that test**, not at some later idle point. A `setTimeout` written inside a `test()` fires once the body has returned, before the next test starts. +- **Whatever is left over is dropped when the test ends.** An uncleared interval, or a chain longer than the drain reached, cannot fire inside the next test. Fake-timer state is untouched by this — that queue belongs to the suite. +- **A timer callback that throws fails the test that scheduled it**, and nothing else: it is reported as `uncaught exception in a timer callback`. It is not delivered to whatever frame happened to be awaiting, so a `try`/`catch` around an unrelated `await` will not see it and that `await` still resolves normally. This is Node's shape — an uncaught top-level error — rather than an exception at the wait. +- **Real outstanding work outranks virtual time.** While a `fetch` or an `Atomics.waitAsync` is still in flight, no timer runs. Without that rule `Promise.race([fetch(url), timeoutAfter(ms)])` resolved to the timeout every time, whatever `ms` was, because the virtual clock costs nothing to advance. + +`performance.now()` is worth one note of its own: while timers are faked it measures elapsed virtual time from the install, so it is not on the same timeline as `performance.timeOrigin` (which keeps reporting the real process origin). Leaving fake timers puts it back on the real timeline. + +##### `goccia:timers` + +The engine surface underneath is importable on its own, for a suite that does not use the Vitest shim: + +```javascript +import { useFakeTimers, advanceTimersByTime, useRealTimers } from "goccia:timers"; +``` + +It exports the same operations plus the four timer globals, but speaks in epoch milliseconds rather than `Date` objects and returns `undefined` rather than chaining. Wrapping that in Vitest's shapes is exactly what the `vi` members do. + #### `process.env` GocciaScript has no `process`. `vi.stubEnv` writes to whatever one the host injected, so a suite that needs it supplies it — the same `--global` and `--globals` options the loader has, now on `GocciaTestRunner` too: @@ -644,9 +720,7 @@ With no `process` at all, `vi.stubEnv` throws and names the two options rather t #### Why the other members throw -The fake-timer family throws because there is no fake clock — timers run on the real event loop. - -`vi.waitFor` and `vi.waitUntil` throw for a different reason. They are async polling APIs, not timer APIs: each retries its callback on an interval until it passes or a timeout elapses, which needs execution to suspend and resume between attempts. GocciaScript's runner has no such primitive — `await` is a synchronous drain and there is no general event loop, as [Async Tests](#async-tests-promises) describes, so a poll loop would spin without anything ever being able to change the condition. Both members report that reason rather than the fake-timer one: a fake clock is not what is missing. +`vi.waitFor` and `vi.waitUntil` throw even though fake timers now exist, because a fake clock is not what they need. They are async polling APIs: each retries its callback on an interval until it passes or a timeout elapses, which needs execution to suspend and resume between attempts. GocciaScript's runner has no such primitive — `await` is a synchronous drain, as [Async Tests](#async-tests-promises) describes, and the virtual timer queue only moves when a test moves it — so a poll loop would spin without anything ever being able to change the condition. Both members report that reason by name. `vi.resetModules` throws because the loader has no cache-eviction path. @@ -686,6 +760,8 @@ expect(set).toEqual(new Set([2, 1])); | Missing export on a mock | Reported eagerly at link time | Reported lazily, at property access | | `process` | Not provided; inject one with `--global` / `--globals` when a suite needs it | The real process environment and the rest of the Node `process` API | | `import.meta.env` | Not available; `vi.stubEnv` writes to `process.env` | Vite populates it, and `vi.stubEnv` writes there | +| Timer ids | Numbers, as on the web | Node `Timeout` objects with `ref`/`unref`/`refresh` | +| Timers without `vi.useFakeTimers()` | Virtual: the clock jumps to the next due timer when the engine would otherwise wait, so a delay is an ordering key rather than a duration | Real elapsed time on the event loop | | 12-hour `Intl` time separator | U+202F (narrow no-break space) before AM/PM | U+0020 in Node 24 (ICU 77.1) and bun 1.3 | One of those rows is worth expanding, because it cost a debugging session before it was written down: diff --git a/scripts/differential/r-faketimers.test.js b/scripts/differential/r-faketimers.test.js new file mode 100644 index 000000000..66606b73a --- /dev/null +++ b/scripts/differential/r-faketimers.test.js @@ -0,0 +1,702 @@ +// Differential suite: the `vi` fake-timer family. +// +// Vitest gates. Fake timers are testing-API semantics — the product target is +// Vitest-exact behaviour, and Vitest's own timers wrap @sinonjs/fake-timers, +// so only vitest can decide what a tick does. Bun is skipped for the reason +// e-mocks records: the suite imports `vi` from a bare `vitest` specifier, and +// importing the real `vitest` package from a `bun test` file drops bun's +// injected globals and dies on `describe is not defined`. +// +// Deliberately not asserted here: the type of a timer id. Vitest runs in Node, +// where the fake clock hands back a Node-shaped `Timeout` object; GocciaScript +// hands back a number, as the web platform does. Both are cleared by passing +// them to clearTimeout, which is the part a suite depends on. +// +// --------------------------------------------------------------------------- +// Reading a failure after a Vitest bump +// --------------------------------------------------------------------------- +// Most of what is below is ordinary behaviour that any fake-timer +// implementation would have to keep. A dozen assertions are not: they pin a +// number, a string, or a workaround that belongs to @sinonjs/fake-timers and +// to the way Vitest drives it, so a bump can move them without anything being +// wrong with this engine. Each one carries a `PINNED:` note naming what it +// depends on. If one of those fails after a bump, re-probe the pinned Vitest +// and move the expectation; if anything WITHOUT such a note fails, the engine +// regressed. + +import { vi } from "vitest"; + +describe("advancing", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + test("timers fire in due-time order, then in registration order", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const log = []; + setTimeout(() => log.push("late"), 20); + setTimeout(() => log.push("early-a"), 10); + setTimeout(() => log.push("early-b"), 10); + vi.advanceTimersByTime(20); + + expect(log).toEqual(["early-a", "early-b", "late"]); + }); + + test("a synchronous advance runs no microtasks between timers", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const log = []; + setTimeout(() => { + log.push("first"); + Promise.resolve().then(() => log.push("microtask")); + }, 10); + setTimeout(() => log.push("second"), 20); + Promise.resolve().then(() => log.push("outer-microtask")); + vi.advanceTimersByTime(25); + + expect(log).toEqual(["first", "second"]); + }); + + test("an asynchronous advance drains microtasks around every timer", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const log = []; + setTimeout(() => { + log.push("first"); + Promise.resolve().then(() => log.push("after-first")); + }, 10); + setTimeout(() => { + log.push("second"); + Promise.resolve().then(() => log.push("after-second")); + }, 20); + Promise.resolve().then(() => log.push("pending")); + await vi.advanceTimersByTimeAsync(25); + + expect(log).toEqual([ + "pending", + "first", + "after-first", + "second", + "after-second", + ]); + }); + + test("a zero, missing or negative delay is due immediately", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const log = []; + setTimeout(() => log.push("zero"), 0); + setTimeout(() => log.push("missing")); + setTimeout(() => log.push("negative"), -5); + setTimeout(() => log.push("one"), 1); + vi.advanceTimersByTime(0); + + expect(log).toEqual(["zero", "missing", "negative"]); + + vi.advanceTimersByTime(1); + expect(log).toEqual(["zero", "missing", "negative", "one"]); + }); + + // PINNED: the `delay || (duringTick ? 1 : 0)` rule in the fake clock's + // addTimer. Nothing requires a nested zero-delay timer to land one + // millisecond later rather than on the current instant — it is how sinon + // stops a zero-delay chain from looping inside one tick. + test("a zero-delay timer scheduled inside a callback lands on the next millisecond", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const log = []; + setTimeout(() => { + log.push("outer@" + Date.now()); + setTimeout(() => log.push("inner@" + Date.now()), 0); + }, 5); + + vi.advanceTimersByTime(5); + expect(log).toEqual(["outer@5"]); + + vi.advanceTimersByTime(1); + expect(log).toEqual(["outer@5", "inner@6"]); + }); + + test("a timer scheduled inside a callback fires later in the same advance", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const log = []; + setTimeout(() => { + log.push("outer@" + Date.now()); + setTimeout(() => log.push("inner@" + Date.now()), 5); + }, 10); + vi.advanceTimersByTime(20); + + expect(log).toEqual(["outer@10", "inner@15"]); + }); + + test("extra arguments reach the callback", () => { + vi.useFakeTimers(); + const seen = []; + setTimeout((first, second) => seen.push([first, second]), 1, "x", 2); + vi.advanceTimersByTime(1); + + expect(seen).toEqual([["x", 2]]); + }); + + // PINNED: Vitest's own workaround for sinonjs/fake-timers#250 — it follows + // clock.next() with a zero-length tick so the whole instant fires. If + // upstream fixes the issue and Vitest drops the workaround, only the second + // and third timers move. + test("advanceTimersToNextTimer fires every timer due at that instant", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const log = []; + setTimeout(() => log.push("a@" + Date.now()), 10); + setTimeout(() => log.push("b@" + Date.now()), 20); + setTimeout(() => log.push("c@" + Date.now()), 20); + + vi.advanceTimersToNextTimer(); + expect(log).toEqual(["a@10"]); + expect(Date.now()).toBe(10); + + vi.advanceTimersToNextTimer(); + expect(log).toEqual(["a@10", "b@20", "c@20"]); + expect(Date.now()).toBe(20); + }); + + // PINNED: the exact string thrown by the fake clock's doTick. + test("a negative advance is refused", () => { + vi.useFakeTimers(); + + expect(() => vi.advanceTimersByTime(-1)).toThrow( + "Negative ticks are not supported", + ); + }); + + test("a throwing callback does not stop the timers behind it", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const log = []; + setTimeout(() => { + log.push("throwing"); + throw new Error("boom"); + }, 5); + setTimeout(() => log.push("later"), 10); + + expect(() => vi.advanceTimersByTime(20)).toThrow("boom"); + expect(log).toEqual(["throwing", "later"]); + expect(Date.now()).toBe(20); + }); + + // PINNED: which advance members catch and which do not. The fake clock's + // tick records the first exception and carries on; its `next` has no handler, + // so runAllTimers and advanceTimersToNextTimer stop. That asymmetry is an + // implementation detail of sinon, not a rule about timers. + test("stepping to a single timer stops at a throwing callback", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const log = []; + setTimeout(() => { + log.push("throwing"); + throw new Error("boom"); + }, 5); + setTimeout(() => log.push("later"), 5); + + expect(() => vi.advanceTimersToNextTimer()).toThrow("boom"); + expect(log).toEqual(["throwing"]); + expect(vi.getTimerCount()).toBe(1); + }); +}); + +describe("cancelling", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + test("clearTimeout cancels from outside and from inside a callback", () => { + vi.useFakeTimers(); + const log = []; + const cancelled = setTimeout(() => log.push("cancelled"), 10); + setTimeout(() => { + log.push("canceller"); + clearTimeout(cancelled); + }, 5); + vi.advanceTimersByTime(20); + + expect(log).toEqual(["canceller"]); + }); + + test("clearing an absent or falsy id is a no-op", () => { + vi.useFakeTimers(); + + expect(() => clearTimeout(undefined)).not.toThrow(); + expect(() => clearTimeout(0)).not.toThrow(); + expect(() => clearInterval(undefined)).not.toThrow(); + }); + + // PINNED: the rewind half. clearAllTimers maps onto the clock's `reset`, + // which also restores `now` to the install instant — a suite would not + // predict that from the member's name. + test("clearAllTimers drops everything pending and rewinds the clock", () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + setTimeout(() => {}, 5); + setInterval(() => {}, 5); + vi.advanceTimersByTime(2); + expect(vi.getTimerCount()).toBe(2); + + vi.clearAllTimers(); + expect(vi.getTimerCount()).toBe(0); + expect(Date.now() > 1600000000000).toBe(true); + }); + + test("getTimerCount reports what is still pending", () => { + vi.useFakeTimers(); + const id = setTimeout(() => {}, 5); + expect(vi.getTimerCount()).toBe(1); + + clearTimeout(id); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe("intervals", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + test("an interval reschedules from its previous due time", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const log = []; + const id = setInterval(() => log.push(Date.now()), 10); + vi.advanceTimersByTime(35); + clearInterval(id); + + expect(log).toEqual([10, 20, 30]); + }); + + test("a single advance fires every tick the interval crossed", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const log = []; + const id = setInterval(() => log.push(Date.now()), 10); + vi.advanceTimersByTime(50); + clearInterval(id); + + expect(log).toEqual([10, 20, 30, 40, 50]); + }); + + test("an interval that clears itself stops and leaves nothing pending", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + let runs = 0; + const id = setInterval(() => { + runs += 1; + if (runs === 3) clearInterval(id); + }, 10); + vi.advanceTimersByTime(100); + + expect(runs).toBe(3); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe("running", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + test("runAllTimers drains a chain of timers", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const log = []; + setTimeout(() => { + log.push("a"); + setTimeout(() => log.push("b"), 100); + }, 10); + vi.runAllTimers(); + + expect(log).toEqual(["a", "b"]); + expect(vi.getTimerCount()).toBe(0); + }); + + // PINNED: both the number and the sentence. 10000 is Vitest's configured + // loopLimit, not a property of timers, and the message is @sinonjs's. + test("runAllTimers gives up on a self-rescheduling timer", () => { + vi.useFakeTimers(); + let runs = 0; + const reschedule = () => { + runs += 1; + setTimeout(reschedule, 1); + }; + setTimeout(reschedule, 1); + + expect(() => vi.runAllTimers()).toThrow( + "Aborting after running 10000 timers, assuming an infinite loop!", + ); + expect(runs).toBe(10000); + }); + + // PINNED: same asymmetry as above, from the other side. + test("runAllTimers stops at a throwing callback", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const log = []; + setTimeout(() => { + log.push("throwing"); + throw new Error("boom"); + }, 5); + setTimeout(() => log.push("later"), 10); + + expect(() => vi.runAllTimers()).toThrow("boom"); + expect(log).toEqual(["throwing"]); + expect(vi.getTimerCount()).toBe(1); + expect(Date.now()).toBe(5); + }); + + // PINNED: same asymmetry again — runToLast goes through tick, so it catches. + test("runOnlyPendingTimers keeps going past a throwing callback", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const log = []; + setTimeout(() => { + log.push("throwing"); + throw new Error("boom"); + }, 5); + setTimeout(() => log.push("later"), 10); + + expect(() => vi.runOnlyPendingTimers()).toThrow("boom"); + expect(log).toEqual(["throwing", "later"]); + expect(Date.now()).toBe(10); + }); + + // PINNED: runToLast advances to the LATEST due time among the timers pending + // at the call, so what counts as "only pending" includes anything that + // becomes due inside that window. + test("runOnlyPendingTimers stops at the timers that were pending", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const log = []; + setTimeout(() => { + log.push("a"); + setTimeout(() => log.push("far"), 100); + }, 10); + setTimeout(() => log.push("b"), 20); + vi.runOnlyPendingTimers(); + + expect(log).toEqual(["a", "b"]); + expect(Date.now()).toBe(20); + expect(vi.getTimerCount()).toBe(1); + }); + + test("runOnlyPendingTimers still fires what a pending timer scheduled inside the window", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const log = []; + setTimeout(() => { + log.push("a"); + setTimeout(() => log.push("nested"), 1); + }, 10); + setTimeout(() => log.push("b"), 20); + vi.runOnlyPendingTimers(); + + expect(log).toEqual(["a", "nested", "b"]); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe("the mocked system clock", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + test("setSystemTime moves Date and new Date together", () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + + expect(Date.now()).toBe(1000); + expect(new Date().getTime()).toBe(1000); + expect(vi.getMockedSystemTime() instanceof Date).toBe(true); + expect(vi.getMockedSystemTime().getTime()).toBe(1000); + }); + + test("setSystemTime takes a Date as well as a number", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2020-01-01T00:00:00.000Z")); + + expect(Date.now()).toBe(1577836800000); + + vi.advanceTimersByTime(500); + expect(Date.now()).toBe(1577836800500); + }); + + test("setSystemTime keeps a pending timer's remaining delay", () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + const log = []; + setTimeout(() => log.push(Date.now()), 10); + vi.setSystemTime(5000); + vi.advanceTimersByTime(10); + + expect(log).toEqual([5010]); + }); + + test("moving the clock backwards keeps the timer pending", () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + const log = []; + setTimeout(() => log.push(Date.now()), 10); + vi.setSystemTime(0); + + expect(vi.getTimerCount()).toBe(1); + + vi.advanceTimersByTime(10); + expect(log).toEqual([10]); + }); + + test("performance.now measures elapsed virtual time, not the simulated date", () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + const before = performance.now(); + vi.setSystemTime(9999999); + + expect(performance.now()).toBe(before); + + vi.advanceTimersByTime(250); + expect(performance.now() - before).toBe(250); + }); + + test("getRealSystemTime reads the real clock even while one is mocked", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + + expect(Date.now()).toBe(0); + expect(vi.getRealSystemTime() > 1600000000000).toBe(true); + }); + + test("isFakeTimers and getMockedSystemTime follow the mode", () => { + expect(vi.isFakeTimers()).toBe(false); + expect(vi.getMockedSystemTime()).toBe(null); + + vi.useFakeTimers(); + expect(vi.isFakeTimers()).toBe(true); + expect(vi.getMockedSystemTime() instanceof Date).toBe(true); + + vi.useRealTimers(); + expect(vi.isFakeTimers()).toBe(false); + expect(vi.getMockedSystemTime()).toBe(null); + }); + + // PINNED: that a second useFakeTimers() discards pending timers rather than + // carrying them over. + test("re-enabling fake timers installs a fresh clock at the current instant", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + setTimeout(() => {}, 5); + expect(vi.getTimerCount()).toBe(1); + + vi.useFakeTimers(); + expect(vi.getTimerCount()).toBe(0); + expect(Date.now()).toBe(0); + }); +}); + +describe("fractional delays and advances", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + // PINNED: the fake clock computes a due time with parseInt, which drops the + // fraction of a DELAY, while the fraction of an ADVANCE is banked in a + // nanosecond remainder and carried. The pairing is unintuitive enough that + // the opposite is the natural guess, so both halves are pinned together. + test("a fractional delay is truncated", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const log = []; + setTimeout(() => log.push(Date.now()), 1.5); + + vi.advanceTimersByTime(1); + expect(log).toEqual([1]); + }); + + test("a delay below one millisecond is due immediately", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const log = []; + setTimeout(() => log.push(Date.now()), 0.4); + + vi.advanceTimersByTime(0); + expect(log).toEqual([0]); + }); + + test("fractional advances accumulate", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const log = []; + setTimeout(() => log.push(Date.now()), 2); + + vi.advanceTimersByTime(1.5); + expect(log).toEqual([]); + expect(Date.now()).toBe(1); + + vi.advanceTimersByTime(0.5); + expect(log).toEqual([2]); + expect(Date.now()).toBe(2); + }); +}); + +describe("zero-period intervals", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + // PINNED: an interval reschedules by adding its period to the due time, so a + // period of zero re-arms at the instant it just ran. Every tick therefore + // lands on the same instant, and the advance still finishes where it was + // asked to. Node would clamp the period to 1ms; the fake clock does not, and + // the fake clock is the oracle here. + test("every tick lands on the same instant", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const stamps = []; + let runs = 0; + const id = setInterval(() => { + runs += 1; + stamps.push(Date.now()); + if (runs >= 5) clearInterval(id); + }, 0); + + vi.advanceTimersByTime(3); + + expect(stamps).toEqual([0, 0, 0, 0, 0]); + expect(Date.now()).toBe(3); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe("an advance keeps its own recorded exception", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + // The recorded exception is per advance operation, not per clock: an advance + // called from inside a timer callback sees nothing of the enclosing one's + // error. Not pinned — a shared slot would be a bug in any implementation, + // and it was one here. + test("a nested advance does not steal the outer one's error", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const log = []; + + setTimeout(() => { + log.push("first"); + throw new Error("outer"); + }, 5); + setTimeout(() => { + log.push("second"); + let inner = null; + try { + vi.advanceTimersByTime(0); + } catch (error) { + inner = error.message; + } + log.push("inner=" + inner); + }, 10); + + let outer = null; + try { + vi.advanceTimersByTime(20); + } catch (error) { + outer = error.message; + } + log.push("outer=" + outer); + + expect(log).toEqual(["first", "second", "inner=null", "outer=outer"]); + }); +}); + +describe("performance.now across the fake/real transition", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + // PINNED: that a mocked monotonic clock starts at zero rather than continuing + // the process timeline, and that leaving fake timers puts it back rather than + // stranding it at whatever the advance reached. + test("it starts at zero when faked and returns to the real timeline after", () => { + const realBefore = performance.now(); + + vi.useFakeTimers(); + expect(performance.now()).toBe(0); + vi.advanceTimersByTime(500); + expect(performance.now()).toBe(500); + + vi.useRealTimers(); + expect(performance.now() >= realBefore).toBe(true); + expect(typeof performance.timeOrigin).toBe("number"); + }); + + // A setSystemTime jump is a change of date, not elapsed time. + test("setSystemTime does not move it", () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + const before = performance.now(); + + vi.setSystemTime(9999999); + expect(performance.now()).toBe(before); + + vi.advanceTimersByTime(250); + expect(performance.now() - before).toBe(250); + }); +}); + +describe("setSystemTime accepts what Date accepts", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + // Anything that is not already a Date goes through the Date constructor, so a + // date string is supported API. Not pinned: this is Vitest's documented + // signature, not an implementation detail. + test("an ISO string", () => { + vi.useFakeTimers(); + vi.setSystemTime("2020-01-01T00:00:00.000Z"); + + expect(Date.now()).toBe(1577836800000); + }); + + test("a Date and a number agree", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2020-01-01T00:00:00.000Z")); + const fromDate = Date.now(); + + vi.setSystemTime(1577836800000); + expect(Date.now()).toBe(fromDate); + }); +}); + +describe("the unmocked guard", () => { + // PINNED: Vitest's own guard message, verbatim, from its FakeTimers wrapper. + test("the advance members refuse to run without fake timers", () => { + vi.useRealTimers(); + const message = + "A function to advance timers was called but the timers APIs are not mocked"; + + expect(() => vi.advanceTimersByTime(1)).toThrow(message); + expect(() => vi.advanceTimersToNextTimer()).toThrow(message); + expect(() => vi.runAllTimers()).toThrow(message); + expect(() => vi.runOnlyPendingTimers()).toThrow(message); + expect(() => vi.getTimerCount()).toThrow(message); + }); + + // PINNED: Vitest returns its utils object from these; nothing about a timer + // queue requires it. + test("every timer member chains by returning vi", () => { + expect(vi.useFakeTimers()).toBe(vi); + expect(vi.setSystemTime(0)).toBe(vi); + expect(vi.advanceTimersByTime(0)).toBe(vi); + expect(vi.advanceTimersToNextTimer()).toBe(vi); + expect(vi.runAllTimers()).toBe(vi); + expect(vi.runOnlyPendingTimers()).toBe(vi); + expect(vi.useRealTimers()).toBe(vi); + }); +}); diff --git a/scripts/test-cli-differential.ts b/scripts/test-cli-differential.ts index 09e45d80d..0371339b4 100644 --- a/scripts/test-cli-differential.ts +++ b/scripts/test-cli-differential.ts @@ -87,9 +87,9 @@ type Classification = { * Differential suite classification. `kind` names why the oracle was chosen: * - `language` — ECMAScript syntax/semantics, where bun is a sound oracle and * the testing API is incidental. - * - `matcher` / `lifecycle` / `mocks` — testing-API semantics, where the - * product target is Vitest-exact behaviour and only vitest may decide. The - * `mocks` suite is not there yet: see its entry below. + * - `matcher` / `lifecycle` / `mocks` / `timers` — testing-API semantics, where + * the product target is Vitest-exact behaviour and only vitest may decide. + * The `mocks` suite is not there yet: see its entry below. */ const CLASSIFICATION: Record = { "a-typesyntax.test.ts": { kind: "language", bun: "gate", vitest: "skip" }, @@ -196,6 +196,14 @@ const CLASSIFICATION: Record = { // and the testing API is incidental. Vitest is skipped for the same reason as // the other language suites. "q-reflectconstruct.test.js": { kind: "language", bun: "gate", vitest: "skip" }, + // Fake timers. Vitest gates: what a tick does — the ordering, the microtask + // interleaving an `Async` advance adds, the loop guard, and how setSystemTime + // treats pending timers — is testing-API semantics decided by Vitest's own + // clock (@sinonjs/fake-timers), not by ECMAScript. Bun is skipped for the + // reason e-mocks records: the suite imports `vi` from a bare `vitest` + // specifier, which drops bun's injected globals and dies on + // `describe is not defined`. + "r-faketimers.test.js": { kind: "timers", bun: "skip", vitest: "gate" }, }; type Verdict = { diff --git a/scripts/test-cli.ts b/scripts/test-cli.ts index b475f17ea..9800bcd66 100644 --- a/scripts/test-cli.ts +++ b/scripts/test-cli.ts @@ -3316,8 +3316,21 @@ console.log("Assertion failure text..."); ["vi.hoisted(() => ({}))", "vi.hoisted is not supported"], ["vi.importMock('./x.js')", "vi.importMock is not supported"], ["vi.setConfig({})", "vi.setConfig is not supported"], - ["vi.useFakeTimers()", "vi.useFakeTimers is not supported"], - ["vi.advanceTimersByTime(1)", "vi.advanceTimersByTime is not supported"], + // The fake-timer family is implemented, but three of its members are not + // and must keep saying so by name rather than becoming absent properties: + // no requestAnimationFrame, no process.nextTick, and no real elapsed time + // for an auto-advancing clock to track. `setTimerTickMode("manual")` is + // accepted — it names the only behaviour there is — so the unsupported + // case has to ask for one of the others. + [ + "vi.advanceTimersToNextFrame()", + "vi.advanceTimersToNextFrame is not supported", + ], + ["vi.runAllTicks()", "vi.runAllTicks is not supported"], + [ + "vi.setTimerTickMode('interval')", + "vi.setTimerTickMode is not supported", + ], ["vi.importActual('./x.js')", "vi.importActual is not supported"], ["vi.resetModules()", "vi.resetModules is not supported"], ["vi.doMock('./x.js')", "vi.doMock is not supported"], @@ -3980,6 +3993,101 @@ console.log("Runtime diagnostic parity..."); } } +// -- Timers: containment and uncaught attribution ------------------------------ + +// Two properties that only show up in the runner's output, so neither can be +// asserted from inside a suite. +console.log("Timers (containment and uncaught attribution)..."); +{ + const tmp = mkdtemp("goccia-timers-"); + try { + // A timer callback that throws is an uncaught error in Node, not something + // the frame that happened to be awaiting can catch. It must therefore leave + // the await alone AND still fail the test that scheduled it — reporting it + // at the await made an unrelated try/catch swallow it and left the awaited + // promise pending on top of that. + const uncaught = join(tmp, "uncaught.test.js"); + writeFileSync( + uncaught, + [ + 'test("a throwing timer does not surface at an unrelated await", async () => {', + " let caughtHere = null;", + " setTimeout(() => { throw new Error('from-the-timer'); }, 0);", + " try {", + " const value = await new Promise((resolve) => setTimeout(() => resolve('resolved'), 5));", + " console.log('AWAIT-RESULT: ' + value);", + " } catch (error) {", + " caughtHere = error.message;", + " }", + " console.log('CAUGHT-AT-AWAIT: ' + caughtHere);", + "});", + "", + ].join("\n"), + ); + const out = ( + await $`${TESTRUNNER} ${uncaught} --no-progress 2>&1`.nothrow() + ).text(); + + if (!out.includes("AWAIT-RESULT: resolved")) + throw new Error( + `a throwing timer must not disturb the awaiting frame, got: ${out}`, + ); + if (!out.includes("CAUGHT-AT-AWAIT: null")) + throw new Error( + `a throwing timer must not be catchable at the await, got: ${out}`, + ); + if (!out.includes("uncaught exception in a timer callback")) + throw new Error( + `a throwing timer must fail the test that scheduled it, got: ${out}`, + ); + if (!out.includes("from-the-timer")) + throw new Error(`the timer's own error must be named, got: ${out}`); + + // The timer surface is the runner's alone. The loader gets neither the + // globals nor the module, so a sandboxed script cannot schedule anything. + const probe = join(tmp, "probe.js"); + writeFileSync( + probe, + [ + 'console.log("setTimeout=" + typeof setTimeout);', + 'console.log("setInterval=" + typeof setInterval);', + 'console.log("clearTimeout=" + typeof clearTimeout);', + "", + ].join("\n"), + ); + const loaderOut = (await $`${LOADER} ${probe} 2>&1`.nothrow()).text(); + for (const absent of [ + "setTimeout=undefined", + "setInterval=undefined", + "clearTimeout=undefined", + ]) { + if (!loaderOut.includes(absent)) + throw new Error( + `GocciaScriptLoader must not expose the timer globals (${absent}), got: ${loaderOut}`, + ); + } + + const moduleProbe = join(tmp, "module-probe.js"); + writeFileSync( + moduleProbe, + [ + 'import * as timers from "goccia:timers";', + "console.log(typeof timers);", + "", + ].join("\n"), + ); + const moduleOut = ( + await $`${LOADER} ${moduleProbe} --source-type=module 2>&1`.nothrow() + ).text(); + if (moduleOut.includes("object")) + throw new Error( + `GocciaScriptLoader must not resolve goccia:timers, got: ${moduleOut}`, + ); + } finally { + clean(tmp); + } +} + // -- Global injection (TestRunner) --------------------------------------------- // The runner grew --global/--globals so a suite can be handed a host global it diff --git a/source/units/Goccia.AsyncContext.pas b/source/units/Goccia.AsyncContext.pas index a3dd4afb2..8c2dafe35 100644 --- a/source/units/Goccia.AsyncContext.pas +++ b/source/units/Goccia.AsyncContext.pas @@ -123,11 +123,8 @@ implementation { Publishes the current snapshot to the collector. One instance per thread, created the first time a binding takes effect on that thread. } TGocciaAsyncContextRoots = class(TGCRootSource) - private - FCollector: TGarbageCollector; public procedure MarkRootReferences; override; - property Collector: TGarbageCollector read FCollector write FCollector; end; const @@ -210,13 +207,23 @@ function CurrentAsyncContext: TGocciaAsyncContextSnapshot; end; { The root source registers with whichever collector is current when it is - built, so a thread whose collector was replaced needs a fresh one. } + built, so a thread whose collector was replaced needs a fresh one. + + The identity test asks the source which collector it is *registered with* + rather than comparing against a collector remembered alongside it. A + remembered address is not an identity: TGarbageCollector.Shutdown followed by + Initialize can put the next thread-local collector where the previous one + was, and the stale compare then reported "same collector" for a source + registered with the dead one — leaving the current snapshot unmarked for the + rest of the thread's life. The collector's destructor nils the registration on + every source it owns, so this test cannot match a destroyed one. } procedure EnsureSnapshotRoots; var Collector: TGarbageCollector; begin Collector := TGarbageCollector.Instance; - if Assigned(GSnapshotRoots) and (GSnapshotRoots.Collector = Collector) then + if Assigned(Collector) and Assigned(GSnapshotRoots) and + (GSnapshotRoots.RegisteredCollector = Collector) then Exit; FreeAndNil(GSnapshotRoots); @@ -224,7 +231,6 @@ procedure EnsureSnapshotRoots; Exit; GSnapshotRoots := TGocciaAsyncContextRoots.Create; - GSnapshotRoots.Collector := Collector; end; procedure SetCurrentAsyncContext( diff --git a/source/units/Goccia.Builtins.Performance.pas b/source/units/Goccia.Builtins.Performance.pas index da07a839c..2402df75a 100644 --- a/source/units/Goccia.Builtins.Performance.pas +++ b/source/units/Goccia.Builtins.Performance.pas @@ -133,8 +133,16 @@ function TGocciaPerformancePrototypeHost.PerformanceNow(const AArgs: TGocciaArgu ElapsedNanoseconds: Int64; begin Performance := RequirePerformanceThis(AThisValue); - ElapsedNanoseconds := Performance.FHostEnvironment.MonotonicNanoseconds - - Performance.FTimeOriginMonotonicNanoseconds; + { A mocked monotonic clock — the virtual timer queue installs one while fake + timers are on — already counts from its own origin, so subtracting this + performance object's time origin would measure from a boot that is not on + the same timeline. Under a mock, elapsed virtual time is the answer, which + is what @sinonjs/fake-timers reports too. } + if Performance.FHostEnvironment.HasMonotonicClockOverride then + ElapsedNanoseconds := Performance.FHostEnvironment.MonotonicNanoseconds + else + ElapsedNanoseconds := Performance.FHostEnvironment.MonotonicNanoseconds - + Performance.FTimeOriginMonotonicNanoseconds; Result := TGocciaNumberLiteralValue.Create(Max(0.0, Int64ToDouble(ElapsedNanoseconds) / 1000000.0)); end; diff --git a/source/units/Goccia.Builtins.TestingLibrary.pas b/source/units/Goccia.Builtins.TestingLibrary.pas index 1b37d66ac..0bda30f6a 100644 --- a/source/units/Goccia.Builtins.TestingLibrary.pas +++ b/source/units/Goccia.Builtins.TestingLibrary.pas @@ -389,6 +389,7 @@ implementation Goccia.MicrotaskQueue, Goccia.RegExp.Runtime, Goccia.Timeout, + Goccia.Timers, Goccia.Utils, Goccia.Values.AsymmetricMatcher, Goccia.Values.ClassHelper, @@ -3908,6 +3909,7 @@ procedure TGocciaTestAssertions.BuildNestedRegistrations( if (TGocciaMicrotaskQueue.Instance <> nil) then TGocciaMicrotaskQueue.Instance.ClearQueue; DiscardFetchCompletions; + DiscardRealTimers; raise; end; on E: Exception do @@ -3919,6 +3921,7 @@ procedure TGocciaTestAssertions.BuildNestedRegistrations( if (TGocciaMicrotaskQueue.Instance <> nil) then TGocciaMicrotaskQueue.Instance.ClearQueue; DiscardFetchCompletions; + DiscardRealTimers; raise; end; if not FSuppressOutput then @@ -4088,6 +4091,7 @@ procedure TGocciaTestAssertions.ExecuteSuite(const ASuite: TGocciaTestSuite; ExceptionDetail, ExceptionSummary: string; FailureRecorded: Boolean; TerminalUnwinding: Boolean; + TimerErrorValue: TGocciaValue; EffectiveSuiteName: string; HookFailed: Boolean; HookMessage: string; @@ -4267,9 +4271,49 @@ procedure TGocciaTestAssertions.ExecuteSuite(const ASuite: TGocciaTestSuite; end else DrainMicrotasksAndFetchCompletions; + + { Timers the body scheduled are work the body started, so they + run here — at the end of the test that owns them — rather + than at the engine's idle point, which under this runner is + reached while the entry module is still evaluating and every + test body is still ahead of it. Without this a + `setTimeout` written inside a `test()` never ran at all and + was discarded on the way out, silently. + + Timeouts only and bounded, so an uncleared interval cannot + hold the test open. } + DrainRealTimersForHost; + + { A throwing timer callback is an uncaught error in Node, not + something the awaiting frame catches, so the queue parks it + instead of raising it at whatever happened to be waiting. + Attributing it is the runner's job, and this is the point + where the test that scheduled it is still the current one. } + if TakeUncaughtTimerError(TimerErrorValue) then + begin + RejectionReason := DescribeRejectionReason(TimerErrorValue); + AssertionFailed('timer callback', + 'Uncaught exception in a timer callback: ' + + RejectionReason); + if FTestStats.CurrentSuiteName <> '' then + AFailedTestDetails.Add('Test "' + TestCase.Name + + '" in suite "' + FTestStats.CurrentSuiteName + + '": uncaught exception in a timer callback: ' + + RejectionReason) + else + AFailedTestDetails.Add('Test "' + TestCase.Name + + '": uncaught exception in a timer callback: ' + + RejectionReason); + FailureRecorded := True; + end; finally if Assigned(TestResult) then RemoveTempRootIfNeeded(TestResult); + { Whatever the bounded drain left — an uncleared interval, or a + chain it did not reach — belongs to this test and must not + fire inside the next one. Fake-timer state is untouched: that + queue belongs to the suite. } + DiscardRealTimers; end; except { Test-scope timeout: record TIMEOUT and let execution @@ -4283,6 +4327,7 @@ procedure TGocciaTestAssertions.ExecuteSuite(const ASuite: TGocciaTestSuite; if (TGocciaMicrotaskQueue.Instance <> nil) then TGocciaMicrotaskQueue.Instance.ClearQueue; DiscardFetchCompletions; + DiscardRealTimers; AssertionFailed('test execution', Format('Test exceeded per-test timeout of %dms', [E.DurationMs])); @@ -4311,6 +4356,7 @@ procedure TGocciaTestAssertions.ExecuteSuite(const ASuite: TGocciaTestSuite; if (TGocciaMicrotaskQueue.Instance <> nil) then TGocciaMicrotaskQueue.Instance.ClearQueue; DiscardFetchCompletions; + DiscardRealTimers; TerminalUnwinding := True; raise; end; @@ -4326,12 +4372,14 @@ procedure TGocciaTestAssertions.ExecuteSuite(const ASuite: TGocciaTestSuite; if (TGocciaMicrotaskQueue.Instance <> nil) then TGocciaMicrotaskQueue.Instance.ClearQueue; DiscardFetchCompletions; + DiscardRealTimers; TerminalUnwinding := True; raise; end; if (TGocciaMicrotaskQueue.Instance <> nil) then TGocciaMicrotaskQueue.Instance.ClearQueue; DiscardFetchCompletions; + DiscardRealTimers; if E is TGocciaError then begin ExceptionDetail := TGocciaError(E).GetDetailedMessage; @@ -4559,6 +4607,7 @@ procedure TGocciaTestAssertions.RunCallbacks(const ACallbacks: TGocciaArgumentsC if (TGocciaMicrotaskQueue.Instance <> nil) then TGocciaMicrotaskQueue.Instance.ClearQueue; DiscardFetchCompletions; + DiscardRealTimers; raise; end; on E: TGocciaThrowValue do @@ -4575,6 +4624,7 @@ procedure TGocciaTestAssertions.RunCallbacks(const ACallbacks: TGocciaArgumentsC if (TGocciaMicrotaskQueue.Instance <> nil) then TGocciaMicrotaskQueue.Instance.ClearQueue; DiscardFetchCompletions; + DiscardRealTimers; raise; end; AssertionFailed('callback execution', 'Callback threw an exception: ' + E.Message); diff --git a/source/units/Goccia.Builtins.Timers.pas b/source/units/Goccia.Builtins.Timers.pas new file mode 100644 index 000000000..1d8b1543a --- /dev/null +++ b/source/units/Goccia.Builtins.Timers.pas @@ -0,0 +1,468 @@ +{ The JavaScript surface of the virtual timer queue. + + Two halves, both backed by the one queue in Goccia.Timers: + + - the `setTimeout` / `clearTimeout` / `setInterval` / `clearInterval` + globals, and + - the `goccia:timers` module, the low-level control surface the Vitest + compatibility shim builds `vi.useFakeTimers` and the rest of the timer + family on top of. + + `goccia:timers` deliberately speaks in numbers rather than in Vitest's + shapes: `getMockedSystemTime` reports epoch milliseconds or `null`, and + `setSystemTime` takes epoch milliseconds. Wrapping those in `Date` is the + shim's job, which keeps the engine surface free of a dependency on the Date + shim and keeps `goccia:timers` usable from a suite that never imports + `vitest`. + + See docs/adr/0113-deterministic-virtual-timer-queue.md. } + +unit Goccia.Builtins.Timers; + +{$I Goccia.inc} + +interface + +uses + Goccia.Scope, + Goccia.Values.ObjectValue; + +{ Returns the `goccia:timers` namespace. AHostToken carries the extension's + host state: `var`, not `out`, so a token RegisterTimerGlobals already created + is reused. As an `out` parameter it was cleared on entry, so an engine that + installed the globals and then imported the module built a second host and + Detach released only that one — leaving the first on the thread's list for the + worker's lifetime. Both drove the same singleton queue, so nothing observed it + behaving differently; it was a bounded per-engine leak, not a split queue. } +function CreateTimersNamespace(var AHostToken: TObject): TGocciaObjectValue; + +{ Binds setTimeout, clearTimeout, setInterval and clearInterval into AScope. + AHostToken is shared with CreateTimersNamespace when both halves are + installed by one extension. } +procedure RegisterTimerGlobals(const AScope: TGocciaScope; + var AHostToken: TObject); + +procedure ReleaseTimersHost(const AHostToken: TObject); +procedure ClearTimersHosts; + +implementation + +uses + Generics.Collections, + Math, + SysUtils, + + Goccia.Arguments.Collection, + Goccia.ThreadCleanupRegistry, + Goccia.Timers, + Goccia.Values.ErrorHelper, + Goccia.Values.NativeFunction, + Goccia.Values.NativeFunctionCallback, + Goccia.Values.Primitives; + +const + NOT_FAKED_MESSAGE = + 'A function to advance timers was called but the timers APIs are not ' + + 'mocked. Call `vi.useFakeTimers()` in the test file first.'; + CALLBACK_REQUIRED_MESSAGE = + 'The "callback" argument must be of type function.'; + CALLBACK_REQUIRED_SUGGESTION = + 'Pass a function as the first argument, as in setTimeout(() => {}, 0).'; + +type + TGocciaTimersHostList = TObjectList; + + { Method targets for the native functions. One instance per extension, held + in the module-level list so a detached extension can release it. } + TGocciaTimersHost = class + private + function Queue: TGocciaTimerQueue; + function RequireFaking: TGocciaTimerQueue; + function Schedule(const AKind: TGocciaTimerKind; + const AArgs: TGocciaArgumentsCollection): TGocciaValue; + function MillisecondArgument( + const AArgs: TGocciaArgumentsCollection): Double; + public + function SetTimeoutCallback(const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; + function SetIntervalCallback(const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; + function ClearTimerCallback(const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; + + function UseFakeTimers(const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; + function UseRealTimers(const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; + function IsFakeTimers(const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; + function SetSystemTime(const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; + function GetMockedSystemTime(const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; + function GetRealSystemTime(const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; + function AdvanceTimersByTime(const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; + function AdvanceTimersByTimeAsync(const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; + function AdvanceTimersToNextTimer(const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; + function AdvanceTimersToNextTimerAsync( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; + function RunAllTimers(const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; + function RunAllTimersAsync(const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; + function RunOnlyPendingTimers(const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; + function RunOnlyPendingTimersAsync(const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; + function ClearAllTimers(const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; + function GetTimerCount(const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; + end; + +threadvar + GTimersHosts: TGocciaTimersHostList; + +function EnsureHost(var AHostToken: TObject): TGocciaTimersHost; +begin + if AHostToken is TGocciaTimersHost then + Exit(TGocciaTimersHost(AHostToken)); + + Result := TGocciaTimersHost.Create; + if not Assigned(GTimersHosts) then + GTimersHosts := TGocciaTimersHostList.Create(True); + GTimersHosts.Add(Result); + AHostToken := Result; +end; + +{ TGocciaTimersHost } + +function TGocciaTimersHost.Queue: TGocciaTimerQueue; +begin + TGocciaTimerQueue.Initialize; + Result := TGocciaTimerQueue.Instance; +end; + +function TGocciaTimersHost.RequireFaking: TGocciaTimerQueue; +begin + Result := Queue; + if not Result.Faking then + ThrowError(NOT_FAKED_MESSAGE); +end; + +{ A missing argument is zero — Vitest's own `advanceTimersByTime()` moves the + clock by nothing. A present but non-finite one is passed through so the queue + can refuse it: mapping it to zero here hid a bug in the caller behind a silent + no-op, and Vitest's alternative is to leave the clock reading NaN. } +function TGocciaTimersHost.MillisecondArgument( + const AArgs: TGocciaArgumentsCollection): Double; +begin + if AArgs.Length = 0 then + Exit(0); + Result := AArgs.GetElement(0).ToNumberLiteral.Value; +end; + +function TGocciaTimersHost.Schedule(const AKind: TGocciaTimerKind; + const AArgs: TGocciaArgumentsCollection): TGocciaValue; +var + Callback: TGocciaValue; + Delay: Double; + ExtraArgs: TArray; + I: Integer; +begin + if (AArgs.Length = 0) or not AArgs.GetElement(0).IsCallable then + ThrowTypeError(CALLBACK_REQUIRED_MESSAGE, CALLBACK_REQUIRED_SUGGESTION); + + Callback := AArgs.GetElement(0); + if AArgs.Length > 1 then + Delay := AArgs.GetElement(1).ToNumberLiteral.Value + else + Delay := 0; + + SetLength(ExtraArgs, Max(0, AArgs.Length - 2)); + for I := 2 to AArgs.Length - 1 do + ExtraArgs[I - 2] := AArgs.GetElement(I); + + Result := TGocciaNumberLiteralValue.Create( + Queue.AddTimer(AKind, Callback, ExtraArgs, Delay)); +end; + +function TGocciaTimersHost.SetTimeoutCallback( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +begin + Result := Schedule(gtkTimeout, AArgs); +end; + +function TGocciaTimersHost.SetIntervalCallback( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +begin + Result := Schedule(gtkInterval, AArgs); +end; + +{ One implementation behind both clearTimeout and clearInterval. The fake clock + lets either name clear either kind, and there is nothing a stricter rule + would protect: the id space is shared. } +function TGocciaTimersHost.ClearTimerCallback( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +begin + Result := TGocciaUndefinedLiteralValue.UndefinedValue; + if AArgs.Length = 0 then + Exit; + Queue.ClearTimer(AArgs.GetElement(0).ToNumberLiteral.Value); +end; + +function TGocciaTimersHost.UseFakeTimers( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +var + Now: Double; + TimerQueue: TGocciaTimerQueue; +begin + TimerQueue := Queue; + { Vitest starts the fake clock at the date already in effect: the real time, + or the frozen one a prior setSystemTime installed. } + if (AArgs.Length > 0) and + not (AArgs.GetElement(0) is TGocciaUndefinedLiteralValue) then + Now := AArgs.GetElement(0).ToNumberLiteral.Value + else if TimerQueue.Faking then + Now := TimerQueue.NowMilliseconds + else if TimerQueue.MockedDateOnly then + Now := TimerQueue.MockedDate + else + Now := TimerQueue.RealEpochMilliseconds; + if IsNan(Now) then + Now := TimerQueue.RealEpochMilliseconds; + + TimerQueue.BeginFakeTimers(Now); + Result := TGocciaUndefinedLiteralValue.UndefinedValue; +end; + +function TGocciaTimersHost.UseRealTimers( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +begin + Queue.EndFakeTimers; + Result := TGocciaUndefinedLiteralValue.UndefinedValue; +end; + +function TGocciaTimersHost.IsFakeTimers( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +begin + Result := TGocciaBooleanLiteralValue.FromBoolean(Queue.Faking); +end; + +function TGocciaTimersHost.SetSystemTime( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +var + TimerQueue: TGocciaTimerQueue; +begin + TimerQueue := Queue; + { `setSystemTime()` with nothing to set means "freeze at now". A non-finite + argument is refused by the queue itself — this entry point hands its value + straight through, so the guard cannot live at the Vitest shim's boundary + alone. } + if (AArgs.Length = 0) or + (AArgs.GetElement(0) is TGocciaUndefinedLiteralValue) then + TimerQueue.SetSystemTime(TimerQueue.RealEpochMilliseconds) + else + TimerQueue.SetSystemTime(AArgs.GetElement(0).ToNumberLiteral.Value); + Result := TGocciaUndefinedLiteralValue.UndefinedValue; +end; + +function TGocciaTimersHost.GetMockedSystemTime( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +var + TimerQueue: TGocciaTimerQueue; +begin + TimerQueue := Queue; + if TimerQueue.Faking then + Result := TGocciaNumberLiteralValue.Create(TimerQueue.NowMilliseconds) + else if TimerQueue.MockedDateOnly then + Result := TGocciaNumberLiteralValue.Create(TimerQueue.MockedDate) + else + Result := TGocciaNullLiteralValue.NullValue; +end; + +function TGocciaTimersHost.GetRealSystemTime( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +begin + Result := TGocciaNumberLiteralValue.Create(Queue.RealEpochMilliseconds); +end; + +function TGocciaTimersHost.AdvanceTimersByTime( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +begin + RequireFaking.Tick(MillisecondArgument(AArgs), False); + Result := TGocciaUndefinedLiteralValue.UndefinedValue; +end; + +{ The `Async` members differ from their synchronous twins in exactly one way: + the microtask queue is drained before the first timer and again after each + one, so a promise callback a timer scheduled runs before the next timer does. + Because `await` in GocciaScript is a synchronous drain, doing that work here + and letting the shim's `async` wrapper return the promise produces the same + observable ordering as Vitest's real event-loop boundary. } +function TGocciaTimersHost.AdvanceTimersByTimeAsync( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +begin + RequireFaking.Tick(MillisecondArgument(AArgs), True); + Result := TGocciaUndefinedLiteralValue.UndefinedValue; +end; + +function TGocciaTimersHost.AdvanceTimersToNextTimer( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +begin + RequireFaking.AdvanceToNextTimer(False); + Result := TGocciaUndefinedLiteralValue.UndefinedValue; +end; + +function TGocciaTimersHost.AdvanceTimersToNextTimerAsync( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +begin + RequireFaking.AdvanceToNextTimer(True); + Result := TGocciaUndefinedLiteralValue.UndefinedValue; +end; + +function TGocciaTimersHost.RunAllTimers( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +begin + RequireFaking.RunAllTimers(False); + Result := TGocciaUndefinedLiteralValue.UndefinedValue; +end; + +function TGocciaTimersHost.RunAllTimersAsync( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +begin + RequireFaking.RunAllTimers(True); + Result := TGocciaUndefinedLiteralValue.UndefinedValue; +end; + +function TGocciaTimersHost.RunOnlyPendingTimers( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +begin + RequireFaking.RunPendingTimers(False); + Result := TGocciaUndefinedLiteralValue.UndefinedValue; +end; + +function TGocciaTimersHost.RunOnlyPendingTimersAsync( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +begin + RequireFaking.RunPendingTimers(True); + Result := TGocciaUndefinedLiteralValue.UndefinedValue; +end; + +function TGocciaTimersHost.ClearAllTimers( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +begin + Queue.ClearAllTimers; + Result := TGocciaUndefinedLiteralValue.UndefinedValue; +end; + +function TGocciaTimersHost.GetTimerCount( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +begin + Result := TGocciaNumberLiteralValue.Create(RequireFaking.CountTimers); +end; + +procedure RegisterTimerGlobals(const AScope: TGocciaScope; + var AHostToken: TObject); +var + Host: TGocciaTimersHost; +begin + Host := EnsureHost(AHostToken); + AScope.DefineLexicalBinding('setTimeout', + TGocciaNativeFunctionValue.Create(Host.SetTimeoutCallback, + 'setTimeout', 2), dtConst, True); + AScope.DefineLexicalBinding('setInterval', + TGocciaNativeFunctionValue.Create(Host.SetIntervalCallback, + 'setInterval', 2), dtConst, True); + AScope.DefineLexicalBinding('clearTimeout', + TGocciaNativeFunctionValue.Create(Host.ClearTimerCallback, + 'clearTimeout', 1), dtConst, True); + AScope.DefineLexicalBinding('clearInterval', + TGocciaNativeFunctionValue.Create(Host.ClearTimerCallback, + 'clearInterval', 1), dtConst, True); +end; + +function CreateTimersNamespace(var AHostToken: TObject): TGocciaObjectValue; +var + Host: TGocciaTimersHost; + Namespace: TGocciaObjectValue; + + procedure Add(const AName: string; + const ACallback: TGocciaNativeFunctionCallback; const AArity: Integer); + begin + Namespace.AssignProperty(AName, + TGocciaNativeFunctionValue.Create(ACallback, AName, AArity)); + end; + +begin + Host := EnsureHost(AHostToken); + + Namespace := TGocciaObjectValue.Create( + TGocciaObjectValue.SharedObjectPrototype); + Add('useFakeTimers', Host.UseFakeTimers, 1); + Add('useRealTimers', Host.UseRealTimers, 0); + Add('isFakeTimers', Host.IsFakeTimers, 0); + Add('setSystemTime', Host.SetSystemTime, 1); + Add('getMockedSystemTime', Host.GetMockedSystemTime, 0); + Add('getRealSystemTime', Host.GetRealSystemTime, 0); + Add('advanceTimersByTime', Host.AdvanceTimersByTime, 1); + Add('advanceTimersByTimeAsync', Host.AdvanceTimersByTimeAsync, 1); + Add('advanceTimersToNextTimer', Host.AdvanceTimersToNextTimer, 0); + Add('advanceTimersToNextTimerAsync', + Host.AdvanceTimersToNextTimerAsync, 0); + Add('runAllTimers', Host.RunAllTimers, 0); + Add('runAllTimersAsync', Host.RunAllTimersAsync, 0); + Add('runOnlyPendingTimers', Host.RunOnlyPendingTimers, 0); + Add('runOnlyPendingTimersAsync', Host.RunOnlyPendingTimersAsync, 0); + Add('clearAllTimers', Host.ClearAllTimers, 0); + Add('getTimerCount', Host.GetTimerCount, 0); + Add('setTimeout', Host.SetTimeoutCallback, 2); + Add('setInterval', Host.SetIntervalCallback, 2); + Add('clearTimeout', Host.ClearTimerCallback, 1); + Add('clearInterval', Host.ClearTimerCallback, 1); + + Result := Namespace; +end; + +{ Drops one extension's host. The list owns its entries, so Remove frees it; + an unknown or already-released token is ignored so a double detach is safe. } +procedure ReleaseTimersHost(const AHostToken: TObject); +begin + if not (Assigned(AHostToken) and Assigned(GTimersHosts)) then + Exit; + GTimersHosts.Remove(AHostToken); +end; + +procedure ClearTimersHosts; +begin + FreeAndNil(GTimersHosts); +end; + +initialization + RegisterThreadvarCleanup(ClearTimersHosts); + +end. diff --git a/source/units/Goccia.FetchManager.pas b/source/units/Goccia.FetchManager.pas index 762a5f0bc..9c3598458 100644 --- a/source/units/Goccia.FetchManager.pas +++ b/source/units/Goccia.FetchManager.pas @@ -82,8 +82,10 @@ implementation Goccia.Builtins.Atomics, Goccia.GarbageCollector, + Goccia.InstructionLimit, Goccia.MicrotaskQueue, - Goccia.Timeout; + Goccia.Timeout, + Goccia.Timers; const FETCH_POLL_INTERVAL_MS = 1; @@ -873,23 +875,61 @@ function WaitForFetchPromise(const APromise: TGocciaPromiseValue): Boolean; var Manager: TGocciaFetchManager; HasPendingFetch: Boolean; + TimersRun: Integer; begin if not Assigned(APromise) then Exit(False); + TimersRun := 0; while APromise.State = gpsPending do begin DrainMicrotasksAndFetchCompletions; if APromise.State <> gpsPending then Exit(True); + CheckExecutionTimeout; + CheckInstructionLimit; + Manager := TGocciaFetchManager.Instance; HasPendingFetch := Assigned(Manager) and Manager.HasPending; - if not HasPendingFetch and not HasPendingAtomicsWaitAsyncCompletions then + + { Work that is really outstanding outranks virtual time. + + Despite the name this is the host's general "drive this promise to + settlement" wait — the test runner uses it for every async test's + returned promise — so the virtual timer queue belongs here alongside + fetch and Atomics.waitAsync. But it must not be reached FIRST. A real-mode + timer costs no real time, so running one while a fetch was still in + flight made `Promise.race([fetch(url), timeoutAfter(ms)])` resolve to the + timeout every single time, whatever ms was, and a live interval could + spend the entire budget before the response ever arrived. Polling the + real work first means the race is decided by whether the fetch completes + at all, which is the outcome a suite writing that race expects. } + if HasPendingFetch or HasPendingAtomicsWaitAsyncCompletions then + begin + Sleep(FETCH_POLL_INTERVAL_MS); + Continue; + end; + + { Nothing real is outstanding, so the clock may jump to the next timer. A + real-mode timer is a continuation no amount of microtask draining will + produce. Under fake timers this does nothing, because the suite, not the + engine, decides when those run — and it does nothing for a queue owned by + another realm either. } + if TimersRun >= TIMER_LOOP_LIMIT then + begin + { Spending the whole budget with timers still runnable is a diagnosis of + its own, and one the caller cannot make: reported as an unsettled + promise it read as a missing `await` rather than as a timer that keeps + rescheduling itself. } + if HasRunnableRealTimers then + RaiseRealTimerLoopLimit; Exit(False); + end; - CheckExecutionTimeout; - Sleep(FETCH_POLL_INTERVAL_MS); + if not RunOneRealTimer then + Exit(False); + Inc(TimersRun); end; Result := True; diff --git a/source/units/Goccia.GarbageCollector.pas b/source/units/Goccia.GarbageCollector.pas index 489adb87f..db91a80b7 100644 --- a/source/units/Goccia.GarbageCollector.pas +++ b/source/units/Goccia.GarbageCollector.pas @@ -31,6 +31,19 @@ TGCRootSource = class(TInterfacedObject) procedure AfterConstruction; override; procedure BeforeDestruction; override; procedure MarkRootReferences; virtual; abstract; + // The collector this source is still registered with, or nil once that + // collector has been destroyed. + // + // A holder that caches a root source across engines has to key on this + // rather than on the collector it remembers separately. A + // Shutdown/Initialize pair can put the next thread-local collector at the + // address the previous one occupied, and a bare pointer compare against a + // remembered address then reports "same collector" for a source that is + // registered with the dead one — so the source is never rebuilt and + // nothing it publishes is ever marked again. Reading it here cannot report + // a stale match: the collector's destructor nils this field on every + // source it still owns. + function RegisteredCollector: TGarbageCollector; end; TGCManagedObject = class @@ -429,6 +442,11 @@ procedure TGCRootSource.BeforeDestruction; inherited; end; +function TGCRootSource.RegisteredCollector: TGarbageCollector; +begin + Result := FRootSourceOwner; +end; + procedure InitializeTempRoot(var ARoot: TGocciaTempRoot); begin ARoot.ObjectValue := nil; diff --git a/source/units/Goccia.HostEnvironment.pas b/source/units/Goccia.HostEnvironment.pas index 59c511eed..778cc1f51 100644 --- a/source/units/Goccia.HostEnvironment.pas +++ b/source/units/Goccia.HostEnvironment.pas @@ -64,6 +64,10 @@ TGocciaHostEnvironment = class FHasTimeZoneOverride: Boolean; FNextChildStreamId: UInt64; FChildLock: TGocciaCriticalSection; + FHasEpochOverride: Boolean; + FEpochOverride: Int64; + FHasMonotonicOverride: Boolean; + FMonotonicOverride: Int64; procedure SetProviders(const AClock: IGocciaHostClock; const ARandom: IGocciaHostRandom; const AHasTimeZoneOverride: Boolean); @@ -86,6 +90,26 @@ TGocciaHostEnvironment = class procedure UseDeterministicProfile; procedure ConfigureAsChildOf(const AParent: TGocciaHostEnvironment); + { A mocked clock, layered over the configured providers rather than + replacing them. The virtual timer queue installs one while fake timers + are on, so Date, Temporal.Now and performance all read the same simulated + instant; RealEpochNanoseconds still reaches the provider underneath, + which is what `vi.getRealSystemTime()` needs. Each half is independent: + freezing the date without faking timers leaves monotonic time real. + + Not inherited by ConfigureAsChildOf — a child realm gets the real clock + until something mocks its own. } + procedure OverrideClock(const AHasEpoch: Boolean; + const AEpochNanoseconds: Int64; const AHasMonotonic: Boolean; + const AMonotonicNanoseconds: Int64); + procedure ClearClockOverride; + function HasClockOverride: Boolean; + { True only while monotonic time is mocked. A mocked monotonic clock is + measured from the mock's own origin, so a reader that subtracts a time + origin of its own — performance.now() — has to stop doing that. } + function HasMonotonicClockOverride: Boolean; + function RealEpochNanoseconds: Int64; + function EpochNanoseconds: Int64; {$IFDEF FPC}inline;{$ENDIF} function MonotonicNanoseconds: Int64; {$IFDEF FPC}inline;{$ENDIF} function TimeZoneIdentifier: string; {$IFDEF FPC}inline;{$ENDIF} @@ -280,13 +304,48 @@ procedure TGocciaHostEnvironment.ConfigureAsChildOf( SetProviders(Clock, Random, HasTimeZoneOverride); end; +procedure TGocciaHostEnvironment.OverrideClock(const AHasEpoch: Boolean; + const AEpochNanoseconds: Int64; const AHasMonotonic: Boolean; + const AMonotonicNanoseconds: Int64); +begin + FHasEpochOverride := AHasEpoch; + FEpochOverride := AEpochNanoseconds; + FHasMonotonicOverride := AHasMonotonic; + FMonotonicOverride := AMonotonicNanoseconds; +end; + +procedure TGocciaHostEnvironment.ClearClockOverride; +begin + FHasEpochOverride := False; + FHasMonotonicOverride := False; +end; + +function TGocciaHostEnvironment.HasClockOverride: Boolean; +begin + Result := FHasEpochOverride or FHasMonotonicOverride; +end; + +function TGocciaHostEnvironment.HasMonotonicClockOverride: Boolean; +begin + Result := FHasMonotonicOverride; +end; + +function TGocciaHostEnvironment.RealEpochNanoseconds: Int64; +begin + Result := FClock.EpochNanoseconds; +end; + function TGocciaHostEnvironment.EpochNanoseconds: Int64; begin + if FHasEpochOverride then + Exit(FEpochOverride); Result := FClock.EpochNanoseconds; end; function TGocciaHostEnvironment.MonotonicNanoseconds: Int64; begin + if FHasMonotonicOverride then + Exit(FMonotonicOverride); Result := FClock.MonotonicNanoseconds; end; diff --git a/source/units/Goccia.RuntimeExtensions.Timers.pas b/source/units/Goccia.RuntimeExtensions.Timers.pas new file mode 100644 index 000000000..66b1e622b --- /dev/null +++ b/source/units/Goccia.RuntimeExtensions.Timers.pas @@ -0,0 +1,132 @@ +unit Goccia.RuntimeExtensions.Timers; + +{$I Goccia.inc} + +{ Timer runtime extension. + + Installs the four timer globals and the `goccia:timers` control module, and + points the virtual timer queue at this engine's host environment so a mocked + clock reaches `Date`, `Temporal.Now` and `performance`. + + Installed by the test-runner profile only. The timers are deterministic and + carry no ambient authority, but they are a scheduling surface a sandboxed + script does not otherwise have, and the acceptance target for them is the + test runner. See docs/adr/0113-deterministic-virtual-timer-queue.md. } + +interface + +uses + Goccia.Runtime, + Goccia.RuntimeExtensions.NamespaceModule, + Goccia.Values.Primitives; + +const + TIMERS_MODULE_NAME = 'goccia:timers'; + +type + TGocciaTimersRuntimeExtension = class(TGocciaRuntimeExtension) + private + FTimersModule: TGocciaRuntimeNamespaceModuleRegistration; + FHostToken: TObject; + function MaterializeTimers: TGocciaValue; + public + procedure Attach(const ARuntime: TGocciaRuntimeCore); override; + procedure Detach; override; + procedure WaitForIdle; override; + procedure DiscardPending; override; + end; + +implementation + +uses + Goccia.Builtins.Timers, + Goccia.Timers; + +procedure TGocciaTimersRuntimeExtension.Attach( + const ARuntime: TGocciaRuntimeCore); +var + Queue: TGocciaTimerQueue; +begin + inherited Attach(ARuntime); + + TGocciaTimerQueue.Initialize; + Queue := TGocciaTimerQueue.Instance; + { The queue is per thread and outlives one engine, so an engine that attaches + starts from a clean queue rather than from whatever the previous file on + this thread left pending. Each test file gets its own engine, which is what + keeps fake-timer state from leaking across files without a reset hook of + its own. } + Queue.ResetForEngine; + { Both pointers name objects this engine owns, and both are cleared in Detach + while it is still alive. Attaching a second engine over a queue that still + named the first would leave PublishClock writing into a freed host + environment; assigning here — rather than only when a clock is first mocked + — is what keeps the two in step. } + Queue.HostEnvironment := Runtime.Engine.HostEnvironment; + Queue.OwnerRealm := Runtime.Engine.Realm; + + RegisterTimerGlobals(Runtime.Engine.Interpreter.GlobalScope, FHostToken); + Runtime.RegisterRuntimeGlobalName('setTimeout'); + Runtime.RegisterRuntimeGlobalName('setInterval'); + Runtime.RegisterRuntimeGlobalName('clearTimeout'); + Runtime.RegisterRuntimeGlobalName('clearInterval'); + + FTimersModule := TGocciaRuntimeNamespaceModuleRegistration.Create(Runtime, + TIMERS_MODULE_NAME, MaterializeTimers); +end; + +function TGocciaTimersRuntimeExtension.MaterializeTimers: TGocciaValue; +begin + Result := CreateTimersNamespace(FHostToken); +end; + +{ Real-mode timers fire where a host event loop would have taken over. + + This is one of those points, not the only one and not the last: the engine + reaches runtime idle when the entry module has finished evaluating, which + under the test runner is *before* any test body has run. Timers a test + schedules are drained by the runner's own per-test lifecycle instead + (Goccia.Builtins.TestingLibrary), which is also what attributes a throwing + callback to the test that scheduled it. + + Timeouts only, and nothing raises: DrainRealTimers skips intervals, because an + uncleared one is infinite and would spend the whole budget without finishing + any sooner, and it parks a thrown value for the host rather than propagating + it — a module-scope timer must not fail the file from inside a teardown path. + Under fake timers nothing runs here at all: a timer the suite never advanced + to is a timer the suite did not want. } +procedure TGocciaTimersRuntimeExtension.WaitForIdle; +begin + inherited; + DrainRealTimersForHost; +end; + +procedure TGocciaTimersRuntimeExtension.DiscardPending; +begin + inherited; + DiscardRealTimers; +end; + +procedure TGocciaTimersRuntimeExtension.Detach; +var + Queue: TGocciaTimerQueue; +begin + FTimersModule.Free; + FTimersModule := nil; + + Queue := TGocciaTimerQueue.Instance; + if Assigned(Queue) then + begin + { The clock override has to come off before the host environment goes away, + and the queue must stop pointing at a freed one. } + Queue.ResetForEngine; + Queue.HostEnvironment := nil; + Queue.OwnerRealm := nil; + end; + + ReleaseTimersHost(FHostToken); + FHostToken := nil; + inherited; +end; + +end. diff --git a/source/units/Goccia.RuntimeExtensions.VitestCompat.pas b/source/units/Goccia.RuntimeExtensions.VitestCompat.pas index d53e7ac11..dcdec6397 100644 --- a/source/units/Goccia.RuntimeExtensions.VitestCompat.pas +++ b/source/units/Goccia.RuntimeExtensions.VitestCompat.pas @@ -63,6 +63,7 @@ implementation Goccia.Error, Goccia.Keywords.Reserved, Goccia.Modules.Virtual, + Goccia.RuntimeExtensions.Timers, Goccia.SourcePipeline, Goccia.Values.Primitives; @@ -76,6 +77,9 @@ implementation { The two callee spellings Vitest's own hoisting transform matches. } VI_NAMESPACE_NAME = 'vi'; VITEST_NAMESPACE_NAME = 'vitest'; + { The engine module the fake-timer half of `vi` is built on. Installed by the + same runtime profile, ahead of this extension. } + TIMERS_MODULE_SPECIFIER = TIMERS_MODULE_NAME; DOCS_REFERENCE = 'See docs/testing-api.md (Vitest compatibility) for the supported surface.'; @@ -224,6 +228,27 @@ function VitestCompatShimSource: string; ' spyOn,' + LB + '} from "goccia:test";' + LB + LB + + '// The virtual timer queue. `goccia:timers` speaks in epoch milliseconds;' + LB + + '// wrapping those in Date and returning `vi` for chaining is this shim.' + LB + + 'import {' + LB + + ' useFakeTimers as engineUseFakeTimers,' + LB + + ' useRealTimers as engineUseRealTimers,' + LB + + ' isFakeTimers as engineIsFakeTimers,' + LB + + ' setSystemTime as engineSetSystemTime,' + LB + + ' getMockedSystemTime as engineGetMockedSystemTime,' + LB + + ' getRealSystemTime as engineGetRealSystemTime,' + LB + + ' advanceTimersByTime as engineAdvanceTimersByTime,' + LB + + ' advanceTimersByTimeAsync as engineAdvanceTimersByTimeAsync,' + LB + + ' advanceTimersToNextTimer as engineAdvanceTimersToNextTimer,' + LB + + ' advanceTimersToNextTimerAsync as engineAdvanceTimersToNextTimerAsync,' + LB + + ' runAllTimers as engineRunAllTimers,' + LB + + ' runAllTimersAsync as engineRunAllTimersAsync,' + LB + + ' runOnlyPendingTimers as engineRunOnlyPendingTimers,' + LB + + ' runOnlyPendingTimersAsync as engineRunOnlyPendingTimersAsync,' + LB + + ' clearAllTimers as engineClearAllTimers,' + LB + + ' getTimerCount as engineGetTimerCount,' + LB + + '} from "' + TIMERS_MODULE_SPECIFIER + '";' + LB + + LB + 'export {' + LB + ' describe,' + LB + ' test,' + LB + @@ -268,14 +293,28 @@ function VitestCompatShimSource: string; ' "vi.mock with a factory is supported, but the factory is relocated " +' + LB + ' "into its own module scope, so there is no shared hoisted-variable " +' + LB + ' "scope for it to read.";' + LB + - 'const FAKE_TIMERS =' + LB + - ' "GocciaScript has no fake-timer clock; timers run on the real event loop.";' + LB + + 'const NO_FRAME_CLOCK =' + LB + + ' "the timer queue is supported, but this member drives ' + + 'requestAnimationFrame, " +' + LB + + ' "which GocciaScript does not provide: there is no display to pace a " +' + LB + + ' "frame against.";' + LB + + 'const NO_NEXT_TICK =' + LB + + ' "the timer queue is supported, but this member drains " +' + LB + + ' "process.nextTick, and GocciaScript has no process. Promise jobs run " +' + LB + + ' "on the engine microtask queue, which the async advance members " +' + LB + + ' "already drain.";' + LB + + 'const NO_AUTO_ADVANCE =' + LB + + ' "the timer queue is supported, and the manual mode is already how it " +' + LB + + ' "behaves, so that one is accepted. Every other mode advances the " +' + LB + + ' "clock against real elapsed time, which no GocciaScript clock " +' + LB + + ' "measures. Advance the timers explicitly instead.";' + LB + 'const ASYNC_POLLING =' + LB + ' "this member polls asynchronously, retrying its callback until the " +' + LB + ' "condition holds or a timeout elapses, which needs execution to " +' + LB + ' "suspend and resume between attempts. GocciaScript has no such " +' + LB + - ' "primitive: await is a synchronous drain and there is no general " +' + LB + - ' "event loop on which a pending condition could change.";' + LB + + ' "primitive: await is a synchronous drain, and the virtual timer queue " +' + LB + + ' "only moves when a test moves it, so a poll loop would spin without " +' + LB + + ' "anything being able to change the condition.";' + LB + 'const CONFIG =' + LB + ' "GocciaScript has no runtime-mutable test configuration.";' + LB + LB + @@ -417,6 +456,42 @@ function VitestCompatShimSource: string; ' return vi;' + LB + '};' + LB + LB + + '// Fake timers. The clock and the queue live in the engine; everything' + LB + + '// here is the shape Vitest exposes them in — Date instead of epoch' + LB + + '// milliseconds, and `vi` back for chaining.' + LB + + '// Vitest passes anything that is not already a Date through the Date' + LB + + '// constructor, so a date STRING is supported API — `Number(value)` on' + LB + + '// one produces NaN instead of an instant. The engine refuses a' + LB + + '// non-finite result rather than installing a NaN clock the way Vitest' + LB + + '// does; see docs/testing-api.md.' + LB + + 'const toEpochMilliseconds = (value) =>' + LB + + ' (value instanceof Date ? value : new Date(value)).getTime();' + LB + + LB + + '// Only `now` is honoured. `toFake` has nothing to select from — there is' + LB + + '// exactly one timer queue and it is always the faked one — and the' + LB + + '// auto-advance options describe real elapsed time, which no GocciaScript' + LB + + '// clock ever measures.' + LB + + 'const useFakeTimers = (config) => {' + LB + + ' engineUseFakeTimers(' + LB + + ' config && config.now !== undefined' + LB + + ' ? toEpochMilliseconds(config.now)' + LB + + ' : undefined,' + LB + + ' );' + LB + + ' return vi;' + LB + + '};' + LB + + LB + + 'const setSystemTime = (value) => {' + LB + + ' engineSetSystemTime(' + LB + + ' value === undefined ? undefined : toEpochMilliseconds(value),' + LB + + ' );' + LB + + ' return vi;' + LB + + '};' + LB + + LB + + 'const getMockedSystemTime = () => {' + LB + + ' const milliseconds = engineGetMockedSystemTime();' + LB + + ' return milliseconds === null ? null : new Date(milliseconds);' + LB + + '};' + LB + + LB + 'export const vi = {' + LB + ' fn: registerMock,' + LB + ' spyOn: registerSpy,' + LB + @@ -435,19 +510,56 @@ function VitestCompatShimSource: string; ' importMock: unsupported("importMock", NO_ACTUAL_MODULE),' + LB + ' hoisted: unsupported("hoisted", FACTORY_SCOPE),' + LB + LB + - ' useFakeTimers: unsupported("useFakeTimers", FAKE_TIMERS),' + LB + - ' useRealTimers: unsupported("useRealTimers", FAKE_TIMERS),' + LB + - ' isFakeTimers: unsupported("isFakeTimers", FAKE_TIMERS),' + LB + - ' setSystemTime: unsupported("setSystemTime", FAKE_TIMERS),' + LB + - ' getMockedSystemTime: unsupported("getMockedSystemTime", FAKE_TIMERS),' + LB + - ' getRealSystemTime: unsupported("getRealSystemTime", FAKE_TIMERS),' + LB + - ' advanceTimersByTime: unsupported("advanceTimersByTime", FAKE_TIMERS),' + LB + - ' advanceTimersByTimeAsync:' + LB + - ' unsupported("advanceTimersByTimeAsync", FAKE_TIMERS),' + LB + - ' advanceTimersToNextTimer:' + LB + - ' unsupported("advanceTimersToNextTimer", FAKE_TIMERS),' + LB + - ' runAllTimers: unsupported("runAllTimers", FAKE_TIMERS),' + LB + - ' runOnlyPendingTimers: unsupported("runOnlyPendingTimers", FAKE_TIMERS),' + LB + + ' useFakeTimers: useFakeTimers,' + LB + + ' useRealTimers: () => { engineUseRealTimers(); return vi; },' + LB + + ' isFakeTimers: () => engineIsFakeTimers(),' + LB + + ' setSystemTime: setSystemTime,' + LB + + ' getMockedSystemTime: getMockedSystemTime,' + LB + + ' getRealSystemTime: () => engineGetRealSystemTime(),' + LB + + ' getTimerCount: () => engineGetTimerCount(),' + LB + + ' clearAllTimers: () => { engineClearAllTimers(); return vi; },' + LB + + LB + + ' advanceTimersByTime: (ms) => {' + LB + + ' engineAdvanceTimersByTime(ms);' + LB + + ' return vi;' + LB + + ' },' + LB + + ' advanceTimersByTimeAsync: async (ms) => {' + LB + + ' engineAdvanceTimersByTimeAsync(ms);' + LB + + ' return vi;' + LB + + ' },' + LB + + ' advanceTimersToNextTimer: () => {' + LB + + ' engineAdvanceTimersToNextTimer();' + LB + + ' return vi;' + LB + + ' },' + LB + + ' advanceTimersToNextTimerAsync: async () => {' + LB + + ' engineAdvanceTimersToNextTimerAsync();' + LB + + ' return vi;' + LB + + ' },' + LB + + ' runAllTimers: () => { engineRunAllTimers(); return vi; },' + LB + + ' runAllTimersAsync: async () => { engineRunAllTimersAsync(); return vi; },' + LB + + ' runOnlyPendingTimers: () => {' + LB + + ' engineRunOnlyPendingTimers();' + LB + + ' return vi;' + LB + + ' },' + LB + + ' runOnlyPendingTimersAsync: async () => {' + LB + + ' engineRunOnlyPendingTimersAsync();' + LB + + ' return vi;' + LB + + ' },' + LB + + LB + + ' // The three timer members the queue cannot honour. Named errors rather' + LB + + ' // than absent properties, so a suite reaching for one is told which' + LB + + ' // part of the family is missing and why.' + LB + + ' advanceTimersToNextFrame:' + LB + + ' unsupported("advanceTimersToNextFrame", NO_FRAME_CLOCK),' + LB + + ' runAllTicks: unsupported("runAllTicks", NO_NEXT_TICK),' + LB + + LB + + ' // "manual" is not a mode this has to implement — it is a description' + LB + + ' // of the only behaviour there is, so asking for it is satisfied by' + LB + + ' // doing nothing. The auto-advancing modes are the unsupported ones.' + LB + + ' setTimerTickMode: (mode) => {' + LB + + ' if (mode === "manual") return vi;' + LB + + ' return unsupported("setTimerTickMode", NO_AUTO_ADVANCE)();' + LB + + ' },' + LB + LB + ' stubGlobal: stubGlobal,' + LB + ' unstubAllGlobals: unstubAllGlobals,' + LB + diff --git a/source/units/Goccia.RuntimeProfiles.TestRunner.pas b/source/units/Goccia.RuntimeProfiles.TestRunner.pas index 859de43bf..fa022a483 100644 --- a/source/units/Goccia.RuntimeProfiles.TestRunner.pas +++ b/source/units/Goccia.RuntimeProfiles.TestRunner.pas @@ -21,6 +21,7 @@ implementation uses Goccia.RuntimeExtensions.TestingLibrary, + Goccia.RuntimeExtensions.Timers, Goccia.RuntimeExtensions.VitestCompat, Goccia.RuntimeProfiles.Loader; @@ -41,6 +42,13 @@ procedure ApplyTestRunnerRuntimeProfile(const ARuntime: TGocciaRuntimeCore; so that globals and `goccia:test` drive the same registry. Installing the loader's copy as well would register `goccia:test` twice. } ApplyLoaderRuntimeProfile(ARuntime, False); + { The timer globals and `goccia:timers` are runner-only. They are + deterministic and carry no ambient authority, but a scheduling surface is + still a surface, and the acceptance target — a Vitest suite draining a + setTimeout-based scheduler through fake timers — is the runner's. + Installed before the testing library so a real-mode timer drains after the + other extensions have gone idle. } + ARuntime.Install(TGocciaTimersRuntimeExtension.Create); ARuntime.Install(TGocciaTestingLibraryRuntimeExtension.Create( ASnapshotHost, ASnapshotUpdateMode, ASnapshotFormatter, True)); { A suite written against Vitest imports from a bare `vitest` specifier, diff --git a/source/units/Goccia.Timers.pas b/source/units/Goccia.Timers.pas new file mode 100644 index 000000000..47fa01955 --- /dev/null +++ b/source/units/Goccia.Timers.pas @@ -0,0 +1,1389 @@ +{ Deterministic virtual timer queue. + + GocciaScript has no host event loop and never waits on wall time. A timer + registered here is a record in an ordered queue with a due time on a virtual + clock, and it runs only when something advances that clock: + + - under fake timers, one of the `vi` advance members (see + Goccia.Builtins.Timers), and + - under real timers, the engine itself, wherever it would otherwise have + nothing left to do: an `await` whose promise is still pending + (Goccia.Values.Await and Goccia.FetchManager), the end of each test + (Goccia.Builtins.TestingLibrary), and the engine's own idle point + (Goccia.RuntimeExtensions.Timers). + + Either way no real time passes: the clock jumps to each timer's due time. + Work that is really outstanding still outranks it — a fetch in flight is + polled before the clock is allowed to move — and an exception a real-mode + callback throws is parked for the host rather than raised at whichever frame + happened to be waiting, which is not the one Node would report it at. + + The ordering, the due-time arithmetic and the loop guard are modelled on + @sinonjs/fake-timers, which is what Vitest's fake timers wrap, and every rule + in here was probed against the pinned Vitest 4.1.10 rather than read off its + documentation. See docs/adr/0113-deterministic-virtual-timer-queue.md. + + The queue is shared machinery: it sits below both executors, so the + interpreter and the bytecode VM get identical behaviour by construction. } + +unit Goccia.Timers; + +{$I Goccia.inc} + +interface + +uses + Generics.Collections, + + Goccia.AsyncContext, + Goccia.GarbageCollector, + Goccia.HostEnvironment, + Goccia.Values.Primitives; + +const + { @sinonjs/fake-timers aborts a runAll() that keeps finding new timers, and + Vitest configures the limit at 10000. The message is reproduced verbatim + because suites assert on it — built from the constant so the two cannot + drift apart. } + TIMER_LOOP_LIMIT = 10000; + { Node clamps a delay above the signed 32-bit range to 1ms, and so does the + fake clock. } + TIMER_MAX_DELAY = 2147483647.0; + NEGATIVE_TICKS_MESSAGE = 'Negative ticks are not supported'; + { One advance may legitimately fire a great many timers — a 10ms interval + advanced by an hour fires 360000 — so this bound is far above anything a + real suite reaches and exists only to stop the one shape that cannot + terminate: a timer whose period is zero, which reschedules itself at the + instant it just ran so the clock can never move past it. Vitest hangs + forever on that shape; aborting is the one place this deliberately does + better than the oracle. } + TIMER_TICK_LOOP_LIMIT = 1000000; + TICK_LOOP_LIMIT_MESSAGE = + 'Aborting after firing %d timers in a single advance: a timer keeps ' + + 'rescheduling itself at the current instant, so the clock cannot move ' + + 'past it. A setInterval with a period of 0 does this.'; + { Vitest lets a non-finite system time through and leaves Date.now() reporting + NaN. GocciaScript refuses it instead: the mocked clock reaches JavaScript as + an Int64 nanosecond count on the host environment, so there is no NaN to + propagate, and every arithmetic consumer of the virtual clock — range tests, + due-time shifting — silently stops working once one is admitted. } + NON_FINITE_SYSTEM_TIME_MESSAGE = + 'The system time must be a finite number of milliseconds or a valid Date.'; + NON_FINITE_SYSTEM_TIME_SUGGESTION = + 'Pass a finite epoch value, as in setSystemTime(0) or ' + + 'setSystemTime(new Date("2020-01-01")).'; + { A real-mode drain that hits the bound has to say so in its own words: the + fake-clock message names an advance member the program never called. } + REAL_TIMER_LOOP_LIMIT_MESSAGE = + 'Aborting after running %d timers, assuming an infinite loop of ' + + 'self-rescheduling timers. Clear the timer, or drive it with ' + + 'vi.useFakeTimers() so the test decides when it runs.'; + +type + TGocciaTimerKind = (gtkTimeout, gtkInterval); + + { One advance operation's recorded exception. + + The slot has to be per-operation rather than per-queue. A tick drains the + microtask queue between timers, and guest code reached from there can start + another advance or another engine wait re-entrantly; with one queue-wide + slot the outer tick's recorded exception was raised at the inner site, and + the outer tick then believed it had succeeded. Each operation pushes its own + slot and the enclosing ones stay saved — and stay marked — until it pops. } + TGocciaTimerThrowSlot = record + Value: TGocciaValue; + HasValue: Boolean; + end; + + { One scheduled callback. Owned by the queue's list, which frees it. } + TGocciaTimerEntry = class + public + Id: Double; + Kind: TGocciaTimerKind; + Callback: TGocciaValue; + Args: TArray; + Delay: Double; + IntervalDelay: Double; + CallAt: Double; + CreatedAt: Double; + { True while this entry's callback is on the stack. An advance reached from + inside that callback — through the microtask drain, or through an engine + wait an `await` in it started — must not pick the same entry again: an + interval stays in the queue while it runs, and the real-mode step chooses + the earliest timer without a due-time window, so it re-entered the same + callback until the stack ran out. } + Dispatching: Boolean; + { Cancelled while its own callback was running. An interval stays in the + queue while it runs — that is what lets an explicit nested advance + re-enter it, as Vitest allows — so `clearInterval(id)` called from inside + that very callback would otherwise delete and free the entry the + dispatcher is still holding. It is moved aside and marked instead, and + freed when the callback returns. } + Cleared: Boolean; + { The async context in effect where the timer was registered. A timer + callback is a continuation, so it runs under that context rather than + under whatever the code advancing the clock happens to hold. } + Context: TGocciaAsyncContextSnapshot; + end; + + TGocciaTimerEntryList = TObjectList; + + TGocciaTimerQueue = class; + + { Publishes the queue's live values to the collector: a pending timer's + callback, its arguments and its captured context are reachable from nothing + else. } + TGocciaTimerRoots = class(TGCRootSource) + private + FQueue: TGocciaTimerQueue; + public + procedure MarkRootReferences; override; + end; + + TGocciaTimerQueue = class + private + FTimers: TGocciaTimerEntryList; + { Timeout entries whose callbacks are on the stack. They are extracted from + FTimers before dispatch, so this is what keeps them marked — and it is a + stack, not a slot, because a nested advance can start another one. } + FInFlight: TGocciaTimerEntryList; + FRoots: TGocciaTimerRoots; + FNextId: Double; + + FNow: Double; + FStart: Double; + FAdjusted: Double; + { Sub-millisecond remainder carried between advances, in nanoseconds. An + advance moves the clock by whole milliseconds and banks the fraction, so + two half-millisecond advances move it by one — probed: tick(1.5) then + tick(0.5) lands on 2 and fires a timer due there. } + FNanos: Double; + FDuringTick: Boolean; + FFaking: Boolean; + FMockedDateOnly: Boolean; + FMockedDate: Double; + + FHostEnvironment: TGocciaHostEnvironment; + FOwnerRealm: TObject; + + FThrow: TGocciaTimerThrowSlot; + FSavedThrows: TArray; + FSavedThrowCount: Integer; + { The value of an exception this queue is in the middle of raising. Held + until the next advance starts rather than released at the raise: the + exception object does not root what it carries, and the unwind runs + arbitrary `finally` blocks that can collect. } + FRaisedThrow: TGocciaValue; + { An exception from a real-mode timer. Not raised at whichever frame + happened to be waiting — in Node that frame is unaffected and the error is + an uncaught top-level one — so it is parked here for the host to report. } + FUncaughtError: TGocciaValue; + FHasUncaughtError: Boolean; + + procedure EnsureRoots; + procedure PublishClock; + procedure SetNow(const AValue: Double); + function IndexOfId(const AId: Double): Integer; + function IsEarlier(const ALeft, ARight: TGocciaTimerEntry): Boolean; + function IsDispatchable(const ATimer: TGocciaTimerEntry): Boolean; + function FirstTimerInRange(const AFrom, ATo: Double): TGocciaTimerEntry; + function FirstTimer: TGocciaTimerEntry; + function FirstRealTimer(const ATimeoutsOnly: Boolean): TGocciaTimerEntry; + function LastTimer: TGocciaTimerEntry; + procedure RetireEntry(const AIndex: Integer); + procedure RetireAllEntries; + procedure CallTimer(const ATimer: TGocciaTimerEntry; + const ACaptureThrows: Boolean); + procedure CapturePendingThrow(const AValue: TGocciaValue); + function PushThrowSlot: Integer; + function TakeThrowSlot(const AToken: Integer; + out AValue: TGocciaValue): Boolean; + procedure RaiseThrown(const AValue: TGocciaValue); + procedure DrainMicrotasks; + function DoTick(const AMilliseconds: Double; + const AAsync: Boolean): Double; + function DoNext(const AAsync, ACaptureThrows: Boolean): Double; + function RunOneRealTimerOfKind(const ATimeoutsOnly: Boolean): Boolean; + public + class function Instance: TGocciaTimerQueue; + class procedure Initialize; + class procedure Shutdown; + + constructor Create; + destructor Destroy; override; + + { Registration. ADelay and AInterval are raw JavaScript numbers; the + normalisation the fake clock applies happens here. } + function AddTimer(const AKind: TGocciaTimerKind; + const ACallback: TGocciaValue; const AArgs: TArray; + const ADelay: Double): Double; + procedure ClearTimer(const AId: Double); + { The `vi.clearAllTimers` semantics: fake-timer mode only, and it rewinds + the clock as well. DiscardTimers is the engine-side counterpart — it + drops the queue in either mode and leaves the clock alone, for a host + tearing a run down. } + procedure ClearAllTimers; + procedure DiscardTimers; + function CountTimers: Integer; + function HasPendingTimers: Boolean; + + { Advancing. Every member returns the new virtual now, in milliseconds. + AAsync drains the microtask queue between timers, which is what makes an + `Async` advance member observe the promise callbacks a timer queued. } + function Tick(const AMilliseconds: Double; + const AAsync: Boolean): Double; + function AdvanceToNextTimer(const AAsync: Boolean): Double; + function RunAllTimers(const AAsync: Boolean): Double; + function RunPendingTimers(const AAsync: Boolean): Double; + + { Runs the next due timer and drains microtasks after it. Real-timer mode + only; returns False when nothing ran. This is what the engine calls where + a host event loop would have taken over. An exception the callback threw + is parked in the uncaught slot rather than raised here. } + function RunOneRealTimer: Boolean; + { Bounded real-timer drain for the end of a run. + + Timeouts only, and it never raises. An uncleared interval is by + construction infinite, so running it here would burn the whole budget and + finish no sooner; and a shutdown path that threw would turn a passing file + into a failing one. Whatever a callback threw is parked in the uncaught + slot for the host to report. } + procedure DrainRealTimers; + + { An exception a real-mode timer callback threw, if any, cleared by the + read. The host attributes it: the test runner fails the test that was + running, or the file when none was. } + function TakeUncaughtError(out AValue: TGocciaValue): Boolean; + function HasUncaughtError: Boolean; + + { The engine boundary, in both directions. + + The queue is a thread singleton and outlives any one engine, but every + JavaScript value it holds belongs to the realm that put it there. Anything + left behind — a recorded exception, the value of one already raised, an + uncaught error nobody collected — is published to the NEXT engine's + collector by the root source, which then walks a value whose heap is gone. + That is an access violation on the second test file in a process, and it + is why this is separate from EndFakeTimers: `vi.useRealTimers()` must not + discard an uncaught error the runner has not reported yet. } + procedure ResetForEngine; + + { Fake-timer mode. } + procedure BeginFakeTimers(const ANowMilliseconds: Double); + procedure EndFakeTimers; + procedure SetSystemTime(const AEpochMilliseconds: Double); + procedure ClearMockedDate; + function RealEpochMilliseconds: Double; + + { The engine whose Date, Temporal.Now and performance this queue drives + while a clock is mocked. Assigned by the runtime extension. + + Both are cleared on detach while that engine is still alive: the queue is + a thread singleton that outlives any one engine, so a stale pointer here + would have PublishClock writing into freed memory. } + property HostEnvironment: TGocciaHostEnvironment read FHostEnvironment + write FHostEnvironment; + { The realm this queue's timers belong to, as an opaque handle compared by + identity against Goccia.Realm's current realm. A ShadowRealm child runs on + the same thread and shares this singleton, so without the check the + child's `await` would run the parent realm's callbacks with the child's + realm installed — parent code observing child intrinsics. } + property OwnerRealm: TObject read FOwnerRealm write FOwnerRealm; + + property Faking: Boolean read FFaking; + property MockedDateOnly: Boolean read FMockedDateOnly; + property MockedDate: Double read FMockedDate; + property NowMilliseconds: Double read FNow; + end; + +{ The loop-limit message the fake clock produces, built from the constant. } +function TimerLoopLimitMessage: string; + +{ Runs the next due real-mode timer on this thread's queue, if there is one. + False when nothing ran — no queue yet, fake timers on, nothing pending, or the + queue belongs to a realm other than the one currently executing. This is the + seam the engine's promise waits call: a real-mode timer is a continuation no + amount of microtask draining will produce. } +function RunOneRealTimer: Boolean; + +{ True when this thread's queue still holds real-mode timers the current realm + may run. Lets a caller tell "the bound stopped me" from "there was nothing + left", which is the difference between a timer diagnosis and an ordinary + unsettled promise. } +function HasRunnableRealTimers: Boolean; + +{ Drops every timer on this thread's queue. Between-test isolation: a drain the + runner abandoned must not leave callbacks that fire inside the next test. } +procedure DiscardRealTimers; + +{ Bounded real-mode drain the host runs where a program would otherwise be + finished. Returns False when the bound stopped it with timers still runnable. } +function DrainRealTimersForHost: Boolean; + +{ An exception a real-mode timer threw, for the host to attribute. } +function TakeUncaughtTimerError(out AValue: TGocciaValue): Boolean; + +{ Reports a wait that spent its whole timer budget with timers still runnable. + Lives here so callers that cannot reach the error helpers — the fetch manager + compiles on a lane without them — can still name the real cause instead of + letting it surface as an ordinary unsettled promise. } +procedure RaiseRealTimerLoopLimit; + +implementation + +uses + Math, + SysUtils, + + Goccia.Arguments.Collection, + Goccia.CapabilityAudit, + Goccia.Constants.ErrorNames, + Goccia.EngineFault, + Goccia.Error, + Goccia.InstructionLimit, + Goccia.MemoryLimit, + Goccia.MicrotaskQueue, + Goccia.Realm, + Goccia.ThreadCleanupRegistry, + Goccia.Timeout, + Goccia.Values.Error, + Goccia.Values.ErrorHelper, + Goccia.Values.FunctionBase, + Goccia.VM.Exception; + +const + NANOSECONDS_PER_MILLISECOND = 1000000.0; + +threadvar + TimerQueueThreadInstance: TGocciaTimerQueue; + +{ TGocciaTimerRoots } + +procedure TGocciaTimerRoots.MarkRootReferences; + + procedure MarkTimer(const ATimer: TGocciaTimerEntry); + var + I: Integer; + begin + if not Assigned(ATimer) then + Exit; + if Assigned(ATimer.Callback) then + ATimer.Callback.MarkReferences; + for I := Low(ATimer.Args) to High(ATimer.Args) do + if Assigned(ATimer.Args[I]) then + ATimer.Args[I].MarkReferences; + if Assigned(ATimer.Context) then + ATimer.Context.MarkReferences; + end; + +var + I: Integer; +begin + if not Assigned(FQueue) then + Exit; + for I := 0 to FQueue.FTimers.Count - 1 do + MarkTimer(FQueue.FTimers[I]); + for I := 0 to FQueue.FInFlight.Count - 1 do + MarkTimer(FQueue.FInFlight[I]); + { Every enclosing advance's recorded exception, not just the innermost one: + an outer tick's value has to survive the whole re-entrant window its + microtask drain opened. } + if FQueue.FThrow.HasValue and Assigned(FQueue.FThrow.Value) then + FQueue.FThrow.Value.MarkReferences; + for I := 0 to FQueue.FSavedThrowCount - 1 do + if FQueue.FSavedThrows[I].HasValue and + Assigned(FQueue.FSavedThrows[I].Value) then + FQueue.FSavedThrows[I].Value.MarkReferences; + if Assigned(FQueue.FRaisedThrow) then + FQueue.FRaisedThrow.MarkReferences; + if Assigned(FQueue.FUncaughtError) then + FQueue.FUncaughtError.MarkReferences; +end; + +{ TGocciaTimerQueue } + +class function TGocciaTimerQueue.Instance: TGocciaTimerQueue; +begin + Result := TimerQueueThreadInstance; +end; + +class procedure TGocciaTimerQueue.Initialize; +begin + if not Assigned(TimerQueueThreadInstance) then + TimerQueueThreadInstance := TGocciaTimerQueue.Create; +end; + +class procedure TGocciaTimerQueue.Shutdown; +begin + FreeAndNil(TimerQueueThreadInstance); +end; + +constructor TGocciaTimerQueue.Create; +begin + inherited Create; + FTimers := TGocciaTimerEntryList.Create(True); + FInFlight := TGocciaTimerEntryList.Create(True); + FNextId := 1; + FNow := 0; + FStart := 0; + FAdjusted := 0; + FNanos := 0; +end; + +destructor TGocciaTimerQueue.Destroy; +begin + { FHostEnvironment is deliberately not touched. The queue is a thread + singleton and outlives the engines that point it at their host environment, + so by teardown the pointer may name a freed one; clearing the override is + the detaching extension's job, while its engine is still alive. } + FreeAndNil(FRoots); + FInFlight.Free; + FTimers.Free; + inherited; +end; + +{ The root source registers with whichever collector was current when it was + built, so a thread whose collector was replaced between engines needs a fresh + one — the same rule the async-context roots follow. + + Two properties here are load-bearing. + + The identity test asks the source which collector it is *registered with*. + Comparing against a separately remembered collector pointer is unsound: a + Shutdown/Initialize pair can put the next thread-local collector at the + address the previous one had, and the source then stays registered with the + dead collector while this believes it is current. The collector's destructor + nils the registration on every source it owns, so this test cannot match a + destroyed one. + + And every caller must reach this BEFORE the value it wants published becomes + reachable only from the queue. AddTimer calls it first, while the callback and + arguments are still held by the caller's argument collection, so there is no + window in which an entry is in FTimers with no root source to mark it. } +procedure TGocciaTimerQueue.EnsureRoots; +var + Collector: TGarbageCollector; +begin + Collector := TGarbageCollector.Instance; + if Assigned(Collector) and Assigned(FRoots) and + (FRoots.RegisteredCollector = Collector) then + Exit; + + FreeAndNil(FRoots); + if not Assigned(Collector) then + Exit; + + FRoots := TGocciaTimerRoots.Create; + FRoots.FQueue := Self; +end; + +{ The mocked clock reaches JavaScript through the engine's host environment, + which is what Date, Temporal.Now and performance already read. Overriding + there rather than patching a global keeps every reader consistent and leaves + the Date shim untouched. } +procedure TGocciaTimerQueue.PublishClock; +begin + if not Assigned(FHostEnvironment) then + Exit; + + if FFaking then + { Monotonic time excludes whatever setSystemTime jumped the wall clock by, + so performance.now() measures elapsed virtual time rather than the + simulated date. } + FHostEnvironment.OverrideClock( + True, Round(FNow * NANOSECONDS_PER_MILLISECOND), + True, Round((FNow - FAdjusted - FStart) * NANOSECONDS_PER_MILLISECOND)) + else if FMockedDateOnly then + { setSystemTime outside useFakeTimers freezes the date and nothing else, + exactly as Vitest's Date-only mock does. } + FHostEnvironment.OverrideClock( + True, Round(FMockedDate * NANOSECONDS_PER_MILLISECOND), False, 0) + else + FHostEnvironment.ClearClockOverride; +end; + +procedure TGocciaTimerQueue.SetNow(const AValue: Double); +begin + FNow := AValue; + PublishClock; +end; + +function TGocciaTimerQueue.RealEpochMilliseconds: Double; +begin + if Assigned(FHostEnvironment) then + Result := FHostEnvironment.RealEpochNanoseconds / NANOSECONDS_PER_MILLISECOND + else + Result := 0; + Result := Int(Result); +end; + +function TGocciaTimerQueue.IndexOfId(const AId: Double): Integer; +var + I: Integer; +begin + for I := 0 to FTimers.Count - 1 do + if FTimers[I].Id = AId then + Exit(I); + Result := -1; +end; + +procedure RequireFiniteEpoch(const AValue: Double); +begin + if IsNan(AValue) or IsInfinite(AValue) then + ThrowTypeError(NON_FINITE_SYSTEM_TIME_MESSAGE, + NON_FINITE_SYSTEM_TIME_SUGGESTION); +end; + +function TimerLoopLimitMessage: string; +begin + Result := Format('Aborting after running %d timers, assuming an infinite ' + + 'loop!', [TIMER_LOOP_LIMIT]); +end; + +{ The fake clock's ordering: due time, then registration order, then id. Two + timers due at the same instant therefore fire in the order they were + scheduled, and setSystemTime — which shifts every due time and creation time + by the same amount — cannot reorder them. } +function TGocciaTimerQueue.IsEarlier( + const ALeft, ARight: TGocciaTimerEntry): Boolean; +begin + if ALeft.CallAt <> ARight.CallAt then + Exit(ALeft.CallAt < ARight.CallAt); + if ALeft.CreatedAt <> ARight.CreatedAt then + Exit(ALeft.CreatedAt < ARight.CreatedAt); + Result := ALeft.Id < ARight.Id; +end; + +{ A due time that is not a finite number can never select an entry. The + in-flight check is deliberately NOT here: re-entering a running timer is + something the fake clock permits, and a suite that calls an advance member + from inside a timer callback gets that nesting under Vitest too (probed: an + interval whose callback advances the clock re-enters itself three deep). Only + the real-mode selector excludes it, because only that one picks the earliest + timer with no window to bound the nesting. } +function TGocciaTimerQueue.IsDispatchable( + const ATimer: TGocciaTimerEntry): Boolean; +begin + Result := Assigned(ATimer) and + (not IsNan(ATimer.CallAt)) and (not IsInfinite(ATimer.CallAt)); +end; + +{ The range test is written as an explicit "inside" rather than as a negated + "outside". Every comparison against a NaN bound is false, so the negated form + skipped nothing and reported an arbitrary timer as due — which is how a NaN + clock turned into an unbounded DoTick recursion. Non-finite bounds now select + nothing at all. } +function TGocciaTimerQueue.FirstTimerInRange( + const AFrom, ATo: Double): TGocciaTimerEntry; +var + Candidate: TGocciaTimerEntry; + I: Integer; +begin + Result := nil; + if IsNan(AFrom) or IsNan(ATo) or IsInfinite(AFrom) or IsInfinite(ATo) then + Exit; + for I := 0 to FTimers.Count - 1 do + begin + Candidate := FTimers[I]; + if not IsDispatchable(Candidate) then + Continue; + if not ((Candidate.CallAt >= AFrom) and (Candidate.CallAt <= ATo)) then + Continue; + if (Result = nil) or IsEarlier(Candidate, Result) then + Result := Candidate; + end; +end; + +function TGocciaTimerQueue.FirstTimer: TGocciaTimerEntry; +var + I: Integer; +begin + Result := nil; + for I := 0 to FTimers.Count - 1 do + if IsDispatchable(FTimers[I]) and + ((Result = nil) or IsEarlier(FTimers[I], Result)) then + Result := FTimers[I]; +end; + +{ The real-mode selector, and the only one that skips a timer already on the + stack. + + Real mode has no window to pick within: it takes the earliest timer whatever + its due time, because the whole point is to jump the clock to it. An interval + stays in the queue while its callback runs, so the moment that callback + reached an engine wait — an `await`, or a microtask drain that led to one — + this selector handed back the very same entry and the callback re-entered + itself, again and again, until the stack ran out. The fake-clock paths need no + such check: their range or their explicit advance bounds the nesting. + + ATimeoutsOnly additionally skips intervals, for the end-of-run drain: an + uncleared interval is by construction never exhausted, so running it there + would spend the whole budget and finish no sooner than skipping it. } +function TGocciaTimerQueue.FirstRealTimer( + const ATimeoutsOnly: Boolean): TGocciaTimerEntry; +var + I: Integer; +begin + Result := nil; + for I := 0 to FTimers.Count - 1 do + begin + if FTimers[I].Dispatching then + Continue; + if ATimeoutsOnly and (FTimers[I].Kind <> gtkTimeout) then + Continue; + if not IsDispatchable(FTimers[I]) then + Continue; + if (Result = nil) or IsEarlier(FTimers[I], Result) then + Result := FTimers[I]; + end; +end; + +function TGocciaTimerQueue.LastTimer: TGocciaTimerEntry; +var + I: Integer; +begin + Result := nil; + for I := 0 to FTimers.Count - 1 do + if IsDispatchable(FTimers[I]) and + ((Result = nil) or IsEarlier(Result, FTimers[I])) then + Result := FTimers[I]; +end; + +{ The truncation is not a shortcut — it is what the fake clock does. It computes + a due time with `parseInt(delay)`, which stringifies and cuts at the decimal + point, so a fractional delay loses its fraction. Probed rather than assumed, + because the opposite is the obvious guess: under Vitest 4.1.10 + `setTimeout(fn, 1.5)` followed by `advanceTimersByTime(1)` fires the timer, + and a delay of 0.4 is due immediately. Fractions survive on the *advance* + side instead, through FNanos. } +function NormalizedDelay(const AValue: Double): Double; +begin + if IsNan(AValue) or IsInfinite(AValue) then + Exit(0); + Result := Int(AValue); + if Result > TIMER_MAX_DELAY then + Result := 1; + if Result < 0 then + Result := 0; +end; + +function TGocciaTimerQueue.AddTimer(const AKind: TGocciaTimerKind; + const ACallback: TGocciaValue; const AArgs: TArray; + const ADelay: Double): Double; +var + Entry: TGocciaTimerEntry; +begin + EnsureRoots; + + Entry := TGocciaTimerEntry.Create; + Entry.Id := FNextId; + FNextId := FNextId + 1; + Entry.Kind := AKind; + Entry.Callback := ACallback; + Entry.Args := AArgs; + Entry.Delay := NormalizedDelay(ADelay); + if AKind = gtkInterval then + Entry.IntervalDelay := Entry.Delay + else + Entry.IntervalDelay := 0; + Entry.CreatedAt := FNow; + { A zero delay means "the next turn". Scheduled from inside a running timer + that is one virtual millisecond later, which is what keeps a chain of + zero-delay timers from all collapsing onto the instant the chain started + and looping forever inside one advance. } + if Entry.Delay <> 0 then + Entry.CallAt := FNow + Entry.Delay + else if FDuringTick then + Entry.CallAt := FNow + 1 + else + Entry.CallAt := FNow; + Entry.Context := CurrentAsyncContext; + + FTimers.Add(Entry); + Result := Entry.Id; +end; + +procedure TGocciaTimerQueue.ClearTimer(const AId: Double); +var + Index: Integer; +begin + { A falsy id is ignored rather than reported: clearTimeout(undefined) is + common in cleanup paths and does nothing everywhere else either. } + if IsNan(AId) or (AId = 0) then + Exit; + Index := IndexOfId(AId); + if Index >= 0 then + RetireEntry(Index); +end; + +{ Removes the entry at AIndex from the pending list, freeing it — unless its own + callback is on the stack, in which case it is moved to the in-flight list and + marked, so the dispatcher's frame keeps a live object to return through. A + callback that cancels its own interval once it has seen enough is by far the + common case, and it used to free the entry the dispatcher was about to touch + on the way out. } +procedure TGocciaTimerQueue.RetireEntry(const AIndex: Integer); +var + Entry: TGocciaTimerEntry; +begin + Entry := FTimers[AIndex]; + if not Entry.Dispatching then + begin + FTimers.Delete(AIndex); + Exit; + end; + Entry.Cleared := True; + FInFlight.Add(FTimers.Extract(Entry)); +end; + +{ Empties the pending list without freeing anything a dispatcher still holds. } +procedure TGocciaTimerQueue.RetireAllEntries; +var + I: Integer; +begin + for I := FTimers.Count - 1 downto 0 do + RetireEntry(I); +end; + +{ Drops the queue and rewinds the clock to the instant fake timers were + installed — both halves, because the fake clock's reset does both and a suite + can see either. Outside fake timers it does nothing rather than reporting an + error, which is also what Vitest does. } +procedure TGocciaTimerQueue.ClearAllTimers; +begin + if not FFaking then + Exit; + RetireAllEntries; + FNanos := 0; + SetNow(FStart); +end; + +procedure TGocciaTimerQueue.DiscardTimers; +begin + RetireAllEntries; + FUncaughtError := nil; + FHasUncaughtError := False; +end; + +function TGocciaTimerQueue.CountTimers: Integer; +begin + Result := FTimers.Count; +end; + +function TGocciaTimerQueue.HasPendingTimers: Boolean; +begin + Result := FTimers.Count > 0; +end; + +procedure TGocciaTimerQueue.DrainMicrotasks; +var + Queue: TGocciaMicrotaskQueue; +begin + Queue := TGocciaMicrotaskQueue.Instance; + if Assigned(Queue) and Queue.HasPending then + Queue.DrainQueue; +end; + +{ A *tick* records the first exception it produced, keeps running the remaining + timers, and rethrows once it is over — a suite can observe both halves, so + both are reproduced. Stepping to a single timer does not: the fake clock's + `next` has no handler of its own, which is why `runAllTimers` and + `advanceTimersToNextTimer` stop at the throwing timer and leave the rest + pending. Probed for each member separately, because the three do not agree. } +procedure TGocciaTimerQueue.CapturePendingThrow(const AValue: TGocciaValue); +begin + if FThrow.HasValue then + Exit; + FThrow.Value := AValue; + FThrow.HasValue := True; +end; + +{ Opens a fresh slot for one advance operation and saves the enclosing one. + Returns the depth to hand back to TakeThrowSlot. } +function TGocciaTimerQueue.PushThrowSlot: Integer; +begin + { The value of an exception raised by the previous advance is released here + rather than at the raise itself: by now that exception has been caught or + has left the engine, and nothing else roots what it carried. } + FRaisedThrow := nil; + + if FSavedThrowCount >= Length(FSavedThrows) then + SetLength(FSavedThrows, FSavedThrowCount * 2 + 8); + Result := FSavedThrowCount; + FSavedThrows[Result] := FThrow; + Inc(FSavedThrowCount); + FThrow.Value := nil; + FThrow.HasValue := False; +end; + +{ Closes the slot AToken opened, restoring the enclosing one, and reports + whatever this operation recorded. The token is a depth rather than a pop + count, so an operation unwound by an exception cannot pop past its caller. } +function TGocciaTimerQueue.TakeThrowSlot(const AToken: Integer; + out AValue: TGocciaValue): Boolean; +var + I: Integer; +begin + AValue := nil; + Result := False; + if (AToken < 0) or (AToken >= FSavedThrowCount) then + Exit; + + Result := FThrow.HasValue; + AValue := FThrow.Value; + + FThrow := FSavedThrows[AToken]; + for I := AToken to FSavedThrowCount - 1 do + begin + FSavedThrows[I].Value := nil; + FSavedThrows[I].HasValue := False; + end; + FSavedThrowCount := AToken; +end; + +{ The value stays in a marked field across the raise. A TGocciaThrowValue does + not root what it carries, and unwinding runs arbitrary `finally` blocks that + can allocate — so releasing the field first left the in-flight value + collectable. It is dropped at the next PushThrowSlot instead. } +procedure TGocciaTimerQueue.RaiseThrown(const AValue: TGocciaValue); +begin + FRaisedThrow := AValue; + raise TGocciaThrowValue.Create(AValue); +end; + +procedure TGocciaTimerQueue.CallTimer(const ATimer: TGocciaTimerEntry; + const ACaptureThrows: Boolean); +var + CallArgs: TGocciaArgumentsCollection; + ContextToken: Integer; + I, Index: Integer; + Owned: TGocciaTimerEntry; +begin + Owned := nil; + if ATimer.Kind = gtkInterval then + { An interval reschedules from its previous due time, not from now, so a + long-running callback cannot make the interval drift. } + ATimer.CallAt := ATimer.CallAt + ATimer.IntervalDelay + else + begin + Index := IndexOfId(ATimer.Id); + if Index >= 0 then + begin + Owned := FTimers.Extract(FTimers[Index]); + { Onto the in-flight stack, not into a single slot: a nested advance can + put another timeout in flight, and a slot would leave the outer one + unmarked for the rest of its own callback. } + FInFlight.Add(Owned); + end; + end; + + ATimer.Dispatching := True; + try + ContextToken := EnterAsyncContext(ATimer.Context); + try + if Assigned(ATimer.Callback) and ATimer.Callback.IsCallable then + begin + CallArgs := TGocciaArgumentsCollection.Create; + try + for I := Low(ATimer.Args) to High(ATimer.Args) do + CallArgs.Add(ATimer.Args[I]); + if ACaptureThrows then + begin + try + DispatchCall(ATimer.Callback, CallArgs, + TGocciaUndefinedLiteralValue.UndefinedValue); + except + on E: EGocciaBytecodeThrow do + CapturePendingThrow(E.ThrownValue); + on E: TGocciaThrowValue do + CapturePendingThrow(E.Value); + on E: TGocciaTimeoutError do + raise; + on E: TGocciaInstructionLimitError do + raise; + on E: TGocciaMemoryLimitError do + raise; + on E: EGocciaCapabilityAuditDeliveryError do + raise; + on E: TGocciaTypeError do + CapturePendingThrow( + CreateErrorObject(TYPE_ERROR_NAME, E.Message)); + on E: TGocciaReferenceError do + CapturePendingThrow( + CreateErrorObject(REFERENCE_ERROR_NAME, E.Message)); + on E: TGocciaSyntaxError do + CapturePendingThrow( + CreateErrorObject(SYNTAX_ERROR_NAME, E.Message)); + on E: Exception do + begin + if IsEngineIntegrityFault(E) then + raise; + CapturePendingThrow(CreateErrorObject(ERROR_NAME, E.Message)); + end; + end; + end + else + DispatchCall(ATimer.Callback, CallArgs, + TGocciaUndefinedLiteralValue.UndefinedValue); + finally + CallArgs.Free; + end; + end; + finally + LeaveAsyncContext(ContextToken); + end; + finally + ATimer.Dispatching := False; + { Frees it: the in-flight list owns its entries. A timeout always lands + here; an interval only when its own callback cancelled it. } + if Assigned(Owned) then + FInFlight.Remove(Owned) + else if ATimer.Cleared then + FInFlight.Remove(ATimer); + end; +end; + +{ Mirrors @sinonjs/fake-timers' doTick. The lagging `Previous` bound is what + makes a timer scheduled during the tick eligible on the following iteration, + and the trailing re-check is what picks up timers a callback scheduled + strictly inside the remaining range. } +function TGocciaTimerQueue.DoTick(const AMilliseconds: Double; + const AAsync: Boolean): Double; +var + Fired: Integer; + NanosTotal, OldNow, Previous, TickFrom, TickTo: Double; + Timer: TGocciaTimerEntry; + WasDuringTick: Boolean; +begin + { Ordered as the fake clock orders it: the non-finite refusal first, because + `NaN < 0` is false and a NaN would otherwise reach the arithmetic below and + turn every range test and the trailing re-check into nonsense. } + if IsNan(AMilliseconds) or IsInfinite(AMilliseconds) then + ThrowTypeError(NON_FINITE_SYSTEM_TIME_MESSAGE, + 'Advance the timers by a finite number of milliseconds.'); + if AMilliseconds < 0 then + ThrowTypeError(NEGATIVE_TICKS_MESSAGE); + if IsNan(FNow) or IsInfinite(FNow) then + ThrowTypeError(NON_FINITE_SYSTEM_TIME_MESSAGE, + NON_FINITE_SYSTEM_TIME_SUGGESTION); + + { Whole milliseconds move the clock; the fraction is banked. Two advances of + half a millisecond therefore move it by one and fire a timer due there, + which a per-advance truncation would never do. } + NanosTotal := FNanos + Round(Frac(AMilliseconds) * NANOSECONDS_PER_MILLISECOND); + TickTo := FNow + Int(AMilliseconds); + if NanosTotal >= NANOSECONDS_PER_MILLISECOND then + begin + TickTo := TickTo + 1; + NanosTotal := NanosTotal - NANOSECONDS_PER_MILLISECOND; + end; + FNanos := NanosTotal; + + TickFrom := FNow; + Previous := FNow; + Fired := 0; + WasDuringTick := FDuringTick; + FDuringTick := True; + try + if AAsync then + DrainMicrotasks; + + Timer := FirstTimerInRange(TickFrom, TickTo); + while Assigned(Timer) and (TickFrom <= TickTo) do + begin + CheckExecutionTimeout; + CheckInstructionLimit; + Inc(Fired); + if Fired > TIMER_TICK_LOOP_LIMIT then + ThrowError(Format(TICK_LOOP_LIMIT_MESSAGE, [TIMER_TICK_LOOP_LIMIT])); + + TickFrom := Timer.CallAt; + SetNow(Timer.CallAt); + OldNow := FNow; + CallTimer(Timer, True); + if AAsync then + DrainMicrotasks; + + { A setSystemTime inside the callback moved the wall clock under us; the + window this tick is walking moves with it so the remaining timers, whose + due times were shifted by the same amount, stay in range. } + if OldNow <> FNow then + begin + TickFrom := TickFrom + (FNow - OldNow); + TickTo := TickTo + (FNow - OldNow); + Previous := Previous + (FNow - OldNow); + end; + + Timer := FirstTimerInRange(Previous, TickTo); + Previous := TickFrom; + end; + finally + FDuringTick := WasDuringTick; + end; + + { Timers a callback scheduled strictly inside the remaining range still have + to run. The recursion terminates because it can only be entered with a timer + due at or before TickTo, and each pass either fires one or moves the clock + to TickTo. } + Timer := FirstTimerInRange(TickFrom, TickTo); + if Assigned(Timer) then + DoTick(TickTo - FNow, AAsync) + else + SetNow(TickTo); + + Result := FNow; +end; + +function TGocciaTimerQueue.Tick(const AMilliseconds: Double; + const AAsync: Boolean): Double; +var + Thrown: TGocciaValue; + HasThrown: Boolean; + Token: Integer; +begin + Token := PushThrowSlot; + try + Result := DoTick(AMilliseconds, AAsync); + finally + { Runs on the exception path too, so an operation cut short by a timeout or + an instruction limit still restores its caller's slot instead of leaving + its own recorded value to surface at an unrelated advance. That hard fault + wins: the recorded value is simply dropped. } + HasThrown := TakeThrowSlot(Token, Thrown); + end; + if HasThrown then + RaiseThrown(Thrown); +end; + +function TGocciaTimerQueue.DoNext(const AAsync, + ACaptureThrows: Boolean): Double; +var + Timer: TGocciaTimerEntry; + WasDuringTick: Boolean; +begin + Timer := FirstTimer; + if not Assigned(Timer) then + Exit(FNow); + + WasDuringTick := FDuringTick; + FDuringTick := True; + try + SetNow(Timer.CallAt); + CallTimer(Timer, ACaptureThrows); + if AAsync then + DrainMicrotasks; + finally + FDuringTick := WasDuringTick; + end; + Result := FNow; +end; + +{ Vitest follows the clock's own `next` with a zero-length tick so that every + timer due at the instant it landed on fires, not just the first one. The step + itself lets an exception out, so a throwing timer leaves the rest of that + instant pending. } +function TGocciaTimerQueue.AdvanceToNextTimer(const AAsync: Boolean): Double; +var + Thrown: TGocciaValue; + HasThrown: Boolean; + Token: Integer; +begin + Token := PushThrowSlot; + try + DoNext(AAsync, False); + DoTick(0, AAsync); + finally + HasThrown := TakeThrowSlot(Token, Thrown); + end; + if HasThrown then + RaiseThrown(Thrown); + Result := FNow; +end; + +{ Steps one timer at a time, so a throwing callback aborts the run and leaves + everything behind it pending — the fake clock's runAll has no handler of its + own either. } +function TGocciaTimerQueue.RunAllTimers(const AAsync: Boolean): Double; +var + I: Integer; +begin + for I := 0 to TIMER_LOOP_LIMIT - 1 do + begin + if not Assigned(FirstTimer) then + Exit(FNow); + CheckExecutionTimeout; + CheckInstructionLimit; + DoNext(AAsync, False); + end; + ThrowError(TimerLoopLimitMessage); + Result := FNow; +end; + +{ "Only pending" means the timers that exist when the call is made: the clock + advances to the latest of their due times, which fires anything that becomes + due on the way — including a timer one of them scheduled inside that window — + and leaves anything scheduled beyond it pending. } +function TGocciaTimerQueue.RunPendingTimers(const AAsync: Boolean): Double; +var + Timer: TGocciaTimerEntry; +begin + Timer := LastTimer; + if not Assigned(Timer) then + Exit(FNow); + Result := Tick(Timer.CallAt - FNow, AAsync); +end; + +{ One real-mode step. + + The exception a callback throws does NOT come out here. This is reached from + an engine wait — an `await`, or the runner draining a test's returned promise + — and in Node a timer callback that throws is an uncaught top-level error + while the frame that happened to be waiting carries on untouched. Raising it + at the wait made it catchable by an unrelated `try` around the `await`, and + left the awaited promise pending on top of that. It is parked instead, for the + host to attribute and report. } +function TGocciaTimerQueue.RunOneRealTimerOfKind( + const ATimeoutsOnly: Boolean): Boolean; +var + Thrown: TGocciaValue; + HasThrown: Boolean; + Timer: TGocciaTimerEntry; + Token: Integer; + WasDuringTick: Boolean; +begin + Result := False; + if FFaking then + Exit; + Timer := FirstRealTimer(ATimeoutsOnly); + if not Assigned(Timer) then + Exit; + + Token := PushThrowSlot; + try + WasDuringTick := FDuringTick; + FDuringTick := True; + try + SetNow(Timer.CallAt); + CallTimer(Timer, True); + DrainMicrotasks; + finally + FDuringTick := WasDuringTick; + end; + finally + HasThrown := TakeThrowSlot(Token, Thrown); + end; + + if HasThrown and not FHasUncaughtError then + begin + FUncaughtError := Thrown; + FHasUncaughtError := True; + end; + Result := True; +end; + +function TGocciaTimerQueue.RunOneRealTimer: Boolean; +begin + Result := RunOneRealTimerOfKind(False); +end; + +procedure TGocciaTimerQueue.DrainRealTimers; +var + I: Integer; +begin + if FFaking then + Exit; + for I := 0 to TIMER_LOOP_LIMIT - 1 do + begin + CheckExecutionTimeout; + CheckInstructionLimit; + if not RunOneRealTimerOfKind(True) then + Exit; + end; +end; + +function TGocciaTimerQueue.TakeUncaughtError( + out AValue: TGocciaValue): Boolean; +begin + AValue := FUncaughtError; + Result := FHasUncaughtError; + FUncaughtError := nil; + FHasUncaughtError := False; +end; + +function TGocciaTimerQueue.HasUncaughtError: Boolean; +begin + Result := FHasUncaughtError; +end; + +procedure TGocciaTimerQueue.BeginFakeTimers(const ANowMilliseconds: Double); +begin + RequireFiniteEpoch(ANowMilliseconds); + EnsureRoots; + { Re-enabling installs a fresh clock: whatever was scheduled against the + previous one is discarded rather than carried over, which is what a second + useFakeTimers() does in Vitest. } + RetireAllEntries; + FFaking := True; + FMockedDateOnly := False; + FNow := ANowMilliseconds; + FStart := ANowMilliseconds; + FAdjusted := 0; + FNanos := 0; + PublishClock; +end; + +procedure TGocciaTimerQueue.ResetForEngine; +var + I: Integer; +begin + EndFakeTimers; + FInFlight.Clear; + FThrow.Value := nil; + FThrow.HasValue := False; + for I := 0 to FSavedThrowCount - 1 do + begin + FSavedThrows[I].Value := nil; + FSavedThrows[I].HasValue := False; + end; + FSavedThrowCount := 0; + FRaisedThrow := nil; + FUncaughtError := nil; + FHasUncaughtError := False; + FNextId := 1; +end; + +procedure TGocciaTimerQueue.EndFakeTimers; +begin + RetireAllEntries; + FFaking := False; + FMockedDateOnly := False; + FNow := 0; + FStart := 0; + FAdjusted := 0; + FNanos := 0; + PublishClock; +end; + +{ The finite check lives here rather than only at the JavaScript boundary + because there are two boundaries: the Vitest shim's `vi.setSystemTime`, and + `goccia:timers`' own `setSystemTime`, which converts its argument and calls + straight through. Guarding only the shim left the second one able to install a + NaN clock — after which every due-time comparison is false, the range test + selects arbitrarily, and the trailing re-check in DoTick recurses on NaN until + the stack is gone. } +procedure TGocciaTimerQueue.SetSystemTime(const AEpochMilliseconds: Double); +var + Difference: Double; + I: Integer; +begin + RequireFiniteEpoch(AEpochMilliseconds); + + if not FFaking then + begin + FMockedDateOnly := True; + FMockedDate := AEpochMilliseconds; + PublishClock; + Exit; + end; + + Difference := AEpochMilliseconds - FNow; + FAdjusted := FAdjusted + Difference; + FNow := AEpochMilliseconds; + { Every pending timer keeps the delay it was scheduled with: moving the wall + clock is not the same as letting time pass. } + for I := 0 to FTimers.Count - 1 do + begin + FTimers[I].CreatedAt := FTimers[I].CreatedAt + Difference; + FTimers[I].CallAt := FTimers[I].CallAt + Difference; + end; + PublishClock; +end; + +procedure TGocciaTimerQueue.ClearMockedDate; +begin + FMockedDateOnly := False; + PublishClock; +end; + +{ The queue this thread holds, but only when the realm currently executing is + the one whose timers it carries. + + A ShadowRealm child engine runs on the same thread and shares this singleton. + Without the check, an `await` inside the child drained the PARENT realm's + timer callbacks with the child's realm installed as current, so parent code + ran against child intrinsics — a realm-isolation break, and one that no error + reports. A queue with no owner recorded is one no timer extension attached to; + it has no timers either, so the guard costs nothing there. } +function OwningQueueForCurrentRealm: TGocciaTimerQueue; +begin + Result := TGocciaTimerQueue.Instance; + if not Assigned(Result) then + Exit; + if Result.OwnerRealm <> TObject(CurrentRealm) then + Result := nil; +end; + +function RunOneRealTimer: Boolean; +var + Queue: TGocciaTimerQueue; +begin + Queue := OwningQueueForCurrentRealm; + Result := Assigned(Queue) and Queue.RunOneRealTimer; +end; + +function HasRunnableRealTimers: Boolean; +var + Queue: TGocciaTimerQueue; +begin + Queue := OwningQueueForCurrentRealm; + Result := Assigned(Queue) and (not Queue.Faking) and + Assigned(Queue.FirstRealTimer(False)); +end; + +procedure DiscardRealTimers; +var + Queue: TGocciaTimerQueue; +begin + Queue := TGocciaTimerQueue.Instance; + { Fake timers are left alone. A suite that installs a clock in beforeEach and + schedules against it across a test owns that queue, and Vitest does not + reset it between tests either — only real-mode leftovers, which nothing is + waiting on, are dropped. } + if Assigned(Queue) and (not Queue.Faking) then + Queue.DiscardTimers; +end; + +function DrainRealTimersForHost: Boolean; +var + Queue: TGocciaTimerQueue; +begin + Queue := OwningQueueForCurrentRealm; + if not Assigned(Queue) then + Exit(True); + Queue.DrainRealTimers; + Result := not Assigned(Queue.FirstRealTimer(True)); +end; + +function TakeUncaughtTimerError(out AValue: TGocciaValue): Boolean; +var + Queue: TGocciaTimerQueue; +begin + AValue := nil; + Queue := TGocciaTimerQueue.Instance; + Result := Assigned(Queue) and Queue.TakeUncaughtError(AValue); +end; + +procedure RaiseRealTimerLoopLimit; +begin + ThrowError(Format(REAL_TIMER_LOOP_LIMIT_MESSAGE, [TIMER_LOOP_LIMIT])); +end; + +procedure CleanupTimerQueueThreadState; +begin + TGocciaTimerQueue.Shutdown; +end; + +initialization + RegisterThreadvarCleanup(CleanupTimerQueueThreadState); + +end. diff --git a/source/units/Goccia.Values.Await.pas b/source/units/Goccia.Values.Await.pas index ef85efa89..5060f87c4 100644 --- a/source/units/Goccia.Values.Await.pas +++ b/source/units/Goccia.Values.Await.pas @@ -26,6 +26,7 @@ implementation Goccia.MemoryLimit, Goccia.MicrotaskQueue, Goccia.Timeout, + Goccia.Timers, Goccia.Values.Error, Goccia.Values.ErrorHelper, Goccia.Values.PromiseValue, @@ -51,6 +52,41 @@ procedure DrainMicrotasksUntilPromiseSettled( Queue.DrainOneJob; end; +{ A real-mode timer is the one continuation an await can be waiting on that no + amount of microtask draining will produce. GocciaScript has no host event + loop to hand control back to, so the awaiting frame runs the timer queue + itself: the virtual clock jumps to the next due timer, the timer fires, its + microtasks drain, and the loop asks again. No real time passes. + + Three things it deliberately does not do. Under fake timers it runs nothing — + a suite that turned the clock over to `vi` decides when timers run, and an + await that silently advanced it would take that decision away. It runs + nothing for a queue belonging to another realm, so a ShadowRealm child's await + cannot execute its parent's callbacks. And an exception a callback throws does + not surface here: it is parked for the host, because in Node the awaiting + frame is not the one that sees it. } +procedure RunTimersUntilPromiseSettled(const APromise: TGocciaPromiseValue); +var + Iterations: Integer; +begin + Iterations := 0; + while Assigned(APromise) and (APromise.State = gpsPending) and + (Iterations < TIMER_LOOP_LIMIT) do + begin + CheckExecutionTimeout; + CheckInstructionLimit; + if not RunOneRealTimer then + Exit; + Inc(Iterations); + DrainMicrotasksUntilPromiseSettled(APromise); + end; + { The budget ran out with timers still runnable: name that rather than let it + reach the caller as an ordinary unsettled promise. } + if Assigned(APromise) and (APromise.State = gpsPending) and + (Iterations >= TIMER_LOOP_LIMIT) and HasRunnableRealTimers then + RaiseRealTimerLoopLimit; +end; + procedure RejectPromiseWithException(const APromise: TGocciaPromiseValue; const AException: Exception); begin @@ -133,6 +169,8 @@ function AwaitValue(const AValue: TGocciaValue): TGocciaValue; WaitForAtomicsPromise(Promise); if Promise.State = gpsPending then DrainMicrotasksUntilPromiseSettled(Promise); + if Promise.State = gpsPending then + RunTimersUntilPromiseSettled(Promise); if Promise.State = gpsFulfilled then Result := Promise.PromiseResult diff --git a/tests/built-ins/ShadowRealm/timer-realm-isolation.js b/tests/built-ins/ShadowRealm/timer-realm-isolation.js new file mode 100644 index 000000000..f08af02e4 --- /dev/null +++ b/tests/built-ins/ShadowRealm/timer-realm-isolation.js @@ -0,0 +1,73 @@ +/*--- +description: a child realm's synchronous wait does not run the parent realm's timers +features: [ShadowRealm, Timers] +---*/ + +// The virtual timer queue is one per thread, and a ShadowRealm child engine +// runs on the same thread as its parent. The drains therefore have to ask +// whether the realm currently executing is the one whose timers the queue +// carries: without that, a wait reached from inside the child ran the PARENT's +// callbacks while the child's realm was installed as current, so parent code +// executed against child intrinsics — an isolation break that nothing reports. +// +// This lives here rather than beside the other timer tests because it needs +// `unsafe-shadowrealm`, and a directory carrying that flag cannot also import a +// runtime module: doing so faults the engine during the parallel runner's +// warm-up. That is a separate, pre-existing defect — `node:async_hooks` and +// `goccia:csv` reproduce it identically — so this file uses the timer globals +// only. See docs/adr/0113-deterministic-virtual-timer-queue.md. + +// `Array.fromAsync` over an async iterator awaits on the caller's own stack +// rather than suspending, which is what makes the drain reachable at all: an +// ordinary `async` function suspends and resumes through a promise reaction, so +// its `await` never gets there. This shape does. +const PENDING_FROM_ASYNC = + "(() => {" + + " const iterable = {" + + " [Symbol.asyncIterator]: () => ({ next: () => new Promise(() => {}) })" + + " };" + + " try { Array.fromAsync(iterable); } catch (error) { /* never settles */ }" + + " return 'child-ran';" + + "})()"; + +const pendingFromAsyncHere = () => { + const iterable = { + [Symbol.asyncIterator]: () => ({ next: () => new Promise(() => {}) }), + }; + try { + Array.fromAsync(iterable); + } catch (error) { + // never settles + } + return "parent-ran"; +}; + +describe("a child realm does not run the parent's timers", () => { + test("the drain is reachable at all from this shape", () => { + let parentTimerRan = false; + setTimeout(() => { + parentTimerRan = true; + }, 0); + + // Run in the parent, where the queue's owner IS the current realm: the + // timer runs. Without this half the isolation test below would pass for the + // wrong reason — because nothing reached the drain either way. + expect(pendingFromAsyncHere()).toBe("parent-ran"); + expect(parentTimerRan).toBe(true); + }); + + test("the same wait inside a ShadowRealm leaves the parent's queue alone", () => { + let parentTimerRan = false; + const id = setTimeout(() => { + parentTimerRan = true; + }, 0); + + const realm = new ShadowRealm(); + const result = realm.evaluate(PENDING_FROM_ASYNC); + + expect(result).toBe("child-ran"); + expect(parentTimerRan).toBe(false); + + clearTimeout(id); + }); +}); diff --git a/tests/built-ins/Timers/fake-timers.js b/tests/built-ins/Timers/fake-timers.js new file mode 100644 index 000000000..341359ebf --- /dev/null +++ b/tests/built-ins/Timers/fake-timers.js @@ -0,0 +1,341 @@ +/*--- +description: the goccia:timers control surface — advancing, running and the mocked system clock +features: [Timers] +---*/ + +import { + useFakeTimers, + useRealTimers, + isFakeTimers, + setSystemTime, + getMockedSystemTime, + getRealSystemTime, + advanceTimersByTime, + advanceTimersByTimeAsync, + advanceTimersToNextTimer, + runAllTimers, + runOnlyPendingTimers, + clearAllTimers, + getTimerCount, +} from "goccia:timers"; + +describe("advancing", () => { + afterEach(() => { + useRealTimers(); + }); + + test("a synchronous advance runs no microtasks between timers", () => { + useFakeTimers(); + setSystemTime(0); + const log = []; + setTimeout(() => { + log.push("first"); + Promise.resolve().then(() => log.push("microtask")); + }, 10); + setTimeout(() => log.push("second"), 20); + advanceTimersByTime(25); + + // The microtask the first timer queued is still waiting: the synchronous + // advance never yields, so it cannot land between the two timers. + expect(log).toEqual(["first", "second"]); + }); + + test("an asynchronous advance drains microtasks around every timer", async () => { + useFakeTimers(); + setSystemTime(0); + const log = []; + setTimeout(() => { + log.push("first"); + Promise.resolve().then(() => log.push("after-first")); + }, 10); + setTimeout(() => { + log.push("second"); + Promise.resolve().then(() => log.push("after-second")); + }, 20); + Promise.resolve().then(() => log.push("pending")); + await advanceTimersByTimeAsync(25); + + expect(log).toEqual([ + "pending", + "first", + "after-first", + "second", + "after-second", + ]); + }); + + test("advanceTimersToNextTimer fires every timer due at that instant", () => { + useFakeTimers(); + setSystemTime(0); + const log = []; + setTimeout(() => log.push("a@" + Date.now()), 10); + setTimeout(() => log.push("b@" + Date.now()), 20); + setTimeout(() => log.push("c@" + Date.now()), 20); + + advanceTimersToNextTimer(); + expect(log).toEqual(["a@10"]); + expect(Date.now()).toBe(10); + + advanceTimersToNextTimer(); + expect(log).toEqual(["a@10", "b@20", "c@20"]); + expect(Date.now()).toBe(20); + }); + + test("a negative advance is refused", () => { + useFakeTimers(); + expect(() => advanceTimersByTime(-1)).toThrow( + "Negative ticks are not supported", + ); + }); + + test("a throwing callback does not stop the timers behind it", () => { + useFakeTimers(); + setSystemTime(0); + const log = []; + setTimeout(() => { + log.push("throwing"); + throw new Error("boom"); + }, 5); + setTimeout(() => log.push("later"), 10); + + // The error surfaces once the advance is over, and the clock still went + // the whole distance it was asked to. + expect(() => advanceTimersByTime(20)).toThrow("boom"); + expect(log).toEqual(["throwing", "later"]); + expect(Date.now()).toBe(20); + }); + + test("stepping to a single timer stops at a throwing callback", () => { + useFakeTimers(); + setSystemTime(0); + const log = []; + setTimeout(() => { + log.push("throwing"); + throw new Error("boom"); + }, 5); + setTimeout(() => log.push("later"), 5); + + // Unlike an advance, a step has no handler of its own, so the rest of the + // instant is left pending. + expect(() => advanceTimersToNextTimer()).toThrow("boom"); + expect(log).toEqual(["throwing"]); + expect(getTimerCount()).toBe(1); + }); +}); + +describe("running", () => { + afterEach(() => { + useRealTimers(); + }); + + test("runAllTimers drains a chain of timers", () => { + useFakeTimers(); + setSystemTime(0); + const log = []; + setTimeout(() => { + log.push("a"); + setTimeout(() => log.push("b"), 100); + }, 10); + runAllTimers(); + + expect(log).toEqual(["a", "b"]); + expect(getTimerCount()).toBe(0); + }); + + test("runAllTimers gives up on a self-rescheduling timer", () => { + useFakeTimers(); + let runs = 0; + const reschedule = () => { + runs += 1; + setTimeout(reschedule, 1); + }; + setTimeout(reschedule, 1); + + expect(() => runAllTimers()).toThrow( + "Aborting after running 10000 timers, assuming an infinite loop!", + ); + expect(runs).toBe(10000); + }); + + test("runAllTimers stops at a throwing callback", () => { + useFakeTimers(); + setSystemTime(0); + const log = []; + setTimeout(() => { + log.push("throwing"); + throw new Error("boom"); + }, 5); + setTimeout(() => log.push("later"), 10); + + expect(() => runAllTimers()).toThrow("boom"); + expect(log).toEqual(["throwing"]); + expect(getTimerCount()).toBe(1); + expect(Date.now()).toBe(5); + }); + + test("runOnlyPendingTimers stops at the timers that were pending", () => { + useFakeTimers(); + setSystemTime(0); + const log = []; + setTimeout(() => { + log.push("a"); + setTimeout(() => log.push("far"), 100); + }, 10); + setTimeout(() => log.push("b"), 20); + runOnlyPendingTimers(); + + // The clock advanced to the latest due time that existed when the call was + // made, so the nested timer beyond it is still waiting. + expect(log).toEqual(["a", "b"]); + expect(Date.now()).toBe(20); + expect(getTimerCount()).toBe(1); + }); + + test("runOnlyPendingTimers still fires what a pending timer scheduled inside the window", () => { + useFakeTimers(); + setSystemTime(0); + const log = []; + setTimeout(() => { + log.push("a"); + setTimeout(() => log.push("nested"), 1); + }, 10); + setTimeout(() => log.push("b"), 20); + runOnlyPendingTimers(); + + expect(log).toEqual(["a", "nested", "b"]); + expect(getTimerCount()).toBe(0); + }); + + test("clearAllTimers drops everything pending and rewinds the clock", () => { + useFakeTimers(); + setSystemTime(1000); + setTimeout(() => {}, 5); + setInterval(() => {}, 5); + advanceTimersByTime(2); + expect(getTimerCount()).toBe(2); + + // The fake clock's reset does both halves, so both are reproduced: the + // clock goes back to the instant fake timers were installed. + clearAllTimers(); + expect(getTimerCount()).toBe(0); + expect(Date.now() > 1600000000000).toBe(true); + }); + + test("clearAllTimers does nothing without fake timers", () => { + expect(() => clearAllTimers()).not.toThrow(); + }); +}); + +describe("the mocked system clock", () => { + afterEach(() => { + useRealTimers(); + }); + + test("enabling fake timers freezes Date at the real time it started from", () => { + expect(isFakeTimers()).toBe(false); + useFakeTimers(); + expect(isFakeTimers()).toBe(true); + + const frozen = Date.now(); + Array.from({ length: 20000 }, (_, index) => index * 2).reduce( + (total, value) => total + value, + 0, + ); + expect(Date.now()).toBe(frozen); + expect(frozen).toBe(getMockedSystemTime()); + }); + + test("setSystemTime moves Date and new Date together", () => { + useFakeTimers(); + setSystemTime(1000); + + expect(Date.now()).toBe(1000); + expect(new Date().getTime()).toBe(1000); + expect(getMockedSystemTime()).toBe(1000); + }); + + test("advancing the clock advances Date", () => { + useFakeTimers(); + setSystemTime(1577836800000); + advanceTimersByTime(500); + + expect(Date.now()).toBe(1577836800500); + }); + + test("setSystemTime keeps a pending timer's remaining delay", () => { + useFakeTimers(); + setSystemTime(1000); + const log = []; + setTimeout(() => log.push(Date.now()), 10); + + // Moving the wall clock is not the same as letting time pass, so the timer + // is still 10ms away — now from the new instant. + setSystemTime(5000); + advanceTimersByTime(10); + expect(log).toEqual([5010]); + }); + + test("performance.now measures elapsed virtual time, not the simulated date", () => { + useFakeTimers(); + setSystemTime(1000); + const before = performance.now(); + setSystemTime(9999999); + expect(performance.now()).toBe(before); + + advanceTimersByTime(250); + expect(performance.now() - before).toBe(250); + }); + + test("getRealSystemTime reads the real clock even while one is mocked", () => { + useFakeTimers(); + setSystemTime(0); + + expect(Date.now()).toBe(0); + expect(getRealSystemTime() > 1600000000000).toBe(true); + }); + + test("setSystemTime without fake timers freezes Date and nothing else", () => { + setSystemTime(4200); + + expect(isFakeTimers()).toBe(false); + expect(Date.now()).toBe(4200); + expect(getMockedSystemTime()).toBe(4200); + + useRealTimers(); + expect(getMockedSystemTime()).toBe(null); + expect(Date.now() > 1600000000000).toBe(true); + }); + + test("re-enabling fake timers installs a fresh clock at the current instant", () => { + useFakeTimers(); + setSystemTime(0); + setTimeout(() => {}, 5); + expect(getTimerCount()).toBe(1); + + useFakeTimers(); + expect(getTimerCount()).toBe(0); + expect(Date.now()).toBe(0); + }); + + test("useRealTimers hands the clock and the queue back", () => { + useFakeTimers(); + setSystemTime(0); + setTimeout(() => {}, 5); + useRealTimers(); + + expect(isFakeTimers()).toBe(false); + expect(getMockedSystemTime()).toBe(null); + expect(Date.now() > 1600000000000).toBe(true); + }); + + test("the advance members refuse to run without fake timers", () => { + const message = + "A function to advance timers was called but the timers APIs are not mocked"; + + expect(() => advanceTimersByTime(1)).toThrow(message); + expect(() => advanceTimersToNextTimer()).toThrow(message); + expect(() => runAllTimers()).toThrow(message); + expect(() => runOnlyPendingTimers()).toThrow(message); + expect(() => getTimerCount()).toThrow(message); + }); +}); diff --git a/tests/built-ins/Timers/real-timers.js b/tests/built-ins/Timers/real-timers.js new file mode 100644 index 000000000..49d338580 --- /dev/null +++ b/tests/built-ins/Timers/real-timers.js @@ -0,0 +1,138 @@ +/*--- +description: real-mode timers run where the engine would otherwise have nothing left to do +features: [Timers] +---*/ + +import { AsyncLocalStorage } from "node:async_hooks"; +import { + advanceTimersByTime, + isFakeTimers, + useFakeTimers, + useRealTimers, +} from "goccia:timers"; + +// Without fake timers the queue is still virtual: no wall time passes, the +// clock simply jumps to the next due timer whenever the engine is about to run +// out of work. The two points where that happens are an `await` on a promise a +// timer will settle, and the end of the run. + +describe("real-mode timers", () => { + test("awaiting a promise a timer settles resolves through the queue", async () => { + expect(isFakeTimers()).toBe(false); + + const value = await new Promise((resolve) => { + setTimeout(() => resolve("settled"), 50); + }); + + expect(value).toBe("settled"); + }); + + test("a chain of timers resolves in order", async () => { + const log = []; + await new Promise((resolve) => { + setTimeout(() => { + log.push("first"); + setTimeout(() => { + log.push("second"); + resolve(); + }, 0); + }, 0); + }); + + expect(log).toEqual(["first", "second"]); + }); + + test("a rejection from a timer callback reaches the awaiting frame", async () => { + await expect( + new Promise((resolve, reject) => { + setTimeout(() => reject(new Error("late failure")), 5); + }), + ).rejects.toThrow("late failure"); + }); + + test("an interval can drive a promise and then be cleared", async () => { + let ticks = 0; + const id = setInterval(() => { + ticks += 1; + }, 10); + + await new Promise((resolve) => setTimeout(resolve, 35)); + clearInterval(id); + + expect(ticks).toBe(3); + }); +}); + +describe("async context through a timer callback", () => { + test("a callback observes the store that was in effect where it was scheduled", async () => { + const storage = new AsyncLocalStorage(); + const seen = []; + + const scheduled = storage.run("OUTER", () => { + return new Promise((resolve) => { + setTimeout(() => { + seen.push(storage.getStore()); + resolve(); + }, 5); + }); + }); + + // The `run` has already returned by the time the timer fires, so a + // callback that read the ambient context would see nothing. The snapshot + // captured at registration is what carries the store into it. + expect(storage.getStore()).toBeUndefined(); + await scheduled; + + expect(seen).toEqual(["OUTER"]); + expect(storage.getStore()).toBeUndefined(); + }); + + test("two timers scheduled under different stores stay separate", async () => { + const storage = new AsyncLocalStorage(); + const seen = []; + + const first = storage.run("A", () => + new Promise((resolve) => { + setTimeout(() => { + seen.push(storage.getStore()); + resolve(); + }, 10); + }), + ); + const second = storage.run("B", () => + new Promise((resolve) => { + setTimeout(() => { + seen.push(storage.getStore()); + resolve(); + }, 5); + }), + ); + + await second; + await first; + + expect(seen).toEqual(["B", "A"]); + }); + + test("fake timers carry the same snapshot", () => { + useFakeTimers(); + try { + const storage = new AsyncLocalStorage(); + const seen = []; + + storage.run("FAKE", () => { + setTimeout(() => seen.push(storage.getStore()), 5); + }); + expect(seen).toEqual([]); + + // Advancing from outside the `run` still reaches the callback with the + // store, because the context travels with the timer rather than with the + // frame that advanced the clock. + advanceTimersByTime(5); + expect(seen).toEqual(["FAKE"]); + expect(storage.getStore()).toBeUndefined(); + } finally { + useRealTimers(); + } + }); +}); diff --git a/tests/built-ins/Timers/regressions.js b/tests/built-ins/Timers/regressions.js new file mode 100644 index 000000000..916f0e37f --- /dev/null +++ b/tests/built-ins/Timers/regressions.js @@ -0,0 +1,333 @@ +/*--- +description: regression cases for the virtual timer queue, one per reviewed defect +features: [Timers] +---*/ + +import { + advanceTimersByTime, + getTimerCount, + setSystemTime, + useFakeTimers, + useRealTimers, +} from "goccia:timers"; + +// Every case here reproduces a defect the timer queue shipped with in review. +// They are kept together, and named for what they guard, because each one +// passes trivially against a queue that never had the bug. + +// Scheduled while the entry module evaluates. The engine reaches runtime idle +// at that moment and used to drain it there — ten thousand times, because an +// interval is never exhausted — before a single test body had run. +let moduleIntervalRuns = 0; +const moduleInterval = setInterval(() => { + moduleIntervalRuns += 1; +}, 1); + +describe("a non-finite system time is refused, not installed", () => { + afterEach(() => { + useRealTimers(); + }); + + // Installing NaN as the clock made every due-time comparison false: the range + // test then selected arbitrarily and the trailing re-check in the tick + // recursed on NaN until the stack was gone. Vitest tolerates it because a JS + // clock can hold NaN; this one cannot, so it refuses at the door. + test("setSystemTime rejects NaN", () => { + useFakeTimers(); + + expect(() => setSystemTime(NaN)).toThrow(TypeError); + expect(() => setSystemTime(NaN)).toThrow("must be a finite number"); + }); + + test("setSystemTime rejects Infinity", () => { + useFakeTimers(); + + expect(() => setSystemTime(Infinity)).toThrow(TypeError); + expect(() => setSystemTime(-Infinity)).toThrow(TypeError); + }); + + // The engine surface takes epoch milliseconds, so a string arrives as NaN + // through ToNumber. This is the second entry point — it does not go through + // the Vitest shim's Date conversion — and it is why the guard has to be in + // the queue rather than at the shim boundary. + test("the engine surface rejects a string that is not a number", () => { + useFakeTimers(); + + expect(() => setSystemTime("2020-01-01")).toThrow(TypeError); + }); + + test("the clock still works afterwards", () => { + useFakeTimers(); + setSystemTime(0); + try { + setSystemTime(NaN); + } catch (error) { + // refused, as asserted above + } + + expect(Date.now()).toBe(0); + + const log = []; + setTimeout(() => log.push(Date.now()), 5); + advanceTimersByTime(5); + expect(log).toEqual([5]); + }); + + test("a refused advance leaves the clock alone", () => { + useFakeTimers(); + setSystemTime(10); + + expect(() => advanceTimersByTime(NaN)).toThrow(TypeError); + expect(Date.now()).toBe(10); + }); +}); + +describe("an advance keeps its own recorded exception", () => { + afterEach(() => { + useRealTimers(); + }); + + // The recorded exception used to live on the queue rather than on the + // operation, so an advance started from inside a timer callback raised the + // OUTER advance's error at the inner call — and the outer advance then + // reported success. Vitest keeps it per operation; this matches. + test("a nested advance does not steal the outer one's error", () => { + useFakeTimers(); + setSystemTime(0); + const log = []; + + setTimeout(() => { + log.push("first"); + throw new Error("outer"); + }, 5); + setTimeout(() => { + log.push("second"); + let inner = null; + try { + advanceTimersByTime(0); + } catch (error) { + inner = error.message; + } + log.push("inner=" + inner); + }, 10); + + let outer = null; + try { + advanceTimersByTime(20); + } catch (error) { + outer = error.message; + } + log.push("outer=" + outer); + + expect(log).toEqual(["first", "second", "inner=null", "outer=outer"]); + }); +}); + +describe("real-mode timers", () => { + // An interval stays in the queue while its callback runs, and the real-mode + // step picks the earliest timer with no window to bound it — so the moment a + // callback awaited, the step handed back the same entry and the callback + // re-entered itself until the stack ran out. + test("an awaiting interval callback does not re-enter itself", async () => { + let depth = 0; + let maxDepth = 0; + let runs = 0; + + const id = setInterval(async () => { + runs += 1; + depth += 1; + if (depth > maxDepth) maxDepth = depth; + await Promise.resolve(); + await null; + depth -= 1; + if (runs >= 3) clearInterval(id); + }, 5); + + await new Promise((resolve) => setTimeout(resolve, 40)); + + expect(maxDepth).toBe(1); + expect(runs).toBe(3); + }); + + // The module-scope interval above. Draining it at the engine's idle point + // spent the whole budget on a timer that can never be exhausted, and did it + // before any test existed to attribute the work to. + test("the idle drain leaves intervals alone", () => { + expect(moduleIntervalRuns).toBe(0); + clearInterval(moduleInterval); + }); + + // An uncleared zero-period interval reschedules at the instant it just ran. + // Under real timers it must not be picked up by any drain at all. + test("an uncleared zero-period interval does not hold the run open", () => { + let runs = 0; + const id = setInterval(() => { + runs += 1; + }, 0); + + expect(typeof id).toBe("number"); + expect(runs).toBe(0); + clearInterval(id); + }); + + test("a zero-delay chain resolves in order", async () => { + const log = []; + await new Promise((resolve) => { + setTimeout(() => { + log.push("first"); + setTimeout(() => { + log.push("second"); + setTimeout(() => { + log.push("third"); + resolve(); + }, 0); + }, 0); + }, 0); + }); + + expect(log).toEqual(["first", "second", "third"]); + }); +}); + +// Cross-test state, because what is being checked is what happens BETWEEN two +// tests: the pair below only means anything run in order. +let bodyTimerRan = false; +let strandedIntervalRan = false; + +describe("timers a test body schedules", () => { + // The engine's idle point is reached while the entry module is still + // evaluating, so a `setTimeout` written inside a test body was never drained + // by it — the timer simply sat in the queue until the run tore down and + // discarded it, silently. The runner drains at the end of each test instead. + test("are drained at the end of the test that scheduled them", () => { + setTimeout(() => { + bodyTimerRan = true; + }, 0); + + // Still pending here: the drain happens once the body has returned. + expect(bodyTimerRan).toBe(false); + }); + + test("have run by the time the next test starts", () => { + expect(bodyTimerRan).toBe(true); + }); +}); + +describe("timers a test body strands", () => { + test("an interval left running does not survive the test", () => { + setInterval(() => { + strandedIntervalRan = true; + }, 1); + + expect(strandedIntervalRan).toBe(false); + }); + + test("it cannot fire inside the next test", async () => { + // Anything the previous test left behind would have had every chance here: + // this body awaits a timer, which is exactly when the queue runs. + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(strandedIntervalRan).toBe(false); + expect(getTimerCount).toBeInstanceOf(Function); + }); +}); + +describe("fractional delays and advances", () => { + afterEach(() => { + useRealTimers(); + }); + + // The fake clock computes a due time with parseInt, so the fraction of a + // delay is dropped — but the fraction of an ADVANCE is banked and carried, + // so two half-millisecond advances move the clock by one. Both halves probed + // against Vitest 4.1.10. + test("a fractional delay is truncated", () => { + useFakeTimers(); + setSystemTime(0); + const log = []; + setTimeout(() => log.push(Date.now()), 1.5); + + advanceTimersByTime(1); + expect(log).toEqual([1]); + }); + + test("a delay below one millisecond is due immediately", () => { + useFakeTimers(); + setSystemTime(0); + const log = []; + setTimeout(() => log.push(Date.now()), 0.4); + + advanceTimersByTime(0); + expect(log).toEqual([0]); + }); + + test("fractional advances accumulate", () => { + useFakeTimers(); + setSystemTime(0); + const log = []; + setTimeout(() => log.push(Date.now()), 2); + + advanceTimersByTime(1.5); + expect(log).toEqual([]); + expect(Date.now()).toBe(1); + + advanceTimersByTime(0.5); + expect(log).toEqual([2]); + expect(Date.now()).toBe(2); + }); +}); + +describe("zero-period intervals under fake timers", () => { + afterEach(() => { + useRealTimers(); + }); + + // Vitest fires every one of them at the instant the clock is already on, and + // the advance still finishes where it was asked to. Matched rather than + // clamped to Node's 1ms floor, because Vitest is the oracle for the fake + // clock; the unbounded shape is caught by the per-advance bound instead. + test("every tick lands on the same instant", () => { + useFakeTimers(); + setSystemTime(0); + const stamps = []; + let runs = 0; + const id = setInterval(() => { + runs += 1; + stamps.push(Date.now()); + if (runs >= 5) clearInterval(id); + }, 0); + + advanceTimersByTime(3); + + expect(stamps).toEqual([0, 0, 0, 0, 0]); + expect(Date.now()).toBe(3); + expect(getTimerCount()).toBe(0); + }); +}); + +describe("performance.now across the fake/real transition", () => { + test("it starts at zero when faked and returns to the real timeline after", () => { + const realBefore = performance.now(); + + useFakeTimers(); + expect(performance.now()).toBe(0); + advanceTimersByTime(500); + expect(performance.now()).toBe(500); + + useRealTimers(); + // Back on the process timeline, not stranded at 500. + expect(performance.now() >= realBefore).toBe(true); + expect(typeof performance.timeOrigin).toBe("number"); + }); +}); + +describe("the timer globals are the runner's", () => { + test("they are reported as runtime globals", () => { + const names = Goccia.runtimeGlobals; + + expect(names.includes("setTimeout")).toBe(true); + expect(names.includes("clearTimeout")).toBe(true); + expect(names.includes("setInterval")).toBe(true); + expect(names.includes("clearInterval")).toBe(true); + }); +}); diff --git a/tests/built-ins/Timers/scheduling.js b/tests/built-ins/Timers/scheduling.js new file mode 100644 index 000000000..15a824973 --- /dev/null +++ b/tests/built-ins/Timers/scheduling.js @@ -0,0 +1,168 @@ +/*--- +description: setTimeout, setInterval and their clear counterparts over the virtual timer queue +features: [Timers] +---*/ + +import { + useFakeTimers, + useRealTimers, + setSystemTime, + advanceTimersByTime, + getTimerCount, +} from "goccia:timers"; + +// The queue is virtual: nothing here waits on wall time, and a delay is an +// ordering key on a clock the test moves. Every expectation was probed against +// Vitest 4.1.10, whose fake timers wrap @sinonjs/fake-timers. + +describe("scheduling", () => { + afterEach(() => { + useRealTimers(); + }); + + test("timers fire in due-time order, then in registration order", () => { + useFakeTimers(); + setSystemTime(0); + const log = []; + setTimeout(() => log.push("late"), 20); + setTimeout(() => log.push("early-a"), 10); + setTimeout(() => log.push("early-b"), 10); + advanceTimersByTime(20); + + expect(log).toEqual(["early-a", "early-b", "late"]); + }); + + test("a zero, missing or negative delay is due immediately", () => { + useFakeTimers(); + setSystemTime(0); + const log = []; + setTimeout(() => log.push("zero"), 0); + setTimeout(() => log.push("missing")); + setTimeout(() => log.push("negative"), -5); + setTimeout(() => log.push("one"), 1); + advanceTimersByTime(0); + + expect(log).toEqual(["zero", "missing", "negative"]); + + advanceTimersByTime(1); + expect(log).toEqual(["zero", "missing", "negative", "one"]); + }); + + test("a zero-delay timer scheduled from inside a callback lands on the next millisecond", () => { + useFakeTimers(); + setSystemTime(0); + const log = []; + setTimeout(() => { + log.push("outer@" + Date.now()); + setTimeout(() => log.push("inner@" + Date.now()), 0); + }, 5); + + // The chain does not collapse onto the instant it started, which is what + // keeps a self-rescheduling zero-delay timer from looping inside one + // advance. + advanceTimersByTime(5); + expect(log).toEqual(["outer@5"]); + + advanceTimersByTime(1); + expect(log).toEqual(["outer@5", "inner@6"]); + }); + + test("extra arguments reach the callback", () => { + useFakeTimers(); + const seen = []; + setTimeout((first, second) => seen.push([first, second]), 1, "x", 2); + advanceTimersByTime(1); + + expect(seen).toEqual([["x", 2]]); + }); + + test("clearTimeout cancels a pending timer, from outside and from a callback", () => { + useFakeTimers(); + const log = []; + const cancelled = setTimeout(() => log.push("cancelled"), 10); + setTimeout(() => { + log.push("canceller"); + clearTimeout(cancelled); + }, 5); + advanceTimersByTime(20); + + expect(log).toEqual(["canceller"]); + }); + + test("clearing an id that is absent, falsy or undefined is a no-op", () => { + useFakeTimers(); + expect(() => clearTimeout(undefined)).not.toThrow(); + expect(() => clearTimeout(0)).not.toThrow(); + expect(() => clearTimeout(999999)).not.toThrow(); + expect(() => clearInterval(undefined)).not.toThrow(); + }); + + test("either clear name cancels either kind, as the fake clock allows", () => { + useFakeTimers(); + const log = []; + const timeoutId = setTimeout(() => log.push("timeout"), 5); + const intervalId = setInterval(() => log.push("interval"), 5); + clearInterval(timeoutId); + clearTimeout(intervalId); + advanceTimersByTime(20); + + expect(log).toEqual([]); + expect(getTimerCount()).toBe(0); + }); + + test("a timer id is a number", () => { + useFakeTimers(); + const id = setTimeout(() => {}, 5); + + expect(typeof id).toBe("number"); + clearTimeout(id); + }); + + test("setTimeout rejects a non-callable callback", () => { + useFakeTimers(); + expect(() => setTimeout()).toThrow(TypeError); + expect(() => setTimeout("log('hi')", 1)).toThrow(TypeError); + }); +}); + +describe("intervals", () => { + afterEach(() => { + useRealTimers(); + }); + + test("an interval reschedules from its previous due time", () => { + useFakeTimers(); + setSystemTime(0); + const log = []; + const id = setInterval(() => log.push(Date.now()), 10); + advanceTimersByTime(35); + clearInterval(id); + + expect(log).toEqual([10, 20, 30]); + }); + + test("a single advance fires every tick the interval crossed", () => { + useFakeTimers(); + setSystemTime(0); + const log = []; + const id = setInterval(() => log.push(Date.now()), 10); + advanceTimersByTime(50); + clearInterval(id); + + expect(log).toEqual([10, 20, 30, 40, 50]); + }); + + test("an interval that clears itself stops and leaves nothing pending", () => { + useFakeTimers(); + setSystemTime(0); + let runs = 0; + const id = setInterval(() => { + runs += 1; + if (runs === 3) clearInterval(id); + }, 10); + advanceTimersByTime(100); + + expect(runs).toBe(3); + expect(getTimerCount()).toBe(0); + }); +}); diff --git a/tests/language/modules/vitest-compat-shim.js b/tests/language/modules/vitest-compat-shim.js index d1d4140ca..66ffecf89 100644 --- a/tests/language/modules/vitest-compat-shim.js +++ b/tests/language/modules/vitest-compat-shim.js @@ -68,12 +68,41 @@ describe("vitest compatibility shim", () => { expect(() => vi.hoisted(() => {})).toThrow("vi.hoisted"); }); - test("fake timers throw and name the reason", () => { - expect(() => vi.useFakeTimers()).toThrow("vi.useFakeTimers is not supported"); - expect(() => vi.useFakeTimers()).toThrow("no fake-timer clock"); - expect(() => vi.setSystemTime(0)).toThrow("vi.setSystemTime"); - expect(() => vi.advanceTimersByTime(1)).toThrow("vi.advanceTimersByTime"); - expect(() => vi.runAllTimers()).toThrow("vi.runAllTimers"); + test("the fake-timer family is implemented and chains like Vitest's", () => { + expect(vi.useFakeTimers()).toBe(vi); + expect(vi.isFakeTimers()).toBe(true); + expect(vi.setSystemTime(0)).toBe(vi); + expect(Date.now()).toBe(0); + expect(vi.advanceTimersByTime(1)).toBe(vi); + expect(vi.runAllTimers()).toBe(vi); + expect(vi.runOnlyPendingTimers()).toBe(vi); + expect(vi.advanceTimersToNextTimer()).toBe(vi); + expect(vi.getTimerCount()).toBe(0); + expect(vi.useRealTimers()).toBe(vi); + expect(vi.isFakeTimers()).toBe(false); + }); + + test("advancing without fake timers reports the Vitest message", () => { + expect(() => vi.advanceTimersByTime(1)).toThrow( + "A function to advance timers was called but the timers APIs are not mocked", + ); + expect(() => vi.runAllTimers()).toThrow("vi.useFakeTimers()"); + }); + + test("the timer members the queue cannot honour throw by name", () => { + expect(() => vi.advanceTimersToNextFrame()).toThrow( + "vi.advanceTimersToNextFrame is not supported", + ); + expect(() => vi.advanceTimersToNextFrame()).toThrow("requestAnimationFrame"); + expect(() => vi.runAllTicks()).toThrow("vi.runAllTicks is not supported"); + expect(() => vi.runAllTicks()).toThrow("process.nextTick"); + expect(() => vi.setTimerTickMode("interval")).toThrow( + "vi.setTimerTickMode is not supported", + ); + + // The reason has to name the actual gap, not the timer queue, which is + // there. + expect(() => vi.runAllTicks()).toThrow("the timer queue is supported"); }); test("the async polling members throw for polling, not for fake timers", () => { @@ -104,7 +133,6 @@ describe("vitest compatibility shim", () => { test("every unsupported member is a defined function, never a no-op", () => { expect(typeof vi.mock).toBe("function"); expect(typeof vi.importActual).toBe("function"); - expect(typeof vi.useFakeTimers).toBe("function"); expect(typeof vi.hoisted).toBe("function"); expect(typeof vi.resetModules).toBe("function"); });