From 49b969e081459d8c3d9a2ad52db6fc1bd55ee95a Mon Sep 17 00:00:00 2001 From: "every.channel" Date: Tue, 17 Feb 2026 02:00:38 -0800 Subject: [PATCH] ec-node: wt-publish via moq-transport (draft-07) --- Cargo.lock | 329 +++++++++++++++++- apps/web/app.js | 4 +- crates/ec-node/Cargo.toml | 3 + crates/ec-node/src/main.rs | 83 +++-- .../ECP-0063-cloudflare-moq-webtransport.md | 9 +- nix/modules/ec-node.nix | 9 + 6 files changed, 385 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9da631e..15178ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1085,6 +1085,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -1108,7 +1118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -1121,7 +1131,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -1825,6 +1835,9 @@ dependencies = [ "just-webrtc", "moq-mux", "moq-native", + "moq-native-ietf", + "moq-pub", + "moq-transport", "reqwest", "serde", "serde_json", @@ -2019,12 +2032,35 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "env_filter" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +dependencies = [ + "log", + "regex", +] + [[package]] name = "env_home" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" +[[package]] +name = "env_logger" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -2219,6 +2255,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "four-cc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795cbfc56d419a7ce47ccbb7504dd9a5b7c484c083c356e797de08bd988d9629" + [[package]] name = "fs_extra" version = "1.3.0" @@ -3667,6 +3709,30 @@ dependencies = [ "system-deps", ] +[[package]] +name = "jiff" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c867c356cc096b33f4981825ab281ecba3db0acefe60329f044c1789d94c6543" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7946b4325269738f270bb55b3c19ab5c5040525f83fd625259422a9d25d9be5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "jni" version = "0.21.1" @@ -4088,6 +4154,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "moq-catalog" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b9cf3655b840e98b6527e75975b9406560bdd20a637223e653f04464e114f17" +dependencies = [ + "serde", +] + [[package]] name = "moq-lite" version = "0.10.1" @@ -4169,7 +4244,7 @@ dependencies = [ "rcgen 0.14.7", "reqwest", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pemfile", "rustls-webpki", "serde", @@ -4179,10 +4254,91 @@ dependencies = [ "tracing", "tracing-subscriber", "url", - "web-transport-quinn", + "web-transport-quinn 0.11.4", "web-transport-ws", ] +[[package]] +name = "moq-native-ietf" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e8e6cb8df625bcf7c5e09d18b155467aa36fe84a47b95af55b485f5613fd91c" +dependencies = [ + "anyhow", + "clap", + "futures", + "hex", + "log", + "moq-transport", + "quinn", + "rand 0.8.5", + "ring", + "rustls", + "rustls-native-certs 0.7.3", + "rustls-pemfile", + "tokio", + "url", + "web-transport", + "web-transport-quinn 0.3.4", + "webpki", +] + +[[package]] +name = "moq-pub" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c47f3c2fbd884037543088a44c36d8a82388a3c38fde13c4e1946f61b94f0d45" +dependencies = [ + "anyhow", + "bytes", + "clap", + "env_logger", + "log", + "moq-catalog", + "moq-native-ietf", + "moq-transport", + "mp4", + "rfc6381-codec", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "moq-transport" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50d2f896962af0634a5b71f274a07590fbbe21f30c89d986066479078644b477" +dependencies = [ + "bytes", + "futures", + "log", + "paste", + "serde", + "serde_json", + "serde_with", + "thiserror 1.0.69", + "tokio", + "uuid", + "web-transport", +] + +[[package]] +name = "mp4" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3" +dependencies = [ + "byteorder", + "bytes", + "num-rational", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "mp4-atom" version = "0.10.1" @@ -4199,6 +4355,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "mp4ra-rust" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdbc3d3867085d66ac6270482e66f3dd2c5a18451a3dc9ad7269e94844a536b7" +dependencies = [ + "four-cc", +] + +[[package]] +name = "mpeg4-audio-const" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a1fe2275b68991faded2c80aa4a33dba398b77d276038b8f50701a22e55918" + [[package]] name = "muda" version = "0.17.1" @@ -4585,6 +4756,7 @@ dependencies = [ "num-bigint", "num-integer", "num-traits", + "serde", ] [[package]] @@ -4913,6 +5085,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -5354,6 +5532,15 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "portable-atomic-util" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +dependencies = [ + "portable-atomic", +] + [[package]] name = "portmapper" version = "0.13.0" @@ -5516,6 +5703,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "qlog" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f15b83c59e6b945f2261c95a1dd9faf239187f32ff0a96af1d1d28c4557f919" +dependencies = [ + "serde", + "serde_json", + "serde_with", + "smallvec", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -5556,6 +5755,7 @@ dependencies = [ "fastbloom", "getrandom 0.3.4", "lru-slab", + "qlog", "rand 0.9.2", "ring", "rustc-hash", @@ -5858,6 +6058,16 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "rfc6381-codec" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed54c20f5c3ec82eab6d998b313dc75ec5d5650d4f57675e61d72489040297fd" +dependencies = [ + "mp4ra-rust", + "mpeg4-audio-const", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -5979,16 +6189,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + [[package]] name = "rustls-native-certs" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.5.1", ] [[package]] @@ -6016,16 +6239,16 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni", "log", "once_cell", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -6223,6 +6446,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.5.1" @@ -6230,7 +6466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -6395,6 +6631,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap 2.13.0", "itoa", "memchr", "serde", @@ -6652,6 +6889,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smol_str" @@ -6998,7 +7238,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" dependencies = [ "bitflags 2.10.0", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch", @@ -8277,6 +8517,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-transport" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4703a5ad424f8eca7860903b94f6ed747cf58bebba3081ede78e84493a12440c" +dependencies = [ + "bytes", + "thiserror 1.0.69", + "web-transport-quinn 0.3.4", + "web-transport-wasm", +] + [[package]] name = "web-transport-iroh" version = "0.1.1" @@ -8295,6 +8547,18 @@ dependencies = [ "web-transport-trait", ] +[[package]] +name = "web-transport-proto" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974fa1e325e6cc5327de8887f189a441fcff4f8eedcd31ec87f0ef0cc5283fbc" +dependencies = [ + "bytes", + "http", + "thiserror 2.0.18", + "url", +] + [[package]] name = "web-transport-proto" version = "0.3.1" @@ -8322,6 +8586,24 @@ dependencies = [ "url", ] +[[package]] +name = "web-transport-quinn" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3020b51cda10472a365e42d9a701916d4f04d74cc743de08246ef6a421c2d137" +dependencies = [ + "bytes", + "futures", + "http", + "log", + "quinn", + "quinn-proto", + "thiserror 1.0.69", + "tokio", + "url", + "web-transport-proto 0.2.8", +] + [[package]] name = "web-transport-quinn" version = "0.11.4" @@ -8333,7 +8615,7 @@ dependencies = [ "http", "quinn", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "thiserror 2.0.18", "tokio", "tracing", @@ -8351,6 +8633,19 @@ dependencies = [ "bytes", ] +[[package]] +name = "web-transport-wasm" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e8f572ad133af04a5aa4a207d48d3f6a2f1f3006aa1b8f0d774d28c085d699" +dependencies = [ + "bytes", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-transport-ws" version = "0.2.5" @@ -8410,6 +8705,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" +dependencies = [ + "ring", + "untrusted 0.9.0", +] + [[package]] name = "webpki-root-certs" version = "1.0.5" diff --git a/apps/web/app.js b/apps/web/app.js index 24cf99a..0800605 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -3,7 +3,8 @@ // This uses the upstream hang web component (WebTransport + WebCodecs). // It is intentionally dependency-light: no framework, no bundler. -import "https://cdn.jsdelivr.net/npm/@kixelated/hang@0.7.0/watch/element.js"; +// Use an ESM CDN that rewrites bare module specifiers. +import "https://esm.sh/@kixelated/hang@0.7.4/watch/element.js"; const DEFAULT_RELAY_URL = "https://relay.cloudflare.mediaoverquic.com/"; @@ -178,4 +179,3 @@ function main() { } main(); - diff --git a/crates/ec-node/Cargo.toml b/crates/ec-node/Cargo.toml index 711b78a..f05bbbd 100644 --- a/crates/ec-node/Cargo.toml +++ b/crates/ec-node/Cargo.toml @@ -32,6 +32,9 @@ tracing-subscriber.workspace = true hang = "0.14.0" moq-mux = "0.2.1" moq-native = { version = "0.13.1", default-features = true } +moq-native-ietf = "0.7.1" +moq-pub = "0.8.8" +moq-transport = "0.12.2" url = "2" [dev-dependencies] diff --git a/crates/ec-node/src/main.rs b/crates/ec-node/src/main.rs index 835dd62..45ff444 100644 --- a/crates/ec-node/src/main.rs +++ b/crates/ec-node/src/main.rs @@ -39,10 +39,8 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio_tungstenite::tungstenite::Message as WsMessage; use futures_util::{SinkExt, StreamExt}; -use hang as hang_moq; -use moq_mux as moq_mux_lib; -use moq_native as moq_native_lib; use tokio::process::Command as TokioCommand; +use tokio::io::AsyncReadExt; use url::Url; const DIRECT_WIRE_TAG_FRAME: u8 = 0x00; @@ -4231,38 +4229,33 @@ async fn wt_publish(args: WtPublishArgs) -> Result<()> { let relay_url = Url::parse(&args.url) .with_context(|| format!("invalid relay url: {}", args.url))?; - // Build the WebTransport client. - let mut client_cfg = moq_native_lib::ClientConfig::default(); - if args.tls_disable_verify { - client_cfg.tls.disable_verify = Some(true); - } - let client = client_cfg.init().context("failed to init moq-native client")?; + // Cloudflare's relay currently implements a subset of the IETF MoQ Transport draft-07. + // Use moq-transport (via moq-native-ietf) for interoperability. + let mut tls_args = moq_native_ietf::tls::Args::default(); + tls_args.disable_verify = args.tls_disable_verify; + let tls = tls_args.load().context("failed to load TLS config")?; - // Build a hang broadcast producer + catalog + fMP4 importer. - // This matches the moq-dev/moq-cli publishing strategy, but with our own ffmpeg input. - let origin = hang_moq::moq_lite::Origin::produce(); + let bind: std::net::SocketAddr = "[::]:0".parse().expect("valid bind addr"); + let quic = moq_native_ietf::quic::Endpoint::new(moq_native_ietf::quic::Config::new( + bind, None, tls, + )) + .context("failed to init moq-native-ietf endpoint")?; - let mut broadcast = hang_moq::moq_lite::BroadcastProducer::default(); - let catalog = hang_moq::Catalog::default().produce(); - broadcast.insert_track(catalog.track.clone()); - - let mut importer = moq_mux_lib::import::Fmp4::new( - broadcast.clone(), - catalog.clone(), - moq_mux_lib::import::Fmp4Config { - passthrough: args.passthrough, - }, - ); - - origin.publish_broadcast(&args.name, broadcast.consume()); + let namespace = moq_transport::coding::TrackNamespace::from_utf8_path(&args.name); + let (writer, _, reader) = moq_transport::serve::Tracks::new(namespace).produce(); + let mut media = moq_pub::Media::new(writer).context("failed to init moq-pub media parser")?; tracing::info!(url=%relay_url, name=%args.name, "connecting to relay"); - let session = client - .with_publish(origin.consume()) - .connect(relay_url) + let (session, _cid) = quic + .client + .connect(&relay_url, None) .await .context("failed to connect to relay")?; + let (session, mut publisher) = moq_transport::session::Publisher::connect(session) + .await + .context("failed to create moq-transport publisher")?; + // Spawn ffmpeg to generate fMP4 suitable for hang/moq-mux. // We keep this conservative and deterministic-ish by default: // - single threaded x264 @@ -4330,16 +4323,27 @@ async fn wt_publish(args: WtPublishArgs) -> Result<()> { .take() .ok_or_else(|| anyhow!("ffmpeg stdout unavailable"))?; - tracing::info!("publishing fMP4 -> hang -> relay"); - let decode_task = tokio::spawn(async move { importer.decode_from(&mut stdout).await }); + tracing::info!("publishing fMP4 -> moq-pub -> relay"); + let decode_task = tokio::spawn(async move { + let mut buf = bytes::BytesMut::new(); + loop { + let n = stdout + .read_buf(&mut buf) + .await + .context("failed to read from ffmpeg stdout")?; + if n == 0 { + anyhow::bail!("ffmpeg stdout EOF"); + } + media.parse(&mut buf).context("failed to parse fMP4")?; + } + #[allow(unreachable_code)] + Ok::<(), anyhow::Error>(()) + }); tokio::select! { - res = session.closed() => { - if let Err(err) = res { - // moq-lite errors are not always `std::error::Error`; keep this explicit. - return Err(anyhow!("relay session closed: {err:?}")); - } + res = session.run() => { let _ = child.kill().await; + res.context("relay session error")?; Ok(()) } res = decode_task => { @@ -4353,7 +4357,7 @@ async fn wt_publish(args: WtPublishArgs) -> Result<()> { } Ok(Err(err)) => { let _ = child.kill().await; - Err(err).context("fmp4 import failed") + Err(err).context("fmp4 ingest failed") } Err(err) => { let _ = child.kill().await; @@ -4361,9 +4365,14 @@ async fn wt_publish(args: WtPublishArgs) -> Result<()> { } } } + res = publisher.announce(reader) => { + let _ = child.kill().await; + res.context("publisher announce failed")?; + Ok(()) + } _ = tokio::signal::ctrl_c() => { tracing::info!("ctrl-c; shutting down"); - session.close(hang_moq::moq_lite::Error::Cancel); + // Best-effort shutdown; the underlying QUIC session is dropped after kill. let _ = child.kill().await; tokio::time::sleep(Duration::from_millis(100)).await; Ok(()) diff --git a/evolution/proposals/ECP-0063-cloudflare-moq-webtransport.md b/evolution/proposals/ECP-0063-cloudflare-moq-webtransport.md index 7470d7c..00e4641 100644 --- a/evolution/proposals/ECP-0063-cloudflare-moq-webtransport.md +++ b/evolution/proposals/ECP-0063-cloudflare-moq-webtransport.md @@ -52,6 +52,14 @@ Use the `@kixelated/hang` web component: This is WebTransport + WebCodecs based, and is expected to interoperate with Cloudflare's current relay preview. +### Transport compatibility + +Cloudflare's public relay currently implements a subset of the IETF MoQ Transport draft-07 and may not interoperate with +newer draft implementations. + +Implementation choice: +- `ec-node wt-publish` uses `moq-native-ietf` + `moq-transport` + `moq-pub` (fMP4 ingestion) for Cloudflare relay compatibility. + ### Share link Web share link: @@ -62,4 +70,3 @@ Web share link: - Keep existing `/api/*` bootstrap endpoints during migration. - Make the web site prefer MoQ/WebTransport; keep legacy paths hidden behind "advanced" until removed. - Reversible by switching the deployed assets back to the previous UI build, and/or pointing users at the legacy paths. - diff --git a/nix/modules/ec-node.nix b/nix/modules/ec-node.nix index 26a1a56..1d08507 100644 --- a/nix/modules/ec-node.nix +++ b/nix/modules/ec-node.nix @@ -306,6 +306,15 @@ in after = [ "network-online.target" ]; wants = [ "network-online.target" ]; + # Keep the unit from entering "failed" due to rapid restarts (deploy-flake treats + # failed units during `switch-to-configuration test` as a deployment failure). + # + # If the relay is temporarily unreachable, we want systemd to keep retrying without + # tripping rate limits. + unitConfig = { + StartLimitIntervalSec = 0; + }; + serviceConfig = { Type = "simple"; ExecStart = "${runner}/bin/${unit}";