diff --git a/Cargo.lock b/Cargo.lock index bf04d04d26..bffa4a4856 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1071,9 +1071,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.2" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -2481,9 +2481,9 @@ checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" [[package]] name = "find-msvc-tools" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "fixed-resample" @@ -2581,9 +2581,9 @@ dependencies = [ [[package]] name = "foundations" -version = "5.8.1" +version = "5.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e26ce0c3071293f6cb986cdd1e9f09d26d72313b7fc85010cbded344182c26df" +checksum = "84c5a08bded019d15c789a5e64aff8f0013416130fbfabd757d2232199cb0527" dependencies = [ "anyhow", "cf-rustracing", @@ -2621,9 +2621,9 @@ dependencies = [ [[package]] name = "foundations-macros" -version = "5.8.1" +version = "5.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "260f833086964a5ce5a0a28fa0f7f1101249eef3b3d45619d3437a74b65ad096" +checksum = "d46317fec5543083ec837c5d1d40f2ec813922f76962e745ce0db173579fe9c3" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -3180,7 +3180,7 @@ dependencies = [ [[package]] name = "hang" -version = "0.20.4" +version = "0.20.5" dependencies = [ "anyhow", "bytes", @@ -3606,9 +3606,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -3620,9 +3620,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -3633,9 +3633,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -3647,16 +3647,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -3667,15 +3668,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -3774,9 +3775,9 @@ dependencies = [ [[package]] name = "inotify" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ "bitflags 2.13.1", "inotify-sys", @@ -4365,7 +4366,7 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libmoq" -version = "0.5.7" +version = "0.5.8" dependencies = [ "anyhow", "bytes", @@ -4388,9 +4389,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "bitflags 2.13.1", "libc", @@ -4445,9 +4446,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -4594,9 +4595,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "minicov" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +checksum = "c3aa3aa12b448ac225b3102217d1ac5cc717908f02722926524b0599c933c7a0" dependencies = [ "cc", "walkdir", @@ -4698,7 +4699,7 @@ dependencies = [ [[package]] name = "moq-boy" -version = "0.4.7" +version = "0.4.8" dependencies = [ "anyhow", "boytacean", @@ -4718,7 +4719,7 @@ dependencies = [ [[package]] name = "moq-cli" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "axum", @@ -4750,7 +4751,7 @@ dependencies = [ [[package]] name = "moq-ffi" -version = "0.3.10" +version = "0.3.11" dependencies = [ "bytes", "hang", @@ -4798,7 +4799,7 @@ dependencies = [ [[package]] name = "moq-hls" -version = "0.4.7" +version = "0.4.8" dependencies = [ "anyhow", "axum", @@ -4856,7 +4857,7 @@ dependencies = [ [[package]] name = "moq-mux" -version = "0.9.6" +version = "0.9.7" dependencies = [ "anyhow", "base64 0.23.1", @@ -4887,7 +4888,7 @@ dependencies = [ [[package]] name = "moq-native" -version = "0.19.10" +version = "0.19.11" dependencies = [ "anyhow", "bytes", @@ -4945,7 +4946,7 @@ dependencies = [ [[package]] name = "moq-net" -version = "0.2.11" +version = "0.2.12" dependencies = [ "bytes", "criterion", @@ -4976,7 +4977,7 @@ dependencies = [ [[package]] name = "moq-relay" -version = "0.14.10" +version = "0.14.11" dependencies = [ "anyhow", "axum", @@ -5019,7 +5020,7 @@ dependencies = [ [[package]] name = "moq-rtc" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "aws-lc-rs", @@ -5112,7 +5113,7 @@ dependencies = [ [[package]] name = "moq-token-cli" -version = "0.5.43" +version = "0.5.44" dependencies = [ "anyhow", "clap", @@ -5122,7 +5123,7 @@ dependencies = [ [[package]] name = "moq-transcode" -version = "0.0.10" +version = "0.0.11" dependencies = [ "anyhow", "bytes", @@ -5155,7 +5156,7 @@ dependencies = [ [[package]] name = "moq-video" -version = "0.0.16" +version = "0.0.17" dependencies = [ "anyhow", "ashpd", @@ -5482,9 +5483,9 @@ dependencies = [ [[package]] name = "netlink-proto" -version = "0.12.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6f7398dddf5f152d2a91a2921a134c6097056e292c0d4b9906007855e7cece6" +checksum = "93af8261786086024cd5e96e0a991dd65ced07bbf7c233a487bbc96b971d5539" dependencies = [ "bytes", "futures-channel", @@ -6806,9 +6807,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plain" @@ -6962,9 +6963,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -7894,9 +7895,9 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "safe_arch" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a52ec151f024d703f9fd65abb7cbe81e7cdb39f18917a3a37e3014470dc7c59" +checksum = "42c6efa15875e6ecb39ca61fb0b0c1a40b84fac5a5ffe71eef7d1000c8eb3f5f" dependencies = [ "bytemuck", ] @@ -9238,9 +9239,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -10035,9 +10036,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -10250,9 +10251,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.16" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", @@ -11279,9 +11280,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "ws_stream_wasm" @@ -11595,9 +11596,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -11606,9 +11607,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "yoke", "zerofrom", @@ -11617,13 +11618,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4f1a9ed176..d50d40ea55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,7 +86,7 @@ loom = { version = "0.7.2", features = ["futures"] } mdns-sd = { version = "0.20", features = ["async"] } moq-audio = { version = "0.0.17", path = "rs/moq-audio" } moq-flate = { version = "0.1.1", path = "rs/moq-flate" } -moq-hls = { version = "0.4.7", path = "rs/moq-hls", default-features = false } +moq-hls = { version = "0.4.8", path = "rs/moq-hls", default-features = false } moq-json = { version = "0.3.3", path = "rs/moq-json" } moq-loc = { version = "0.2", path = "rs/moq-loc" } moq-msf = { version = "0.4", path = "rs/moq-msf" } @@ -97,7 +97,7 @@ moq-net = { version = "0.2", path = "rs/moq-net" } # driver at runtime. Compiles on any platform (macOS included) but only actually # used by moq-video on Linux. moq-nvenc = { version = "0.0.3", path = "rs/moq-nvenc" } -moq-rtc = { version = "0.2.3", path = "rs/moq-rtc" } +moq-rtc = { version = "0.2.4", path = "rs/moq-rtc" } moq-rtmp = { version = "0.2.4", path = "rs/moq-rtmp" } moq-srt = { version = "0.2.3", path = "rs/moq-srt" } moq-stats = { version = "0.1.4", path = "rs/moq-stats" } @@ -108,7 +108,7 @@ moq-token-cli = { version = "0.5", path = "rs/moq-token-cli" } # can leave them off to drop the CUDA dependencies. Both crates still default # them on when depended on directly. VAAPI is opt-in everywhere, since that # backend has never been validated on real hardware. -moq-transcode = { version = "0.0.10", path = "rs/moq-transcode", default-features = false } +moq-transcode = { version = "0.0.11", path = "rs/moq-transcode", default-features = false } # Standalone crate (moq-dev/vaapi); vendored from cros-libva + cros-codecs. # dlopen's libva at runtime (no libva-dev at build, no NEEDED libva in the binary). moq-vaapi = "0.0.3" @@ -117,7 +117,7 @@ moq-vaapi = "0.0.3" # builds, which want the codecs and not the device stack. Adding # `features = ["capture"]` to a consumer that ships in those bindings pulls the # whole device graph into every one of them. -moq-video = { version = "0.0.16", path = "rs/moq-video", default-features = false } +moq-video = { version = "0.0.17", path = "rs/moq-video", default-features = false } percent-encoding = "2" qmux = { version = "0.5.0", default-features = false } serde = { version = "1", features = ["derive"] } diff --git a/bun.lock b/bun.lock index 12b245612f..04eb90a31d 100644 --- a/bun.lock +++ b/bun.lock @@ -98,7 +98,7 @@ }, "js/hang": { "name": "@moq/hang", - "version": "0.3.5", + "version": "0.4.0", "dependencies": { "@kixelated/libavjs-webcodecs-polyfill": "^0.5.5", "@libav.js/variant-opus-af": "^6.9.8", @@ -1296,7 +1296,7 @@ "media-captions": ["media-captions@0.0.18", "", {}, "sha512-JW18P6FuHdyLSGwC4TQ0kF3WdNj/+wMw2cKOb8BnmY6vSJGtnwJ+vkYj+IjHOV34j3XMc70HDeB/QYKR7E7fuQ=="], - "mediabunny": ["mediabunny@1.53.1", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-w25gA6mQuHSLzdpWswqwndbKtHFcJ0IRowJaIHgY7rlJaVTHhomfx5mLq4IHqonrsGCGDE/LIrtXz8mxiDzavA=="], + "mediabunny": ["mediabunny@1.54.0", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-L7yaV6iS6SYtHAs0FPQsAy1fprwvoX39WqbAD7GaCQVXKj/7zwxlAQkCkL2K52XakuDES8lbRRNpUlGWUOnv8Q=="], "merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="], diff --git a/cpp/obs/src/moq-source.cpp b/cpp/obs/src/moq-source.cpp index 5bf653b37a..22895affb6 100644 --- a/cpp/obs/src/moq-source.cpp +++ b/cpp/obs/src/moq-source.cpp @@ -142,9 +142,9 @@ struct subscription_ref { subscription_ref &operator=(const subscription_ref &) = delete; }; -// user_data for a single moq_origin_request. The generation must travel with the -// request rather than live on ctx: a reconnect can issue a new request while an -// older one still has a delivery in flight, and a single slot on ctx would let +// user_data for a single moq_origin_consume_announced. The generation must travel +// with the request rather than live on ctx: a reconnect can issue a new request while +// an older one still has a delivery in flight, and a single slot on ctx would let // that stale delivery read the new generation and pass the staleness check. // Allocated before the request exists and freed by its terminal on_broadcast. struct broadcast_request { @@ -698,11 +698,14 @@ static void moq_source_start_consume(struct moq_source *ctx, uint32_t expected_g req->ctx = ctx; req->gen = expected_gen; - // Resolve the broadcast by path against what is announced now plus any - // dynamic fallback, failing if neither can serve it. libmoq copies the path, + // Wait for the broadcast to be announced. This runs off the session-connected + // callback, and announcements arrive over the session after it connects, so + // resolving against only what is announced *now* (moq_origin_request) would race + // them and blank the source for a broadcast that is live. libmoq copies the path, // so it need not outlive this call, and delivers the broadcast handle // asynchronously to on_broadcast. - int32_t request = moq_origin_request(origin, broadcast_copy, strlen(broadcast_copy), on_broadcast, req); + int32_t request = + moq_origin_consume_announced(origin, broadcast_copy, strlen(broadcast_copy), on_broadcast, req); if (request < 0) { LOG_ERROR("Failed to request broadcast '%s': %d", broadcast_copy, request); bfree(broadcast_copy); @@ -730,15 +733,15 @@ static void moq_source_start_consume(struct moq_source *ctx, uint32_t expected_g } else { // Stale or shutting down: close it; its terminal releases the reference. pthread_mutex_unlock(&ctx->mutex); - moq_origin_request_close(request); + moq_origin_consume_announced_close(request); } } -// Receives the broadcast resolved by moq_origin_request: a positive handle once -// served, then exactly once more with a terminal code (0 = finished, including -// after moq_origin_request_close; < 0 = could not be served). The terminal is the -// last touch of user_data, so it both frees the request context and releases the -// request's lifetime reference via subscription_ref. +// Receives the announced broadcast: a positive handle once announced, then exactly +// once more with a terminal code (0 = finished, including after +// moq_origin_consume_announced_close; < 0 = error). The terminal is the last touch +// of user_data, so it both frees the request context and releases the request's +// lifetime reference via subscription_ref. static void on_broadcast(void *user_data, int32_t broadcast) { struct broadcast_request *req = (struct broadcast_request *)user_data; @@ -843,10 +846,11 @@ static void moq_source_disconnect_locked(struct moq_source *ctx) ctx->catalog_handle = -1; } - // An unresolved request still owes a terminal on_broadcast; closing it makes - // that fire (with 0) instead of leaving it pending until the source dies. + // An unresolved wait still owes a terminal on_broadcast; closing it makes that + // fire (with 0) instead of leaving it pending until the source dies. This is the + // path that ends a wait for a broadcast that is never announced. if (ctx->request >= 0) { - moq_origin_request_close(ctx->request); + moq_origin_consume_announced_close(ctx->request); ctx->request = -1; } diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 5f631b90a3..5ab2268543 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -288,8 +288,9 @@ advertises its own identity by setting `--cluster-mesh` to its externally-reachable URL, which it publishes on the cluster origin for other peers to discover and dial. -The `quinn` and `noq` QUIC backends support mTLS; configuring `tls.root` with a -backend that does not (e.g. `quiche`) is a startup error. +The `quinn`, `noq`, and `quiche` QUIC backends support mTLS. Quinn and noq hot +reload the trusted roots for new handshakes. Quiche currently requires a relay +restart after rotating inbound `server.tls.root` files. ## Stream Listeners diff --git a/doc/bin/relay/cluster.md b/doc/bin/relay/cluster.md index 1ba036cf24..3eae31f454 100644 --- a/doc/bin/relay/cluster.md +++ b/doc/bin/relay/cluster.md @@ -132,25 +132,25 @@ connect_api = "https://api.example.com/cluster/connect" node = "us-west.example.com:4443" ``` -The source returns a bare JSON array of peer hostnames: +The source returns a JSON array of peer URLs. Legacy bare hosts remain accepted: ```json -["eu-west.example.com:4443", "us-east.example.com:4443"] +["https://eu-west.example.com/?cost=10", "us-east.example.com:4443"] ``` -The relay reconciles that list against its live dials: new entries are dialed, entries that disappear are dropped. It composes with `connect` (static seeds that are never reconciled away) and `mesh` (gossip). The relay's own `node` value, when set, is sent as a `?node=` query parameter so the endpoint can return the peers for that specific node; for mTLS-gated endpoints the cluster client certificate identifies the caller as well. +The relay reconciles that list against its live dials: new entries are dialed, entries that disappear are dropped, and a changed URL for a `connect_api`-owned peer replaces its session. That includes dial-side inputs such as `?cost=` and an inline `?jwt=`. An identical render is a no-op. It composes with `connect` (static seeds that are never reconciled away) and `mesh` (gossip). If another source already owns a peer's session, the API entry remains its updated fallback until that source disappears. The relay's own `node` value, when set, is sent as a `?node=` query parameter so the endpoint can return the peers for that specific node; for mTLS-gated endpoints the cluster client certificate identifies the caller as well. - **HTTP(S) URL**: re-checked every 30s, but freshness is delegated to a standard HTTP cache (`http-cache`), so the response's `Cache-Control` controls how often a check turns into a real fetch. While the cached list is still fresh (`max-age`), the re-check is served from cache with no network round-trip; once it's stale the cache issues a conditional GET (`ETag` / `Last-Modified`) and falls back to the last cached body if revalidation fails (stale-if-error). Set a longer `max-age` to reduce load on your endpoint, or `no-cache` to force a conditional GET on every tick. Transient endpoint blips don't churn the dial set. - **Local file** (a path or `file://` URL): watched via OS filesystem notifications (inotify / FSEvents / kqueue), with a periodic re-check as a safety net. -If a fetch fails or returns garbage, the relay logs and keeps the last good list rather than tearing the cluster down. This keeps the moq-relay binary generic: all routing decisions (which node connects where) live in whatever service answers the endpoint. +If a fetch fails, an entry is invalid, or one identity has conflicting entries, the relay logs and keeps the entire last good list rather than applying a partial topology. This keeps the moq-relay binary generic: all routing decisions (which node connects where) live in whatever service answers the endpoint. ## Authentication Cluster peers must authenticate to each other: - **mTLS** (recommended). Set `tls.root` to the CA that signed the cluster certificates. Inbound connections presenting a valid client cert are granted full access; outbound dials use `connect.tls.cert` / `connect.tls.key`. -- **JWT**. For static `connect` peers, supply the token inline as a `?jwt=` query parameter on the URL. For gossip- and `connect_api`-discovered peers (whose addresses can't carry an inline token), set `cluster.token` to a file holding the JWT; it's presented on any dial whose URL has no inline `?jwt=` (so an inline token wins per-peer). Either way the token needs broad enough scope to cover whatever paths the cluster carries. +- **JWT**. Supply a per-peer token inline as a `?jwt=` query parameter on a static or `connect_api` URL. Alternatively, set `cluster.token` to a file holding the shared JWT; it is presented on any dial whose URL has no inline token. Gossip must use the shared token or mTLS: never put a JWT in `cluster.node`, because that URL is advertised to the mesh and written to logs. Either way the token needs broad enough scope to cover whatever paths the cluster carries. See [Authentication](/bin/relay/auth) for the full setup. @@ -164,7 +164,7 @@ peer stays loudly visible in the logs and a returning one is picked up within se `cluster.root` was removed. To dial cluster peers use `cluster.connect`; to advertise this relay's own address set `cluster.node` and enable `cluster.mesh`. `cluster.mesh` is now a boolean gossip toggle (it used to take this relay's URL); the URL moved to `cluster.node`. The old `mesh = ""` form still works for backwards compatibility: it enables gossip and is treated as `cluster.node`, with a deprecation warning (or an error if it conflicts with an explicit `cluster.node`). -`cluster.connect` entries are now full URLs; a bare host or `host:port` still works but logs a deprecation warning. A JWT for a static peer belongs inline as a `?jwt=` query parameter (the `cluster.token` file remains for gossip / `connect_api` peers, which can't carry an inline token). +`cluster.connect` entries are now full URLs; a bare host or `host:port` still works but logs a deprecation warning. A per-peer JWT belongs inline as a `?jwt=` query parameter on a static or `connect_api` URL. The `cluster.token` file remains the shared fallback and is required for JWT-authenticated gossip; never put a JWT in the advertised `cluster.node` URL. | Old | New | |---|---| diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index f640b91dac..24055bf89a 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -58,6 +58,11 @@ certificate, and Unix sockets add optional peer-credential gating. # is configured below. bind = "[::]:443" +# MoQ versions accepted by QUIC, WebTransport, and WebSocket listeners. +# TCP and Unix stream listeners also accept moq-lite-05 because it carries +# their request path in SETUP. Omit to accept every supported version. +version = ["moq-transport-16"] + # Plaintext qmux over TCP (no TLS, carries no peer identity). Trusted networks # only; a non-loopback bind logs a warning. Requires the `tcp` build feature. [listen.tcp] @@ -96,11 +101,14 @@ generate = ["localhost", "127.0.0.1"] # Optional: root CAs to accept for mTLS peer authentication. # Clients that present a cert signed by one of these CAs are granted # full access (publish/subscribe/cluster). Intended for relay clustering. -# Supported by the quinn and noq backends. root = ["/path/to/peer-ca.pem"] ``` -For production, use certificates from Let's Encrypt or another CA. +For production, use certificates from Let's Encrypt or another CA. The Quinn +and Noq backends watch certificate, key, and root CA files and reload them for +new connections. Existing connections keep the identity established by their +original handshake. The Quiche backend reloads outbound client roots but +requires a relay restart after rotating its inbound TLS files. ### \[web.http] @@ -142,8 +150,14 @@ listen = "0.0.0.0:443" # TLS certificates (can be the same as listen.tls) cert = "cert.pem" key = "key.pem" + +# Optional root CAs for HTTPS/WSS client certificate authentication. +root = ["/path/to/peer-ca.pem"] ``` +HTTPS/WSS certificate, key, and root CA files are watched and reloaded for new +connections. A failed reload retains the last valid configuration. + ### \[auth] Authentication configuration. @@ -182,12 +196,14 @@ node = "us-west.example.com:4443" mesh = true # Optional. Fetch the peer list from an HTTP(S) endpoint or local file (a JSON -# array of hostnames) and reconcile it at runtime, no restart needed. +# array of peer URLs) and reconcile it at runtime, replacing sessions when URL +# configuration such as ?cost= or ?jwt= changes. connect_api = "https://api.example.com/cluster/connect" # JWT for outbound cluster dials (alternative to mTLS), applied to any peer -# whose URL has no inline ?jwt=. Required to authenticate gossip / connect_api -# discovered peers; for static `connect` peers, prefer an inline ?jwt=. +# whose URL has no inline ?jwt=. An inline token works for static and +# connect_api-discovered peers. Gossip must use this shared token or mTLS because +# the advertised cluster.node URL is public. token = "cluster.jwt" [cluster.lan] @@ -254,6 +270,10 @@ goaway.handover = "10s" # resolution_delay = "50ms" ``` +Custom client root files are watched and reloaded for new outbound connections. +If a changed file is temporarily missing, empty, or invalid, the relay retains +the last valid roots. + The connect timeout is also available as `--connect-timeout` or `MOQ_CONNECT_TIMEOUT`, the address race as `--connect-race` or `MOQ_CONNECT_RACE`, and the resolution delay as `--connect-resolution-delay` or diff --git a/doc/concept/layer/hang.md b/doc/concept/layer/hang.md index eaddae7885..94546bc9ba 100644 --- a/doc/concept/layer/hang.md +++ b/doc/concept/layer/hang.md @@ -89,17 +89,20 @@ The `role` field is `subtitle` (spoken dialogue) or `caption` (all audio, includ The frame timestamp carries the cue's start time on the same media clock as audio and video, so cues schedule against the same playhead with no separate pacing. The section is omitted entirely when a broadcast publishes no captions. +A publisher can set a rendition's optional `stalled` field to recommend temporarily avoiding it without removing or closing the track. +Players prefer decoder-supported unstalled renditions and can fall back to a stalled rendition when none remain. + ### Cross-broadcast renditions -A rendition may set an optional `broadcast` field: a path relative to the broadcast that served the catalog (e.g. `"../source"`), pointing at another broadcast that publishes the actual track. -A consumer resolves the reference against the path it reached the catalog broadcast at (`..` pops a segment, other segments append) and subscribes to the track on the resolved broadcast over the same connection. -That is the path the consumer asked for, not one the publisher declares, so a reference can only ever name a broadcast the consumer could have named itself. +A rendition may set an optional `broadcast` field: a path relative to the broadcast that served the catalog (e.g. `"./source"`), pointing at another broadcast that publishes the actual track. +A consumer resolves a non-empty reference like a relative URL: it replaces the catalog broadcast's last path segment, then applies `.` and `..` segments. An empty reference names the catalog broadcast itself. +It resolves against the path it reached the catalog broadcast at, not one the publisher declares, so a reference can only ever name a broadcast the consumer could have named itself, and subscribes to the track there over the same connection. When the field is absent, the track lives in the same broadcast as the catalog. A reference that walks above the root (more `..` than the catalog path has segments) names no broadcast, so the whole catalog is rejected rather than pointed at whatever path the walk stops on. The root is the consumer's authorized subtree, so such a reference is an attempt to name content it cannot reach: a publisher emitting one has a bug, and quietly serving the remaining renditions would hide that. This lets a transcoder publish a sidecar catalog that adds new renditions while pointing unchanged ones at the original broadcast, instead of re-publishing those bytes through the transcoder. -For example, a transcoder consuming `room/source` can publish `room/transcode` whose catalog contains a downscaled `480p` rendition plus the original `1080p` rendition marked `"broadcast": "../source"`. +For example, a transcoder consuming `room/source` can publish `room/transcode` whose catalog contains a downscaled `480p` rendition plus the original `1080p` rendition marked `"broadcast": "./source"`. A viewer of `room/transcode` then pulls `480p` from the transcoder and `1080p` directly from the source, and the relay dedupes the source subscription with the transcoder's own. Rejection happens where the catalog is read, not where a track is subscribed: the rendition set drives track layouts, playlists, codec lists, and quality selectors, so a reference caught any later would already have been offered and chosen. In Rust the `moq-mux` catalog stream rejects it and every exporter reads through that stream; `@moq/watch` rejects the catalog it would otherwise publish. diff --git a/doc/concept/standard/msf.md b/doc/concept/standard/msf.md index d1c84724de..895ad05fc1 100644 --- a/doc/concept/standard/msf.md +++ b/doc/concept/standard/msf.md @@ -17,4 +17,7 @@ moved init data out of the track into a root `initDataList` referenced by `initR Our implementation hides this on the wire: the catalog API is a version-agnostic snapshot, draft-00 catalogs still decode, and init data is always presented inline regardless of how it was carried. +Our implementation also carries an optional `stalled` boolean on video tracks as a non-standard extension shared with the hang catalog. +It recommends temporarily avoiding a track without making that track unavailable. + [See the draft](https://www.ietf.org/archive/id/draft-ietf-moq-msf-01.html) for the latest details. diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index 0d1c40aa2b..96c6e33943 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -225,6 +225,17 @@ if (moq_consume_video_properties(catalog, &snapshot) < 0) { } ``` +## Stalled video renditions + +`moq_consume_video_stalled` reports whether the publisher recommends temporarily avoiding a rendition. The track remains directly usable, and catalogs that omit the hint report false. Pass the same catalog snapshot and rendition index used by `moq_consume_video_config`: + +```c +bool stalled; +if (moq_consume_video_stalled(catalog, index, &stalled) < 0) { + fprintf(stderr, "video stalled state failed: %s\n", moq_error()); +} +``` + ## Raw media The `moq_publish_media_*` and `moq_consume_video` / `moq_consume_audio` calls carry already-encoded frames, for a caller that brings its own codec. The `_raw` calls carry uncompressed media instead and run the codec inside libmoq, so a C application can publish pixels and PCM without linking one. diff --git a/doc/lib/go/moq.md b/doc/lib/go/moq.md index 87d1c4836a..298968cf8b 100644 --- a/doc/lib/go/moq.md +++ b/doc/lib/go/moq.md @@ -201,6 +201,8 @@ media, err := broadcast.PublishMedia("avc3", nil, moq.WithVideoHint(moq.VideoHin })) ``` +Each catalog `Video` has a `Stalled` boolean. A true value recommends temporarily avoiding that rendition, but the track remains directly usable. Existing catalogs default it to false. + Properties that apply to every video rendition are updated together. Nil fields clear the corresponding catalog property, and rotation is normalized to the nearest clockwise quarter turn: ```go diff --git a/doc/lib/kt/moq.md b/doc/lib/kt/moq.md index f9d01334d0..a43d270723 100644 --- a/doc/lib/kt/moq.md +++ b/doc/lib/kt/moq.md @@ -135,6 +135,8 @@ Moq.connect("https://relay.example.com").use { moq -> } ``` +Each catalog `Video` has a `stalled` boolean. A true value recommends temporarily avoiding that rendition, but the track remains directly usable. Existing catalogs default it to false. + Properties that apply to every video rendition are updated together. `null` fields clear the corresponding catalog property, and rotation is normalized to the nearest clockwise quarter turn: ```kotlin diff --git a/doc/lib/py/moq-rs.md b/doc/lib/py/moq-rs.md index 6946092a45..1c96c6ba6c 100644 --- a/doc/lib/py/moq-rs.md +++ b/doc/lib/py/moq-rs.md @@ -137,6 +137,8 @@ video = broadcast.publish_media( A value the stream later detects fills only a gap the hint left, so a detected value always wins. Audio formats resolve entirely from their init bytes, so they take no hint. +Each catalog `Video` has a `stalled` boolean. A true value recommends temporarily avoiding that rendition, but the track remains directly usable. Existing catalogs default it to false. + Properties that apply to every video rendition are updated together. Omitted fields clear the corresponding catalog property, and rotation is normalized to the nearest clockwise quarter turn: ```python @@ -337,6 +339,8 @@ broadcast = await client.announced_broadcast("live/cam1") broadcast = await client.request_broadcast("live/cam1") ``` +Announcements arrive over the session after it connects, so `request_broadcast` on its own races them: right after connecting it can raise for a broadcast that is live. Await `announced_broadcast(path)` first when you know the path you want; `request_broadcast` is for a path a dynamic handler serves, or one you already know is announced. + Each broadcast carries a `Route`: `route.hops` is the chain of relay origin ids (as `list[int]`) the broadcast passed through to reach you, oldest first, and `route.cost` is the publisher's advertised preference (lower wins). The route is dynamic; `await broadcast.route_changed()` returns the current route first, then blocks for each change (e.g. an upstream failover), and returns `None` once the broadcast ends. A publisher advertises its own route with `producer.set_route(moq.Route(hops=[], cost=10))`, for example a standby transcoder that lowers its cost to 0 once it is warm. ## Examples diff --git a/doc/lib/swift/moq.md b/doc/lib/swift/moq.md index b5cad92c06..1f344a78d5 100644 --- a/doc/lib/swift/moq.md +++ b/doc/lib/swift/moq.md @@ -146,6 +146,8 @@ try broadcast.finish() Video publishers can pass `video: VideoHint(...)` to seed catalog fields before the stream reveals them. Use `publishMedia(on:format:initData:video:)` to accept a media track obtained from `BroadcastDynamic`. +Each catalog `Video` has a `stalled` boolean. A true value recommends temporarily avoiding that rendition, but the track remains directly usable. Existing catalogs default it to false. + Properties that apply to every video rendition are updated together. `nil` fields clear the corresponding catalog property, and rotation is normalized to the nearest clockwise quarter turn: ```swift diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index 98f7458733..7796ffb892 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -154,6 +154,7 @@ In addition to the WebCodecs fields, each rendition MAY carry the fields common type VideoDecoderConfigExtensions = { "displayAspectWidth": number | undefined, "displayAspectHeight": number | undefined, + "stalled": boolean | undefined, } ~~~ @@ -161,6 +162,11 @@ type VideoDecoderConfigExtensions = { A consumer that understands neither field MUST assume square pixels, a 1:1 ratio. Both MUST be present together; a consumer that sees only one MUST ignore it. +`stalled` indicates that the publisher recommends temporarily avoiding the rendition. +The track remains available when `stalled` is true. +A consumer SHOULD select an unstalled rendition when it supports one, but MAY select a stalled rendition when no unstalled rendition is suitable. +If absent, `stalled` defaults to false. + For example: ~~~ @@ -172,6 +178,7 @@ For example: "codedWidth": 1280, "codedHeight": 720, "bitrate": 6000000, + "stalled": true, "framerate": 30.0, "jitter": 33 }, @@ -336,7 +343,9 @@ By default a rendition's track lives in the same broadcast that served the catal The `broadcast` field overrides that, naming a different broadcast that publishes the track. The value is a relative path, resolved against the path of the broadcast that served the catalog. -It uses the `.` and `..` semantics of a relative URL reference ({{!RFC3986, Section 5.2.4}}), for example `../source`. +It uses relative reference resolution ({{!RFC3986, Section 5.2}}): a non-empty reference replaces the catalog broadcast's last path segment before applying `.` and `..` segments. +For example, `./source` in a catalog served by `room/transcode` resolves to `room/source`, while `.` resolves to `room`. +An empty reference resolves to the catalog broadcast itself. A publisher MUST NOT use an absolute path, nor a reference that escapes above the root. The root is the consumer's authorized subtree, so such a reference names content the consumer cannot reach. A consumer MUST reject a catalog containing one, rather than resolving the reference against a different broadcast or ignoring the rendition. diff --git a/go/wrapper/moq/types.go b/go/wrapper/moq/types.go index 90e8143c18..a792569be8 100644 --- a/go/wrapper/moq/types.go +++ b/go/wrapper/moq/types.go @@ -44,7 +44,7 @@ type ( Subscription = ffi.MoqSubscription // TrackInfo holds publisher-side track properties: priority, ordering, latency budget, and timescale. TrackInfo = ffi.MoqTrackInfo - // Video describes one video rendition in a broadcast catalog: codec, dimensions, bitrate, framerate, and container. + // Video describes one catalog rendition, including whether the publisher recommends temporarily avoiding it. Video = ffi.MoqVideo // VideoHint supplies catalog fields a video stream can't reveal itself, such as bitrate, filling only the gaps. VideoHint = ffi.MoqVideoHint diff --git a/js/hang/package.json b/js/hang/package.json index 9d2b0ed442..05050fa58a 100644 --- a/js/hang/package.json +++ b/js/hang/package.json @@ -1,7 +1,7 @@ { "name": "@moq/hang", "type": "module", - "version": "0.3.5", + "version": "0.4.0", "description": "WebCodecs-based media format for MoQ", "license": "(MIT OR Apache-2.0)", "repository": "github:moq-dev/moq", diff --git a/js/hang/src/catalog/audio.ts b/js/hang/src/catalog/audio.ts index cbe35916eb..4a8bff7aee 100644 --- a/js/hang/src/catalog/audio.ts +++ b/js/hang/src/catalog/audio.ts @@ -15,7 +15,7 @@ const TrackSchema = z.object({ */ export const AudioConfigSchema = z.object({ // Optional reference to another broadcast that publishes this track, expressed - // relative to the broadcast that served this catalog (e.g. "../source"). + // relative to the broadcast that served this catalog (e.g. "./source"). // If unset, the track lives in the same broadcast as the catalog. broadcast: z.optional(RelativeBroadcastSchema), diff --git a/js/hang/src/catalog/path.ts b/js/hang/src/catalog/path.ts index 6dcee21e09..ea8afb2a14 100644 --- a/js/hang/src/catalog/path.ts +++ b/js/hang/src/catalog/path.ts @@ -3,10 +3,10 @@ import * as z from "zod/mini"; /** * Zod schema for a relative broadcast reference stored in a catalog (a rendition's - * `broadcast` field, e.g. "../source"). Normalizes the input the same way the Rust + * `broadcast` field, e.g. "./source"). Normalizes the input the same way the Rust * `PathRelative` type does so JS and Rust agree byte-for-byte after deserialization. - * Resolve it against the catalog broadcast's own path with `Path.resolve`, which rejects a - * reference that walks above the root: hang requires rejecting such a catalog. + * Resolve it against the catalog broadcast's own path with `Path.tryResolve`, which returns + * `undefined` for a reference that walks above the root: hang requires rejecting such a catalog. */ export const RelativeBroadcastSchema = z.pipe(z.string(), z.transform(Path.normalizeRelative)); diff --git a/js/hang/src/catalog/root.test.ts b/js/hang/src/catalog/root.test.ts index bc3a2eb20e..4e555cdcb8 100644 --- a/js/hang/src/catalog/root.test.ts +++ b/js/hang/src/catalog/root.test.ts @@ -27,7 +27,7 @@ test("rendition broadcast reference is parsed and normalized", () => { video: { renditions: { video: { - broadcast: ".././source/", + broadcast: "././source/", codec: "avc1.64001f", container: { kind: "legacy" }, }, @@ -36,8 +36,25 @@ test("rendition broadcast reference is parsed and normalized", () => { }; const parsed = RootSchema.parse(catalog); if (!parsed.video || !("renditions" in parsed.video)) throw new Error("missing video section"); - // Normalized like Rust PathRelative: `.` and empty segments dropped, `..` preserved. - expect(parsed.video.renditions.video?.broadcast).toBe("../source"); + // Normalized like Rust PathRelative: redundant `.` and empty segments are dropped. + expect(parsed.video.renditions.video?.broadcast).toBe("source"); +}); + +test("rendition parent broadcast reference stays distinct from empty", () => { + const catalog = { + video: { + renditions: { + video: { + broadcast: ".", + codec: "avc1.64001f", + container: { kind: "legacy" }, + }, + }, + }, + }; + const parsed = RootSchema.parse(catalog); + if (!parsed.video || !("renditions" in parsed.video)) throw new Error("missing video section"); + expect(parsed.video.renditions.video?.broadcast).toBe("."); }); test("rendition without broadcast reference stays undefined", () => { diff --git a/js/hang/src/catalog/video.test.ts b/js/hang/src/catalog/video.test.ts index e8877614d0..0c8cee3aa6 100644 --- a/js/hang/src/catalog/video.test.ts +++ b/js/hang/src/catalog/video.test.ts @@ -15,6 +15,17 @@ test("video config accepts canonical display aspect fields", () => { expect("displayRatioHeight" in parsed).toBe(false); }); +test("video config accepts optional stalled state", () => { + const active = VideoConfigSchema.parse({ + codec: "avc1.64001f", + container: { kind: "legacy" }, + }); + const stalled = VideoConfigSchema.parse({ ...active, stalled: true }); + + expect(active.stalled).toBeUndefined(); + expect(stalled.stalled).toBe(true); +}); + test("legacy video arrays derive display size from display aspect fields", () => { const parsed = VideoSchema.parse([ { diff --git a/js/hang/src/catalog/video.ts b/js/hang/src/catalog/video.ts index 7ef870a011..5daf1804b9 100644 --- a/js/hang/src/catalog/video.ts +++ b/js/hang/src/catalog/video.ts @@ -12,7 +12,7 @@ const TrackSchema = z.object({ /** Schema for a single video rendition's decoder config. Mirrors WebCodecs VideoDecoderConfig. */ export const VideoConfigSchema = z.object({ // Optional reference to another broadcast that publishes this track, expressed - // relative to the broadcast that served this catalog (e.g. "../source"). + // relative to the broadcast that served this catalog (e.g. "./source"). // If unset, the track lives in the same broadcast as the catalog. broadcast: z.optional(RelativeBroadcastSchema), @@ -45,6 +45,10 @@ export const VideoConfigSchema = z.object({ // TODO: Support up to Number.MAX_SAFE_INTEGER bitrate: z.optional(u53Schema), + // Whether the publisher recommends temporarily avoiding this rendition. + // The track remains available and may still be selected as a fallback. + stalled: z.optional(z.boolean()), + // If true, the decoder will optimize for latency. // Default: true optimizeForLatency: z.optional(z.boolean()), diff --git a/js/msf/src/catalog.test.ts b/js/msf/src/catalog.test.ts index 10a8269da5..24532bcd8d 100644 --- a/js/msf/src/catalog.test.ts +++ b/js/msf/src/catalog.test.ts @@ -184,3 +184,24 @@ test("preserves SAP fields through decode and encode", () => { expect(wireTracks[0].maxObjSapStartingType).toBe(2); expect(wireTracks[0].jitter).toBe(15); }); + +test.each([ + ["omitted", undefined], + ["false", false], + ["true", true], +] as const)("preserves %s stalled state through decode and encode", (_name, stalled) => { + const track = { + name: "video0", + packaging: "loc", + isLive: true, + role: "video", + codec: "av01.0.08M.10.0.110.09", + ...(stalled === undefined ? {} : { stalled }), + }; + const catalog = decode(encodeJson({ version: "draft-01", tracks: [track] })); + + expect(catalog.tracks[0].stalled).toBe(stalled); + const wire = decodeJson(encode(catalog)); + const tracks = wire.tracks as { stalled?: boolean }[]; + expect(tracks[0].stalled).toBe(stalled); +}); diff --git a/js/msf/src/catalog.ts b/js/msf/src/catalog.ts index 625e4c84a9..24601e15ec 100644 --- a/js/msf/src/catalog.ts +++ b/js/msf/src/catalog.ts @@ -35,6 +35,8 @@ const trackShape = { samplerate: z.optional(z.number()), channelConfig: z.optional(z.string()), bitrate: z.optional(z.number()), + // Non-standard: whether the publisher recommends temporarily avoiding this track. + stalled: z.optional(z.boolean()), /** Resolved base64 initialization data (draft-01's initRef indirection is resolved away). */ initData: z.optional(z.string()), renderGroup: z.optional(z.number()), diff --git a/js/net/src/consume.ts b/js/net/src/consume.ts index 197309ee82..9c070aa878 100644 --- a/js/net/src/consume.ts +++ b/js/net/src/consume.ts @@ -6,7 +6,7 @@ import type * as Path from "./path.ts"; * subscribers. * * `Connection.consume(path)` must not mint a fresh subscription per call: repeat requests - * for the same path (e.g. several renditions referencing one `broadcast: "../source"`) should + * for the same path (e.g. several renditions referencing one `broadcast: "./source"`) should * share a single upstream subscription. This mirrors the Rust `origin::Consumer` weak-cache: * a still-live path resolves to a shared {@link broadcast.Consumer.clone}, a closed one is * re-consumed on the next request. Each handle is reference-counted, so the shared broadcast diff --git a/js/net/src/path.test.ts b/js/net/src/path.test.ts index b1bee824d5..834db511a0 100644 --- a/js/net/src/path.test.ts +++ b/js/net/src/path.test.ts @@ -190,49 +190,60 @@ test("from sanitizes multiple arguments with slashes", () => { expect(Path.from("foo//", "//bar", "baz")).toBe("foo/bar/baz" as Path.Valid); }); -test("resolve appends and pops", () => { - expect(Path.resolve(Path.from("a/b"), "c")).toBe(Path.from("a/b/c")); - expect(Path.resolve(Path.from("a/b"), "c/d")).toBe(Path.from("a/b/c/d")); - expect(Path.resolve(Path.from("a/b/c"), "../d")).toBe(Path.from("a/b/d")); - expect(Path.resolve(Path.from("a/b/c"), "..")).toBe(Path.from("a/b")); - expect(Path.resolve(Path.from("a/b/c"), "../../x")).toBe(Path.from("a/x")); +test("resolve replaces the base name", () => { + expect(Path.resolve(Path.from("a/b"), "c")).toBe(Path.from("a/c")); + expect(Path.resolve(Path.from("a/b"), "c/d")).toBe(Path.from("a/c/d")); + expect(Path.resolve(Path.from("foo.hang/catalog.pro"), "./transcode.pro")).toBe( + Path.from("foo.hang/transcode.pro"), + ); }); test("resolve with empty rel returns base", () => { expect(Path.resolve(Path.from("a/b"), "")).toBe(Path.from("a/b")); - expect(Path.resolve(Path.empty(), "")).toBe(Path.empty()); }); -test("resolve rejects a reference that escapes the root", () => { - expect(Path.resolve(Path.from("a/b/c"), "../../../../x")).toBeUndefined(); - expect(Path.resolve(Path.from("a/b/c"), "../../../..")).toBeUndefined(); - // A `..` that escapes mid-way is rejected even if later segments walk back down. - expect(Path.resolve(Path.from("a/b/c"), "../../../../a/b/c")).toBeUndefined(); - expect(Path.resolve(Path.empty(), "../x")).toBeUndefined(); +test("resolve single dotdot pops one segment", () => { + expect(Path.resolve(Path.from("a/b/c"), "../d")).toBe(Path.from("a/d")); + expect(Path.resolve(Path.from("a/b/c"), "..")).toBe(Path.from("a")); +}); + +test("resolve multiple dotdot pops multiple segments", () => { + expect(Path.resolve(Path.from("a/b/c"), "../../x")).toBe(Path.from("x")); + expect(Path.resolve(Path.from("a/b/c"), "../../../x")).toBe(Path.from("x")); }); -test("resolve allows a reference landing on the root", () => { - // Popping to exactly the root stops at it rather than walking above it. - expect(Path.resolve(Path.from("a"), "..")).toBe(Path.empty()); - expect(Path.resolve(Path.from("a/b"), "../..")).toBe(Path.empty()); - expect(Path.resolve(Path.from("a/b"), "../../x")).toBe(Path.from("x")); +test("resolve excess dotdot clamps at empty", () => { + expect(Path.resolve(Path.from("a"), "../../../foo")).toBe(Path.from("foo")); + expect(Path.resolve(Path.from("a"), "..")).toBe(Path.from("")); +}); + +test("resolve with empty base", () => { expect(Path.resolve(Path.empty(), "foo")).toBe(Path.from("foo")); + expect(Path.resolve(Path.empty(), "..")).toBe(Path.from("")); +}); + +test("resolve dot names the base parent", () => { + expect(Path.resolve(Path.from("a/b"), ".")).toBe(Path.from("a")); + expect(Path.resolve(Path.from("a/b"), "./c")).toBe(Path.from("a/c")); + expect(Path.resolve(Path.from("a/b"), "./../c")).toBe(Path.from("c")); + expect(Path.resolve(Path.from("a/b"), "foo/./bar")).toBe(Path.from("a/foo/bar")); }); -test("resolve treats dot as a no-op", () => { - expect(Path.resolve(Path.from("a/b"), ".")).toBe(Path.from("a/b")); - expect(Path.resolve(Path.from("a/b"), "./c")).toBe(Path.from("a/b/c")); - expect(Path.resolve(Path.from("a/b"), "./../c")).toBe(Path.from("a/c")); - expect(Path.resolve(Path.from("a/b"), "foo/./bar")).toBe(Path.from("a/b/foo/bar")); +test("resolve self-reference via sibling name equals base", () => { + expect(Path.resolve(Path.from("a/b"), "./b")).toBe(Path.from("a/b")); }); -test("resolve self-reference via dotdot equals base", () => { - expect(Path.resolve(Path.from("a/b"), "../b")).toBe(Path.from("a/b")); +test("tryResolve distinguishes the root from an escape", () => { + expect(Path.tryResolve(Path.from("top"), ".")).toBe(Path.empty()); + expect(Path.tryResolve(Path.from("top"), "..")).toBeUndefined(); + expect(Path.tryResolve(Path.from("a/b"), "..")).toBe(Path.empty()); + expect(Path.tryResolve(Path.from("a/b"), "../..")).toBeUndefined(); }); -test("normalizeRelative drops empty and dot segments", () => { +test("normalizeRelative preserves an all-dot reference", () => { expect(Path.normalizeRelative("")).toBe(""); - expect(Path.normalizeRelative(".")).toBe(""); + expect(Path.normalizeRelative(".")).toBe("."); + expect(Path.normalizeRelative("././")).toBe("."); expect(Path.normalizeRelative("./foo")).toBe("foo"); expect(Path.normalizeRelative("foo//bar")).toBe("foo/bar"); expect(Path.normalizeRelative("foo/./bar")).toBe("foo/bar"); diff --git a/js/net/src/path.ts b/js/net/src/path.ts index 9a379f14c5..e98b34021e 100644 --- a/js/net/src/path.ts +++ b/js/net/src/path.ts @@ -176,54 +176,77 @@ export function empty(): Valid { /** * Normalize a relative path reference: trim leading/trailing slashes, drop empty - * segments, and drop `.` segments (no-ops, matching POSIX). `..` is preserved and - * only interpreted by {@link resolve}. + * segments, and drop redundant `.` segments. A reference made only of `.` segments + * normalizes to `.` because it names the base's parent, while empty names the base. + * `..` is preserved and only interpreted by {@link resolve}. * * Mirrors the Rust `PathRelative::new` normalization, so JS and Rust agree * byte-for-byte on the stored form. Two callers comparing normalized strings can - * detect that `""`, `"."`, `"/./"` etc. all mean "no reference". + * detect equivalent references while preserving the distinction between `""` and `"."`. */ export function normalizeRelative(rel: string): string { - return rel - .split("/") - .filter((s) => s !== "" && s !== ".") - .join("/"); + const raw = rel.split("/"); + const normalized = raw.filter((s) => s !== "" && s !== ".").join("/"); + + return normalized === "" && raw.includes(".") ? "." : normalized; } /** * Resolve a relative path reference against a base path. * - * `..` segments pop the last segment of the base; other segments are appended. - * `.` and empty segments are no-ops. An empty / normalized-empty `rel` returns the - * base path unchanged. - * - * Returns `undefined` when a `..` has nothing left to pop, i.e. the reference walks - * above the root and names nothing. Popping to exactly the root is fine and yields the - * empty path, which still names a broadcast. The alternative, clamping, would silently - * land `../../../../x` on an unrelated `x`, so the caller decides what an out-of-range - * reference means (the hang catalog rejects such a catalog outright). + * A non-empty reference replaces the last segment of the base, matching relative URL + * resolution. `..` segments then pop another segment; other segments are appended. + * `.` and empty segments are no-ops. Excess `..` once the base is empty is also a no-op + * (subsequent named segments still append). An empty `rel` returns the base unchanged. * - * Mirrors the Rust `Path::resolve`, used by hang catalogs to express cross-broadcast - * track references (a rendition's `broadcast` field). + * Mirrors the Rust `Path::resolve`, used by hang catalogs to express + * cross-broadcast track references (a rendition's `broadcast` field). * * @example * ```typescript - * Path.resolve(Path.from("a/b/c"), "../source"); // "a/b/source" - * Path.resolve(Path.from("a/b"), "x/y"); // "a/b/x/y" - * Path.resolve(Path.from("a/b"), "./c"); // "a/b/c" - * Path.resolve(Path.from("a"), ".."); // "" (the root) - * Path.resolve(Path.from("a"), "../../x"); // undefined + * Path.resolve(Path.from("a/b/c"), "./source"); // "a/b/source" + * Path.resolve(Path.from("a/b"), "."); // "a" + * Path.resolve(Path.from("a/b/c"), "../source"); // "a/source" * ``` */ -export function resolve(base: Valid, rel: string): Valid | undefined { +export function resolve(base: Valid, rel: string): Valid { + if (rel === "") return base; + + const segments = base === "" ? [] : base.split("/"); + segments.pop(); + + for (const seg of rel.split("/")) { + if (seg === "" || seg === ".") { + continue; + } + if (seg === "..") { + segments.pop(); + } else { + segments.push(seg); + } + } + + return segments.join("/") as Valid; +} + +/** + * Resolve a relative path, returning `undefined` if it escapes above the root. + * + * Unlike {@link resolve}, this distinguishes a valid reference to the empty root + * path from excess `..` segments. Use it for untrusted catalog references that + * must not be clamped to the root. + */ +export function tryResolve(base: Valid, rel: string): Valid | undefined { + if (rel === "") return base; + const segments = base === "" ? [] : base.split("/"); + segments.pop(); for (const seg of rel.split("/")) { if (seg === "" || seg === ".") { continue; } if (seg === "..") { - // Nothing left to pop: the reference walks above the root. if (segments.pop() === undefined) return undefined; } else { segments.push(seg); diff --git a/js/watch/src/broadcast.test.ts b/js/watch/src/broadcast.test.ts index 537338b7ca..addac78c2e 100644 --- a/js/watch/src/broadcast.test.ts +++ b/js/watch/src/broadcast.test.ts @@ -38,13 +38,13 @@ function withoutWarnings(fn: () => T): T { describe("relativeBroadcast", () => { it("resolves a legal reference against the origin", () => { - const { source, owner } = broadcast("a/b", ["a/b", "a/source", "a/b/sub"]); + const { source, owner } = broadcast("a/b", ["a/b", "a/source", "a/sub"]); const effect = new Effect(); try { - expect(source.relativeBroadcast(effect, "../source")).toBeDefined(); + expect(source.relativeBroadcast(effect, "./source")).toBeDefined(); expect(source.relativeBroadcast(effect, "sub")).toBeDefined(); // Nothing routes an unpublished sibling, so the reference stays pending. - expect(source.relativeBroadcast(effect, "../missing")).toBeUndefined(); + expect(source.relativeBroadcast(effect, "./missing")).toBeUndefined(); } finally { effect.close(); source.close(); @@ -59,11 +59,11 @@ describe("relativeBroadcast", () => { // Clamping would subscribe to an unrelated `x` instead of dropping the rendition; // `x` is published, so a defined result here would prove the clamp bug. withoutWarnings(() => { - expect(source.relativeBroadcast(effect, "../../../x")).toBeUndefined(); - expect(source.relativeBroadcast(effect, "../../../..")).toBeUndefined(); + expect(source.relativeBroadcast(effect, "../../x")).toBeUndefined(); + expect(source.relativeBroadcast(effect, "../..")).toBeUndefined(); }); // Popping to exactly the root stops at it, and the root still names a broadcast. - expect(source.relativeBroadcast(effect, "../..")).toBeDefined(); + expect(source.relativeBroadcast(effect, "..")).toBeDefined(); } finally { effect.close(); source.close(); @@ -96,8 +96,8 @@ describe("relativeBroadcast", () => { // and serving the rest would hide a publisher bug behind a track that never fills. const { source, owner } = manual({ good: rendition(), - sibling: rendition("../source"), - bad: rendition("../../../x"), + sibling: rendition("./source"), + bad: rendition("../../x"), }); const error = console.error; @@ -120,7 +120,7 @@ describe("relativeBroadcast", () => { format: "vtt", role: "subtitle", container: { kind: "legacy" }, - broadcast: "../../../x", + broadcast: "../../x", } as Catalog.TextConfig; const { source, owner } = manualCatalog({ video: { renditions: { good: rendition() } }, @@ -142,8 +142,8 @@ describe("relativeBroadcast", () => { it("accepts a catalog whose references stay within the root", async () => { const { source, owner } = manual({ good: rendition(), - sibling: rendition("../source"), - root: rendition("../.."), + sibling: rendition("./source"), + root: rendition(".."), }); try { @@ -166,7 +166,7 @@ describe("relativeBroadcast", () => { expect(own).toBeDefined(); expect(source.relativeBroadcast(effect, undefined)).toBe(own); expect(source.relativeBroadcast(effect, "")).toBe(own); - expect(source.relativeBroadcast(effect, "../b")).toBe(own); + expect(source.relativeBroadcast(effect, "./b")).toBe(own); } finally { effect.close(); source.close(); diff --git a/js/watch/src/broadcast.ts b/js/watch/src/broadcast.ts index f7bb1500db..8f97c2b867 100644 --- a/js/watch/src/broadcast.ts +++ b/js/watch/src/broadcast.ts @@ -25,7 +25,7 @@ function findEscaping(base: Moq.Path.Valid, catalog: Catalog.Root): string | und ]; for (const [name, config] of renditions) { - if (config.broadcast && Path.resolve(base, config.broadcast) === undefined) return name; + if (config.broadcast && Path.tryResolve(base, config.broadcast) === undefined) return name; } return undefined; @@ -298,7 +298,7 @@ export class Broadcast { if (!rel) return effect.get(this.out.active); const base = effect.get(this.in.name); - const resolved = Path.resolve(base, rel); + const resolved = Path.tryResolve(base, rel); if (resolved === undefined) { console.warn("ignoring rendition: broadcast reference escapes the root", base, rel); return undefined; diff --git a/js/watch/src/msf.test.ts b/js/watch/src/msf.test.ts new file mode 100644 index 0000000000..90a44d6f61 --- /dev/null +++ b/js/watch/src/msf.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test"; +import type * as Msf from "@moq/msf"; +import { toHang } from "./msf"; + +test("preserves stalled video renditions", () => { + const catalog: Msf.Catalog = { + tracks: [ + { + name: "video", + packaging: "loc", + role: "video", + codec: "vp09.00.10.08", + stalled: true, + }, + ], + }; + + expect(toHang(catalog).video?.renditions.video?.stalled).toBe(true); +}); diff --git a/js/watch/src/msf.ts b/js/watch/src/msf.ts index 30fdc8ff2a..fc3eb9d3fe 100644 --- a/js/watch/src/msf.ts +++ b/js/watch/src/msf.ts @@ -52,6 +52,7 @@ function toVideoConfig(track: Msf.Track): Catalog.VideoConfig | undefined { codedHeight: track.height != null ? u53(track.height) : undefined, framerate: track.framerate, bitrate: track.bitrate != null ? u53(track.bitrate) : undefined, + stalled: track.stalled, jitter: track.jitter != null ? u53(track.jitter) : undefined, }; } diff --git a/js/watch/src/video/config.test.ts b/js/watch/src/video/config.test.ts new file mode 100644 index 0000000000..feb8dcf0b7 --- /dev/null +++ b/js/watch/src/video/config.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test"; +import * as Catalog from "@moq/hang/catalog"; +import { Effect, Signal } from "@moq/signals"; +import { playbackIdentity } from "./config"; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function config(fields: Record = {}): Catalog.VideoConfig { + return Catalog.VideoConfigSchema.parse({ + codec: "avc1.64001f", + container: { kind: "legacy" }, + description: "0142002a", + optimizeForLatency: true, + ...fields, + }); +} + +test("metadata changes do not change the playback identity", async () => { + const rendition = new Signal(config()); + const root = new Effect(); + const identity = root.computed((effect) => playbackIdentity(effect.get(rendition))); + let runs = 0; + + root.run((effect) => { + effect.get(identity); + runs++; + }); + await flush(); + expect(runs).toBe(1); + + rendition.set( + config({ + bitrate: 2_000_000, + stalled: true, + codedWidth: 1280, + codedHeight: 720, + framerate: 30, + jitter: 33, + }), + ); + await flush(); + expect(runs).toBe(1); + + rendition.set(config({ codec: "avc1.640028" })); + await flush(); + expect(runs).toBe(2); + + root.close(); +}); + +test("routing and decoder inputs change the playback identity", () => { + const base = playbackIdentity(config()); + + expect(playbackIdentity(config({ broadcast: "../source" }))).not.toEqual(base); + expect(playbackIdentity(config({ description: "0164001f" }))).not.toEqual(base); + expect(playbackIdentity(config({ container: { kind: "loc" } }))).not.toEqual(base); + expect(playbackIdentity(config({ displayAspectWidth: 16, displayAspectHeight: 9 }))).not.toEqual(base); + expect(playbackIdentity(config({ optimizeForLatency: false }))).not.toEqual(base); +}); diff --git a/js/watch/src/video/config.ts b/js/watch/src/video/config.ts new file mode 100644 index 0000000000..eb2ebe9c89 --- /dev/null +++ b/js/watch/src/video/config.ts @@ -0,0 +1,38 @@ +import type * as Catalog from "@moq/hang/catalog"; + +/** Internal key used to describe which fields can change a support probe's result. */ +export const supportCacheKey = Symbol("supportCacheKey"); + +/** The catalog fields that determine the demuxer and WebCodecs decoder instance. */ +export type DecoderConfig = Pick< + Catalog.VideoConfig, + "codec" | "container" | "description" | "displayAspectWidth" | "displayAspectHeight" +> & { + optimizeForLatency: boolean; +}; + +/** The routing and decoder state whose changes require a new playback pipeline. */ +export type PlaybackIdentity = { + broadcast: Catalog.VideoConfig["broadcast"]; + decoder: DecoderConfig; +}; + +/** Reduce a rendition config to the fields that require a new playback pipeline. */ +export function playbackIdentity(config: Catalog.VideoConfig): PlaybackIdentity { + return { + broadcast: config.broadcast, + decoder: { + codec: config.codec, + container: config.container, + description: config.description, + displayAspectWidth: config.displayAspectWidth, + displayAspectHeight: config.displayAspectHeight, + optimizeForLatency: config.optimizeForLatency ?? true, + }, + }; +} + +/** Return a stable cache key for the fields used by the WebCodecs support probe. */ +export function decoderConfigKey(config: Catalog.VideoConfig): string { + return JSON.stringify(playbackIdentity(config).decoder); +} diff --git a/js/watch/src/video/decoder.ts b/js/watch/src/video/decoder.ts index c9f25327c3..03b68272cf 100644 --- a/js/watch/src/video/decoder.ts +++ b/js/watch/src/video/decoder.ts @@ -3,10 +3,26 @@ import * as Container from "@moq/hang/container"; import * as Util from "@moq/hang/util"; import type * as Moq from "@moq/net"; import { Time } from "@moq/net"; -import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals"; +import { + type Computed, + Effect, + type Getter, + getter, + type Inputs, + type Readonlys, + readonlys, + Signal, +} from "@moq/signals"; import { base64ToBytes } from "../base64"; import type { Sync } from "../sync"; +import { + type DecoderConfig, + decoderConfigKey, + type PlaybackIdentity, + playbackIdentity, + supportCacheKey, +} from "./config"; import { caughtUp, renditionJitter, switchJitter } from "./playhead"; import { rotateVideoDimensions } from "./presentation"; import type { Source } from "./source"; @@ -48,11 +64,6 @@ type DecoderOutput = { buffered: Signal; }; -// The types in VideoDecoderConfig that cause a hard reload. -// ex. codedWidth/Height are optional and can be changed in-band, so we don't want to trigger a reload. -// This way we can keep the current subscription active. -type RequiredDecoderConfig = Omit; - /** Downloads video from a track and decodes it into {@link VideoFrame}s with WebCodecs. */ export class Decoder { readonly in: Readonlys; @@ -73,6 +84,7 @@ export class Decoder { // The current track running, held so we can cancel it when the new track is ready. #active = new Signal(undefined); #pendingJitter = new Signal(undefined); + readonly #identity: Computed; #signals = new Effect(); @@ -91,6 +103,10 @@ export class Decoder { this.source = source; this.sync = sync; + this.#identity = this.#signals.computed((effect) => { + const config = effect.get(this.source.out.config); + return config ? playbackIdentity(config) : undefined; + }); this.#signals.run(this.#runJitter.bind(this)); this.#signals.run(this.#runPending.bind(this)); @@ -110,7 +126,7 @@ export class Decoder { this.in.enabled, this.source.in.broadcast, this.source.out.track, - this.source.out.config, + this.#identity, ]); if (!values) { // Close the active track when disabled (e.g. paused or not visible). @@ -118,11 +134,11 @@ export class Decoder { this.#active.set(undefined); return; } - const [_, broadcast, track, config] = values; + const [_, broadcast, track, identity] = values; // Honor a per-rendition `broadcast` override: subscribe on the resolved source // broadcast instead of the catalog's own broadcast. - const active: Moq.Broadcast.Consumer | undefined = broadcast.relativeBroadcast(effect, config.broadcast); + const active: Moq.Broadcast.Consumer | undefined = broadcast.relativeBroadcast(effect, identity.broadcast); if (!active) { // Going offline should clear the last rendered frame. this.#active.set(undefined); @@ -136,7 +152,7 @@ export class Decoder { sync: this.sync, broadcast: active, track, - config, + config: identity.decoder, stats: this.#out.stats, }); effect.set(this.#pendingJitter, pending.jitter); @@ -240,7 +256,7 @@ interface DecoderTrackProps { sync: Sync; broadcast: Moq.Broadcast.Consumer; track: string; - config: Catalog.VideoConfig; + config: DecoderConfig; stats: Signal; } @@ -249,7 +265,7 @@ class DecoderTrack { sync: Sync; broadcast: Moq.Broadcast.Consumer; track: string; - config: RequiredDecoderConfig; + config: DecoderConfig; stats: Signal; jitter: Time.Milli | undefined; @@ -269,13 +285,10 @@ class DecoderTrack { #signals = new Effect(); constructor(props: DecoderTrackProps) { - // Remove the codedWidth/Height from the config to avoid a hard reload if nothing else has changed. - const { codedWidth: _, codedHeight: __, ...requiredConfig } = props.config; - this.sync = props.sync; this.broadcast = props.broadcast; this.track = props.track; - this.config = requiredConfig; + this.config = props.config; this.stats = props.stats; this.jitter = renditionJitter(props.config); @@ -364,9 +377,11 @@ class DecoderTrack { }); decoder.configure({ - ...this.config, + codec: this.config.codec, description: this.config.description ? Util.Hex.toBytes(this.config.description) : undefined, - optimizeForLatency: this.config.optimizeForLatency ?? true, + displayAspectWidth: this.config.displayAspectWidth, + displayAspectHeight: this.config.displayAspectHeight, + optimizeForLatency: this.config.optimizeForLatency, // @ts-expect-error Only supported by Chrome, so the renderer has to flip manually. flip: false, }); @@ -441,7 +456,9 @@ class DecoderTrack { decoder.configure({ codec: this.config.codec, description, - optimizeForLatency: this.config.optimizeForLatency ?? true, + displayAspectWidth: this.config.displayAspectWidth, + displayAspectHeight: this.config.displayAspectHeight, + optimizeForLatency: this.config.optimizeForLatency, // @ts-expect-error Only supported by Chrome, so the renderer has to flip manually. flip: false, }); @@ -573,6 +590,8 @@ async function supported(config: Catalog.VideoConfig): Promise { const { supported } = await VideoDecoder.isConfigSupported({ codec: config.codec, description, + displayAspectWidth: config.displayAspectWidth, + displayAspectHeight: config.displayAspectHeight, optimizeForLatency: config.optimizeForLatency ?? true, }); @@ -586,6 +605,8 @@ async function supported(config: Catalog.VideoConfig): Promise { const retry = await VideoDecoder.isConfigSupported({ codec: avc1, description, + displayAspectWidth: config.displayAspectWidth, + displayAspectHeight: config.displayAspectHeight, optimizeForLatency: config.optimizeForLatency ?? true, }); if (retry.supported) { @@ -596,3 +617,5 @@ async function supported(config: Catalog.VideoConfig): Promise { return false; } + +Object.assign(supported, { [supportCacheKey]: decoderConfigKey }); diff --git a/js/watch/src/video/source.test.ts b/js/watch/src/video/source.test.ts index 5b8fb9443e..91d929d0a2 100644 --- a/js/watch/src/video/source.test.ts +++ b/js/watch/src/video/source.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; -import type * as Catalog from "@moq/hang/catalog"; +import * as Catalog from "@moq/hang/catalog"; +import { Path } from "@moq/net"; import { Signal } from "@moq/signals"; -import type { Broadcast } from "../broadcast"; +import { Broadcast } from "../broadcast"; +import { decoderConfigKey, supportCacheKey } from "./config"; import { Source } from "./source"; const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -10,11 +12,11 @@ async function settle(): Promise { for (let i = 0; i < 5; i++) await flush(); } -function config(codec: string): Catalog.VideoConfig { - return { codec, container: { kind: "legacy" } }; +function config(codec: string, fields: Record = {}): Catalog.VideoConfig { + return Catalog.VideoConfigSchema.parse({ codec, container: { kind: "legacy" }, ...fields }); } -function broadcast(renditions: Record): Broadcast { +function mockBroadcast(renditions: Record): Broadcast { return { in: { connection: new Signal(undefined), @@ -25,6 +27,20 @@ function broadcast(renditions: Record): Broadcast { } as unknown as Broadcast; } +function mutableBroadcast(renditions: Record): { + broadcast: Broadcast; + catalog: Signal; +} { + const catalog = new Signal({ video: { renditions } }); + return { + broadcast: { + in: { connection: new Signal(undefined) }, + out: { catalog }, + } as unknown as Broadcast, + catalog, + }; +} + async function withoutWarnings(fn: () => Promise): Promise { const warn = console.warn; console.warn = () => {}; @@ -39,7 +55,7 @@ describe("Source error signal", () => { it("is unsupported when the catalog has video renditions but none are supported", async () => { await withoutWarnings(async () => { const source = new Source({ - broadcast: broadcast({ hd: config("hev1.1.6.L120.90") }), + broadcast: mockBroadcast({ hd: config("hev1.1.6.L120.90") }), supported: async () => false, }); @@ -54,7 +70,7 @@ describe("Source error signal", () => { it("treats a support probe throw as unsupported without aborting the remaining renditions", async () => { await withoutWarnings(async () => { const source = new Source({ - broadcast: broadcast({ + broadcast: mockBroadcast({ bad: config("not-a-codec"), good: config("avc1.640028"), }), @@ -81,7 +97,7 @@ describe("Source error signal", () => { ); const source = new Source({ - broadcast: broadcast({ hd: config("avc1.640028") }), + broadcast: mockBroadcast({ hd: config("avc1.640028") }), supported, }); @@ -101,7 +117,7 @@ describe("Source error signal", () => { it("is undefined when the catalog has no video renditions", async () => { const source = new Source({ - broadcast: broadcast({}), + broadcast: mockBroadcast({}), supported: async () => false, }); @@ -111,4 +127,156 @@ describe("Source error signal", () => { source.close(); }); + + // The catalog goes as a whole rather than losing the one rendition, so a valid sibling + // is no rescue: the reference names content outside the consumer's authorized subtree, + // and selecting the fallback would leave a publisher bug to surface later as a track + // that never fills. + it("selects nothing when an escaping rendition rejects the catalog", async () => { + const invalidVideo = { ...config("avc1.640028"), broadcast: "../../source" }; + const validVideo = { ...config("avc1.640028"), broadcast: "./source" }; + const audioConfig = Catalog.AudioConfigSchema.parse({ + codec: "opus", + container: { kind: "legacy" }, + sampleRate: 48_000, + numberOfChannels: 2, + }); + const broadcast = new Broadcast({ + enabled: true, + name: Path.from("room/catalog.hang"), + catalogFormat: "manual", + catalog: { + video: { renditions: { invalid: invalidVideo, fallback: validVideo } }, + audio: { + renditions: { + invalid: { ...audioConfig, broadcast: "../../source" }, + fallback: { ...audioConfig, broadcast: "./source" }, + }, + }, + }, + }); + const source = new Source({ broadcast, supported: async () => true }); + + const error = console.error; + console.error = () => {}; + try { + await settle(); + expect(broadcast.out.catalog.peek()).toBeUndefined(); + expect(Object.keys(source.out.available.peek())).toEqual([]); + expect(source.out.track.peek()).toBeUndefined(); + } finally { + console.error = error; + } + + source.close(); + broadcast.close(); + }); +}); + +describe("Source stalled rendition selection", () => { + it("skips a stalled manual target while an unstalled rendition exists", async () => { + const source = new Source({ + broadcast: mockBroadcast({ + low: config("avc1.64001e", { bitrate: 1_000_000 }), + high: config("avc1.640028", { bitrate: 2_000_000, stalled: true }), + }), + target: { name: "high" }, + supported: async () => true, + }); + + await settle(); + expect(source.out.track.peek()).toBe("low"); + expect(Object.keys(source.out.available.peek())).toEqual(["low", "high"]); + source.close(); + }); + + it("selects the lowest rendition when every supported rendition is stalled", async () => { + const source = new Source({ + broadcast: mockBroadcast({ + high: config("avc1.640028", { bitrate: 2_000_000, stalled: true }), + low: config("avc1.64001e", { bitrate: 1_000_000, stalled: true }), + }), + supported: async () => true, + }); + + await settle(); + expect(source.out.track.peek()).toBe("low"); + source.close(); + }); + + it("uses coded dimensions when stalled renditions omit bitrate", async () => { + const source = new Source({ + broadcast: mockBroadcast({ + high: config("avc1.640028", { codedWidth: 1920, codedHeight: 1080, stalled: true }), + low: config("avc1.64001e", { codedWidth: 854, codedHeight: 480, stalled: true }), + }), + supported: async () => true, + }); + + await settle(); + expect(source.out.track.peek()).toBe("low"); + source.close(); + }); + + it("does not repeat support probes for metadata-only changes", async () => { + const state = mutableBroadcast({ high: config("avc1.640028", { bitrate: 2_000_000 }) }); + let probes = 0; + const supported = async () => { + probes++; + return true; + }; + Object.assign(supported, { [supportCacheKey]: decoderConfigKey }); + const source = new Source({ + broadcast: state.broadcast, + supported, + }); + + await settle(); + expect(probes).toBe(1); + + state.catalog.set({ + video: { + renditions: { + high: config("avc1.640028", { + bitrate: 1_500_000, + stalled: true, + codedWidth: 1280, + codedHeight: 720, + }), + }, + }, + }); + await settle(); + + expect(probes).toBe(1); + expect(Number(source.out.config.peek()?.bitrate)).toBe(1_500_000); + expect(source.out.config.peek()?.stalled).toBe(true); + source.close(); + }); + + it("repeats custom support probes when metadata changes", async () => + withoutWarnings(async () => { + const state = mutableBroadcast({ high: config("avc1.640028", { codedWidth: 1280 }) }); + let probes = 0; + const source = new Source({ + broadcast: state.broadcast, + supported: async (rendition) => { + probes++; + return (rendition.codedWidth ?? 0) <= 1920; + }, + }); + + await settle(); + expect(probes).toBe(1); + expect(Object.keys(source.out.available.peek())).toEqual(["high"]); + + state.catalog.set({ + video: { renditions: { high: config("avc1.640028", { codedWidth: 3840 }) } }, + }); + await settle(); + + expect(probes).toBe(2); + expect(source.out.available.peek()).toEqual({}); + source.close(); + })); }); diff --git a/js/watch/src/video/source.ts b/js/watch/src/video/source.ts index f2034ab015..6df0917358 100644 --- a/js/watch/src/video/source.ts +++ b/js/watch/src/video/source.ts @@ -2,6 +2,7 @@ import type * as Catalog from "@moq/hang/catalog"; import type * as Moq from "@moq/net"; import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals"; import type { Broadcast } from "../broadcast"; +import { supportCacheKey } from "./config"; /** * A function that checks if a video configuration can be played. @@ -10,6 +11,10 @@ import type { Broadcast } from "../broadcast"; */ export type Supported = (config: Catalog.VideoConfig) => Promise; +type CacheableSupported = Supported & { + [supportCacheKey]?: (config: Catalog.VideoConfig) => string; +}; + /** A video source error that prevents choosing a usable rendition. */ export type SourceError = "unsupported"; @@ -97,7 +102,7 @@ function byPixels(target: number): RenditionFilter { return [rest[0].name]; } - // No entries had resolution metadata — return all names unranked. + // No entries had resolution metadata, so return all names unranked. return entries.map(([name]) => name); }; } @@ -138,7 +143,7 @@ function byDimensions(width?: number, height?: number): RenditionFilter { return [rest[0].name]; } - // No entries had resolution metadata — return all names unranked. + // No entries had resolution metadata, so return all names unranked. return entries.map(([name]) => name); }; } @@ -175,7 +180,7 @@ function byBitrate(target: number): RenditionFilter { return [rest[0].name]; } - // No entries had bitrate metadata — return all names unranked. + // No entries had bitrate metadata, so return all names unranked. return entries.map(([name]) => name); }; } @@ -208,6 +213,18 @@ function bestRendition(entries: [string, Catalog.VideoConfig][]): string { return best[0]; } +/** Return unstalled renditions, or the lowest bitrate or resolution when every option is stalled. */ +function selectableRenditions(renditions: Record): Record { + const active = Object.entries(renditions).filter(([, config]) => !config.stalled); + if (active.length > 0) return Object.fromEntries(active); + + const entries = Object.entries(renditions); + if (entries.length === 0) return {}; + const byRate = byBitrate(0)(entries); + const lowest = byRate.length === 1 ? byRate[0] : byDimensions(0, 0)(entries)[0]; + return { [lowest]: renditions[lowest] }; +} + /** * Source handles catalog extraction, support checking, and rendition selection * for video playback. The Decoder consumes whichever rendition it picks. @@ -225,6 +242,7 @@ export class Source { readonly out = readonlys(this.#out); #signals = new Effect(); + #supportCache = new WeakMap>(); constructor(props?: Inputs) { this.in = { @@ -258,6 +276,15 @@ export class Source { const renditions = effect.get(this.#out.catalog)?.renditions ?? {}; this.#out.error.set(undefined); + let cache = this.#supportCache.get(supported); + if (!cache) { + cache = new Map(); + this.#supportCache.set(supported, cache); + } + const names = new Set(Object.keys(renditions)); + for (const name of cache.keys()) { + if (!names.has(name)) cache.delete(name); + } effect.spawn(async () => { const available: Record = {}; @@ -268,14 +295,29 @@ export class Source { const cancelled = effect.cancel.then(() => undefined); for (const [name, config] of Object.entries(renditions)) { + const cacheKey = (supported as CacheableSupported)[supportCacheKey]; + const key = cacheKey ? cacheKey(config) : JSON.stringify(config); + const cached = cache.get(name); let isSupported: boolean | undefined = false; - try { - isSupported = await Promise.race([supported(config), cancelled]); - } catch (err) { - console.warn( - `[Source] video rendition ${name} (${config.codec}) support probe failed; treating as unsupported`, - err, - ); + if (cached?.key === key) { + isSupported = cached.supported; + } else { + let failed = false; + try { + isSupported = await Promise.race([supported(config), cancelled]); + } catch (err) { + failed = true; + console.warn( + `[Source] video rendition ${name} (${config.codec}) support probe failed; treating as unsupported`, + err, + ); + } + if (!failed && isSupported !== undefined) { + cache.set(name, { + key: cacheKey ? cacheKey(config) : JSON.stringify(config), + supported: isSupported, + }); + } } // Torn down: stop probing and publish nothing, since the rerun redoes this. @@ -296,12 +338,12 @@ export class Source { } #runSelected(effect: Effect): void { - const available = effect.get(this.#out.available); + const available = selectableRenditions(effect.get(this.#out.available)); if (Object.keys(available).length === 0) return; const target = effect.get(this.in.target); - // Manual selection by name — skip all ABR logic. + // Manual selection by name skips all ABR logic. if (target?.name && target.name in available) { const config = available[target.name]; effect.set(this.#out.track, target.name); @@ -354,7 +396,7 @@ export class Source { filters.push(byBitrate(target.bitrate)); } - // No filters — pick the best rendition by quality. + // With no filters, pick the best rendition by quality. if (filters.length === 0) { return bestRendition(entries); } diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt index 8317d980ce..26f4abd681 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt @@ -93,7 +93,7 @@ typealias Datagram = uniffi.moq.MoqDatagram typealias Frame = uniffi.moq.MoqFrame /** A [Frame] plus the codec metadata a media track carries. */ typealias MediaFrame = uniffi.moq.MoqMediaFrame -/** The catalog description of a video track: codec, dimensions, bitrate, and container. */ +/** The catalog description of a video track, including whether the publisher recommends temporarily avoiding it. */ typealias Video = uniffi.moq.MoqVideo /** Caller-provided catalog fields for a video track. */ typealias VideoHint = uniffi.moq.MoqVideoHint diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index 0fac58bbfe..142bf8036a 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -201,7 +201,7 @@ All consumers (`CatalogConsumer`, `MediaConsumer`, `TrackConsumer`, `AudioConsum - **`MediaFrame`**. `.payload: bytes`, `.timestamp_us: int`, `.keyframe: bool`. Returned by media subscriptions. - **`Datagram`**. `.sequence: int`, `.timestamp_us: int`, `.payload: bytes`. Delivered only on datagram-capable transports and lite-05 or newer moq-lite. - **`Audio`**. `.codec`, `.sample_rate`, `.channel_count`, `.bitrate`, `.description`. -- **`Video`**. `.codec`, `.coded: Dimensions`, `.display_aspect`, `.bitrate`, `.framerate`, `.description`. +- **`Video`**. `.codec`, `.coded: Dimensions`, `.display_aspect`, `.bitrate`, `.stalled`, `.framerate`, `.description`. A true `.stalled` recommends temporarily avoiding the rendition without making it unavailable. - **`Subscription`**. Subscriber delivery preferences: priority, ordering priority, staleness, and optional group range. - **`TrackInfo`**. Publisher track properties: priority, ordering priority, cache window, and timescale. - **`Dimensions`**. `.width: int`, `.height: int`. diff --git a/rs/hang/CHANGELOG.md b/rs/hang/CHANGELOG.md index 8eb265dff7..f93c351455 100644 --- a/rs/hang/CHANGELOG.md +++ b/rs/hang/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.20.5](https://github.com/moq-dev/moq/compare/hang-v0.20.4...hang-v0.20.5) - 2026-08-14 + +### Fixed + +- *(path)* resolve catalog references like URLs ([#2855](https://github.com/moq-dev/moq/pull/2855)) + ## [0.20.4](https://github.com/moq-dev/moq/compare/hang-v0.20.3...hang-v0.20.4) - 2026-08-06 ### Added diff --git a/rs/hang/Cargo.toml b/rs/hang/Cargo.toml index 3eb1d1d8f9..ea1b6a1bb8 100644 --- a/rs/hang/Cargo.toml +++ b/rs/hang/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.20.4" +version = "0.20.5" edition = "2024" rust-version.workspace = true diff --git a/rs/hang/src/catalog/audio/mod.rs b/rs/hang/src/catalog/audio/mod.rs index c66da8f980..9e1f0f30f1 100644 --- a/rs/hang/src/catalog/audio/mod.rs +++ b/rs/hang/src/catalog/audio/mod.rs @@ -69,7 +69,7 @@ impl Audio { #[non_exhaustive] pub struct AudioConfig { /// Optional reference to another broadcast that publishes this track, expressed - /// relative to the broadcast that served this catalog (e.g. `../source`). If unset, + /// relative to the broadcast that served this catalog (e.g. `./source`). If unset, /// the track lives in the same broadcast as the catalog. /// /// Resolve it with [`Path::resolve`](moq_net::Path::resolve): a reference that walks diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index 185073bc6e..161e7550e3 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -236,6 +236,7 @@ mod test { display_aspect_width: None, display_aspect_height: None, bitrate: None, + stalled: None, framerate: None, optimize_for_latency: None, container: Container::Legacy, @@ -277,7 +278,7 @@ mod test { "video": { "renditions": { "video": { - "broadcast": "../source", + "broadcast": "./source", "codec": "avc1.64001f", "codedWidth": 1280, "codedHeight": 720, @@ -291,7 +292,7 @@ mod test { let rendition = parsed.video.renditions.get("video").expect("missing rendition"); assert_eq!( rendition.broadcast.as_ref().map(|p| p.as_str()), - Some("../source"), + Some("source"), "broadcast field did not deserialize" ); @@ -357,6 +358,29 @@ mod test { ); } + #[test] + fn rendition_with_parent_broadcast_stays_distinct_from_empty() { + let encoded = r#"{ + "video": { + "renditions": { + "video": { + "broadcast": ".", + "codec": "avc1.64001f", + "container": {"kind": "legacy"} + } + } + } + }"#; + + let parsed = Catalog::from_str(encoded).expect("failed to decode"); + let rendition = parsed.video.renditions.get("video").expect("missing rendition"); + assert_eq!( + rendition.broadcast.as_ref().map(|p| p.as_str()), + Some("."), + "parent reference should not normalize to empty" + ); + } + #[test] fn unknown_container_keeps_siblings() { // A rendition using a future container must not take down the rest of the catalog. diff --git a/rs/hang/src/catalog/video/mod.rs b/rs/hang/src/catalog/video/mod.rs index abe439ae89..3a2d1c5287 100644 --- a/rs/hang/src/catalog/video/mod.rs +++ b/rs/hang/src/catalog/video/mod.rs @@ -142,7 +142,7 @@ pub struct Display { #[non_exhaustive] pub struct VideoConfig { /// Optional reference to another broadcast that publishes this track, expressed - /// relative to the broadcast that served this catalog (e.g. `../source`). If unset, + /// relative to the broadcast that served this catalog (e.g. `./source`). If unset, /// the track lives in the same broadcast as the catalog. /// /// This allows a transcoder to author a downstream catalog that points unchanged @@ -190,6 +190,13 @@ pub struct VideoConfig { #[serde(default)] pub bitrate: Option, + /// Whether the publisher recommends temporarily avoiding this rendition. + /// + /// The track remains available. Consumers may still select it when no + /// unstalled rendition is suitable. + #[serde(default)] + pub stalled: Option, + /// The frame rate of the video track, if known. #[serde(default)] pub framerate: Option, @@ -237,6 +244,7 @@ impl VideoConfig { display_aspect_width: None, display_aspect_height: None, bitrate: None, + stalled: None, framerate: None, optimize_for_latency: None, container: Container::default(), @@ -284,6 +292,20 @@ mod test { assert_eq!(config.display_aspect_height, Some(9)); } + #[test] + fn stalled_is_optional_and_round_trips() { + let mut config = VideoConfig::new(VideoCodec::VP8); + let encoded = serde_json::to_value(&config).expect("failed to encode"); + assert!(encoded.get("stalled").is_none()); + + config.stalled = Some(true); + let encoded = serde_json::to_value(&config).expect("failed to encode"); + assert_eq!(encoded["stalled"], true); + + let decoded: VideoConfig = serde_json::from_value(encoded).expect("failed to decode"); + assert_eq!(decoded.stalled, Some(true)); + } + #[test] fn normalizes_video_rotation_to_quarter_turns() { for (rotation, expected) in [ diff --git a/rs/libmoq/CHANGELOG.md b/rs/libmoq/CHANGELOG.md index 56a3d58c4c..9f9d6f6abc 100644 --- a/rs/libmoq/CHANGELOG.md +++ b/rs/libmoq/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.8](https://github.com/moq-dev/moq/compare/libmoq-v0.5.7...libmoq-v0.5.8) - 2026-08-14 + +### Added + +- *(libmoq)* declare the catalog container for manually authored renditions ([#2805](https://github.com/moq-dev/moq/pull/2805)) + ## [0.5.7](https://github.com/moq-dev/moq/compare/libmoq-v0.5.6...libmoq-v0.5.7) - 2026-08-13 ### Added diff --git a/rs/libmoq/Cargo.toml b/rs/libmoq/Cargo.toml index f20482ea97..312baeb0cd 100644 --- a/rs/libmoq/Cargo.toml +++ b/rs/libmoq/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley ", "Brian Medley " repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.5.7" +version = "0.5.8" edition = "2024" rust-version.workspace = true diff --git a/rs/libmoq/README.md b/rs/libmoq/README.md index 5490dac089..d1a118c477 100644 --- a/rs/libmoq/README.md +++ b/rs/libmoq/README.md @@ -62,6 +62,7 @@ int32_t moq_consume_catalog(uint32_t broadcast, void (*on_catalog)(void *user_da int32_t moq_consume_catalog_close(uint32_t catalog); int32_t moq_consume_catalog_free(uint32_t catalog); int32_t moq_consume_video_config(uint32_t catalog, uint32_t index, moq_video_config *dst); +int32_t moq_consume_video_stalled(uint32_t catalog, uint32_t index, bool *dst); int32_t moq_consume_audio_config(uint32_t catalog, uint32_t index, moq_audio_config *dst); // Consuming: Video diff --git a/rs/libmoq/src/api.rs b/rs/libmoq/src/api.rs index 0c89beca1b..fb35371145 100644 --- a/rs/libmoq/src/api.rs +++ b/rs/libmoq/src/api.rs @@ -2413,6 +2413,29 @@ pub unsafe extern "C" fn moq_consume_video_config(catalog: u32, index: u32, dst: }) } +/// Query whether the publisher recommends temporarily avoiding a video rendition. +/// +/// The track remains available. A false value also covers catalogs that omit the +/// optional field. +/// +/// Returns zero on success, or a negative code on failure. +/// +/// # Safety +/// - The caller must ensure that `dst` points to properly aligned, writable storage for a `bool`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn moq_consume_video_stalled(catalog: u32, index: u32, dst: *mut bool) -> i32 { + ffi::enter(move || { + let catalog = ffi::parse_id(catalog)?; + if dst.is_null() { + return Err(Error::InvalidPointer); + } + + let stalled = State::lock().consume.video_stalled(catalog, index as usize)?; + unsafe { dst.write(stalled) }; + Ok(()) + }) +} + /// Query the catalog properties shared by every video rendition. /// /// The destination is filled by value and remains valid after the catalog snapshot is freed. diff --git a/rs/libmoq/src/consume.rs b/rs/libmoq/src/consume.rs index d247ac8851..1ccdf42358 100644 --- a/rs/libmoq/src/consume.rs +++ b/rs/libmoq/src/consume.rs @@ -219,6 +219,19 @@ impl Consume { Ok(()) } + /// Return whether the publisher recommends temporarily avoiding a video rendition. + pub fn video_stalled(&self, catalog: Id, index: usize) -> Result { + let consume = self.catalog.get(catalog).ok_or(Error::CatalogNotFound)?; + let (_, config) = consume + .catalog + .video + .renditions + .iter() + .nth(index) + .ok_or(Error::NoIndex)?; + Ok(config.stalled.unwrap_or(false)) + } + /// Fill `dst` with the properties shared by every video rendition. pub fn video_properties(&self, catalog: Id, dst: &mut moq_video_properties) -> Result<(), Error> { let consume = self.catalog.get(catalog).ok_or(Error::CatalogNotFound)?; diff --git a/rs/libmoq/src/test.rs b/rs/libmoq/src/test.rs index f2ca14dec9..497cf7d8fc 100644 --- a/rs/libmoq/src/test.rs +++ b/rs/libmoq/src/test.rs @@ -285,6 +285,30 @@ fn publish_catalog_roundtrip() { container: moq_container::default(), }; assert_eq!(unsafe { moq_publish_video_config(broadcast, &video) }, 0); + let stalled_video_name = "video-stalled"; + let stalled_video = moq_video_config { + name: stalled_video_name.as_ptr() as *const c_char, + name_len: stalled_video_name.len(), + codec: video_codec.as_ptr() as *const c_char, + codec_len: video_codec.len(), + description: description.as_ptr(), + description_len: description.len(), + coded_width: &width, + coded_height: &height, + container: moq_container::default(), + }; + assert_eq!(unsafe { moq_publish_video_config(broadcast, &stalled_video) }, 0); + { + let mut state = State::lock(); + let (_, catalog) = state.publish.pair_mut(Id::try_from(broadcast).unwrap()).unwrap(); + catalog + .lock() + .video + .renditions + .get_mut(stalled_video_name) + .unwrap() + .stalled = Some(true); + } let properties = moq_video_properties { display_width: 1080, display_height: 1920, @@ -340,6 +364,35 @@ fn publish_catalog_roundtrip() { assert_eq!(codec, "vp8"); assert_eq!(unsafe { *video_cfg.coded_width }, 1920); assert_eq!(unsafe { *video_cfg.coded_height }, 1080); + let mut stalled = std::mem::MaybeUninit::::uninit(); + assert_eq!( + unsafe { moq_consume_video_stalled(catalog_id, 0, stalled.as_mut_ptr()) }, + 0 + ); + assert!(!unsafe { stalled.assume_init() }); + + let mut stalled = std::mem::MaybeUninit::::uninit(); + assert_eq!( + unsafe { moq_consume_video_stalled(catalog_id, 1, stalled.as_mut_ptr()) }, + 0 + ); + assert!(unsafe { stalled.assume_init() }); + assert_eq!( + unsafe { moq_consume_video_stalled(catalog_id, 0, std::ptr::null_mut()) }, + -6, + "null stalled pointer should return InvalidPointer (-6)" + ); + assert_eq!( + unsafe { + moq_publish_video_remove( + broadcast, + stalled_video_name.as_ptr() as *const c_char, + stalled_video_name.len(), + ) + }, + 0 + ); + let active_catalog_id = id(catalog_cb.recv()); let mut properties = moq_video_properties::default(); assert_eq!(unsafe { moq_consume_video_properties(catalog_id, &mut properties) }, 0); @@ -380,6 +433,7 @@ fn publish_catalog_roundtrip() { assert_eq!(unsafe { moq_consume_audio_config(catalog_id2, 0, &mut audio_cfg) }, 0); assert_eq!(moq_consume_catalog_free(catalog_id), 0); + assert_eq!(moq_consume_catalog_free(active_catalog_id), 0); assert_eq!(moq_consume_catalog_free(catalog_id2), 0); assert_eq!(moq_consume_catalog_close(catalog_task), 0); assert_eq!(catalog_cb.recv_terminal(), 0, "catalog close delivers terminal 0"); diff --git a/rs/moq-boy/CHANGELOG.md b/rs/moq-boy/CHANGELOG.md index a715aa19a8..c77bfaeb7e 100644 --- a/rs/moq-boy/CHANGELOG.md +++ b/rs/moq-boy/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.8](https://github.com/moq-dev/moq/compare/moq-boy-v0.4.7...moq-boy-v0.4.8) - 2026-08-14 + +### Other + +- updated the following local packages: moq-video + ## [0.4.7](https://github.com/moq-dev/moq/compare/moq-boy-v0.4.6...moq-boy-v0.4.7) - 2026-08-13 ### Added diff --git a/rs/moq-boy/Cargo.toml b/rs/moq-boy/Cargo.toml index 7d8814a8dc..2644585fcd 100644 --- a/rs/moq-boy/Cargo.toml +++ b/rs/moq-boy/Cargo.toml @@ -7,7 +7,7 @@ license = "MIT OR Apache-2.0" keywords = ["moq", "gameboy", "streaming", "emulator", "live"] categories = ["multimedia::video", "emulators", "network-programming"] -version = "0.4.7" +version = "0.4.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-cli/CHANGELOG.md b/rs/moq-cli/CHANGELOG.md index 0cd09d3cb9..e1280860b7 100644 --- a/rs/moq-cli/CHANGELOG.md +++ b/rs/moq-cli/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.9.11](https://github.com/moq-dev/moq/compare/moq-cli-v0.9.10...moq-cli-v0.9.11) - 2026-08-14 + +### Added + +- *(cli)* run multiple import/export stages over one connection ([#2809](https://github.com/moq-dev/moq/pull/2809)) + +### Fixed + +- *(path)* resolve catalog references like URLs ([#2855](https://github.com/moq-dev/moq/pull/2855)) +- *(net)* stop blocking connect on the initial announce set ([#2856](https://github.com/moq-dev/moq/pull/2856)) + ## [0.9.10](https://github.com/moq-dev/moq/compare/moq-cli-v0.9.9...moq-cli-v0.9.10) - 2026-08-13 ### Fixed diff --git a/rs/moq-cli/Cargo.toml b/rs/moq-cli/Cargo.toml index 13fa9908bd..6cd3c842db 100644 --- a/rs/moq-cli/Cargo.toml +++ b/rs/moq-cli/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.9.10" +version = "0.9.11" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-cli/src/main.rs b/rs/moq-cli/src/main.rs index fe1e360522..2176f62541 100644 --- a/rs/moq-cli/src/main.rs +++ b/rs/moq-cli/src/main.rs @@ -562,7 +562,7 @@ async fn run_stdout(consumer: moq_net::origin::Consumer, name: String, args: Sub // Confirm the broadcast is reachable and wait for it to be announced; `Subscribe` then // resolves it (and any sibling broadcast a rendition's `broadcast` field references, - // e.g. "../source") through the origin. + // e.g. "./source") through the origin. consumer .announced_broadcast(&name) .await diff --git a/rs/moq-cli/src/transcode.rs b/rs/moq-cli/src/transcode.rs index c47c20b8d7..8c291d599e 100644 --- a/rs/moq-cli/src/transcode.rs +++ b/rs/moq-cli/src/transcode.rs @@ -67,15 +67,19 @@ fn parse_resize_acceleration(arg: &str) -> Result anyhow::Result<()> { - let source_path = moq - .broadcast - .clone() - .filter(|name| !name.is_empty()) - .context("`transcode` requires the source broadcast: pass --broadcast ")?; - let output_path = args - .output - .clone() - .unwrap_or_else(|| format!("{source_path}/transcode.hang")); + let source_path = moq_net::PathOwned::from( + moq.broadcast + .clone() + .context("`transcode` requires the source broadcast: pass --broadcast ")?, + ); + if source_path.is_empty() { + anyhow::bail!("`transcode` requires the source broadcast: pass --broadcast "); + } + let output_path = moq_net::PathOwned::from( + args.output + .clone() + .unwrap_or_else(|| format!("{source_path}/transcode.hang")), + ); // Publish the derivative through one origin and consume the source through // another, over a single auto-reconnecting session. @@ -89,19 +93,32 @@ pub async fn run(moq: MoqSide, args: Args, net: Net) -> anyhow::Result<()> { // surfaced rather than transcoded over. The reconnect loop covers the dial; // restarting after a mid-run drop is the caller's call. let remote = moq_net::Origin::random().produce(); - let mut session = net + let session = net .client(moq.client.clone())? .with_publisher(&publish) .with_subscriber(remote.clone()) .connect(url); - // Wait for the first session: the origin can't route a broadcast request - // until a connected session registers its handler. - while !matches!(session.status().await?, moq_native::Status::Connected) {} + // Wait for the source to be announced rather than for the session to connect: + // `request_broadcast` answers on the spot, so asking the moment a session exists + // races the announcement that makes the path routable. + // + // Raced against the session ending, since the wait itself never fails: the origin + // outlives the session here, so a rejected token or an exhausted retry budget would + // otherwise leave us waiting for an announcement that can never arrive. + let consumer = remote.consume(); + tokio::select! { + announced = consumer.announced_broadcast(&source_path) => { + announced.context("origin closed before the source broadcast was announced")?; + } + closed = session.closed() => { + closed.context("session failed before the source broadcast was announced")?; + anyhow::bail!("session closed before the source broadcast was announced"); + } + } - // Request the source broadcast; the session subscribes upstream on demand. - let source = remote - .consume() + // Resolve it for real; the session subscribes upstream on demand. + let source = consumer .request_broadcast(&source_path) .await .context("source broadcast unavailable")?; @@ -123,13 +140,9 @@ pub async fn run(moq: MoqSide, args: Args, net: Net) -> anyhow::Result<()> { name => moq_video::decode::Kind::Named(name.to_string()), }; config.resize.acceleration = args.resize_acceleration; - // Reference the source renditions relatively when the output nests under - // the source (`a/b` -> `a/b/transcode.hang` is `..`, one `..` per level); + // Reference the source renditions relatively when the output nests under it; // otherwise the derivative catalog advertises only the rungs. - config.source = output_path.strip_prefix(&format!("{source_path}/")).map(|rest| { - let depth = rest.split('/').count(); - moq_net::PathRelativeOwned::from(vec![".."; depth].join("/")) - }); + config.source = moq_transcode::source_reference(&source_path, &output_path); let output = publish .create_broadcast(&output_path, moq_net::broadcast::Route::new().with_announce(true)) diff --git a/rs/moq-ffi/CHANGELOG.md b/rs/moq-ffi/CHANGELOG.md index eaa649fc7e..d3f1dea9a2 100644 --- a/rs/moq-ffi/CHANGELOG.md +++ b/rs/moq-ffi/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.11](https://github.com/moq-dev/moq/compare/moq-ffi-v0.3.10...moq-ffi-v0.3.11) - 2026-08-14 + +### Added + +- *(bindings)* fetch and decode a retained media group ([#2827](https://github.com/moq-dev/moq/pull/2827)) + +### Fixed + +- *(mux)* derive missing video geometry ([#2840](https://github.com/moq-dev/moq/pull/2840)) +- *(net)* stop blocking connect on the initial announce set ([#2856](https://github.com/moq-dev/moq/pull/2856)) + ## [0.3.10](https://github.com/moq-dev/moq/compare/moq-ffi-v0.3.9...moq-ffi-v0.3.10) - 2026-08-13 ### Added diff --git a/rs/moq-ffi/Cargo.toml b/rs/moq-ffi/Cargo.toml index e740dbd6bb..c21e0e167f 100644 --- a/rs/moq-ffi/Cargo.toml +++ b/rs/moq-ffi/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley ", "Brian Medley " repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.3.10" +version = "0.3.11" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/moq-ffi/src/media.rs b/rs/moq-ffi/src/media.rs index 4937b31ec7..67934e1efd 100644 --- a/rs/moq-ffi/src/media.rs +++ b/rs/moq-ffi/src/media.rs @@ -84,6 +84,9 @@ pub struct MoqVideo { pub coded: Option, pub display_aspect: Option, pub bitrate: Option, + /// Whether the publisher recommends temporarily avoiding this rendition. + #[uniffi(default = false)] + pub stalled: bool, pub framerate: Option, pub container: MoqContainer, } @@ -217,6 +220,7 @@ pub(crate) fn convert_catalog(catalog: &moq_mux::catalog::hang::Catalog None, }, bitrate: config.bitrate, + stalled: config.stalled.unwrap_or(false), framerate: config.framerate, container: MoqContainer::from_catalog(&config.container)?, }, @@ -262,3 +266,22 @@ pub(crate) fn convert_catalog(catalog: &moq_mux::catalog::hang::Catalog Result, MoqError> { let _guard = crate::ffi::RUNTIME.enter(); let path = moq_net::Path::new(&path).to_owned(); @@ -290,6 +293,9 @@ impl MoqOriginConsumer { /// errors if nothing can serve it. Unlike `announced_broadcast`, this does *not* wait /// indefinitely for a future announcement: it resolves or fails based on what is /// announced now plus any dynamic fallback. Drop the returned future to cancel. + /// + /// Calling this straight after connecting therefore races the session's announcements + /// and can report a live broadcast as unroutable. Await `announced_broadcast` first. pub async fn request_broadcast(&self, path: String) -> Result, MoqError> { let broadcast = self.inner.request_broadcast(path.as_str()).await?; Ok(Arc::new(MoqBroadcastConsumer::new(broadcast))) diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 02fe28c2da..f5e05186b6 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -515,7 +515,9 @@ async fn fetch_media_group_rejects_invalid_container_before_fetching() { #[tokio::test] async fn fetch_media_group_decodes_multiple_cmaf_samples() { - let config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); + let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); + config.coded_width = Some(320); + config.coded_height = Some(240); let muxer = moq_mux::container::fmp4::Muxer::video(&config).unwrap(); let init = muxer.init().unwrap().expect("VP8 init should be available"); let catalog_container = hang::catalog::Container::Cmaf { init: init.clone() }; diff --git a/rs/moq-hls/CHANGELOG.md b/rs/moq-hls/CHANGELOG.md index d61f4db544..207e712f04 100644 --- a/rs/moq-hls/CHANGELOG.md +++ b/rs/moq-hls/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.8](https://github.com/moq-dev/moq/compare/moq-hls-v0.4.7...moq-hls-v0.4.8) - 2026-08-14 + +### Fixed + +- *(mux)* derive missing video geometry ([#2840](https://github.com/moq-dev/moq/pull/2840)) +- *(path)* resolve catalog references like URLs ([#2855](https://github.com/moq-dev/moq/pull/2855)) + ## [0.4.7](https://github.com/moq-dev/moq/compare/moq-hls-v0.4.6...moq-hls-v0.4.7) - 2026-08-13 ### Fixed diff --git a/rs/moq-hls/Cargo.toml b/rs/moq-hls/Cargo.toml index 1d34ae2541..498289b377 100644 --- a/rs/moq-hls/Cargo.toml +++ b/rs/moq-hls/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.4.7" +version = "0.4.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 54ad94a345..8f390383f2 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -365,6 +365,29 @@ mod tests { } } + fn vp8_frame(micros: u64, keyframe: bool) -> moq_mux::container::Frame { + let payload = if keyframe { + // Key frame tag, start code, and 320x240 geometry. + &[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00][..] + } else { + &[0x31, 0x00, 0x00][..] + }; + moq_mux::container::Frame { + timestamp: moq_net::Timestamp::from_micros(micros).unwrap(), + payload: bytes::Bytes::copy_from_slice(payload), + keyframe, + duration: None, + } + } + + fn video_config() -> hang::catalog::VideoConfig { + let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); + config.coded_width = Some(320); + config.coded_height = Some(240); + config.framerate = Some(30.0); + config + } + // Let the origin's spawned attach task run so a created broadcast is routable. async fn settle() { for _ in 0..10 { @@ -372,6 +395,29 @@ mod tests { } } + #[tokio::test] + async fn escaping_broadcast_reference_is_not_advertised() { + let origin = moq_net::Origin::random().produce(); + let _broadcast = origin + .create_broadcast("a/pub", moq_net::broadcast::Route::new().with_announce(true)) + .expect("publish allowed"); + settle().await; + let source = moq_mux::Source::new(origin.consume(), "a/pub"); + let upstream = Upstream { + broadcast: source.broadcast().await.unwrap(), + source, + }; + let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); + config.broadcast = Some(moq_net::PathRelative::new("../../source").to_owned()); + let mut catalog = moq_mux::catalog::hang::Catalog::default(); + catalog.timeline = Some(hang::catalog::Timeline::new(hang::timeline::DEFAULT_NAME)); + catalog.video.renditions.insert("video".to_string(), config); + + let renditions = renditions::Producer::new(Config::default().window); + renditions.sync(&upstream, &catalog); + assert!(renditions.get(Kind::Video, "video").is_none()); + } + // The whole fetch-on-demand path in process: a broadcast publishes media through the // catalog (which records the timeline), the Broadcaster renders playlists from the // timeline alone, and a segment request fetches and transmuxes exactly its groups. @@ -386,8 +432,9 @@ mod tests { let reserved = catalog.reserve(); let mut registration = reserved.video("video0"); - let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); - config.framerate = Some(30.0); + let mut config = video_config(); + config.coded_width = None; + config.coded_height = None; registration.set(config); drop(reserved); @@ -396,11 +443,11 @@ mod tests { let mut media = catalog .media_producer(track, moq_mux::catalog::hang::Container::Legacy) .unwrap(); - media.write(frame(0, true)).unwrap(); - media.write(frame(1_000_000, false)).unwrap(); - media.write(frame(2_000_000, true)).unwrap(); - media.write(frame(3_000_000, false)).unwrap(); - media.write(frame(4_000_000, true)).unwrap(); + media.write(vp8_frame(0, true)).unwrap(); + media.write(vp8_frame(1_000_000, false)).unwrap(); + media.write(vp8_frame(2_000_000, true)).unwrap(); + media.write(vp8_frame(3_000_000, false)).unwrap(); + media.write(vp8_frame(4_000_000, true)).unwrap(); let source = moq_mux::Source::new(origin.consume(), "live"); let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap(); @@ -462,9 +509,7 @@ mod tests { let reserved = catalog.reserve(); let mut video_registration = reserved.video("video0"); - let mut video_config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); - video_config.framerate = Some(30.0); - video_registration.set(video_config); + video_registration.set(video_config()); let mut audio_registration = reserved.audio("audio0"); let audio_config = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2); audio_registration.set(audio_config); @@ -545,9 +590,7 @@ mod tests { let reserved = catalog.reserve(); let mut registration = reserved.video("video0"); - let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); - config.framerate = Some(30.0); - registration.set(config); + registration.set(video_config()); drop(reserved); let track = broadcast.create_track("video0", None).unwrap(); @@ -645,9 +688,7 @@ mod tests { let reserved = catalog.reserve(); let mut video_registration = reserved.video("video0"); - let mut video_config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); - video_config.framerate = Some(30.0); - video_registration.set(video_config); + video_registration.set(video_config()); let mut audio_registration = reserved.audio("audio0"); let audio_config = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2); audio_registration.set(audio_config); @@ -732,8 +773,7 @@ mod tests { let reserved = catalog.reserve(); let mut registration = reserved.video("video0"); - let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); - config.framerate = Some(30.0); + let config = video_config(); registration.set(config); drop(reserved); @@ -802,8 +842,7 @@ mod tests { let reserved = catalog.reserve(); let mut registration = reserved.video("video0"); - let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); - config.framerate = Some(30.0); + let config = video_config(); registration.set(config.clone()); drop(reserved); @@ -956,8 +995,7 @@ mod tests { let reserved = catalog.reserve(); let mut registration = reserved.video("video0"); - let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); - config.framerate = Some(30.0); + let config = video_config(); registration.set(config); drop(reserved); @@ -1014,8 +1052,7 @@ mod tests { let reserved = catalog.reserve(); let mut registration = reserved.video("video0"); - let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); - config.framerate = Some(30.0); + let config = video_config(); registration.set(config); drop(reserved); diff --git a/rs/moq-hls/src/export/renditions.rs b/rs/moq-hls/src/export/renditions.rs index b6e09403b9..0a9e7e1a0c 100644 --- a/rs/moq-hls/src/export/renditions.rs +++ b/rs/moq-hls/src/export/renditions.rs @@ -295,6 +295,28 @@ impl Producer { let Ok(mut current) = self.state.write() else { return; }; + let mut catalog = catalog.clone(); + catalog.video.renditions.retain(|name, config| { + let valid = upstream.source.resolve_reference(config.broadcast.as_ref()).is_some(); + if !valid { + tracing::warn!( + rendition = name, + "ignoring video rendition whose broadcast escapes above the root" + ); + } + valid + }); + catalog.audio.renditions.retain(|name, config| { + let valid = upstream.source.resolve_reference(config.broadcast.as_ref()).is_some(); + if !valid { + tracing::warn!( + rendition = name, + "ignoring audio rendition whose broadcast escapes above the root" + ); + } + valid + }); + let catalog = &catalog; // Renditions the catalog dropped or reconfigured. Close each as it goes so any cursor // over it drains and ends, instead of parking on a window that never finishes -- the diff --git a/rs/moq-msf/src/lib.rs b/rs/moq-msf/src/lib.rs index 6bf61cad3b..dcc6f1e70b 100644 --- a/rs/moq-msf/src/lib.rs +++ b/rs/moq-msf/src/lib.rs @@ -102,6 +102,11 @@ pub struct Track { /// Bitrate in bits per second. pub bitrate: Option, + /// Whether the publisher recommends temporarily avoiding this track. + /// + /// This is a non-standard extension shared with the hang catalog. + pub stalled: Option, + /// Resolved base64 initialization data. /// /// On the wire this is carried indirectly through draft-01's `initDataList` + @@ -372,6 +377,7 @@ impl Track { samplerate: None, channel_config: None, bitrate: None, + stalled: None, init_data: None, init_ref: None, render_group: None, @@ -522,6 +528,7 @@ mod test { samplerate: None, channel_config: None, bitrate: Some(6_000_000), + stalled: Some(true), init_data: None, init_ref: None, render_group: Some(1), @@ -545,6 +552,7 @@ mod test { samplerate: Some(48_000), channel_config: Some("2".to_string()), bitrate: Some(128_000), + stalled: None, init_data: None, init_ref: None, render_group: Some(1), @@ -568,6 +576,7 @@ mod test { samplerate: None, channel_config: None, bitrate: Some(5_000_000), + stalled: None, init_data: None, init_ref: None, render_group: Some(1), @@ -596,6 +605,7 @@ mod test { assert!(track.get("maxGrpSapStartingType").is_none()); assert!(track.get("maxObjSapStartingType").is_none()); assert!(track.get("jitter").is_none()); + assert_eq!(track["stalled"], true); } #[test] diff --git a/rs/moq-mux/CHANGELOG.md b/rs/moq-mux/CHANGELOG.md index 22337c0870..0b1ae8e697 100644 --- a/rs/moq-mux/CHANGELOG.md +++ b/rs/moq-mux/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.9.7](https://github.com/moq-dev/moq/compare/moq-mux-v0.9.6...moq-mux-v0.9.7) - 2026-08-14 + +### Added + +- *(bindings)* fetch and decode a retained media group ([#2827](https://github.com/moq-dev/moq/pull/2827)) + +### Fixed + +- *(mux)* derive missing video geometry ([#2840](https://github.com/moq-dev/moq/pull/2840)) +- *(path)* resolve catalog references like URLs ([#2855](https://github.com/moq-dev/moq/pull/2855)) +- *(moq-mux)* stop publishing audio spliced across a TS discontinuity ([#2823](https://github.com/moq-dev/moq/pull/2823)) +- *(moq-mux)* anchor the TS table cadence to the media timeline ([#2825](https://github.com/moq-dev/moq/pull/2825)) + ## [0.9.6](https://github.com/moq-dev/moq/compare/moq-mux-v0.9.5...moq-mux-v0.9.6) - 2026-08-13 ### Added diff --git a/rs/moq-mux/Cargo.toml b/rs/moq-mux/Cargo.toml index 9dc8386138..1cfa104cd3 100644 --- a/rs/moq-mux/Cargo.toml +++ b/rs/moq-mux/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.9.6" +version = "0.9.7" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-mux/src/catalog/hang/consumer.rs b/rs/moq-mux/src/catalog/hang/consumer.rs index 7250e7afb5..7fced81afe 100644 --- a/rs/moq-mux/src/catalog/hang/consumer.rs +++ b/rs/moq-mux/src/catalog/hang/consumer.rs @@ -97,7 +97,7 @@ fn check_resolvable(base: &PathOwned, catalog: &Catalog) -> Re continue; }; - if base.resolve(rel).is_none() { + if base.try_resolve(rel).is_none() { tracing::error!(rendition, %rel, catalog = %base, "rejecting catalog: broadcast reference escapes the root"); return Err(crate::Error::EscapingBroadcast(rel.to_string())); } @@ -242,12 +242,13 @@ mod test { } /// A reference that stops at or below the root names a broadcast, so the catalog stands. + /// `a/pub` resolves against `a`, so `..` is what lands on the root itself. #[tokio::test] async fn accepts_references_within_the_root() { let catalog = publish(vec![ ("here", opus()), - ("sibling", referencing("../source")), - ("root", referencing("../..")), + ("sibling", referencing("./source")), + ("root", referencing("..")), ]) .expect("catalog should be accepted") .expect("catalog should decode"); diff --git a/rs/moq-mux/src/catalog/msf/consumer.rs b/rs/moq-mux/src/catalog/msf/consumer.rs index 1a573d18f1..05425518d7 100644 --- a/rs/moq-mux/src/catalog/msf/consumer.rs +++ b/rs/moq-mux/src/catalog/msf/consumer.rs @@ -215,6 +215,7 @@ fn video_config_from_msf(track: &moq_msf::Track) -> Result> config.coded_width = track.width; config.coded_height = track.height; config.bitrate = track.bitrate; + config.stalled = track.stalled; config.framerate = track.framerate; config.container = container; config.jitter = track.jitter; @@ -386,6 +387,7 @@ mod test { track.height = Some(1080); track.framerate = Some(30.0); track.bitrate = Some(5_000_000); + track.stalled = Some(true); track.init_data = init_data.map(str::to_string); track.render_group = Some(1); track @@ -423,6 +425,7 @@ mod test { assert_eq!(video.coded_height, Some(1080)); assert_eq!(video.framerate, Some(30.0)); assert_eq!(video.bitrate, Some(5_000_000)); + assert_eq!(video.stalled, Some(true)); } #[test] diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index 3366cf0d7f..73c73daa2e 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -509,6 +509,7 @@ fn to_msf(catalog: &hang::Catalog) -> moq_msf::Catalog { track.height = config.coded_height; track.framerate = config.framerate; track.bitrate = config.bitrate; + track.stalled = config.stalled; track.init_data = init_data; track.render_group = Some(1); track.alt_group = if has_multiple_video { Some(1) } else { None }; @@ -820,6 +821,7 @@ mod test { video_config.coded_width = Some(1280); video_config.coded_height = Some(720); video_config.bitrate = Some(6_000_000); + video_config.stalled = Some(true); video_config.framerate = Some(30.0); video_config.container = Container::Legacy; @@ -850,6 +852,7 @@ mod test { assert_eq!(video.height, Some(720)); assert_eq!(video.framerate, Some(30.0)); assert_eq!(video.bitrate, Some(6_000_000)); + assert_eq!(video.stalled, Some(true)); assert!(video.init_data.is_none()); // H.264 may carry B-frames, so SAP starting type is 2 (leading pictures allowed). assert_eq!(video.max_grp_sap_starting_type, Some(2)); diff --git a/rs/moq-mux/src/codec/av1/import.rs b/rs/moq-mux/src/codec/av1/import.rs index 1c9df1b5d2..b84907ed40 100644 --- a/rs/moq-mux/src/codec/av1/import.rs +++ b/rs/moq-mux/src/codec/av1/import.rs @@ -184,17 +184,13 @@ impl Import { } self.last_seq = Some(seq_obu.clone()); - let mut reader = &seq_obu[..]; - let header = ObuHeader::parse(&mut reader)?; - let payload_offset = seq_obu.len() - reader.len(); - - match SequenceHeaderObu::parse(header, &mut &seq_obu[payload_offset..]) { - Ok(seq_header) => self.init(&seq_header), - Err(_) if !self.catalog.configured() => { + match parse_sequence_header(seq_obu)? { + Some(seq_header) => self.init(&seq_header), + None if !self.catalog.configured() => { tracing::debug!("sequence header parse failed, using minimal config"); self.init_minimal(); } - Err(_) => {} + None => {} } Ok(()) } @@ -275,6 +271,26 @@ fn is_sequence_header(obu: &[u8]) -> bool { .unwrap_or(false) } +fn parse_sequence_header(obu: &[u8]) -> Result> { + let mut reader = obu; + let header = ObuHeader::parse(&mut reader)?; + Ok(SequenceHeaderObu::parse(header, &mut reader).ok()) +} + +/// Read encoded dimensions from the first parseable sequence header in a frame. +pub(crate) fn dimensions(payload: &[u8]) -> Result> { + let Some(sequence) = find_sequence_header(payload) else { + return Ok(None); + }; + let Some(sequence) = parse_sequence_header(&sequence)? else { + return Ok(None); + }; + Ok(Some(( + sequence.max_frame_width as u32, + sequence.max_frame_height as u32, + ))) +} + /// Find the first sequence-header OBU in a payload, if any. fn find_sequence_header(payload: &[u8]) -> Option { let mut buf = Bytes::copy_from_slice(payload); diff --git a/rs/moq-mux/src/codec/h264/export.rs b/rs/moq-mux/src/codec/h264/export.rs index 54cd817761..880adc34db 100644 --- a/rs/moq-mux/src/codec/h264/export.rs +++ b/rs/moq-mux/src/codec/h264/export.rs @@ -124,6 +124,9 @@ impl Export { } fn update_catalog(&mut self, catalog: &Catalog) -> crate::Result<()> { + let mut catalog = catalog.clone(); + self.source.retain_valid_media(&mut catalog); + let picked = catalog .video .renditions @@ -152,7 +155,9 @@ impl Export { return Ok(()); } - let source = ExportSource::for_video_raw(&self.source, name, config, self.latency)?; + let Some(source) = ExportSource::for_video_raw(&self.source, name, config, self.latency)? else { + unreachable!("invalid broadcast references were removed above"); + }; let convert = match config.description.as_ref().filter(|d| !d.is_empty()) { None => None, Some(avcc) => { diff --git a/rs/moq-mux/src/codec/h265/export.rs b/rs/moq-mux/src/codec/h265/export.rs index bc9c50d4a3..d87bd98fcf 100644 --- a/rs/moq-mux/src/codec/h265/export.rs +++ b/rs/moq-mux/src/codec/h265/export.rs @@ -116,6 +116,9 @@ impl Export { } fn update_catalog(&mut self, catalog: &Catalog) -> crate::Result<()> { + let mut catalog = catalog.clone(); + self.source.retain_valid_media(&mut catalog); + let picked = catalog .video .renditions @@ -144,7 +147,9 @@ impl Export { return Ok(()); } - let source = ExportSource::for_video_raw(&self.source, name, config, self.latency)?; + let Some(source) = ExportSource::for_video_raw(&self.source, name, config, self.latency)? else { + unreachable!("invalid broadcast references were removed above"); + }; let convert = match config.description.as_ref().filter(|d| !d.is_empty()) { None => None, Some(hvcc) => { diff --git a/rs/moq-mux/src/container/flv/export.rs b/rs/moq-mux/src/container/flv/export.rs index 3f676561dc..6b9ea41d76 100644 --- a/rs/moq-mux/src/container/flv/export.rs +++ b/rs/moq-mux/src/container/flv/export.rs @@ -303,7 +303,9 @@ impl Export { !self.video.is_empty() || !self.audio.is_empty() } - fn update_catalog(&mut self, catalog: Catalog) -> anyhow::Result<()> { + fn update_catalog(&mut self, mut catalog: Catalog) -> anyhow::Result<()> { + self.source.retain_valid_media(&mut catalog); + // A single-track FLV stream binds only the first rendition of each kind; // multitrack binds them all. Bind newly-seen renditions in name order (the // catalog is a BTreeMap) so each keeps a stable track id. @@ -351,7 +353,9 @@ impl Export { (VideoCodec::AV1(av1), None) => Some(Bytes::copy_from_slice(&av1c_bytes(av1))), _ => None, }; - let source = ExportSource::for_video(&self.source, name, config, self.latency)?; + let Some(source) = ExportSource::for_video(&self.source, name, config, self.latency)? else { + continue; + }; let track_id = u8::try_from(self.video.len()).context("too many FLV video tracks")?; self.video.push(FlvTrack { name: name.clone(), @@ -379,7 +383,9 @@ impl Export { } let flavor = audio_flavor(config)?; ensure_legacy(&config.container, "audio", name)?; - let source = ExportSource::for_audio(&self.source, name, config, self.latency)?; + let Some(source) = ExportSource::for_audio(&self.source, name, config, self.latency)? else { + continue; + }; let track_id = u8::try_from(self.audio.len()).context("too many FLV audio tracks")?; self.audio.push(FlvTrack { name: name.clone(), diff --git a/rs/moq-mux/src/container/fmp4/export.rs b/rs/moq-mux/src/container/fmp4/export.rs index 35e81d9008..cb3d27a3bc 100644 --- a/rs/moq-mux/src/container/fmp4/export.rs +++ b/rs/moq-mux/src/container/fmp4/export.rs @@ -236,14 +236,23 @@ impl Export { // is ready, so the source keeps polling for SPS/PPS-bearing frames // instead of parking. let waiting_for_init = !self.init_emitted; - for track in self.tracks.values_mut() { + for (name, track) in &mut self.tracks { if track.pending.is_some() || track.finished { continue; } loop { match track.source.poll_read(waiter) { Poll::Ready(Ok(Some(frame))) => { - if waiting_for_init && !track.source.header_ready() { + let geometry_ready = !track.is_video + || self + .catalog_snapshot + .as_ref() + .and_then(|catalog| catalog.video.renditions.get(name)) + .is_some_and(|config| { + matches!(config.container, Container::Cmaf { .. }) + || track.source.video_geometry_ready(config) + }); + if waiting_for_init && (!track.source.header_ready() || !geometry_ready) { continue; } track.pending = Some(frame); @@ -373,6 +382,7 @@ impl Export { .audio .renditions .retain(|name, config| crate::catalog::hang::supported(name, &config.container)); + self.source.retain_valid_media(&mut catalog); let catalog = &catalog; let mut active: HashMap = HashMap::new(); @@ -391,7 +401,9 @@ impl Export { if self.tracks.contains_key(name) { continue; } - let source = ExportSource::for_video(&self.source, name, config, self.latency)?; + let Some(source) = ExportSource::for_video(&self.source, name, config, self.latency)? else { + continue; + }; let timescale = catalog_timescale_video(config)?; let framerate = super::usable_video_framerate(config).unwrap_or(30.0); self.tracks.insert( @@ -417,7 +429,9 @@ impl Export { if self.tracks.contains_key(name) { continue; } - let source = ExportSource::for_audio(&self.source, name, config, self.latency)?; + let Some(source) = ExportSource::for_audio(&self.source, name, config, self.latency)? else { + continue; + }; let timescale = catalog_timescale_audio(config)?; self.tracks.insert( name.clone(), @@ -449,7 +463,17 @@ impl Export { /// True once every source has resolved its codec config so we can build /// the merged init segment. fn init_ready(&self) -> bool { - self.catalog_snapshot.is_some() && self.tracks.values().all(|t| t.source.header_ready()) + let Some(catalog) = self.catalog_snapshot.as_ref() else { + return false; + }; + self.tracks.values().all(|t| t.source.header_ready()) + && catalog.video.renditions.iter().all(|(name, config)| { + matches!(config.container, Container::Cmaf { .. }) + || self + .tracks + .get(name) + .is_some_and(|track| track.source.video_geometry_ready(config)) + }) } /// Build the merged ftyp + multi-track moov init segment from the cached @@ -474,10 +498,11 @@ impl Export { Container::Legacy | Container::Loc => { // H.264/H.265 need a synthesized config record here; VP8 has none. let description = track.source.description(); + let config = track.source.video_config(config).unwrap_or_else(|| config.clone()); let trak = crate::container::fmp4::synthesize_video_trak( track.track_id, track.timescale, - config, + &config, description.map(|d| d.as_ref()), )?; trexs.push(mp4_atom::Trex { diff --git a/rs/moq-mux/src/container/fmp4/export_test.rs b/rs/moq-mux/src/container/fmp4/export_test.rs index c4c38f0acd..d35dbd0cf9 100644 --- a/rs/moq-mux/src/container/fmp4/export_test.rs +++ b/rs/moq-mux/src/container/fmp4/export_test.rs @@ -126,22 +126,27 @@ async fn legacy_aac_source_to_cmaf_export_synthesizes_esds() { moov.encode(&mut buf).expect("encode synthesized moov"); } -/// VP8 source (catalog `Container::Legacy`, codec `vp8`, no `description`) → -/// fMP4 export must synthesize a `vp08` sample entry. VP8 carries no out-of-band -/// config, so this exercises the description-less synthesis path. +/// VP8 source (catalog `Container::Legacy`, codec `vp8`, no dimensions or +/// `description`) → fMP4 export derives geometry from the keyframe and +/// synthesizes a `vp08` sample entry. VP8 carries no out-of-band config, so +/// this exercises the dimensionless startup and description-less synthesis paths. #[tokio::test(start_paused = true)] async fn vp8_source_to_cmaf_export_synthesizes_vp08() { use hang::catalog::{Container, VideoCodec, VideoConfig}; let mut live = Live::new(".vp8", |catalog, name| { let mut config = VideoConfig::new(VideoCodec::VP8); - config.coded_width = Some(320); - config.coded_height = Some(240); config.container = Container::Legacy; catalog.lock().video.renditions.insert(name, config); }); + // Geometry-less startup frames must not park the source before the keyframe. + live.track.write(raw_frame(0, &[0x31, 0x00, 0x00], true)).unwrap(); live.track - .write(raw_frame(0, &[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a], true)) + .write(raw_frame( + 33_000, + &[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00], + true, + )) .unwrap(); live.track.finish().unwrap(); @@ -177,6 +182,80 @@ async fn vp8_source_to_cmaf_export_synthesizes_vp08() { moov.encode(&mut buf).expect("encode synthesized moov"); } +/// If codec data cannot reveal geometry yet, the exporter waits for a later +/// catalog snapshot instead of freezing zero dimensions into the init segment. +#[tokio::test(start_paused = true)] +async fn dimensionless_video_waits_for_catalog_geometry() { + use hang::catalog::{Container, VideoCodec, VideoConfig}; + + let mut live = Live::new(".vp8", |catalog, name| { + let mut config = VideoConfig::new(VideoCodec::VP8); + config.container = Container::Legacy; + catalog.lock().video.renditions.insert(name, config); + }); + let name = live.track.name().to_string(); + // A VP8 interframe carries no geometry. Mark it as the group boundary only + // so the synthetic producer accepts it before the catalog update arrives. + live.track.write(raw_frame(0, &[0x31, 0x00, 0x00], true)).unwrap(); + + let mut exporter = crate::container::fmp4::Export::new(live.source(), live.catalog_stream().await); + let pending = tokio::time::timeout(std::time::Duration::from_secs(1), exporter.next()).await; + assert!( + pending.is_err(), + "exporter emitted an init without geometry: {pending:?}" + ); + + { + let mut catalog = live.catalog.lock(); + let config = catalog.video.renditions.get_mut(&name).unwrap(); + config.coded_width = Some(320); + config.coded_height = Some(240); + } + + let init = chunk_now(&mut exporter) + .await + .init() + .expect("the init segment comes first"); + let mut cursor = Cursor::new(init.as_ref()); + let mut moov = None; + while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor).expect("decode init") { + if let mp4_atom::Any::Moov(value) = atom { + moov = Some(value); + } + } + let trak = &moov.expect("init segment missing moov").trak[0]; + let mp4_atom::Codec::Vp08(vp08) = &trak.mdia.minf.stbl.stsd.codecs[0] else { + panic!("expected vp08 sample entry"); + }; + assert_eq!((vp08.visual.width, vp08.visual.height), (320, 240)); +} + +/// A fixed codec description cannot recover on a later frame, so malformed +/// metadata must fail instead of leaving the exporter pending for geometry. +#[tokio::test(start_paused = true)] +async fn dimensionless_video_rejects_a_malformed_description() { + use hang::catalog::{Container, H264, VideoConfig}; + + let live = Live::new(".avc1", |catalog, name| { + let mut config = VideoConfig::new(H264 { + profile: 0x42, + constraints: 0, + level: 0x1f, + inline: false, + }); + config.description = Some(bytes::Bytes::from_static(&[1])); + config.container = Container::Legacy; + catalog.lock().video.renditions.insert(name, config); + }); + + let mut exporter = crate::container::fmp4::Export::new(live.source(), live.catalog_stream().await); + let error = exporter.next().await.expect_err("malformed fixed description"); + assert!(matches!( + error, + crate::Error::H264(crate::codec::h264::Error::AvccTooShort) + )); +} + /// VP9 source (catalog `Container::Legacy`, codec `vp09`, no `description`) → /// fMP4 export must synthesize a `vp09` sample entry whose `vpcC` round-trips /// the catalog's VP9 parameters. @@ -552,6 +631,8 @@ async fn unusable_framerate_uses_the_standard_fallback_rate() { let mut live = Live::new(".vp8", |catalog, name| { let mut config = VideoConfig::new(VideoCodec::VP8); + config.coded_width = Some(320); + config.coded_height = Some(240); config.framerate = Some(0.0005); config.container = Container::Legacy; catalog.lock().video.renditions.insert(name, config); diff --git a/rs/moq-mux/src/container/fmp4/mod.rs b/rs/moq-mux/src/container/fmp4/mod.rs index 3bf82dc1ad..abcb2bb710 100644 --- a/rs/moq-mux/src/container/fmp4/mod.rs +++ b/rs/moq-mux/src/container/fmp4/mod.rs @@ -124,6 +124,10 @@ pub enum Error { #[error("video track {0} missing in catalog")] MissingVideoTrack(String), + /// A synthesized video track has no usable encoded dimensions. + #[error("missing video dimensions for codec: {0}")] + MissingVideoDimensions(String), + #[error("audio track {0} missing in catalog")] MissingAudioTrack(String), @@ -533,8 +537,28 @@ pub(crate) fn synthesize_video_trak( config: &VideoConfig, description: Option<&[u8]>, ) -> Result { - let width = config.coded_width.unwrap_or(0) as u16; - let height = config.coded_height.unwrap_or(0) as u16; + if !matches!( + config.codec, + VideoCodec::H264(_) | VideoCodec::H265(_) | VideoCodec::AV1(_) | VideoCodec::VP8 | VideoCodec::VP9(_) + ) { + return Err(Error::UnsupportedSynthesis(format!("video codec {:?}", config.codec))); + } + + let width = u16::try_from( + config + .coded_width + .ok_or_else(|| Error::MissingVideoDimensions(config.codec.to_string()))?, + ) + .map_err(|_| Error::MissingVideoDimensions(config.codec.to_string()))?; + let height = u16::try_from( + config + .coded_height + .ok_or_else(|| Error::MissingVideoDimensions(config.codec.to_string()))?, + ) + .map_err(|_| Error::MissingVideoDimensions(config.codec.to_string()))?; + if width == 0 || height == 0 { + return Err(Error::MissingVideoDimensions(config.codec.to_string())); + } let visual = mp4_atom::Visual { data_reference_index: 1, width, @@ -588,7 +612,7 @@ pub(crate) fn synthesize_video_trak( vpcc: crate::codec::vp9::vpcc(vp9), ..Default::default() }), - other => return Err(Error::UnsupportedSynthesis(format!("video codec {:?}", other))), + other => unreachable!("unsupported codecs rejected before geometry synthesis: {other:?}"), }; Ok(build_video_trak( @@ -1119,6 +1143,8 @@ mod tests { #[test] fn synthesized_video_init_sets_the_track_flags() { let mut config = VideoConfig::new(hang::catalog::VideoCodec::VP8); + config.coded_width = Some(320); + config.coded_height = Some(240); config.framerate = Some(30.0); let video = synthesize_video_trak(1, 30_000, &config, None).unwrap(); let init = encode_init(None, vec![video], Vec::new()).unwrap(); @@ -1129,6 +1155,13 @@ mod tests { assert_eq!(tkhd.volume.integer(), 0); } + #[test] + fn synthesized_video_init_rejects_missing_dimensions() { + let config = VideoConfig::new(hang::catalog::VideoCodec::VP8); + let error = synthesize_video_trak(1, 30_000, &config, None).unwrap_err(); + assert!(matches!(error, Error::MissingVideoDimensions(_))); + } + #[test] fn decode_reads_trun_sample_duration() { use mp4_atom::Encode; diff --git a/rs/moq-mux/src/container/fmp4/muxer.rs b/rs/moq-mux/src/container/fmp4/muxer.rs index 9bf892d814..0f23576dc3 100644 --- a/rs/moq-mux/src/container/fmp4/muxer.rs +++ b/rs/moq-mux/src/container/fmp4/muxer.rs @@ -7,7 +7,7 @@ use hang::catalog::{AudioConfig, Container as CatalogContainer, VideoConfig}; use crate::catalog::hang::Container as HangContainer; use crate::container::Frame; -use crate::container::source::{VideoTransform, build_video_transform}; +use crate::container::source::{VideoTransform, build_video_transform, catalog_dimensions, codec_dimensions}; use super::export::{ apply_codec_durations, catalog_timescale_audio, catalog_timescale_video, extract_init, infer_missing_durations, @@ -42,8 +42,8 @@ enum Kind { /// [`fragmenter`](Self::fragmenter) instead cuts a stream into one separately addressable /// fragment per frame, for a consumer that stores media per encoded frame. /// -/// For inline-parameter-set codecs (catalog `description` absent), [`init`](Self::init) returns -/// `None` until a group has been [`read`](Self::read) to resolve the config from a keyframe. +/// For video missing codec configuration or geometry, [`init`](Self::init) returns `None` until a +/// group has been [`read`](Self::read) to resolve the missing fields from a keyframe. pub struct Muxer { kind: Kind, container: HangContainer, @@ -64,14 +64,22 @@ impl Muxer { pub fn video(config: &VideoConfig) -> crate::Result { let container = (&config.container).try_into()?; let framerate = super::usable_video_framerate(config).unwrap_or(30.0); + let description = config.description.as_ref().filter(|b| !b.is_empty()).cloned(); + let mut config = config.clone(); + if catalog_dimensions(&config).is_none() + && let Some((width, height)) = codec_dimensions(&config.codec, description.as_deref(), &[])? + { + config.coded_width = Some(width); + config.coded_height = Some(height); + } Ok(Self { container, - transform: build_video_transform(config), - description: config.description.as_ref().filter(|b| !b.is_empty()).cloned(), - timescale: moq_net::Timescale::new(catalog_timescale_video(config)?).map_err(Error::from)?, + transform: build_video_transform(&config), + description, + timescale: moq_net::Timescale::new(catalog_timescale_video(&config)?).map_err(Error::from)?, default_frame: Duration::from_secs_f64(1.0 / framerate), opus: false, - kind: Kind::Video(config.clone()), + kind: Kind::Video(config), }) } @@ -138,6 +146,7 @@ impl Muxer { while let Some(frames) = self.container.read(group).await? { for frame in frames { let Some(transform) = self.transform.as_mut() else { + self.resolve_video_dimensions(&frame.payload)?; out.push(frame); continue; }; @@ -149,6 +158,7 @@ impl Muxer { { self.description = Some(d.clone()); } + self.resolve_video_dimensions(&frame.payload)?; if let Some(payload) = payload { out.push(Frame { payload, ..frame }); } @@ -160,19 +170,40 @@ impl Muxer { Ok(out) } + fn resolve_video_dimensions(&mut self, payload: &[u8]) -> crate::Result<()> { + let Kind::Video(config) = &mut self.kind else { + return Ok(()); + }; + if catalog_dimensions(config).is_some() { + return Ok(()); + } + if let Some((width, height)) = codec_dimensions(&config.codec, self.description.as_deref(), payload)? { + config.coded_width = Some(width); + config.coded_height = Some(height); + } + Ok(()) + } + /// Build the rendition's CMAF init segment (ftyp+moov), or `None` if it isn't buildable yet. /// /// A `Cmaf` rendition's catalog init passes through (with the track id normalized to match /// [`fragment`](Self::fragment)); a `Legacy`/`Loc` rendition's is synthesized from the catalog - /// config. `None` means an inline-parameter-set video rendition whose codec config hasn't been - /// resolved yet: [`read`](Self::read) a group (its keyframe carries the parameter sets) and call - /// again. + /// config. `None` means a video rendition whose codec config or geometry hasn't been resolved + /// yet: [`read`](Self::read) a group (its keyframe carries those fields) and call again. pub fn init(&self) -> crate::Result> { // An inline codec carries its config in-band, so the init can't be built until a keyframe // group has been read. if self.transform.is_some() && self.description.is_none() { return Ok(None); } + if matches!( + self.catalog_container(), + CatalogContainer::Legacy | CatalogContainer::Loc + ) && let Kind::Video(config) = &self.kind + && catalog_dimensions(config).is_none() + { + return Ok(None); + } let mut traks: Vec = Vec::new(); let mut trexs: Vec = Vec::new(); @@ -318,9 +349,61 @@ mod tests { assert_eq!(decoded[1].timestamp.as_micros(), 10_033_000); } + #[tokio::test] + async fn dimensionless_vp8_init_waits_for_keyframe_geometry() { + let track = moq_net::broadcast::Info::new() + .produce() + .create_track("v", None) + .unwrap(); + let mut subscriber = track.subscribe(None); + let mut producer = crate::container::Producer::new(track, HangContainer::Legacy); + producer + .write(Frame { + timestamp: Timestamp::ZERO, + // Key frame tag, start code, and 320x240 geometry. + payload: Bytes::from_static(&[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00]), + keyframe: true, + duration: None, + }) + .unwrap(); + producer.finish().unwrap(); + let mut group = subscriber.next_group().await.unwrap().expect("a group"); + + let config = VideoConfig::new(VideoCodec::VP8); + let mut muxer = Muxer::video(&config).unwrap(); + assert!(muxer.init().unwrap().is_none(), "geometry is unresolved before media"); + + let frames = muxer.read(&mut group).await.unwrap(); + assert_eq!(frames.len(), 1); + let init = muxer.init().unwrap().expect("keyframe geometry makes init buildable"); + let wire = super::super::Wire::from_init(&init).unwrap(); + let mp4_atom::Codec::Vp08(vp08) = &wire.trak().mdia.minf.stbl.stsd.codecs[0] else { + panic!("expected VP8 sample entry"); + }; + assert_eq!((vp08.visual.width, vp08.visual.height), (320, 240)); + } + + #[test] + fn malformed_fixed_description_errors_instead_of_waiting_for_geometry() { + let mut config = VideoConfig::new(hang::catalog::H264 { + profile: 0x42, + constraints: 0, + level: 0x1f, + inline: false, + }); + config.description = Some(Bytes::from_static(&[1])); + + assert!(matches!( + Muxer::video(&config), + Err(crate::Error::H264(crate::codec::h264::Error::AvccTooShort)) + )); + } + // A 30 fps Legacy VP8 rendition: no description needed, so the muxer builds without media. fn video_muxer() -> Muxer { let mut config = VideoConfig::new(VideoCodec::VP8); + config.coded_width = Some(320); + config.coded_height = Some(240); config.framerate = Some(30.0); Muxer::video(&config).unwrap() } @@ -391,6 +474,8 @@ mod tests { #[test] fn low_framerate_fallback_fits_mp4_timing_fields() { let mut config = VideoConfig::new(VideoCodec::VP8); + config.coded_width = Some(320); + config.coded_height = Some(240); config.framerate = Some(0.0011); let muxer = Muxer::video(&config).unwrap(); assert_eq!(muxer.timescale().as_u64(), 11); @@ -487,6 +572,8 @@ mod tests { #[test] fn init_rejects_a_catalog_scale_too_large_for_mdhd() { let mut config = VideoConfig::new(VideoCodec::VP8); + config.coded_width = Some(320); + config.coded_height = Some(240); config.framerate = Some(5_000_000.0); // 5e9 ticks, past u32::MAX let err = Muxer::video(&config).unwrap().init().unwrap_err(); assert!( diff --git a/rs/moq-mux/src/container/group.rs b/rs/moq-mux/src/container/group.rs index 24547dd3a4..9f479bdc5b 100644 --- a/rs/moq-mux/src/container/group.rs +++ b/rs/moq-mux/src/container/group.rs @@ -109,7 +109,9 @@ mod tests { /// One CMAF fragment decodes to several samples, which are handed back one at a time. #[tokio::test] async fn hands_back_a_cmaf_batch_one_frame_at_a_time() { - let config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); + let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); + config.coded_width = Some(320); + config.coded_height = Some(240); let muxer = crate::container::fmp4::Muxer::video(&config).unwrap(); let init = muxer.init().unwrap().expect("VP8 init should be available"); let cmaf = hang::catalog::Container::Cmaf { init }; diff --git a/rs/moq-mux/src/container/mkv/export.rs b/rs/moq-mux/src/container/mkv/export.rs index bc69975dba..5769d12df0 100644 --- a/rs/moq-mux/src/container/mkv/export.rs +++ b/rs/moq-mux/src/container/mkv/export.rs @@ -223,15 +223,21 @@ impl Export { // without the header anyway, and parking them would stop us from // polling for the next SPS/PPS-bearing frame. let waiting_for_header = !self.header_emitted; - for track in self.tracks.values_mut() { + for (name, track) in &mut self.tracks { if track.pending.is_some() || track.finished { continue; } loop { match track.source.poll_read(waiter) { Poll::Ready(Ok(Some(frame))) => { - if waiting_for_header && !track.source.header_ready() { - // Drop this slice and keep polling for SPS/PPS. + let geometry_ready = track.kind == TrackKind::Audio + || self + .catalog_snapshot + .as_ref() + .and_then(|catalog| catalog.video.renditions.get(name)) + .is_some_and(|config| track.source.video_geometry_ready(config)); + if waiting_for_header && (!track.source.header_ready() || !geometry_ready) { + // Drop this slice and keep polling for codec configuration or geometry. continue; } track.pending = Some(frame); @@ -300,7 +306,9 @@ impl Export { Poll::Pending } - fn update_catalog(&mut self, catalog: Catalog) -> Result<()> { + fn update_catalog(&mut self, mut catalog: Catalog) -> Result<()> { + self.source.retain_valid_media(&mut catalog); + let mut active: HashMap = HashMap::new(); for name in catalog.video.renditions.keys() { active.insert(name.clone(), ()); @@ -333,7 +341,9 @@ impl Export { continue; } ensure_legacy(&config.container, "video", name)?; - let source = ExportSource::for_video(&self.source, name, config, self.latency)?; + let Some(source) = ExportSource::for_video(&self.source, name, config, self.latency)? else { + continue; + }; self.tracks.insert( name.clone(), MkvTrack { @@ -352,7 +362,9 @@ impl Export { continue; } ensure_legacy(&config.container, "audio", name)?; - let source = ExportSource::for_audio(&self.source, name, config, self.latency)?; + let Some(source) = ExportSource::for_audio(&self.source, name, config, self.latency)? else { + continue; + }; self.tracks.insert( name.clone(), MkvTrack { @@ -379,9 +391,16 @@ impl Export { /// catalog arrives `tracks` is empty, and `all()` would otherwise be /// vacuously true and send us into `build_header` with no snapshot. fn header_ready(&self) -> bool { - self.catalog_snapshot.is_some() - && !self.tracks.is_empty() + let Some(catalog) = self.catalog_snapshot.as_ref() else { + return false; + }; + !self.tracks.is_empty() && self.tracks.values().all(|t| t.source.header_ready()) + && catalog.video.renditions.iter().all(|(name, config)| { + self.tracks + .get(name) + .is_some_and(|track| track.source.video_geometry_ready(config)) + }) } fn build_header(&self) -> Result { @@ -406,9 +425,10 @@ impl Export { .tracks .get(name) .ok_or_else(|| Error::MissingVideoTrack(name.clone()))?; + let config = track.source.video_config(config).unwrap_or_else(|| config.clone()); entries.push(build_video_track_entry( track.track_number, - config, + &config, track.source.description(), )?); } diff --git a/rs/moq-mux/src/container/mkv/export_test.rs b/rs/moq-mux/src/container/mkv/export_test.rs index eeae180e33..a840b268cf 100644 --- a/rs/moq-mux/src/container/mkv/export_test.rs +++ b/rs/moq-mux/src/container/mkv/export_test.rs @@ -305,6 +305,45 @@ async fn export_waits_for_catalog_before_header() { drop(producer); } +/// A catalog may publish a codec before its optional dimensions. The MKV +/// header is immutable, so derive geometry from the first keyframe before +/// writing `PixelWidth` and `PixelHeight`. +#[tokio::test(start_paused = true)] +async fn export_derives_video_geometry_before_header() { + use hang::catalog::{Container, VideoConfig}; + + let mut live = Live::new(".vp8", |catalog, name| { + let mut config = VideoConfig::new(VideoCodec::VP8); + config.container = Container::Legacy; + catalog.lock().video.renditions.insert(name, config); + }); + // Geometry-less startup frames must not park the source before the keyframe. + live.track.write(raw_frame(0, &[0x31, 0x00, 0x00], true)).unwrap(); + live.track + .write(raw_frame( + 33_000, + &[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00], + true, + )) + .unwrap(); + live.track.finish().unwrap(); + + let mut exporter = crate::container::mkv::Export::new(live.source(), live.catalog_stream().await); + let header = tokio::time::timeout(std::time::Duration::from_secs(1), exporter.next()) + .await + .expect("exporter timed out") + .expect("exporter result") + .expect("expected header bytes"); + + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + let mut importer = crate::container::mkv::Import::new(broadcast, catalog.reserve()); + importer.decode(&bytes::BytesMut::from(header.as_ref())).unwrap(); + let video = catalog.snapshot().video.renditions.values().next().unwrap().clone(); + assert_eq!(video.coded_width, Some(320)); + assert_eq!(video.coded_height, Some(240)); +} + #[tokio::test(start_paused = true)] async fn export_emits_blocks_for_each_frame() { // Import a WebM that contains 3 video frames + 2 audio frames, export it, diff --git a/rs/moq-mux/src/container/source.rs b/rs/moq-mux/src/container/source.rs index 72cb940079..1d9b01dd57 100644 --- a/rs/moq-mux/src/container/source.rs +++ b/rs/moq-mux/src/container/source.rs @@ -70,6 +70,10 @@ pub(crate) struct ExportSource { /// OpusHead). Some once the codec config is available — from the catalog /// `description`, or synthesized by the transform. description: Option, + /// Video codec used to derive geometry from its configuration or keyframes. + video_codec: Option, + /// Geometry resolved from the initial catalog or codec data received afterward. + video_dimensions: Option<(u32, u32)>, } impl ExportSource { @@ -84,20 +88,8 @@ impl ExportSource { name: &str, config: &VideoConfig, latency: crate::Latency, - ) -> Result { - let media: HangContainer = (&config.container).try_into()?; - let transform = build_video_transform(config); - let description = config.description.as_ref().filter(|b| !b.is_empty()).cloned(); - - let request = source.request(config.broadcast.as_ref())?; - - Ok(Self { - state: SourceState::Requesting(request, name.to_string()), - media: Some(media), - latency, - transform, - description, - }) + ) -> Result, crate::Error> { + Self::video(source, name, config, latency, build_video_transform(config)) } /// Subscribe to a video rendition without attaching any codec-shape @@ -111,19 +103,34 @@ impl ExportSource { name: &str, config: &VideoConfig, latency: crate::Latency, - ) -> Result { + ) -> Result, crate::Error> { + Self::video(source, name, config, latency, None) + } + + fn video( + source: &crate::Source, + name: &str, + config: &VideoConfig, + latency: crate::Latency, + transform: Option, + ) -> Result, crate::Error> { let media: HangContainer = (&config.container).try_into()?; let description = config.description.as_ref().filter(|b| !b.is_empty()).cloned(); + let Some(request) = source.try_request(config.broadcast.as_ref()) else { + return Ok(None); + }; - let request = source.request(config.broadcast.as_ref())?; - - Ok(Self { + let mut source = Self { state: SourceState::Requesting(request, name.to_string()), media: Some(media), latency, - transform: None, + transform, description, - }) + video_codec: Some(config.codec.clone()), + video_dimensions: catalog_dimensions(config), + }; + source.resolve_video_dimensions(&[])?; + Ok(Some(source)) } /// Subscribe to an audio rendition. Audio has no codec-shape transform; @@ -135,19 +142,22 @@ impl ExportSource { name: &str, config: &AudioConfig, latency: crate::Latency, - ) -> Result { + ) -> Result, crate::Error> { let media: HangContainer = (&config.container).try_into()?; let description = config.description.as_ref().filter(|b| !b.is_empty()).cloned(); + let Some(request) = source.try_request(config.broadcast.as_ref()) else { + return Ok(None); + }; - let request = source.request(config.broadcast.as_ref())?; - - Ok(Self { + Ok(Some(Self { state: SourceState::Requesting(request, name.to_string()), media: Some(media), latency, transform: None, description, - }) + video_codec: None, + video_dimensions: None, + })) } /// Subscribe to a verbatim `mpegts` stream rendition (SCTE-35, private PES, ...). @@ -163,6 +173,8 @@ impl ExportSource { latency, transform: None, description: None, + video_codec: None, + video_dimensions: None, }) } @@ -177,6 +189,27 @@ impl ExportSource { self.transform.is_none() || self.description.is_some() } + /// Combine the latest catalog config with geometry resolved from codec data. + pub fn video_config(&self, config: &VideoConfig) -> Option { + if catalog_dimensions(config).is_some() { + return Some(config.clone()); + } + + let (width, height) = self.video_dimensions?; + let mut config = config.clone(); + config.coded_width = Some(width); + config.coded_height = Some(height); + Some(config) + } + + /// True when this codec is unsupported or has enough geometry to build a video header. + pub fn video_geometry_ready(&self, config: &VideoConfig) -> bool { + !matches!( + config.codec, + VideoCodec::H264(_) | VideoCodec::H265(_) | VideoCodec::VP8 | VideoCodec::VP9(_) | VideoCodec::AV1(_) + ) || self.video_config(config).is_some() + } + /// Pull the next normalized frame. /// /// Parameter-only frames (SPS/PPS-only inputs to the Avc3 transform) are @@ -234,6 +267,7 @@ impl ExportSource { }; let Some(transform) = self.transform.as_mut() else { + self.resolve_video_dimensions(&frame.payload)?; return Poll::Ready(Ok(Some(frame))); }; @@ -243,10 +277,12 @@ impl ExportSource { // resolved description (it may have just become available) // and pull the next frame. self.refresh_description(); + self.resolve_video_dimensions(&frame.payload)?; continue; } Some(payload) => { self.refresh_description(); + self.resolve_video_dimensions(&payload)?; return Poll::Ready(Ok(Some(Frame { payload, ..frame }))); } } @@ -265,6 +301,50 @@ impl ExportSource { self.description = Some(d.clone()); } } + + fn resolve_video_dimensions(&mut self, payload: &[u8]) -> crate::Result<()> { + if self.video_dimensions.is_some() { + return Ok(()); + } + let Some(codec) = self.video_codec.as_ref() else { + return Ok(()); + }; + self.video_dimensions = codec_dimensions(codec, self.description.as_deref(), payload)?; + Ok(()) + } +} + +pub(crate) fn catalog_dimensions(config: &VideoConfig) -> Option<(u32, u32)> { + let dimensions = (config.coded_width?, config.coded_height?); + (dimensions.0 > 0 && dimensions.1 > 0).then_some(dimensions) +} + +/// Resolve encoded dimensions from codec configuration or an in-band keyframe. +pub(crate) fn codec_dimensions( + codec: &VideoCodec, + description: Option<&[u8]>, + payload: &[u8], +) -> crate::Result> { + let dimensions = match codec { + VideoCodec::H264(_) => match description { + Some(description) => catalog_dimensions(&crate::codec::h264::config(description)?), + None => None, + }, + VideoCodec::H265(_) => match description { + Some(description) => catalog_dimensions(&crate::codec::h265::config(description)?), + None => None, + }, + VideoCodec::VP8 if !payload.is_empty() => crate::codec::vp8::FrameHeader::parse(payload)? + .dimensions + .map(|(width, height)| (u32::from(width), u32::from(height))), + VideoCodec::VP9(_) if !payload.is_empty() => crate::codec::vp9::config_from_keyframe(payload)? + .as_ref() + .and_then(catalog_dimensions), + VideoCodec::AV1(_) if !payload.is_empty() => crate::codec::av1::dimensions(payload)?, + _ => None, + }; + + Ok(dimensions.filter(|(width, height)| *width > 0 && *height > 0)) } /// Build a video transform for an Annex-B source, or `None` if the catalog @@ -308,24 +388,24 @@ mod tests { config } - /// A rendition whose `broadcast` escapes the root fails, rather than resolving against - /// whatever broadcast the clamped path lands on. The test origin serves every path it - /// is asked for, so a clamped request would happily resolve. + /// A rendition whose `broadcast` escapes the root is skipped, rather than resolving + /// against whatever broadcast the clamped path lands on. The test origin serves every + /// path it is asked for, so a clamped request would happily resolve. #[tokio::test] - async fn escaping_reference_fails_the_rendition() { + async fn escaping_reference_skips_the_rendition() { let live = Live::avc3(); let source = live.source(); let latency = crate::Latency::REAL_TIME; - let escaping = |result: Result, what: &str| match result { - Err(crate::Error::EscapingBroadcast(_)) => {} - Err(err) => panic!("{what} failed with the wrong error: {err:?}"), - Ok(_) => panic!("{what} should not resolve"), + let escaping = |result: Result, crate::Error>, what: &str| match result { + Ok(None) => {} + Err(err) => panic!("{what} failed instead of being skipped: {err:?}"), + Ok(Some(_)) => panic!("{what} should not resolve"), }; - // The source is rooted at a single-segment path, so two `..` walk above the root. - // A single `..` stops at the root, which still names a broadcast. - for reference in ["../../elsewhere", "../../.."] { + // A reference resolves against the catalog's parent, and the source is rooted at a + // single-segment path, so its parent is the root and any `..` walks above it. + for reference in ["..", "../source", "../../elsewhere"] { let config = video(Some(reference)); escaping(ExportSource::for_video(&source, "video", &config, latency), reference); escaping( @@ -340,18 +420,20 @@ mod tests { } /// A legal reference still resolves, as does an absent or empty one (the catalog's own - /// broadcast). + /// broadcast). A lone `.` names the parent, which here is the root. #[tokio::test] async fn legal_reference_keeps_the_rendition() { let live = Live::avc3(); let source = live.source(); let latency = crate::Latency::REAL_TIME; - for reference in [None, Some(""), Some("../source"), Some("sub"), Some("..")] { + for reference in [None, Some(""), Some("./source"), Some("sub"), Some(".")] { ExportSource::for_video(&source, "video", &video(reference), latency) - .unwrap_or_else(|err| panic!("{reference:?} should keep the rendition: {err:?}")); + .unwrap_or_else(|err| panic!("{reference:?} should keep the rendition: {err:?}")) + .unwrap_or_else(|| panic!("{reference:?} should keep the rendition")); ExportSource::for_audio(&source, "audio", &audio(reference), latency) - .unwrap_or_else(|err| panic!("{reference:?} should keep the rendition: {err:?}")); + .unwrap_or_else(|err| panic!("{reference:?} should keep the rendition: {err:?}")) + .unwrap_or_else(|| panic!("{reference:?} should keep the rendition")); } } } diff --git a/rs/moq-mux/src/container/ts/export.rs b/rs/moq-mux/src/container/ts/export.rs index 31466e55d3..086fed1b03 100644 --- a/rs/moq-mux/src/container/ts/export.rs +++ b/rs/moq-mux/src/container/ts/export.rs @@ -343,6 +343,8 @@ impl Export { } fn update_catalog(&mut self, mut catalog: Catalog) -> anyhow::Result<()> { + self.source.retain_valid(&mut catalog); + // The MPEG-TS section lives in the extension. The trait only exposes // `mpegts_mut`, and this snapshot is owned, so clone it out (`()` yields the // empty default: no verbatim streams, no preserved PIDs/descriptors). @@ -427,7 +429,9 @@ impl Export { self.tracks.insert(name.clone(), track); } None => { - let source = ExportSource::for_video(&self.source, name, config, self.latency)?; + let Some(source) = ExportSource::for_video(&self.source, name, config, self.latency)? else { + continue; + }; self.insert_track(name, source, pid, kind, descriptors, reserve); } } @@ -444,7 +448,9 @@ impl Export { self.tracks.insert(name.clone(), track); } None => { - let source = ExportSource::for_audio(&self.source, name, config, self.latency)?; + let Some(source) = ExportSource::for_audio(&self.source, name, config, self.latency)? else { + continue; + }; self.insert_track(name, source, pid, kind, descriptors, DEFAULT_DTS_RESERVE); } } diff --git a/rs/moq-mux/src/container/ts/import.rs b/rs/moq-mux/src/container/ts/import.rs index bf695dbdb2..c3439c0a40 100644 --- a/rs/moq-mux/src/container/ts/import.rs +++ b/rs/moq-mux/src/container/ts/import.rs @@ -66,6 +66,8 @@ pub struct Import { streams: HashMap>, /// In-progress PES reassembly, keyed by elementary PID. pending: HashMap, + /// Per elementary-stream-PID TS continuity state. + continuity: HashMap, /// True once a PMT with at least one supported stream has been parsed. initialized: bool, /// Raw 90 kHz PTS of the first audio frame in the current consecutive run. @@ -138,6 +140,7 @@ impl Import { pmt_pids: HashSet::new(), streams: HashMap::new(), pending: HashMap::new(), + continuity: HashMap::new(), initialized: false, audio_burst: None, scratch: Vec::new(), @@ -219,6 +222,43 @@ impl Import { self.si_section(pid, &pkt); continue; } + // An elementary stream's PES is as vulnerable to a break as a section is. A + // looping publisher wraps with its last PES still open and short of its declared + // length, so the next loop's leading packets would otherwise complete it and hand + // the codec one buffer straddling the cut. + if let Ok(pid) = Pid::new(pid) + && self.streams.contains_key(&pid) + { + match self.continuity.entry(pid).or_default().observe(&pkt) { + Continuation::Duplicate => continue, + // Flagged corrupt, so the packet joins the partial rather than opening a + // new PES out of bytes the demodulator already disowned. + Continuation::Corrupt => { + self.pending.remove(&pid); + if let Some(stream) = self.streams.get_mut(&pid) { + stream.desync(); + } + continue; + } + Continuation::Broken => { + // Salvage the truncated PES only where its bytes stand on their own, then + // drop whatever is left mid-unit and require the next frame to prove its + // boundary. + if self.streams.get(&pid).is_some_and(Stream::salvages_partial_pes) { + self.flush(pid)?; + } else { + self.pending.remove(&pid); + } + if let Some(stream) = self.streams.get_mut(&pid) { + stream.desync(); + } + // This packet still routes normally: a PUSI opens a fresh PES, while a + // continuation finds no pending entry and is dropped, so the stream + // resumes at the next PES start rather than mid-frame. + } + Continuation::Contiguous => {} + } + } // PIDs we don't decode and don't carry (`Stream::Ignored`: a base catalog's // undecoded streams, or an ambiguous 0x86 PID without CUEI) are dropped here, // not fed to the PES reader, which aborts on private sections (spec section 7: @@ -411,6 +451,7 @@ impl Import { self.initialized = true; } self.streams.insert(pid, stream); + self.continuity.entry(pid).or_default(); Ok(()) } @@ -441,6 +482,7 @@ impl Import { } // This PID is becoming section-framed; drop any partial PES a prior codec left pending. self.pending.remove(&pid); + self.continuity.remove(&pid); if !self.supports_mpegts { // Always route to Ignored, replacing any prior codec on this PID (a later PMT // can reassign it), so a private section never reaches the PES reader. Warn once. @@ -952,6 +994,89 @@ impl VerbatimStream { } } +/// Whether one PID's TS packets are still an unbroken chain, for whoever is accumulating +/// bytes out of them. +/// +/// Both reassemblers on this PID need the same answer: a section and a PES are equally +/// meaningless when spliced onto bytes that didn't follow them. Keeping one implementation +/// keeps a subtle rule (which packets advance the counter, which retransmissions to ignore) +/// from drifting into two. +#[derive(Default)] +struct Continuity { + /// Last continuity_counter seen on a packet with payload, to spot gaps. + last_cc: Option, + /// Last payload packet, to skip ISO 13818-1 duplicates (same cc, identical bytes). + last_pkt: Option<[u8; 188]>, +} + +/// What one packet says about the bytes already accumulated for its PID. +enum Continuation { + /// An exact retransmission, which ISO 13818-1 permits once. Ignore the packet entirely: + /// processing it would duplicate its bytes. + Duplicate, + /// Contiguous with the previous payload packet. + Contiguous, + /// A counter gap or a declared discontinuity. Whatever was accumulating for this PID is + /// lost, but this packet's own payload is intact and still worth routing. + Broken, + /// The demodulator flagged this packet corrupt. The partial is lost like [`Broken`], and + /// so is the packet: nothing in it can be trusted. + Corrupt, +} + +impl Continuity { + /// Classify one 188-byte packet, recording what the next call needs. + fn observe(&mut self, pkt: &[u8; 188]) -> Continuation { + // transport_error_indicator: the demodulator flagged this packet as corrupt, so its + // payload can't be trusted (and we don't validate CRC-32). Forgetting the counter + // keeps the next clean packet from also looking like a gap. + if pkt[1] & 0x80 != 0 { + self.last_cc = None; + self.last_pkt = None; + return Continuation::Corrupt; + } + + let afc = (pkt[3] >> 4) & 0x3; + let has_payload = afc & 0x1 != 0; + // Read the adaptation field before the no-payload case: a discontinuity can ride on + // an adaptation-only packet, and it counts just the same. + let discontinuity = if afc & 0x2 != 0 { + let af_len = pkt[4] as usize; + af_len > 0 && pkt[5] & 0x80 != 0 + } else { + false + }; + + if !has_payload { + if discontinuity { + self.last_cc = None; + self.last_pkt = None; + return Continuation::Broken; + } + return Continuation::Contiguous; + } + + // A retransmission repeats the counter, and so does a loss of exactly 15 packets. + // Only the bytes tell them apart, which is why the counter alone can't decide: taking + // every repeat for a duplicate would carry a partial straight across that loss and + // join it to unrelated bytes. + if self.last_pkt.as_ref().is_some_and(|last| last == pkt) { + return Continuation::Duplicate; + } + self.last_pkt = Some(*pkt); + + // Only payload packets advance the counter, so this is the one place it moves. + let cc = pkt[3] & 0x0f; + let cc_gap = matches!(self.last_cc, Some(last) if cc != (last + 1) & 0x0f); + self.last_cc = Some(cc); + if discontinuity || cc_gap { + Continuation::Broken + } else { + Continuation::Contiguous + } + } +} + /// Byte-level reassembler for MPEG-TS private sections on one PID. /// /// Private sections (SCTE-35 table_id 0xFC and others) are not PES. This handles @@ -965,69 +1090,40 @@ struct SectionReassembler { /// thus section_length) may not all be present yet, so completeness is /// re-checked as bytes arrive; empty means no section in progress. acc: Vec, - /// Last continuity_counter seen on a packet with payload, to spot gaps. - last_cc: Option, - /// Last payload packet, to skip ISO 13818-1 duplicates (same cc, identical bytes). - last_pkt: Option<[u8; 188]>, + /// Shared with the PES path: a broken chain drops the partial either way. + continuity: Continuity, } impl SectionReassembler { /// Consume one 188-byte TS packet, appending every completed section to `out`. fn push(&mut self, pkt: &[u8], out: &mut Vec>) { - // transport_error_indicator: the demodulator flagged this packet as corrupt, - // so its payload can't be trusted (and we don't validate CRC-32). Drop it and - // any partial; resync at the next clean PUSI. - if pkt[1] & 0x80 != 0 { - self.acc.clear(); - self.last_cc = None; - self.last_pkt = None; - return; + let pkt: &[u8; 188] = pkt.try_into().expect("section packet must be 188 bytes"); + match self.continuity.observe(pkt) { + Continuation::Duplicate => return, + // A packet flagged corrupt takes its own payload down with the partial: this is + // the one case where the packet itself must not be processed. + Continuation::Corrupt => { + self.acc.clear(); + return; + } + Continuation::Broken => self.acc.clear(), + Continuation::Contiguous => {} } let pusi = pkt[1] & 0x40 != 0; let afc = (pkt[3] >> 4) & 0x3; - let cc = pkt[3] & 0x0f; let has_payload = afc & 0x1 != 0; - // Parse the adaptation field before the no-payload early return: a - // discontinuity can ride on an adaptation-only packet and must still reset - // reassembly. let mut off = 4; - let mut discontinuity = false; if afc & 0x2 != 0 { let af_len = pkt[4] as usize; - discontinuity = af_len > 0 && pkt[5] & 0x80 != 0; off = 5 + af_len; } if !has_payload { - // An adaptation-only discontinuity still drops the partial; forgetting the - // counter keeps the next payload packet from looking like a gap. - if discontinuity { - self.acc.clear(); - self.last_cc = None; - self.last_pkt = None; - } return; } - // ISO 13818-1 permits one identical retransmission of a payload packet (same - // cc, same bytes); processing it would reset a healthy partial or re-emit a - // completed section. Skip it, recording this packet to catch the next. - if self.last_pkt.as_ref().is_some_and(|last| last[..] == pkt[..]) { - return; - } - self.last_pkt = pkt.try_into().ok(); - - // A continuity-counter gap (only payload packets advance it) or a declared - // discontinuity both mean the in-progress section is lost. - let cc_gap = matches!(self.last_cc, Some(last) if cc != (last + 1) & 0x0f); - let reset = discontinuity || cc_gap; - if reset { - self.acc.clear(); - } - self.last_cc = Some(cc); - if off >= pkt.len() { return; } @@ -1166,6 +1262,38 @@ impl Stream { } } + /// Whether a PES cut short by a break is still worth publishing. + /// + /// True where one PES carries many independently decodable units, so the ones ahead of + /// the cut are whole and correct on their own. False where it carries exactly one: half + /// an access unit is a picture with missing slices, and half a keyframe stays wrong for + /// every picture that references it. Verbatim payloads are all-or-nothing the same way. + fn salvages_partial_pes(&self) -> bool { + match self { + Stream::Aac(_) | Stream::Legacy(_) => true, + // Opus carries many packets per PES like the audio above, but its framing is + // declared rather than self-describing: a trailing packet cut short of the length + // its control header promises is a parse error, and that error would travel up out + // of `decode` and end the session. Losing the PES beats losing the broadcast. + Stream::Opus(_) => false, + Stream::H264 { .. } | Stream::H265 { .. } | Stream::Verbatim(_) => false, + Stream::Clock | Stream::Ignored => false, + } + } + + /// Sync was lost: drop whatever partial unit is held and stop vouching for the next + /// boundary. This is [`seek`](Self::seek) without the group-sequence side effect, for a + /// break the stream recovers from in place. + fn desync(&mut self) { + match self { + Stream::H264 { split, .. } => split.reset(), + Stream::H265 { split, .. } => split.reset(), + Stream::Aac(stream) => stream.desync(), + Stream::Legacy(stream) => stream.desync(), + Stream::Opus(_) | Stream::Verbatim(_) | Stream::Clock | Stream::Ignored => {} + } + } + fn seek(&mut self, sequence: u64) -> anyhow::Result<()> { match self { Stream::H264 { split, import, .. } => { @@ -1236,6 +1364,11 @@ impl Stream { /// publish payload bytes as audio, and worse, each false positive would reset the budget /// below and keep a stream that never really parses scanning forever. /// +/// A frame joined out of a carried tail is confirmed the same way, even though the previous +/// frame vouched for where that tail begins. What it vouched for is the boundary, not the +/// bytes the next PES supplies, and a splice joins two unrelated halves whose seam a header +/// alone can't see. See [`needs_confirmation`](Self::needs_confirmation). +/// /// The budget is what keeps that from failing silently. A PID whose frames never parse /// (a PMT declaring a stream type the payload doesn't match) would otherwise scan /// forever, publishing nothing and holding its catalog reservation open, which withholds @@ -1247,6 +1380,9 @@ struct Resync { /// Whether the next frame comes from a scan rather than the previous frame's end, and /// so has to be confirmed before it can be published. unconfirmed: bool, + /// End of stream: nothing more can arrive to confirm anything, so publish what parses + /// rather than drop a frame that is whole. + draining: bool, } impl Default for Resync { @@ -1259,6 +1395,7 @@ impl Default for Resync { // would publish payload as audio and take the track's sample rate and channel // count from it for the life of the broadcast. unconfirmed: true, + draining: false, } } } @@ -1299,12 +1436,22 @@ impl Resync { self.discarded > Self::BUDGET } - /// Whether the frame at the current offset still needs a header at its end to confirm - /// it, which is true from a scan until the frame it found is published. + /// Whether the offset itself is one nothing has vouched for, which is true from a scan + /// (or the start of a stream, or a seek) until the frame it found is published. fn unconfirmed(&self) -> bool { self.unconfirmed } + /// Whether the frame at the current offset has to be confirmed by a header where it ends + /// before it can be published. Beyond an unconfirmed offset, that covers a frame + /// beginning in a carried tail: the previous frame vouched for where the tail starts, but + /// nothing vouches for the bytes joined onto it, and at a splice the two halves are + /// unrelated. Confirming only these keeps the cost off the common path, since a frame + /// that begins inside a PES is whole by the time it is parsed. + fn needs_confirmation(&self, in_tail: bool) -> bool { + !self.draining && (self.unconfirmed || in_tail) + } + /// A frame was published, so the stream is back in sync: the next frame starts where /// this one ended and needs no confirmation of its own. fn recovered(&mut self) { @@ -1330,10 +1477,11 @@ impl Resync { self.discarded = 0; } - /// End of stream. Nothing more can arrive to confirm the carried candidate, so take it - /// as-is rather than drop a frame that is whole and parses. - fn accept_unconfirmed(&mut self) { + /// End of stream. Nothing more can arrive to confirm the carried tail, so take it as-is + /// rather than drop a frame that is whole and parses. + fn drain(&mut self) { self.unconfirmed = false; + self.draining = true; } } @@ -1439,17 +1587,19 @@ impl AacStream { in_tail = false; } - // Parse the frame here, and when this offset came from a scan rather than the - // previous frame's end, require a header where the frame it declares ends - // before believing it. See `Resync`. `Err(None)` means nothing parsed badly, - // the candidate just isn't usable. + // Parse the frame here, and unless the previous frame vouched for this offset and + // for the bytes past it, require a header where the frame it declares ends before + // believing it. See `Resync`. `Err(None)` means nothing parsed badly, the + // candidate just isn't usable. + let confirm = self.resync.needs_confirmation(in_tail); let parsed: Result<_, Option> = match adts::Header::parse(&data[offset..]) { Ok(header) => { let end = offset + header.frame_len; if end > data.len() { if !self.resync.unconfirmed() { // A boundary the previous frame vouched for: the frame continues in - // the next PES, so finish it there. + // the next PES, so finish it there. A joined tail waits here too, since + // nothing can confirm a frame the buffer doesn't hold yet. break; } // Unconfirmed, and the length it declares outruns the buffer, so a split @@ -1465,7 +1615,7 @@ impl AacStream { in_tail, }); Err(None) - } else if !self.resync.unconfirmed() { + } else if !confirm { Ok((header, end)) } else if end + adts::MIN_HEADER_LEN > data.len() { // Whole, but too few bytes left to confirm it. Carry it and retry once @@ -1611,26 +1761,31 @@ impl AacStream { } fn seek(&mut self, sequence: u64) -> anyhow::Result<()> { - // A seek is a discontinuity; the partial frame will never see its end, and whatever - // vouched for the next frame boundary no longer applies. - self.tail.clear(); - self.tail_pts = None; - self.resync.desynced(); + // A seek is a discontinuity like any other. + self.desync(); if let Some(import) = &mut self.import { import.seek(sequence)?; } Ok(()) } + /// The partial frame will never see its end, and whatever vouched for the next frame + /// boundary no longer applies. + fn desync(&mut self) { + self.tail.clear(); + self.tail_pts = None; + self.resync.desynced(); + } + fn finish(&mut self) -> anyhow::Result<()> { - // Drain a candidate held only for want of a successor to confirm it: at end of - // stream that successor is never coming. Only once this stream has published a - // frame, though. Before that nothing has vouched for any boundary, so accepting one - // here would hand a capture that joined mid-frame and ended immediately the same - // false frame that starting unconfirmed exists to reject, and build the track's - // config out of it. + // Drain a frame held only for want of a successor to confirm it: at end of stream + // that successor is never coming. Only once this stream has published a frame, + // though. Before that nothing has vouched for any boundary, so accepting one here + // would hand a capture that joined mid-frame and ended immediately the same false + // frame that starting unconfirmed exists to reject, and build the track's config out + // of it. if !self.tail.is_empty() && self.import.is_some() { - self.resync.accept_unconfirmed(); + self.resync.drain(); self.write(Pending::empty(), None)?; } // A partial frame at end of stream isn't emissible; drop it, but leave a trace for @@ -1842,17 +1997,19 @@ impl LegacyStream { in_tail = false; } - // Parse the frame here, and when this offset came from a scan rather than the - // previous frame's end, require a header where the frame it declares ends - // before believing it. See `Resync`. `Err(None)` means nothing parsed badly, - // the candidate just isn't usable. + // Parse the frame here, and unless the previous frame vouched for this offset and + // for the bytes past it, require a header where the frame it declares ends before + // believing it. See `Resync`. `Err(None)` means nothing parsed badly, the + // candidate just isn't usable. + let confirm = self.resync.needs_confirmation(in_tail); let parsed: Result<_, Option> = match (self.descriptor.parse)(&data[offset..]) { Ok(header) => { let end = offset + header.len; if end > data.len() { if !self.resync.unconfirmed() { // A boundary the previous frame vouched for: the frame continues in - // the next PES, so finish it there. + // the next PES, so finish it there. A joined tail waits here too, since + // nothing can confirm a frame the buffer doesn't hold yet. break; } // Unconfirmed, and the length it declares outruns the buffer, so a split @@ -1867,7 +2024,7 @@ impl LegacyStream { in_tail, }); Err(None) - } else if !self.resync.unconfirmed() { + } else if !confirm { Ok((header, end)) } else if end + self.descriptor.min_header_len > data.len() { // Whole, but too few bytes left to confirm it. Carry it and retry once @@ -1983,26 +2140,31 @@ impl LegacyStream { } fn seek(&mut self, sequence: u64) -> anyhow::Result<()> { - // A seek is a discontinuity; the partial frame will never see its end, and whatever - // vouched for the next frame boundary no longer applies. - self.tail.clear(); - self.tail_pts = None; - self.resync.desynced(); + // A seek is a discontinuity like any other. + self.desync(); if let Some(import) = &mut self.import { import.seek(sequence)?; } Ok(()) } + /// The partial frame will never see its end, and whatever vouched for the next frame + /// boundary no longer applies. + fn desync(&mut self) { + self.tail.clear(); + self.tail_pts = None; + self.resync.desynced(); + } + fn finish(&mut self) -> anyhow::Result<()> { - // Drain a candidate held only for want of a successor to confirm it: at end of - // stream that successor is never coming. Only once this stream has published a - // frame, though. Before that nothing has vouched for any boundary, so accepting one - // here would hand a capture that joined mid-frame and ended immediately the same - // false frame that starting unconfirmed exists to reject, and build the track's - // config out of it. + // Drain a frame held only for want of a successor to confirm it: at end of stream + // that successor is never coming. Only once this stream has published a frame, + // though. Before that nothing has vouched for any boundary, so accepting one here + // would hand a capture that joined mid-frame and ended immediately the same false + // frame that starting unconfirmed exists to reject, and build the track's config out + // of it. if !self.tail.is_empty() && self.import.is_some() { - self.resync.accept_unconfirmed(); + self.resync.drain(); self.write(Pending::empty())?; } // A partial frame at end of stream isn't emissible verbatim; drop it, but @@ -2601,6 +2763,55 @@ mod test { p } + /// Open a bounded audio PES whose declared payload is longer than this packet carries. + fn audio_pes_open(pid: u16, cc: u8, pts: u64, declared: usize, payload: &[u8]) -> Vec { + assert!(payload.len() < declared, "the test PES must remain open"); + let pts_field = [ + 0x21 | (((pts >> 30) & 0x07) << 1) as u8, + ((pts >> 22) & 0xff) as u8, + 0x01 | (((pts >> 15) & 0x7f) << 1) as u8, + ((pts >> 7) & 0xff) as u8, + 0x01 | ((pts & 0x7f) << 1) as u8, + ]; + let mut pes = vec![0x00, 0x00, 0x01, 0xc0]; + let pes_len = 3 + 5 + declared; + pes.push((pes_len >> 8) as u8); + pes.push((pes_len & 0xff) as u8); + pes.extend_from_slice(&[0x80, 0x80, 0x05]); + pes.extend_from_slice(&pts_field); + pes.extend_from_slice(payload); + + let af_len = 184 - 1 - pes.len(); + let mut p = vec![ + 0x47, + 0x40 | ((pid >> 8) as u8 & 0x1f), + (pid & 0xff) as u8, + 0x30 | (cc & 0x0f), + ]; + p.push(af_len as u8); + if af_len > 0 { + p.push(0x00); + p.extend(std::iter::repeat_n(0xff, af_len - 1)); + } + p.extend_from_slice(&pes); + assert_eq!(p.len(), 188, "open audio PES packet must fill exactly one TS packet"); + p + } + + /// Build a non-PUSI TS payload packet with adaptation-field stuffing. + fn ts_continuation(pid: u16, cc: u8, payload: &[u8]) -> Vec { + let af_len = 184 - 1 - payload.len(); + let mut p = vec![0x47, ((pid >> 8) as u8 & 0x1f), (pid & 0xff) as u8, 0x30 | (cc & 0x0f)]; + p.push(af_len as u8); + if af_len > 0 { + p.push(0x00); + p.extend(std::iter::repeat_n(0xff, af_len - 1)); + } + p.extend_from_slice(payload); + assert_eq!(p.len(), 188, "continuation packet must fill exactly one TS packet"); + p + } + // MP2/AC-3 flush like any audio PES but don't consume the jitter hint; if one // anchored the audio run, an AAC PID in the same TS would publish a jitter // inflated by the inter-PID PTS offset. @@ -2730,6 +2941,92 @@ mod test { ); } + /// Read every retained frame of the single video rendition in `catalog`. + async fn read_video_frames( + consumer: &moq_net::broadcast::Consumer, + catalog: &crate::catalog::Producer, + ) -> Vec { + let name = catalog + .snapshot() + .video + .renditions + .keys() + .next() + .expect("a video track") + .clone(); + let track = consumer.track(&name).unwrap().subscribe(None).await.unwrap(); + let mut reader = crate::container::Consumer::new(track, crate::catalog::hang::Container::Legacy); + let mut frames = Vec::new(); + while let Ok(Ok(Some(frame))) = tokio::time::timeout(std::time::Duration::from_millis(50), reader.read()).await + { + frames.push(frame); + } + frames + } + + /// Annex-B bytes for one access unit: SPS + PPS + IDR for a keyframe, else a delta slice. + fn annexb_au(keyframe: bool) -> Vec { + use crate::container::test_util::{IDR, PPS, SPS}; + let nals: &[&[u8]] = if keyframe { + &[SPS, PPS, IDR] + } else { + &[&[0x41, 0x9a, 0x00, 0x01]] + }; + let mut out = Vec::new(); + for nal in nals { + out.extend_from_slice(&[0, 0, 0, 1]); + out.extend_from_slice(nal); + } + out + } + + // A break mid-picture is not the same as a break mid-audio. One video PES is exactly one + // access unit, so there is no whole unit ahead of the cut to salvage: publishing what + // arrived would hand the decoder a picture with missing slices, and a keyframe missing + // slices stays wrong for every picture that references it. Drop it and wait for the next. + #[tokio::test(start_paused = true)] + async fn video_drops_a_partial_access_unit_across_a_continuity_break() { + const VIDEO_PID: u16 = 0x0050; + + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + let mut import = super::Import::new(broadcast, catalog.reserve()); + + let pmt = synth_pmt(&[(StreamType::H264, VIDEO_PID)], false); + import.decode(&bytes::BytesMut::from(&pmt[..])).unwrap(); + + // A keyframe cut in half by the wrap: the PES declares more than this packet carries. + let whole = annexb_au(true); + import + .decode(audio_pes_open(VIDEO_PID, 0, 90_000, whole.len() + 32, &whole[..28]).as_slice()) + .unwrap(); + // The wrap completes the open PES with unrelated bytes, and continuity gives it away. + import + .decode(ts_continuation(VIDEO_PID, 12, &whole[28..]).as_slice()) + .unwrap(); + // A clean keyframe after it, which is what the track should carry. + import + .decode(audio_pes_packet(VIDEO_PID, 13, 270_000, &whole).as_slice()) + .unwrap(); + import + .decode(audio_pes_packet(VIDEO_PID, 14, 450_000, &annexb_au(false)).as_slice()) + .unwrap(); + import.finish().unwrap(); + + let frames = read_video_frames(&consumer, &catalog).await; + assert!( + frames.iter().all(|f| f.payload.len() >= whole.len() || !f.keyframe), + "a keyframe with missing slices reached the track: {:?}", + frames.iter().map(|f| (f.keyframe, f.payload.len())).collect::>() + ); + assert_eq!( + frames.first().map(|f| f.payload.to_vec()), + Some(whole.clone()), + "the first published picture is not the whole keyframe" + ); + } + /// One whole ADTS frame carrying `raw_len` bytes of `fill`. `fill` must not be 0xFF, /// which a resync would mistake for a frame sync. fn adts_frame(raw_len: usize, fill: u8) -> Vec { @@ -2738,6 +3035,44 @@ mod test { f } + // The AAC mirror of `legacy_drops_a_pes_completed_across_a_continuity_break`. + #[tokio::test(start_paused = true)] + async fn aac_drops_a_pes_completed_across_a_continuity_break() { + const AAC_PID: u16 = 0x0060; + + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + let mut import = super::Import::new(broadcast, catalog.reserve()); + + let pmt = synth_pmt(&[(StreamType::AdtsAac, AAC_PID)], false); + import.decode(&bytes::BytesMut::from(&pmt[..])).unwrap(); + + let mut opening = adts_frame(40, 0xAA); + opening.extend_from_slice(&adts_frame(40, 0xBB)[..25]); + import + .decode(audio_pes_open(AAC_PID, 0, 90_000, 94, &opening).as_slice()) + .unwrap(); + import + .decode(ts_continuation(AAC_PID, 12, &adts_frame(40, 0xCC)[..32]).as_slice()) + .unwrap(); + let mut normal = adts_frame(40, 0xDD); + normal.extend_from_slice(&adts_frame(40, 0xEE)); + import + .decode(audio_pes_packet(AAC_PID, 13, 270_000, &normal).as_slice()) + .unwrap(); + import.finish().unwrap(); + + let frames = read_audio_frames(&consumer, &catalog).await; + assert_eq!( + frames.iter().map(|f| f.payload.to_vec()).collect::>(), + [0xAA, 0xDD, 0xEE] + .map(|fill| adts_frame(40, fill)[7..].to_vec()) + .to_vec(), + "the wrap was spliced onto the frame the cut left open" + ); + } + // ISO 13818-1 doesn't require AAC frames to align with PES boundaries any more than it // does the legacy codecs, but only the legacy path reassembled a split frame: AAC // rejected the PES outright, so a mux that split one killed the broadcast on well-formed @@ -2818,6 +3153,88 @@ mod test { ); } + // AAC reassembles a split frame the same way the legacy codecs do, so it splices the same + // way at a wrap and confirms the join for the same reason. See + // `legacy_confirms_a_frame_joined_out_of_a_carried_tail`. + #[tokio::test(start_paused = true)] + async fn aac_confirms_a_frame_joined_out_of_a_carried_tail() { + const AAC_PID: u16 = 0x0060; + + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + let mut import = super::Import::new(broadcast, catalog.reserve()); + + let pmt = synth_pmt(&[(StreamType::AdtsAac, AAC_PID)], false); + import.decode(&bytes::BytesMut::from(&pmt[..])).unwrap(); + + // A whole frame, then one cut mid-frame by the wrap. + let mut payload = adts_frame(40, 0xAA); + payload.extend_from_slice(&adts_frame(40, 0xBB)[..25]); + import + .decode(audio_pes_packet(AAC_PID, 0, 90_000, &payload).as_slice()) + .unwrap(); + // The top of the file again: the tail splices onto its first frame. + let mut wrapped = adts_frame(40, 0xCC); + wrapped.extend_from_slice(&adts_frame(40, 0xDD)); + import + .decode(audio_pes_packet(AAC_PID, 1, 270_000, &wrapped).as_slice()) + .expect("a splice is not fatal"); + import + .decode(audio_pes_packet(AAC_PID, 2, 450_000, &adts_frame(40, 0xEE)).as_slice()) + .unwrap(); + import.finish().unwrap(); + + // The track carries raw AAC, so each published frame is its ADTS body. + let frames = read_audio_frames(&consumer, &catalog).await; + assert_eq!( + frames.iter().map(|f| f.payload.to_vec()).collect::>(), + [0xAA, 0xCC, 0xDD, 0xEE] + .map(|fill| adts_frame(40, fill)[7..].to_vec()) + .to_vec(), + "the splice cost more than the frame it interrupted" + ); + assert_eq!( + frames[1].timestamp, + Timestamp::from_micros(3_000_000).unwrap(), + "not re-anchored on the new PES" + ); + } + + // The end-of-stream drain, for AAC. See `legacy_drains_a_joined_frame_at_end_of_stream`. + #[tokio::test(start_paused = true)] + async fn aac_drains_a_joined_frame_at_end_of_stream() { + const AAC_PID: u16 = 0x0060; + + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + let mut import = super::Import::new(broadcast, catalog.reserve()); + + let pmt = synth_pmt(&[(StreamType::AdtsAac, AAC_PID)], false); + import.decode(&bytes::BytesMut::from(&pmt[..])).unwrap(); + + let split = adts_frame(40, 0xBB); + let mut payload = adts_frame(40, 0xAA); + payload.extend_from_slice(&split[..25]); + import + .decode(audio_pes_packet(AAC_PID, 0, 90_000, &payload).as_slice()) + .unwrap(); + // The rest of the split frame and nothing else, so the stream ends with it joined, + // whole, and unconfirmed. + import + .decode(audio_pes_packet(AAC_PID, 1, 270_000, &split[25..]).as_slice()) + .unwrap(); + import.finish().unwrap(); + + let frames = read_audio_frames(&consumer, &catalog).await; + assert_eq!( + frames.iter().map(|f| f.payload.to_vec()).collect::>(), + [0xAA, 0xBB].map(|fill| adts_frame(40, fill)[7..].to_vec()).to_vec(), + "the last frame was held for a confirmation that could never arrive" + ); + } + /// One whole MPEG-2 Layer II frame (8 kbps, 16 kHz, mono = 72 bytes), filled with /// `fill` so frames are told apart on the wire. Small enough that two fit in the /// single TS packet [`audio_pes_packet`] builds. `fill` must not be 0xFF, which a @@ -2828,6 +3245,47 @@ mod test { f } + // The shape a looping mux actually produces, which is not a carried tail: the last PES + // before the cut is truncated, so it stays open, and the next loop's leading continuation + // packets complete it. The foreign bytes land in the SAME PES, so the codec sees one + // buffer with nothing carried, and the confirmation rule above never gets a say. The + // continuity counter is what gives the wrap away, so the truncated PES is flushed for the + // whole frames it did deliver and the stream resumes at the next PES start. + #[tokio::test(start_paused = true)] + async fn legacy_drops_a_pes_completed_across_a_continuity_break() { + const MP2_PID: u16 = 0x0061; + + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + let mut import = super::Import::new(broadcast, catalog.reserve()); + + let pmt = synth_pmt(&[(StreamType::Mpeg1Audio, MP2_PID)], false); + import.decode(&bytes::BytesMut::from(&pmt[..])).unwrap(); + + let mut opening = mp2_frame(0xAA); + opening.extend_from_slice(&mp2_frame(0xBB)[..40]); + import + .decode(audio_pes_open(MP2_PID, 0, 90_000, 144, &opening).as_slice()) + .unwrap(); + import + .decode(ts_continuation(MP2_PID, 12, &mp2_frame(0xCC)[..32]).as_slice()) + .unwrap(); + let mut normal = mp2_frame(0xDD); + normal.extend_from_slice(&mp2_frame(0xEE)); + import + .decode(audio_pes_packet(MP2_PID, 13, 270_000, &normal).as_slice()) + .unwrap(); + import.finish().unwrap(); + + let frames = read_audio_frames(&consumer, &catalog).await; + assert_eq!( + frames.iter().map(|f| f.payload.clone()).collect::>(), + vec![mp2_frame(0xAA), mp2_frame(0xDD), mp2_frame(0xEE)], + "the wrap was spliced onto the frame the cut left open" + ); + } + // A damaged frame header must not take the session down with it: the demuxer scans to // the next sync word and keeps publishing, the way the TS and video layers already do. // One bit flipped in a sync word used to abort the whole broadcast (#2729). @@ -2900,32 +3358,113 @@ mod test { import .decode(audio_pes_packet(MP2_PID, 1, 270_000, &wrapped).as_slice()) .expect("a splice is not fatal"); - // The recovered frame is only published once the frame after it confirms the - // boundary, so the stream has to keep running past the wrap. + // One more PES to show the wrap costs nothing after it. import .decode(audio_pes_packet(MP2_PID, 2, 450_000, &mp2_frame(0xEE)).as_slice()) .unwrap(); import.finish().unwrap(); let frames = read_audio_frames(&consumer, &catalog).await; - // The tail still carries an intact header claiming 72 bytes, and it sits at a - // boundary the previous frame vouched for, so the splice itself is published as one - // frame of mixed bytes; the demuxer only learns sync was lost at the frame after it. - // Catching that too would mean confirming every frame, not just scanned ones. - assert_eq!(frames.len(), 3, "the stream recovers after the splice"); + // The stale tail still carries an intact header claiming 72 bytes, so what gives the + // splice away is the confirmation: the frame it declares ends inside the wrapped + // frame rather than at a header. The tail is scanned past, not published. assert_eq!( - frames[1].payload.as_ref(), - &mp2_frame(0xDD)[..], - "resynced onto the next whole frame" + frames.iter().map(|f| f.payload.clone()).collect::>(), + vec![mp2_frame(0xCC), mp2_frame(0xDD), mp2_frame(0xEE)], + "the splice was published as audio instead of scanned past" ); - // Re-anchored on PES 2 (270000 ticks = 3 s), give or take the frame the splice ate, - // rather than inheriting the stale tail's 1 s. At a real loop wrap that inherited - // error is the whole file's duration, not a frame. - assert!( - (Timestamp::from_micros(3_000_000).unwrap()..Timestamp::from_micros(3_200_000).unwrap()) - .contains(&frames[1].timestamp), - "not re-anchored on the new PES: {:?}", - frames[1].timestamp + // Re-anchored on PES 2 (270000 ticks = 3 s) rather than inheriting the stale tail's + // 1 s. At a real loop wrap that inherited error is the whole file's duration, not a + // frame. + assert_eq!( + frames[0].timestamp, + Timestamp::from_micros(3_000_000).unwrap(), + "not re-anchored on the new PES" + ); + } + + // The same splice one frame later, which is the shape a real wrap takes: the stream has + // already published a frame, so the tail sits at a boundary the previous frame vouched + // for. What it vouched for is where the tail begins, not the unrelated bytes the next PES + // joins onto it, so the join still has to be confirmed. Without that this published one + // frame of pre-wrap and post-wrap bytes spliced together, and swallowed the real frame + // underneath it (#2802). + #[tokio::test(start_paused = true)] + async fn legacy_confirms_a_frame_joined_out_of_a_carried_tail() { + const MP2_PID: u16 = 0x0061; + + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + let mut import = super::Import::new(broadcast, catalog.reserve()); + + let pmt = synth_pmt(&[(StreamType::Mpeg1Audio, MP2_PID)], false); + import.decode(&bytes::BytesMut::from(&pmt[..])).unwrap(); + + // A whole frame, then one cut mid-frame by the wrap. The first vouches for where the + // tail begins, which is what made the join look trustworthy. + let mut payload = mp2_frame(0xAA); + payload.extend_from_slice(&mp2_frame(0xBB)[..40]); + import + .decode(audio_pes_packet(MP2_PID, 0, 90_000, &payload).as_slice()) + .unwrap(); + // The top of the file again: the tail splices onto its first frame. + let mut wrapped = mp2_frame(0xCC); + wrapped.extend_from_slice(&mp2_frame(0xDD)); + import + .decode(audio_pes_packet(MP2_PID, 1, 270_000, &wrapped).as_slice()) + .expect("a splice is not fatal"); + import + .decode(audio_pes_packet(MP2_PID, 2, 450_000, &mp2_frame(0xEE)).as_slice()) + .unwrap(); + import.finish().unwrap(); + + let frames = read_audio_frames(&consumer, &catalog).await; + assert_eq!( + frames.iter().map(|f| f.payload.clone()).collect::>(), + vec![mp2_frame(0xAA), mp2_frame(0xCC), mp2_frame(0xDD), mp2_frame(0xEE)], + "the splice cost more than the frame it interrupted" + ); + // The wrapped frame keeps the new PES's PTS, not the pre-wrap tail's. + assert_eq!( + frames[1].timestamp, + Timestamp::from_micros(3_000_000).unwrap(), + "not re-anchored on the new PES" + ); + } + + // Confirming a joined frame means holding it when the buffer ends too soon after it to + // hold a header. At end of stream that header is never coming, so the drain publishes it + // anyway rather than dropping a frame that is whole and parses. + #[tokio::test(start_paused = true)] + async fn legacy_drains_a_joined_frame_at_end_of_stream() { + const MP2_PID: u16 = 0x0061; + + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + let mut import = super::Import::new(broadcast, catalog.reserve()); + + let pmt = synth_pmt(&[(StreamType::Mpeg1Audio, MP2_PID)], false); + import.decode(&bytes::BytesMut::from(&pmt[..])).unwrap(); + + let mut payload = mp2_frame(0xAA); + payload.extend_from_slice(&mp2_frame(0xBB)[..40]); + import + .decode(audio_pes_packet(MP2_PID, 0, 90_000, &payload).as_slice()) + .unwrap(); + // The rest of the split frame and nothing else, so the stream ends with it joined, + // whole, and unconfirmed. + import + .decode(audio_pes_packet(MP2_PID, 1, 270_000, &mp2_frame(0xBB)[40..]).as_slice()) + .unwrap(); + import.finish().unwrap(); + + let frames = read_audio_frames(&consumer, &catalog).await; + assert_eq!( + frames.iter().map(|f| f.payload.clone()).collect::>(), + vec![mp2_frame(0xAA), mp2_frame(0xBB)], + "the last frame was held for a confirmation that could never arrive" ); } @@ -3465,6 +4004,20 @@ mod test { ); } + #[test] + fn tei_pusi_section_is_dropped() { + // A PUSI flagged TEI must not start a section out of payload the demodulator has + // already disowned. Resetting the counter is not enough: the packet itself is junk. + let mut corrupt = packet(true, 0, 0, &CUE); + corrupt[1] |= 0x80; // transport_error_indicator + let clean = packet(true, 1, 0, &CUE); + assert_eq!( + run(&[corrupt, clean]), + vec![CUE.to_vec()], + "a section was emitted from a packet flagged corrupt" + ); + } + #[test] fn duplicate_mid_section_packet_is_skipped() { // A 3-packet section with the central continuation duplicated (same cc, same diff --git a/rs/moq-mux/src/source.rs b/rs/moq-mux/src/source.rs index 70d1288569..c4ccfd8fc8 100644 --- a/rs/moq-mux/src/source.rs +++ b/rs/moq-mux/src/source.rs @@ -2,7 +2,7 @@ //! //! A hang catalog rendition may reference a track published in *another* //! broadcast via its `broadcast` field (a path relative to the catalog's -//! broadcast, e.g. `../source`). Resolving that reference needs the catalog +//! broadcast, e.g. `./source`). Resolving that reference needs the catalog //! broadcast's own path and an [`moq_net::origin::Consumer`] to fetch the //! referenced broadcast from. [`Source`] bundles the two, and resolves both the //! catalog broadcast and any referenced broadcast through the same origin so @@ -62,18 +62,26 @@ impl Source { self.origin.request_broadcast(&self.path) } + /// Resolve a rendition's optional broadcast reference to an origin path. + /// + /// A missing or empty reference returns the catalog broadcast path. A valid reference + /// may return the empty root path, which still names a broadcast. `None` means the + /// reference walked above the root and names nothing. + pub fn resolve_reference(&self, rel: Option<&moq_net::PathRelative<'_>>) -> Option { + match rel.filter(|rel| !rel.is_empty()) { + Some(rel) => self.path.try_resolve(rel), + None => Some(self.path.clone()), + } + } + /// The path of the broadcast a rendition's `broadcast` reference names. /// - /// A missing or empty `rel` names the catalog broadcast; anything else names the - /// resolved broadcast, which may be the catalog's own path again (`../pub` from - /// `a/pub`). + /// The erroring counterpart to [`Self::resolve_reference`], for the consumer side, where + /// an escaping reference is a fault to report rather than a rendition to drop. fn target(&self, rel: Option<&moq_net::PathRelative<'_>>) -> crate::Result { - let Some(rel) = rel.filter(|rel| !rel.is_empty()) else { - return Ok(self.path.clone()); - }; - - self.path.resolve(rel).ok_or_else(|| { - tracing::error!(%rel, catalog = %self.path, "broadcast reference escapes the root"); + self.resolve_reference(rel).ok_or_else(|| { + let rel = rel.map_or("", |rel| rel.as_str()); + tracing::error!(rel, catalog = %self.path, "broadcast reference escapes the root"); crate::Error::EscapingBroadcast(rel.to_string()) }) } @@ -94,6 +102,57 @@ impl Source { Ok(self.origin.request_broadcast(&self.target(rel)?)) } + /// The skipping counterpart to [`Self::request`], returning `None` when `rel` walks above + /// the origin root. + /// + /// The exporters use it to drop a single bad rendition rather than fail the whole export. + /// [`Self::retain_valid`] already removes those renditions, so this is the second half of + /// the same policy rather than a different one. + pub(crate) fn try_request( + &self, + rel: Option<&moq_net::PathRelative<'_>>, + ) -> Option> { + Some(self.origin.request_broadcast(&self.resolve_reference(rel)?)) + } + + /// Remove renditions whose broadcast reference escapes above the origin root. + /// + /// Every section carrying renditions must be listed here; one left out silently exempts + /// its renditions from the containment check, exactly as on the consumer side. + pub(crate) fn retain_valid( + &self, + catalog: &mut crate::catalog::hang::Catalog, + ) { + self.retain_valid_references("video", &mut catalog.video.renditions); + self.retain_valid_references("audio", &mut catalog.audio.renditions); + self.retain_valid_references("text", &mut catalog.text.renditions); + } + + /// Remove media renditions whose broadcast reference escapes above the origin root. + pub(crate) fn retain_valid_media(&self, catalog: &mut hang::Catalog) { + self.retain_valid_references("video", &mut catalog.video.renditions); + self.retain_valid_references("audio", &mut catalog.audio.renditions); + self.retain_valid_references("text", &mut catalog.text.renditions); + } + + fn retain_valid_references( + &self, + kind: &'static str, + renditions: &mut std::collections::BTreeMap, + ) { + renditions.retain(|name, config| { + let valid = self.resolve_reference(config.broadcast()).is_some(); + if !valid { + tracing::warn!( + rendition = name, + kind, + "ignoring rendition whose broadcast escapes above the root" + ); + } + valid + }); + } + /// Resolve an optional cross-broadcast reference to its broadcast. /// /// `rel` is a rendition's catalog `broadcast` field: `None` (or an empty reference) @@ -132,6 +191,28 @@ impl Source { } } +trait BroadcastConfig { + fn broadcast(&self) -> Option<&moq_net::PathRelativeOwned>; +} + +impl BroadcastConfig for hang::catalog::VideoConfig { + fn broadcast(&self) -> Option<&moq_net::PathRelativeOwned> { + self.broadcast.as_ref() + } +} + +impl BroadcastConfig for hang::catalog::AudioConfig { + fn broadcast(&self) -> Option<&moq_net::PathRelativeOwned> { + self.broadcast.as_ref() + } +} + +impl BroadcastConfig for hang::catalog::TextConfig { + fn broadcast(&self) -> Option<&moq_net::PathRelativeOwned> { + self.broadcast.as_ref() + } +} + /// Test helper: serve `broadcast` on a throwaway origin's dynamic handler and return a /// [`Source`] rooted at it, so exporter tests that build a local broadcast can still resolve /// it by path. The origin is leaked so the broadcast stays reachable for the source's @@ -154,6 +235,7 @@ pub(crate) fn announced(broadcast: &moq_net::broadcast::Consumer) -> Source { #[cfg(test)] mod tests { use super::*; + use hang::catalog::{H264, VideoConfig}; use moq_net::{Origin, PathRelative}; /// Let the origin's spawned attach task run: a created broadcast becomes @@ -216,8 +298,8 @@ mod tests { let source = Source::new(origin.consume(), "a/pub"); - // Walks back to the catalog's own path. - let rel = PathRelative::new("../pub"); + // Names the catalog within its own parent. + let rel = PathRelative::new("./pub"); source .subscribe_track(Some(&rel), "video") .await @@ -242,10 +324,15 @@ mod tests { let source = Source::new(origin.consume(), "a/pub"); - // `a/pub` has two segments, so a third `..` walks above the root. `../..` stops at - // the root, which still names a broadcast, so it is not in this set. - for reference in ["../../../elsewhere", "../../..", "../../../.."] { + // A reference resolves against the catalog's parent (`a`), so a second `..` walks + // above the root. A lone `..` stops at the root, which still names a broadcast, so + // it is not in this set. + for reference in ["../../elsewhere", "../..", "../../.."] { let rel = PathRelative::new(reference); + assert!( + source.resolve_reference(Some(&rel)).is_none(), + "{reference} should escape" + ); assert!(source.request(Some(&rel)).is_err(), "{reference} should be rejected"); // Neither `elsewhere` nor the catalog broadcast answers it: the rendition names @@ -263,6 +350,51 @@ mod tests { } } + #[test] + fn escaping_rendition_is_removed_while_valid_sibling_remains() { + let origin = Origin::random().produce(); + let source = Source::new(origin.consume(), "a/pub"); + let mut escaped = VideoConfig::new(H264 { + profile: 0x42, + constraints: 0, + level: 0x1e, + inline: false, + }); + escaped.broadcast = Some(PathRelative::new("../../source").to_owned()); + let mut sibling = escaped.clone(); + sibling.broadcast = Some(PathRelative::new("./source").to_owned()); + + let mut catalog = hang::Catalog::default(); + catalog.video.renditions.insert("escaped".to_string(), escaped); + catalog.video.renditions.insert("sibling".to_string(), sibling); + source.retain_valid_media(&mut catalog); + + assert!(!catalog.video.renditions.contains_key("escaped")); + assert!(catalog.video.renditions.contains_key("sibling")); + } + + /// The filter covers every section carrying renditions, not just the media ones. Text + /// is the section that only exists on one side of the containment check by default, so + /// it is the one that silently goes unchecked when the two sides drift. + #[test] + fn escaping_text_rendition_is_removed() { + let origin = Origin::random().produce(); + let source = Source::new(origin.consume(), "a/pub"); + + let mut escaped = hang::catalog::TextConfig::new(hang::catalog::TextFormat::Vtt); + escaped.broadcast = Some(PathRelative::new("../../source").to_owned()); + let mut sibling = escaped.clone(); + sibling.broadcast = Some(PathRelative::new("./source").to_owned()); + + let mut catalog = hang::Catalog::default(); + catalog.text.renditions.insert("escaped".to_string(), escaped); + catalog.text.renditions.insert("sibling".to_string(), sibling); + source.retain_valid_media(&mut catalog); + + assert!(!catalog.text.renditions.contains_key("escaped")); + assert!(catalog.text.renditions.contains_key("sibling")); + } + #[tokio::test] async fn subscribe_track_resolves_referenced_broadcast() { let origin = Origin::random().produce(); @@ -280,10 +412,57 @@ mod tests { let source = Source::new(origin.consume(), "a/pub"); // The reference resolves to `a/source`, whose "video" track answers the subscribe. - let rel = PathRelative::new("../source"); + let rel = PathRelative::new("./source"); source .subscribe_track(Some(&rel), "video") .await .expect("referenced track should resolve"); } + + #[tokio::test] + async fn dot_resolves_output_parent() { + let origin = Origin::random().produce(); + + let _catalog = origin + .create_broadcast( + "a/source/transcode", + moq_net::broadcast::Route::new().with_announce(true), + ) + .unwrap(); + + let mut referenced = origin + .create_broadcast("a/source", moq_net::broadcast::Route::new().with_announce(true)) + .unwrap(); + let _video = referenced.create_track("video", None).unwrap(); + settle().await; + + let source = Source::new(origin.consume(), "a/source/transcode"); + let rel = PathRelative::new("."); + source + .subscribe_track(Some(&rel), "video") + .await + .expect("dot should resolve to the catalog broadcast's parent"); + } + + #[tokio::test] + async fn dot_resolves_one_segment_catalog_to_root() { + let origin = Origin::random().produce(); + + let _catalog = origin + .create_broadcast("top", moq_net::broadcast::Route::new().with_announce(true)) + .unwrap(); + + let mut root = origin + .create_broadcast("", moq_net::broadcast::Route::new().with_announce(true)) + .unwrap(); + let _video = root.create_track("video", None).unwrap(); + settle().await; + + let source = Source::new(origin.consume(), "top"); + let rel = PathRelative::new("."); + source + .subscribe_track(Some(&rel), "video") + .await + .expect("dot should resolve to the empty root broadcast"); + } } diff --git a/rs/moq-native/CHANGELOG.md b/rs/moq-native/CHANGELOG.md index 0b1a32313f..9d052fbbe3 100644 --- a/rs/moq-native/CHANGELOG.md +++ b/rs/moq-native/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.11](https://github.com/moq-dev/moq/compare/moq-native-v0.19.10...moq-native-v0.19.11) - 2026-08-14 + +### Added + +- *(cli)* run multiple import/export stages over one connection ([#2809](https://github.com/moq-dev/moq/pull/2809)) + +### Other + +- reload custom root CAs without restart ([#2863](https://github.com/moq-dev/moq/pull/2863)) + ## [0.19.10](https://github.com/moq-dev/moq/compare/moq-native-v0.19.9...moq-native-v0.19.10) - 2026-08-13 ### Added diff --git a/rs/moq-native/Cargo.toml b/rs/moq-native/Cargo.toml index b90dcfc995..20df561100 100644 --- a/rs/moq-native/Cargo.toml +++ b/rs/moq-native/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.19.10" +version = "0.19.11" edition = "2024" rust-version.workspace = true @@ -16,8 +16,8 @@ categories = ["multimedia", "network-programming", "web-programming"] default = ["quinn", "aws-lc-rs", "websocket", "tcp", "uds"] quinn = ["dep:quinn", "dep:web-transport-quinn", "dep:rcgen", "dep:reqwest", "dep:rustls-webpki", "watch", "dep:dns-lookup"] noq = ["dep:web-transport-noq", "dep:rcgen", "dep:reqwest", "dep:rustls-webpki", "watch", "dep:dns-lookup"] -quiche = ["dep:web-transport-quiche", "dep:rcgen", "dep:reqwest", "dep:rustls-webpki", "dep:rustls-native-certs", "dep:dns-lookup"] -# Filesystem watcher for hot-reloading on-disk TLS certs/keys; the QUIC backends imply it. +quiche = ["dep:web-transport-quiche", "dep:rcgen", "dep:reqwest", "dep:rustls-webpki", "dep:rustls-native-certs", "dep:dns-lookup", "watch"] +# Filesystem watcher for hot-reloading on-disk TLS certs, keys, and root CAs. watch = ["dep:notify"] # Capture qlog traces (`--*-quic-qlog `). Off by default: quinn and noq pull in # the qlog crate and its serde_json encoder, which a production build has no use for. @@ -32,7 +32,7 @@ iroh = ["dep:web-transport-iroh", "dep:web-transport-proto", "dep:noq-proto"] # LAN peer discovery over mDNS (the `mdns` module). mdns = ["dep:mdns-sd", "dep:hmac", "dep:sha2", "dep:subtle"] jemalloc = ["dep:tikv-jemallocator", "dep:tikv-jemalloc-ctl"] -websocket = ["dep:qmux"] +websocket = ["dep:qmux", "watch"] # Plain-TCP qmux transport (`tcp://`), no TLS. Server-side and client-side. tcp = ["dep:qmux", "dep:dns-lookup"] # Unix-domain-socket qmux transport (`unix://`), unix-only. Adds peer-credential diff --git a/rs/moq-native/src/noq.rs b/rs/moq-native/src/noq.rs index 4d832d154b..78e04744ba 100644 --- a/rs/moq-native/src/noq.rs +++ b/rs/moq-native/src/noq.rs @@ -491,11 +491,7 @@ impl NoqServer { let mut tls = if config.tls.root.is_empty() { tls_builder.with_no_client_auth().with_cert_resolver(certs.clone()) } else { - let roots = config.tls.load_roots()?; - let verifier = rustls::server::WebPkiClientVerifier::builder_with_provider(Arc::new(roots), provider) - .allow_unauthenticated() - .build() - .map_err(Error::ClientVerifier)?; + let verifier = config.tls.client_verifier(provider)?; tls_builder .with_client_cert_verifier(verifier) .with_cert_resolver(certs.clone()) @@ -512,6 +508,7 @@ impl NoqServer { tls.alpn_protocols = alpns; tls.key_log = Arc::new(rustls::KeyLogFile::new()); + config.tls.disable_resumption(&mut tls); let tls: noq::crypto::rustls::QuicServerConfig = tls.try_into()?; let mut tls = noq::ServerConfig::with_crypto(Arc::new(tls)); diff --git a/rs/moq-native/src/quiche.rs b/rs/moq-native/src/quiche.rs index c77eac7f3e..4d2b4dd452 100644 --- a/rs/moq-native/src/quiche.rs +++ b/rs/moq-native/src/quiche.rs @@ -304,7 +304,9 @@ impl QuicheClient { // handshake fails closed. let roots = match &verification { Verification::Roots { custom, system } => { - let mut roots = custom.clone(); + // Quiche takes concrete trust anchors per connection rather than a + // verifier, so refresh the custom bundle before each handshake. + let mut roots = custom.refresh(); if *system { let native = rustls_native_certs::load_native_certs(); for err in native.errors { @@ -615,6 +617,7 @@ impl QuicheServer { } if !config.tls.root.is_empty() { + tracing::warn!("the quiche backend snapshots server mTLS roots; restart after rotating --server-tls-root"); let roots = config .tls .root diff --git a/rs/moq-native/src/quinn.rs b/rs/moq-native/src/quinn.rs index 22bccb6e7c..9f73979c89 100644 --- a/rs/moq-native/src/quinn.rs +++ b/rs/moq-native/src/quinn.rs @@ -504,11 +504,7 @@ impl QuinnServer { let mut tls = if config.tls.root.is_empty() { tls_builder.with_no_client_auth().with_cert_resolver(certs.clone()) } else { - let roots = config.tls.load_roots()?; - let verifier = rustls::server::WebPkiClientVerifier::builder_with_provider(Arc::new(roots), provider) - .allow_unauthenticated() - .build() - .map_err(Error::ClientVerifier)?; + let verifier = config.tls.client_verifier(provider)?; tls_builder .with_client_cert_verifier(verifier) .with_cert_resolver(certs.clone()) @@ -525,6 +521,7 @@ impl QuinnServer { tls.alpn_protocols = alpns; tls.key_log = Arc::new(rustls::KeyLogFile::new()); + config.tls.disable_resumption(&mut tls); let tls: quinn::crypto::rustls::QuicServerConfig = tls.try_into()?; let mut tls = quinn::ServerConfig::with_crypto(Arc::new(tls)); diff --git a/rs/moq-native/src/tls.rs b/rs/moq-native/src/tls.rs index 0e31d93c72..e8e70b1eeb 100644 --- a/rs/moq-native/src/tls.rs +++ b/rs/moq-native/src/tls.rs @@ -5,14 +5,16 @@ //! supplies the certificate chain to serve, loaded from disk or self-signed on //! startup, and optionally the roots that authenticate mTLS clients. //! -//! Certificates loaded from disk are watched and hot reloaded, so rotating them -//! needs no restart. [`Certificates`] reads the current set back out. +//! Certificates, keys, and custom root CAs loaded from disk are normally hot +//! reloaded for new handshakes. Quiche servers are the exception: all inbound +//! TLS material is fixed when the listener is built. [`Certificates`] reads the +//! current served set back out. use crate::crypto; use rustls::pki_types::pem::PemObject; use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime}; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; use std::{fs, io}; #[cfg(all( @@ -20,9 +22,6 @@ use std::{fs, io}; any(feature = "aws-lc-rs", feature = "ring") ))] use rustls::pki_types::PrivatePkcs8KeyDer; -#[cfg(any(feature = "quinn", feature = "noq", feature = "quiche"))] -use std::sync::RwLock; - /// Errors loading or generating TLS certificates and keys. /// /// Shared by the client TLS config and the quinn/noq servers so each backend's @@ -127,6 +126,10 @@ pub enum Error { #[error("failed to build client certificate verifier")] ClientVerifier(#[source] rustls::server::VerifierBuilderError), + /// The server-certificate verifier couldn't be built from the configured roots. + #[error("failed to build server certificate verifier")] + ServerVerifier(#[source] rustls::client::VerifierBuilderError), + /// Generating a self-signed certificate failed. #[cfg(any(feature = "quinn", feature = "noq", feature = "quiche"))] #[error(transparent)] @@ -155,6 +158,19 @@ pub(crate) fn read_certs(path: &Path) -> Result>> { .map_err(Error::Read) } +/// Load every configured custom root, rejecting a path that contains no certificates. +fn read_roots(paths: &[PathBuf]) -> Result>> { + let mut roots = Vec::new(); + for path in paths { + let certs = read_certs(path)?; + if certs.is_empty() { + return Err(Error::EmptyRoots(path.clone())); + } + roots.extend(certs); + } + Ok(roots) +} + // ── Client ────────────────────────────────────────────────────────── /// The dial side's TLS: who to trust, and the optional mTLS identity to present. @@ -173,6 +189,8 @@ pub struct Connect { /// roots are only loaded when no custom root is given, so passing a root /// replaces them; set `--connect-tls-system-roots` to trust both (e.g. to reach a /// local relay with a private CA and a remote one with a public CA). + /// Files are hot reloaded for new connections, retaining the last valid roots + /// if a rotation is temporarily missing or malformed. #[serde(skip_serializing_if = "Vec::is_empty")] #[arg(id = "connect-tls-root", long = "connect-tls-root", env = "MOQ_CONNECT_TLS_ROOT")] #[serde_as(as = "serde_with::OneOrMany<_>")] @@ -369,6 +387,148 @@ struct Deprecated { client_host_name: Option, } +/// The last valid contents of a set of custom root files. +/// +/// Quiche consumes concrete DER roots per connection, while rustls consumes a +/// verifier. Keeping the parsed roots here gives both paths identical +/// last-known-good behavior when a file is briefly absent or incomplete during +/// rotation. +#[derive(Clone)] +pub(crate) struct CustomRoots { + paths: Vec, + current: Arc>>>, +} + +impl CustomRoots { + fn new(paths: Vec) -> Result { + let current = read_roots(&paths)?; + Ok(Self { + paths, + current: Arc::new(RwLock::new(current)), + }) + } + + fn load(&self) -> Result>> { + read_roots(&self.paths) + } + + fn replace(&self, roots: Vec>) { + *self.current.write().unwrap_or_else(std::sync::PoisonError::into_inner) = roots; + } + + pub(crate) fn current(&self) -> Vec> { + self.current + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + /// Refresh from disk, retaining and returning the last valid roots on failure. + #[cfg(feature = "quiche")] + pub(crate) fn refresh(&self) -> Vec> { + self.refresh_with(|| self.load()) + } + + #[cfg(feature = "quiche")] + fn refresh_with( + &self, + load: impl FnOnce() -> Result>>, + ) -> Vec> { + let mut current = self.current.write().unwrap_or_else(std::sync::PoisonError::into_inner); + match load().and_then(|roots| { + root_store(&roots)?; + Ok(roots) + }) { + Ok(roots) => { + *current = roots.clone(); + roots + } + Err(err) => { + tracing::warn!(%err, "failed to reload client root certificates; retaining previous roots"); + current.clone() + } + } + } +} + +#[cfg(feature = "watch")] +struct ReloadState { + current: RwLock>, + build: Box Result> + Send + Sync>, + role: &'static str, +} + +#[cfg(feature = "watch")] +impl ReloadState { + fn reload(&self) { + match (self.build)() { + Ok(next) => { + *self.current.write().unwrap_or_else(std::sync::PoisonError::into_inner) = next; + tracing::info!(role = self.role, "reloaded TLS root certificates"); + } + Err(err) => { + tracing::warn!(%err, role = self.role, "failed to reload TLS root certificates; retaining previous roots"); + } + } + } + + fn current(&self) -> Arc { + self.current + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} + +/// A verifier whose implementation is replaced after its root files change. +#[cfg(feature = "watch")] +struct Reloading { + state: Arc>, + // Holds the OS watcher alive for exactly as long as the TLS config. + _watcher: Option, +} + +#[cfg(feature = "watch")] +impl Reloading { + fn new( + paths: &[PathBuf], + initial: Arc, + role: &'static str, + build: impl Fn() -> Result> + Send + Sync + 'static, + ) -> Self { + let state = Arc::new(ReloadState { + current: RwLock::new(initial), + build: Box::new(build), + role, + }); + + let reload = state.clone(); + let watcher = match crate::watch::callback(paths, move || reload.reload()) { + Ok(watcher) => Some(watcher), + Err(err) => { + tracing::error!(%err, role, "failed to watch TLS root certificates; hot reload disabled"); + None + } + }; + + Self { + state, + _watcher: watcher, + } + } + + fn current(&self) -> Arc { + self.state.current() + } +} + +#[cfg(feature = "watch")] +impl std::fmt::Debug for Reloading { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Reloading").field("role", &self.state.role).finish() + } +} + /// The resolved server-certificate verification policy. /// /// Computed once by [Client::verification] and shared by every backend (the @@ -388,10 +548,7 @@ pub(crate) enum Verification { /// store is trusted too; each backend resolves that its own way (the rustls /// backends use the OS platform verifier, quiche loads the native roots). /// `custom` are extra PEM roots trusted in addition. - Roots { - custom: Vec>, - system: bool, - }, + Roots { custom: CustomRoots, system: bool }, } impl Connect { @@ -566,20 +723,13 @@ impl Connect { // root replaces them unless the system roots are explicitly re-enabled. let system = system_roots.unwrap_or(roots.is_empty()); - let mut custom = Vec::new(); - for root in &roots { - let certs = read_certs(root)?; - if certs.is_empty() { - return Err(Error::EmptyRoots(root.clone())); - } - custom.extend(certs); - } + let custom = CustomRoots::new(roots)?; // WebPKI needs at least one trusted root to ever succeed, so fail fast // instead of producing confusing handshake errors later. With system // trust enabled the verifier supplies its own roots, so custom roots are // optional. - if !system && custom.is_empty() { + if !system && custom.current().is_empty() { return Err(Error::NoRoots); } @@ -614,56 +764,86 @@ impl Connect { pub fn build(&self) -> Result { let provider = crypto::provider(); let verification = self.verification()?; + let reloadable_roots = cfg!(feature = "watch") + && matches!(&verification, Verification::Roots { custom, .. } if !custom.paths.is_empty()); // Allow TLS 1.2 in addition to 1.3 for WebSocket compatibility. // QUIC always negotiates TLS 1.3 regardless of this setting. let builder = rustls::ClientConfig::builder_with_provider(provider.clone()) .with_protocol_versions(&[&rustls::version::TLS13, &rustls::version::TLS12])?; - // Install the server-certificate verifier. Disabled/Fingerprints get a - // placeholder empty store here and swap in their own verifier below. - let builder = match &verification { - Verification::Roots { custom, system: true } => Self::system_verifier(builder, custom, &provider)?, - Verification::Roots { custom, system: false } => builder.with_root_certificates(root_store(custom)?), - Verification::Disabled | Verification::Fingerprints(_) => { - builder.with_root_certificates(rustls::RootCertStore::empty()) - } - }; - - let mut tls = self.with_client_auth(builder)?; - - match verification { + let verifier: Arc = match verification { Verification::Disabled => { tracing::warn!( "TLS server certificate verification is disabled; A man-in-the-middle attack is possible." ); - tls.dangerous() - .set_certificate_verifier(Arc::new(NoCertificateVerification(provider))); + Arc::new(NoCertificateVerification(provider)) } Verification::Fingerprints(fingerprints) => { let fingerprints = fingerprints.into_iter().map(|fp| fp.to_vec()).collect(); - let verifier = FingerprintVerifier::new(provider, fingerprints); - tls.dangerous().set_certificate_verifier(Arc::new(verifier)); + Arc::new(FingerprintVerifier::new(provider, fingerprints)) } - // The verifier was installed by the builder above. - Verification::Roots { .. } => {} + Verification::Roots { custom, system } => Self::root_server_verifier(custom, system, provider)?, + }; + + let builder = builder.dangerous().with_custom_certificate_verifier(verifier); + let mut tls = self.with_client_auth(builder)?; + + // A resumed session skips certificate verification. Disable resumption when + // roots can change underneath this config so removing a CA takes effect on + // every subsequent handshake. + if reloadable_roots { + tls.resumption = rustls::client::Resumption::disabled(); } Ok(tls) } - /// Build the verifier for system/default trust on the rustls backends. + /// Build a reloadable verifier for custom roots and system/default trust. /// /// Uses the OS-native platform verifier (Keychain/SecTrust, Windows /// CryptoAPI, or the native store on Linux) everywhere it works, optionally /// extended with `custom` PEM roots. Android's platform verifier needs JNI /// setup (see [`init_android`]); until that has run we trust the bundled /// Mozilla roots so verification still works out of the box. - fn system_verifier( - builder: rustls::ConfigBuilder, + fn root_server_verifier( + custom: CustomRoots, + system: bool, + provider: crypto::Provider, + ) -> Result> { + let initial = Self::build_root_server_verifier(&custom.current(), system, &provider)?; + + #[cfg(feature = "watch")] + if !custom.paths.is_empty() { + let paths = custom.paths.clone(); + let reload = custom.clone(); + let reload_provider = provider.clone(); + let verifier = ReloadingServerVerifier::new(&paths, initial, move || { + let roots = reload.load()?; + let verifier = Self::build_root_server_verifier(&roots, system, &reload_provider)?; + reload.replace(roots); + Ok(verifier) + }); + return Ok(Arc::new(verifier)); + } + + Ok(initial) + } + + fn build_root_server_verifier( custom: &[CertificateDer<'static>], + system: bool, provider: &crypto::Provider, - ) -> Result> { + ) -> Result> { + if !system { + let roots = root_store(custom)?; + let verifier = + rustls::client::WebPkiServerVerifier::builder_with_provider(Arc::new(roots), provider.clone()) + .build() + .map_err(Error::ServerVerifier)?; + return Ok(verifier); + } + // Android's platform verifier needs JNI init (see `init_android`) and, // unlike the other platforms, can't be extended with custom roots. So use // it only once initialized and with no custom roots; otherwise trust the @@ -672,7 +852,7 @@ impl Connect { { if ANDROID_INITIALIZED.load(std::sync::atomic::Ordering::Acquire) && custom.is_empty() { let verifier = rustls_platform_verifier::Verifier::new(provider.clone())?; - return Ok(builder.dangerous().with_custom_certificate_verifier(Arc::new(verifier))); + return Ok(Arc::new(verifier)); } let mut roots = rustls::RootCertStore::empty(); @@ -680,7 +860,11 @@ impl Connect { for cert in custom { roots.add(cert.clone()).map_err(Error::AddRoot)?; } - Ok(builder.with_root_certificates(roots)) + let verifier = + rustls::client::WebPkiServerVerifier::builder_with_provider(Arc::new(roots), provider.clone()) + .build() + .map_err(Error::ServerVerifier)?; + Ok(verifier) } #[cfg(not(target_os = "android"))] @@ -690,7 +874,7 @@ impl Connect { } else { rustls_platform_verifier::Verifier::new_with_extra_roots(custom.iter().cloned(), provider.clone())? }; - Ok(builder.dangerous().with_custom_certificate_verifier(Arc::new(verifier))) + Ok(Arc::new(verifier)) } } @@ -795,7 +979,9 @@ pub struct Listen { /// do not present a certificate are unaffected. /// /// Plain-TLS listeners built via [`Self::server_config`] also use these roots - /// for optional mTLS. + /// for optional mTLS. Root files are hot reloaded for new handshakes on the + /// rustls-based backends; quiche servers require a restart because their TLS + /// hook fixes client-auth roots when the listener is built. #[arg( long = "listen-tls-root", id = "listen-tls-root", @@ -898,19 +1084,53 @@ impl Listen { } } + /// Disable cached client authentication when client roots can reload. + #[cfg(feature = "watch")] + pub(crate) fn disable_resumption(&self, tls: &mut rustls::ServerConfig) { + if !self.root.is_empty() { + tls.session_storage = Arc::new(rustls::server::NoServerSessionStorage {}); + tls.send_tls13_tickets = 0; + } + } + /// Load all configured root CAs into a [`rustls::RootCertStore`]. pub fn load_roots(&self) -> Result { - let mut roots = rustls::RootCertStore::empty(); - for path in &self.root { - let certs = read_certs(path)?; - if certs.is_empty() { - return Err(Error::Empty); - } - for cert in certs { - roots.add(cert).map_err(Error::AddRoot)?; - } + root_store(&read_roots(&self.root)?) + } + + /// Build the optional-client-auth verifier, reloading configured roots in place. + #[cfg(any(feature = "quinn", feature = "noq", feature = "quiche"))] + pub(crate) fn client_verifier( + &self, + provider: crypto::Provider, + ) -> Result> { + let initial = Self::build_client_verifier(&self.root, &provider)?; + + #[cfg(feature = "watch")] + { + let paths = self.root.clone(); + let reload_paths = paths.clone(); + let reload_provider = provider.clone(); + let verifier = ReloadingClientVerifier::new(&paths, initial, move || { + Self::build_client_verifier(&reload_paths, &reload_provider) + }); + Ok(Arc::new(verifier)) } - Ok(roots) + + #[cfg(not(feature = "watch"))] + Ok(initial) + } + + #[cfg(any(feature = "quinn", feature = "noq", feature = "quiche"))] + fn build_client_verifier( + paths: &[PathBuf], + provider: &crypto::Provider, + ) -> Result> { + let roots = root_store(&read_roots(paths)?)?; + rustls::server::WebPkiClientVerifier::builder_with_provider(Arc::new(roots), provider.clone()) + .allow_unauthenticated() + .build() + .map_err(Error::ClientVerifier) } /// Build a [`rustls::ServerConfig`] for a plain-TLS (non-QUIC) server, e.g. an @@ -943,15 +1163,12 @@ fn server_config(config: &Listen, alpn: Vec>) -> Result, +} + +#[cfg(feature = "watch")] +impl ReloadingServerVerifier { + fn new( + paths: &[PathBuf], + initial: Arc, + build: impl Fn() -> Result> + Send + Sync + 'static, + ) -> Self { + Self { + inner: Reloading::new(paths, initial, "client", build), + } + } +} + +#[cfg(feature = "watch")] +impl rustls::client::danger::ServerCertVerifier for ReloadingServerVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + server_name: &ServerName<'_>, + ocsp_response: &[u8], + now: UnixTime, + ) -> std::result::Result { + self.inner + .current() + .verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> std::result::Result { + self.inner.current().verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> std::result::Result { + self.inner.current().verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.current().supported_verify_schemes() + } + + fn requires_raw_public_keys(&self) -> bool { + self.inner.current().requires_raw_public_keys() + } +} + +/// Delegates each client-certificate check to the latest valid root verifier. +#[cfg(all(feature = "watch", any(feature = "quinn", feature = "noq", feature = "quiche")))] +#[derive(Debug)] +struct ReloadingClientVerifier { + inner: Reloading, +} + +#[cfg(all(feature = "watch", any(feature = "quinn", feature = "noq", feature = "quiche")))] +impl ReloadingClientVerifier { + fn new( + paths: &[PathBuf], + initial: Arc, + build: impl Fn() -> Result> + Send + Sync + 'static, + ) -> Self { + Self { + inner: Reloading::new(paths, initial, "server", build), + } + } +} + +#[cfg(all(feature = "watch", any(feature = "quinn", feature = "noq", feature = "quiche")))] +impl rustls::server::danger::ClientCertVerifier for ReloadingClientVerifier { + fn offer_client_auth(&self) -> bool { + self.inner.current().offer_client_auth() + } + + fn client_auth_mandatory(&self) -> bool { + self.inner.current().client_auth_mandatory() + } + + fn root_hint_subjects(&self) -> &[rustls::DistinguishedName] { + // A live verifier cannot return a borrowed slice from a replaceable value. + // Empty hints ask clients to offer any configured identity, which rustls + // then verifies against the current roots. + &[] + } + + fn verify_client_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + now: UnixTime, + ) -> std::result::Result { + self.inner.current().verify_client_cert(end_entity, intermediates, now) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> std::result::Result { + self.inner.current().verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> std::result::Result { + self.inner.current().verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.current().supported_verify_schemes() + } + + fn requires_raw_public_keys(&self) -> bool { + self.inner.current().requires_raw_public_keys() + } +} + // ── NoCertificateVerification ─────────────────────────────────────── #[derive(Debug)] @@ -1204,6 +1556,8 @@ mod tests { use super::*; use rustls::client::danger::ServerCertVerifier; use rustls::pki_types::ServerName; + #[cfg(feature = "watch")] + use rustls::server::danger::ClientCertVerifier; fn self_signed() -> CertificateDer<'static> { let key = rcgen::KeyPair::generate().unwrap(); @@ -1359,6 +1713,350 @@ mod tests { (file, path) } + #[cfg(all(feature = "watch", any(feature = "quinn", feature = "noq", feature = "quiche")))] + fn signed_certificates() -> ( + String, + CertificateDer<'static>, + PrivateKeyDer<'static>, + CertificateDer<'static>, + PrivateKeyDer<'static>, + ) { + use rcgen::{BasicConstraints, ExtendedKeyUsagePurpose, IsCa, Issuer}; + + let ca_key = rcgen::KeyPair::generate().unwrap(); + let mut ca_params = rcgen::CertificateParams::new(Vec::::new()).unwrap(); + ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + let ca = ca_params.self_signed(&ca_key).unwrap(); + let issuer = Issuer::from_params(&ca_params, &ca_key); + + let server_key = rcgen::KeyPair::generate().unwrap(); + let server_key_der = PrivatePkcs8KeyDer::from(server_key.serialize_der()); + let mut server_params = rcgen::CertificateParams::new(vec!["localhost".to_string()]).unwrap(); + server_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + let server = server_params.signed_by(&server_key, &issuer).unwrap(); + + let client_key = rcgen::KeyPair::generate().unwrap(); + let client_key_der = PrivatePkcs8KeyDer::from(client_key.serialize_der()); + let mut client_params = rcgen::CertificateParams::new(Vec::::new()).unwrap(); + client_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth]; + let client = client_params.signed_by(&client_key, &issuer).unwrap(); + + ( + ca.pem(), + server.into(), + server_key_der.into(), + client.into(), + client_key_der.into(), + ) + } + + #[cfg(all(feature = "watch", any(feature = "quinn", feature = "noq", feature = "quiche")))] + fn handshake_kinds( + client: Arc, + server: Arc, + ) -> std::result::Result<(rustls::HandshakeKind, rustls::HandshakeKind), rustls::Error> { + let name = ServerName::try_from("localhost").unwrap(); + let mut client = rustls::ClientConnection::new(client, name).unwrap(); + let mut server = rustls::ServerConnection::new(server).unwrap(); + + for _ in 0..100 { + let mut client_data = Vec::new(); + client.write_tls(&mut client_data).unwrap(); + if !client_data.is_empty() { + server.read_tls(&mut client_data.as_slice()).unwrap(); + server.process_new_packets()?; + } + + let mut server_data = Vec::new(); + server.write_tls(&mut server_data).unwrap(); + if !server_data.is_empty() { + client.read_tls(&mut server_data.as_slice()).unwrap(); + client.process_new_packets()?; + } + + if !client.is_handshaking() && !server.is_handshaking() && !client.wants_write() && !server.wants_write() { + return Ok((client.handshake_kind().unwrap(), server.handshake_kind().unwrap())); + } + } + + panic!("TLS handshake did not settle"); + } + + #[cfg(all(feature = "watch", any(feature = "quinn", feature = "noq", feature = "quiche")))] + #[test] + fn reloadable_roots_disable_session_resumption() { + use std::io::Write; + + let (ca, server_cert, server_key, _, _) = signed_certificates(); + let mut root_file = tempfile::NamedTempFile::new().unwrap(); + root_file.write_all(ca.as_bytes()).unwrap(); + let roots = read_roots(&[root_file.path().to_path_buf()]).unwrap(); + let provider = crypto::provider(); + + let control = rustls::ClientConfig::builder_with_provider(provider.clone()) + .with_safe_default_protocol_versions() + .unwrap() + .with_root_certificates(root_store(&roots).unwrap()) + .with_no_client_auth(); + let reloadable = Connect { + root: vec![root_file.path().to_path_buf()], + ..Default::default() + } + .build() + .unwrap(); + let server = rustls::ServerConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .unwrap() + .with_no_client_auth() + .with_single_cert(vec![server_cert], server_key) + .unwrap(); + let server = Arc::new(server); + + let control = Arc::new(control); + assert_eq!( + handshake_kinds(control.clone(), server.clone()).unwrap().0, + rustls::HandshakeKind::Full + ); + assert_eq!( + handshake_kinds(control, server.clone()).unwrap().0, + rustls::HandshakeKind::Resumed + ); + + let reloadable = Arc::new(reloadable); + assert_eq!( + handshake_kinds(reloadable.clone(), server.clone()).unwrap().0, + rustls::HandshakeKind::Full + ); + assert_eq!( + handshake_kinds(reloadable, server).unwrap().0, + rustls::HandshakeKind::Full + ); + } + + #[cfg(all(feature = "watch", any(feature = "quinn", feature = "noq", feature = "quiche")))] + #[test] + fn reloadable_client_roots_disable_server_resumption() { + use std::io::Write; + + let (ca_a, server_cert, server_key, client_cert, client_key) = signed_certificates(); + let (ca_b, _, _, _, _) = signed_certificates(); + let mut root_file = tempfile::NamedTempFile::new().unwrap(); + root_file.write_all(ca_a.as_bytes()).unwrap(); + let paths = vec![root_file.path().to_path_buf()]; + let provider = crypto::provider(); + + let build_client = |cert, key| { + rustls::ClientConfig::builder_with_provider(provider.clone()) + .with_safe_default_protocol_versions() + .unwrap() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoCertificateVerification(provider.clone()))) + .with_client_auth_cert(vec![cert], key) + .unwrap() + }; + let control_client = Arc::new(build_client(client_cert.clone(), client_key.clone_key())); + let reloadable_client = Arc::new(build_client(client_cert, client_key)); + + let verifier = Listen::build_client_verifier(&paths, &provider).unwrap(); + let control = rustls::ServerConfig::builder_with_provider(provider.clone()) + .with_safe_default_protocol_versions() + .unwrap() + .with_client_cert_verifier(verifier) + .with_single_cert(vec![server_cert.clone()], server_key.clone_key()) + .unwrap(); + + let initial = Listen::build_client_verifier(&paths, &provider).unwrap(); + let reload_paths = paths.clone(); + let reload_provider = provider.clone(); + let verifier = Arc::new(ReloadingClientVerifier::new(&paths, initial, move || { + Listen::build_client_verifier(&reload_paths, &reload_provider) + })); + let reload = verifier.inner.state.clone(); + let mut reloadable = rustls::ServerConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .unwrap() + .with_client_cert_verifier(verifier) + .with_single_cert(vec![server_cert], server_key) + .unwrap(); + Listen { + root: paths, + ..Default::default() + } + .disable_resumption(&mut reloadable); + + let control = Arc::new(control); + assert_eq!( + handshake_kinds(control_client.clone(), control.clone()).unwrap().1, + rustls::HandshakeKind::Full + ); + assert_eq!( + handshake_kinds(control_client, control).unwrap().1, + rustls::HandshakeKind::Resumed + ); + + let reloadable = Arc::new(reloadable); + assert_eq!( + handshake_kinds(reloadable_client.clone(), reloadable.clone()) + .unwrap() + .1, + rustls::HandshakeKind::Full + ); + + std::fs::write(root_file.path(), ca_b).unwrap(); + reload.reload(); + assert!(handshake_kinds(reloadable_client, reloadable).is_err()); + } + + #[cfg(all(feature = "watch", any(feature = "quinn", feature = "noq", feature = "quiche")))] + #[test] + fn custom_roots_reload_for_new_client_and_server_handshakes() { + use std::io::Write; + + let (ca_a, server_a, _, client_a, _) = signed_certificates(); + let (ca_b, server_b, _, client_b, _) = signed_certificates(); + let mut root_file = tempfile::NamedTempFile::new().unwrap(); + root_file.write_all(ca_a.as_bytes()).unwrap(); + let paths = vec![root_file.path().to_path_buf()]; + let provider = crypto::provider(); + + let custom = CustomRoots::new(paths.clone()).unwrap(); + let initial = Connect::build_root_server_verifier(&custom.current(), false, &provider).unwrap(); + let reload_custom = custom.clone(); + let reload_provider = provider.clone(); + let server_verifier = ReloadingServerVerifier::new(&paths, initial, move || { + let roots = reload_custom.load()?; + let verifier = Connect::build_root_server_verifier(&roots, false, &reload_provider)?; + reload_custom.replace(roots); + Ok(verifier) + }); + + let initial = Listen::build_client_verifier(&paths, &provider).unwrap(); + let reload_paths = paths.clone(); + let reload_provider = provider.clone(); + let client_verifier = ReloadingClientVerifier::new(&paths, initial, move || { + Listen::build_client_verifier(&reload_paths, &reload_provider) + }); + + let name = ServerName::try_from("localhost").unwrap(); + let now = UnixTime::now(); + assert!( + server_verifier + .verify_server_cert(&server_a, &[], &name, &[], now) + .is_ok() + ); + assert!( + server_verifier + .verify_server_cert(&server_b, &[], &name, &[], now) + .is_err() + ); + assert!(client_verifier.verify_client_cert(&client_a, &[], now).is_ok()); + assert!(client_verifier.verify_client_cert(&client_b, &[], now).is_err()); + + std::fs::write(root_file.path(), ca_b).unwrap(); + server_verifier.inner.state.reload(); + client_verifier.inner.state.reload(); + + assert!( + server_verifier + .verify_server_cert(&server_a, &[], &name, &[], now) + .is_err() + ); + assert!(client_verifier.verify_client_cert(&client_a, &[], now).is_err()); + + // A malformed replacement must not erase the last valid verifier. + std::fs::write(root_file.path(), "not a PEM certificate").unwrap(); + server_verifier.inner.state.reload(); + client_verifier.inner.state.reload(); + assert!( + server_verifier + .verify_server_cert(&server_b, &[], &name, &[], now) + .is_ok() + ); + assert!(client_verifier.verify_client_cert(&client_b, &[], now).is_ok()); + } + + #[cfg(all(feature = "quiche", feature = "watch"))] + #[test] + fn custom_root_refresh_retains_last_valid_bundle() { + use std::io::Write; + + let (ca_a, _, _, _, _) = signed_certificates(); + let (ca_b, _, _, _, _) = signed_certificates(); + let mut root_file = tempfile::NamedTempFile::new().unwrap(); + root_file.write_all(ca_a.as_bytes()).unwrap(); + let roots = CustomRoots::new(vec![root_file.path().to_path_buf()]).unwrap(); + let initial = roots.current(); + + std::fs::write(root_file.path(), "not a PEM certificate").unwrap(); + assert_eq!(roots.refresh(), initial); + + std::fs::write( + root_file.path(), + "-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----\n", + ) + .unwrap(); + assert_eq!(roots.refresh(), initial); + + std::fs::write(root_file.path(), ca_b).unwrap(); + let rotated = roots.refresh(); + assert_ne!(rotated, initial); + assert_eq!(roots.current(), rotated); + } + + #[cfg(all(feature = "quiche", feature = "watch"))] + #[test] + fn custom_root_refresh_serializes_cache_updates() { + let (ca_a, _, _, _, _) = signed_certificates(); + let (ca_b, _, _, _, _) = signed_certificates(); + let (ca_c, _, _, _, _) = signed_certificates(); + let parse = |pem: &str| { + CertificateDer::pem_slice_iter(pem.as_bytes()) + .collect::, _>>() + .unwrap() + }; + let initial = parse(&ca_a); + let bundle_b = parse(&ca_b); + let bundle_c = parse(&ca_c); + let roots = CustomRoots { + paths: Vec::new(), + current: Arc::new(RwLock::new(initial)), + }; + + let (first_loaded_tx, first_loaded_rx) = std::sync::mpsc::sync_channel(0); + let (release_first_tx, release_first_rx) = std::sync::mpsc::sync_channel(0); + let first_roots = roots.clone(); + let first = std::thread::spawn(move || { + first_roots.refresh_with(|| { + first_loaded_tx.send(()).unwrap(); + release_first_rx.recv().unwrap(); + Ok(bundle_b) + }) + }); + first_loaded_rx.recv().unwrap(); + + let (second_ready_tx, second_ready_rx) = std::sync::mpsc::sync_channel(0); + let (second_loaded_tx, second_loaded_rx) = std::sync::mpsc::channel(); + let second_roots = roots.clone(); + let expected = bundle_c.clone(); + let second = std::thread::spawn(move || { + second_ready_tx.send(()).unwrap(); + second_roots.refresh_with(|| { + second_loaded_tx.send(()).unwrap(); + Ok(bundle_c) + }) + }); + second_ready_rx.recv().unwrap(); + let overlapped = second_loaded_rx + .recv_timeout(std::time::Duration::from_millis(100)) + .is_ok(); + + release_first_tx.send(()).unwrap(); + first.join().unwrap(); + second.join().unwrap(); + assert!(!overlapped, "root cache refresh transactions must not overlap"); + assert_eq!(roots.current(), expected); + } + #[test] fn build_uses_platform_verifier_by_default() { // No custom roots, system trust on: resolves to the OS platform verifier diff --git a/rs/moq-native/src/watch.rs b/rs/moq-native/src/watch.rs index 6e6f7105e5..a0971dc582 100644 --- a/rs/moq-native/src/watch.rs +++ b/rs/moq-native/src/watch.rs @@ -1,4 +1,4 @@ -//! Watch on-disk files (TLS certs/keys) and get notified when they're rotated. +//! Watch on-disk TLS certificates, keys, and root CAs for rotation. use std::path::{Path, PathBuf}; @@ -34,33 +34,10 @@ impl FileWatcher { // notify emits per change (and any unrelated churn in the directory): a // full buffer already has a pending wakeup, so extra sends are dropped. let (tx, rx) = mpsc::channel(1); - let mut watcher = notify::recommended_watcher(move |res: notify::Result| { - let send = match res { - Ok(event) => is_reload_trigger(&event.kind), - // A watcher error (e.g. inotify queue overflow) may mean we missed a - // real change, so reload to be safe. - Err(_) => true, - }; - if send { - let _ = tx.try_send(()); - } + let watcher = callback(paths, move || { + let _ = tx.try_send(()); })?; - // Watch each distinct parent directory once. A bare filename like - // `cert.pem` has an empty-string parent (`Some("")`, not `None`), which the - // OS watcher rejects with "No path was found", so map that to the current - // directory. - let mut dirs: Vec<&Path> = paths - .iter() - .filter_map(|p| p.parent()) - .map(|p| if p.as_os_str().is_empty() { Path::new(".") } else { p }) - .collect(); - dirs.sort_unstable(); - dirs.dedup(); - for dir in dirs { - watcher.watch(dir, notify::RecursiveMode::NonRecursive)?; - } - Ok(Self { _watcher: watcher, events: rx, @@ -80,6 +57,43 @@ impl FileWatcher { } } +/// Watch `paths` and invoke `changed` after a write or atomic replacement. +/// +/// The callback runs on notify's worker thread, so it must finish promptly. +pub(crate) fn callback( + paths: &[PathBuf], + mut changed: impl FnMut() + Send + 'static, +) -> notify::Result { + let mut watcher = notify::recommended_watcher(move |res: notify::Result| { + let reload = match res { + Ok(event) => is_reload_trigger(&event.kind), + // A watcher error (e.g. inotify queue overflow) may mean we missed a + // real change, so reload to be safe. + Err(_) => true, + }; + if reload { + changed(); + } + })?; + + // Watch each distinct parent directory once. A bare filename like + // `cert.pem` has an empty-string parent (`Some("")`, not `None`), which the + // OS watcher rejects with "No path was found", so map that to the current + // directory. + let mut dirs: Vec<&Path> = paths + .iter() + .filter_map(|p| p.parent()) + .map(|p| if p.as_os_str().is_empty() { Path::new(".") } else { p }) + .collect(); + dirs.sort_unstable(); + dirs.dedup(); + for dir in dirs { + watcher.watch(dir, notify::RecursiveMode::NonRecursive)?; + } + + Ok(watcher) +} + /// Whether a raw notify event reflects a real change that should trigger a reload. /// /// The reload path opens and reads the watched files, and notify's inotify backend diff --git a/rs/moq-net/CHANGELOG.md b/rs/moq-net/CHANGELOG.md index fbb60450c6..3809a1be58 100644 --- a/rs/moq-net/CHANGELOG.md +++ b/rs/moq-net/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.12](https://github.com/moq-dev/moq/compare/moq-net-v0.2.11...moq-net-v0.2.12) - 2026-08-14 + +### Fixed + +- *(path)* resolve catalog references like URLs ([#2855](https://github.com/moq-dev/moq/pull/2855)) +- *(moq-net)* serve an IETF subscribe from the live edge ([#2862](https://github.com/moq-dev/moq/pull/2862)) +- *(net)* stop blocking connect on the initial announce set ([#2856](https://github.com/moq-dev/moq/pull/2856)) +- *(net)* wake a capped subscriber when its parked group is evicted ([#2844](https://github.com/moq-dev/moq/pull/2844)) +- *(net)* decode largest object in subscribe ok ([#2837](https://github.com/moq-dev/moq/pull/2837)) + ## [0.2.11](https://github.com/moq-dev/moq/compare/moq-net-v0.2.10...moq-net-v0.2.11) - 2026-08-13 ### Added diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index 931bb074ec..939cdab2a6 100644 --- a/rs/moq-net/Cargo.toml +++ b/rs/moq-net/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.2.11" +version = "0.2.12" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index bd9ff9d3fb..5267214b48 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -284,19 +284,13 @@ impl Client { peer_setup: None, })?; - // Block until the initial announce set has landed (Lite05+ reports it - // via AnnounceOk + N), so a `request_broadcast()` for a live path resolves - // immediately instead of racing announcement gossip. - let (session, mut driver) = Session::new( + return Ok(Session::new( session, version.into(), start.recv_bandwidth, start.driver, start.goaway, - ); - driver.wait_ready(|waiter| start.connecting.poll_ready(waiter)).await; - - return Ok((session, driver)); + )); } Some(ALPN_LITE_04) => { self.versions @@ -314,17 +308,13 @@ impl Client { peer_setup: None, })?; - // Lite04 has no initial-set boundary, so this resolves immediately. - let (session, mut driver) = Session::new( + return Ok(Session::new( session, lite::Version::Lite04.into(), start.recv_bandwidth, start.driver, start.goaway, - ); - driver.wait_ready(|waiter| start.connecting.poll_ready(waiter)).await; - - return Ok((session, driver)); + )); } Some(ALPN_LITE_03) => { self.versions @@ -343,17 +333,13 @@ impl Client { peer_setup: None, })?; - // Lite03 has no initial-set boundary, so this resolves immediately. - let (session, mut driver) = Session::new( + return Ok(Session::new( session, lite::Version::Lite03.into(), start.recv_bandwidth, start.driver, start.goaway, - ); - driver.wait_ready(|waiter| start.connecting.poll_ready(waiter)).await; - - return Ok((session, driver)); + )); } Some(ALPN_LITE) | None => { let supported = self.versions.filter(&NEGOTIATED.into()).ok_or(Error::Version)?; @@ -392,7 +378,7 @@ impl Client { .copied() .ok_or(Error::Version)?; - let (recv_bw, protocol, connecting, goaway) = match version { + let (recv_bw, protocol, goaway) = match version { Version::Lite(v) => { let stream = stream.with_version(v); let start = lite::start(lite::Config { @@ -408,7 +394,7 @@ impl Client { peer_setup: None, })?; - (start.recv_bandwidth, start.driver, Some(start.connecting), start.goaway) + (start.recv_bandwidth, start.driver, start.goaway) } Version::Ietf(v) => { // Decode the parameters to get the initial request ID and what the server @@ -438,18 +424,11 @@ impl Client { peer_setup_stream: None, peer_declared: Some(peer_declared), })?; - (None, protocol, None, goaway) + (None, protocol, goaway) } }; - let (session, mut driver) = Session::new(session, version, recv_bw, protocol, goaway); - if let Some(connecting) = connecting { - // Block until the initial announce set has landed (for versions that - // report one); resolves immediately otherwise. - driver.wait_ready(|waiter| connecting.poll_ready(waiter)).await; - } - - Ok((session, driver)) + Ok(Session::new(session, version, recv_bw, protocol, goaway)) } } @@ -698,7 +677,10 @@ mod tests { .into(), ); - let _connection = client.connect(fake.clone()).await.unwrap(); + // `connect` returns as soon as the handshake completes and never polls the driver, + // so the session makes no progress (and never closes) unless we drive it here. + let (_session, driver) = client.connect(fake.clone()).await.unwrap(); + let _driver = tokio::spawn(driver); // Verify the client setup was encoded using Draft14 framing (ALPN_LITE fallback path). let mut setup_bytes = Bytes::from(fake.control_writes()); @@ -725,6 +707,32 @@ mod tests { assert_ne!(code, SessionError::Version.to_code(), "SessionInfo failed to decode"); } + /// `connect` must not depend on the peer answering. A peer that opens the announce + /// stream and then says nothing (or promises a count it never delivers) used to hold + /// `connect` for the life of the session, since it waited for the initial announce + /// set. Resolving a path you need is `announced_broadcast`'s job, which waits for + /// that path rather than for the peer to finish talking. + #[tokio::test(start_paused = true)] + async fn connect_does_not_wait_for_the_peer_to_announce() { + // Serves bidi streams, so the announce stream opens, and never answers on them. + let gate = kio::Producer::new(true); + let transport = crate::lite::test_transport::SinkSession::gated_bi(gate.consume()) + .with_protocol(crate::version::ALPN_LITE_05); + + // A subscribe origin is what makes the client open an announce stream at all. + let origin = crate::origin::Info::new(crate::Origin::new(1).unwrap()).produce(); + let client = Client::new() + .with_versions([Version::Lite(lite::Version::Lite05)].into()) + .with_subscriber(origin); + + // Paused time auto-advances while every task is idle, so a `connect` that waits + // on the silent peer trips this rather than hanging the suite. + tokio::time::timeout(std::time::Duration::from_secs(30), client.connect(transport)) + .await + .expect("connect waited on a peer that never announced") + .expect("connect failed"); + } + #[tokio::test(start_paused = true)] async fn alpn_lite_falls_back_to_draft14_and_switches_version_post_setup() { run_alpn_lite_fallback_case(Some(ALPN_LITE)).await; diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 88f324f077..b177b25666 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -1398,7 +1398,14 @@ struct TrackServe { } impl TrackServe { - fn new(session: S, track: track::Subscriber, request_id: RequestId, version: Version) -> Self { + fn new(session: S, mut track: track::Subscriber, request_id: RequestId, version: Version) -> Self { + // A fresh cursor starts at the oldest cached group, so leaving it there replays the + // whole retained history at once, one concurrent stream per group. LargestObject is + // the only filter we accept and it means the live edge. + if let Some(latest) = track.latest() { + track.start_at(latest); + } + Self { session, track, @@ -1690,6 +1697,39 @@ mod group_priority_test { } } +#[cfg(test)] +mod subscribe_cursor_test { + use super::*; + use crate::lite::test_transport::{Log, SinkSession}; + + /// A subscription's cursor starts at the oldest cached group, so serving it verbatim + /// replays every retained group at once, each on its own stream. Relays reject the burst + /// and players skip straight back to the live edge, so the catch-up is pure waste. + #[tokio::test] + async fn a_subscribe_is_served_from_the_live_edge() { + let log = Log::default(); + let session = SinkSession::new(log.clone()); + + let mut track = track::Producer::new(std::sync::Arc::new(crate::broadcast::Info::default()), "video", None); + for sequence in 0..4 { + let mut group = track.create_group(group::Info { sequence }).unwrap(); + group + .write_frame(crate::Timestamp::from_millis(0).unwrap(), b"frame".as_slice()) + .unwrap(); + group.finish().unwrap(); + } + + let subscriber = track.subscribe(None); + track.finish().unwrap(); + + let mut serve = TrackServe::new(session, subscriber, RequestId(1), Version::Draft16); + kio::wait(|waiter| serve.poll(waiter)).await.unwrap(); + + // `GroupServe` sets the priority once per stream it opens, so this counts groups served. + assert_eq!(log.priorities().len(), 1, "only group 3 should have been served"); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/rs/moq-net/src/lite/connecting.rs b/rs/moq-net/src/lite/connecting.rs deleted file mode 100644 index b02cc3fd0e..0000000000 --- a/rs/moq-net/src/lite/connecting.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Tracks a session's connection progress so `connect()` can block until it's done. -//! -//! Today "connecting" means every announce-prefix stream has received its initial -//! set (AnnounceInit for Lite01/02, AnnounceOk + N for Lite05). It's deliberately -//! generic so future work (e.g. extension negotiation) can register additional -//! steps that must finish before a session is considered connected. -//! -//! Backed by `kio`: each in-flight step holds a [`ConnectingProducer`], and the -//! session is connected once they've all been dropped (which closes the channel). -//! A step drops its producer when it finishes (or, on an early error, when it goes -//! out of scope), so a failed step can't hang `connect()`. Prefer `kio` over `tokio` -//! primitives for new async state so the synchronous poll API stays available. - -use std::task::Poll; - -use kio::{Consumer, Producer, Waiter}; - -/// Producer side: hold one per in-flight connection step (e.g. one per announce -/// prefix). Clone it to add a step; drop it to mark that step done. The session is -/// connected once every producer has been dropped. -/// -/// The inner producer exists purely for its `Clone` (adds a step) and `Drop` (closes -/// the channel when the last one goes); it is never read, hence the allow. -#[derive(Clone)] -pub(super) struct ConnectingProducer(#[allow(dead_code)] Producer<()>); - -/// Consumer side: returned by [`crate::lite::start`] and awaited by `connect()`. -pub(crate) struct Connecting(Consumer<()>); - -impl Connecting { - /// Create a producer/consumer pair. The consumer reports "connected" once every - /// [`ConnectingProducer`] (the original plus any clones) has been dropped. - pub(super) fn new() -> (ConnectingProducer, Self) { - let producer = Producer::new(()); - let consumer = producer.consume(); - (ConnectingProducer(producer), Self(consumer)) - } - - /// Poll for connection completion: ready once every step's producer has dropped. - pub(crate) fn poll_ready(&self, waiter: &Waiter) -> Poll<()> { - self.0.poll_closed(waiter) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ready_once_every_producer_dropped() { - let (producer, connecting) = Connecting::new(); - let noop = kio::Waiter::noop(); - let second = producer.clone(); - assert!( - connecting.poll_ready(&noop).is_pending(), - "not ready while a producer lives" - ); - drop(producer); - assert!(connecting.poll_ready(&noop).is_pending(), "still a clone outstanding"); - drop(second); - assert!( - connecting.poll_ready(&noop).is_ready(), - "ready once the last producer drops" - ); - } - - #[test] - fn ready_when_sole_producer_dropped() { - // No steps registered (a version with no initial-set boundary, or an empty - // origin): dropping the only producer resolves immediately. - let (producer, connecting) = Connecting::new(); - drop(producer); - assert!(connecting.poll_ready(&kio::Waiter::noop()).is_ready()); - } -} diff --git a/rs/moq-net/src/lite/mod.rs b/rs/moq-net/src/lite/mod.rs index 3a2979685e..bafa81b58c 100644 --- a/rs/moq-net/src/lite/mod.rs +++ b/rs/moq-net/src/lite/mod.rs @@ -5,7 +5,6 @@ //! Specification: [] mod announce; -mod connecting; mod datagram; mod fetch; mod goaway; @@ -27,7 +26,6 @@ mod track; mod version; pub use announce::*; -pub(crate) use connecting::*; #[allow(unused_imports)] pub use datagram::*; #[allow(unused_imports)] diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index 98e0c999df..257370ec7d 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -9,13 +9,11 @@ use crate::{ use std::task::{Context, Poll, ready}; use super::{ - Connecting, DataType, PeerSetup, Publisher, PublisherConfig, Setup, Subscriber, SubscriberConfig, SubscriberDriver, - Version, + DataType, PeerSetup, Publisher, PublisherConfig, Setup, Subscriber, SubscriberConfig, SubscriberDriver, Version, }; pub(crate) struct SessionStart { pub recv_bandwidth: Option, - pub connecting: Connecting, pub driver: MaybeSendBox<'static, Result<(), Error>>, /// The session-side GOAWAY halves, stored on the public [`crate::Session`]. pub goaway: crate::goaway::Handle, @@ -88,10 +86,7 @@ pub struct Config { /// Start a lite session. /// -/// Returns the receive-bandwidth consumer (if any) and a [`Connecting`] handle that -/// becomes ready once the initial announce set has been inserted into the subscribe -/// origin, letting `connect()` block past the startup race. It is ready immediately -/// when there is nothing to wait on (a version without an initial-set boundary). +/// Returns the receive-bandwidth consumer (if any) plus the driver that runs the session. pub fn start(config: Config) -> Result { let Config { mut session, @@ -116,18 +111,6 @@ pub fn start(config: Config) -> Result Some(recv_bw), }; - // Connection-progress tracker. Only block on the initial set for versions with an - // initial-set boundary (AnnounceInit for Lite01/02, AnnounceOk for Lite05+). For other - // versions we drop the producer here, which closes the channel and makes - // `Connecting::ready` resolve immediately. An empty subscribe origin also resolves - // immediately because the subscriber arms with a prefix count of zero. - let (connecting_producer, connecting) = Connecting::new(); - let sub_connecting = if matches!(version, Version::Lite01 | Version::Lite02) || version.has_announce_ok() { - Some(connecting_producer) - } else { - None - }; - // Declare our origin (hop) id in SETUP so the peer can serve our // subscriptions from a route that does not flow through us. Taken from the // caller's real handles before the empty-half defaulting below, since those @@ -146,8 +129,7 @@ pub fn start(config: Config) -> Result(config: Config) -> Result(config: Config) -> Result Subscriber { &mut run.routes, )?; } - // The first `initial_count` Active messages are the initial set; once - // they're all in, the caller drops its producer to mark this prefix - // connected. - run.initial_remaining = run.initial_remaining.saturating_sub(1); } lite::AnnounceBroadcast::Ended { suffix, .. } => { let path = prefix.join(&suffix); @@ -464,11 +460,6 @@ impl Subscriber { /// error ends it. pub(super) struct SubscriberDriver { subscriber: Subscriber, - /// Our own clone of the connection-progress producer, dropped on the first - /// poll. Holding it until then keeps `Connecting` pending so `connect()` - /// drives the driver at least once, exactly like the old announce task; the - /// per-prefix clones then own the boundary. - connecting: Option, /// One machine per permitted prefix. Only an error ends the session; a /// prefix finishing cleanly (publisher FIN) just retires. prefixes: Vec>, @@ -482,22 +473,17 @@ pub(super) struct SubscriberDriver { } impl SubscriberDriver { - /// `connecting` is the connection-progress producer for this session (None for - /// versions with no initial-set boundary). Each prefix holds its own clone and - /// drops it once its initial set is in; with no prefixes it drops here, so the - /// session is connected now. - pub fn new(subscriber: Subscriber, connecting: Option) -> Self { + pub fn new(subscriber: Subscriber) -> Self { let prefixes = subscriber .origin .allowed() .map(|p| p.to_owned()) .collect::>() .into_iter() - .map(|prefix| AnnouncePrefix::new(subscriber.clone(), prefix, connecting.clone())) + .map(|prefix| AnnouncePrefix::new(subscriber.clone(), prefix)) .collect(); Self { - connecting, prefixes, uni: UniAccept::new(subscriber.clone()), bandwidth: Some(RecvBandwidth::new(subscriber.clone())), @@ -508,10 +494,6 @@ impl SubscriberDriver { } pub fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { - // Each prefix holds its own clone; with no prefixes, this drop is what - // marks the session connected. - self.connecting.take(); - let mut i = 0; while i < self.prefixes.len() { match self.prefixes[i].poll(waiter) { @@ -1079,9 +1061,6 @@ impl ProbeStream { struct AnnouncePrefix { subscriber: Subscriber, prefix: PathOwned, - // Dropped once this prefix's initial set is in (or on any exit), so a failed - // prefix can't hang connect(). - connecting: Option, state: PrefixState, } @@ -1096,7 +1075,6 @@ enum PrefixState { Cost { stream: Stream, responder_origin: Option, - initial_count: u64, }, /// Lite01/02: reading the ANNOUNCE_INIT set. ReadInit { stream: Stream, run: PrefixRun }, @@ -1111,10 +1089,6 @@ struct PrefixRun { /// it comes from the connect config or the peer's SETUP, neither of which /// changes for the life of the session. link_cost: u64, - /// The first `initial_count` Active messages are the initial set; once - /// they're all in, the connecting producer drops to mark this prefix - /// connected. - initial_remaining: u64, routes: HashMap, // Lite06+: announce ids. Each received `active` implicitly assigns the next // per-stream ordinal; `ended`/`restart` reference it instead of repeating the @@ -1126,11 +1100,10 @@ struct PrefixRun { } impl AnnouncePrefix { - fn new(subscriber: Subscriber, prefix: PathOwned, connecting: Option) -> Self { + fn new(subscriber: Subscriber, prefix: PathOwned) -> Self { Self { subscriber, prefix, - connecting, state: PrefixState::Open, } } @@ -1169,15 +1142,16 @@ impl AnnouncePrefix { false => PrefixState::Cost { stream, responder_origin: None, - initial_count: 0, }, }; } PrefixState::ReadOk { stream } => { - // Lite05+: the publisher reports its own origin id (which we stamp onto - // every received Announce's hop chain, since it no longer does so - // itself) plus the count of initial active announces that follow - // immediately. + // Lite05+: the publisher reports its own origin id, which we stamp onto + // every received Announce's hop chain since it no longer does so itself. + // Its `active` count marks where the initial set ends; nothing here needs + // that boundary, so it is read and dropped. Callers that must not race an + // announcement use `origin::Consumer::announced_broadcast`, which waits + // for the path itself. let ok = ready!(stream.reader.poll_decode::(&mut cx))?; // A peer may legally report id 0 (no identity). When the caller assigned // it one, stand that in so the route isn't loop-blind. @@ -1191,7 +1165,6 @@ impl AnnouncePrefix { self.state = PrefixState::Cost { stream, responder_origin: Some(origin), - initial_count: ok.active, }; } PrefixState::Cost { .. } => { @@ -1199,41 +1172,26 @@ impl AnnouncePrefix { let PrefixState::Cost { stream, responder_origin, - initial_count, } = std::mem::replace(&mut self.state, PrefixState::Open) else { unreachable!() }; - let mut run = PrefixRun { + let run = PrefixRun { responder_origin, link_cost, - initial_remaining: 0, routes: HashMap::new(), next_announce_id: 0, announced_by_id: HashMap::new(), }; - // Release the producer once this prefix's initial set is in. Lite01/02 - // deliver it via ANNOUNCE_INIT (the ReadInit state); Lite05 delivers - // `initial_count` Announce::Active counted in the run loop; Lite03/04 - // have no boundary. - match self.subscriber.version { - Version::Lite01 | Version::Lite02 => { - self.state = PrefixState::ReadInit { stream, run }; - } - _ if self.subscriber.version.has_announce_ok() => { - if initial_count == 0 { - self.connecting.take(); - } - run.initial_remaining = initial_count; - self.state = PrefixState::Run { stream, run }; - } - _ => { - self.connecting.take(); - self.state = PrefixState::Run { stream, run }; - } - } + // Lite01/02 send the initial set as one ANNOUNCE_INIT message, so they + // read that before the update stream. Every other version streams it as + // ordinary announces. + self.state = match self.subscriber.version { + Version::Lite01 | Version::Lite02 => PrefixState::ReadInit { stream, run }, + _ => PrefixState::Run { stream, run }, + }; } PrefixState::ReadInit { stream, run } => { let msg = ready!(stream.reader.poll_decode::(&mut cx))?; @@ -1251,7 +1209,6 @@ impl AnnouncePrefix { &mut run.routes, )?; } - self.connecting.take(); let PrefixState::ReadInit { stream, run } = std::mem::replace(&mut self.state, PrefixState::Open) else { unreachable!() @@ -1273,9 +1230,6 @@ impl AnnouncePrefix { return Poll::Ready(Ok(())); }; self.subscriber.handle_announce(&self.prefix, announce, run)?; - if run.initial_remaining == 0 { - self.connecting.take(); - } } } } diff --git a/rs/moq-net/src/lite/test_transport.rs b/rs/moq-net/src/lite/test_transport.rs index 6511d405a7..6b3dd8c76f 100644 --- a/rs/moq-net/src/lite/test_transport.rs +++ b/rs/moq-net/src/lite/test_transport.rs @@ -313,11 +313,24 @@ pub struct SinkSession { /// Set by [`Self::gated_bi`]. `None` parks `open_bi` itself forever, which is all /// a test driving only uni streams needs. bi_gate: Option>, + /// The ALPN to report, for a test that needs a specific negotiated version rather + /// than the SETUP-negotiated fallback an absent one selects. + protocol: Option<&'static str>, } impl SinkSession { pub fn new(log: Log) -> Self { - Self { log, bi_gate: None } + Self { + log, + bi_gate: None, + protocol: None, + } + } + + /// Report `protocol` as the negotiated ALPN. + pub fn with_protocol(mut self, protocol: &'static str) -> Self { + self.protocol = Some(protocol); + self } /// Serve bidi streams, holding every write until `gate` flips to true. @@ -329,6 +342,7 @@ impl SinkSession { Self { log: Log::default(), bi_gate: Some(gate), + protocol: None, } } } @@ -378,7 +392,7 @@ impl poll::Session for SinkSession { } fn protocol(&self) -> Option<&str> { - None + self.protocol } fn close(&mut self, code: u32, reason: &str) { diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 04d66605ce..cad81439e7 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -2669,11 +2669,10 @@ impl Consumer { /// is closed before the broadcast is announced. The returned broadcast may itself be closed /// later. Subscribers should watch [`broadcast::Consumer::closed`] to react to that. /// - /// Prefer this over [`Self::request_broadcast`] when you know the exact path you want but - /// cannot guarantee the announcement has already been received. With moq-lite-05 (and - /// the older Lite01/02) `connect()` already blocks until the initial announce set lands, - /// so [`Self::request_broadcast`] is race-free for broadcasts that were live at connect time; - /// this method is still needed to wait for a broadcast that comes online *after* connect. + /// Use this whenever you know the exact path you want and cannot guarantee its + /// announcement has already arrived, which includes every path you resolve right after + /// connecting: [`Self::request_broadcast`] answers on the spot, so asking it first + /// races the announcement and reports a live broadcast as unroutable. pub async fn announced_broadcast(&self, path: impl AsPath) -> Option { let path = path.as_path(); diff --git a/rs/moq-net/src/path.rs b/rs/moq-net/src/path.rs index 68a41c58e2..a4e32be5e5 100644 --- a/rs/moq-net/src/path.rs +++ b/rs/moq-net/src/path.rs @@ -324,37 +324,69 @@ impl<'a> Path<'a> { /// Resolve a [`PathRelative`] against this path. /// - /// `..` segments in `rel` pop the last segment of the base; other segments are appended. + /// A non-empty reference replaces the last segment of the base, matching relative URL + /// resolution. `..` segments then pop another segment; other segments are appended. + /// Excess `..` is a no-op once the base is empty (subsequent named segments still append). /// An empty `rel` returns this path as an owned copy. /// - /// Returns `None` when a `..` has nothing left to pop, i.e. the reference walks above the - /// root and names nothing. Popping to exactly the root is fine and yields the empty path, - /// which still names a broadcast. The alternative, clamping, would silently land - /// `../../../../x` on an unrelated `x`, so the caller decides what an out-of-range - /// reference means (the hang catalog rejects such a catalog outright). - /// - /// [`PathRelative::new`] strips `.` and empty segments, so they are not handled here. + /// [`PathRelative::new`] strips empty and redundant `.` segments, but preserves a lone `.` + /// so it can reference the base's parent. /// /// # Examples /// ``` /// use moq_net::{Path, PathRelative}; /// /// let base = Path::new("a/b/c"); - /// assert_eq!(base.resolve(&PathRelative::new("../d")).unwrap().as_str(), "a/b/d"); - /// assert_eq!(base.resolve(&PathRelative::new("d")).unwrap().as_str(), "a/b/c/d"); - /// assert_eq!(base.resolve(&PathRelative::new("../../..")).unwrap().as_str(), ""); - /// assert_eq!(base.resolve(&PathRelative::new("../../../../x")), None); + /// assert_eq!(base.resolve(&PathRelative::new("./d")).as_str(), "a/b/d"); + /// assert_eq!(base.resolve(&PathRelative::new(".")).as_str(), "a/b"); + /// assert_eq!(base.resolve(&PathRelative::new("../d")).as_str(), "a/d"); /// ``` - pub fn resolve(&self, rel: &PathRelative<'_>) -> Option { + pub fn resolve(&self, rel: &PathRelative<'_>) -> PathOwned { + if rel.is_empty() { + return self.to_owned(); + } + + let mut segments: Vec<&str> = self.parts().collect(); + segments.pop(); + + for seg in rel.as_str().split('/') { + if seg == "." { + continue; + } else if seg == ".." { + segments.pop(); + } else { + segments.push(seg); + } + } + + let path = segments.join("/"); + if path.is_empty() { + Path::empty() + } else { + Path(Repr::Shared { + buf: path.into(), + start: 0, + }) + } + } + + /// Resolve a [`PathRelative`], returning `None` if it escapes above the root. + /// + /// Unlike [`Path::resolve`], this distinguishes a valid reference to the empty root + /// path from excess `..` segments. Use it when an untrusted relative reference must + /// not be clamped to the root. + pub fn try_resolve(&self, rel: &PathRelative<'_>) -> Option { if rel.is_empty() { return Some(self.to_owned()); } let mut segments: Vec<&str> = self.parts().collect(); + segments.pop(); for seg in rel.as_str().split('/') { - if seg == ".." { - // Nothing left to pop: the reference walks above the root. + if seg == "." { + continue; + } else if seg == ".." { segments.pop()?; } else { segments.push(seg); @@ -363,13 +395,13 @@ impl<'a> Path<'a> { let path = segments.join("/"); if path.is_empty() { - return Some(Path::empty()); + Some(Path::empty()) + } else { + Some(Path(Repr::Shared { + buf: path.into(), + start: 0, + })) } - - Some(Path(Repr::Shared { - buf: path.into(), - start: 0, - })) } } @@ -482,30 +514,29 @@ pub type PathRelativeOwned = PathRelative<'static>; /// A relative broadcast path, used to reference one broadcast from another broadcast's content. /// /// Unlike [`Path`] (which is a complete reference within the broadcast namespace), -/// `PathRelative` may contain `..` segments to walk up the namespace and is meaningful only -/// when resolved against a base [`Path`] via [`Path::resolve`]. The hang catalog uses it to -/// point a rendition at a track published in a sibling broadcast (e.g. `../source`). +/// `PathRelative` may contain `.` and `..` segments to walk the namespace and is meaningful +/// only when resolved against a base [`Path`] via [`Path::resolve`]. The hang catalog uses it +/// to point a rendition at a track published in a sibling broadcast (e.g. `./source`). /// /// `PathRelative` has no `Encode`/`Decode` impl, so it never appears in announce/subscribe /// frames. It does serialize via serde for off-wire use (e.g. as a field inside a catalog /// JSON payload, which itself travels as a track). /// /// Normalization on creation: leading/trailing slashes are trimmed, consecutive internal -/// slashes collapse to one, and `.` segments are stripped (treated as no-ops, matching -/// POSIX). `..` is preserved and is interpreted at resolve time. +/// slashes collapse to one, and redundant `.` segments are stripped. A reference made only +/// of `.` segments normalizes to `.` rather than empty because `.` resolves to the base's +/// parent while empty resolves to the base itself. `..` is preserved for resolve time. /// /// # Examples /// ``` /// use moq_net::{Path, PathRelative}; /// -/// let rel = PathRelative::new("../source"); -/// assert_eq!(Path::new("a/b").resolve(&rel).unwrap().as_str(), "a/source"); +/// let rel = PathRelative::new("./source"); +/// assert_eq!(Path::new("a/b").resolve(&rel).as_str(), "a/source"); /// -/// // A reference that walks above the root names nothing. -/// assert_eq!(Path::new("a").resolve(&PathRelative::new("../../b")), None); -/// -/// // `.` segments are stripped on creation. +/// // Redundant `.` segments are stripped on creation. /// assert_eq!(PathRelative::new("./a/./b").as_str(), "a/b"); +/// assert_eq!(PathRelative::new(".").as_str(), "."); /// ``` #[derive(Debug, PartialEq, Eq, Hash, Clone, serde::Serialize)] pub struct PathRelative<'a>(Cow<'a, str>); @@ -514,7 +545,7 @@ impl<'a> PathRelative<'a> { /// Create a new `PathRelative` from a string slice. /// /// Leading and trailing slashes are trimmed, consecutive internal slashes collapse to one, - /// and `.` segments are stripped. See the type-level doc for the full normalization rules. + /// and redundant `.` segments are stripped. See the type-level doc for the full rules. pub fn new(s: &'a str) -> Self { let trimmed = s.trim_start_matches('/').trim_end_matches('/'); @@ -592,11 +623,17 @@ fn needs_normalize_relative(trimmed: &str) -> bool { } fn normalize_relative_segments(trimmed: &str) -> String { - trimmed + let segments = trimmed .split('/') .filter(|seg| !seg.is_empty() && *seg != ".") .collect::>() - .join("/") + .join("/"); + + if segments.is_empty() && trimmed.split('/').any(|seg| seg == ".") { + ".".to_string() + } else { + segments + } } impl Default for PathRelative<'_> { @@ -1370,80 +1407,89 @@ mod tests { } #[test] - fn test_path_relative_strips_dot_segments() { - assert_eq!(PathRelative::new(".").as_str(), ""); + fn test_path_relative_normalizes_dot_segments() { + assert_eq!(PathRelative::new(".").as_str(), "."); + assert_eq!(PathRelative::new("././").as_str(), "."); assert_eq!(PathRelative::new("./foo").as_str(), "foo"); assert_eq!(PathRelative::new("foo/./bar").as_str(), "foo/bar"); assert_eq!(PathRelative::new("./../foo").as_str(), "../foo"); // From takes the same normalization. assert_eq!(PathRelative::from("./foo".to_string()).as_str(), "foo"); - assert_eq!(PathRelative::from(".".to_string()).as_str(), ""); + assert_eq!(PathRelative::from(".".to_string()).as_str(), "."); } #[test] - fn test_resolve_appends_and_pops() { - let base = Path::new("a/b/c"); - assert_eq!(base.resolve(&PathRelative::new("d")).unwrap().as_str(), "a/b/c/d"); - assert_eq!(base.resolve(&PathRelative::new("d/e")).unwrap().as_str(), "a/b/c/d/e"); - assert_eq!(base.resolve(&PathRelative::new("../d")).unwrap().as_str(), "a/b/d"); - assert_eq!(base.resolve(&PathRelative::new("..")).unwrap().as_str(), "a/b"); - assert_eq!(base.resolve(&PathRelative::new("../../x/y")).unwrap().as_str(), "a/x/y"); + fn test_resolve_replaces_base_name() { + let base = Path::new("a/b"); + assert_eq!(base.resolve(&PathRelative::new("c")).as_str(), "a/c"); + assert_eq!(base.resolve(&PathRelative::new("c/d")).as_str(), "a/c/d"); + assert_eq!( + Path::new("foo.hang/catalog.pro") + .resolve(&PathRelative::new("./transcode.pro")) + .as_str(), + "foo.hang/transcode.pro" + ); } #[test] fn test_resolve_empty_rel_returns_base() { let base = Path::new("a/b"); - assert_eq!(base.resolve(&PathRelative::empty()).unwrap().as_str(), "a/b"); - assert_eq!(Path::empty().resolve(&PathRelative::empty()).unwrap(), Path::empty()); + assert_eq!(base.resolve(&PathRelative::new("")).as_str(), "a/b"); } #[test] - fn test_resolve_rejects_escape() { + fn test_resolve_single_dotdot() { let base = Path::new("a/b/c"); - // One more `..` than the base has segments. Clamping would land this on "x". - assert_eq!(base.resolve(&PathRelative::new("../../../../x")), None); - assert_eq!(base.resolve(&PathRelative::new("../../../..")), None); - // A `..` that escapes mid-way is rejected even if later segments walk back down. - assert_eq!(base.resolve(&PathRelative::new("../../../../a/b/c")), None); - assert_eq!(Path::empty().resolve(&PathRelative::new("../x")), None); + assert_eq!(base.resolve(&PathRelative::new("../d")).as_str(), "a/d"); + assert_eq!(base.resolve(&PathRelative::new("..")).as_str(), "a"); } #[test] - fn test_resolve_allows_root() { - // Popping to exactly the root stops at it rather than walking above it, and the - // root still names a broadcast. - assert_eq!(Path::new("a").resolve(&PathRelative::new("..")).unwrap(), Path::empty()); - assert_eq!( - Path::new("a/b").resolve(&PathRelative::new("../..")).unwrap(), - Path::empty() - ); - assert_eq!( - Path::new("a/b") - .resolve(&PathRelative::new("../../x")) - .unwrap() - .as_str(), - "x" - ); - assert_eq!( - Path::empty().resolve(&PathRelative::new("foo")).unwrap().as_str(), - "foo" - ); + fn test_resolve_multiple_dotdot() { + let base = Path::new("a/b/c"); + assert_eq!(base.resolve(&PathRelative::new("../../x")).as_str(), "x"); + assert_eq!(base.resolve(&PathRelative::new("../../../x")).as_str(), "x"); + } + + #[test] + fn test_resolve_dotdot_clamps_at_root() { + let base = Path::new("a"); + // Excess `..` clamps at the root instead of escaping it. + assert_eq!(base.resolve(&PathRelative::new("../../../foo")).as_str(), "foo"); + assert_eq!(base.resolve(&PathRelative::new("..")).as_str(), ""); } #[test] - fn test_resolve_dot_is_noop() { + fn test_resolve_empty_base() { + let base = Path::empty(); + assert_eq!(base.resolve(&PathRelative::new("foo")).as_str(), "foo"); + assert_eq!(base.resolve(&PathRelative::new("..")).as_str(), ""); + } + + #[test] + fn test_resolve_dot_names_parent() { let base = Path::new("a/b"); - // `.` is normalized away by PathRelative::new, so resolve ignores it. - assert_eq!(base.resolve(&PathRelative::new(".")).unwrap().as_str(), "a/b"); - assert_eq!(base.resolve(&PathRelative::new("./c")).unwrap().as_str(), "a/b/c"); - assert_eq!(base.resolve(&PathRelative::new("./../c")).unwrap().as_str(), "a/c"); + assert_eq!(base.resolve(&PathRelative::new(".")).as_str(), "a"); + assert_eq!(base.resolve(&PathRelative::new("./c")).as_str(), "a/c"); + assert_eq!(base.resolve(&PathRelative::new("./../c")).as_str(), "c"); } #[test] - fn test_resolve_self_reference_via_dotdot() { - // Walking `..` back to the same path yields the base unchanged, which lets the + fn test_resolve_self_reference_via_sibling_name() { + // Naming the base within its parent yields the base unchanged, which lets the // caller compare resolved == base to detect a self-reference. let base = Path::new("a/b"); - assert_eq!(base.resolve(&PathRelative::new("../b")).unwrap().as_str(), "a/b"); + assert_eq!(base.resolve(&PathRelative::new("./b")).as_str(), "a/b"); + } + + #[test] + fn test_try_resolve_distinguishes_root_from_escape() { + let base = Path::new("top"); + assert_eq!(base.try_resolve(&PathRelative::new(".")).unwrap().as_str(), ""); + assert!(base.try_resolve(&PathRelative::new("..")).is_none()); + + let nested = Path::new("a/b"); + assert_eq!(nested.try_resolve(&PathRelative::new("..")).unwrap().as_str(), ""); + assert!(nested.try_resolve(&PathRelative::new("../..")).is_none()); } } diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index 1949f007a4..68517df331 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -177,8 +177,7 @@ struct DriverState { // transport reports no send-rate estimate), since a completed future must not be // polled again. maintenance: Option>, - // Cached so a poll after completion (e.g. after `wait_ready` consumed the - // result) doesn't re-poll a finished future. + // Cached so a poll after completion doesn't re-poll a finished future. result: Option>, } @@ -190,23 +189,6 @@ impl Driver { pub fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { self.state.poll(waiter) } - - /// Drive the session until the readiness condition resolves, so `connect` can block on the - /// initial announce set. - /// - /// A session that dies first still resolves readiness: the connecting producers - /// live inside the driver, so its completion drops them and releases the barrier. - /// The error isn't lost, it's cached for whoever drives the session next. - pub(super) async fn wait_ready(&mut self, poll_ready: impl Fn(&kio::Waiter) -> Poll<()>) { - kio::wait(|waiter| { - if poll_ready(waiter).is_ready() { - return Poll::Ready(()); - } - let _ = self.poll(waiter); - Poll::Pending - }) - .await - } } impl DriverState { diff --git a/rs/moq-relay/CHANGELOG.md b/rs/moq-relay/CHANGELOG.md index e26091348c..3aa29fdc8e 100644 --- a/rs/moq-relay/CHANGELOG.md +++ b/rs/moq-relay/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.14.11](https://github.com/moq-dev/moq/compare/moq-relay-v0.14.10...moq-relay-v0.14.11) - 2026-08-14 + +### Fixed + +- *(relay)* honor server version over WebSocket ([#2841](https://github.com/moq-dev/moq/pull/2841)) +- *(net)* stop blocking connect on the initial announce set ([#2856](https://github.com/moq-dev/moq/pull/2856)) + +### Other + +- reload custom root CAs without restart ([#2863](https://github.com/moq-dev/moq/pull/2863)) + ## [0.14.10](https://github.com/moq-dev/moq/compare/moq-relay-v0.14.9...moq-relay-v0.14.10) - 2026-08-13 ### Added diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index bfba215771..0d05e33720 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.14.10" +version = "0.14.11" edition = "2024" # sysinfo 0.39 (cache governor cgroup limits) needs 1.95, above the 1.91 # workspace floor. moq-relay is lib+bin, so this applies to its library target diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index f4bc1823a8..4b429d2a32 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -1,5 +1,5 @@ use std::{ - collections::{HashMap, HashSet}, + collections::HashMap, path::PathBuf, sync::{ Arc, Mutex, @@ -44,6 +44,94 @@ fn should_dial(self_url: &str, peer: &str) -> bool { peer > self_url } +/// One peer's stable identity and the dial-affecting configuration currently +/// supplied by a discovery source. +#[derive(Clone, PartialEq, Eq)] +struct DialTarget { + key: String, + url: Url, + cost: Option, +} + +impl DialTarget { + fn parse(peer: &str) -> anyhow::Result { + let mut url = peer_url(peer)?; + let key = { + let mut identity = url.clone(); + identity.set_query(None); + identity.into() + }; + let cost = take_cost(&mut url)?; + Ok(Self { key, url, cost }) + } +} + +/// Every currently-live gossip path for a canonical peer, in announcement order. +/// Query changes create a new path before the old path necessarily unannounces, +/// so the latest live path owns the dial configuration. +#[derive(Default)] +struct GossipTargets { + by_key: HashMap>, +} + +enum GossipUpdate { + /// The current advertisement changed to this target. + Current(DialTarget), + /// A non-current or unknown advertisement disappeared. + Unchanged, + /// The peer has no live advertisements left. + Gone, +} + +impl GossipTargets { + fn announce(&mut self, advertisement: String, target: DialTarget) -> DialTarget { + let targets = self.by_key.entry(target.key.clone()).or_default(); + targets.retain(|(existing, _)| existing != &advertisement); + targets.push((advertisement, target.clone())); + target + } + + fn unannounce(&mut self, advertisement: &str, key: &str) -> GossipUpdate { + let Some(targets) = self.by_key.get_mut(key) else { + return GossipUpdate::Unchanged; + }; + let was_current = targets.last().is_some_and(|(current, _)| current == advertisement); + let previous_len = targets.len(); + targets.retain(|(existing, _)| existing != advertisement); + if targets.len() == previous_len { + return GossipUpdate::Unchanged; + } + if targets.is_empty() { + self.by_key.remove(key); + return GossipUpdate::Gone; + } + if was_current { + return GossipUpdate::Current(targets.last().expect("live target exists").1.clone()); + } + GossipUpdate::Unchanged + } +} + +/// Parse a complete dynamic peer list before mutating the live dial set. A bad +/// entry or conflicting duplicate rejects the whole update, preserving the +/// last-known-good topology. +fn parse_peer_list(list: Vec, node: Option<&str>) -> anyhow::Result> { + let self_key = node.map(canonicalize_peer_key); + let mut desired = HashMap::new(); + for (index, peer) in list.into_iter().enumerate() { + let target = DialTarget::parse(&peer).with_context(|| format!("invalid peer at index {index}"))?; + if Some(&target.key) == self_key.as_ref() { + continue; + } + if let Some(previous) = desired.insert(target.key.clone(), target.clone()) + && previous != target + { + anyhow::bail!("peer identity {} has conflicting configurations", target.key); + } + } + Ok(desired) +} + /// A mechanism that wants a dial kept alive. A single peer can be wanted by more /// than one at once (e.g. gossiped *and* listed by `--cluster-connect-api`), so /// [`DialEntry`] tracks a set of these and only tears the dial down when the last @@ -67,42 +155,59 @@ enum DialSource { } /// The set of [`DialSource`]s currently keeping a dial alive. -#[derive(Clone, Copy, Default)] +#[derive(Clone, Default)] struct DialSources { - seeded: bool, - gossip: bool, - api: bool, + seeded: Option, + gossip: Option, + api: Option, #[cfg(feature = "cluster-lan")] - mdns: bool, + mdns: Option, } impl DialSources { - fn set(&mut self, source: DialSource) { + fn get(&self, source: DialSource) -> Option<&DialTarget> { + match source { + DialSource::Static => self.seeded.as_ref(), + DialSource::Gossip => self.gossip.as_ref(), + DialSource::Api => self.api.as_ref(), + #[cfg(feature = "cluster-lan")] + DialSource::Mdns => self.mdns.as_ref(), + } + } + + fn set(&mut self, source: DialSource, target: DialTarget) { match source { - DialSource::Static => self.seeded = true, - DialSource::Gossip => self.gossip = true, - DialSource::Api => self.api = true, + DialSource::Static => self.seeded = Some(target), + DialSource::Gossip => self.gossip = Some(target), + DialSource::Api => self.api = Some(target), #[cfg(feature = "cluster-lan")] - DialSource::Mdns => self.mdns = true, + DialSource::Mdns => self.mdns = Some(target), } } fn clear(&mut self, source: DialSource) { match source { - DialSource::Static => self.seeded = false, - DialSource::Gossip => self.gossip = false, - DialSource::Api => self.api = false, + DialSource::Static => self.seeded = None, + DialSource::Gossip => self.gossip = None, + DialSource::Api => self.api = None, #[cfg(feature = "cluster-lan")] - DialSource::Mdns => self.mdns = false, + DialSource::Mdns => self.mdns = None, } } - fn any(&self) -> bool { + /// The source to fall back to when the active one is released, most durable + /// first: operator intent, then the API list, then the two discovery + /// mechanisms whose targets come and go on their own. + fn fallback(&self) -> Option<(DialSource, &DialTarget)> { + let fallback = self + .seeded + .as_ref() + .map(|target| (DialSource::Static, target)) + .or_else(|| self.api.as_ref().map(|target| (DialSource::Api, target))) + .or_else(|| self.gossip.as_ref().map(|target| (DialSource::Gossip, target))); #[cfg(feature = "cluster-lan")] - let extra = self.mdns; - #[cfg(not(feature = "cluster-lan"))] - let extra = false; - self.seeded || self.gossip || self.api || extra + let fallback = fallback.or_else(|| self.mdns.as_ref().map(|target| (DialSource::Mdns, target))); + fallback } } @@ -112,10 +217,11 @@ impl DialSources { struct DialEntry { handle: AbortHandle, sources: DialSources, + active: DialSource, unannounced_at: Option, } -/// Map of in-flight cluster dials, keyed by peer URL. Cloneable: the inner +/// Map of in-flight cluster dials, keyed by canonical peer identity. Cloneable: the inner /// map is shared via `Arc>` so the discovery task and the static-seed /// phase write to the same set of entries. #[derive(Clone, Default)] @@ -129,43 +235,80 @@ impl DialMap { self.inner.lock().expect("dial map poisoned").contains_key(peer) } - /// Record a freshly-spawned dial for `peer` under `source`. If `peer` is - /// already dialed, add `source` to its set and abort the redundant `handle` - /// (the existing dial stands, since dialing dedupes by URL). Always spawn the - /// task first, then call this: it resolves the "two sources discover the same - /// peer at once" race without leaking a task. - fn insert(&self, peer: String, handle: AbortHandle, source: DialSource) { + /// Record an already-spawned dial under `source`. If the source is redundant, + /// abort its task instead of leaking a second session. + fn insert(&self, target: DialTarget, handle: AbortHandle, source: DialSource) { + let mut handle = Some(handle); + self.upsert(target, source, &mut |_| handle.take().expect("dial handle used once")); + if let Some(handle) = handle { + handle.abort(); + } + } + + /// Add or update one source's target, replacing the live dial only when that + /// source already owns it. Other sources retain their latest target as a + /// fallback without changing the first source's active configuration. + fn upsert(&self, target: DialTarget, source: DialSource, spawn: &mut F) -> bool + where + F: FnMut(DialTarget) -> AbortHandle, + { let mut map = self.inner.lock().expect("dial map poisoned"); - if let Some(entry) = map.get_mut(&peer) { - entry.sources.set(source); - if source == DialSource::Gossip { - entry.unannounced_at = None; + if let Some(entry) = map.get_mut(&target.key) { + let replace = entry.active == source && entry.sources.get(source) != Some(&target); + entry.sources.set(source, target.clone()); + let reannounced = source == DialSource::Gossip && entry.unannounced_at.take().is_some(); + if replace { + entry.handle.abort(); + entry.handle = spawn(target); } - drop(map); - handle.abort(); - } else { - let mut sources = DialSources::default(); - sources.set(source); - map.insert( - peer, - DialEntry { - handle, - sources, - unannounced_at: None, - }, - ); + return reannounced; } + + let key = target.key.clone(); + let handle = spawn(target.clone()); + let mut sources = DialSources::default(); + sources.set(source, target); + map.insert( + key, + DialEntry { + handle, + sources, + active: source, + unannounced_at: None, + }, + ); + false } - /// Add `source` to an already-dialed `peer` (no-op if absent). Used when a - /// peer reached via one source is also discovered via another, without opening - /// a second dial. Adding [`DialSource::Gossip`] also clears any pending - /// unannounce; returns whether such a timestamp was cleared (a reannounce). - fn add_source(&self, peer: &str, source: DialSource) -> bool { + /// Release one source. If it owned the live dial, switch to a remaining + /// source's latest target or abandon the peer when no source remains. + fn release(&self, peer: &str, source: DialSource, spawn: &mut F) + where + F: FnMut(DialTarget) -> AbortHandle, + { let mut map = self.inner.lock().expect("dial map poisoned"); - let Some(entry) = map.get_mut(peer) else { return false }; - entry.sources.set(source); - source == DialSource::Gossip && entry.unannounced_at.take().is_some() + let Some(entry) = map.get_mut(peer) else { return }; + if entry.sources.get(source).is_none() { + return; + } + let active_target = entry.sources.get(source).cloned(); + entry.sources.clear(source); + if entry.active != source { + return; + } + + if let Some((next_source, next_target)) = entry.sources.fallback() { + let next_target = next_target.clone(); + entry.active = next_source; + if active_target.as_ref() != Some(&next_target) { + entry.handle.abort(); + entry.handle = spawn(next_target); + } + return; + } + + let entry = map.remove(peer).expect("entry exists"); + entry.handle.abort(); } /// Start the gossip stale timer on `peer` if it isn't already pending. No-op @@ -174,7 +317,7 @@ impl DialMap { fn mark_unannounced(&self, peer: &str, now: Instant) { let mut map = self.inner.lock().expect("dial map poisoned"); if let Some(entry) = map.get_mut(peer) - && entry.sources.gossip + && entry.sources.gossip.is_some() { entry.unannounced_at.get_or_insert(now); } @@ -182,77 +325,49 @@ impl DialMap { /// Release the gossip source from entries whose unannounce has stuck for at /// least `threshold`, aborting the dial only if no other source still wants it. - fn sweep_stale(&self, now: Instant, threshold: Duration) { + fn sweep_stale(&self, now: Instant, threshold: Duration, spawn: &mut F) + where + F: FnMut(DialTarget) -> AbortHandle, + { let mut map = self.inner.lock().expect("dial map poisoned"); - map.retain(|peer, entry| { - let Some(at) = entry.unannounced_at else { return true }; - if now.duration_since(at) < threshold { - return true; - } - entry.unannounced_at = None; - entry.sources.clear(DialSource::Gossip); - if entry.sources.any() { - tracing::debug!(%peer, "peer no longer gossiped; still wanted by another source"); - true - } else { - tracing::info!(%peer, "peer no longer gossiped; abandoning dial"); - entry.handle.abort(); - false - } - }); - } + let expired: Vec = map + .iter_mut() + .filter_map(|(peer, entry)| { + let at = entry.unannounced_at?; + (now.duration_since(at) >= threshold).then(|| { + entry.unannounced_at = None; + peer.clone() + }) + }) + .collect(); + drop(map); - /// Release `source` from `peer`, aborting the dial only if no other source - /// still wants it. For a source whose "gone" signal is definite, unlike the - /// gossip unannounce that [`sweep_stale`](Self::sweep_stale) has to wait out. - #[cfg(feature = "cluster-lan")] - fn release(&self, peer: &str, source: DialSource) { - let mut map = self.inner.lock().expect("dial map poisoned"); - let Some(entry) = map.get_mut(peer) else { return }; - entry.sources.clear(source); - if entry.sources.any() { - tracing::debug!(%peer, "peer no longer discovered; still wanted by another source"); - return; + for peer in expired { + self.release(&peer, DialSource::Gossip, spawn); } - tracing::info!(%peer, "peer no longer discovered; abandoning dial"); - entry.handle.abort(); - map.remove(peer); } - /// Reconcile the API source against `desired`: release [`DialSource::Api`] from - /// entries no longer listed (aborting only those nothing else wants), mark the - /// API source on already-dialed peers that are listed, and return the desired - /// peers not yet dialed (the caller spawns those and re-inserts them). - fn reconcile_api(&self, desired: &HashSet) -> Vec { - let mut map = self.inner.lock().expect("dial map poisoned"); - - // One mutable pass: set the API source on listed peers, release it from the - // rest (aborting only those nothing else wants). - map.retain(|peer, entry| { - if desired.contains(peer) { - entry.sources.set(DialSource::Api); - return true; - } - if !entry.sources.api { - return true; - } - entry.sources.clear(DialSource::Api); - if entry.sources.any() { - tracing::debug!(%peer, "peer dropped from cluster-connect-api; still wanted by another source"); - true - } else { - tracing::info!(%peer, "peer dropped from cluster-connect-api; abandoning dial"); - entry.handle.abort(); - false - } - }); + /// Reconcile the API source against `desired`, including changes to a peer's + /// dial-affecting URL or link cost while its canonical identity stays fixed. + fn reconcile_api(&self, desired: &HashMap, mut spawn: F) + where + F: FnMut(DialTarget) -> AbortHandle, + { + for target in desired.values() { + self.upsert(target.clone(), DialSource::Api, &mut spawn); + } - // Whatever's left in `desired` but absent from the map needs a fresh dial. - desired + let removed: Vec = self + .inner + .lock() + .expect("dial map poisoned") .iter() - .filter(|peer| !map.contains_key(*peer)) - .cloned() - .collect() + .filter(|(peer, entry)| entry.sources.api.is_some() && !desired.contains_key(*peer)) + .map(|(peer, _)| peer.clone()) + .collect(); + for peer in removed { + self.release(&peer, DialSource::Api, &mut spawn); + } } } @@ -305,7 +420,7 @@ pub struct ClusterConfig { /// Fetch the list of peers to dial from an HTTP(S) URL or a local file, /// reloading at runtime without a restart. The source returns a JSON array - /// of peer hostnames: `["a.pop.example", "b.pop.example"]`. An http(s) URL is + /// of peer URLs: `["https://a.pop.example/?cost=1", "b.pop.example"]`. An http(s) URL is /// re-checked on a fixed cadence, with caching, conditional revalidation /// (`ETag` / `Last-Modified`), and stale-if-error handled by the shared HTTP /// cache client, so the response's `Cache-Control` controls how often a real @@ -356,8 +471,9 @@ pub struct ClusterConfig { /// JWT presented on outbound cluster dials, read from this file. Applied to /// any peer whose URL doesn't already carry a `?jwt=` (so it authenticates - /// gossip- and `connect_api`-discovered peers, whose addresses can't embed a - /// token). For static `--cluster-connect` peers, prefer an inline `?jwt=`. + /// any peer whose URL has no inline token). An inline `?jwt=` can provide a + /// per-peer credential for static or `connect_api` peers. Gossip should use + /// this shared token or mTLS because the advertised node URL is public. #[arg(id = "cluster-token", long = "cluster-token", env = "MOQ_CLUSTER_TOKEN")] pub token: Option, @@ -746,9 +862,8 @@ impl Cluster { } // Token presented on outbound dials whose URL doesn't already carry a - // `?jwt=`. This is how gossip- and connect_api-discovered peers (whose - // addresses can't carry an inline token) authenticate, so it isn't - // deprecated; for static `connect` peers, an inline `?jwt=` is preferred. + // `?jwt=`. This remains the shared credential for any peer without a + // per-peer inline token. let token = match &self.config.token { Some(path) => std::fs::read_to_string(path) .context("failed to read cluster token")? @@ -821,8 +936,8 @@ impl Cluster { let mut supervised: tokio::task::JoinSet> = tokio::task::JoinSet::new(); for peer in &self.config.connect { - let key = canonicalize_peer_key(peer); - if dialed.contains(&key) { + let target = DialTarget::parse(peer).context("invalid --cluster-connect peer URL")?; + if dialed.contains(&target.key) { continue; } if is_legacy_peer(peer) { @@ -834,13 +949,9 @@ impl Cluster { } let this = self.clone(); let token = token.clone(); - let peer_for_task = peer.clone(); - let handle = tasks.spawn(async move { - if let Err(err) = this.run_remote(&peer_for_task, token).await { - tracing::warn!(%err, peer = %peer_for_task, "cluster peer connection ended"); - } - }); - dialed.insert(key, handle, DialSource::Static); + let peer_for_task = target.clone(); + let handle = tasks.spawn(this.supervise_remote(peer_for_task, token)); + dialed.insert(target, handle, DialSource::Static); } if let Some(source) = self.config.connect_api.clone() { @@ -938,6 +1049,7 @@ impl Cluster { return; }; let mut announced = consumer.announced(); + let mut live = GossipTargets::default(); let mut sweep = tokio::time::interval(SWEEP_INTERVAL); sweep.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); @@ -951,41 +1063,50 @@ impl Cluster { // The address to dial, which keeps its query: `run_remote` reads // `?cost=` and `?jwt=` off it. The key is only its identity. let peer = advertised_node_url(relative.as_str()); - let key = canonicalize_peer_key(&peer); + let target = match DialTarget::parse(&peer) { + Ok(target) => target, + Err(err) => { + tracing::warn!(%err, "invalid gossiped cluster peer URL; ignoring update"); + continue; + } + }; // Skip self and any peer we lose the tiebreaker to; that side // dials us instead, so each pair forms a single session. - if !should_dial(&self_url, &key) { + if !should_dial(&self_url, &target.key) { continue; } + let advertisement = relative.as_str().to_owned(); match broadcast { Some(_) => { - if dialed.contains(&key) { - // Already dialed (possibly via another source). Mark gossip as - // a wanter and cancel any pending stale-sweep. - if dialed.add_source(&key, DialSource::Gossip) { - tracing::debug!(peer = %key, "reannounce within sweep window; keeping dial"); - } - continue; + let target = live.announce(advertisement, target); + let mut spawn = |target: DialTarget| { + tracing::info!(peer = %target.key, "discovered cluster peer; dialing"); + tokio::spawn(self.clone().supervise_remote(target, token.clone())).abort_handle() + }; + let key = target.key.clone(); + if dialed.upsert(target, DialSource::Gossip, &mut spawn) { + tracing::debug!(peer = %key, "reannounce within sweep window; keeping dial"); } - tracing::info!(peer = %key, "discovered cluster peer; dialing"); - let this = self.clone(); - let token = token.clone(); - // Logged by key, never `peer`, so an inline jwt stays out of the logs. - let log_peer = key.clone(); - let handle = tokio::spawn(async move { - if let Err(err) = this.run_remote(&peer, token).await { - tracing::warn!(%err, peer = %log_peer, "cluster peer connection ended"); - } - }); - dialed.insert(key, handle.abort_handle(), DialSource::Gossip); - } - None => { - dialed.mark_unannounced(&key, Instant::now()); } + None => match live.unannounce(&advertisement, &target.key) { + GossipUpdate::Current(target) => { + let mut spawn = |target: DialTarget| { + tracing::info!(peer = %target.key, "cluster peer advertisement changed; redialing"); + tokio::spawn(self.clone().supervise_remote(target, token.clone())).abort_handle() + }; + dialed.upsert(target, DialSource::Gossip, &mut spawn); + } + GossipUpdate::Unchanged => {} + GossipUpdate::Gone => dialed.mark_unannounced(&target.key, Instant::now()), + }, } } _ = sweep.tick() => { - dialed.sweep_stale(Instant::now(), STALE_AFTER); + let mut spawn = |target: DialTarget| { + tracing::info!(peer = %target.key, "cluster peer source changed; redialing"); + tokio::spawn(self.clone().supervise_remote(target, token.clone())).abort_handle() + }; + dialed.sweep_stale(Instant::now(), STALE_AFTER, &mut spawn); } } } @@ -1012,6 +1133,13 @@ impl Cluster { ) -> anyhow::Result<()> { use moq_native::mdns::Event; + // Logged by key, never by the target's URL, so an inline query stays out of + // the logs. + let mut spawn = |target: DialTarget| { + tracing::info!(peer = %target.key, "dialing LAN cluster peer"); + tokio::spawn(self.clone().supervise_remote(target, token.clone())).abort_handle() + }; + while let Some(event) = discovery.recv().await { match event { Event::Found(peer) => { @@ -1022,29 +1150,20 @@ impl Cluster { // The address to dial keeps its query, since `run_remote` reads // `?cost=` off it. The key is only its identity, exactly as on the // gossip path. - let peer = node.to_string(); - let key = canonicalize_peer_key(&peer); + let target = match DialTarget::parse(node.as_ref()) { + Ok(target) => target, + Err(err) => { + tracing::warn!(%err, peer = %peer.id, "LAN peer advertised an invalid --cluster-node URL; skipping"); + continue; + } + }; // Skip any peer we lose the tiebreaker to; that side dials us. - if !should_dial(&self_url, &key) { + if !should_dial(&self_url, &target.key) { continue; } - if dialed.contains(&key) { - dialed.add_source(&key, DialSource::Mdns); - continue; - } - tracing::info!(peer = %key, "discovered LAN cluster peer; dialing"); - let this = self.clone(); - let token = token.clone(); - // Logged by key, never `peer`, so an inline query stays out of the logs. - let log_peer = key.clone(); - let handle = tokio::spawn(async move { - if let Err(err) = this.run_remote(&peer, token).await { - tracing::warn!(%err, peer = %log_peer, "cluster peer connection ended"); - } - }); - dialed.insert(key, handle.abort_handle(), DialSource::Mdns); + dialed.upsert(target, DialSource::Mdns, &mut spawn); } - Event::Lost(id) => dialed.release(&canonicalize_peer_key(&id), DialSource::Mdns), + Event::Lost(id) => dialed.release(&canonicalize_peer_key(&id), DialSource::Mdns, &mut spawn), _ => continue, } } @@ -1058,7 +1177,7 @@ impl Cluster { /// Drive `--cluster-connect-api`: an http(s) URL is polled, a local path (or /// `file://` URL) is watched for changes. Either way the source yields a JSON - /// array of peer hostnames that's reconciled into the shared dial map. + /// array of peer URLs that's reconciled into the shared dial map. async fn run_connect_api(self, source: String, node: Option, token: String, dialed: DialMap) { match Url::parse(&source) { Ok(url) if matches!(url.scheme(), "http" | "https") => { @@ -1174,50 +1293,45 @@ impl Cluster { .await .context("failed to read cluster.connect_api body")?; - serde_json::from_str(&body).context("cluster.connect_api response is not a JSON array of hostnames") + serde_json::from_str(&body).context("cluster.connect_api response is not a JSON array of peer URLs") } /// Reconcile a freshly fetched peer list into the dial map: dial peers that /// are new and drop API peers that disappeared. The relay's own [`node`] URL /// is filtered out so it never dials itself. fn apply_peer_list(&self, list: Vec, node: &Option, token: &str, dialed: &DialMap) { - // Dedupe against the shared dial map (and filter out self) on the canonical - // key, so an API entry matches the same peer reached via `connect`/gossip - // regardless of how each spells it. reconcile_api then yields canonical keys. - let self_key = node.as_deref().map(canonicalize_peer_key); - let desired: HashSet = list - .into_iter() - .map(|peer| canonicalize_peer_key(&peer)) - .filter(|key| Some(key) != self_key.as_ref()) - .collect(); + // Dedupe against the shared dial map (and filter out self) on stable identity, + // while retaining the full dial configuration so a cost or credential update + // replaces the existing session. + let desired = match parse_peer_list(list, node.as_deref()) { + Ok(desired) => desired, + Err(err) => { + tracing::warn!(%err, "invalid cluster.connect_api peer list; keeping current peers"); + return; + } + }; - for peer in dialed.reconcile_api(&desired) { - tracing::info!(%peer, "cluster.connect_api peer; dialing"); - let this = self.clone(); - let token = token.to_string(); - let peer_for_task = peer.clone(); - let handle = tokio::spawn(async move { - if let Err(err) = this.run_remote(&peer_for_task, token).await { - tracing::warn!(%err, peer = %peer_for_task, "cluster peer connection ended"); - } - }); - dialed.insert(peer, handle.abort_handle(), DialSource::Api); + dialed.reconcile_api(&desired, |target| { + tracing::info!(peer = %target.key, "cluster.connect_api peer; dialing"); + let handle = tokio::spawn(self.clone().supervise_remote(target, token.to_string())); + handle.abort_handle() + }); + } + + async fn supervise_remote(self, target: DialTarget, token: String) { + let log_peer = target.key.clone(); + if let Err(err) = self.run_remote(&target, token).await { + tracing::warn!(%err, peer = %log_peer, "cluster peer connection ended"); } } - #[tracing::instrument("remote", skip_all, err, fields(%remote))] - async fn run_remote(self, remote: &str, token: String) -> anyhow::Result<()> { - let mut url = peer_url(remote)?; - // The link's price, declared by us as the dialing side and charged to - // every announcement crossing the connection (see - // `moq_net::Client::with_cost`). Carried as a `?cost=` query - // param on the peer URL so static lists, gossip, and connect-api feeds can - // each price their links: 0 for a same-datacenter sibling, higher for a - // metered backbone. Stripped here; the value rides SETUP, not the URL. - let cost = take_cost(&mut url)?; + #[tracing::instrument("remote", skip_all, err, fields(remote = %target.key))] + async fn run_remote(self, target: &DialTarget, token: String) -> anyhow::Result<()> { + let mut url = target.url.clone(); + let cost = target.cost; // Apply the shared cluster token unless the URL already carries its own - // non-empty `?jwt=` (an inline token on a static `connect` peer wins; the - // shared token still covers discovered peers that have none). An empty + // non-empty `?jwt=` (a per-peer inline token wins; the shared token still + // covers peers that have none). An empty // `?jwt=` counts as absent, matching `AuthParams::from_url`. if !token.is_empty() && !url.query_pairs().any(|(key, value)| key == "jwt" && !value.is_empty()) { url.query_pairs_mut().append_pair("jwt", &token); @@ -1394,10 +1508,10 @@ fn peer_url(peer: &str) -> anyhow::Result { // A full URL has a scheme separator; a bare host or `host:port` does not // (and `Url::parse` would otherwise mis-read `host:port` as scheme `host`). if peer.contains("://") { - return Url::parse(peer).with_context(|| format!("invalid cluster peer URL: {peer}")); + return Url::parse(peer).context("invalid cluster peer URL"); } - Url::parse(&format!("https://{peer}/")).with_context(|| format!("invalid cluster peer host: {peer}")) + Url::parse(&format!("https://{peer}/")).context("invalid cluster peer host") } /// The address to dial for a node advertised under `MESH_PREFIX`. @@ -1582,16 +1696,64 @@ mod tests { tokio::spawn(std::future::pending::<()>()).abort_handle() } + fn target(key: &str) -> DialTarget { + DialTarget { + key: key.to_string(), + url: Url::parse(&format!("https://{key}/")).expect("test URL"), + cost: None, + } + } + + fn desired(keys: &[&str]) -> HashMap { + keys.iter().map(|key| ((*key).to_string(), target(key))).collect() + } + + /// A query change creates a new gossip path before the old path necessarily + /// unannounces. Removing that old path must not stale the replacement. + #[test] + fn gossip_old_unannounce_keeps_new_target() { + let mut live = GossipTargets::default(); + let old = DialTarget::parse("https://peer.example/?cost=1").unwrap(); + let new = DialTarget::parse("https://peer.example/?cost=2").unwrap(); + live.announce("peer.example/?cost=1".to_string(), old.clone()); + live.announce("peer.example/?cost=2".to_string(), new.clone()); + + assert!(matches!( + live.unannounce("peer.example/?cost=1", &old.key), + GossipUpdate::Unchanged + )); + assert!(matches!( + live.unannounce("peer.example/?cost=2", &new.key), + GossipUpdate::Gone + )); + } + + /// If the current gossip path disappears while an older one is still live, + /// the remaining target becomes current again. + #[test] + fn gossip_current_unannounce_restores_live_fallback() { + let mut live = GossipTargets::default(); + let old = DialTarget::parse("https://peer.example/?cost=1").unwrap(); + let new = DialTarget::parse("https://peer.example/?cost=2").unwrap(); + live.announce("peer.example/?cost=1".to_string(), old.clone()); + live.announce("peer.example/?cost=2".to_string(), new.clone()); + + let GossipUpdate::Current(current) = live.unannounce("peer.example/?cost=2", &new.key) else { + panic!("older live advertisement must become current"); + }; + assert!(current == old); + } + /// `mark_unannounced` is a no-op for static peers (operator intent says /// "always dial"), so the sweep never has a stale timestamp to act on. #[tokio::test] async fn sweep_preserves_static_peer() { let dialed = DialMap::default(); - dialed.insert("static-peer:4443".into(), placeholder_handle(), DialSource::Static); + dialed.insert(target("static-peer:4443"), placeholder_handle(), DialSource::Static); let long_ago = Instant::now() - Duration::from_secs(3600); dialed.mark_unannounced("static-peer:4443", long_ago); - dialed.sweep_stale(Instant::now(), STALE_AFTER); + dialed.sweep_stale(Instant::now(), STALE_AFTER, &mut |_| panic!("must not redial")); assert!(dialed.contains("static-peer:4443")); } @@ -1602,10 +1764,10 @@ mod tests { async fn sweep_evicts_stale_gossip_peer() { let dialed = DialMap::default(); let now = Instant::now(); - dialed.insert("gone:4443".into(), placeholder_handle(), DialSource::Gossip); + dialed.insert(target("gone:4443"), placeholder_handle(), DialSource::Gossip); dialed.mark_unannounced("gone:4443", now - STALE_AFTER - Duration::from_secs(1)); - dialed.sweep_stale(now, STALE_AFTER); + dialed.sweep_stale(now, STALE_AFTER, &mut |_| panic!("must not redial")); assert!(!dialed.contains("gone:4443")); } @@ -1618,10 +1780,10 @@ mod tests { async fn sweep_keeps_recently_unannounced_peer() { let dialed = DialMap::default(); let now = Instant::now(); - dialed.insert("flapping:4443".into(), placeholder_handle(), DialSource::Gossip); + dialed.insert(target("flapping:4443"), placeholder_handle(), DialSource::Gossip); dialed.mark_unannounced("flapping:4443", now - Duration::from_millis(50)); - dialed.sweep_stale(now, STALE_AFTER); + dialed.sweep_stale(now, STALE_AFTER, &mut |_| panic!("must not redial")); assert!(dialed.contains("flapping:4443")); } @@ -1630,10 +1792,10 @@ mod tests { #[tokio::test] async fn sweep_keeps_currently_announced_peer() { let dialed = DialMap::default(); - dialed.insert("healthy:4443".into(), placeholder_handle(), DialSource::Gossip); + dialed.insert(target("healthy:4443"), placeholder_handle(), DialSource::Gossip); // No mark_unannounced -> stays announced. - dialed.sweep_stale(Instant::now(), STALE_AFTER); + dialed.sweep_stale(Instant::now(), STALE_AFTER, &mut |_| panic!("must not redial")); assert!(dialed.contains("healthy:4443")); } @@ -1645,19 +1807,20 @@ mod tests { async fn reannounce_cancels_pending_sweep() { let dialed = DialMap::default(); let now = Instant::now(); - dialed.insert("flap:4443".into(), placeholder_handle(), DialSource::Gossip); + let target = target("flap:4443"); + dialed.insert(target.clone(), placeholder_handle(), DialSource::Gossip); dialed.mark_unannounced("flap:4443", now - STALE_AFTER - Duration::from_secs(1)); // Re-adding the gossip source (a reannounce) clears the pending sweep. assert!( - dialed.add_source("flap:4443", DialSource::Gossip), + dialed.upsert(target.clone(), DialSource::Gossip, &mut |_| panic!("must not redial")), "should report a cleared pending-sweep" ); - dialed.sweep_stale(now, STALE_AFTER); + dialed.sweep_stale(now, STALE_AFTER, &mut |_| panic!("must not redial")); assert!(dialed.contains("flap:4443")); // A second reannounce has nothing to clear. - assert!(!dialed.add_source("flap:4443", DialSource::Gossip)); + assert!(!dialed.upsert(target, DialSource::Gossip, &mut |_| panic!("must not redial"))); } /// A peer wanted by both gossip and the API survives losing either source: the @@ -1667,20 +1830,16 @@ mod tests { let dialed = DialMap::default(); let now = Instant::now(); // Gossiped first, then also appears in the API list. - dialed.insert("both:4443".into(), placeholder_handle(), DialSource::Gossip); - let desired: HashSet = ["both:4443".to_string()].into_iter().collect(); - assert!( - dialed.reconcile_api(&desired).is_empty(), - "already dialed; no new spawn" - ); + dialed.insert(target("both:4443"), placeholder_handle(), DialSource::Gossip); + dialed.reconcile_api(&desired(&["both:4443"]), |_| panic!("already dialed")); // Dropped from the API list -> still wanted by gossip. - assert!(dialed.reconcile_api(&HashSet::new()).is_empty()); + dialed.reconcile_api(&HashMap::new(), |_| panic!("gossip dial stays active")); assert!(dialed.contains("both:4443"), "gossip still wants it"); // Now gossip goes stale too -> the dial is finally released. dialed.mark_unannounced("both:4443", now - STALE_AFTER - Duration::from_secs(1)); - dialed.sweep_stale(now, STALE_AFTER); + dialed.sweep_stale(now, STALE_AFTER, &mut |_| panic!("must not redial")); assert!(!dialed.contains("both:4443")); } @@ -1689,11 +1848,11 @@ mod tests { #[tokio::test] async fn insert_merges_redundant_dial() { let dialed = DialMap::default(); - dialed.insert("p:4443".into(), placeholder_handle(), DialSource::Gossip); - dialed.insert("p:4443".into(), placeholder_handle(), DialSource::Api); + dialed.insert(target("p:4443"), placeholder_handle(), DialSource::Gossip); + dialed.insert(target("p:4443"), placeholder_handle(), DialSource::Api); // Dropping the API source leaves the gossip source holding the dial. - assert!(dialed.reconcile_api(&HashSet::new()).is_empty()); + dialed.reconcile_api(&HashMap::new(), |_| panic!("gossip dial stays active")); assert!(dialed.contains("p:4443"), "gossip source still holds the dial"); } @@ -1703,17 +1862,18 @@ mod tests { #[tokio::test] async fn reconcile_api_adds_and_removes_only_api() { let dialed = DialMap::default(); - dialed.insert("static:4443".into(), placeholder_handle(), DialSource::Static); - dialed.insert("gossip:4443".into(), placeholder_handle(), DialSource::Gossip); - dialed.insert("api-keep:4443".into(), placeholder_handle(), DialSource::Api); - dialed.insert("api-drop:4443".into(), placeholder_handle(), DialSource::Api); + dialed.insert(target("static:4443"), placeholder_handle(), DialSource::Static); + dialed.insert(target("gossip:4443"), placeholder_handle(), DialSource::Gossip); + dialed.insert(target("api-keep:4443"), placeholder_handle(), DialSource::Api); + dialed.insert(target("api-drop:4443"), placeholder_handle(), DialSource::Api); // Desired: keep one existing API peer, drop the other, add a new one. // Static/Gossip peers are not in the list but must survive. - let desired: HashSet = ["api-keep:4443".to_string(), "api-new:4443".to_string()] - .into_iter() - .collect(); - let mut to_add = dialed.reconcile_api(&desired); + let mut to_add = Vec::new(); + dialed.reconcile_api(&desired(&["api-keep:4443", "api-new:4443"]), |target| { + to_add.push(target.key); + placeholder_handle() + }); to_add.sort(); assert_eq!(to_add, vec!["api-new:4443".to_string()]); @@ -1728,14 +1888,171 @@ mod tests { #[tokio::test] async fn reconcile_api_dedupes_against_other_sources() { let dialed = DialMap::default(); - dialed.insert("shared:4443".into(), placeholder_handle(), DialSource::Static); + dialed.insert(target("shared:4443"), placeholder_handle(), DialSource::Static); - let desired: HashSet = ["shared:4443".to_string()].into_iter().collect(); - assert!(dialed.reconcile_api(&desired).is_empty()); + dialed.reconcile_api(&desired(&["shared:4443"]), |_| panic!("already dialed")); assert!(dialed.contains("shared:4443")); } - /// The peer-list wire format is a bare JSON array of host strings. + /// A secondary source can update its target without disturbing the source that + /// opened the session. If the active source disappears, its latest fallback + /// configuration is used for the replacement. + #[tokio::test] + async fn inactive_source_update_applies_on_takeover() { + let dialed = DialMap::default(); + let gossip = DialTarget::parse("https://peer.example/?cost=1").unwrap(); + let api = DialTarget::parse("https://peer.example/?cost=3").unwrap(); + let old_task = tokio::spawn(std::future::pending::<()>()); + dialed.insert(gossip.clone(), old_task.abort_handle(), DialSource::Gossip); + + let desired = [(api.key.clone(), api.clone())].into_iter().collect(); + dialed.reconcile_api(&desired, |_| panic!("inactive source must not redial")); + assert!(!old_task.is_finished()); + + let now = Instant::now(); + dialed.mark_unannounced(&gossip.key, now - STALE_AFTER - Duration::from_secs(1)); + let mut spawned = Vec::new(); + dialed.sweep_stale(now, STALE_AFTER, &mut |target| { + spawned.push(target); + placeholder_handle() + }); + + tokio::task::yield_now().await; + assert!(old_task.is_finished(), "old source must be aborted"); + assert_eq!(spawned.len(), 1); + assert!(spawned[0] == api); + } + + /// If the fallback source requests the same target, ownership transfers without + /// interrupting the healthy session. + #[tokio::test] + async fn identical_fallback_takeover_keeps_task() { + let dialed = DialMap::default(); + let target = DialTarget::parse("https://peer.example/?cost=1").unwrap(); + let task = tokio::spawn(std::future::pending::<()>()); + dialed.insert(target.clone(), task.abort_handle(), DialSource::Gossip); + + let desired = [(target.key.clone(), target.clone())].into_iter().collect(); + dialed.reconcile_api(&desired, |_| panic!("inactive source must not redial")); + + let now = Instant::now(); + dialed.mark_unannounced(&target.key, now - STALE_AFTER - Duration::from_secs(1)); + dialed.sweep_stale(now, STALE_AFTER, &mut |_| panic!("identical fallback must not redial")); + + assert!(!task.is_finished(), "healthy dial must be preserved"); + let map = dialed.inner.lock().expect("dial map"); + let entry = map.get(&target.key).expect("current dial"); + assert_eq!(entry.active, DialSource::Api); + assert!(entry.sources.get(entry.active) == Some(&target)); + drop(map); + task.abort(); + } + + /// A cost-only API update has the same identity but different SETUP input, so + /// it must replace the live task instead of being mistaken for an unchanged + /// peer. + #[tokio::test] + async fn reconcile_api_replaces_cost_only_change() { + let dialed = DialMap::default(); + let old = DialTarget::parse("https://peer.example/?cost=1").unwrap(); + let new = DialTarget::parse("https://peer.example/?cost=2").unwrap(); + assert_eq!(old.key, new.key); + assert!(old != new); + + let old_task = tokio::spawn(std::future::pending::<()>()); + dialed.insert(old, old_task.abort_handle(), DialSource::Api); + let desired = [(new.key.clone(), new.clone())].into_iter().collect(); + let mut spawned = Vec::new(); + dialed.reconcile_api(&desired, |target| { + spawned.push(target); + placeholder_handle() + }); + + tokio::task::yield_now().await; + assert!(old_task.is_finished(), "old dial must be aborted"); + assert_eq!(spawned.len(), 1); + assert!(spawned[0] == new); + } + + /// An identical API render keeps the live task, avoiding connection churn. + #[tokio::test] + async fn reconcile_api_identical_target_is_noop() { + let dialed = DialMap::default(); + let target = DialTarget::parse("https://peer.example/?cost=2&jwt=secret").unwrap(); + let task = tokio::spawn(std::future::pending::<()>()); + dialed.insert(target.clone(), task.abort_handle(), DialSource::Api); + let desired = [(target.key.clone(), target)].into_iter().collect(); + + dialed.reconcile_api(&desired, |_| panic!("identical target must not redial")); + assert!(!task.is_finished(), "live dial must be preserved"); + task.abort(); + } + + /// Inline credentials are dial-affecting even though they are excluded from + /// peer identity, so rotating one replaces the session too. + #[tokio::test] + async fn reconcile_api_replaces_inline_credential() { + let dialed = DialMap::default(); + let old = DialTarget::parse("https://peer.example/?jwt=old").unwrap(); + let new = DialTarget::parse("https://peer.example/?jwt=new").unwrap(); + assert_eq!(old.key, new.key); + + let old_task = tokio::spawn(std::future::pending::<()>()); + dialed.insert(old, old_task.abort_handle(), DialSource::Api); + let desired = [(new.key.clone(), new.clone())].into_iter().collect(); + let mut spawned = Vec::new(); + dialed.reconcile_api(&desired, |target| { + spawned.push(target); + placeholder_handle() + }); + + tokio::task::yield_now().await; + assert!(old_task.is_finished(), "old dial must be aborted"); + assert_eq!(spawned.len(), 1); + assert!(spawned[0] == new); + } + + /// A malformed replacement is rejected before reconciliation, so it cannot + /// tear down or reconfigure the last-known-good dial set. + #[tokio::test] + async fn malformed_peer_list_preserves_current_dial() { + let cluster = Cluster::new(ClusterConfig::default()).expect("cluster"); + let dialed = DialMap::default(); + let current = DialTarget::parse("https://peer.example/?cost=1").unwrap(); + let task = tokio::spawn(std::future::pending::<()>()); + dialed.insert(current.clone(), task.abort_handle(), DialSource::Api); + + cluster.apply_peer_list( + vec!["https://peer.example/?cost=invalid".to_string()], + &None, + "", + &dialed, + ); + + assert!(!task.is_finished(), "last-known-good dial must stay active"); + let map = dialed.inner.lock().expect("dial map"); + let entry = map.get(¤t.key).expect("current dial"); + assert!(entry.sources.get(entry.active) == Some(¤t)); + task.abort(); + } + + /// The same identity cannot appear twice with different SETUP inputs because + /// input ordering must not choose which configuration wins. + #[test] + fn peer_list_rejects_conflicting_duplicate() { + let Err(err) = parse_peer_list( + vec![ + "https://peer.example/?cost=1".to_string(), + "https://peer.example/?cost=2".to_string(), + ], + None, + ) else { + panic!("conflicting duplicate must fail"); + }; + assert!(format!("{err:#}").contains("conflicting configurations")); + } + + /// The peer-list wire format is a JSON array of URL strings. #[test] fn peer_list_parses_as_string_array() { let body = r#"["a.pop.example", "b.pop.example:4443"]"#; @@ -1899,6 +2216,14 @@ mod tests { assert!(!is_legacy_peer("https://cdn.example.com/?jwt=abc")); } + /// A malformed URL may contain a credential, so parse errors must not echo the + /// raw input into logs. + #[test] + fn peer_url_error_redacts_inline_credential() { + let err = peer_url("https://peer.example:bad/?jwt=top-secret").unwrap_err(); + assert!(!format!("{err:#}").contains("top-secret")); + } + /// What a discovering relay reads off `announced()` for this node. fn advertised(node: &str) -> String { Path::new(MESH_PREFIX) @@ -1918,7 +2243,7 @@ mod tests { "https://a.example/", "https://b.example:4443/", "tcp://c.example:4443", - "https://d.example/?jwt=abc", + "https://d.example/?cost=7", "https://e.example/deep/path", // Legacy bare forms, which carry no scheme and default to https. "rendezvous.example.com:4443", @@ -1938,13 +2263,13 @@ mod tests { /// every gossip-discovered link silently at the default cost. #[test] fn an_advertised_url_keeps_the_query_it_is_dialed_with() { - let dialed = advertised_node_url(&advertised("https://a.example/?cost=10&jwt=abc")); - assert_eq!(dialed, "https://a.example/?cost=10&jwt=abc"); + let dialed = advertised_node_url(&advertised("https://a.example/?cost=10")); + assert_eq!(dialed, "https://a.example/?cost=10"); // The value has to reach where `run_remote` reads it. let mut url = peer_url(&dialed).unwrap(); assert_eq!(take_cost(&mut url).unwrap(), Some(10)); - assert_eq!(url.as_str(), "https://a.example/?jwt=abc"); + assert_eq!(url.as_str(), "https://a.example/"); } /// The colon that ends a scheme is the only signal, so a bare `host:port` (whose @@ -2022,7 +2347,7 @@ mod tests { // A URL form and the legacy host:port form dedupe against each other. let dialed = DialMap::default(); dialed.insert( - canonicalize_peer_key("https://host:4443/?jwt=abc"), + DialTarget::parse("https://host:4443/?jwt=abc").unwrap(), placeholder_handle(), DialSource::Static, ); @@ -2147,17 +2472,19 @@ mod tests { #[tokio::test] async fn mdns_release_respects_the_other_sources() { let dialed = DialMap::default(); - dialed.insert("peer:4443".into(), placeholder_handle(), DialSource::Mdns); - dialed.add_source("peer:4443", DialSource::Static); + let peer = target("peer:4443"); + dialed.insert(peer.clone(), placeholder_handle(), DialSource::Mdns); + dialed.insert(peer.clone(), placeholder_handle(), DialSource::Static); - dialed.release("peer:4443", DialSource::Mdns); - assert!(dialed.contains("peer:4443"), "the static seed still wants it"); + // The static seed wants the same target, so the takeover reuses the dial. + dialed.release(&peer.key, DialSource::Mdns, &mut |_| panic!("must not redial")); + assert!(dialed.contains(&peer.key), "the static seed still wants it"); - dialed.release("peer:4443", DialSource::Static); - assert!(!dialed.contains("peer:4443"), "the last release abandons the dial"); + dialed.release(&peer.key, DialSource::Static, &mut |_| panic!("must not redial")); + assert!(!dialed.contains(&peer.key), "the last release abandons the dial"); // Releasing an unknown peer is a no-op, not a panic. - dialed.release("never-dialed", DialSource::Mdns); + dialed.release("never-dialed", DialSource::Mdns, &mut |_| panic!("must not redial")); } #[test] diff --git a/rs/moq-relay/src/relay.rs b/rs/moq-relay/src/relay.rs index 5d99efa12c..6662bdc8b7 100644 --- a/rs/moq-relay/src/relay.rs +++ b/rs/moq-relay/src/relay.rs @@ -87,6 +87,7 @@ impl Relay { config.resolve(); let mtls_enabled = !config.listen.tls.root.is_empty(); + let server_versions = config.listen.versions(); #[allow(unused_mut)] let mut server = config.listen.init(config.quic.clone())?; @@ -139,8 +140,9 @@ impl Relay { let drain_timeout = config.drain_timeout.unwrap_or(DEFAULT_DRAIN_TIMEOUT); let (shutdown_trigger, shutdown) = Shutdown::new(drain_timeout); // Create a web server too. mTLS for HTTPS is opt-in via `--web-https-root`. - let web = - Web::new(auth.clone(), cluster.clone(), server.certificates(), config.web).with_shutdown(shutdown.clone()); + let web = Web::new(auth.clone(), cluster.clone(), server.certificates(), config.web) + .with_shutdown(shutdown.clone()) + .with_versions(server_versions); // Internal (ops) listener (plain HTTP, opt-in via `--internal-listen`) for // /metrics + /health + /nodes, separate from the customer-facing web server. No-op diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index 36a5d7a43e..e662fd430c 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -140,6 +140,7 @@ pub(crate) struct WebState { pub struct Web { state: Arc, config: WebConfig, + versions: moq_net::Versions, health: moq_native::accept::Health, } @@ -158,10 +159,17 @@ impl Web { Self { state, config, + versions: moq_net::Versions::all(), health: moq_native::accept::Health::new("web"), } } + /// Restrict which MoQ versions WebSocket sessions accept, in preference order. + pub fn with_versions(mut self, versions: moq_net::Versions) -> Self { + self.versions = versions; + self + } + /// A live handle to the accept-loop health of the HTTP/HTTPS listeners, for an /// embedder that publishes it (see [`moq_native::accept`]). /// @@ -228,7 +236,8 @@ impl Web { app }; - app.layer(CorsLayer::new().allow_origin(Any).allow_methods([Method::GET])) + app.layer(Extension(self.versions.clone())) + .layer(CorsLayer::new().allow_origin(Any).allow_methods([Method::GET])) .with_state(self.state.clone()) } @@ -322,18 +331,22 @@ fn build_https_config( .context("failed to build https TLS config") } -/// Reload the HTTPS cert/key/root whenever they change on disk. +fn https_watch_paths(cert: &[PathBuf], key: &[PathBuf], root: &[PathBuf]) -> Vec { + cert.iter() + .cloned() + .chain(key.iter().cloned()) + .chain(root.iter().cloned()) + .collect() +} + +/// Reload the HTTPS certificate and key whenever they change on disk. /// /// `RustlsConfig::reload_from_pem_file` would rebuild with `with_no_client_auth` /// (silently stripping mTLS when configured), so we always rebuild via the full -/// [`build_https_config`] path. +/// [`build_https_config`] path. The client verifier watches root files itself, +/// while this watcher also uses them to retry a failed certificate/key rotation. async fn reload_https_config(config: RustlsConfig, cert: Vec, key: Vec, root: Vec) { - let paths: Vec = cert - .iter() - .cloned() - .chain(key.iter().cloned()) - .chain(root.iter().cloned()) - .collect(); + let paths = https_watch_paths(&cert, &key, &root); let mut watcher = match moq_native::watch::FileWatcher::new(&paths) { Ok(watcher) => watcher, @@ -756,6 +769,21 @@ mod tests { ); } + #[test] + fn https_watch_paths_include_roots() { + let cert = PathBuf::from("cert.pem"); + let key = PathBuf::from("key.pem"); + let root = PathBuf::from("root.pem"); + assert_eq!( + https_watch_paths( + std::slice::from_ref(&cert), + std::slice::from_ref(&key), + std::slice::from_ref(&root) + ), + vec![cert, key, root] + ); + } + #[tokio::test] async fn build_https_config_no_client_auth_when_ca_empty() { let dir = TempDir::new().unwrap(); diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index ea6c65a603..dbcbb97b5d 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -21,6 +21,7 @@ pub(crate) async fn serve_ws( OriginalUri(uri): OriginalUri, headers: HeaderMap, mtls: Option>, + Extension(versions): Extension, State(state): State>, ) -> axum::response::Result { // If this isn't a WebSocket upgrade (e.g. a plain browser visit), serve @@ -29,7 +30,8 @@ pub(crate) async fn serve_ws( return Ok(landing_response()); }; - let ws = negotiate_subprotocol(ws)?; + let alpns = versions.alpns(); + let ws = negotiate_subprotocol(ws, &alpns)?; let host = uri .authority() @@ -63,7 +65,16 @@ pub(crate) async fn serve_ws( // Unfortunately, we need to convert from Axum to Tungstenite. // Axum uses Tungstenite internally, but it's not exposed to avoid semvar issues. let socket = WebSocketAdapter::new(socket); - let _ = handle_socket(id, socket, alpn, publish, subscribe, stats, state.shutdown.clone()).await; + let session = SessionInputs { + id, + alpn, + versions, + publish, + subscribe, + stats, + shutdown: state.shutdown.clone(), + }; + let _ = handle_socket(socket, session).await; })) } @@ -74,16 +85,18 @@ fn request_auth_params(auth: &Auth, host: &str, uri: &Uri) -> Result( - _id: u64, - socket: T, +struct SessionInputs { + id: u64, alpn: Option, + versions: moq_net::Versions, publish: Option, subscribe: Option, stats: Session, - mut shutdown: crate::Shutdown, -) -> anyhow::Result<()> + shutdown: crate::Shutdown, +} + +#[tracing::instrument("ws", err, skip_all, fields(id = session.id))] +async fn handle_socket(socket: T, session: SessionInputs) -> anyhow::Result<()> where T: futures::Stream> + futures::Sink @@ -91,6 +104,16 @@ where + Unpin + 'static, { + let SessionInputs { + id: _, + alpn, + versions, + publish, + subscribe, + stats, + mut shutdown, + } = session; + // Wrap the WebSocket in a WebTransport compatibility layer. We have to // forward the negotiated subprotocol explicitly; axum performed the // upgrade, so qmux can't sniff it from the handshake. @@ -110,7 +133,7 @@ where // Only set the side the token actually grants. moq-net defaults the // unset side to a fresh no-op origin, which is fine for a // publish-only or subscribe-only token. - let mut server = moq_net::Server::new().with_stats(stats); + let mut server = moq_net::Server::new().with_versions(versions).with_stats(stats); if let Some(subscribe) = subscribe { server = server.with_publisher(&subscribe); } @@ -139,8 +162,8 @@ where /// Pick a subprotocol for the upgrade, or fail the handshake outright. /// -/// We advertise the full qmux × moq-net subprotocol matrix, with bare qmux -/// fallbacks last. axum picks the first entry that the client also offered, so +/// We advertise the configured qmux × moq-net subprotocol matrix, with bare +/// qmux fallbacks last. axum picks the first entry that the client also offered, so /// a modern client lands on `qmux-01.moq-lite-05`; old clients still match /// `webtransport` or `qmux-00.moql` and negotiate via SETUP. /// @@ -152,8 +175,8 @@ where /// /// A client that offers no subprotocol at all is left alone: it upgrades and /// negotiates the moq version over moq-lite SETUP instead. -fn negotiate_subprotocol(ws: WebSocketUpgrade) -> Result { - let supported = supported_subprotocols(); +fn negotiate_subprotocol(ws: WebSocketUpgrade, alpns: &[&str]) -> Result { + let supported = supported_subprotocols(alpns); if !subprotocols_acceptable(ws.requested_protocols().map(HeaderValue::as_bytes), &supported) { tracing::debug!("rejecting WebSocket upgrade: no supported subprotocol offered"); @@ -196,7 +219,7 @@ const QMUX01_ONLY_ALPNS: &[&str] = &["moqt-18", "moqt-19"]; /// Subprotocols to advertise on the WebSocket upgrade. /// -/// Generates the cross product of [`QMUX_VERSIONS`] × `moq_net::ALPNS`, with +/// Generates the cross product of `alpns` × [`QMUX_VERSIONS`], with /// the bare qmux fallbacks (`qmux-01`, `qmux-00`, `webtransport`) appended /// last so versioned subprotocols always win the exact-string match axum /// performs. Without the versioned entries, axum picks bare `webtransport`, @@ -205,10 +228,10 @@ const QMUX01_ONLY_ALPNS: &[&str] = &["moqt-18", "moqt-19"]; /// /// `qmux-00.moqt-1{8,9}` is excluded: moq-transport-18 and -19 require qmux-01, so /// those pairs are illegal. -fn supported_subprotocols() -> Vec { - let mut out = Vec::with_capacity(QMUX_VERSIONS.len() * moq_net::ALPNS.len() + qmux::ALPNS.len()); - for &version in QMUX_VERSIONS { - for &alpn in moq_net::ALPNS { +fn supported_subprotocols(alpns: &[&str]) -> Vec { + let mut out = Vec::with_capacity(QMUX_VERSIONS.len() * alpns.len() + qmux::ALPNS.len()); + for &alpn in alpns { + for &version in QMUX_VERSIONS { if version == qmux::Version::QMux00 && QMUX01_ONLY_ALPNS.contains(&alpn) { continue; } @@ -400,7 +423,7 @@ mod tests { vec![Some(0xff000012), Some(0xff000013)] ); - let list = supported_subprotocols(); + let list = supported_subprotocols(moq_net::ALPNS); // Newest moq ALPN under the preferred prefix must come first so axum // picks it whenever the client offers it. @@ -440,9 +463,37 @@ mod tests { } } + #[test] + fn supported_subprotocols_only_lists_configured_alpns() { + let list = supported_subprotocols(&["moqt-16"]); + + assert!(list.contains(&"qmux-01.moqt-16".to_string())); + assert!(list.contains(&"qmux-00.moqt-16".to_string())); + assert!(list.iter().all(|entry| !entry.contains("moq-lite"))); + assert!(list.iter().all(|entry| !entry.contains("moqt-19"))); + } + + #[test] + fn supported_subprotocols_preserves_moq_preference_across_qmux_versions() { + let list = supported_subprotocols(&["moqt-16", "moqt-18"]); + let preferred = list + .iter() + .position(|entry| entry == "qmux-00.moqt-16") + .expect("missing preferred moqt-16 pair"); + let newer_qmux = list + .iter() + .position(|entry| entry == "qmux-01.moqt-18") + .expect("missing moqt-18 pair"); + + assert!( + preferred < newer_qmux, + "configured MoQ preference must outrank QMux version preference: {list:?}", + ); + } + #[test] fn subprotocols_acceptable_requires_a_match_when_any_are_offered() { - let supported = supported_subprotocols(); + let supported = supported_subprotocols(moq_net::ALPNS); let known = supported.first().expect("no supported subprotocols").clone(); // Offering nothing is the legacy route: upgrade and negotiate via SETUP. @@ -520,7 +571,7 @@ mod tests { ); // A supported identifier still upgrades. - let supported = supported_subprotocols(); + let supported = supported_subprotocols(moq_net::ALPNS); let known = supported.first().expect("no supported subprotocols"); let status = handshake_status(addr, Some(&format!("bogus-99, {known}"))).await; assert!( @@ -555,7 +606,7 @@ mod tests { let route = any(move |ws: WebSocketUpgrade| { let tx = tx.clone(); async move { - let ws = negotiate_subprotocol(ws)?; + let ws = negotiate_subprotocol(ws, moq_net::ALPNS)?; Ok::<_, StatusCode>(ws.on_upgrade(move |socket| async move { let wire = socket.protocol().and_then(|h| h.to_str().ok()).map(str::to_owned); let socket = WebSocketAdapter::new(socket); @@ -643,7 +694,7 @@ mod tests { let (addr, mut rx) = spawn_test_server().await; let url = format!("ws://{addr}/"); - for entry in supported_subprotocols() { + for entry in supported_subprotocols(moq_net::ALPNS) { // Bare fallbacks can't be offered in isolation via the qmux client API; // they're covered by `axum_ws_negotiates_newest_moq_alpn`. let Some((version, app)) = split_pair(&entry) else { @@ -767,14 +818,18 @@ mod tests { let (server_to_client, client_incoming) = mpsc::unbounded_channel(); let frozen = Arc::new(AtomicBool::new(false)); + let session = SessionInputs { + id: 0, + alpn: Some(alpn.clone()), + versions: moq_net::Versions::all(), + publish: None, + subscribe: None, + stats: Session::default(), + shutdown: crate::Shutdown::disabled(), + }; let server = tokio::spawn(handle_socket( - 0, Pipe::new(server_incoming, server_to_client, frozen.clone()), - Some(alpn.clone()), - None, - None, - Session::default(), - crate::Shutdown::disabled(), + session, )); // A real qmux peer, so the transport handshake completes and its 10s diff --git a/rs/moq-relay/tests/cluster_unknown.rs b/rs/moq-relay/tests/cluster_unknown.rs index b19246d6ab..ad8ff03994 100644 --- a/rs/moq-relay/tests/cluster_unknown.rs +++ b/rs/moq-relay/tests/cluster_unknown.rs @@ -140,10 +140,13 @@ async fn read_first_frame(port: u16) -> Result, String> { .map_err(|_| "subscriber connect timeout".to_string())? .map_err(|err| format!("subscriber connect failed: {err}"))?; - let broadcast = tokio::time::timeout(TIMEOUT, consumer.request_broadcast(PATH)) + // Wait for the announcement rather than asking the moment the session connects: + // `request_broadcast` answers on the spot, so it would race the announcement that + // makes the path routable. + let broadcast = tokio::time::timeout(TIMEOUT, consumer.announced_broadcast(PATH)) .await - .map_err(|_| "request_broadcast timed out".to_string())? - .map_err(|err| format!("request_broadcast failed: {err}"))?; + .map_err(|_| "announced_broadcast timed out".to_string())? + .ok_or_else(|| "origin closed before the broadcast was announced".to_string())?; let mut track = tokio::time::timeout(TIMEOUT, broadcast.track("video").expect("track handle").subscribe(None)) .await diff --git a/rs/moq-relay/tests/smoke.rs b/rs/moq-relay/tests/smoke.rs index 6c965ed6ce..c74a7ab20b 100644 --- a/rs/moq-relay/tests/smoke.rs +++ b/rs/moq-relay/tests/smoke.rs @@ -10,7 +10,7 @@ use std::{net::TcpListener, time::Duration}; use moq_native::moq_net::{self, Origin}; -use moq_relay::{AuthConfig, Cluster, ClusterConfig, Connection, PublicConfig, Web, WebConfig}; +use moq_relay::{AuthConfig, Cluster, ClusterConfig, Config, Connection, PublicConfig, Relay, Web, WebConfig}; const TIMEOUT: Duration = Duration::from_secs(10); @@ -119,6 +119,31 @@ async fn spawn_relay() -> (u16, tokio::task::JoinHandle<()>) { (port, handle) } +/// Stand up the assembled relay path with `--server-version` restricted. +async fn spawn_versioned_relay(versions: Vec) -> (u16, tokio::task::JoinHandle<()>) { + let port = free_tcp_port(); + let mut config = Config::default(); + config.listen.bind = Some("127.0.0.1:0".to_string()); + config.listen.tls.generate = vec!["localhost".into()]; + config.listen.version = versions; + config.web.ws = true; + config.web.http.listen = Some(format!("127.0.0.1:{port}").parse().expect("parse listen")); + + #[allow(deprecated)] + let public = PublicConfig::Simple(vec![String::new()]); + config.auth.public = Some(public); + + let relay = Relay::load(config).await.expect("load relay"); + let web = relay.web; + let (server_result_tx, mut server_result_rx) = tokio::sync::oneshot::channel(); + let handle = tokio::spawn(async move { + let _ = server_result_tx.send(web.run().await); + }); + + wait_for_http(port, &mut server_result_rx).await; + (port, handle) +} + fn client() -> moq_native::Client { client_version(None) } @@ -220,6 +245,35 @@ async fn relay_websocket_round_trip_uses_newest_version() { web_handle.abort(); } +/// `--server-version` applies to the WebSocket fallback as well as QUIC. +#[tokio::test] +async fn relay_websocket_honors_server_version() { + let allowed: moq_net::Version = "moq-transport-16".parse().expect("parse allowed version"); + let excluded = newest_lite_version(); + let (port, web_handle) = spawn_versioned_relay(vec![allowed]).await; + let url: url::Url = format!("ws://127.0.0.1:{port}/smoke").parse().expect("parse url"); + + let excluded_result = tokio::time::timeout( + TIMEOUT, + client_version(Some(excluded)).connect(url.clone()).established(), + ) + .await + .expect("excluded client connect timeout"); + assert!( + excluded_result.is_err(), + "WebSocket accepted excluded version {excluded} despite --server-version {allowed}" + ); + + let session = tokio::time::timeout(TIMEOUT, client_version(Some(allowed)).connect(url).established()) + .await + .expect("allowed client connect timeout") + .expect("allowed client connect failed"); + assert_eq!(session.version(), Some(allowed)); + + drop(session); + web_handle.abort(); +} + #[tokio::test] async fn relay_web_serves_merged_routes() { tokio::time::pause(); diff --git a/rs/moq-rtc/CHANGELOG.md b/rs/moq-rtc/CHANGELOG.md index d776373735..fb99660a96 100644 --- a/rs/moq-rtc/CHANGELOG.md +++ b/rs/moq-rtc/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.4](https://github.com/moq-dev/moq/compare/moq-rtc-v0.2.3...moq-rtc-v0.2.4) - 2026-08-14 + +### Fixed + +- *(path)* resolve catalog references like URLs ([#2855](https://github.com/moq-dev/moq/pull/2855)) +- *(rtc)* publish VP8 and VP9 dimensions ([#2845](https://github.com/moq-dev/moq/pull/2845)) + ## [0.2.3](https://github.com/moq-dev/moq/compare/moq-rtc-v0.2.2...moq-rtc-v0.2.3) - 2026-08-06 ### Added diff --git a/rs/moq-rtc/Cargo.toml b/rs/moq-rtc/Cargo.toml index a9f3c35977..f370aab7e1 100644 --- a/rs/moq-rtc/Cargo.toml +++ b/rs/moq-rtc/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.2.3" +version = "0.2.4" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-rtc/src/codec/bitstream_test.rs b/rs/moq-rtc/src/codec/bitstream_test.rs index 858cb5ab94..92c3bd61c9 100644 --- a/rs/moq-rtc/src/codec/bitstream_test.rs +++ b/rs/moq-rtc/src/codec/bitstream_test.rs @@ -89,23 +89,47 @@ async fn opus_frame_publishes_catalog_entry() { } #[tokio::test(start_paused = true)] -async fn vp9_keyframe_flag_from_uncompressed_header() { +async fn idle_video_bridges_do_not_gate_audio_catalog() { let broadcast = moq_net::broadcast::Info::new(); let mut producer = broadcast.produce(); let catalog = moq_mux::catalog::Producer::new(&mut producer).expect("catalog"); + let mut updates = catalog.consume().expect("catalog consumer"); - let mut bridge = codec::vp9::Bridge::new(producer, catalog.clone()).expect("bridge"); + let _vp8 = codec::vp8::Bridge::new(producer.clone(), catalog.clone()).expect("vp8 bridge"); + let _vp9 = codec::vp9::Bridge::new(producer.clone(), catalog.clone()).expect("vp9 bridge"); + let mut opus = codec::opus::Bridge::new(producer, catalog, 48_000, 2).expect("opus bridge"); + Bridge::push( + &mut opus, + Frame { + timestamp_us: 0, + payload: Bytes::from_static(&[0xfc, 0xff, 0xfe]), + }, + ) + .expect("push opus"); - // VP9 uncompressed header: frame_type is bit 2. 0 = keyframe, 1 = inter. - // Byte with bit 2 cleared is a keyframe; with bit 2 set is an inter frame. - let keyframe_byte = 0b1000_0010; // frame_marker=10, profile bits, frame_type=0 - let interframe_byte = 0b1000_0110; // same shape but frame_type=1 + let snapshot = tokio::time::timeout(std::time::Duration::from_millis(1), updates.next()) + .await + .expect("idle video must not gate active audio") + .expect("catalog update") + .expect("catalog snapshot"); + assert_eq!(snapshot.audio.renditions.len(), 1); + assert!(snapshot.video.renditions.is_empty()); +} + +#[tokio::test(start_paused = true)] +async fn vp9_keyframe_publishes_dimensions_and_starts_group() { + let broadcast = moq_net::broadcast::Info::new(); + let mut producer = broadcast.produce(); + let catalog = moq_mux::catalog::Producer::new(&mut producer).expect("catalog"); + + let mut bridge = codec::vp9::Bridge::new(producer, catalog.clone()).expect("bridge"); Bridge::push( &mut bridge, Frame { timestamp_us: 0, - payload: Bytes::from(vec![keyframe_byte, 0, 0]), + // VP9 profile 0 keyframe header for 320x240. + payload: Bytes::from_static(&[0x82, 0x49, 0x83, 0x42, 0x20, 0x13, 0xf0, 0x0e, 0xf0, 0x00]), }, ) .expect("keyframe accepted"); @@ -117,12 +141,17 @@ async fn vp9_keyframe_flag_from_uncompressed_header() { &mut bridge, Frame { timestamp_us: 33_000, - payload: Bytes::from(vec![interframe_byte, 0, 0]), + // frame_marker=10, profile=0, show_existing=0, frame_type=1. + payload: Bytes::from_static(&[0x84, 0x00, 0x00]), }, ) .expect("interframe accepted"); - assert_eq!(catalog.snapshot().video.renditions.len(), 1, "vp9 rendition announced"); + let snapshot = catalog.snapshot(); + assert_eq!(snapshot.video.renditions.len(), 1, "vp9 rendition announced"); + let config = snapshot.video.renditions.values().next().unwrap(); + assert_eq!(config.coded_width, Some(320)); + assert_eq!(config.coded_height, Some(240)); } // ── Egress (RTP-out) round-trip tests ───────────────────────────────────── diff --git a/rs/moq-rtc/src/codec/mod.rs b/rs/moq-rtc/src/codec/mod.rs index 3d386de351..e5d2f94ea5 100644 --- a/rs/moq-rtc/src/codec/mod.rs +++ b/rs/moq-rtc/src/codec/mod.rs @@ -51,18 +51,115 @@ pub trait Bridge: Send { fn abort(self: Box, err: moq_net::Error); } -/// A bridge's video catalog entry, removed however the bridge ends. -/// -/// A separate value rather than a `Drop` on the bridge itself, so a bridge's terminal -/// [`Bridge::abort`] can consume its track producer. -pub(crate) struct VideoRendition { - pub catalog: moq_mux::catalog::Producer, - pub name: String, +/// A mux importer whose catalog configuration is resolved from its first frame. +pub(crate) trait DeferredImport: Send + Sized { + /// Create the importer and its unresolved catalog rendition. + fn create(track: moq_net::track::Producer, reserved: moq_mux::catalog::Reserved) -> moq_mux::Result; + + /// Decode one complete codec frame. + fn decode(&mut self, frame: Bytes, pts: moq_net::Timestamp) -> moq_mux::Result<()>; + + /// Abort the active media track. + fn abort(self, err: moq_net::Error); +} + +impl DeferredImport for moq_mux::codec::vp8::Import { + fn create(track: moq_net::track::Producer, reserved: moq_mux::catalog::Reserved) -> moq_mux::Result { + Self::new(track, reserved, Default::default()) + } + + fn decode(&mut self, frame: Bytes, pts: moq_net::Timestamp) -> moq_mux::Result<()> { + moq_mux::codec::vp8::Import::decode(self, frame, Some(pts)) + } + + fn abort(self, err: moq_net::Error) { + moq_mux::codec::vp8::Import::abort(self, err); + } +} + +impl DeferredImport for moq_mux::codec::vp9::Import { + fn create(track: moq_net::track::Producer, reserved: moq_mux::catalog::Reserved) -> moq_mux::Result { + Self::new(track, reserved, Default::default()) + } + + fn decode(&mut self, frame: Bytes, pts: moq_net::Timestamp) -> moq_mux::Result<()> { + moq_mux::codec::vp9::Import::decode(self, frame, Some(pts)) + } + + fn abort(self, err: moq_net::Error) { + moq_mux::codec::vp9::Import::abort(self, err); + } +} + +struct PendingVideo { + track: moq_net::track::Producer, + catalog: moq_mux::catalog::Producer, +} + +enum DeferredState { + Pending(Box), + Active(Box), + Failed(Box), + Poisoned, +} + +/// Defers a video importer's catalog reservation until its first frame. +pub(crate) struct DeferredVideo { + state: DeferredState, } -impl Drop for VideoRendition { - fn drop(&mut self) { - self.catalog.lock().video.renditions.remove(&self.name); +impl DeferredVideo { + /// Create the media track without gating the initial catalog snapshot. + pub fn new( + mut broadcast: moq_net::broadcast::Producer, + catalog: moq_mux::catalog::Producer, + suffix: &str, + ) -> Result { + let track = broadcast.unique_track(suffix, catalog.track_info())?; + Ok(Self { + state: DeferredState::Pending(Box::new(PendingVideo { track, catalog })), + }) + } + + /// Decode a frame, creating the importer on first use. + pub fn decode(&mut self, frame: Bytes, pts: moq_net::Timestamp) -> Result<()> { + if let DeferredState::Active(import) = &mut self.state { + return import.decode(frame, pts).map_err(Into::into); + } + + let DeferredState::Pending(pending) = std::mem::replace(&mut self.state, DeferredState::Poisoned) else { + return Err(crate::Error::Other(anyhow::anyhow!( + "video bridge initialization already failed" + ))); + }; + let reserved = pending.catalog.reserve(); + let abort = pending.track.clone(); + let import = match I::create(pending.track, reserved) { + Ok(import) => import, + Err(err) => { + self.state = DeferredState::Failed(Box::new(abort)); + return Err(err.into()); + } + }; + self.state = DeferredState::Active(Box::new(import)); + let DeferredState::Active(import) = &mut self.state else { + unreachable!(); + }; + import.decode(frame, pts).map_err(Into::into) + } + + /// Abort the media track in either lifecycle state. + pub fn abort(self, err: moq_net::Error) { + match self.state { + DeferredState::Pending(pending) => { + let _ = pending.track.abort(err); + } + DeferredState::Active(import) => import.abort(err), + DeferredState::Failed(track) => { + let _ = track.abort(err); + } + DeferredState::Poisoned => {} + } } } diff --git a/rs/moq-rtc/src/codec/vp8.rs b/rs/moq-rtc/src/codec/vp8.rs index 7eeba3eae7..6eb0f16850 100644 --- a/rs/moq-rtc/src/codec/vp8.rs +++ b/rs/moq-rtc/src/codec/vp8.rs @@ -1,69 +1,86 @@ //! VP8 bridge. //! -//! VP8 carries no out-of-band config record. str0m hands us complete frames -//! and we forward them to a `.vp8` track with the matching catalog entry. -//! Keyframes are detected from the first byte (P-frame bit, RFC 6386 §9.1). +//! str0m hands us complete VP8 frames, which is exactly the raw shape that +//! [`moq_mux::codec::vp8::Import`] consumes. The shared importer parses keyframes +//! so the catalog carries the encoded dimensions and stays in sync if they change. use crate::{Result, codec}; -/// Forwards str0m's VP8 frames to a `.vp8` track, detecting keyframes inline. +/// Bridges str0m VP8 frames into a MoQ VP8 track. pub struct Bridge { - /// Owns the catalog rendition, retiring it when the bridge goes away. - rendition: codec::VideoRendition, - track: moq_mux::container::Producer, - announced: bool, + import: codec::DeferredVideo, } impl Bridge { - /// Publish a `.vp8` track on `broadcast`; the catalog rendition is added on the first frame. - pub fn new(mut broadcast: moq_net::broadcast::Producer, mut catalog: moq_mux::catalog::Producer) -> Result { - let track = broadcast.create_track(broadcast.unique_name(".vp8"), catalog.track_info())?; - let name = track.name().to_string(); - let producer = catalog.media_producer(track, moq_mux::catalog::hang::Container::Legacy)?; - Ok(Self { - rendition: codec::VideoRendition { catalog, name }, - track: producer, - announced: false, - }) - } - - fn announce(&mut self) -> Result<()> { - if self.announced { - return Ok(()); - } - let name = self.rendition.name.clone(); - let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); - config.container = hang::catalog::Container::Legacy; - // Publish explicitly rather than through the guard's drop, which only warns: - // marking the rendition announced when the catalog never took it would leave the - // media track advertised nowhere, and `announced` latches so we'd never retry. - let mut guard = self.rendition.catalog.lock(); - guard.video.renditions.insert(name, config); - guard.commit()?; - self.announced = true; - Ok(()) + /// Publish a `.vp8` track on `broadcast`, adding the catalog rendition once config is known. + pub fn new(broadcast: moq_net::broadcast::Producer, catalog: moq_mux::catalog::Producer) -> Result { + let import = codec::DeferredVideo::new(broadcast, catalog, ".vp8")?; + Ok(Self { import }) } } impl codec::Bridge for Bridge { fn push(&mut self, frame: codec::Frame) -> Result<()> { - self.announce()?; let pts = moq_net::Timestamp::from_micros(frame.timestamp_us) .map_err(|err| crate::Error::Other(anyhow::anyhow!("invalid timestamp: {err}")))?; - // VP8: first byte bit 0 == 0 means keyframe (RFC 6386 §9.1). - let keyframe = frame.payload.first().map(|b| b & 0x01 == 0).unwrap_or(false); - self.track - .write(moq_mux::container::Frame { - timestamp: pts, - payload: frame.payload, - keyframe, - duration: None, - }) - .map_err(|err| crate::Error::Other(anyhow::anyhow!("vp8 track write failed: {err}")))?; - Ok(()) + self.import.decode(frame.payload, pts) } fn abort(self: Box, err: moq_net::Error) { - self.track.abort(err); + self.import.abort(err); + } +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + + use crate::codec::{self, Bridge as _}; + + #[test] + fn keyframe_publishes_catalog_dimensions() { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap(); + let mut bridge = super::Bridge::new(broadcast, catalog.clone()).unwrap(); + + assert!(catalog.snapshot().video.renditions.is_empty()); + + // VP8 keyframe header for 320x240. + bridge + .push(codec::Frame { + timestamp_us: 0, + payload: Bytes::from_static(&[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00]), + }) + .unwrap(); + + let snapshot = catalog.snapshot(); + let config = snapshot.video.renditions.values().next().unwrap(); + assert_eq!(config.coded_width, Some(320)); + assert_eq!(config.coded_height, Some(240)); + } + + #[tokio::test] + async fn importer_creation_failure_preserves_abort_error() { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap(); + let _collision = broadcast.create_track(hang::timeline::DEFAULT_NAME, None).unwrap(); + let consumer = broadcast.consume(); + let mut bridge = super::Bridge::new(broadcast, catalog).unwrap(); + let mut track = consumer.track("0.vp8").unwrap().subscribe(None).await.unwrap(); + + let result = bridge.push(codec::Frame { + timestamp_us: 0, + payload: Bytes::from_static(&[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00]), + }); + assert!(result.is_err(), "timeline collision must fail importer creation"); + + Box::new(bridge).abort(moq_net::Error::Transport("session failed".into())); + let Err(error) = track.recv_group().await else { + panic!("aborted track must fail"); + }; + assert!(matches!( + error, + moq_net::Error::Transport(message) if message == "session failed" + )); } } diff --git a/rs/moq-rtc/src/codec/vp9.rs b/rs/moq-rtc/src/codec/vp9.rs index 30012f336b..ccad7e3884 100644 --- a/rs/moq-rtc/src/codec/vp9.rs +++ b/rs/moq-rtc/src/codec/vp9.rs @@ -1,123 +1,61 @@ //! VP9 bridge. //! -//! Keyframes are detected from the frame_type bit (RFC 8741 §3 / VP9 spec §6.2: -//! the second bit of the uncompressed header). +//! str0m hands us complete VP9 frames, which is exactly the raw shape that +//! [`moq_mux::codec::vp9::Import`] consumes. The shared importer parses keyframes +//! so the catalog carries the encoded dimensions and stays in sync if they change. use crate::{Result, codec}; -/// Forwards str0m's VP9 frames to a `.vp9` track, detecting keyframes inline. +/// Bridges str0m VP9 frames into a MoQ VP9 track. pub struct Bridge { - /// Owns the catalog rendition, retiring it when the bridge goes away. - rendition: codec::VideoRendition, - track: moq_mux::container::Producer, - announced: bool, + import: codec::DeferredVideo, } impl Bridge { - /// Publish a `.vp9` track on `broadcast`; the catalog rendition is added on the first frame. - pub fn new(mut broadcast: moq_net::broadcast::Producer, mut catalog: moq_mux::catalog::Producer) -> Result { - let track = broadcast.create_track(broadcast.unique_name(".vp9"), catalog.track_info())?; - let name = track.name().to_string(); - let producer = catalog.media_producer(track, moq_mux::catalog::hang::Container::Legacy)?; - Ok(Self { - rendition: codec::VideoRendition { catalog, name }, - track: producer, - announced: false, - }) - } - - fn announce(&mut self) -> Result<()> { - if self.announced { - return Ok(()); - } - let name = self.rendition.name.clone(); - let mut config = hang::catalog::VideoConfig::new(hang::catalog::VP9::default()); - config.container = hang::catalog::Container::Legacy; - // Publish explicitly rather than through the guard's drop, which only warns: - // marking the rendition announced when the catalog never took it would leave the - // media track advertised nowhere, and `announced` latches so we'd never retry. - let mut guard = self.rendition.catalog.lock(); - guard.video.renditions.insert(name, config); - guard.commit()?; - self.announced = true; - Ok(()) + /// Publish a `.vp9` track on `broadcast`, adding the catalog rendition once config is known. + pub fn new(broadcast: moq_net::broadcast::Producer, catalog: moq_mux::catalog::Producer) -> Result { + let import = codec::DeferredVideo::new(broadcast, catalog, ".vp9")?; + Ok(Self { import }) } } impl codec::Bridge for Bridge { fn push(&mut self, frame: codec::Frame) -> Result<()> { - self.announce()?; let pts = moq_net::Timestamp::from_micros(frame.timestamp_us) .map_err(|err| crate::Error::Other(anyhow::anyhow!("invalid timestamp: {err}")))?; - let keyframe = is_keyframe(&frame.payload); - self.track - .write(moq_mux::container::Frame { - timestamp: pts, - payload: frame.payload, - keyframe, - duration: None, - }) - .map_err(|err| crate::Error::Other(anyhow::anyhow!("vp9 track write failed: {err}")))?; - Ok(()) + self.import.decode(frame.payload, pts) } fn abort(self: Box, err: moq_net::Error) { - self.track.abort(err); - } -} - -/// Detect a VP9 keyframe from the uncompressed header's first byte (VP9 spec -/// §6.2), reading bits MSB-first: `frame_marker(2)`, `profile_low(1)`, -/// `profile_high(1)`, a `reserved(1)` bit only when profile == 3, -/// `show_existing_frame(1)`, then `frame_type(1)` (0 == KEY_FRAME). A -/// show-existing frame carries no frame_type and is never a keyframe. -fn is_keyframe(payload: &[u8]) -> bool { - let Some(&b) = payload.first() else { - return false; - }; - let profile = (((b >> 4) & 1) << 1) | ((b >> 5) & 1); // (high << 1) | low - // Bits consumed from the MSB: 2 (marker) + 2 (profile), plus profile 3's reserved bit. - let mut pos = 4; - if profile == 3 { - pos += 1; + self.import.abort(err); } - let show_existing_frame = (b >> (7 - pos)) & 1; - if show_existing_frame == 1 { - return false; - } - pos += 1; - let frame_type = (b >> (7 - pos)) & 1; - frame_type == 0 } #[cfg(test)] mod tests { - use super::is_keyframe; + use bytes::Bytes; - // frame_marker = 0b10 in the top two bits for every well-formed header. - #[test] - fn profile0_keyframe_and_interframe() { - // profile 0, show_existing_frame = 0, frame_type = 0 (key) / 1 (inter). - assert!(is_keyframe(&[0b1000_0010])); - assert!(!is_keyframe(&[0b1000_0110])); - } - - #[test] - fn profile0_show_existing_frame_is_not_keyframe() { - // profile 0, show_existing_frame = 1: no frame_type follows. - assert!(!is_keyframe(&[0b1000_1000])); - } + use crate::codec::{self, Bridge as _}; #[test] - fn profile3_keyframe_and_interframe() { - // profile 3 (both profile bits set) inserts a reserved bit before - // show_existing_frame, shifting frame_type one position right. - assert!(is_keyframe(&[0b1011_0000])); // reserved=0, show=0, frame_type=0 - assert!(!is_keyframe(&[0b1011_0010])); // frame_type=1 - } + fn keyframe_publishes_catalog_dimensions() { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap(); + let mut bridge = super::Bridge::new(broadcast, catalog.clone()).unwrap(); + + assert!(catalog.snapshot().video.renditions.is_empty()); + + // VP9 profile 0 keyframe header for 320x240. + bridge + .push(codec::Frame { + timestamp_us: 0, + payload: Bytes::from_static(&[0x82, 0x49, 0x83, 0x42, 0x20, 0x13, 0xf0, 0x0e, 0xf0, 0x00]), + }) + .unwrap(); - #[test] - fn empty_payload_is_not_keyframe() { - assert!(!is_keyframe(&[])); + let snapshot = catalog.snapshot(); + let config = snapshot.video.renditions.values().next().unwrap(); + assert_eq!(config.coded_width, Some(320)); + assert_eq!(config.coded_height, Some(240)); } } diff --git a/rs/moq-rtc/src/egress.rs b/rs/moq-rtc/src/egress.rs index e57771ffba..6e428de958 100644 --- a/rs/moq-rtc/src/egress.rs +++ b/rs/moq-rtc/src/egress.rs @@ -166,11 +166,14 @@ impl EgressSource { .audio .renditions .values() - .any(|r| matches!(r.codec, AudioCodec::Opus)) + .any(|r| matches!(r.codec, AudioCodec::Opus) && valid_reference(&self.source, r.broadcast.as_ref())) { out.push(Codec::Opus); } for rendition in self.catalog.video.renditions.values() { + if !valid_reference(&self.source, rendition.broadcast.as_ref()) { + continue; + } let codec = match rendition.codec.kind() { VideoCodecKind::H264 => Some(Codec::H264), VideoCodecKind::H265 => Some(Codec::H265), @@ -189,6 +192,10 @@ impl EgressSource { } } +fn valid_reference(source: &moq_mux::Source, broadcast: Option<&moq_net::PathRelative<'_>>) -> bool { + source.resolve_reference(broadcast).is_some() +} + /// Find the first catalog rendition for the given codec and build a /// [`codec::Track`] subscribed to it, honoring an optional cross-broadcast /// reference (the rendition's catalog `broadcast` field). Returns `None` if no @@ -196,11 +203,10 @@ impl EgressSource { async fn pick_track(source: &moq_mux::Source, catalog: &Catalog, codec: Codec) -> Result> { match codec { Codec::Opus => { - let Some((name, config)) = catalog - .audio - .renditions - .iter() - .find(|(_, c)| matches!(c.codec, AudioCodec::Opus)) + let Some((name, config)) = + catalog.audio.renditions.iter().find(|(_, c)| { + matches!(c.codec, AudioCodec::Opus) && valid_reference(source, c.broadcast.as_ref()) + }) else { return Ok(None); }; @@ -216,7 +222,12 @@ async fn pick_track(source: &moq_mux::Source, catalog: &Catalog, codec: Codec) - Codec::Av1 => VideoCodecKind::AV1, _ => unreachable!(), }; - let Some((name, config)) = catalog.video.renditions.iter().find(|(_, c)| c.codec.kind() == target) else { + let Some((name, config)) = catalog + .video + .renditions + .iter() + .find(|(_, c)| c.codec.kind() == target && valid_reference(source, c.broadcast.as_ref())) + else { return Ok(None); }; let track = source.subscribe_track(config.broadcast.as_ref(), name).await?; @@ -288,6 +299,42 @@ pub fn dispatch(rtc: &mut str0m::Rtc, request: WriteRequest, wallclock: Instant) #[cfg(test)] mod tests { use super::*; + use hang::catalog::{AudioConfig, H264, VideoCodec, VideoConfig}; + use moq_net::{Origin, PathRelative}; + + #[test] + fn catalog_codecs_ignores_codecs_available_only_via_escaping_references() { + let origin = Origin::random().produce(); + let source = moq_mux::Source::new(origin.consume(), "a/pub"); + let mut catalog = Catalog::default(); + + let mut escaped_audio = AudioConfig::new(AudioCodec::Opus, 48_000, 2); + escaped_audio.broadcast = Some(PathRelative::new("../../source").to_owned()); + catalog.audio.renditions.insert("opus".to_string(), escaped_audio); + + let mut escaped_video = VideoConfig::new(H264 { + profile: 0x42, + constraints: 0, + level: 0x1e, + inline: false, + }); + escaped_video.broadcast = Some(PathRelative::new("../../source").to_owned()); + catalog.video.renditions.insert("h264".to_string(), escaped_video); + + let mut valid_video = VideoConfig::new(VideoCodec::VP8); + valid_video.broadcast = Some(PathRelative::new("./source").to_owned()); + catalog.video.renditions.insert("vp8".to_string(), valid_video); + + let (writes_tx, writes_rx) = mpsc::channel(1); + let egress = EgressSource { + source, + catalog, + writes_tx, + writes_rx: Some(writes_rx), + }; + + assert_eq!(egress.catalog_codecs(), vec![Codec::Vp8]); + } #[test] fn egress_clock_ignores_cross_track_dequeue_jitter() { diff --git a/rs/moq-rtc/src/server/whep.rs b/rs/moq-rtc/src/server/whep.rs index 2b36d0c922..dc0f44894c 100644 --- a/rs/moq-rtc/src/server/whep.rs +++ b/rs/moq-rtc/src/server/whep.rs @@ -103,7 +103,7 @@ pub async fn accept( let offer = sdp::parse_offer(offer)?; // Resolve the broadcast on the subscribe origin by path. The `Source` fetches it (and any - // sibling broadcast a rendition's catalog `broadcast` field references, e.g. `../source`) + // sibling broadcast a rendition's catalog `broadcast` field references, e.g. `./source`) // via `request_broadcast`, which resolves an announced broadcast immediately and falls back // to a dynamic handler; with neither it errors and the WHEP client retries (typical). let broadcast = broadcast.as_path().to_string(); diff --git a/rs/moq-token-cli/CHANGELOG.md b/rs/moq-token-cli/CHANGELOG.md index a26c128efb..62a39a8e9c 100644 --- a/rs/moq-token-cli/CHANGELOG.md +++ b/rs/moq-token-cli/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.44](https://github.com/moq-dev/moq/compare/moq-token-cli-v0.5.43...moq-token-cli-v0.5.44) - 2026-08-14 + +### Other + +- update Cargo.lock dependencies + ## [0.5.43](https://github.com/moq-dev/moq/compare/moq-token-cli-v0.5.42...moq-token-cli-v0.5.43) - 2026-08-07 ### Other diff --git a/rs/moq-token-cli/Cargo.toml b/rs/moq-token-cli/Cargo.toml index 87c8077c25..fea64204e7 100644 --- a/rs/moq-token-cli/Cargo.toml +++ b/rs/moq-token-cli/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.5.43" +version = "0.5.44" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-transcode/CHANGELOG.md b/rs/moq-transcode/CHANGELOG.md index 73a4290436..2f64e51dcc 100644 --- a/rs/moq-transcode/CHANGELOG.md +++ b/rs/moq-transcode/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.11](https://github.com/moq-dev/moq/compare/moq-transcode-v0.0.10...moq-transcode-v0.0.11) - 2026-08-14 + +### Fixed + +- *(path)* resolve catalog references like URLs ([#2855](https://github.com/moq-dev/moq/pull/2855)) +- *(net)* stop blocking connect on the initial announce set ([#2856](https://github.com/moq-dev/moq/pull/2856)) + ## [0.0.10](https://github.com/moq-dev/moq/compare/moq-transcode-v0.0.9...moq-transcode-v0.0.10) - 2026-08-13 ### Added diff --git a/rs/moq-transcode/Cargo.toml b/rs/moq-transcode/Cargo.toml index 7665682357..2bfebe3036 100644 --- a/rs/moq-transcode/Cargo.toml +++ b/rs/moq-transcode/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.10" +version = "0.0.11" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-transcode/README.md b/rs/moq-transcode/README.md index c4f3995c0a..a55ddb044b 100644 --- a/rs/moq-transcode/README.md +++ b/rs/moq-transcode/README.md @@ -37,8 +37,8 @@ broadcast. ```rust let mut config = moq_transcode::Config::default(); // The derivative is announced at `/transcode.hang`, so the source -// renditions are referenced one level up. -config.source = Some(moq_net::PathRelativeOwned::from("..".to_string())); +// renditions are referenced through its parent. +config.source = Some(moq_net::PathRelativeOwned::from(".".to_string())); let output = origin.create_broadcast( format!("{path}/transcode.hang"), diff --git a/rs/moq-transcode/examples/transcode.rs b/rs/moq-transcode/examples/transcode.rs index f2b550bc45..79212a6e97 100644 --- a/rs/moq-transcode/examples/transcode.rs +++ b/rs/moq-transcode/examples/transcode.rs @@ -6,9 +6,9 @@ // cargo run -p moq-transcode --example transcode -- \ // --url http://localhost:4443/anon --source my-broadcast // -// The derivative appears at `/transcode.hang`: its catalog references -// the source renditions via a relative `broadcast: ".."` pointer and adds the -// ladder rungs, which are only encoded while someone watches (or fetches) them. +// When the derivative is nested beneath the source, its catalog references the +// source renditions relatively and adds the ladder rungs. An unrelated output +// omits the passthrough renditions. Rungs are only encoded while watched or fetched. use anyhow::Context; use clap::Parser; @@ -32,10 +32,12 @@ struct Args { async fn main() -> anyhow::Result<()> { moq_native::Log::new(tracing::Level::INFO).init()?; let args = Args::parse(); - let output_path = args - .output - .clone() - .unwrap_or_else(|| format!("{}/transcode.hang", args.source)); + let source_path = moq_net::PathOwned::from(args.source); + let output_path = moq_net::PathOwned::from( + args.output + .clone() + .unwrap_or_else(|| format!("{source_path}/transcode.hang")), + ); // Publish the derivative through one origin and consume the source through // another, over a single auto-reconnecting session. @@ -43,31 +45,44 @@ async fn main() -> anyhow::Result<()> { let remote = moq_net::Origin::random().produce(); let client = moq_native::connect::Config::default().init(Default::default())?; - let mut session = client + let session = client .with_publisher(&publish) .with_subscriber(remote.clone()) .connect(args.url.clone()); - // Wait for the first session: the origin can't route a broadcast request - // until a connected session registers its handler. - while !matches!(session.status().await?, moq_native::Status::Connected) {} + // Wait for the source to be announced rather than for the session to connect: + // `request_broadcast` answers on the spot, so asking the moment a session exists + // races the announcement that makes the path routable. + // + // Raced against the session ending, since the wait itself never fails: the origin + // outlives the session here, so a rejected token or an exhausted retry budget would + // otherwise leave us waiting for an announcement that can never arrive. + let consumer = remote.consume(); + tokio::select! { + announced = consumer.announced_broadcast(&source_path) => { + announced.context("origin closed before the source broadcast was announced")?; + } + closed = session.closed() => { + closed.context("session failed before the source broadcast was announced")?; + anyhow::bail!("session closed before the source broadcast was announced"); + } + } - // Request the source broadcast; the session subscribes upstream on demand. - let source = remote - .consume() - .request_broadcast(&args.source) + // Resolve it for real; the session subscribes upstream on demand. + let source = consumer + .request_broadcast(&source_path) .await .context("source broadcast unavailable")?; let mut config = moq_transcode::Config::default(); - // The derivative lives one level below the source, so the source is `..`. - // The default ladder and encoder (hardware first: NVENC on Linux) apply. - config.source = Some(moq_net::PathRelativeOwned::from("..".to_string())); + // Reference the source when the normalized output is nested beneath it. The + // default ladder and encoder (hardware first: NVENC on Linux) apply. + config.source = moq_transcode::source_reference(&source_path, &output_path); let output = publish .create_broadcast(&output_path, moq_net::broadcast::Route::new().with_announce(true)) .context("failed to create the derivative broadcast")?; - tracing::info!(source = %args.source, output = %output_path, "transcoding"); + tracing::info!(source = %source_path, output = %output_path, "transcoding"); tokio::select! { res = moq_transcode::run(source, output, config) => Ok(res?), diff --git a/rs/moq-transcode/src/catalog.rs b/rs/moq-transcode/src/catalog.rs index 102847ca35..a3cfba2413 100644 --- a/rs/moq-transcode/src/catalog.rs +++ b/rs/moq-transcode/src/catalog.rs @@ -319,7 +319,7 @@ mod tests { video.insert("low", source(640, 360, None)).unwrap(); video.insert("high", source(1920, 1080, None)).unwrap(); let mut remote = source(3840, 2160, None); - remote.broadcast = Some(PathRelativeOwned::from("../other".to_string())); + remote.broadcast = Some(PathRelativeOwned::from("./other".to_string())); video.insert("remote", remote).unwrap(); let (name, config) = choose_source(&video).unwrap(); diff --git a/rs/moq-transcode/src/config.rs b/rs/moq-transcode/src/config.rs index b8314e96c9..f7eae38c1d 100644 --- a/rs/moq-transcode/src/config.rs +++ b/rs/moq-transcode/src/config.rs @@ -1,6 +1,27 @@ //! Transcoder configuration: the rung ladder and catalog wiring. -use moq_net::PathRelativeOwned; +use moq_net::{AsPath, PathRelativeOwned}; + +/// Compute the source broadcast reference for an output nested beneath it. +/// +/// Both paths are normalized before comparison. Returns `None` when `output` is +/// not a descendant of `source`, so callers can omit passthrough renditions. +pub fn source_reference(source: impl AsPath, output: impl AsPath) -> Option { + let source = source.as_path(); + let output = output.as_path(); + let rest = output.strip_prefix(&source)?; + if rest.is_empty() { + return None; + } + + let parents = rest.parts().count().saturating_sub(1); + let rel = if parents == 0 { + ".".to_string() + } else { + vec![".."; parents].join("/") + }; + Some(PathRelativeOwned::from(rel)) +} /// One candidate output rendition: a target resolution (by height) and bitrate. /// @@ -39,7 +60,7 @@ pub struct Config { pub rungs: Vec, /// Where the source broadcast lives relative to the output broadcast, e.g. - /// `".."` when the output is published at `/transcode.hang`. When + /// `"."` when the output is published at `/transcode.hang`. When /// set, the derivative catalog references the source renditions (all video /// and audio) through this path so players fetch them from the source /// directly; the transcoder never proxies or subscribes them. `None` omits @@ -79,3 +100,24 @@ impl Default for Config { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn source_reference_normalizes_and_counts_output_depth() { + assert_eq!(source_reference("a/b", "a/b/transcode.hang").unwrap().as_str(), "."); + assert_eq!(source_reference("/a//b/", "a/b/dir/").unwrap().as_str(), "."); + assert_eq!( + source_reference("a/b", "a/b/dir/transcode.hang").unwrap().as_str(), + ".." + ); + assert_eq!( + source_reference("a/b", "a/b/one/two/transcode.hang").unwrap().as_str(), + "../.." + ); + assert!(source_reference("a/b", "other/transcode.hang").is_none()); + assert!(source_reference("a/b", "a/b").is_none()); + } +} diff --git a/rs/moq-transcode/src/lib.rs b/rs/moq-transcode/src/lib.rs index 6a9291a00d..272f4bcca4 100644 --- a/rs/moq-transcode/src/lib.rs +++ b/rs/moq-transcode/src/lib.rs @@ -27,7 +27,7 @@ mod error; mod feed; mod rung; -pub use config::{Config, Rung}; +pub use config::{Config, Rung, source_reference}; pub use error::Error; /// Transcode `source` into `output` until the source broadcast ends. @@ -505,7 +505,7 @@ mod tests { rungs: vec![Rung::new(120, 100_000)], encoder: moq_video::encode::Kind::Software, decoder: moq_video::decode::Kind::Software, - source: Some(moq_net::PathRelativeOwned::from("..".to_string())), + source: Some(moq_net::PathRelativeOwned::from(".".to_string())), ..Default::default() }; @@ -548,7 +548,7 @@ mod tests { assert!(rung.codec.to_string().starts_with("avc3.")); let passthrough = derived.video.renditions.get("video").expect("passthrough missing"); - assert_eq!(passthrough.broadcast.as_ref().map(|b| b.as_ref()), Some("..")); + assert_eq!(passthrough.broadcast.as_ref().map(|b| b.as_ref()), Some(".")); // Subscribing to the rung starts the live loop, which mirrors source // group sequences 1:1. diff --git a/rs/moq-video/CHANGELOG.md b/rs/moq-video/CHANGELOG.md index 6ad935daec..ba1d2372ce 100644 --- a/rs/moq-video/CHANGELOG.md +++ b/rs/moq-video/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.17](https://github.com/moq-dev/moq/compare/moq-video-v0.0.16...moq-video-v0.0.17) - 2026-08-14 + +### Added + +- *(libmoq)* declare the catalog container for manually authored renditions ([#2805](https://github.com/moq-dev/moq/pull/2805)) + ## [0.0.16](https://github.com/moq-dev/moq/compare/moq-video-v0.0.15...moq-video-v0.0.16) - 2026-08-13 ### Added diff --git a/rs/moq-video/Cargo.toml b/rs/moq-video/Cargo.toml index 7f163cdc13..0b67574028 100644 --- a/rs/moq-video/Cargo.toml +++ b/rs/moq-video/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.16" +version = "0.0.17" edition = "2024" rust-version.workspace = true diff --git a/swift/Sources/Moq/Aliases.swift b/swift/Sources/Moq/Aliases.swift index 88b81b5ae4..92c984b693 100644 --- a/swift/Sources/Moq/Aliases.swift +++ b/swift/Sources/Moq/Aliases.swift @@ -14,8 +14,8 @@ public typealias MediaFrame = MoqFFI.MoqMediaFrame /// The JSON manifest describing a broadcast's tracks: video and audio /// renditions, display geometry, and untyped application sections. public typealias Catalog = MoqFFI.MoqCatalog -/// A video rendition in the catalog: codec, coded/display dimensions, bitrate, -/// framerate, and container. +/// A video rendition in the catalog: codec, dimensions, bitrate, temporary +/// avoidance recommendation, framerate, and container. public typealias Video = MoqFFI.MoqVideo /// Caller-provided catalog fields for a video track. public typealias VideoHint = MoqFFI.MoqVideoHint