Skip to content

feat(net, relay): GOAWAY with graceful drain and cluster migration - #2490

Closed
ksletmoe-aws wants to merge 10 commits into
moq-dev:mainfrom
ksletmoe-aws:goaway
Closed

feat(net, relay): GOAWAY with graceful drain and cluster migration#2490
ksletmoe-aws wants to merge 10 commits into
moq-dev:mainfrom
ksletmoe-aws:goaway

Conversation

@ksletmoe-aws

Copy link
Copy Markdown
Contributor

Summary

This implements GOAWAY support in moq-net, on both the moq-lite wire (lite-04+) and the IETF moq-transport wire (draft-14 through draft-19), in both directions. A session can send a GOAWAY to gracefully drain its peer (optionally with a replacement URI and a deadline), and can observe a GOAWAY it receives and react to it.

moq-relay consumes this in both directions:

  • Downstream (--drain-timeout): the first shutdown signal sends every accepted session (QUIC/WebTransport and the WebSocket fallback) an empty-URI GOAWAY and waits for clients to leave before force-closing; a second signal exits immediately.
  • Upstream (--cluster-drain-timeout): on a GOAWAY from a cluster peer, the relay dials the replacement while the old session keeps serving, then drains the old session in the background. Downstream sessions never see a GOAWAY of their own, and live subscriptions resume at a group boundary on the new connection.

Thanks for the GOAWAY groundwork already in place (the codecs and the receive-path stubs); this completes the lifecycle on top of it.

Built on #2241: migration is the route machinery's job, not GOAWAY's

An earlier draft of this work (pre-#2241) hand-rolled a seamless-failover layer: failover markers on origin paths, silent announce swaps, successor-by-identity tracking, and a Handoff typestate to drive it. All of that is gone. #2241's resume/route model already provides exactly those semantics as first-class primitives:

  • A replacement session sharing the same origin attaches its announcements as additional routes to the broadcasts the old session serves.
  • When the old session closes, its routes detach and the origin hands live subscriptions over to the new route at a group boundary (resume::Producer::switch), with the first-hop-identity rule deciding route-change vs broadcast-replacement.

So GOAWAY here is deliberately thin: the protocol lifecycle in moq-net, and policy in the relay (when to dial, which redirect to trust, how long to drain). The cluster's migration handler is roughly 60 lines because the model does the heavy lifting. An empty-URI GOAWAY ("reconnect to me") needs no special-casing anymore: a restarted publisher resolves as a different first-hop identity and replaces the broadcast, exactly as the model specifies.

moq-net API

  • Send / drain. Session::drain() claims the one-GOAWAY-per-session slot and returns a Drain (or None on versions without GOAWAY, so a caller can't silently no-op into a hang; dropping an unstarted Drain releases the claim). Drain::start(uri) / start_with_timeout(uri, deadline) send the frame and return a Draining; Draining::complete() waits for the peer to leave, force-closing with the new GoawayTimeout code (33) when the deadline expires.
  • Receive / observe. Session::goaway() resolves with GoawayReceived { uri, timeout }; Session::is_going_away() is a cheap synchronous check. A duplicate GOAWAY (a protocol violation) keeps the first payload and is logged, since an observer may already be acting on the first URI.
  • Request gating. After a received GOAWAY, new SUBSCRIBE / FETCH / TRACK / announce-interest opens are rejected with GoingAway (32); PROBE is silently skipped; existing subscriptions keep flowing until the session closes. Under the resume model the rejection typically surfaces as a stall on the gated route (waiting for another route to serve it) rather than a torn-down subscription.
  • Wire channels per version. lite-04+ uses the dedicated Goaway control stream; IETF draft-14-16 ride the shared control stream through the adapter; draft-17+ use the SETUP uni streams (ours to send, the peer's to receive). The receive reader stays alive after decoding: closing the peer's SETUP stream mid-session is a protocol violation (draft-19 §3.3) a strict peer would punish right in the middle of the drain.
  • Codec conformance. Draft-18 (moq-rs: close FFI parity gaps and smooth subscribe ergonomics #1559) appends the required control-stream Request ID (0; tolerated-optional on decode from lenient peers); draft-19 (ci: cross-compile all x86_64-darwin release artifacts on Apple Silicon #1623) removes it. The 8,192-byte New Session URI cap is enforced on both wires. On moq-lite it is rejected from the length prefix alone, and the moq-lite draft now specifies the cap (changelog under lite-06).
  • Plumbing. Everything follows the driver architecture: send paths are TaskSet children racing the drain trigger against transport close (nothing spawned, nothing blocking clean shutdown), and Draining::complete polls under a kio waiter.

Two bugs found by the real-transport tests

  • The lite GOAWAY frame was silently lost on real QUIC. Writer's drop-reset raced the FIN and discarded the unacked frame; the in-memory mock physically cannot lose data, so only the new quinn tests caught it. Fixed by awaiting FIN acknowledgment before dropping, the same dance as send_setup.
  • The close reason vanished on quinn. Session::closed() stringified the transport error, and quinn's Display drops the application close code and reason entirely ("connection error: closed"). closed() now surfaces code=N: reason when the transport carries them, so a peer can at least distinguish a GoawayTimeout force-close from a network failure. See the open question below about going further.

Branch target and API compatibility

Target: main. Everything in moq-net is strictly additive: Session::{drain, goaway, is_going_away}, Drain/Draining, GoawayReceived, Version::has_goaway, and two new variants on the #[non_exhaustive] Error (wire codes 32/33; 31 was already taken by Evicted). The relay config fields land on #[non_exhaustive] structs.

One deliberate exception, flagged in the open: moq_relay::Connection gains a required shutdown field, and Connection is a plain pub-field struct that embedders construct with a literal, so this breaks external constructors (the in-repo fix was one line in the smoke test; Shutdown::disabled() is the drop-in value). By the letter of the branch rules that routes to dev. We propose main anyway because the blast radius is one line per embedder and the fix is mechanical. If you'd rather avoid the break entirely, the clean alternatives are an additive Connection::run_with_shutdown(self, Shutdown), or biting the #[non_exhaustive] + constructor bullet now (this is the second time a new knob has hit this struct's literal-construction shape). Happy to re-shape either way.

Known limitations

  • Resume is group-granular. The resume model splices per-session tracks at group boundaries; there is no object-level resume (that requires carrying object ids through the relay plus RESET_STREAM_AT, unchanged from before and out of scope). Graceful drains are seamless because the in-flight group finishes on the old route; an abrupt mid-group death loses at most the group in flight.
  • Desynced replacements are untested. Every failover test uses siblings sharing one origin. A replacement far behind stalls (by design) and one far ahead of the group cache falls forward; neither case has a test yet.
  • The force-close reason is best-effort on real QUIC. The overstaying peer's own teardown close can race the server's CONNECTION_CLOSE and stomp the reason, so the structured-reason assertion lives in the deterministic mock test; the real-QUIC test asserts the enforcement (prompt close) only.

Open questions

  • Structured close codes (deferred to a follow-up, raised here for direction). Session::closed() currently surfaces close reasons as strings (code=33: goaway timeout). GOAWAY introduces the first codes where programmatic matching genuinely matters, and an earlier draft of this work had a full Error::from_code inverse (payload-free codes round-trip; codes whose variants carry non-wire payloads stay Remote; local-only codes never decode). We deliberately did not include it here: it changes how every close surfaces (not just GOAWAY), it touches the error architecture this repo just rewrote, and it is only as reliable as close-code delivery, which quinn does not currently guarantee (see the race above), suggesting the robust version also wants a web-transport-trait-level guarantee. If you want closed() to return matchable variants, we'll do it as a focused follow-up; the from_code sketch is ready. Error being #[non_exhaustive] and the string format being undocumented keeps the migration cost of deferring near zero.
  • GOAWAY redirect target trust. The only validation on a redirect URI is a scheme security-tier check (encrypted/IPC ≥ plaintext ≥ unknown, no downgrades), plus carrying the cluster JWT. There is no allowlist or known-peer check on the redirect host, and unix in the top tier lets an authenticated upstream point the relay at a local socket. A host allowlist is marked as a deliberate follow-up in the code.

Scope: Rust behavior + both wires; JS wire-level only

@moq/net decodes GOAWAY on both wires (with a byte-layout test locking draft-18 Request ID parity against the Rust encoder), so JS clients interoperate safely with a relay that sends GOAWAY. The JS session-level lifecycle (observing a GOAWAY, reconnecting) is deferred to a follow-up PR, consistent with #2241 deferring its own js/net mirror.

Test plan

All green: 901 Rust tests across moq-net / moq-relay / moq-native, clippy clean, cargo doc -D warnings clean, tsc + biome clean on js/net, kramdown-rfc parses the draft. bun test js/net: all 95 GOAWAY/IETF codec tests pass; the full-suite run carries one pre-existing failure (integration: lite draft-03) that reproduces identically on a clean main checkout (3/3 runs, passes in isolation), so it is not introduced here.

  • moq-net (544 lib + 8 GOAWAY integration on the in-memory mock; deterministic, no sleeps): send/receive across the full version matrix (lite-04/05, drafts 14/16/17/18/19, covering all three wire channels), wire deadline on draft-17+, GoawayTimeout force-close observed by the peer, drain-claim exclusivity and release-on-drop, drain() is None pre-lite-04, a duplicate-GOAWAY regression (injected as raw wire frames; the public API can't send two), and request gating with an existing subscription proven to keep flowing.
  • moq-native (real QUIC / WebTransport): 6 GOAWAY tests, covering the send/receive lifecycle across lite-04/05 and drafts 14/17/19 plus force-close enforcement on draft-19. Real-transport coverage matters here: both bugs above were invisible on the mock.
  • Cross-language smoke (test/smoke/smoke.sh, built from this checkout): the cells runnable on the dev host all pass — rust/js publishers x rust/js subscribers, which covers both implementations this PR touches. The python, c, js-native, and gst cells fail identically on a clean origin/main checkout on the same host (missing build deps: just py build, cc, native-lib dlopen, glib), so they are environmental; the full matrix should be confirmed via nix develop --command just test smoke-full. Note the smoke flows do not exercise GOAWAY itself (they verify media interop); cross-language GOAWAY behavior testing requires the deferred JS session-level support.
  • moq-relay (148 lib incl. scheme-tier and redirect-resolution unit tests + 12 smoke + 3 integration): cluster_migrates_on_upstream_goaway (two siblings over real TCP), cluster_reconnects_on_empty_uri_goaway, and cluster_diamond_goaway_seamless_failover: a 5-node diamond (TOP -> MID-A/MID-B -> BOTTOM -> subscriber) publishing 24 groups of 3 frames each, 20 of them streamed at a steady cadence through the GOAWAY/reconnect/handover window, asserting every group arrives exactly once, in order, every frame intact and exactly counted; a positive gate that BOTTOM reconnects to MID-B; a post-drain phase with the old leg fully severed proving delivery flows via the new leg; no announce churn; and no GOAWAY cascade to the subscriber (sync flag plus a bounded async probe).

Suggested follow-ups

  1. JS session-level GOAWAY (goaway() / drain lifecycle in @moq/net).
  2. Structured close codes + transport-level close-code delivery guarantee (open question above).
  3. Redirect host allowlist for cluster GOAWAY targets (open question above).
  4. Desync hardening: tests (and possibly bound-waits) for a replacement source far ahead of or behind the failed one.
  5. Object-granular resume (object ids through the relay + RESET_STREAM_AT) for zero-loss mid-group failover. Pre-existing, unchanged by this PR.

(Written by Claude Fable 5)

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @ksletmoe-aws, your pull request is larger than the review limit of 150000 diff characters

… the URI

The GOAWAY codecs existed but predated the draft-18/19 churn and had no
bounds on the New Session URI:

- draft-18 (moq-dev#1559) requires a trailing Request ID on the control stream;
  encode appends 0 (no per-request tracking) and decode tolerates a
  lenient peer omitting it. draft-19 (moq-dev#1623) removed the field again.
- Enforce the 8,192-byte New Session URI cap on the IETF wire (all
  drafts) and add the same cap to moq-lite, rejected from the length
  prefix alone so a hostile length cannot force unbounded buffering.
  The moq-lite draft now specifies the cap (matching moq-transport).
- js/net: mirror all of the above, with byte-layout tests locking the
  draft-18 Request ID parity against the Rust encoder.

Test plan: cargo test -p moq-net --lib goaway (11 tests incl. the new
cap and draft-18/19 layout tests); bun test on ietf.test.ts +
lite/goaway.test.ts (95); kramdown-rfc parses the draft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The change adds draft-aware GOAWAY encoding and decoding with URI-size validation, shared session APIs for initiating and observing drains, and protocol wiring across IETF and MoQ Lite. New tests cover wire formats, deadlines, duplicate signals, request gating, and transport behavior. Relay shutdown now drains active sessions, while cluster connections reconnect after upstream GOAWAY messages using validated redirects, retry limits, and timeout-based cleanup. Configuration and protocol documentation describe the new behavior.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: GOAWAY support with graceful draining and relay cluster migration.
Description check ✅ Passed The description is directly related to the changeset and accurately describes the GOAWAY, draining, and migration work.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🧹 Nitpick comments (13)
rs/moq-net/src/ietf/goaway.rs (1)

47-59: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a test for the Draft-18 "absent Request ID" tolerance.

The decoder claims to "tolerate its absence from lenient peers" (Lines 51-54), but every existing test (test_goaway_v18_timeout, test_goaway_v18_drains_optional_request_id) exercises a body that includes the trailing Request ID. The actual tolerant-of-absence path is untested.

✅ Suggested regression test
#[test]
fn test_goaway_v18_tolerates_missing_request_id() {
	// Hand-construct a draft-18 GOAWAY body WITHOUT the optional Request ID.
	let mut buf = BytesMut::new();
	"moqt://relay.example/".encode(&mut buf, Version::Draft18).unwrap();
	5000u64.encode(&mut buf, Version::Draft18).unwrap();

	let mut bytes = bytes::Bytes::from(buf.to_vec());
	let decoded: GoAway = GoAway::decode_msg(&mut bytes, Version::Draft18).unwrap();

	assert_eq!(decoded.new_session_uri, "moqt://relay.example/");
	assert_eq!(decoded.timeout, 5000);
}

Also applies to: 170-189

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-net/src/ietf/goaway.rs` around lines 47 - 59, Add a regression test
alongside the existing Draft-18 GOAWAY tests, using the test suite’s established
encoding and decoding APIs. Construct a Draft-18 body containing only the
session URI and timeout, omit the trailing Request ID, decode it through
GoAway::decode_msg, and assert the URI and timeout are preserved successfully.
rs/moq-net/src/session.rs (1)

185-217: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Consider an options struct for Drain::start/start_with_timeout.

Both methods take positional uri/timeout parameters. Per the guideline, growing this API (e.g. a third option later) would require yet another method name rather than adding a field.

As per coding guidelines: "Use options/config structs instead of positional parameters when an API could gain additional options; this includes APIs with only one option today."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-net/src/session.rs` around lines 185 - 217, The Drain API uses
positional parameters that would require additional method names as options
grow. Introduce an options/config struct for the URI and optional timeout, then
update Drain::start, Drain::start_with_timeout, and start_inner to accept and
use that struct while preserving the existing no-timeout and timeout behaviors.

Source: Coding guidelines

rs/moq-net/tests/goaway.rs (2)

74-83: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the leftover eprintln! checkpoints.

Debug artifacts; no other test in the file has them.

♻️ Proposed cleanup
-		eprintln!("CHECKPOINT: connected");
 		let draining = pair
 			.server
 			.drain()
 			.expect("drain")
 			.start_with_timeout("moqt://relay.example/", Duration::from_secs(5));
-		eprintln!("CHECKPOINT: drain started");
 
 		let goaway = pair.client.goaway().await.expect("session closed before GOAWAY");
-		eprintln!("CHECKPOINT: goaway observed");
 		assert_eq!(&*goaway.uri, "moqt://relay.example/");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-net/tests/goaway.rs` around lines 74 - 83, Remove the leftover
eprintln! checkpoint statements around the drain and GOAWAY flow in the test,
including the connected, drain started, and goaway observed messages, while
leaving the test behavior unchanged.

214-227: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Hand-rolled handshake duplicates connect_mock.

The only reason for the duplication is keeping a clone of the raw MockSession. Returning the transports from the harness (e.g. an extra field on MockPair) removes the second copy of the handshake, which otherwise has to be kept in sync with the driver-spawn ordering note in rs/moq-net/tests/support/harness.rs lines 77-91.

♻️ Sketch
 pub struct MockPair {
 	pub client: Session,
 	pub server: Session,
+	/// Raw transports, for tests that inject wire-level frames.
+	pub client_transport: MockSession,
+	pub server_transport: MockSession,
 }

As per coding guidelines, "Refactor awkward internal shapes during the same change instead of preserving them or adding duplicated one-off helpers."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-net/tests/goaway.rs` around lines 214 - 227, The goaway test
duplicates the handshake flow solely to retain the raw MockSession. Extend the
mock-session harness, including MockPair and connect_mock as appropriate, to
expose the underlying transport while preserving the required driver-spawn
ordering, then refactor this test to use the shared handshake helper and
returned transport instead of repeating Client/Server connection setup.

Source: Coding guidelines

js/net/src/lite/goaway.test.ts (1)

91-104: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a multi-byte case to the cap tests.

Both cap tests use ASCII, so they pass whether the limit counts UTF-8 bytes or UTF-16 code units. A URI under 8,192 characters but over 8,192 bytes is the case that actually pins the decoder's TextEncoder semantics.

♻️ Suggested additional test
 test("Goaway: rejects URI over the 8192-byte cap", async () => {
 	const msg = new Goaway("a".repeat(8193));
 	const encoded = await encode(msg, Version.DRAFT_04);
 	await expect(decode(encoded, Version.DRAFT_04)).rejects.toThrow(/8,192/);
 });
+
+test("Goaway: cap counts UTF-8 bytes, not characters", async () => {
+	// 3 bytes per character: 2,731 characters is 8,193 bytes.
+	const msg = new Goaway("リ".repeat(2731));
+	const encoded = await encode(msg, Version.DRAFT_04);
+	await expect(decode(encoded, Version.DRAFT_04)).rejects.toThrow(/8,192/);
+});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/net/src/lite/goaway.test.ts` around lines 91 - 104, Add a multi-byte UTF-8
URI case to the Goaway cap tests around the existing 8,192-byte boundary, using
a URI whose character count is below 8,192 but whose encoded byte length exceeds
it, and assert decoding rejects it. Keep the existing ASCII boundary and
over-cap tests unchanged.
rs/moq-net/tests/support/mock.rs (1)

177-184: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

stop() reports a clean close to the writer, so stop/reset regressions are invisible to mock tests.

Real QUIC surfaces STOP_SENDING to the sender as a stream error, not Ok(()). As written, neither an explicit stop(code) nor a dropped MockRecvStream can be distinguished by the peer, so the failure mode called out in rs/moq-net/src/ietf/session.rs (dropping the GOAWAY reader emits a STOP_SENDING a strict peer treats as a protocol violation) cannot be reproduced here. Consider having stop(code) record Err(MockError::stream_reset(code)) and leaving Drop as the benign path, and note the limitation in the module doc.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-net/tests/support/mock.rs` around lines 177 - 184, Update
MockRecvStream::stop to record Err(MockError::stream_reset(code)) in
closed.result instead of Ok(()), while preserving its notification and
done-state behavior. Keep Drop as the benign close path so explicit STOP_SENDING
and dropped streams remain distinguishable, and document this mock limitation in
the module documentation.
rs/moq-net/src/lite/goaway.rs (1)

95-124: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider covering the other two rejection paths.

rejects_oversized_uri pins the cap well. Two neighboring branches added in this change are untested: a within-cap length with insufficient remaining() (DecodeError::Short) and a non-UTF-8 payload. Both are cheap to hand-roll with BytesMut.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-net/src/lite/goaway.rs` around lines 95 - 124, The
rejects_oversized_uri test does not cover the neighboring URI decode rejection
branches. Extend this test to add a within-cap length prefix with insufficient
payload and assert DecodeError::Short, then add a within-cap payload containing
invalid UTF-8 and assert DecodeError::InvalidValue, constructing both inputs
with BytesMut while preserving the existing cap assertions.
js/net/src/lite/goaway.ts (1)

31-37: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Name the GOAWAY URI cap and remove the matching-assertion comment.

8192 is doubled in the check/error, and the Reader.string() + Message.decode() path buffers the declared payload before this validation, so a named constant is better. The comment also implies this checks against a Rust matching assertion, but the Rust decoder rejects before reading the payload.

♻️ Suggested constant extraction
+/** Maximum UTF-8 byte length of a GOAWAY New Session URI, matching the IETF wire. */
+const MAX_URI_BYTES = 8192;
+
 export class Goaway {
 	static async `#decode`(r: Reader): Promise<Goaway> {
 		const uri = await r.string();
-		// The URI is capped at 8,192 bytes, matching the IETF wire and the Rust
-		// decoder; a longer one is a protocol violation.
-		if (new TextEncoder().encode(uri).byteLength > 8192) {
- 			throw new Error("GOAWAY URI exceeds 8,192 bytes");
+		// A longer URI is a protocol violation.
+		if (new TextEncoder().encode(uri).byteLength > MAX_URI_BYTES) {
+ 			throw new Error(`GOAWAY URI exceeds ${MAX_URI_BYTES} bytes`);
 		}
 		return new Goaway(uri);
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/net/src/lite/goaway.ts` around lines 31 - 37, Extract the GOAWAY URI limit
used in the `Reader.string()` validation and error message into a named
constant, then reuse it in both places. Remove the comment claiming the limit
matches a Rust decoder assertion, while preserving the existing byte-length
validation and `Goaway` construction.

Source: Coding guidelines

rs/moq-net/src/ietf/subscriber.rs (1)

114-121: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider a SubscriberConfig here, matching the lite subscriber.

This constructor is now at six positional parameters, and the sibling Subscriber in rs/moq-net/src/lite/subscriber.rs already takes a SubscriberConfig struct (which is where going_away landed on that side). Converting this one keeps the two protocol paths symmetric and makes the next added field a non-event at both call sites in rs/moq-net/src/ietf/session.rs.

As per coding guidelines: "Use options/config structs instead of positional parameters when an API could gain additional options" and "Refactor awkward internal shapes during the same change instead of preserving them".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-net/src/ietf/subscriber.rs` around lines 114 - 121, Replace the
positional parameters of the IETF subscriber constructor new with a
SubscriberConfig struct, matching the lite subscriber’s configuration shape and
including session, origin, control, version, tasks, and going_away. Update the
related construction call sites in the IETF session flow to build and pass
SubscriberConfig while preserving existing behavior.

Source: Coding guidelines

rs/moq-relay/tests/goaway_cluster.rs (1)

209-212: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid the deprecated PublicConfig::Simple in a new test.

#[allow(deprecated)] here creates a fresh dependency on a path the repo keeps only for compatibility. Use the current PublicConfig variant so the deprecated form can eventually be dropped without touching tests.

As per coding guidelines: "keep deprecated paths functional but hidden from published surfaces."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-relay/tests/goaway_cluster.rs` around lines 209 - 212, Update the test
setup around AuthConfig to replace the deprecated PublicConfig::Simple
construction with the current non-deprecated PublicConfig variant, and remove
the #[allow(deprecated)] attribute. Preserve the existing public configuration
behavior while ensuring the test no longer depends on the compatibility-only
path.

Source: Coding guidelines

rs/moq-relay/src/main.rs (1)

107-129: 🩺 Stability & Availability | 🔵 Trivial

Consider signalling drain to load balancers and the accept loop.

During the drain window the QUIC/web listeners keep accepting: a client that connects at that moment is handed a GOAWAY immediately, and /health still reports healthy so upstream LBs keep routing traffic to a node that is going away. Flipping /health to unhealthy on shutdown.started() (and optionally stopping server.accept()) makes the drain observable to the infrastructure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-relay/src/main.rs` around lines 107 - 129, Update drain_on_signal to
mark the node unhealthy as soon as shutdown.started() triggers, so /health
reports draining during the entire shutdown window; also stop or gate the
listener accept loop at that point if the existing server API supports it,
preventing new clients from being admitted during drain.
rs/moq-relay/src/config.rs (1)

60-65: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the TOML-vs-CLI regression test for drain_timeout.

The repo convention for TOML-loadable configs is a test proving an absent CLI flag does not clobber the TOML value. Option<u64> makes that true by construction, but the guarded assertion is what keeps it true after refactors.

As per coding guidelines: "For TOML-loadable configs, every #[arg] field must be Option<T>, never a bare scalar; add a regression test ensuring CLI defaults do not clobber TOML values."

♻️ Suggested test
#[test]
fn drain_timeout_survives_toml_merge() {
	let toml = "drain_timeout = 42\n";
	let dir = std::env::temp_dir().join("moq-relay-config-test");
	std::fs::create_dir_all(&dir).unwrap();
	let path = dir.join("drain-timeout.toml");
	std::fs::write(&path, toml).unwrap();

	let args = vec![std::ffi::OsString::from("moq-relay"), std::ffi::OsString::from(&path)];
	let config = Config::parse_and_merge(args).expect("config load");
	assert_eq!(config.drain_timeout, Some(42));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-relay/src/config.rs` around lines 60 - 65, Add a regression test near
the configuration parsing tests for Config::parse_and_merge that writes a TOML
file containing drain_timeout = 42, invokes parsing without a CLI drain-timeout
override, and asserts the merged config retains Some(42). Follow existing
temporary-file and argument-construction conventions, and keep the Option<u64>
field behavior unchanged.

Source: Coding guidelines

rs/moq-relay/src/web.rs (1)

160-166: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

with_shutdown panics if the builder is used out of order.

Arc::get_mut fails as soon as any clone exists (after routes() or serve()), so an embedder that attaches shutdown late gets a runtime panic instead of a compile error. Holding shutdown on Web and materializing the Arc<WebState> in routes() (or taking it in new) removes the ordering constraint entirely.

♻️ Sketch: keep the field on the builder
 pub fn with_shutdown(mut self, shutdown: crate::Shutdown) -> Self {
-	let state = Arc::get_mut(&mut self.state).expect("with_shutdown called after routes were built");
-	state.shutdown = shutdown;
+	// `Web` owns `shutdown` and passes it into `WebState` when `routes()` builds it,
+	// so attaching it is order-independent.
+	self.shutdown = shutdown;
 	self
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-relay/src/web.rs` around lines 160 - 166, Refactor the Web builder so
with_shutdown does not call Arc::get_mut or panic after routes() or serve() has
cloned state. Store the shutdown value directly on Web, then apply it when
materializing WebState in routes() (or during new), allowing shutdown
configuration at any builder stage while preserving the existing
session-draining behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rs/moq-net/src/ietf/adapter.rs`:
- Around line 564-570: Update the GOAWAY framing path around the body-length
encoding to convert body.len() to u16 with try_into() instead of a truncating
cast, and on conversion failure return the same Error::BoundsExceeded(...) path
used by the setup send flow. Preserve the existing encoding-error handling for
valid lengths.

In `@rs/moq-net/src/lite/subscriber.rs`:
- Line 1273: Remove the initial Subscribe stream creation and encoding in
establish, retaining only the open_subscribe(&msg) path so no unused SUBSCRIBE
stream or response remains. Move self.subscriber.check_going_away()? into
open_subscribe if establish or its other callers still require that check.

In `@rs/moq-net/src/session.rs`:
- Around line 194-203: Normalize Duration::ZERO to no local deadline in
start_with_timeout before passing the value into start_inner, so
Draining::complete does not immediately force-close the session while the wire
continues to encode zero as “no deadline.” Preserve non-zero timeout behavior
and the existing start_inner/Draining flow.

In `@rs/moq-net/tests/goaway.rs`:
- Around line 306-317: Replace the timing-based assertion around
bc.track("audio").unwrap().subscribe(None) with a deterministic observable check
that the gated subscription does not reach the upstream wire or server-side
track subscriber. If subscription returns an error, match specifically
Error::GoingAway rather than accepting any Err; if the resume model stalls,
assert the corresponding subscriber-side effect without sleeps or timeout-based
waiting.

In `@rs/moq-net/tests/support/mock.rs`:
- Around line 350-353: Update MockConnection::close to preserve the first close
state: only assign (code, reason) when self.side.conn.close_state is currently
None, while retaining notification behavior for waiters.
- Around line 286-307: Fix the shutdown wait in both accept_uni and accept_bi so
they check the connection’s closed state before awaiting close_notify, avoiding
a missed notification when close() ran earlier. Mirror the state-checking loop
used by the closed() implementation, and return close_error() once shutdown is
observed while preserving normal stream delivery.

In `@rs/moq-relay/src/cluster.rs`:
- Around line 1036-1075: Update resolve_redirect so fallback JWT propagation
occurs only when the parsed redirect host matches the configured fallback host.
Preserve redirect handling and scheme-security checks, but leave cross-host
redirects without the fallback JWT while continuing to carry it for same-host
redirects and existing redirect-owned JWTs.
- Around line 932-993: Bound GOAWAY-triggered migrations in the session loop
around session.goaway() and the replacement-session logic. Track consecutive
migrations (or enforce an equivalent minimum interval), reset the bound after
stable operation, and when the limit is exceeded stop migrating in place so
run_remote_once returns and the normal run_remote backoff path handles
reconnection. Ensure each migration cannot continue spawning detached drain
tasks indefinitely.

In `@rs/moq-relay/src/shutdown.rs`:
- Around line 69-74: Update drain_session to distinguish an already-claimed
drain from a session on a GOAWAY-less version instead of treating every None
result as an abort case. Await the existing drain completion or leave an
already-draining session alone, while retaining immediate session.abort with
GoingAway only for versions that do not support GOAWAY.

In `@rs/moq-relay/tests/goaway_cluster.rs`:
- Around line 35-42: The free-port probe is dropped before ServerConfig::init(),
allowing another test to claim the port and causing spurious failures. In
rs/moq-relay/tests/goaway_cluster.rs lines 35-42, add a shared bind_free_port()
helper that retries initialization after bind failures or loops over fresh probe
ports, and use it from spawn_upstream; in lines 200-206, replace the duplicated
probe/drop/bind logic in spawn_relay_with_upstream with the same helper.
- Around line 364-365: Update the FRAMES_PER_GROUP constant or the adjacent
group 0 comment so they agree, preserving the intended frame count for the fully
verified MID-A flow.

---

Nitpick comments:
In `@js/net/src/lite/goaway.test.ts`:
- Around line 91-104: Add a multi-byte UTF-8 URI case to the Goaway cap tests
around the existing 8,192-byte boundary, using a URI whose character count is
below 8,192 but whose encoded byte length exceeds it, and assert decoding
rejects it. Keep the existing ASCII boundary and over-cap tests unchanged.

In `@js/net/src/lite/goaway.ts`:
- Around line 31-37: Extract the GOAWAY URI limit used in the `Reader.string()`
validation and error message into a named constant, then reuse it in both
places. Remove the comment claiming the limit matches a Rust decoder assertion,
while preserving the existing byte-length validation and `Goaway` construction.

In `@rs/moq-net/src/ietf/goaway.rs`:
- Around line 47-59: Add a regression test alongside the existing Draft-18
GOAWAY tests, using the test suite’s established encoding and decoding APIs.
Construct a Draft-18 body containing only the session URI and timeout, omit the
trailing Request ID, decode it through GoAway::decode_msg, and assert the URI
and timeout are preserved successfully.

In `@rs/moq-net/src/ietf/subscriber.rs`:
- Around line 114-121: Replace the positional parameters of the IETF subscriber
constructor new with a SubscriberConfig struct, matching the lite subscriber’s
configuration shape and including session, origin, control, version, tasks, and
going_away. Update the related construction call sites in the IETF session flow
to build and pass SubscriberConfig while preserving existing behavior.

In `@rs/moq-net/src/lite/goaway.rs`:
- Around line 95-124: The rejects_oversized_uri test does not cover the
neighboring URI decode rejection branches. Extend this test to add a within-cap
length prefix with insufficient payload and assert DecodeError::Short, then add
a within-cap payload containing invalid UTF-8 and assert
DecodeError::InvalidValue, constructing both inputs with BytesMut while
preserving the existing cap assertions.

In `@rs/moq-net/src/session.rs`:
- Around line 185-217: The Drain API uses positional parameters that would
require additional method names as options grow. Introduce an options/config
struct for the URI and optional timeout, then update Drain::start,
Drain::start_with_timeout, and start_inner to accept and use that struct while
preserving the existing no-timeout and timeout behaviors.

In `@rs/moq-net/tests/goaway.rs`:
- Around line 74-83: Remove the leftover eprintln! checkpoint statements around
the drain and GOAWAY flow in the test, including the connected, drain started,
and goaway observed messages, while leaving the test behavior unchanged.
- Around line 214-227: The goaway test duplicates the handshake flow solely to
retain the raw MockSession. Extend the mock-session harness, including MockPair
and connect_mock as appropriate, to expose the underlying transport while
preserving the required driver-spawn ordering, then refactor this test to use
the shared handshake helper and returned transport instead of repeating
Client/Server connection setup.

In `@rs/moq-net/tests/support/mock.rs`:
- Around line 177-184: Update MockRecvStream::stop to record
Err(MockError::stream_reset(code)) in closed.result instead of Ok(()), while
preserving its notification and done-state behavior. Keep Drop as the benign
close path so explicit STOP_SENDING and dropped streams remain distinguishable,
and document this mock limitation in the module documentation.

In `@rs/moq-relay/src/config.rs`:
- Around line 60-65: Add a regression test near the configuration parsing tests
for Config::parse_and_merge that writes a TOML file containing drain_timeout =
42, invokes parsing without a CLI drain-timeout override, and asserts the merged
config retains Some(42). Follow existing temporary-file and
argument-construction conventions, and keep the Option<u64> field behavior
unchanged.

In `@rs/moq-relay/src/main.rs`:
- Around line 107-129: Update drain_on_signal to mark the node unhealthy as soon
as shutdown.started() triggers, so /health reports draining during the entire
shutdown window; also stop or gate the listener accept loop at that point if the
existing server API supports it, preventing new clients from being admitted
during drain.

In `@rs/moq-relay/src/web.rs`:
- Around line 160-166: Refactor the Web builder so with_shutdown does not call
Arc::get_mut or panic after routes() or serve() has cloned state. Store the
shutdown value directly on Web, then apply it when materializing WebState in
routes() (or during new), allowing shutdown configuration at any builder stage
while preserving the existing session-draining behavior.

In `@rs/moq-relay/tests/goaway_cluster.rs`:
- Around line 209-212: Update the test setup around AuthConfig to replace the
deprecated PublicConfig::Simple construction with the current non-deprecated
PublicConfig variant, and remove the #[allow(deprecated)] attribute. Preserve
the existing public configuration behavior while ensuring the test no longer
depends on the compatibility-only path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 874256c9-a773-45aa-9e30-2f42e2b36ae3

📥 Commits

Reviewing files that changed from the base of the PR and between e967b86 and 6c36cfe.

📒 Files selected for processing (38)
  • doc/bin/relay/config.md
  • doc/concept/layer/moq-lite.md
  • drafts/draft-lcurley-moq-lite.md
  • js/net/src/ietf/goaway.ts
  • js/net/src/ietf/ietf.test.ts
  • js/net/src/lite/goaway.test.ts
  • js/net/src/lite/goaway.ts
  • rs/moq-native/tests/broadcast.rs
  • rs/moq-net/src/client.rs
  • rs/moq-net/src/error.rs
  • rs/moq-net/src/goaway.rs
  • rs/moq-net/src/ietf/adapter.rs
  • rs/moq-net/src/ietf/goaway.rs
  • rs/moq-net/src/ietf/session.rs
  • rs/moq-net/src/ietf/subscriber.rs
  • rs/moq-net/src/lib.rs
  • rs/moq-net/src/lite/goaway.rs
  • rs/moq-net/src/lite/publisher.rs
  • rs/moq-net/src/lite/session.rs
  • rs/moq-net/src/lite/subscriber.rs
  • rs/moq-net/src/lite/version.rs
  • rs/moq-net/src/server.rs
  • rs/moq-net/src/session.rs
  • rs/moq-net/src/version.rs
  • rs/moq-net/tests/goaway.rs
  • rs/moq-net/tests/support/harness.rs
  • rs/moq-net/tests/support/mock.rs
  • rs/moq-net/tests/support/mod.rs
  • rs/moq-relay/src/cluster.rs
  • rs/moq-relay/src/config.rs
  • rs/moq-relay/src/connection.rs
  • rs/moq-relay/src/lib.rs
  • rs/moq-relay/src/main.rs
  • rs/moq-relay/src/shutdown.rs
  • rs/moq-relay/src/web.rs
  • rs/moq-relay/src/websocket.rs
  • rs/moq-relay/tests/goaway_cluster.rs
  • rs/moq-relay/tests/smoke.rs

Comment thread rs/moq-net/src/ietf/adapter.rs
Comment thread rs/moq-net/src/lite/subscriber.rs Outdated
Comment thread rs/moq-net/src/session.rs Outdated
Comment thread rs/moq-net/tests/goaway.rs
Comment thread rs/moq-net/tests/support/mock.rs
Comment thread rs/moq-relay/src/cluster.rs
Comment thread rs/moq-relay/src/cluster.rs
Comment thread rs/moq-relay/src/shutdown.rs
Comment thread rs/moq-relay/tests/goaway_cluster.rs Outdated
Comment thread rs/moq-relay/tests/goaway_cluster.rs Outdated
ksletmoe-aws and others added 4 commits July 24, 2026 20:48
Adds the GOAWAY lifecycle on both wires, replacing the receive-side stubs
(lite logged and ignored; IETF failed with Unsupported):

- Send: Session::drain() claims the one-GOAWAY-per-session slot and
  returns a Drain; Drain::start(uri) / start_with_timeout(uri, t) fire
  the frame and return a Draining whose complete() force-closes with the
  new GoawayTimeout code (33) when the deadline expires.
- Receive: Session::goaway() resolves with GoawayReceived { uri, timeout }
  and Session::is_going_away() is a cheap flag. Duplicate GOAWAYs keep
  the first payload (an observer may already be acting on its URI) and
  are logged.
- Request gating: after a received GOAWAY, new SUBSCRIBE / FETCH / TRACK /
  announce-interest opens are rejected with GoingAway (32); PROBE is
  silently skipped; existing subscriptions keep flowing.
- Wire channels per version: lite-04+ uses the dedicated Goaway control
  stream; IETF draft-14-16 ride the shared control stream through the
  adapter; draft-17+ use the SETUP uni streams (ours to send, the peer's
  to receive). The receive reader stays alive after decoding, since
  closing the peer's SETUP stream mid-session is a protocol violation a
  strict peer punishes right in the middle of the drain.
- The lite sender waits for its FIN to be acknowledged before dropping
  the Goaway stream: Writer's Drop resets, and on real QUIC a reset
  racing the FIN discards the unacked GOAWAY frame (caught by the
  real-transport tests; the in-memory mock cannot lose data). Same dance
  as send_setup.
- Session::closed() now surfaces the application close code and reason
  when the transport carries them (quinn's Display drops both), so a
  peer can distinguish a GoawayTimeout force-close from a network error.

All plumbing follows the driver architecture: the send paths are TaskSet
children racing the drain trigger against transport close (nothing
spawned, nothing blocking clean shutdown), and Draining::complete polls
under a kio waiter rather than tokio::select.

Test plan: cargo test -p moq-net (544 lib + 8 goaway integration),
-p moq-relay, -p moq-native (incl. 6 real-QUIC/WebTransport GOAWAY
tests) all green; clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two consumers of the new moq-net GOAWAY support:

Upstream (cluster): when a peer sends GOAWAY, the relay dials the
replacement (the redirect URI when safe, else the original URL) while the
old session keeps serving, then drains the old session in the background
(the GOAWAY's own deadline, else --cluster-drain-timeout, default 10s)
and force-closes it with GoawayTimeout. The replacement shares the same
origin, so its announcements attach as routes to the broadcasts the old
session served and the origin hands live tracks over at a group boundary
when the old routes detach. Downstream sessions never see a GOAWAY.

A redirect is followed only when its scheme is at least as secure as the
current connection's (no plaintext downgrade; unknown schemes rank
lowest, fail-safe), and the cluster JWT carries onto the redirect unless
it brings its own. Constraining the redirect host to known peers is a
deliberate follow-up, marked in the code.

Downstream (--drain-timeout): the first shutdown signal broadcasts a
drain: every accepted session (QUIC/WebTransport and the WebSocket
fallback) sends an empty-URI GOAWAY (reconnect to me) and is force-closed
after the window; a second signal, or the window elapsing, exits. Clients
on pre-GOAWAY versions (moq-lite-03 and earlier) are closed immediately;
there is no wire message to warn them with.

Test plan: cargo test -p moq-relay (148 lib incl. new scheme-tier and
redirect-resolution unit tests + 12 smoke); clippy clean. Cluster
migration integration tests land in the next commit on this branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…test

moq-net (in-memory mock transport, deterministic, no sleeps):
- send/receive across the full GOAWAY version matrix (lite-04/05,
  moq-transport-14/16/17/18/19), covering all three wire channels
- wire timeout on draft-17+, and the GOAWAY_TIMEOUT force-close observed
  by an overstaying peer
- drain claim exclusivity + release-on-drop; drain() is None pre-lite-04
- regression: a duplicate GOAWAY (injected as raw wire frames; the public
  API can't send two) keeps the first payload
- request gating: a new subscribe after GOAWAY never reaches the wire
  (delivered as a resume-model stall), while the existing subscription
  keeps flowing

The mock transport and harness are revived from the earlier GOAWAY
branch; the harness now spawns each side's Driver as soon as its
handshake resolves, since draft-17+ handshakes each block on the peer
driver's SETUP.

moq-relay (real TCP transports): cluster_migrates_on_upstream_goaway
stands up two sibling upstreams sharing one origin, drains sibling A
with a redirect to B, and asserts the cluster reconnects, delivery
resumes contiguously at the next group, no announce churn leaks to the
cluster origin, and the old session drains away.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…knobs

- doc/concept/layer/moq-lite.md: GOAWAY send/receive lifecycle on the
  current API, the route-based seamless resume story, version support,
  and the js/net wire-only status.
- doc/bin/relay/config.md: the top-level drain_timeout (shutdown drain)
  and cluster.drain_timeout (upstream GOAWAY drain window) keys.

Changelogs are release-plz generated from the commit subjects, so no
manual CHANGELOG edits ride along.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a good first attempt and most of the stuff is salvageable.

I would want to see client support via Reconnect in moq-native and moq/net. It should be transparent to the client and there should be (at least) two concurrent connections. Starting with the cluster connections is okay but less testable, and really cluster connections should be leveraging moq-native instead.

We also need some way of marking the old connection/route as lower priority so we migrate the subscription. I know it's a follow up, but it seems required for any sort of draining. We need to somehow cost a draining connection as infinite so its never used as a primary.

Comment thread doc/bin/relay/config.md Outdated
# keeps the old upstream alive this long so in-flight groups finish, then
# force-closes it. A deadline on the received GOAWAY takes precedence.
# Default: 10.
drain_timeout = 10

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: drain

Comment thread doc/concept/layer/moq-lite.md Outdated
The lifecycle on the sending side (Rust `moq-net`):

1. `session.drain()` claims the session for draining (one GOAWAY per session). Returns a `Drain` handle, or `None` on versions without GOAWAY (moq-lite-03 and earlier).
2. `drain.start(uri)` or `drain.start_with_timeout(uri, duration)` sends the GOAWAY frame.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not a fan of xxx_with_yyy methods. If we ever wanted to make another option, then we're out of luck.

Make a separate options struct with a builder instead.

Comment thread rs/moq-net/src/ietf/adapter.rs Outdated
&self,
reader: Reader<S::RecvStream, Version>,
writer: Writer<S::SendStream, Version>,
goaway: crate::goaway::Protocol,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be in the adapter? I guess, but it should be session.rs or something.

Comment thread rs/moq-net/src/ietf/session.rs Outdated
Comment on lines +329 to +330
let mut closed = std::pin::pin!(session.closed());
let mut triggered = std::pin::pin!(goaway.triggered());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ideally we can poll_closed and poll_triggered instead.

Comment thread rs/moq-net/src/goaway.rs Outdated
///
/// Returns `None` if the trigger was dropped without firing (the session is
/// closing without a drain), so no GOAWAY should be sent.
pub async fn triggered(&self) -> Option<Payload> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

poll_triggered

Comment thread rs/moq-net/src/session.rs Outdated
/// With a deadline (from [`Drain::start_with_timeout`]), the session is
/// force-closed with [`Error::GoawayTimeout`] when it expires; the timer is
/// cancelled if the peer closes first.
pub async fn complete(self) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty weird API. Maybe impl Future instead, or return a Result.

Comment thread rs/moq-relay/src/cluster.rs Outdated
Comment on lines +354 to +355
/// force-closes it. A deadline carried on the received GOAWAY takes
/// precedence over this value. Defaults to 10 seconds.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deadline on the GOAWAY should not take precedence; we should take the min.


/// Fallback drain time in seconds for an upstream peer that sends a GOAWAY
/// without its own deadline. After a successful reconnect the relay keeps
/// the old upstream alive this long so its in-flight groups finish, then

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm kinda unsure why this exists? The idea is that the client should establish the new connection in parallel. I'm not sure who closes the connection when it's fully drained, but I wouldn't keep a dead connection "alive" for this arbitrary amount of time.

Comment thread rs/moq-relay/src/cluster.rs Outdated

Err(cs.closed().await.into())
// Fallback drain window for an upstream GOAWAY that carries no timeout.
let drain_timeout =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IDK, shouldn't this whole thing be in Reconnect instead? The code looks pretty terrible.

Comment thread rs/moq-relay/src/cluster.rs Outdated
);
return fallback.clone();
}
// Carry the cluster JWT over to the redirect target unless it brings its own.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah don't do this? The server SHOULD echo back the JWT in the GOAWAY URL. It seems pretty useful for forcing the client to switch auth tokens or something.

Address review feedback on the drain API and add transparent client-side
migration when a GOAWAY redirect arrives.

API reshape (moq-net, moq-relay):
- Replace Drain::start/start_with_timeout with a single start(DrainConfig)
  options struct plus builder.
- Convert the goaway triggered() to poll_triggered and drive the lite and
  IETF session loops off the poll methods.
- Draining::complete now returns Result so callers can distinguish a
  graceful close from a timeout-forced abort.
- Move the GOAWAY protocol plumbing out of the IETF adapter into the
  session driver.
- Rename the relay drain_timeout config to a humantime drain (and
  cluster.drain), keeping the old keys as hidden deprecated aliases.
- Take min(local, goaway) for the drain deadline instead of letting the
  GOAWAY deadline win outright.
- Drop the JWT carry-over on redirect; the sending server echoes the JWT
  in the GOAWAY URI instead.

Reconnect migration (moq-native):
- Add a Drain config and a Status::Migrating variant.
- On GOAWAY, dial the redirect in parallel, drain the old session for
  min(drain, goaway), and fall back to backoff if the redirect fails.

Route deprioritization (moq-net):
- On GOAWAY receipt the subscriber bumps its own routes to
  broadcast::DRAIN_COST, so best_route migrates live subscribers off the
  draining connection (lite and IETF).
@ksletmoe-aws

Copy link
Copy Markdown
Contributor Author

@kixelated — pushed an update working through the review. What's in this revision:

API-shape feedback (all addressed):

  • Drain::start / start_with_timeout → a single start(DrainConfig) with a builder
  • triggered()poll_triggered; the lite + IETF session loops now drive off the poll methods
  • Draining::complete returns Result so a graceful close is distinguishable from a timeout-forced abort
  • moved the GOAWAY plumbing out of the IETF adapter into the session driver
  • drain_timeout → humantime drain / cluster.drain (old keys kept as hidden deprecated aliases)
  • drain deadline is now min(local, goaway) rather than the GOAWAY deadline winning outright
  • dropped the JWT carry-over on redirect; the sending server echoes the JWT in the GOAWAY URI instead

The part I'd most like your eyes on — the reconnect API:
I built the transparent client-side migration in moq-native: Reconnect gains a Drain config and a Status::Migrating, and on GOAWAY it dials the redirect in parallel, drains the old session for min(drain, goaway), and falls back to backoff if the redirect fails. The "cost a draining connection as infinite" piece lives down in moq-net: on GOAWAY receipt the subscriber bumps its own routes to broadcast::DRAIN_COST, so best_route migrates live subscribers off the dying connection — no new public API, works for both lite and IETF. Since you flagged the interface as the uncertain part, I'm posting this shape specifically for you to react to before I build on it.

Held back deliberately: I haven't touched cluster.rs or the @moq/net Reload mirror yet. Both consume this reconnect API, and there's no point mirroring something we might reshape — I'll do the cluster rewrite and the JS side once we've settled the reconnect API.

Target branch: this includes breaking moq-net pub API changes (Drain::start signature, Draining::complete return type), so per the branch-targeting rules I think this should target dev rather than main. Let me know if you agree or want it split differently.

(Written by claude-opus-4.8)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
rs/moq-relay/src/cluster.rs (1)

1009-1045: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Start the drain deadline when GOAWAY is received.

Replacement dialing, including potentially unbounded client.connect, happens before the old session timer starts. A stalled reconnect can therefore retain the old upstream past the configured or peer-advertised deadline. Capture and enforce the deadline before dialing, then use only the remaining budget after swapping sessions. Add a regression test with a delayed replacement dial.

  • rs/moq-relay/src/cluster.rs#L1009-L1045: race reconnect attempts against the effective deadline and force-close the old session when it expires.
  • doc/bin/relay/config.md#L186-L192: state that the effective drain budget begins on GOAWAY receipt, not after reconnect succeeds.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-relay/src/cluster.rs` around lines 1009 - 1045, In
rs/moq-relay/src/cluster.rs:1009-1045, start the effective drain deadline when
GOAWAY is received, race each replacement client.connect attempt against that
deadline, and force-close the old session if reconnecting or draining exceeds
the remaining budget; after swapping sessions, pass only the remaining time to
the drain task, and add a regression test covering a delayed replacement dial.
In doc/bin/relay/config.md:186-192, document that the effective drain budget
starts at GOAWAY receipt rather than after reconnect succeeds.
🧹 Nitpick comments (4)
rs/moq-native/src/reconnect.rs (3)

733-797: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Migration control flow is untested.

The new tests cover Drain::default and resolve_redirect only. The riskiest additions, Status::Migrating transitions, effective_timeout = min(drain.timeout, goaway.timeout), and the nested-GOAWAY path, have no coverage in this crate. A #[tokio::test] driving a real server that drains its session (as in rs/moq-native/tests/broadcast.rs) through Client::reconnect would pin the observable status sequence.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-native/src/reconnect.rs` around lines 733 - 797, Add a tokio
integration test covering migration control flow by driving a real draining
server through Client::reconnect, following the setup pattern in broadcast
tests. Assert the observable Status::Migrating transitions, verify the effective
timeout uses the minimum of drain.timeout and goaway.timeout, and exercise the
nested-GOAWAY path.

349-380: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated Closed handling; consider hoisting the migration into the outer loop.

Lines 350-379 are a near-verbatim copy of Lines 256-276, and the nested monitoring adds a second control-flow level that only handles one extra GOAWAY before falling back to the outer loop. Extracting a helper (e.g. fn handle_closed(...)) or restructuring so a successful migration just updates session/current_url and continues into the existing single monitoring path would remove the duplication and the divergence risk (the copies already differ: "session severed immediately, retrying" vs "session severed immediately").

As per coding guidelines, "Refactor awkward internal shapes during the same change instead of preserving them or adding duplicated one-off helpers."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-native/src/reconnect.rs` around lines 349 - 380, The Closed handling
logic is duplicated across the reconnect flow and has already diverged. Refactor
the outer reconnect loop around the existing Closed-handling branch so a
successful migration updates session/current_url and continues through the
single monitoring path; otherwise centralize shared cleanup, retry, and error
logging in one reusable path, preserving the existing outcomes without adding
another one-off duplicate helper.

Source: Coding guidelines


596-627: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Share the GOAWAY redirect policy instead of copying it.

rs/moq-native/src/reconnect.rs and rs/moq-relay/src/cluster.rs both define resolve_redirect / scheme_security_tier with the same security-relevant behavior, including the mirrored resolve_redirect_policy coverage and JWT non-carryover intent. Host one implementation in a shared location like moq-net and call it from both native and relay paths to avoid policy drift.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-native/src/reconnect.rs` around lines 596 - 627, Extract the shared
GOAWAY redirect policy from reconnect.rs and cluster.rs into a reusable moq-net
implementation, including resolve_redirect, scheme_security_tier,
resolve_redirect_policy behavior, and JWT non-carryover semantics. Update both
native and relay call sites to use the shared symbols, remove their duplicate
policy definitions, and preserve existing fallback and security-downgrade
behavior.
rs/moq-net/src/ietf/subscriber.rs (1)

121-121: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Dead _goaway_received parameter and a draining check that only fires on message arrival, not proactively.

Subscriber::new's _goaway_received is discarded (never stored), and run_subscribe_namespace's draining flag is only checked after a decode_maybe() resolves, so on an idle SUBSCRIBE_NAMESPACE stream this local check never fires. Correctness for the draft14-16 path is preserved by a separate watcher task in ietf/session.rs's start() that races goaway.received.consume() directly and calls drain_all_broadcasts() once, but this makes the parameter and check here misleading dead weight. lite::Subscriber avoids this by racing goaway_received against the announce decode with kio::wait (see rs/moq-net/src/lite/subscriber.rs run_announce_prefix), reacting even when the stream is idle.

Consider either wiring _goaway_received the same way here for consistency, or dropping the unused parameter and the redundant going_away.is_set() gate in this loop.

Also applies to: 226-243

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-net/src/ietf/subscriber.rs` at line 121, Remove the unused
_goaway_received parameter from Subscriber::new and update all callers
accordingly. Remove the redundant going_away.is_set() check from
run_subscribe_namespace, while preserving the existing session-level watcher in
start() that drains broadcasts on GOAWAY.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rs/moq-native/src/client.rs`:
- Around line 63-67: Remove or rename the `drain` option’s `drain-timeout` alias
in the relay command configuration so it no longer conflicts with the
`ClientConfig` option flattened into `moq relay`. Preserve the existing drain
behavior while ensuring `moq relay --help` and configuration parsing complete
without duplicate Clap arguments.

In `@rs/moq-native/src/reconnect.rs`:
- Around line 381-390: Update the nested GOAWAY arm in the reconnect loop to
apply the same sliding-window churn limit used by Cluster::run_remote_once,
preserving retry timing so repeated handshake redirects cannot spin indefinitely
or bypass backoff.timeout. Before replacing the active connection, drain the
outgoing new_session with the existing success-path deadline and abort behavior,
ensuring state.session’s clone does not keep the connection alive.

In `@rs/moq-relay/src/main.rs`:
- Around line 21-40: Update configuration merging across
rs/moq-relay/src/main.rs lines 21-40, rs/moq-relay/src/config.rs lines 66-74,
and rs/moq-relay/src/cluster.rs lines 356-364 so legacy drain_timeout and
cluster.drain_timeout values accept bare numbers as seconds through CLI, TOML,
and environment inputs. Parse these legacy values during normal precedence-aware
merging, reject and report malformed values instead of silently ignoring them,
remove the late post-merge environment fallback, and add coverage for both
timeout settings across all legacy input sources.

---

Outside diff comments:
In `@rs/moq-relay/src/cluster.rs`:
- Around line 1009-1045: In rs/moq-relay/src/cluster.rs:1009-1045, start the
effective drain deadline when GOAWAY is received, race each replacement
client.connect attempt against that deadline, and force-close the old session if
reconnecting or draining exceeds the remaining budget; after swapping sessions,
pass only the remaining time to the drain task, and add a regression test
covering a delayed replacement dial. In doc/bin/relay/config.md:186-192,
document that the effective drain budget starts at GOAWAY receipt rather than
after reconnect succeeds.

---

Nitpick comments:
In `@rs/moq-native/src/reconnect.rs`:
- Around line 733-797: Add a tokio integration test covering migration control
flow by driving a real draining server through Client::reconnect, following the
setup pattern in broadcast tests. Assert the observable Status::Migrating
transitions, verify the effective timeout uses the minimum of drain.timeout and
goaway.timeout, and exercise the nested-GOAWAY path.
- Around line 349-380: The Closed handling logic is duplicated across the
reconnect flow and has already diverged. Refactor the outer reconnect loop
around the existing Closed-handling branch so a successful migration updates
session/current_url and continues through the single monitoring path; otherwise
centralize shared cleanup, retry, and error logging in one reusable path,
preserving the existing outcomes without adding another one-off duplicate
helper.
- Around line 596-627: Extract the shared GOAWAY redirect policy from
reconnect.rs and cluster.rs into a reusable moq-net implementation, including
resolve_redirect, scheme_security_tier, resolve_redirect_policy behavior, and
JWT non-carryover semantics. Update both native and relay call sites to use the
shared symbols, remove their duplicate policy definitions, and preserve existing
fallback and security-downgrade behavior.

In `@rs/moq-net/src/ietf/subscriber.rs`:
- Line 121: Remove the unused _goaway_received parameter from Subscriber::new
and update all callers accordingly. Remove the redundant going_away.is_set()
check from run_subscribe_namespace, while preserving the existing session-level
watcher in start() that drains broadcasts on GOAWAY.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f38ad31e-a62b-4107-b8c6-864ef93cb9b0

📥 Commits

Reviewing files that changed from the base of the PR and between 05c27b3 and e77fef3.

📒 Files selected for processing (23)
  • doc/bin/relay/config.md
  • doc/concept/layer/moq-lite.md
  • rs/moq-native/src/client.rs
  • rs/moq-native/src/lib.rs
  • rs/moq-native/src/reconnect.rs
  • rs/moq-native/tests/broadcast.rs
  • rs/moq-net/src/error.rs
  • rs/moq-net/src/goaway.rs
  • rs/moq-net/src/ietf/adapter.rs
  • rs/moq-net/src/ietf/session.rs
  • rs/moq-net/src/ietf/subscriber.rs
  • rs/moq-net/src/lite/session.rs
  • rs/moq-net/src/lite/subscriber.rs
  • rs/moq-net/src/model/broadcast.rs
  • rs/moq-net/src/model/origin.rs
  • rs/moq-net/src/session.rs
  • rs/moq-net/tests/goaway.rs
  • rs/moq-relay/src/cluster.rs
  • rs/moq-relay/src/config.rs
  • rs/moq-relay/src/lib.rs
  • rs/moq-relay/src/main.rs
  • rs/moq-relay/src/shutdown.rs
  • rs/moq-relay/tests/goaway_cluster.rs
🚧 Files skipped from review as they are similar to previous changes (10)
  • rs/moq-relay/src/lib.rs
  • rs/moq-net/src/error.rs
  • rs/moq-relay/src/shutdown.rs
  • doc/concept/layer/moq-lite.md
  • rs/moq-relay/tests/goaway_cluster.rs
  • rs/moq-net/src/goaway.rs
  • rs/moq-net/src/lite/session.rs
  • rs/moq-net/tests/goaway.rs
  • rs/moq-net/src/ietf/session.rs
  • rs/moq-native/tests/broadcast.rs

Comment on lines +63 to +67
/// GOAWAY-driven migration settings for [`Client::reconnect`].
#[command(flatten)]
#[serde(default)]
pub drain: crate::Drain,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look for every drain-related clap arg id/long/alias across the workspace.
rg -nP --type=rust -C4 '(id|long|alias)\s*=\s*"drain' rs
# Confirm which structs flatten ClientConfig.
rg -nP --type=rust -B3 -A1 'moq_native::ClientConfig' rs

Repository: moq-dev/moq

Length of output: 19748


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files 'rs/moq-native/src/reconnect.rs' 'rs/moq-native/src/client.rs' 'rs/moq-relay/src/config.rs' 'rs/moq-relay/Cargo.toml' 'Cargo.lock'

echo
echo "== relevant definitions =="
sed -n '1,120p' rs/moq-native/src/reconnect.rs
echo
sed -n '1,95p' rs/moq-relay/src/config.rs
echo
sed -n '50,75p' rs/moq-native/src/client.rs

echo
echo "== dependency pins =="
rg -n 'name = "clap"\\n|clap' Cargo.lock | head -80 || true
rg -n 'clap' rs/moq-native/Cargo.toml rs/moq-relay/Cargo.toml rs/moq-cli/Cargo.toml rs/moq-bench/Cargo.toml Cargo.toml || true

Repository: moq-dev/moq

Length of output: 8166


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== exact clap versions =="
sed -n '918,924p;1094,1117p;4409,4445p;4624,4639p;8765,8771p' Cargo.lock

echo
echo "== read-only structural merge of drain-related argument data =="
python3 - <<'PY'
from pathlib import Path
files = [
    "rs/moq-relay/src/config.rs",
    "rs/moq-native/src/client.rs",
    "rs/moq-native/src/reconnect.rs",
]
for path in files:
    text = Path(path).read_text()
    print(f"--- {path} ---")
    done = set()
    for i, line in enumerate(text.splitlines(), 1):
        if any(s in line for s in ('id = "drain', 'long = "drain', "alias = \"drain")):
            print(f"{i}: {line.strip()}")
    # crude structural block extraction for argument attrs only if desired.
PY

echo
echo "== behavior probe from clap source availability and package metadata only =="
python3 - <<'PY'
from pathlib import Path
cargo = Path("Cargo.lock").read_text()
# If lock file says clap is vendored locally in the repo, read local docs/source snippets instead.
for block in cargo.split("\n[[package]]\n")[:1]:
    pass
PY

Repository: moq-dev/moq

Length of output: 2148


Remove --drain-timeout from moq-relay's duplicate drain args.

moq relay flattens ClientConfig, which already exposes --drain-timeout, but relay still declares --drain with alias = "drain-timeout". Clap cannot define the same long argument in one command, so moq relay --help (or parse-time config loading) panics instead of loading relay config. Drop the relay alias or rename the relay option to avoid exposing the same long name.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-native/src/client.rs` around lines 63 - 67, Remove or rename the
`drain` option’s `drain-timeout` alias in the relay command configuration so it
no longer conflicts with the `ClientConfig` option flattened into `moq relay`.
Preserve the existing drain behavior while ensuring `moq relay --help` and
configuration parsing complete without duplicate Clap arguments.

Comment thread rs/moq-native/src/reconnect.rs
Comment thread rs/moq-relay/src/main.rs Outdated
Comment on lines +21 to +40
if std::env::var_os("MOQ_DRAIN_TIMEOUT").is_some() {
tracing::warn!("MOQ_DRAIN_TIMEOUT is deprecated; use MOQ_DRAIN (a humantime duration, e.g. \"10s\") instead");
// Fallback: if the new env isn't set, parse the old one.
if config.drain.is_none() {
if let Ok(val) = std::env::var("MOQ_DRAIN_TIMEOUT") {
if let Ok(secs) = val.parse::<u64>() {
config.drain = Some(std::time::Duration::from_secs(secs));
}
}
}
}
if std::env::var_os("MOQ_CLUSTER_DRAIN_TIMEOUT").is_some() {
tracing::warn!("MOQ_CLUSTER_DRAIN_TIMEOUT is deprecated; use MOQ_CLUSTER_DRAIN (a humantime duration, e.g. \"10s\") instead");
if config.cluster.drain.is_none() {
if let Ok(val) = std::env::var("MOQ_CLUSTER_DRAIN_TIMEOUT") {
if let Ok(secs) = val.parse::<u64>() {
config.cluster.drain = Some(std::time::Duration::from_secs(secs));
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

For humantime 2.3.0 and humantime-serde 1.1.0, confirm whether bare numeric values such as 10are accepted for clap duration parsing and TOMLDuration deserialization.

💡 Result:

In humantime 2.3.0 and humantime-serde 1.1.0, bare numeric values such as 10 are not accepted for duration parsing or deserialization, with the exception of the value 0 [1][2][3]. For humantime 2.3.0, the parse_duration function strictly expects a number followed by a unit (e.g., 10s or 10sec) [1][4]. While version 2.3.0 introduced support for parsing "0" as a valid zero duration, all other bare integers will fail with a parsing error because a time unit is required [2][3][4]. For humantime-serde 1.1.0, the deserialization logic is implemented by calling humantime::parse_duration on the provided string [5]. Because it specifically uses visit_str to process inputs as strings and subsequently invokes the underlying humantime parser, it inherits the same requirement for units [5]. Consequently, bare numeric values in TOML or other formats will not be parsed as durations by humantime-serde [5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Cargo humantime versions =="
rg -n 'humantime|humantime_serde' Cargo.toml Cargo.lock rs/moq-relay -g 'Cargo.toml' -g 'Cargo.lock' || true

echo
echo "== Candidate files =="
fd -a 'main\.rs|config\.rs|cluster\.rs' rs/moq-relay/src || true

echo
echo "== Relevant snippets =="
sed -n '1,120p' rs/moq-relay/src/main.rs
echo
sed -n '1,140p' rs/moq-relay/src/config.rs
echo
sed -n '330,385p' rs/moq-relay/src/cluster.rs

echo
echo "== Dep source/type checks =="
python3 - <<'PY'
from pathlib import Path
import re

cargo_lock = Path('Cargo.lock').read_text()
for m in re.finditer(r'(?ms)^\[\[package\]\]\nname = "humantime-?serde"[\S\s]*?version = "([^"]+)"', cargo_lock):
    print(m.group(0))
for name in ['humantime-serde', 'humantime_serde', 'humantime']:
    if name in cargo_lock:
        print(f'--- {name} ---')
        for m in re.finditer(r'(?ms)^\[\[package\]\]\nname = "'+re.escape(name)+'"[\S\s]*?version = "([^"]+)"', cargo_lock):
            print(m.group(0))

PY

echo
echo "== Inspect humantime-serde dependency source via crates.io source archive (static inspection, not build/run repo code) =="
python3 - <<'PY'
from pathlib import Path
import tarfile
import tempfile
import urllib.request

pkg = 'humantime-serde'
ver = '1.1.0'
url = f'https://crates.io/api/v1/crates/{pkg}/{ver}/download'
tmp = tempfile.mkdtemp()
path = Path(tmp) / f'{pkg}-{ver}.crate'
print(f'fetching {url} to {path}')
urllib.request.urlretrieve(url, path)
with tarfile.open(path, 'r:gz') as tf:
    names = [n for n in tf.getnames() if any(n.endswith(x) for x in ['.rs', 'Cargo.toml'])]
    for n in names:
        if 'src' in n or n == 'Cargo.toml':
            print(f'--- {n} ---')
            data = tf.extractfile(n).read().decode()
            for i,line in enumerate(data.splitlines(),1):
                if 'visit_str' in line or 'visit_u64' in line or r'parse_duration' in line or 'Duration' in line or 'humantime' in line.lower():
                    print(f'{i}: {line}')
PY

Repository: moq-dev/moq

Length of output: 15268


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Cargo config definitions =="
sed -n '160,220p' rs/moq-relay/src/config.rs
echo
sed -n '400,760p' rs/moq-relay/src/config.rs
echo
sed -n '300,430p' rs/moq-relay/src/cluster.rs

echo
echo "== config source via crates.io tarball =="
python3 - <<'PY'
import gzip, tarfile, urllib.request, tempfile, pathlib, re
pkg, ver = 'config', '1.7.0'
url = f'https://crates.io/api/v1/crates/config/{ver}/download'
data = urllib.request.urlopen(url).read()
path = pathlib.Path(tempfile.mkdtemp()) / f'{pkg}-{ver}.tar.gz'
path.write_bytes(data)
with tarfile.open(path, 'r:gz') as tf:
    for n in tf.getnames():
        if 'src' in n and n.endswith('.rs'):
            txt = next(tf.extractfile(n)).read().decode(errors='replace')
            if 'humantime_serde' in txt or 'value_parser' in txt or 'ParseDuration' in txt:
                print(f'--- {n} ---')
                for i,line in enumerate(txt.splitlines(),1):
                    if 'humantime_serde' in line or 'value_parser' in line or 'ParseDuration' in line or 'String' in line or 'Duration' in line or 'default()' in line or 'with_3' in line:
                        print(f'{i}: {line}')
PY

Repository: moq-dev/moq

Length of output: 23535


Keep legacy timeout inputs functional, not just their names.

humantime* requires duration suffixes, so legacy bare-numeric inputs like --drain-timeout 10, drain_timeout = 10, or MOQ_DRAIN_TIMEOUT=10 no longer parse as seconds. The current post-merge environment fallback is also late in merge order, so TOML values can win over legacy env vars and malformed legacy values are ignored. Parse legacy seconds during configuration merging with normal precedence, reject invalid legacy values, and add legacy CLI/TOML/environment coverage for both drain_timeout and cluster.drain_timeout.

📍 Affects 3 files
  • rs/moq-relay/src/main.rs#L21-L40 (this comment)
  • rs/moq-relay/src/config.rs#L66-L74
  • rs/moq-relay/src/cluster.rs#L356-L364
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-relay/src/main.rs` around lines 21 - 40, Update configuration merging
across rs/moq-relay/src/main.rs lines 21-40, rs/moq-relay/src/config.rs lines
66-74, and rs/moq-relay/src/cluster.rs lines 356-364 so legacy drain_timeout and
cluster.drain_timeout values accept bare numbers as seconds through CLI, TOML,
and environment inputs. Parse these legacy values during normal precedence-aware
merging, reject and report malformed values instead of silently ignoring them,
remove the late post-merge environment fallback, and add coverage for both
timeout settings across all legacy input sources.

Source: Coding guidelines

* 'main' of https://github.com/moq-dev/moq: (43 commits)
  feat(cli): add jemalloc heap profiling (moq-dev#2539)
  feat(kio): add a poll-native Deadline and adopt it in moq-net (moq-dev#2536)
  test(moq-srt): size the burst test below the SRT sender's drop window (moq-dev#2540)
  refactor(net): replace the global LRU cache pool with per-track write-time eviction (moq-dev#2526)
  ci: compile-check the Windows and macOS code paths on PRs (moq-dev#2531)
  chore(net): require @moq/qmux 0.3.2 for fallback reset codes (moq-dev#2535)
  fix(hang,watch): deliver non-sequential group ids incrementally (moq-dev#2484)
  feat(moq-audio): play decoded PCM out a speaker (moq-dev#2529)
  fix(js/publish): capture video through a worker MediaStreamTrackProcessor (moq-dev#2528)
  feat(net): surface a peer's stream reset code as a remote error (moq-dev#2510)
  feat(mux): expose catalog bitrate/jitter measurement as catalog::Estimator (moq-dev#2530)
  fix(net): release cache-pool registrations so publishers stop leaking (moq-dev#2525)
  build(deps): bump the github-actions group with 2 updates (moq-dev#2524)
  build(deps-dev): bump ruff from 0.15.21 to 0.15.22 in the uv group (moq-dev#2523)
  build(deps): bump the bun group with 2 updates (moq-dev#2522)
  chore: bump non-Rust package patch versions (moq-dev#2519)
  fix(hang): reject a non-hex catalog description instead of misreading it (moq-dev#2516)
  build(deps): bump the github-actions group across 1 directory with 6 updates (moq-dev#2464)
  refactor(net): poll kio readiness directly (moq-dev#2515)
  chore: release (moq-dev#2491)
  ...

# Conflicts:
#	rs/moq-net/src/client.rs
- lite/subscriber.rs test: add missing goaway_received and going_away
  fields to SubscriberConfig in test (upstream added test, our branch
  added new required fields)
- moq-relay/config.rs: remove drain-timeout serde/clap compat name from
  Config::drain to avoid clap collision with moq-native Drain::timeout
  (both added by goaway branch, compat name no longer needed)
- moq-relay/config.rs: add std::sync::Mutex import for test module
MOQ_DRAIN_TIMEOUT now configures the moq-native reconnect drain, so the
relay must not also read it as a deprecated alias for its own shutdown
drain. Setting it for the client silently reconfigured the server drain
and logged a deprecation warning at a correct use.

The relay shutdown drain keeps --drain / MOQ_DRAIN. The cluster key still
accepts MOQ_CLUSTER_DRAIN_TIMEOUT, which nothing else claims.
The drain keys are new in this branch and have never shipped, so there
are no existing configs to keep working. Carrying aliases and env
fallbacks for names that were never released is dead weight.

Leaves one name per setting: drain / MOQ_DRAIN for the shutdown drain,
and cluster.drain / MOQ_CLUSTER_DRAIN for the upstream peer drain.
@ksletmoe-aws

Copy link
Copy Markdown
Contributor Author

@kixelated pushed a few more commits, mostly housekeeping to get this mergeable again:

  • merged current main in to clear the conflicts. One real conflict in moq-net/src/client.rs: your poll_ready(waiter) refactor from refactor(net): poll kio readiness directly #2515 against the added start.goaway arg on Session::new, kept both.
  • dropped the relay's MOQ_DRAIN_TIMEOUT handling. It collided with the --drain-timeout env on the moq-native reconnect drain, so setting the client drain was silently reconfiguring the relay's shutdown drain and warning at a correct use.
  • stripped the deprecation aliases on the drain config keys. I had added hidden aliases for drain_timeout -> drain, then realized drain_timeout only ever existed on this branch, so I was carrying back-compat machinery for a name we invented and never shipped. One name per setting now.

I also saw #2542. That looks like a cleaner shape than what I had, and I am happy to just follow up there instead of here if you would rather. Say the word and I will close this one out.

One thing that looks missing from #2542 though, and it is the bit you originally flagged: I do not see the draining route getting deprioritized. #2542 keeps the old routes attached at their normal cost and lets tracks hand over at a group boundary, which works when the replacement is genuinely cheaper, but on equal cost route_order falls through to hop count and then hash, so the draining connection can stay primary. That is the "cost a draining connection as infinite so it is never used as primary" case.

What I have on this branch for that: on GOAWAY receipt the subscriber bumps its own routes to broadcast::DRAIN_COST (u64::MAX), so best_route moves live subscribers off the dying connection. It sits at the session layer rather than in Reconnect, since that is where the broadcast producers actually live, so it needs no new public API and both lite and IETF get it. Happy to port that onto goaway-api as a separate commit if you want it, or drop it if you would rather solve the handover a different way.

(Written by claude-opus-4.8)

@ksletmoe-aws

Copy link
Copy Markdown
Contributor Author

Closed in favor of #2542

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants