Skip to content
Open
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
52 changes: 40 additions & 12 deletions .github/workflows/smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ jobs:
# Honor each published binary crate's Cargo.lock. Without --locked, the
# cargo channel can select a newly broken transitive dependency even when
# the crate was published and tested against a working resolution.
run: cargo install --locked moq-relay moq-cli moq-token-cli
run: cargo install --locked moq-relay moq-cli

- name: Install moq Rust packages (apt)
if: matrix.channel == 'apt'
Expand All @@ -130,13 +130,13 @@ jobs:
echo "deb [signed-by=/usr/share/keyrings/moq-keyring.gpg] https://apt.moq.dev stable main" \
| sudo tee /etc/apt/sources.list.d/moq.list
sudo apt-get update
sudo apt-get install -y moq-relay moq-cli moq-token-cli
sudo apt-get install -y moq-relay moq-cli

- name: Install moq Rust packages (brew)
if: matrix.channel == 'brew'
run: |
brew tap moq-dev/tap
brew install moq-dev/tap/moq-relay moq-dev/tap/moq-cli moq-dev/tap/moq-token-cli
brew install moq-dev/tap/moq-relay moq-dev/tap/moq-cli

- name: Install Nix (nix channel)
if: matrix.channel == 'nix'
Expand All @@ -153,11 +153,9 @@ jobs:
run: |
relay=$(nix build --refresh --no-link --print-out-paths 'github:moq-dev/moq#moq-relay')
cli=$(nix build --refresh --no-link --print-out-paths 'github:moq-dev/moq#moq-cli')
token=$(nix build --refresh --no-link --print-out-paths 'github:moq-dev/moq#moq-token-cli')
{
echo "RELAY_BIN=$relay/bin/moq-relay"
echo "MOQ_BIN=$cli/bin/moq"
echo "TOKEN_BIN=$token/bin/moq-token"
} >> "$GITHUB_ENV"

- name: Pull moqdev images (docker channel)
Expand Down Expand Up @@ -214,17 +212,17 @@ jobs:

# ── token interop ──────────────────────────────────────────────────
# Independent of the media matrix: prove the published token tooling
# cross-verifies. moq-token rides the same channel as moq-relay/moq
# (cargo/apt/brew/nix, on PATH or TOKEN_BIN); @moq/token comes from npm and
# runs under both node and bun; rust-docker pulls the moqdev/moq-token-cli
# cross-verifies. `moq auth` rides the same channel as moq-relay/moq
# (cargo/apt/brew/nix, on PATH or MOQ_BIN); @moq/auth comes from npm and
# runs under both node and bun; rust-docker pulls the moqdev/moq-cli
# image. The negative pass inside token.sh confirms each verifier rejects
# tampered tokens and the wrong key.
- name: Token interop
run: |
# The `rust` impl needs moq-token on PATH, which only the
# cargo/apt/brew/nix channels install. The docker channel ships no such
# binary, so it exercises the Rust verifier through the
# moqdev/moq-token-cli image (rust-docker) instead of plain `rust`.
# The `rust` impl needs a native moq binary, which only the
# cargo/apt/brew/nix channels install. The docker channel's MOQ_BIN is a
# wrapper that can't see token.sh's temp files, so it exercises the
# Rust verifier through the moqdev/moq-cli image (rust-docker) instead.
if [ "${{ matrix.channel }}" = "docker" ]; then
impls="js-node,js-bun,rust-docker"
else
Expand Down Expand Up @@ -274,3 +272,33 @@ jobs:
# failing the job; remove this once that protocol mismatch is fixed.
continue-on-error: true
run: ./moxygen.sh

from-dev:
name: Unpublished dev API
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 45

steps:
- name: Checkout smoke
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

- name: Checkout moq (main)
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
repository: moq-dev/moq
ref: main
Comment thread
coderabbitai[bot] marked this conversation as resolved.
path: moq
persist-credentials: false

- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable

- name: System deps
run: sudo apt-get update && sudo apt-get install -y pkg-config cmake g++

- name: From-dev contract cases
run: ./dev.sh --src "$PWD/moq" --timeout 30
39 changes: 23 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Cross-language interop smoke test for the **public** [Media over QUIC](https://g

The [moq-dev/moq](https://github.com/moq-dev/moq) monorepo has its own in-tree smoke test, but it builds every client from workspace source. That proves the code in the tree works; it does **not** prove a real user can install the published artifacts and have them talk to each other. A missing wheel, a stale Homebrew formula, a broken `.deb`, an export that didn't survive packaging, a Go module missing its header. none of that shows up until someone installs from a registry.

This repo installs each client straight from its public package registry, stands up a relay, and runs the interop matrix:
This repo installs each client straight from its public package registry, stands up a relay, and runs the interop matrix. A second **from-dev** channel (`./dev.sh`) installs the unpublished `main` surface from a moq checkout (path, or the tip of git `main`) and runs contract cases the published matrix cannot see yet: reconnecting `Connection` handles, announcements, credential refresh, publication replacement, catalog snapshots-then-deltas, and stats Snapshot versus Window. Embedded relay ownership stays in moq-relay; it is not a smoke client.

- A relay (`moq-relay`) routes broadcasts.
- For each publisher language, publish an H.264 broadcast.
Expand Down Expand Up @@ -84,18 +84,25 @@ RELAY_BIN=/path/to/moq-relay MOQ_BIN=/path/to/moq ./smoke.sh

# prove the harness can fail: no publisher, every subscriber must time out.
./smoke.sh --negative --subscribers rust,python

# unpublished dev API (JS packages + relay from a moq checkout, not npm/crates.io):
just dev --src /path/to/moq
just dev # clones github.com/moq-dev/moq (main tip; --ref for another)
```

`smoke.sh` installs the language clients (PyPI / Go proxy / npm) into a scratch dir on each run, so you always test the latest published versions. It does **not** install the Rust binaries; that is the channel under test.

`dev.sh` is the other way around: it builds `moq-relay` from `MOQ_SRC` (or clones the tip of `main`) and resolves `@moq/net`, `@moq/hang`, and `@moq/json` from that checkout's `js/` tree so the contract cases exercise the unpublished surface.

## Layout

```
smoke.sh orchestrator: relay + media interop matrix
smoke.sh orchestrator: relay + media interop matrix (published packages)
dev.sh orchestrator: unpublished dev API contract cases (path or git `main` tip)
cloudflare.sh orchestrator: Cloudflare client through both projects' relays
moxygen.sh orchestrator: moxygen protocol client through the moq-dev relay
smoke.toml relay config (anonymous, self-signed localhost)
token.sh orchestrator: moq-token generate/verify interop matrix
token.sh orchestrator: `moq auth` generate/verify interop matrix
clients/
python/smoke.py publish/subscribe via moq-rs (PyPI)
go/ publish/subscribe via moq-dev/moq-go (go get)
Expand All @@ -105,9 +112,10 @@ clients/
kotlin/ subscribe via dev.moq:moq (Gradle/JVM)
c/subscribe.c subscribe via libmoq (prebuilt release)
js-native/subscribe.ts subscribe via @moq/net + @moq/hang + WebTransport polyfill (node, bun)
dev/ from-dev contract cases: Connection, catalog Snapshot, stats Snapshot vs Window
(gst) subscribe via the moq-gst plugin (moqsrc); no client dir, driven by gst-launch
docker/ moq-relay + moq wrappers: docker run the moqdev/* images (the docker channel)
token/js/ installs @moq/token (npm) for token.sh to drive under node + bun
token/js/ installs @moq/auth (npm) for token.sh to drive under node + bun
cloudflare/ deterministic subgroup/datagram client using cloudflare/moq-rs Git HEAD
freshness.sh enforces the "always latest, no package locks" policy
.github/workflows/smoke.yml nightly + on-demand CI matrix (os x channel)
Expand All @@ -123,15 +131,14 @@ published flavours, and this test proves they cross-verify:

| Cell | Source under test | Install |
|---|---|---|
| `rust` | the `moq-token` binary (crates.io / Homebrew tap / apt repo / the moq flake) | `cargo install moq-token-cli`, `brew install moq-dev/tap/moq-token-cli`, `apt install`, `nix run github:moq-dev/moq#moq-token-cli` |
| `js-node` | npm [`@moq/token`](https://www.npmjs.com/package/@moq/token)'s `moq-token` CLI, run under **node** | `npm i @moq/token` |
| `js-bun` | the same published npm package, run under **bun** | `npm i @moq/token` |
| `rust-docker` | the [`moqdev/moq-token-cli`](https://hub.docker.com/r/moqdev/moq-token-cli) Docker Hub image (`:latest`) | `docker run moqdev/moq-token-cli …` |

Like `smoke.sh`, the Rust binary is taken from `PATH` (or `TOKEN_BIN`), preferring
`moq-token` and falling back to `moq-token-cli` while channels finish the rename;
`@moq/token` is installed from npm on each run; `rust-docker` `docker pull`s the
`moqdev/moq-token-cli`
| `rust` | `moq auth` from the moq CLI (crates.io / Homebrew tap / apt repo / the moq flake) | `cargo install moq-cli`, `brew install moq-dev/tap/moq-cli`, `apt install moq-cli`, `nix run github:moq-dev/moq#moq-cli` |
| `js-node` | npm [`@moq/auth`](https://www.npmjs.com/package/@moq/auth)'s `moq-auth` CLI, run under **node** | `npm i @moq/auth` |
| `js-bun` | the same published npm package, run under **bun** | `npm i @moq/auth` |
| `rust-docker` | `moq auth` in the [`moqdev/moq-cli`](https://hub.docker.com/r/moqdev/moq-cli) Docker Hub image (`:latest`) | `docker run moqdev/moq-cli auth …` |

Like `smoke.sh`, the `moq` binary is taken from `PATH` (or `TOKEN_BIN` / `MOQ_BIN`);
`@moq/auth` is installed from npm on each run; `rust-docker` `docker pull`s the
`moqdev/moq-cli`
image fresh (`:latest`) and runs the CLI in a throwaway container with the scratch
dir bind-mounted. The image is built `FROM nixos/nix` and ships the nix store, so
it's a genuinely different artifact from the `cargo`/`brew`/`apt` binaries — and
Expand All @@ -141,7 +148,7 @@ daemon); set `TOKEN_DOCKER=podman` to drive it with podman. For every
generator mints a key and signs a token, and the verifier checks it — covering
both symmetric (`HS256`, shared secret) and asymmetric (`EdDSA`/`ES256`/`RS256`,
sign-private/verify-public) keys, and the fact that one side's key encoding
(the Rust CLI writes base64url-JSON; `@moq/token` writes plain JSON) loads on the
(the Rust CLI writes base64url-JSON; `@moq/auth` writes plain JSON) loads on the
other. A negative pass then confirms each verifier **rejects** a tampered token
and a token signed by the wrong key, so a green cell means "accepts the valid
one and refuses the bad ones", not "accepts everything".
Expand All @@ -154,7 +161,7 @@ export that didn't survive `tsc`) shows up as a red cell.
```bash
just token # default: rust generates + verifies (roundtrip + negatives)
just token-full # full matrix: rust, js-node, js-bun + rust-docker (the
# moqdev/moq-token-cli image, where a container runtime is
# moqdev/moq-cli image, where a container runtime is
# available; set TOKEN_DOCKER=podman to use podman)
# or call it directly with explicit axes:
./token.sh --generators rust,js-node --verifiers rust,js-bun --algorithms HS256,EdDSA
Expand Down Expand Up @@ -185,7 +192,7 @@ This test tracks the **latest published** packages, so it sometimes runs ahead o
- **Native JS on node** (`js-native-node`): working. node briefly lagged bun here: `@moq/web-transport`'s `session.ts` did `import { NapiClient } from "../napi.js"` — a *named* import from a napi-rs CJS module whose exports node's ESM loader can't statically see, so node threw `does not provide an export named 'NapiClient'` while Bun's looser CJS interop accepted it. `@moq/web-transport` 0.1.2 shipped the predicted fix (default-import the now-`.cjs` binding, then destructure `NapiClient`), so this cell is green. Exactly the break-then-fix this repo exists to surface.
- **Go (any role)**: working. The `moq-dev/moq-go` module was un-buildable (stuck at v0.2.15, missing the generated `moq.h` header and the prebuilt static libs, so `go get` + build failed); v0.2.22 now ships `moq.h` plus `libmoq_ffi.a` for linux (amd64/arm64), darwin, and windows, and a `CGO_ENABLED=1 go build` against it links cleanly — verified in a linux/amd64 container, clearing the blocker that kept this cell red. One caveat the matrix doesn't see: building the Go client on **macOS** still fails to link, because the module's darwin cgo `LDFLAGS` omit `-framework CoreServices` (needed by the bundled Rust `notify` crate's FSEvents backend); CI only builds Go on Linux. Tracked upstream in moq-dev/moq's `go/moq/cgo.go`.
- **GStreamer subscribe** (`gst`): working. `moq-gst` ships apt/brew/rpm/tarball + nix artifacts, so the cell resolves the newest tag and selects the matching platform tarball from that release's asset metadata. The published plugin load-checks green — `gst-inspect-1.0 moq` exposes `moqsrc`/`moqsink` against a system GStreamer — and `moqsrc` reads a rust-published H.264 broadcast end-to-end.
- **Token interop** (`token.sh`): working on **cargo / apt / nix** plus the **`moqdev/moq-token-cli` Docker image** (Linux). The published `moq-token` binary (from crates.io / apt / nix / Docker Hub) and `@moq/token` (npm, under both node and bun) cross-verify every token across `HS256`, `EdDSA`, `ES256`, and `RS256`, and each verifier rejects tampered tokens and the wrong key. The Docker cell (`rust-docker`) proves the image — built `FROM nixos/nix`, so it carries the libiconv the brew bottle used to leak — runs cleanly. Subscriber-only languages don't ship token tooling yet, so the matrix is rust (binary + Docker) + the two JS runtimes for now.
- **Token interop** (`token.sh`): working on **cargo / apt / nix** plus the **`moqdev/moq-cli` Docker image** (Linux). The published `moq auth` subcommand (from crates.io / apt / nix / Docker Hub) and `@moq/auth` (npm, under both node and bun) cross-verify every token across `HS256`, `EdDSA`, `ES256`, and `RS256`, and each verifier rejects tampered tokens and the wrong key. The Docker cell (`rust-docker`) proves the image — built `FROM nixos/nix`, so it carries the libiconv the brew bottle used to leak — runs cleanly. Subscriber-only languages don't ship token tooling yet, so the matrix is rust (binary + Docker) + the two JS runtimes for now.
- **Token interop on the Homebrew bottle** (`rust` cells, macOS `brew`): working. The `moq-dev/tap/moq-token-cli` package's `moq-token` binary used to abort on launch — it baked in a `/nix/store/…-libiconv/lib/libiconv.2.dylib` rpath from the build sandbox that doesn't exist on a user's Mac (`dyld: Library not loaded`). The 0.5.31 bottle fixes it: its only `LC_RPATH` is now `/usr/lib`, so `@rpath/libiconv.2.dylib` resolves to the system libiconv and the binary runs (verified locally — `generate --algorithm HS256` succeeds, no leaked `/nix/store` rpath). `token.sh` still probes the binary once at startup, so a relapse would be caught again. Exactly the break-then-fix this repo exists to surface.
- **Cloudflare interoperability**: the Cloudflare client publishes and subscribes over WebTransport and raw QUIC through both `cloudflare/moq-rs`'s `moq-relay-ietf` and `moq-dev/moq`'s `moq-relay`, with sustained subgroup payloads checked byte-for-byte. Cloudflare's relay additionally exercises datagrams in both directions. This is a source-head smoke test, so a later upstream commit can intentionally turn it red.
- **Moxygen interoperability**: currently **red**. Moxygen's published source-head interop client negotiates draft-16 and passes 5/6 relay scenarios through `moq-dev/moq`, but `announce-subscribe` closes the subscriber session instead of routing it to the announced publisher. The failure reproduces over WebTransport and raw QUIC with the published relay, and over WebTransport with current moq-dev HEAD. CI runs the full Linux/amd64 Docker lane as non-blocking diagnostic coverage until the mismatch is fixed; `just moxygen` still exits nonzero locally.
Expand Down
105 changes: 105 additions & 0 deletions clients/dev/catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Catalog-only reading: Json.Snapshot.Consumer plus the hang catalog schema.
// A full snapshot is followed by a merge-patch delta. Parsing every frame as a
// catalog is the old consumer path and must fail on the delta frame.
import * as Catalog from "@moq/hang/catalog";
import * as Json from "@moq/json";
import * as Moq from "@moq/net";
import { connected, equal, handle, REPLAY_MS, waitActive, waitAnnounce } from "./lib.ts";

const PATH = "dev.catalog";

const SNAPSHOT: Catalog.Root = {
json: {
tracks: {
status: { mode: "snapshot" },
},
},
};

const UPDATED: Catalog.Root = {
json: {
tracks: {
extra: { mode: "snapshot" },
},
},
};

export async function catalog(url: URL): Promise<void> {
const pub = handle(url);
const sub = handle(url);
try {
const pubOrigin = await connected(pub);
const subOrigin = await connected(sub);
const announced = sub.announced();

const broadcast = pubOrigin.createBroadcast(Moq.Path.from(PATH));
const track = broadcast.createTrack(Catalog.TRACK);
const producer = new Json.Snapshot.Producer<Catalog.Root>({
track,
schema: Catalog.RootSchema,
deltaRatio: 100,
});
broadcast.announce();

await waitAnnounce(announced, PATH, true);
const request = subOrigin.request(Moq.Path.from(PATH));
const consumer = await waitActive(request, "catalog broadcast");

const snapshot = new Json.Snapshot.Consumer<Catalog.Root>({
track: consumer.track(Catalog.TRACK).subscribe({
priority: Catalog.PRIORITY.catalog,
maxAge: REPLAY_MS,
}),
schema: Catalog.RootSchema,
});

producer.update(SNAPSHOT);
const first = await snapshot.next();
if (!first || !equal(first, SNAPSHOT)) {
throw new Error(`first catalog was ${JSON.stringify(first)}`);
}

producer.update(UPDATED);
const second = await snapshot.next();
if (!second || !equal(second, UPDATED)) {
throw new Error(`delta did not reconstruct ${JSON.stringify(second)}`);
}

producer.finish();

const raw = consumer
.track(Catalog.TRACK)
.subscribe({ priority: Catalog.PRIORITY.catalog, maxAge: REPLAY_MS })
.ordered();
const group = await raw.nextGroup();
if (!group) throw new Error("catalog group missing");
const frames: Uint8Array[] = [];
for (;;) {
const frame = await group.readFrame();
if (!frame) break;
frames.push(frame.payload);
}
if (frames.length < 2) {
throw new Error(`expected a snapshot frame then a delta, got ${frames.length} frame(s)`);
}

const decoder = new TextDecoder();
const root = Catalog.RootSchema.parse(JSON.parse(decoder.decode(frames[0])));
if (!equal(root, SNAPSHOT)) throw new Error("frame 0 was not the full catalog snapshot");

let deltaParsedAsCatalog = false;
try {
Catalog.RootSchema.parse(JSON.parse(decoder.decode(frames[1])));
deltaParsedAsCatalog = true;
} catch {
// The delta is an RFC 7396 merge patch, not a catalog root.
}
if (deltaParsedAsCatalog) {
throw new Error("frame 1 parsed as a full catalog; the consumer is not reconstructing deltas");
}
} finally {
pub.close();
sub.close();
}
console.log(" catalog: ok");
}
Loading
Loading