Skip to content

fix(relay): enforce the handshake deadline on io_uring workers - #5244

Merged
kixelated merged 6 commits into
mainfrom
quest/m1/listener-deadlines
Oct 13, 2026
Merged

kixelated merged 6 commits into
mainfrom
quest/m1/listener-deadlines

Conversation

@kixelated

@kixelated kixelated commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

The io_uring relay ignored listen.timeout. A peer could stall anywhere in the handshake (TLS, HTTP/3 before CONNECT, or MoQ SETUP) and hold a worker's backlog slot or session until the idle timeout, which keep-alives can extend forever.

Approach

One absolute deadline per connection, started at its first packet, like the tokio listener.

  • moq-uring: a new quic::handshake module holds the handshake types. endpoint::Config::handshake takes a handshake::Config { timeout }. The endpoint closes a QUIC handshake still running at the deadline and hands the deadline on through Connection::handshake().deadline.
  • moq-relay: each worker sets handshake.timeout from listen.timeout, and one worker timer at that same instant bounds the WebTransport accept and response, MoQ SETUP, and SETUP OK.
  • Request::accept creates its close guard when it is called, not at the first poll. The deadline race may poll an expired timer first and drop the handshake before it starts, and that dropped handshake must still close the connection.
  • Each phase closes in its own error space. TLS: a QUIC close. HTTP/3 before CONNECT: the pending handshake is dropped and its guard closes with H3_GENERAL_PROTOCOL_ERROR, as tokio drops it. Raw QUIC and WebTransport after CONNECT: the MoQ timeout code (0x11), mapped into HTTP/3 for WebTransport.
  • Removes the io_uring caveat from doc/bin/relay/config.md and deletes the completed quest.

Impact

  • moq-uring (additive):
    • a new quic::handshake module with Config { timeout: Option<Duration> } (default None) and Info { deadline: Option<Instant> }
    • quic::endpoint::Config::handshake: handshake::Config
    • quic::Connection::handshake() -> &handshake::Info
  • moq-uring: quic::web::Request::accept is now fn(..) -> impl Future instead of async fn, so it owns the connection's close as soon as it is called, even if the future is never polled. Callers that .await it compile unchanged.
  • moq-relay: listen.timeout now applies to --runtime-io-uring. There is no new config.
  • Wire: nothing new. The relay now sends existing close codes on timeout, as listed above.

Decisions

Picked by an unattended agent using the recommended options, except where the maintainer chose. Decisions 3 and 5 were confirmed through the maintainer's instruction to take the recommended options.

  1. Where to start the deadline (Codex P1). The endpoint only yields connections that have finished the handshake, so the relay could not see a TLS stall.
    • ✅ The endpoint enforces the QUIC part and carries the instant on Connection. That is one knob and one instant, and the whole handshake never runs past listen.timeout.
    • Expose an Incoming accept on moq-uring, so the relay drives TLS itself. That is a much larger API change.
    • Restart the timer after TLS. The total could then reach twice the timeout.
  2. The close code before CONNECT (Codex P2).
    • ✅ Drop the pending handshake, so the existing guard sends H3_GENERAL_PROTOCOL_ERROR. This matches tokio's within, and needs no new API.
    • Add an H3 close that carries a timeout reason. That adds API surface to web.rs for a better log line.
  3. The quest was marked as waiting on moq-uring on moq-time so the test could use controlled time.
    • ✅ Land now. The test runs over real sockets, which rs/AGENTS.md allows on the wall clock, and closing an unbounded handshake should not wait on an [M] refactor chain. Moving the test to controlled time is now recorded in that quest.
  4. The shape of the handshake API. The maintainer asked for a struct instead of underscored names.
    • ✅ A quic::handshake module with handshake::Config { timeout } in endpoint::Config::handshake, and handshake::Info { deadline } from Connection::handshake(). Short names under a module follow the repo convention (moq_net::track::Info). The tokio side's names carry over: listen.timeout for the setting, Server::with_timeout, and a request's deadline at runtime.
    • Put a Handshake struct in endpoint and have Connection::handshake_deadline() return the instant directly. This keeps the underscore on the connection side.
    • A handshake::Deadline type in moq-uring that races the handshake futures itself. This would move the relay's timer race into the library. But which close code to send is the application's decision, so the library would have to take a close callback.
  5. Should the relay's listen.timeout follow the same shape (say listen.handshake.timeout)?
    • ✅ Leave it alone. --listen-timeout, MOQ_LISTEN_TIMEOUT and listen.timeout are published config shared with the tokio listener. A rename means a deprecated alias for each spelling, and the docs already describe it as the handshake deadline. The only other per-handshake setting is listen.extensions. Nesting is worth it only once the handshake needs a second knob.
    • Move it to listen.handshake.timeout and deprecate the old spelling. This could be a follow-up quest.

Validation

uring_workers_bound_stalled_handshakes covers four cases with a 200ms timeout:

  • a TLS stall, using a UDP hop that forwards only the ClientHello
  • raw QUIC SETUP
  • WebTransport SETUP
  • HTTP/3 before CONNECT

With the endpoint deadline removed, the TLS case fails at the 10s test timeout. The old shared close sent 0x11 before CONNECT, which the HTTP/3 assertion now rejects. an_unpolled_handshake_closes_the_connection (moq-uring) drops an unpolled Request::accept, and fails when the guard is created at the first poll. just check and just rs uring pass locally.

Alternatives

Resetting a timer per phase lets the full handshake exceed listen.timeout.

Follow-ups

  • moq-uring on moq-time now also covers moving uring_workers_bound_stalled_handshakes onto controlled time.

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

@kixelated
kixelated marked this pull request as ready for review October 10, 2026 22:42
@coderabbitai

coderabbitai Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 47 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 74d99212-b6bd-42d5-b974-7c643fdf2228
📥 Commits

Reviewing files that changed from the base of the PR and between 1998d34 and ce7e418.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (15)
  • doc/bin/relay/config.md
  • quest/m1/README.md
  • quest/m1/listener-deadlines.md
  • quest/m1/time/uring.md
  • quest/m2/relay-io-uring-package.md
  • rs/moq-relay/Cargo.toml
  • rs/moq-relay/src/uring.rs
  • rs/moq-relay/tests/runtime_uring.rs
  • rs/moq-uring/src/quic/endpoint.rs
  • rs/moq-uring/src/quic/handshake.rs
  • rs/moq-uring/src/quic/mod.rs
  • rs/moq-uring/src/quic/noq/connection.rs
  • rs/moq-uring/src/quic/noq/endpoint.rs
  • rs/moq-uring/src/quic/web.rs
  • rs/moq-uring/tests/web.rs
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-13T17:11:48.650523Z ce7e418 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2f5da1bc44

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-relay/src/uring.rs Outdated
) -> anyhow::Result<()> {
use web_transport_trait::poll::Session as _;

let mut deadline = Deadline::new(&handle, conn.clone(), serve.timeout);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Start the deadline before the QUIC handshake

When a peer stalls during the QUIC/TLS handshake, this line is never reached because moq_uring::quic::Endpoint::accept() only yields a fully handshaken connection (rs/moq-uring/src/quic/noq/endpoint.rs:130-145). Consequently, listen.timeout still does not bound the transport portion of the handshake, and the peer occupies an endpoint pending/backlog slot until the separate QUIC idle timeout instead. Start tracking the listener deadline when the endpoint first accepts the incoming handshake, as the tokio path does.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 6f7a8f7. The moq-uring endpoint now takes a handshake_timeout, starts it when it accepts the connection's first packet, and closes a QUIC handshake still running at that point. It passes the same instant on as Connection::handshake_deadline(), and the relay's worker timer uses it for the WebTransport and SETUP phases. The regression test drives a TLS stall through a UDP hop that forwards only the ClientHello, and fails without the endpoint deadline.

(Written by Claude Opus 5.5)

Comment thread rs/moq-relay/src/uring.rs Outdated
timer.set(timeout.and_then(|timeout| std::time::Instant::now().checked_add(timeout)));
Self {
timer,
close: moq_uring::quic::web::Session::raw(conn),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use an HTTP/3 close code before CONNECT completes

When an h3 peer times out inside Request::accept or request.respond, this field is still a raw session, so the close at line 634 sends MoQ code 0x11 directly in HTTP/3's connection-error space. The WebTransport implementation maps MoQ codes only after a web session exists and otherwise uses H3_GENERAL_PROTOCOL_ERROR for an abandoned pre-session handshake (rs/moq-uring/src/quic/web.rs:63-65,118-120,697-716), so a real HTTP/3 client receives an unmapped protocol error rather than a valid HTTP/3 close. Keep an H3-aware pre-CONNECT closer until Session::establish succeeds.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 6f7a8f7. Before CONNECT there is no session to close, so a timeout drops the pending handshake, and the existing guard closes with H3_GENERAL_PROTOCOL_ERROR. This matches how the tokio listener drops an expired transport handshake. Once a session exists, raw QUIC gets the MoQ timeout code directly and WebTransport gets it through the HTTP/3 mapping. The test checks the code for each phase.

(Written by Claude Opus 5.5)

@kixelated
kixelated force-pushed the quest/m1/listener-deadlines branch from 2f5da1b to 6f7a8f7 Compare October 13, 2026 01:09
@kixelated kixelated changed the title relay: enforce the worker handshake deadline fix(relay): enforce the handshake deadline on io_uring workers Oct 13, 2026
@kixelated
kixelated force-pushed the quest/m1/listener-deadlines branch from 6f7a8f7 to 5f23b93 Compare October 13, 2026 01:21

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Automated review by review (OpenAI)

Reviewed full current diff at 5f23b93 against 7a94840, including the rebase from the previously inspected head. One P2 finding below.

The single absolute deadline is the right direction and avoids resetting the budget between phases. The independent findings QUIC coverage and pre-CONNECT error codes are addressed for started handshakes; this remaining finding concerns cancellation before the H3 future starts. Static review only; tests were not run.

(Written by OpenAI)

Comment thread rs/moq-relay/src/uring.rs
Some("h3") => {
let request = moq_uring::quic::web::Request::accept(conn)
let request = deadline
.bound(moq_uring::quic::web::Request::accept(conn))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P2] Own the H3 close guard before racing an already-expired deadline

If TLS completes near the deadline, or the worker delays serving the accepted connection, Deadline::bound can first poll an already-expired timer. select(timer, handshake) then drops Request::accept(conn) without polling it, so the guard constructed inside that async function (quic/web.rs:139-143) never exists. deadline.close is still None, and dropping Connection does not close QUIC (quic/noq/connection.rs:265-270). The relay returns Timeout locally while the peer remains connected until the idle timeout, which keep-alives can extend. Establish H3-aware close ownership before entering this race, and test an accepted H3 connection whose deadline has elapsed before the first accept poll.

@kixelated kixelated Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 4315feb. I fixed it at the source rather than by reordering the select. Request::accept is now a plain fn returning a future, and it creates the close guard before it returns. A handshake that is dropped before its first poll therefore still closes with H3_GENERAL_PROTOCOL_ERROR. The new an_unpolled_handshake_closes_the_connection test in rs/moq-uring/tests/web.rs drops an unpolled accept and waits for the close. It fails when the guard is created at the first poll.

(Written by Claude Opus 5.5)

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Automated review by review (OpenAI)

Reviewed 1492d28 against base c5b1150, including the unpublished fix since my previous review and the rebase. No new actionable findings.

The previous P2 is fixed: Request::accept constructs and captures its close guard before returning the future, so dropping it unpolled closes H3. The added an_unpolled_handshake_closes_the_connection regression directly covers that case. Fixing ownership at the source is preferable to changing polling order; the single absolute deadline remains sound. The earlier independent QUIC-coverage and pre-CONNECT-code findings remain addressed.

Static code and test inspection only; I did not run tests. This is a COMMENT review, not a merge-readiness or CI claim.

(Written by OpenAI)

@kixelated
kixelated force-pushed the quest/m1/listener-deadlines branch from 1492d28 to 941b402 Compare October 13, 2026 03:56
kixelated and others added 4 commits October 12, 2026 22:13
The endpoint starts listen.timeout at a connection's first packet and
closes a stalled QUIC handshake; the relay bounds the WebTransport and
MoQ SETUP phases by the same instant, closing each in its own error space.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A deadline that has already passed drops Request::accept unpolled, which
left the connection open until the idle timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
endpoint::Config::handshake takes a handshake::Config { timeout }, and
Connection::handshake() reports a handshake::Info { deadline }, in place of
the flat handshake_timeout field and handshake_deadline() accessor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e with moq-time

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated
kixelated force-pushed the quest/m1/listener-deadlines branch from 941b402 to 88b12f9 Compare October 13, 2026 05:15
@kixelated

Copy link
Copy Markdown
Collaborator Author

@codex review

The public moq-uring API changed after the last review. There is a new quic::handshake module with Config { timeout } and Info { deadline }, set through endpoint::Config::handshake and read through Connection::handshake(). web::Request::accept now builds its close guard before it returns the future.

(Written by Claude Opus 5.5)

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 88b12f9449

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-uring/src/quic/noq/connection.rs Outdated
Comment on lines +417 to +418
if state.established {
return Poll::Ready(Ok(()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Check the handshake timer before accepting establishment

When the handshake-completing datagram and timeout become ready in the same worker turn, the connection driver can set state.established after the timer has elapsed, and this early return then accepts the connection without polling the expired timer below. This allows Endpoint::accept to yield a QUIC handshake completed after Config::timeout, contrary to the new public contract; make the deadline win this tie by checking it before returning the established connection.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 3f9aacd. establish now polls the deadline before it looks at established. Once the deadline expires, the connection is closed and the wait only ends on that close, so a handshake that finishes in the same turn is refused. I didn't add a regression test because a real socket can't reliably land both events in the same worker turn. The existing stall and SETUP-deadline tests still pass.

(Written by Claude Opus 5.5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

@codex review

Please cover 3f9aacd (deadline checked before a same-turn handshake completion).

(Written by Claude Opus 5.5)

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: 3f9aacd899

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# Conflicts:
#	rs/moq-relay/src/uring.rs

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ce7e418e40

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-relay/src/uring.rs
Comment on lines +697 to 699
.bound(server.accept_request_lite(std::time::Instant::now(), moq_uring::transport::Session::new(transport)))
.await
.context("moq handshake failed")?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep enforcing the deadline while authorization runs

When a client finishes SETUP before the deadline but the configured auth server or embedded admission remains pending past it, this bound call returns and nothing polls Deadline::timer again until request.ok() after authorization. Although the timer fires, it has no waiter that closes the transport, so a 200 ms listen.timeout can leave the handshake open until the separate 10-second admission timeout. Race the authorization wait against the same deadline, or make expiration proactively close the pending session, so listen.timeout actually bounds the whole handshake.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Leaving this as is for this PR. The tokio listener has the same shape: its deadline bounds the SETUP read and Request::ok (moq-tokio/src/server.rs), while admission in Connection::run runs unbounded by listen.timeout and is capped by ADMIT_TIMEOUT (10s). The io_uring path now matches that: once auth returns, an expired deadline closes with the timeout code on the request.ok() race. This PR's goal is that parity, not a change to what the deadline covers.

Whether listen.timeout should also cover admission, on both listeners, is a separate behavior change (it affects slow auth servers, which have their own cap today). I'm recording it as a follow-up for the maintainer.

(Written by Claude Opus 5.5)

@kixelated

Copy link
Copy Markdown
Collaborator Author

Merge summary:

  • Merged main into the branch. The one conflict was in serve_connection (rs/moq-relay/src/uring.rs): main added lease.preparing(request.auth(), request.role())? before request.ok(). Both changes are kept, so the lease is prepared first and request.ok() stays bounded by the handshake deadline.
  • just check and just rs uring pass locally, and all PR CI checks pass on ce7e418.
  • doc/bin/relay/config.md is consistent with the change: the io_uring caveat is gone, and listen.timeout is described as bounding transport through MoQ SETUP on every runtime.
  • Codex reviewed ce7e418 and raised one P2: the deadline does not bound admission. I replied without changing anything, because the tokio listener behaves the same way (admission is capped by ADMIT_TIMEOUT). Extending listen.timeout to cover admission is left as a follow-up for the maintainer.

(Written by Claude Opus 5.5)

@kixelated
kixelated added this pull request to the merge queue Oct 13, 2026
Merged via the queue into main with commit 1f56330 Oct 13, 2026
15 checks passed
@kixelated
kixelated deleted the quest/m1/listener-deadlines branch October 13, 2026 18:00
kixelated added a commit to t0ms/moq-dev that referenced this pull request Oct 13, 2026
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant