diff --git a/packages/signals/tests/attribution-feedback.test.ts b/packages/signals/tests/attribution-feedback.test.ts index 943526d59..f23e9f4ac 100644 --- a/packages/signals/tests/attribution-feedback.test.ts +++ b/packages/signals/tests/attribution-feedback.test.ts @@ -29,6 +29,7 @@ afterEach(() => { attribution.disable(); flush(); vi.restoreAllMocks(); + vi.useRealTimers(); }); const wait = (ms: number) => new Promise(r => setTimeout(r, ms)); @@ -444,6 +445,12 @@ describe("feedback()", () => { }); it("measures how long each loading boundary showed its fallback, and counts flashes", async () => { + // The show is timed on the engine's clock (`performance.now()`): on the + // wall clock a loaded runner stretched the 20ms wait past the 150ms flash + // window. Faked, the fallback is up for exactly the time advanced. + vi.useFakeTimers({ + toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date", "performance"] + }); arm(); const feed = pagedFeed(); const shown: string[] = []; @@ -464,12 +471,14 @@ describe("feedback()", () => { expect(shown).toEqual(["loading…"]); let [row] = feedback().fallbacks; expect(row).toMatchObject({ boundary: "boundary", shows: 1, shownMs: 0, flashes: 0 }); - await wait(20); + await vi.advanceTimersByTimeAsync(20); feed.resolve("a"); - await until(() => shown.includes("a-p1"), "content"); + await vi.advanceTimersByTimeAsync(0); + flush(); + expect(shown).toContain("a-p1"); [row] = feedback().fallbacks; expect(row.shows).toBe(1); - expect(row.shownMs).toBeGreaterThanOrEqual(15); + expect(row.shownMs).toBe(20); expect(row.worstMs).toBe(row.shownMs); // Under 150ms: a spinner that flashed. expect(row.flashes).toBe(1); diff --git a/packages/signals/tests/attribution-lean-gate.test.ts b/packages/signals/tests/attribution-lean-gate.test.ts index 7bb5174ef..773934413 100644 --- a/packages/signals/tests/attribution-lean-gate.test.ts +++ b/packages/signals/tests/attribution-lean-gate.test.ts @@ -144,7 +144,11 @@ describe("attribution engine: lean gate", () => { it("the checks run without a record: a hot scope still warns", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const { setN } = chain(); - attribution.enable({ log: false, hotRuns: { count: 3, windowMs: 10_000 } }); + attribution.enable({ + log: false, + hotRuns: { count: 3, windowMs: 10_000 }, + hotTime: false + }); for (let i = 1; i <= 4; i++) { setN(i); flush(); diff --git a/packages/signals/tests/observe-idle-cost.test.ts b/packages/signals/tests/observe-idle-cost.test.ts index e561c7e71..42e1965cd 100644 --- a/packages/signals/tests/observe-idle-cost.test.ts +++ b/packages/signals/tests/observe-idle-cost.test.ts @@ -9,97 +9,77 @@ * * Relative tripwire, same discipline as heap-mark-incremental: absolute * wall-clock bounds do not survive CI, so the SAME workload runs against the - * built prod and observe artifacts in one process, interleaved, best-of-k, - * and the observe/prod ratio is what is capped. Both tiers see the same - * machine load, and best-of-k picks the quiet run for each. + * built prod and observe artifacts in one worker thread, as paired samples, + * and the median of the per-pair observe/prod ratios is what is capped. A + * pair sees one machine load for both tiers, so contention largely cancels + * within it; the median drops the pairs it doesn't. */ import { existsSync } from "node:fs"; import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { Worker } from "node:worker_threads"; import { describe, expect, test } from "vitest"; -type Tier = typeof import("../src/index.js"); - // The built artifacts — what apps actually resolve. Gitignored, so the test // skips when they haven't been built (run `pnpm build`); resolved paths rather // than literal specifiers so the build's type pass doesn't try to find them. const here = dirname(fileURLToPath(import.meta.url)); const PROD = resolve(here, "../dist/prod/index.js"); const OBSERVE = resolve(here, "../dist/observe/index.js"); +const WORKER = resolve(here, "observe-idle-cost.worker.mjs"); -/** - * A graph-heavy workload with no hooks installed: N chains of - * signal → memo → memo → effect, then K write passes that touch every chain, - * then teardown. Reads, writes, recomputes, effect runs and creation all - * cross the observe wiring; nothing observes. - */ -function workload(tier: Tier, N: number, K: number): number { - const { createEffect, createMemo, createRoot, createSignal, flush } = tier; - let ms = 0; - createRoot(dispose => { - const setters: ((v: number) => void)[] = []; - let sink = 0; - for (let i = 0; i < N; i++) { - const [a, setA] = createSignal(i); - const b = createMemo(() => a() * 2); - const c = createMemo(() => b() + 1); - createEffect( - () => c(), - v => { - sink += v; - } - ); - setters.push(setA); - } - flush(); - const start = performance.now(); - for (let k = 1; k <= K; k++) { - for (let i = 0; i < N; i++) setters[i](i + k); - flush(); - } - ms = performance.now() - start; - dispose(); - if (sink === Infinity) throw new Error("unreachable"); +type Samples = { + observeDefined: boolean; + prodDefined: boolean; + prodMs: number[]; + observeMs: number[]; +}; + +function measure(): Promise { + return new Promise((done, fail) => { + const worker = new Worker(WORKER, { + workerData: { + prodUrl: pathToFileURL(PROD).href, + observeUrl: pathToFileURL(OBSERVE).href, + // ~13ms a sample locally; 2×(warmup + pairs) samples per round. + N: 1000, + K: 40, + warmup: 5, + pairs: 21 + } + }); + worker.once("message", done); + worker.once("error", fail); }); - return ms; } +const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[xs.length >> 1]; + describe.skipIf(!existsSync(PROD) || !existsSync(OBSERVE))("observe tier idle cost", () => { test("no hooks installed: the observe artifact runs the same graph within the cap of prod", async () => { - const prod = (await import(PROD)) as Tier; - const observe = (await import(OBSERVE)) as Tier; - expect((prod as any).OBSERVE).toBeUndefined(); - expect((observe as any).OBSERVE).toBeDefined(); - - // ~15ms a sample locally; under a loaded CI worker a full three rounds - // stays well inside the explicit timeout below. - const N = 1000; - const K = 10; - // Warm both (JIT, allocator) before anything is timed. - workload(prod, N, 2); - workload(observe, N, 2); - // Measured 2026-09-16 (M-series, five samples): 1.03–1.09 — the wiring - // is 3–9% on a graph that does nothing but cross it. The cap trips when - // the wiring costs ~3x what it does today (25%), which is the regression - // this exists to catch — a hook site that stopped being a null check. - // Noise: the suite runs this beside other files on worker - // threads, so one tier can draw the busy slots; a round is best-of-k - // interleaved, and a round over the cap is re-measured (a regression is - // over the cap every round, contention is not). + // Measured 2026-10-05 (M-series, Node loading the artifacts directly): + // median pair ratio 0.96–1.05. The cap trips when the wiring costs a + // quarter of the graph's own work, which is the regression this exists + // to catch — a hook site that stopped being a null check. For scale, a + // WeakMap bump per write and per recompute reads 1.23–1.28. + // Noise: the suite runs this beside other files on worker threads, so + // the machine's load shifts under the measurement. Pairing cancels the + // shift a pair sees, the median drops the outliers, and a round over + // the cap is re-measured (a regression is over the cap every round, + // contention is not). const CAP = 1.25; let best = Infinity; let detail = ""; for (let round = 0; round < 3 && best >= CAP; round++) { - let prodMs = Infinity; - let observeMs = Infinity; - for (let i = 0; i < 5; i++) { - prodMs = Math.min(prodMs, workload(prod, N, K)); - observeMs = Math.min(observeMs, workload(observe, N, K)); - } - const ratio = observeMs / prodMs; + const s = await measure(); + expect(s.prodDefined).toBe(false); + expect(s.observeDefined).toBe(true); + const ratio = median(s.observeMs.map((o, i) => o / s.prodMs[i])); if (ratio < best) { best = ratio; - detail = `observe ${observeMs.toFixed(1)}ms / prod ${prodMs.toFixed(1)}ms`; + detail = `median pair ratio over ${s.prodMs.length} pairs; observe ${median( + s.observeMs + ).toFixed(1)}ms / prod ${median(s.prodMs).toFixed(1)}ms (medians)`; } } expect(best, detail).toBeLessThan(CAP); diff --git a/packages/signals/tests/observe-idle-cost.worker.mjs b/packages/signals/tests/observe-idle-cost.worker.mjs new file mode 100644 index 000000000..3daa0a9ff --- /dev/null +++ b/packages/signals/tests/observe-idle-cost.worker.mjs @@ -0,0 +1,84 @@ +// Measurement half of observe-idle-cost.test.ts. Runs in a worker thread so +// Node loads the built artifacts itself: inside the test runner they would go +// through vite's SSR transform, which rewrites every cross-module import of +// the module-preserving builds into a namespace-object property load — a +// cost apps never pay, larger than the one under measurement, and not the +// same for both tiers. +import { parentPort, workerData } from "node:worker_threads"; + +const { prodUrl, observeUrl, N, K, warmup, pairs } = workerData; +const prod = await import(prodUrl); +const observe = await import(observeUrl); + +// This thread's CPU time, in ms: time spent descheduled on a loaded runner +// is wall-clock noise, not cost. Wall clock where Node predates the API. +const clock = process.threadCpuUsage + ? () => { + const { user, system } = process.threadCpuUsage(); + return (user + system) / 1000; + } + : () => performance.now(); + +/** + * A graph-heavy workload with no hooks installed: N chains of + * signal → memo → memo → effect, then K write passes that touch every chain, + * then teardown. Reads, writes, recomputes and effect runs cross the observe + * wiring in the timed window; nothing observes. + */ +function workload(tier) { + const { createEffect, createMemo, createRoot, createSignal, flush } = tier; + let ms = 0; + createRoot(dispose => { + const setters = []; + let sink = 0; + for (let i = 0; i < N; i++) { + const [a, setA] = createSignal(i); + const b = createMemo(() => a() * 2); + const c = createMemo(() => b() + 1); + createEffect( + () => c(), + v => { + sink += v; + } + ); + setters.push(setA); + } + flush(); + const start = clock(); + for (let k = 1; k <= K; k++) { + for (let i = 0; i < N; i++) setters[i](i + k); + flush(); + } + ms = clock() - start; + dispose(); + if (sink === Infinity) throw new Error("unreachable"); + }); + return ms; +} + +// Paired samples: each pair runs the tiers back to back, alternating which +// goes first, so both halves of a pair see the same machine load and neither +// tier owns the slot after (say) a GC of the other's garbage. +function pair(i) { + if (i % 2) { + const o = workload(observe); + return [workload(prod), o]; + } + const p = workload(prod); + return [p, workload(observe)]; +} + +for (let i = 0; i < warmup; i++) pair(i); +const prodMs = []; +const observeMs = []; +for (let i = 0; i < pairs; i++) { + const [p, o] = pair(i); + prodMs.push(p); + observeMs.push(o); +} +parentPort.postMessage({ + observeDefined: observe.OBSERVE !== undefined, + prodDefined: prod.OBSERVE !== undefined, + prodMs, + observeMs +}); diff --git a/packages/web/test/loading.spec.tsx b/packages/web/test/loading.spec.tsx index 09c8cf844..dc7d3ddab 100644 --- a/packages/web/test/loading.spec.tsx +++ b/packages/web/test/loading.spec.tsx @@ -892,10 +892,7 @@ describe("Testing Loading", () => { // root insert until the fetch landed: the page stayed blank — not even // content OUTSIDE the boundary mounted — and the fallback never showed. describe(" around an optimistic store's first flight", () => { - // Real timers: the fetches below settle on their own schedule. - beforeEach(() => { - vi.useRealTimers(); - }); + const FETCH_MS = 10; const wait = (ms: number) => new Promise(r => setTimeout(r, ms)); type Item = { id: number; label: string; votes: number }; @@ -905,7 +902,7 @@ describe(" around an optimistic store's first flight", () => { const [list] = make(async () => { tick(); const n = ++fetches; - await wait(10); + await wait(FETCH_MS); return [{ id: 1, label: "Tacos", votes: n - 1 }]; }, [] as Item[]); return { list, setTick }; @@ -952,17 +949,17 @@ describe(" around an optimistic store's first flight", () => { const { html, dispose, refetch } = mount(createOptimisticStore); expect(html()).toBe("

Poll

Loading...
"); - await wait(30); + await vi.advanceTimersByTimeAsync(FETCH_MS); flush(); expect(html()).toBe("

Poll

  • Tacos - 0
"); // Refetch: stale-while-revalidate — no fallback, then the new truth. refetch(); flush(); - await wait(2); + await vi.advanceTimersByTimeAsync(FETCH_MS - 1); flush(); expect(html()).toBe("

Poll

  • Tacos - 0
"); - await wait(30); + await vi.advanceTimersByTimeAsync(1); flush(); expect(html()).toBe("

Poll

  • Tacos - 1
"); dispose(); @@ -971,7 +968,7 @@ describe(" around an optimistic store's first flight", () => { test("control: createStore(fn, seed) in the same spot", async () => { const { html, dispose } = mount(createStore); expect(html()).toBe("

Poll

Loading...
"); - await wait(30); + await vi.advanceTimersByTimeAsync(FETCH_MS); flush(); expect(html()).toBe("

Poll

  • Tacos - 0
"); dispose();