Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
165377f
chore: claim shaper profiles quest
kixelated Sep 29, 2026
1c47bf8
feat(shaper): pipe TCP on the shaper's port to the target, as an option
fperex Sep 25, 2026
a909d5f
feat(shaper): a gaussian jitter model that keeps the order, as an option
fperex Sep 25, 2026
6b7964c
feat(shaper): one link each way for every client, as an option
fperex Sep 25, 2026
7e7b729
feat(shaper): batches that release datagrams together, as an option
fperex Sep 25, 2026
b6230c5
feat(shaper): steps that change a profile part-way through a run
fperex Sep 25, 2026
f56f477
feat(shaper): named profiles for both directions, loaded from TOML
fperex Sep 25, 2026
c630613
feat(shaper): a JSON report of the counters, as an option
fperex Sep 25, 2026
f564161
fix(shaper): send a datagram that is already due at once, not on a timer
fperex Sep 25, 2026
7804662
docs(shaper): a README for the shaper's usage and its options
fperex Sep 25, 2026
53137d6
test(audio-quality): grade bursty and changing paths
kixelated Sep 29, 2026
756200e
fix(shaper): verify fails a step no traffic reached, and a late datag…
kixelated Sep 29, 2026
d9a9de7
fix(shaper): refuse a step that changes nothing and a batch of one
kixelated Sep 29, 2026
748447b
fix(audio-quality): measure a step row from before its step, and coun…
kixelated Sep 29, 2026
2ecb0ab
fix(shaper): refuse jitter with no delay to vary
kixelated Sep 29, 2026
c1723fc
Merge remote-tracking branch 'origin/quest/m0/audio-quality-harness/R…
kixelated Sep 29, 2026
04070bc
Merge origin/quest/m0/audio-quality-harness/README into quest/m0/audi…
kixelated Sep 29, 2026
94c3483
test(audio-quality): count browser rows apart from replays; size the …
kixelated Sep 29, 2026
b7a48e6
fix(moq-shaper): verify every step's phase saw traffic
kixelated Oct 1, 2026
7ae87b1
Merge remote-tracking branch 'origin/quest/m0/audio-quality-harness/R…
kixelated Oct 1, 2026
01ab236
style(moq-shaper): format
kixelated Oct 1, 2026
beeb93d
fix(moq-shaper): require traffic before the first step too
kixelated Oct 1, 2026
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: 2 additions & 2 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ name: Nightly
# the wheel builds in a manylinux container, the Kotlin/Swift/Go/Dart packages
# link the FFI on every platform) and nothing notices until the tag is pushed.
#
# `audio-quality` is here because of what it measures. It plays sixteen rows of
# `audio-quality` is here because of what it measures. It plays twenty-four rows of
# audio in real time, a minute each, which no merge gate should carry, and every
# number it grades is a timing one that moves with whatever else the runner is
# doing. A per-PR arm would fail on unrelated load often enough to be ignored;
Expand Down Expand Up @@ -207,7 +207,7 @@ jobs:
audio-quality:
name: Audio quality
runs-on: ubuntu-24.04-arm
# Sixteen rows at a minute each, plus the relay, CLI, and shaper built from source.
# Twenty-four rows at a minute each, plus the relay, CLI, and shaper built from source.
timeout-minutes: 60
steps:
- name: Free disk space
Expand Down
14 changes: 14 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ hex = "0.4"
# HMAC-SHA256 for the mDNS membership proofs (moq-tokio's `mdns` feature).
hmac = "0.13"
humantime = "2.3"
humantime-serde = "1.1.1"
io-uring = "0.7.14"
jsonwebtoken = "11"
kio = { version = "0.6.1", path = "rs/kio" }
Expand Down
22 changes: 9 additions & 13 deletions quest/m0/audio-quality-harness/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Audio quality harness
# [S] Audio quality harness

## Goal

Expand All @@ -23,14 +23,14 @@ are. The native lane moved to m1 as a standalone quest (decided in the
same budgets and metric schema so the two implementations can be compared
rather than merely both passing.

The browser lane has landed as `test/audio-quality/`, upstreamed from the
reporter's fork on #3477 (`fperex/moq`, branch `debug-findings-solution`) on
its own, without the fork's player, estimator, or shaper changes. Real arrival
traces have landed beside it in `test/audio-quality/traces/`, graded as replay
rows through the player's container consumer and rings, and replayed by the
[jitter target's watch quest](/quest/m0/audio-jitter-target/watch.md). That
leaves the bursty and step profiles, which need shaper features the fork has
and `main` does not.
The browser lane and its opt-in shaper profiles have landed in `test/audio-quality/`,
upstreamed from the reporter's fork on #3477 (`fperex/moq`, branch
`debug-findings-solution`), without the fork's player or estimator changes.
The matrix includes bursty and mid-run step paths, and mild/wide jitter preserves
packet order. Real arrival traces have landed beside it in
`test/audio-quality/traces/`, graded as replay rows through the player's
container consumer and rings, and replayed by the [jitter target's watch
quest](/quest/m0/audio-jitter-target/watch.md).

Jitter comes from the seeded userspace UDP shaper the transport drills run
under (`rs/moq-shaper`, documented in `test/drill/README.md`), not from a fake
Expand All @@ -56,10 +56,6 @@ nightly runner's first runs, since nightly only runs `main`'s code. Tightening
the auto rows belongs to the [jitter target's watch
quest](/quest/m0/audio-jitter-target/watch.md).

## Required

- [Shaper profiles](/quest/m0/audio-quality-harness/shaper-profiles.md) - the bursty and mid-run step profiles, once moq-shaper can express them

## Related

- [Native audio quality](/quest/m1/audio-quality-native.md) - the same profiles and budgets through `moq play` on a dummy device
Expand Down
34 changes: 0 additions & 34 deletions quest/m0/audio-quality-harness/shaper-profiles.md

This file was deleted.

4 changes: 0 additions & 4 deletions quest/m1/audio-quality-native.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,3 @@ Standalone in m1 rather than a child of the m0 [Audio quality
harness](/quest/m0/audio-quality-harness/README.md) line (decided in the
2026-09-28 quest audit): nothing in m0 waits on it. The native jitter target
it grades is done on the jitter target line.

## Related

- [Shaper profiles](/quest/m0/audio-quality-harness/shaper-profiles.md) - the bursty and step profiles both lanes should run
7 changes: 7 additions & 0 deletions rs/moq-shaper/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,12 @@ doc = false
anyhow = { workspace = true }
clap = { workspace = true }
humantime = { workspace = true }
humantime-serde = { workspace = true }
rand = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
toml = { workspace = true }

[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
175 changes: 175 additions & 0 deletions rs/moq-shaper/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# moq-shaper

A seeded userspace UDP impairment relay, so a test can put an impaired path in front of a server.

The shaper binds one socket, forwards every datagram to a target after a profile's treatment, and
forwards the replies back. A client dials the shaper instead of the server and reaches it through a
path with loss, a rate limit, delay, jitter, and reordering. QUIC is indifferent to the extra hop, so
this impairs a real transport with no capabilities and nothing touching the host's network, on macOS
and Linux alike. Kernel impairment (`netem` in a network namespace) is Linux only, needs
`CAP_NET_ADMIN`, and changes the host's network for everything running on it.

It is a datagram relay, not an HTTP interceptor or a TCP proxy, neither of which can impair QUIC.

## Usage

```bash
moq-relay --listen 127.0.0.1:4443 &

moq-shaper --listen 127.0.0.1:4444 --target 127.0.0.1:4443 \
--delay 20ms --jitter 5ms --loss 0.05 --reorder 0.02 --rate 10000000 --seed 1
```

Point the client at the shaper's port. The flags build one profile, applied both ways. The seed and
profile print at start, and the counters at exit (Ctrl-C or SIGTERM), which exits nonzero if the
profile never acted.

| Flag | What it does |
| --- | --- |
| `--listen` | The address clients send to. |
| `--target` | The address every datagram is forwarded to. |
| `--seed` | Seeds every decision; the profile's own, or random, when omitted. |
| `--delay` | The base one-way delay. |
| `--jitter` | How far the delay varies: the most either way, or the sigma of a gaussian. |
| `--jitter-model` | `uniform` (the default) or `gaussian`. See [Jitter](#jitter). |
| `--loss` | The probability that a datagram is dropped. |
| `--reorder` | The probability that a datagram skips the delay, overtaking those in flight. |
| `--rate`, `--burst`, `--queue` | A token bucket: bits per second, the bytes it lets out ahead after an idle spell (1500), and the longest a datagram waits before it is dropped (100ms). |
| `--batch`, `--batch-window` | Hold datagrams until this many wait, or the window closes, then release them together. See [Batches](#batches). |
| `--shared` | Every client shares one link each way. See [Shared path](#shared-path). |
| `--profile` | A built-in profile's name, or a profile TOML file, in place of the flags that shape the path. See [Profiles](#profiles). |
| `--report` | Write the profile, seed and counters to this file as JSON at exit. See [Report](#report). |
| `--report-interval` | Print the same JSON as one line on stdout this often. |

As a library, `Shaper::bind` takes a `Config`: where to listen and forward, the seed, and a `Profile`
per direction. It also takes a `Setup`, which is a `Config` plus the opt-in options below, each off by
default. `Shaper::verify` fails when the shaper stopped forwarding, when an impairment the profile
configures never acted and the traffic makes that silence implausible, or when a direction carried
traffic but no datagram saw one of its phases, before the first step or after one. The relay's drills
(`rs/moq-relay/tests/drills.rs`, described in `test/drill/README.md`) run every scenario through it.

## What a seed does and does not fix

Every decision comes from the seed: each link draws loss, reorder and jitter from its own stream, in the
same order for every datagram whether or not the knob is on. Replay a run with the same seed and the
same datagrams are dropped, the same ones skip the delay, and each draws the same jitter.

The seed does not make the kernel's delivery clock reproducible: when a held datagram actually lands is
the host's scheduling. A test asserting on a replay should assert on the decisions, not on arrival times.

## Jitter

The default, `uniform`, draws each datagram's delay on its own within `jitter` either way of `delay`, so a
later datagram can overtake an earlier one. A drill wants that, and `jitter` may not exceed `delay`.

`gaussian` treats `jitter` as a sigma, clamps the draw at zero, and never lets a datagram leave before the
one in front of it: queueing delay on a FIFO path, which stretches and compresses the spacing and never
changes the order. The sigma may exceed `delay`, but a nonzero `jitter` still needs a nonzero `delay`. Only `reorder` overtakes. This matters beyond realism: QUIC reads an overtake as loss,
retransmits and backs off, so a run meant to measure a jittery path ends up measuring the congestion
response. On a 5ms delay with 5ms of jitter, reordering alone dragged a receiver's audio buffer from
120ms to nearly two seconds.

## Shared path

Each client flow gets a link of its own each way by default: its own seeded stream, rate limit and queue,
so its decisions do not depend on anyone else's traffic.

With `shared`, every flow takes one link each way, the way clients behind one access link do. A browser
page with a WebTransport session of its own and another in its audio worker is two flows on one path:
shared, a gaussian path keeps the order across both, a batch fills from both, and one rate limit serves
both. A flow's treatment then depends on how its datagrams interleave with the others'.

## Batches

A paced hop, a Wi-Fi access point or a cellular scheduler bunches traffic: nothing moves for a while, then
several datagrams arrive at once. A batch holds datagrams until `count` are waiting, or until `window`
after the first arrived, then sends them all when the latest would have left. No delay or jitter produces
that clump, and it is what a receiver's jitter estimate has to cope with. A `count` below two is refused,
since the datagram that fills a batch never waits.

## Steps

A step changes a direction's delay, jitter, loss, reorder or rate limit once the run reaches `at`, counted
from when forwarding started. It changes only what it names, so a later step puts one knob back. A rate
step keeps the old bucket's credit, or its debt, clipped to the new one, and a limit that was not there
starts full. A step can add or change a rate limit, never remove one. A step that leaves the profile as
it was, or comes at zero, is refused. Steps come from a profile file.
A direction that carried traffic fails `verify` unless some datagram saw each phase: the profile before
the first step and after every step. Traffic that starts late, ends early, or goes quiet across a step
never saw the path change.

## Profiles

A profile file names both directions' treatment, the seed, and whether the path is shared. `up` is from
the client to the target, `down` the way back. Every field of a direction is optional and off when left
out, and unknown fields are refused.

```toml
name = "example"
seed = 7
shared = true

[up]
delay = "5ms"
jitter = "5ms"
jitter_model = "gaussian" # required once the delay varies, through jitter or steps
loss = 0.02
reorder = 0.01 # skips the delay, so it needs one

[up.rate]
bits_per_second = 2000000
burst = 12500
queue = "250ms"

[up.batch]
count = 7
window = "160ms"

[[up.steps]] # at 30s the delay grows,
at = "30s"
delay = "60ms"

[[up.steps]] # and at 60s it recovers
at = "60s"
delay = "5ms"

[down]
delay = "5ms"
```

`--profile` takes a file's path, or one of the built-ins in `profiles/`, embedded in the binary. The
audio-quality lane (`test/audio-quality/`) uses `bursty` and `step`, plus its own flag profiles.

| Name | What it models |
| --- | --- |
| `near-zero` | The control. The shaper is in the path but treats nothing. |
| `mild` | A healthy wired LAN: 5ms delay, 5ms sigma, in order. |
| `bursty` | A paced hop: seven datagrams per 160ms window, released together. |
| `step` | 5ms for thirty seconds, then 60ms, with no recovery. |

Each built-in is a shared path, as a browser page's sessions on one host would be.

## Counters

`Shaper::stats` counts what each direction did, summed over every client.

| Counter | Meaning |
| --- | --- |
| `packets` | Datagrams received. |
| `lost` | Datagrams dropped by `loss`. |
| `overflowed` | Datagrams dropped because the rate limit's queue was full. |
| `throttled` | Datagrams that waited for the rate limit. |
| `delayed` | Datagrams given a nonzero delay, by the profile or by a batch holding them. |
| `reordered` | Datagrams sent ahead of the delay, overtaking any still in flight. |

## Report

`--report <file>` writes `{"profile", "seed", "up", "down"}` at exit, before the verdict, so a run that
fails it still leaves its counters behind. `profile` is the profile's name, or null when the flags built
it, and `up` and `down` hold the counters above. `--report-interval` prints the same object as one line on
stdout each period, which also survives a shaper killed before it could write the file.

A profile that silently did nothing turns an impaired run into an unimpaired pass. `verify` catches that
for the shaper's own exit; a harness grading a run should also check the report, where `near-zero` is the
only profile for which a `delayed` of zero is the right answer. Keep the name, the seed and the counters
with the run's artifacts: the seed is what turns a failing run into one that can be looked at again.
16 changes: 16 additions & 0 deletions rs/moq-shaper/profiles/bursty.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# A paced hop that bunches traffic: nothing moves until seven datagrams are
# waiting or the window closes, and then they all leave together.
#
# This is the shape a receiver's jitter estimate has to see, and no delay-and-
# spread profile produces it: the datagrams arrive together rather than late.
name = "bursty"
seed = 3
shared = true

[up.batch]
count = 7
window = "160ms"

[down.batch]
count = 7
window = "160ms"
15 changes: 15 additions & 0 deletions rs/moq-shaper/profiles/mild.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# A healthy wired LAN: a few milliseconds of delay with the same spread again,
# queued in order.
name = "mild"
seed = 2
shared = true

[up]
delay = "5ms"
jitter = "5ms"
jitter_model = "gaussian"

[down]
delay = "5ms"
jitter = "5ms"
jitter_model = "gaussian"
7 changes: 7 additions & 0 deletions rs/moq-shaper/profiles/near-zero.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# The control: the shaper sits in the path but treats nothing.
#
# Every other profile is graded against this one, and a harness asserting that an
# impairment applied needs a profile where `delayed == 0` is the right answer.
name = "near-zero"
seed = 1
shared = true
24 changes: 24 additions & 0 deletions rs/moq-shaper/profiles/step.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# A path that gets worse mid-run: 5ms for the first thirty seconds, then 60ms.
#
# An estimator that only ever converges downward passes every steady profile and
# fails this one. It never recovers, which is what makes it the baseline a
# recovery profile is read against.
name = "step"
seed = 4
shared = true

[up]
delay = "5ms"
jitter_model = "gaussian"

[[up.steps]]
at = "30s"
delay = "60ms"

[down]
delay = "5ms"
jitter_model = "gaussian"

[[down.steps]]
at = "30s"
delay = "60ms"
Loading
Loading