From 3cf2908c597e0bd08edb84161a57b3ecb7f36bba Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 5 Nov 2026 22:29:31 -0800 Subject: [PATCH 1/8] chore(quest): claim quest/m2/probe/stats-ticker.md Quest-Claim: de9b6e72f008ab05 From 7124f3eceb07604935d6665ed51caac101b03b22 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 5 Nov 2026 22:30:08 -0800 Subject: [PATCH 2/8] perf(net): sample send-rate estimates from one page-wide ticker Co-Authored-By: Claude Opus 5.5 --- js/net/src/connection/reload.ts | 4 +- js/net/src/connection/ticker.test.ts | 29 +++++++++++++ js/net/src/connection/ticker.ts | 64 ++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 js/net/src/connection/ticker.test.ts create mode 100644 js/net/src/connection/ticker.ts diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 8074b972720..fc7a93f68f3 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..bcd7b0ad63c --- /dev/null +++ b/js/net/src/connection/ticker.test.ts @@ -0,0 +1,29 @@ +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"]); + } 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..7c605edf442 --- /dev/null +++ b/js/net/src/connection/ticker.ts @@ -0,0 +1,64 @@ +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 { + let ticker = tickers.get(period); + if (!ticker) { + ticker = new Ticker(period); + tickers.set(period, ticker); + } + return ticker.subscribe(fn); +} From b5702232b71c6c8b4e733c94aa02cd8f68b6a3aa Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 5 Nov 2026 22:55:36 -0800 Subject: [PATCH 3/8] fix(bench): pass bigint group sequences in the buffered microbench Co-Authored-By: Claude Opus 5.5 --- js/hang/bench/buffered.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/js/hang/bench/buffered.ts b/js/hang/bench/buffered.ts index 25eaad8d607..edce2c0a557 100644 --- a/js/hang/bench/buffered.ts +++ b/js/hang/bench/buffered.ts @@ -124,7 +124,7 @@ async function measure( if (scenario === "steady") { // The player reads each frame as it lands, so nothing builds up behind it. for (const i of order) { - const sources = tracks.map((track) => track.createGroup(i)); + const sources = tracks.map((track) => track.createGroup(BigInt(i))); for (let frame = 0; frame < frames; frame++) { const reads = players.map((player) => player.consumer.next()); for (const source of sources) source.writeFrame(wire(i, frame)); @@ -139,7 +139,7 @@ async function measure( // Everything arrives before the player reads: a stalled reader, or history after a join. for (const i of order) { - const sources = tracks.map((track) => track.createGroup(sequence(i))); + const sources = tracks.map((track) => track.createGroup(BigInt(sequence(i)))); for (let frame = 0; frame < frames; frame++) await arrive(sources, players, sequence(i), frame); for (const source of sources) source.finish(); } @@ -163,7 +163,7 @@ async function measure( // The budget drops to nothing, so the next arrival makes every older group too stale to keep. delay.set({ max: Milli.zero }); start = performance.now(); - const sources = tracks.map((track) => track.createGroup(groups)); + const sources = tracks.map((track) => track.createGroup(BigInt(groups))); await arrive(sources, players, groups, 0); const elapsed = (performance.now() - start) * 1e6; const newest = timestamp(groups, 0) / 1000; From 9848fce72daf905bdd9505a3a4e0b1db85e5dac6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 01:49:12 -0800 Subject: [PATCH 4/8] quest: leave only the stats-ticker measurement Co-Authored-By: Claude Opus 5.5 --- quest/m2/probe/stats-ticker.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/quest/m2/probe/stats-ticker.md b/quest/m2/probe/stats-ticker.md index 4b3ecf8dbc2..3880d5b35cf 100644 --- a/quest/m2/probe/stats-ticker.md +++ b/quest/m2/probe/stats-ticker.md @@ -19,10 +19,10 @@ 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 +- Done: `js/net/src/connection/ticker.ts` holds one module-level ticker that + runs only while some connection has a reservation reading its estimate, and + samples each such connection per tick. +- Remaining: measure main-thread time before and after with #7524's harness at 4 and 16 connections. Public API: none. Wire: none. From 822354cfb7cdd617336a71dc8e6355ab9ffadd41 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 10:39:25 -0800 Subject: [PATCH 5/8] fix(hang): let the buffered bench deliver sparse groups behind a newer one An authored track's omitted maxAge is zero, so a group arriving below the newest was never delivered and the sparse case hung the browser bench lane. Co-Authored-By: Claude Opus 5.5 --- js/hang/bench/buffered.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/js/hang/bench/buffered.ts b/js/hang/bench/buffered.ts index edce2c0a557..b414665d1de 100644 --- a/js/hang/bench/buffered.ts +++ b/js/hang/bench/buffered.ts @@ -104,8 +104,10 @@ async function measure( ): Promise<{ ns: number; ops: number; gaps: number; warnings: number }> { const delay = new Signal({ max: HOLD }); // Stamped like a hang publisher's media, so the transport finds its live edge at the newest group. + // No publisher max age, so HOLD alone decides what a player keeps, and a sparse group arriving + // behind a newer one is still delivered. const tracks = Array.from({ length: publishers }, (_, i) => - new Track.Producer(`p${i}`).accept({ timescale: Time.Timescale.MICRO }), + new Track.Producer(`p${i}`).accept({ timescale: Time.Timescale.MICRO, maxAge: null }), ); const players = tracks.flatMap((track) => Array.from({ length: readers }, () => open(track, delay))); let warnings = 0; From baa951ca5c734ee28fa795e5da0179efe16dcce7 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 11:08:53 -0800 Subject: [PATCH 6/8] quest: finish the page-wide stats ticker Co-Authored-By: Claude Opus 5.5 --- quest/m2/probe/README.md | 1 - quest/m2/probe/stats-ticker.md | 32 -------------------------------- 2 files changed, 33 deletions(-) delete mode 100644 quest/m2/probe/stats-ticker.md diff --git a/quest/m2/probe/README.md b/quest/m2/probe/README.md index 88931d6bae5..44cb3f158c8 100644 --- a/quest/m2/probe/README.md +++ b/quest/m2/probe/README.md @@ -97,7 +97,6 @@ This README's own work, once the children land: ## Required - [Browser send estimate](/quest/m2/probe/browser-send-estimate.md) - shipping Chrome and Safari publishers adapt to the relay's PROBE estimate, since they have no `getStats()` -- [One page-wide getStats sampling ticker](/quest/m2/probe/stats-ticker.md) - a page's connections share one sampling ticker - [Padding probes](/quest/m2/probe/padding.md) - a browser probes with a lowest-priority padding stream on an ALR schedule - [JS rung disable](/quest/m2/probe/rung-disable-js.md) - `@moq/publish` applies the native rung rule, recovering through padding - [Players set viewer targets](/quest/m2/probe/players.md) - players hold a viewer handle per watched broadcast at its highest enabled rendition's bitrate diff --git a/quest/m2/probe/stats-ticker.md b/quest/m2/probe/stats-ticker.md deleted file mode 100644 index 3880d5b35cf..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. - -- Done: `js/net/src/connection/ticker.ts` holds one module-level ticker that - runs only while some connection has a reservation reading its estimate, and - samples each such connection per tick. -- Remaining: 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 From 2f6510e34bbee60a7273761b276b9fe50ec9c79b Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:04:35 +0000 Subject: [PATCH 7/8] quest: drop the retired stats-ticker link from browser-send-estimate --- quest/m2/probe/browser-send-estimate.md | 1 - 1 file changed, 1 deletion(-) diff --git a/quest/m2/probe/browser-send-estimate.md b/quest/m2/probe/browser-send-estimate.md index d66e605951f..018ce1af028 100644 --- a/quest/m2/probe/browser-send-estimate.md +++ b/quest/m2/probe/browser-send-estimate.md @@ -36,6 +36,5 @@ Public API: none expected. Wire: none, unless the decision below picks a receive ## Related -- [One page-wide getStats sampling ticker](/quest/m2/probe/stats-ticker.md) - shares the `getStats()` sampling this reads - [Padding probes](/quest/m2/probe/padding.md) - raises the estimate once one exists - [Media stats](/quest/m3/stats/README.md) - its JS stats read PROBE for the downlink From 6241b404d5873adb375772623d952727c270346e Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:42:39 +0000 Subject: [PATCH 8/8] quest: drop the retired stats-ticker link from the recut #8021 deletes quest/m2/probe/stats-ticker.md, so recut-held.md's Required entry for it would dangle and quest check fails. The merge queue already holds #8021, and GitHub refuses any push to a queued branch, so this companion PR applies the one-line removal. --- quest/m2/recut-held.md | 1 - 1 file changed, 1 deletion(-) 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