From 10d27e3ad600f69d12676b77400a30c14bbf2be6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 13 Oct 2026 12:04:26 -0700 Subject: [PATCH 1/4] quest(m0): claim wire-compat-green From 068cc1e5fe7700bf5bfd432a3e092ae8345053f2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 13 Oct 2026 12:21:29 -0700 Subject: [PATCH 2/4] test(interop): released wire compat runs its own observer again The released JS lanes copied the checkout's interop subscriber into the released @moq/* environment, so #5099's `catalog.latest()` broke every released-package cell. compat/client.ts now carries the observer, built on the group/frame primitives and the snapshot decoder that both package sets share; one INTEROP_COMPAT_CLIENT replaces the three staging variables and subscribe.ts drops its compat-only paths. Re-measured the planned breaks against moq-cli 0.14.4, moq-relay 0.17.4, hang 0.21.13, @moq/net 0.4.3: lite-fetch-released-relay no longer reproduces and is removed; moq-lite-07-wip and catalog-archive persist with refreshed pins, pending maintainer re-acceptance. Co-Authored-By: Claude Opus 5.5 --- quest/m0/README.md | 7 +- quest/m0/wire-compat-green.md | 46 --------- test/interop/README.md | 8 +- test/interop/clients/js-native/subscribe.ts | 41 ++------ test/interop/compat.sh | 26 ++--- test/interop/compat/client.ts | 108 ++++++++++++++++++++ test/interop/compat/planned-breaks.json | 26 ++--- test/interop/interop.sh | 35 ++++--- 8 files changed, 161 insertions(+), 136 deletions(-) delete mode 100644 quest/m0/wire-compat-green.md diff --git a/quest/m0/README.md b/quest/m0/README.md index 164baba136..f586a46f82 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -57,10 +57,8 @@ that depend on them. Interop on main (2026-10-13): the cross-client matrix fails with `unroutable` whenever WebSocket wins the connect race: since #5294, #4180's upgrade -retires epochless handles before its held `Restart` reaches anyone. The -released wire-compat step aborts on planned breaks the last release made -stale. Both stay in m0 (maintainer, 2026-10-13): a red Interop hides real -regressions. +retires epochless handles before its held `Restart` reaches anyone. It +stays in m0 (maintainer, 2026-10-13): a red Interop hides real regressions. Crashes (2026-10-13, #5407): a publisher on a host whose wall clock reads before 2020 panics, and `panic = "abort"` turns any library panic into a @@ -82,7 +80,6 @@ have fixes on `main`: macOS tests (#5335), drill-sensitivity (#5338), loom - [Upgrade restarts epochless broadcasts](/quest/m0/upgrade-unroutable.md) - a WebSocket-to-QUIC upgrade retires epochless handles only once their `Restart` is delivered, and first-party consumers follow it, fixing Interop's `unroutable` rounds - [Unset host clock](/quest/m0/wall-clock-omit.md) - a publisher whose wall clock reads before 2020 publishes without a catalog clock instead of panicking - [Panic isolation](/quest/m0/panic-isolation.md) - unwind, ignore lock poison, and supervise every task, so a panic closes only its connection or exits on a long-lived task -- [Wire compat on main](/quest/m0/wire-compat-green.md) - the nightly released-wire step runs again: planned breaks re-measured against the new releases, and a released-API client for the released JS lanes - [Release builds link](/quest/m0/release-link.md) - the Linux zigbuild release binaries and moq-gst packages link again after the 2026-10-11 nixpkgs bump, so the next release cut can publish - [Fuzz builds under LTO](/quest/m0/fuzz-lto.md) - the nightly fuzz targets build again despite the release profile's fat LTO - [Audio quality budgets](/quest/m0/audio-quality-budgets.md) - the nightly Audio quality job grades against ceilings measured on its own runner, so a breach means a regression again diff --git a/quest/m0/wire-compat-green.md b/quest/m0/wire-compat-green.md deleted file mode 100644 index 5e1f28cc47..0000000000 --- a/quest/m0/wire-compat-green.md +++ /dev/null @@ -1,46 +0,0 @@ -# [S] Released wire compatibility runs again on main - -## Goal - -Interop's `Released wire compatibility` step (`just test wire-compat`) runs -its whole matrix against the newest releases on `main`'s nightly. Every cell -passes or is skipped by a planned break the maintainer re-accepted against -those exact releases. - -## Plan - -Two independent causes, both on the scheduled Interop run of 2026-10-12 -(38284317921) and earlier: - -- Stale planned breaks. A release went out with moq-cli 0.14.3, moq-relay - 0.17.3, hang 0.21.12, and @moq/net 0.4.3. Every entry in - `test/interop/compat/planned-breaks.json` pins the previous releases, so - `resolve.ts` aborts with `stale planned break: moq-lite-07-wip, moq-cli - changed from 0.14.2 to 0.14.3` before any cell runs. That is the tripwire - working as designed. Re-measure each entry against the new releases: delete - an entry whose break is gone (the `catalog-archive` reshape and the released - relay's FETCH hang may have shipped fixed), and refresh the pinned versions - and reason of one that persists. Get the maintainer's acceptance in the PR, - as the existing reasons record. -- The released JS lanes run this checkout's client. `compat.sh` copies - `test/interop/clients/js-native/subscribe.ts` into the released `@moq/*` - environment. #5099 (2026-10-10) changed that script to call - `catalog.latest()` on a `Json.Snapshot.Consumer`, a method released - `@moq/json` 0.4.2 lacks (`js/json/src/snapshot/consumer.ts`). Every - `publisher=released fetch` cell has failed with `catalog.latest is not a - function` since the 2026-10-11 nightly. Decided by the maintainer - 2026-10-13 on #5388: the compat lanes run their own observer in - `test/interop/compat/client.ts`, using only API the released packages - have, and stop sharing the interop client, whose API follows the checkout. - Staging the client from the released package's tag was rejected, since it - ties the compat run to old harness flags. - -Run `just test wire-compat` locally (it needs `gh` registry access) before -and after. Don't dispatch the nightly on a branch. - -Public API: none. Wire: none. - -## Related - -- [#5099](https://github.com/moq-dev/moq/pull/5099) - the API change the shared client picked up -- [#4728](https://github.com/moq-dev/moq/pull/4728) - the released wire-compat harness and its planned-break tripwire diff --git a/test/interop/README.md b/test/interop/README.md index 1dcbf80ce5..e34fd31496 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -187,13 +187,15 @@ The relay is anonymous here, since released binaries may predate AUTH; the default matrix covers tokens and grants. Current Rust media publishers feed released Rust and JS readers, then released publishers feed current readers, through both relay sources. Rust exports must -decode to a video frame through ffmpeg. The existing JS subscriber -reconstructs the catalog and decodes the container. +decode to a video frame through ffmpeg. The JS reader is +`compat/client.ts`, not the interop subscriber, whose API follows the +checkout. One copy runs against each package set, using only API both have, +and it reconstructs the catalog and decodes the container. Each `moq-net` library states whether a draft has FETCH (lite-05 onward today). A draft without it in both is logged and its FETCH lanes skipped; one the release supports and the checkout dropped fails. Both Rust versions FETCH the group the -publisher's own JS subscriber observed and compare every frame's exact +publisher's own compat client observed and compare every frame's exact payload. JS publishers write one group after that subscriber's demand, and the opposite-source Rust CLI FETCHes it by the same observed ID. diff --git a/test/interop/clients/js-native/subscribe.ts b/test/interop/clients/js-native/subscribe.ts index b7b3b17f98..a2ad4c9c6c 100644 --- a/test/interop/clients/js-native/subscribe.ts +++ b/test/interop/clients/js-native/subscribe.ts @@ -12,7 +12,6 @@ * * @module */ -import { writeFileSync } from "node:fs"; import { parseArgs } from "node:util"; import * as Catalog from "@moq/hang/catalog"; import * as Container from "@moq/hang/container"; @@ -37,8 +36,6 @@ const { positionals, values } = parseArgs({ url: { type: "string" }, broadcast: { type: "string" }, timeout: { type: "string", default: "20" }, - "track-file": { type: "string" }, - track: { type: "string" }, }, }); @@ -67,19 +64,14 @@ async function video(bc: Moq.Broadcast.Consumer): Promise<[string, Catalog.Video async function run(): Promise { const origin = new Moq.Origin.Producer(); - // A released-compat run pins its version through its own transport and runs anonymous. - // Otherwise the grant arrives over AUTH, which only the work-in-progress moq-lite-07 - // carries and no client offers by default. The WebSocket fallback cannot offer it, so it - // stays off. - const compat = process.env.INTEROP_COMPAT_TRANSPORT; - const connection: Moq.Connection.Established = compat - ? await (await import(compat)).connect({ url: new URL(url as string), consume: origin }) - : await Moq.Connection.connect({ - url: new URL(url as string), - consume: origin, - webtransport: { protocols: ["moq-lite-07-wip"] }, - websocket: { enabled: false }, - }); + // The grant arrives over AUTH, which only the work-in-progress moq-lite-07 carries and no + // client offers by default. The WebSocket fallback cannot offer it, so it stays off. + const connection = await Moq.Connection.connect({ + url: new URL(url as string), + consume: origin, + webtransport: { protocols: ["moq-lite-07-wip"] }, + websocket: { enabled: false }, + }); // The grant the relay sent, in the Rust client's `auth granted` shape, so the harness can // check it against the token this cell minted. const printGrant = (grant: Moq.Auth.Grant | undefined) => { @@ -88,11 +80,8 @@ async function run(): Promise { const subscribe = JSON.stringify(grant.subscribe); console.error(`auth granted publish=${publish} subscribe=${subscribe}`); }; - let unwatch = () => {}; - if (!compat) { - printGrant(connection.auth.grant.peek()); - unwatch = connection.auth.grant.subscribe(printGrant); - } + printGrant(connection.auth.grant.peek()); + const unwatch = connection.auth.grant.subscribe(printGrant); let requested: Moq.Origin.Requesting | undefined; try { const path = Moq.Path.from(broadcast as string); @@ -103,16 +92,6 @@ async function run(): Promise { bc = requested.active.peek(); } - if (values["track-file"]) { - // Name a group that live demand is filling, so a FETCH of it never races eviction. - // The track is `--track`, or the catalog's first video rendition. - const name = values.track ?? (await video(bc))[0]; - const group = await bc.track(name).subscribe({ priority: 0 }).recvGroup(); - if (!group) throw new Error(`${name} ended before its first group`); - writeFileSync(values["track-file"], `${name}\n${group.sequence}\n`); - return; - } - const [name, config] = await video(bc); let format: Container.Format; if (config.container.kind === "legacy") { diff --git a/test/interop/compat.sh b/test/interop/compat.sh index a16e1a2222..02d80fed75 100755 --- a/test/interop/compat.sh +++ b/test/interop/compat.sh @@ -57,9 +57,10 @@ RELEASED="$HARNESS_RUN/released/bin/moq" bun "$DIR/compat/resolve.ts" matrix "$HARNESS_RUN/current-help" "$HARNESS_RUN/released-help" "$DIR/compat/planned-breaks.json" "$HARNESS_RUN/shared" "$HARNESS_RUN/versions.json" # A separate package root prevents bun workspaces from replacing released deps -# with the checkout. Copy exactly the same clients into both environments. +# with the checkout. Copy exactly the same client into both environments; it uses +# only API both package sets have, so it never follows the checkout's interop client. mkdir -p "$HARNESS_RUN/js" -cp "$DIR/compat/client.ts" "$DIR/compat/transport.ts" "$DIR/clients/js-native/subscribe.ts" "$HARNESS_RUN/js/" +cp "$DIR/compat/client.ts" "$DIR/compat/transport.ts" "$HARNESS_RUN/js/" python3 - "$HARNESS_RUN/versions.json" "$HARNESS_RUN/js/package.json" <<'PY' import json, sys versions = json.load(open(sys.argv[1])) @@ -157,9 +158,9 @@ cell() { } fetch() { - local relay="$1" pub="$2" sub="$3" js="$4" transport="$5" version="$6" + local relay="$1" pub="$2" sub="$3" js="$4" version="$5" RELAY_BIN="$relay" MOQ_BIN="$pub" INTEROP_SUB_MOQ="$sub" INTEROP_VERSION="$version" \ - INTEROP_NATIVE_CLIENT="$js" INTEROP_COMPAT_TRANSPORT="$transport" INTEROP_FETCH_TRACK=1 \ + INTEROP_COMPAT_CLIENT="$js" INTEROP_FETCH_TRACK=1 \ bash "$DIR/interop.sh" --publishers rust --subscribers rust } @@ -186,29 +187,24 @@ while read -r version <&3; do for relay_source in current released; do if [[ "$relay_source" == current ]]; then relay="$TARGET/debug/moq-relay"; else relay="$released_relay"; fi for source in current released; do - # Each side's JS environment and the version-pinning transport beside it. + # Each side's compat client, with its version-pinning transport beside it. if [[ "$source" == current ]]; then - pub="$CURRENT" sub="$RELEASED" publisher_js="$DIR/compat" - own_js="$DIR/clients/js-native" own_transport="$DIR/compat/transport.ts" - other_js="$HARNESS_RUN/js" other_transport="$HARNESS_RUN/js/transport.ts" + pub="$CURRENT" sub="$RELEASED" own_js="$DIR/compat" other_js="$HARNESS_RUN/js" else - pub="$RELEASED" sub="$CURRENT" publisher_js="$HARNESS_RUN/js" - own_js="$HARNESS_RUN/js" own_transport="$HARNESS_RUN/js/transport.ts" - other_js="$DIR/clients/js-native" other_transport="$DIR/compat/transport.ts" + pub="$RELEASED" sub="$CURRENT" own_js="$HARNESS_RUN/js" other_js="$DIR/compat" fi cell media "$version" "$relay_source" "$source" env RELAY_BIN="$relay" MOQ_BIN="$pub" INTEROP_SUB_MOQ="$sub" INTEROP_VERSION="$version" \ - INTEROP_NATIVE_CLIENT="$other_js" INTEROP_COMPAT_TRANSPORT="$other_transport" INTEROP_DECODE=1 bash "$DIR/interop.sh" --subscribers rust,js-native-node + INTEROP_COMPAT_CLIENT="$other_js" INTEROP_DECODE=1 bash "$DIR/interop.sh" --subscribers rust,js-native-node [[ "$released_fetch" == true && "$current_fetch" == true ]] || continue # Drive JS publication from actual subscriber demand: the publisher's own JS # subscribes and observes the group, which the opposite Rust CLI then # FETCHes by ID. Reading the newest group races its live delivery. cell js-publish "$version" "$relay_source" "$source" env RELAY_BIN="$relay" MOQ_BIN="$pub" INTEROP_SUB_MOQ="$sub" INTEROP_VERSION="$version" \ - INTEROP_JS_PUBLISH_CLIENT="$publisher_js" INTEROP_NATIVE_CLIENT="$own_js" INTEROP_COMPAT_TRANSPORT="$own_transport" \ - INTEROP_FETCH_DATA=1 bash "$DIR/interop.sh" --publishers js-native --subscribers rust + INTEROP_COMPAT_CLIENT="$own_js" INTEROP_FETCH_DATA=1 bash "$DIR/interop.sh" --publishers js-native --subscribers rust # Both Rust sources fetch the same group and compare exact payloads. The # publisher's own JS finds the group, so a catalog break fails only the # media cell. - cell fetch "$version" "$relay_source" "$source" fetch "$relay" "$pub" "$sub" "$own_js" "$own_transport" "$version" + cell fetch "$version" "$relay_source" "$source" fetch "$relay" "$pub" "$sub" "$own_js" "$version" done done done 3<"$HARNESS_RUN/shared" diff --git a/test/interop/compat/client.ts b/test/interop/compat/client.ts index 27ef8213ab..84f165a7c5 100644 --- a/test/interop/compat/client.ts +++ b/test/interop/compat/client.ts @@ -1,11 +1,90 @@ import { readFileSync, writeFileSync } from "node:fs"; +import { parseArgs } from "node:util"; import * as Auth from "@moq/auth"; import * as Catalog from "@moq/hang/catalog"; import * as Container from "@moq/hang/container"; +import * as Json from "@moq/json"; import * as Moq from "@moq/net"; import { install } from "@moq/web-transport"; import { connect } from "./transport"; +// This one client runs against both the checkout's and the released packages, so it sticks to +// API both have: the group and frame primitives and the snapshot decoder, not the consumers above +// them, whose shapes follow the checkout. + +/** Find the catalog's first video rendition, reading updates until one has it. */ +async function video(bc: Moq.Broadcast.Consumer): Promise<[string, Catalog.VideoConfig]> { + const track = bc.track("catalog.json").subscribe({ priority: Catalog.PRIORITY.catalog }); + const decoder = new Json.Snapshot.Decoder({ schema: Catalog.RootSchema }); + for (;;) { + const group = await track.recvGroup(); + if (!group) throw new Error("catalog ended without a video track"); + // Each group opens with a snapshot; the frames after it are deltas. + let first = true; + for (let frame = await group.readFrame(); frame; frame = await group.readFrame()) { + if (first) decoder.snapshot(frame.payload); + else decoder.delta(frame.payload); + first = false; + const rendition = Object.entries(decoder.decode()?.video?.renditions ?? {})[0]; + if (rendition) return rendition; + } + } +} + +/** Print `received N bytes` once a video frame decodes, or record the group live demand fills. */ +async function subscribe(url: URL, broadcast: string, trackFile?: string, trackName?: string) { + install(); + const origin = new Moq.Origin.Producer(); + const connection = await connect({ url, consume: origin, websocket: { enabled: false } }); + const requested = origin.request(Moq.Path.from(broadcast), { announced: true }); + try { + let bc = requested.active.peek(); + while (!bc) { + await requested.active.changed(); + bc = requested.active.peek(); + } + + if (trackFile) { + // Name a group that live demand is filling, so a FETCH of it never races eviction. + const name = trackName ?? (await video(bc))[0]; + const group = await bc.track(name).subscribe({ priority: 0 }).recvGroup(); + if (!group) throw new Error(`${name} ended before its first group`); + writeFileSync(trackFile, `${name}\n${group.sequence}\n`); + return; + } + + const [name, config] = await video(bc); + if (config.container.kind !== "legacy") { + throw new Error(`unsupported video container: ${JSON.stringify(config.container)}`); + } + const format = new Container.Legacy.Format("video"); + const track = bc.track(name).subscribe({ priority: 0 }); + for (let group = await track.recvGroup(); group; group = await track.recvGroup()) { + try { + for (let frame = await group.readFrame(); frame; frame = await group.readFrame()) { + const bytes = format.decode(frame.payload).reduce((n, f) => n + f.payload.byteLength, 0); + if (bytes > 0) { + // The harness judges success by this marker: the @moq/web-transport addon + // can crash during exit teardown after a frame has arrived. + console.error(`received ${bytes} bytes from ${broadcast}`); + return; + } + } + } catch (err) { + // The relay resets a group a newer one supersedes; move on to the next. + if (!(err instanceof Moq.Error.Stream)) throw err; + } + } + throw new Error("no frame data received"); + } finally { + requested.close(); + // Wait for the close to reach the relay before exit, so it is not timed out. + await connection.close(); + await connection.closed; + origin.close(); + } +} + const [command, ...args] = process.argv.slice(2); if (command === "sign") { const key = Auth.Key.parse(readFileSync(args[0], "utf8")); @@ -67,4 +146,33 @@ if (command === "sign") { }); group.close(); await connection.closed; +} else if (command === "subscribe") { + // The flags of the interop subscriber this stands in for. + const { values } = parseArgs({ + args, + options: { + url: { type: "string" }, + broadcast: { type: "string" }, + timeout: { type: "string", default: "20" }, + "track-file": { type: "string" }, + track: { type: "string" }, + }, + }); + const timeout = Number.parseFloat(values.timeout ?? "") * 1000; + if (!values.url || !values.broadcast || !(timeout > 0)) { + throw new Error("usage: subscribe --url U --broadcast B [--timeout S] [--track-file F [--track T]]"); + } + const timer = new Promise((_, reject) => + setTimeout(() => reject(new Error("timed out waiting for data")), timeout), + ); + try { + await Promise.race([ + subscribe(new URL(values.url), values.broadcast, values["track-file"], values.track), + timer, + ]); + process.exit(0); + } catch (err) { + console.error(`error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } } else throw new Error(`unknown compatibility client command: ${command}`); diff --git a/test/interop/compat/planned-breaks.json b/test/interop/compat/planned-breaks.json index 015f81d331..f911e40a2c 100644 --- a/test/interop/compat/planned-breaks.json +++ b/test/interop/compat/planned-breaks.json @@ -1,33 +1,21 @@ { "moq-lite-07-wip": { - "reason": "Approved under /recommended: this draft is unpublished and its wire may change. Remeasured against these releases: released moq-cli and @moq/net reject current lite-07-wip sessions (long buffer, invalid value). Compare stable lite drafts; reassess this measured exception when any affected release changes.", + "reason": "Unpublished draft whose wire may change. Remeasured 2026-10-13 against these releases: every lite-07-wip cell fails, since released moq-cli, moq-relay, and @moq/net reject current lite-07-wip sessions (long buffer, invalid value, hop count exceeds maximum). Compare stable lite drafts; reassess when any affected release changes.", "releases": { - "moq-cli": "0.14.2", - "moq-relay": "0.17.2", - "@moq/net": "0.4.2" + "moq-cli": "0.14.4", + "moq-relay": "0.17.4", + "@moq/net": "0.4.3" } }, "catalog-archive": { - "reason": "Accepted by the maintainer (2026-10-09): #4034 reshaped the catalog `archive` entry in place (`timelines` replaced the required `track`), so released and current hang/@moq/hang reject each other's live catalogs (missing field `track` / `timelines`). Every media cell is cross-version.", + "reason": "#4034 reshaped the catalog `archive` entry in place (`timelines` replaced the required `track`), so released and current hang/@moq/hang reject each other's live catalogs. Remeasured 2026-10-13 against these releases: all 24 media cells fail, Rust and JS readers alike (missing field `track` / `timelines`). Holds until a release carries #4034.", "releases": { - "moq-cli": "0.14.2", - "hang": "0.21.11", + "moq-cli": "0.14.4", + "hang": "0.21.13", "@moq/hang": "0.5.2" }, "cells": { "lanes": ["media"] } - }, - "lite-fetch-released-relay": { - "reason": "Known lite break, accepted to land the nightly (2026-10-09): a FETCH through the released relay from a current publisher hangs until the timeout on lite-05 and lite-06, while released-to-released and current-to-current pass. Follow-up quest planned.", - "releases": { - "moq-relay": "0.17.2" - }, - "cells": { - "lanes": ["fetch"], - "versions": ["moq-lite-05", "moq-lite-06"], - "relay": "released", - "publisher": "current" - } } } diff --git a/test/interop/interop.sh b/test/interop/interop.sh index 4a5da418f4..f4560b4254 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -27,6 +27,11 @@ set -euo pipefail INTEROP_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) WORKSPACE=$(cd "$INTEROP_DIR/../.." && pwd) CLIENTS="$INTEROP_DIR/clients" +# The native-JS subscriber. A released-compat run swaps in its own client, which runs +# against either the checkout's or the released packages (see compat/client.ts). +NATIVE_DIR="${INTEROP_COMPAT_CLIENT:-$CLIENTS/js-native}" +NATIVE_SCRIPT=subscribe.ts +[[ -z "${INTEROP_COMPAT_CLIENT:-}" ]] || NATIVE_SCRIPT=client.ts # Run directory, reserved ports, and process-group ownership. See test/README.md. # shellcheck source-path=SCRIPTDIR source=../lib/harness.sh @@ -34,7 +39,7 @@ source "$INTEROP_DIR/../lib/harness.sh" # Captured before the parse below consumes it, so the rerun command carries every # flag and every environment override this run was actually given. -RERUN="$(harness_env INTEROP_TIMEOUT INTEROP_FPS INTEROP_SIZE INTEROP_PORT INTEROP_PROFILE RELAY_BIN MOQ_BIN INTEROP_SUB_MOQ INTEROP_VERSION INTEROP_NATIVE_CLIENT INTEROP_DECODE INTEROP_JS_PUBLISH_CLIENT INTEROP_COMPAT_TRANSPORT INTEROP_FETCH_DATA INTEROP_FETCH_TRACK)just test interop$(harness_argv "$@")" +RERUN="$(harness_env INTEROP_TIMEOUT INTEROP_FPS INTEROP_SIZE INTEROP_PORT INTEROP_PROFILE RELAY_BIN MOQ_BIN INTEROP_SUB_MOQ INTEROP_VERSION INTEROP_COMPAT_CLIENT INTEROP_DECODE INTEROP_FETCH_DATA INTEROP_FETCH_TRACK)just test interop$(harness_argv "$@")" PUBLISHERS="rust" SUBSCRIBERS="rust" @@ -43,7 +48,7 @@ SUBSCRIBERS="rust" # can't be held to them: a released client may not close cleanly, a released relay names no # connection when one closes, and the tail clients are built from this checkout only. IN_TREE=1 -if [[ -n "${RELAY_BIN:-}${MOQ_BIN:-}${INTEROP_SUB_MOQ:-}${INTEROP_NATIVE_CLIENT:-}${INTEROP_JS_PUBLISH_CLIENT:-}" ]]; then +if [[ -n "${RELAY_BIN:-}${MOQ_BIN:-}${INTEROP_SUB_MOQ:-}${INTEROP_COMPAT_CLIENT:-}" ]]; then IN_TREE=0 fi TIMEOUT="${INTEROP_TIMEOUT:-20}" @@ -282,14 +287,10 @@ prepare_js() { for v in js js-native js-native-node js-native-bun; do needs "$v" && mark_broken "$v" "bun not found"; done return } - # Staged released-compat clients bring their own packages; check every one given. - if [[ -n "${INTEROP_NATIVE_CLIENT:-}${INTEROP_JS_PUBLISH_CLIENT:-}" ]]; then - [[ -z "${INTEROP_NATIVE_CLIENT:-}" || -f "$INTEROP_NATIVE_CLIENT/subscribe.ts" ]] || { - echo "missing staged native client" >&2 - exit 1 - } - [[ -z "${INTEROP_JS_PUBLISH_CLIENT:-}" || -f "$INTEROP_JS_PUBLISH_CLIENT/client.ts" ]] || { - echo "missing staged publisher" >&2 + # A staged released-compat client brings its own packages. + if [[ -n "${INTEROP_COMPAT_CLIENT:-}" ]]; then + [[ -f "$INTEROP_COMPAT_CLIENT/client.ts" ]] || { + echo "missing staged compat client" >&2 exit 1 } return @@ -658,7 +659,7 @@ run_publisher() { ;; js-native) # exec, so the client leads the group and `stop_publisher` waits for its own close. - exec bun "$INTEROP_JS_PUBLISH_CLIENT/client.ts" publish "$url" "$broadcast" + exec bun "$INTEROP_COMPAT_CLIENT/client.ts" publish "$url" "$broadcast" ;; js) # Headless Chromium encodes its own H.264 from a fake camera via @@ -717,18 +718,18 @@ relay_next_line() { # shellcheck disable=SC2329 # reached from a function 'harness_spawn' invokes run_native() { local out - out=$( (cd "${INTEROP_NATIVE_CLIENT:-$CLIENTS/js-native}" && "$@") 2>&1) || true + out=$( (cd "$NATIVE_DIR" && "$@") 2>&1) || true printf '%s\n' "$out" >&2 printf '%s\n' "$out" | grep -q '^received ' } -# Write a track name and the group a live JS subscriber saw on it to -# $HARNESS_RUN/.track. Extra args go to subscribe.ts (e.g. --track). +# Write a track name and the group the compat client's live subscription saw on it to +# $HARNESS_RUN/.track. Extra args go to its subscribe (e.g. --track). # shellcheck disable=SC2329 # reached from run_subscriber, which 'harness_spawn' invokes observe_group() { local broadcast="$1" shift - (cd "${INTEROP_NATIVE_CLIENT:-$CLIENTS/js-native}" && node --import tsx subscribe.ts subscribe --url "$URL" --broadcast "$broadcast" \ + (cd "$INTEROP_COMPAT_CLIENT" && node --import tsx client.ts subscribe --url "$URL" --broadcast "$broadcast" \ --timeout "$TIMEOUT" --track-file "$HARNESS_RUN/$broadcast.track" "$@") } @@ -845,12 +846,12 @@ PYCODE ;; js-native-bun) # Native @moq/net via moq's WebTransport polyfill, under bun. - run_native bun subscribe.ts subscribe \ + run_native bun "$NATIVE_SCRIPT" subscribe \ --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" ;; js-native-node) # Same, under node (tsx runs the TS directly). - run_native node --import tsx subscribe.ts subscribe \ + run_native node --import tsx "$NATIVE_SCRIPT" subscribe \ --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" ;; *) From b101f02c54ac5065d32e1600ce02a11dc201b4e7 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 13 Oct 2026 12:26:45 -0700 Subject: [PATCH 3/4] test(interop): record maintainer re-acceptance of the planned breaks Co-Authored-By: Claude Opus 5.5 --- test/interop/compat/planned-breaks.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/interop/compat/planned-breaks.json b/test/interop/compat/planned-breaks.json index f911e40a2c..8d895128d3 100644 --- a/test/interop/compat/planned-breaks.json +++ b/test/interop/compat/planned-breaks.json @@ -1,6 +1,6 @@ { "moq-lite-07-wip": { - "reason": "Unpublished draft whose wire may change. Remeasured 2026-10-13 against these releases: every lite-07-wip cell fails, since released moq-cli, moq-relay, and @moq/net reject current lite-07-wip sessions (long buffer, invalid value, hop count exceeds maximum). Compare stable lite drafts; reassess when any affected release changes.", + "reason": "Re-accepted by the maintainer (2026-10-13): this draft is unpublished and its wire is still changing. Remeasured 2026-10-13 against these releases: every lite-07-wip cell fails, since released moq-cli, moq-relay, and @moq/net reject current lite-07-wip sessions (long buffer, invalid value, hop count exceeds maximum). Compare stable lite drafts; reassess when any affected release changes.", "releases": { "moq-cli": "0.14.4", "moq-relay": "0.17.4", @@ -8,7 +8,7 @@ } }, "catalog-archive": { - "reason": "#4034 reshaped the catalog `archive` entry in place (`timelines` replaced the required `track`), so released and current hang/@moq/hang reject each other's live catalogs. Remeasured 2026-10-13 against these releases: all 24 media cells fail, Rust and JS readers alike (missing field `track` / `timelines`). Holds until a release carries #4034.", + "reason": "Re-accepted by the maintainer (2026-10-13), until a release carries #4034: #4034 reshaped the catalog `archive` entry in place (`timelines` replaced the required `track`), so released and current hang/@moq/hang reject each other's live catalogs. Remeasured 2026-10-13 against these releases: all 24 media cells fail, Rust and JS readers alike (missing field `track` / `timelines`).", "releases": { "moq-cli": "0.14.4", "hang": "0.21.13", From b1850348bfc86ee4ec2d1564ab4dd4f55b7ba668 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 13 Oct 2026 12:42:05 -0700 Subject: [PATCH 4/4] test(interop): compat catalog reader resyncs after a reset group Co-Authored-By: Claude Opus 5.5 --- test/interop/compat/client.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/test/interop/compat/client.ts b/test/interop/compat/client.ts index 84f165a7c5..42ef0e42d2 100644 --- a/test/interop/compat/client.ts +++ b/test/interop/compat/client.ts @@ -21,12 +21,17 @@ async function video(bc: Moq.Broadcast.Consumer): Promise<[string, Catalog.Video if (!group) throw new Error("catalog ended without a video track"); // Each group opens with a snapshot; the frames after it are deltas. let first = true; - for (let frame = await group.readFrame(); frame; frame = await group.readFrame()) { - if (first) decoder.snapshot(frame.payload); - else decoder.delta(frame.payload); - first = false; - const rendition = Object.entries(decoder.decode()?.video?.renditions ?? {})[0]; - if (rendition) return rendition; + try { + for (let frame = await group.readFrame(); frame; frame = await group.readFrame()) { + if (first) decoder.snapshot(frame.payload); + else decoder.delta(frame.payload); + first = false; + const rendition = Object.entries(decoder.decode()?.video?.renditions ?? {})[0]; + if (rendition) return rendition; + } + } catch (err) { + // The relay resets a group a newer snapshot supersedes; resync from the next. + if (!(err instanceof Moq.Error.Stream)) throw err; } } }