diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index d46ec6b160e..ba80ace3f60 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -20,6 +20,7 @@ import type { Established } from "./established.ts"; import { DISCOVERY_REMOVED } from "./forward.ts"; import { DEFAULT_HANDOVER, type Drain, dialed, type GoawayProps, handover, pinnedTransport, target } from "./goaway.ts"; import { estimates, type Probe } from "./stats.ts"; +import { tick } from "./ticker.ts"; import { heldOf } from "./tokens.ts"; function sameTokens(a: readonly string[], b: readonly string[]): boolean { @@ -454,7 +455,8 @@ export class Reload { }; void sample(); - effect.interval(() => void sample(), BANDWIDTH_POLL); + // One page-wide ticker samples every connection in the same wakeup. + effect.cleanup(tick(BANDWIDTH_POLL, () => void sample())); }); // The live session holds the current tokens on AUTH; a dial picks them up itself. diff --git a/js/net/src/connection/ticker.test.ts b/js/net/src/connection/ticker.test.ts new file mode 100644 index 00000000000..c87600b4f76 --- /dev/null +++ b/js/net/src/connection/ticker.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; +import { Manual, Milli, Nano } from "@moq/clock"; +import { tick } from "./ticker.ts"; + +test("subscribers share one tick and the loop stops with the last", async () => { + const clock = new Manual(); + const installed = clock.install(); + try { + const period = Nano.fromMilli(Milli(100)); + const ticks: string[] = []; + const a = tick(period, () => ticks.push("a")); + await clock.advance(Nano.fromMilli(Milli(50))); + // Joins the running loop rather than starting its own out of phase. + const b = tick(period, () => ticks.push("b")); + await clock.advance(Nano.fromMilli(Milli(50))); + expect(ticks).toEqual(["a", "b"]); + + a(); + await clock.advance(period); + expect(ticks).toEqual(["a", "b", "b"]); + + b(); + b(); + await clock.advance(Nano.fromMilli(Milli(500))); + expect(ticks).toEqual(["a", "b", "b"]); + + // A stopped loop restarts for a later subscriber, rather than leaving the page unsampled. + tick(period, () => ticks.push("c")); + await clock.advance(period); + expect(ticks).toEqual(["a", "b", "b", "c"]); + } finally { + installed.close(); + } +}); diff --git a/js/net/src/connection/ticker.ts b/js/net/src/connection/ticker.ts new file mode 100644 index 00000000000..67be719faad --- /dev/null +++ b/js/net/src/connection/ticker.ts @@ -0,0 +1,65 @@ +import { Instant, type Nano, now, sleepUntil } from "@moq/clock"; + +/** + * One page-wide ticker shared by every caller with the same period. + * + * Each subscriber's callback runs once per tick, all in the same task, so N + * connections sampling `getStats()` cost one timer wakeup per period instead of N + * loops ticking out of phase. The loop runs only while a subscriber is registered. + */ +class Ticker { + readonly #period: Nano; + readonly #subscribers = new Set<() => void>(); + #abort?: AbortController; + + constructor(period: Nano) { + this.#period = period; + } + + /** Runs `fn` on every tick until the returned dispose is called. */ + subscribe(fn: () => void): () => void { + this.#subscribers.add(fn); + if (!this.#abort) this.#start(); + return () => { + if (!this.#subscribers.delete(fn)) return; + if (this.#subscribers.size === 0) { + this.#abort?.abort(); + this.#abort = undefined; + } + }; + } + + #start() { + const abort = new AbortController(); + this.#abort = abort; + void (async () => { + let next = Instant.add(now(), this.#period); + for (;;) { + await sleepUntil(next, { signal: abort.signal }).catch(() => {}); + if (abort.signal.aborted) return; + // Snapshot so a callback that unsubscribes doesn't skip a neighbour. + for (const fn of [...this.#subscribers]) { + try { + fn(); + } catch (err) { + console.error("ticker callback error", err); + } + } + next = Instant.add(now(), this.#period); + } + })(); + } +} + +const tickers = new Map(); + +/** Subscribes `fn` to the page-wide ticker for `period`; returns its dispose. */ +export function tick(period: Nano, fn: () => void): () => void { + if (period <= 0) throw new RangeError("ticker period must be positive"); + let ticker = tickers.get(period); + if (!ticker) { + ticker = new Ticker(period); + tickers.set(period, ticker); + } + return ticker.subscribe(fn); +} diff --git a/quest/m2/probe/README.md b/quest/m2/probe/README.md index cc45d9aedf3..981ecbc7d71 100644 --- a/quest/m2/probe/README.md +++ b/quest/m2/probe/README.md @@ -100,7 +100,6 @@ This README's own work, once the children land: ## Required - [Browser send estimate](/quest/m2/probe/browser-send-estimate.md) - browser publishers adapt to `getStats()`'s send estimate once Chrome and Safari report one -- [One page-wide getStats sampling ticker](/quest/m2/probe/stats-ticker.md) - a page's connections share one sampling ticker ## Related diff --git a/quest/m2/probe/browser-send-estimate.md b/quest/m2/probe/browser-send-estimate.md index 55898a60348..8df877a25b8 100644 --- a/quest/m2/probe/browser-send-estimate.md +++ b/quest/m2/probe/browser-send-estimate.md @@ -44,6 +44,5 @@ Public API: none expected. Wire: none. ## Related -- [One page-wide getStats sampling ticker](/quest/m2/probe/stats-ticker.md) - shares the `getStats()` sampling this reads - [Padding probes](/quest/m3/probe-padding.md) - raises the estimate once one exists - [Media stats](/quest/m3/stats/README.md) - its JS stats read PROBE for the downlink diff --git a/quest/m2/probe/stats-ticker.md b/quest/m2/probe/stats-ticker.md deleted file mode 100644 index 4b3ecf8dbc2..00000000000 --- a/quest/m2/probe/stats-ticker.md +++ /dev/null @@ -1,32 +0,0 @@ -# [XS] One page-wide getStats sampling ticker - -## Goal - -A page's `@moq/net` connections sample their send-rate estimate from one -shared ticker instead of one loop each, so many publishing connections on a -page cost one tick's work per interval. - -## Plan - -Decided 2026-11-03 (maintainer, follow-up planning after the 2026-11-03 /quest-afk and /quest-complete runs), milestone: - -- m0 -- m1 -- ✅ m2, in this epic - -From [moq-dev/moq#7524](https://github.com/moq-dev/moq/pull/7524), which -sampled `getStats()` only while a reservation reads it: loops ticking in -phase cost about a third of the main-thread time for 4 to 16 publishing -connections. Observed there, not implemented. - -- One module-level ticker that runs only while some connection has a - reservation reading its estimate, and samples each such connection per - tick. -- Measure main-thread time before and after with #7524's harness at 4 and - 16 connections. - -Public API: none. Wire: none. - -## Related - -- [Browser send estimate](/quest/m2/probe/browser-send-estimate.md) - where a browser without `getStats()` gets its estimate diff --git a/quest/m2/recut-held.md b/quest/m2/recut-held.md index 5dee6ce812c..0f0cf9e209b 100644 --- a/quest/m2/recut-held.md +++ b/quest/m2/recut-held.md @@ -31,4 +31,3 @@ padding probes already moved to m3 on 2026-11-06. - [Release on leave](/quest/m1/stream-credit/release-on-leave.md) - claim held stream-credit - [Browser send estimate](/quest/m2/probe/browser-send-estimate.md) - claim held probe -- [One page-wide getStats sampling ticker](/quest/m2/probe/stats-ticker.md) - claim held probe