Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion js/net/src/connection/reload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
34 changes: 34 additions & 0 deletions js/net/src/connection/ticker.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
Comment thread
kixelated marked this conversation as resolved.
Resolved

// A stopped loop restarts for a later subscriber, rather than leaving the page unsampled.
tick(period, () => ticks.push("c"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The restart subscriber's dispose is dropped, so the module-level 100 ms ticker keeps one subscriber after this test returns. installed.close() only swaps the ambient clock; the loop stays parked on this Manual's deadline, and the period is the same one bandwidth.test.ts samples on, so a later test file that installs its own clock never ticks — I reproduced that with a subscribe/close/reinstall sequence. Keep the dispose and call it after the assertion; the restart is already proven by the c tick.

await clock.advance(period);
expect(ticks).toEqual(["a", "b", "b", "c"]);
} finally {
installed.close();
}
});
65 changes: 65 additions & 0 deletions js/net/src/connection/ticker.ts
Original file line number Diff line number Diff line change
@@ -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<Nano, Ticker>();

/** 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);
Comment thread
kixelated marked this conversation as resolved.
Resolved
if (!ticker) {
ticker = new Ticker(period);
tickers.set(period, ticker);
}
return ticker.subscribe(fn);
}
1 change: 0 additions & 1 deletion quest/m2/probe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 0 additions & 1 deletion quest/m2/probe/browser-send-estimate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
32 changes: 0 additions & 32 deletions quest/m2/probe/stats-ticker.md

This file was deleted.

1 change: 0 additions & 1 deletion quest/m2/recut-held.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading