Skip to content

fix: accept QUIC streams only once ready and allow for Network.framework's stream 0 - #19

Open
raiseCatError wants to merge 5 commits into
dev/opendisplay-nextfrom
fix/quic-ios-runtime
Open

raiseCatError wants to merge 5 commits into
dev/opendisplay-nextfrom
fix/quic-ios-runtime

Conversation

@raiseCatError

@raiseCatError raiseCatError commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

What changed

Explicit QUIC failed on every dial to a physical iPhone receiver. Two receiver-side defects caused it. Both are now reproduced over real loopback QUIC in CI and fixed in Shared/QUICReceiverSession.swift.

  1. Network.framework's own per-connection object was treated as a MEOW stream.
    • What happens: on every accepted QUIC connection, NWConnectionGroup.newConnectionHandler delivers one extra object beyond the sender's streams. It has no stream ID, never gets past preparing, and carries no data. CI diagnostics recorded 3 objects for 2 sender streams and 4 for 3, in every variant.
    • What the receiver did wrong: it counted that object against the three-stream topology, armed a preface timeout on it, and read from it right after start.
    • Effect on iPhone: that read fails at once. The iPhone log shows it as initial socket-flow … No output handler, then Receive failed with error "Socket is not connected", then quicListener: closing connection N appError=invalidStreamPreface on every connection.
    • Fix: delivered objects are only counted, timed and read once they are .ready. An object that fails before becoming ready is dropped without closing the connection. A real stream's failure is reported as a transport failure, not invalidStreamPreface. Delivered objects stay bounded (at most 3 + 1).
  2. The stream limit of 3 blocked the sender's third stream.
    • Network.framework uses QUIC stream 0 itself, so the sender's streams are IDs 4, 8 and 12. With initial_max_streams_bidi = 3, the third stream stayed preparing forever; CI showed this for Audio in one run and Control in another.
    • Fix: the limit is now 4 (QUICReceiverLimits.transportBidirectionalStreamLimit), set in one shared place (QUICReceiverListener.configureStreamLimits). The application still enforces exactly one stream per channel, three in total.

Also:

  • The listener parameters come from one factory, QUICReceiverListener.listenerParameters, which the loopback tests now use too.
  • Bounded DEBUG logs: one line per accepted connection, and one per stream when it becomes ready, including its stream ID.
  • PROTOCOL.md §2.4 documents the stream-limit detail.

Unchanged: authentication, pinning, ALPN, no 0-RTT, admission, the fallback/downgrade rules, explicit QUIC never using TCP, migration, framing, and the sender. An earlier commit on this branch removed local-endpoint reuse from the listener. A later CI check did not support that theory, so the change is reverted and the listener parameters are exactly as before.

Why

The iPhone's QUIC listener was accepting Mac connections on Wi‑Fi, so UDP wasn't blocked. The receiver closed each connection itself, then the Mac redialed until the retry budget ran out. The NECP File exists and EINVAL connection failures in the log came during this close-and-redial churn. With the connection no longer being closed, they should not recur; the retest will confirm that.

The macOS loopback CI from #18 missed this for two reasons. Its server used its own inline stream handling instead of QUICReceiverGroup. It also opened only two streams, which fit under the limit of 3. On macOS, reading Network.framework's extra object just never completes, where iOS fails the read immediately; the ad-hoc server ignored that.

Verification

  • CI on f0656a6 (macos-26): 1,635 tests, 0 failures; Mac Receiver build (macOS 12 floor) passed; iOS receiver build (iOS 16.4 floor) passed; reserved-region SDK gate passed; PR title check passed. There are no new warnings in production code.

  • New regression tests:

    • QUICSecureTransportTests.testProductionReceiverGroupAcceptsAllThreeChannels runs the production listener parameters, stream limits and QUICReceiverGroup over real loopback QUIC. The sender dials the way MacSenderTransportController.openQUICStreams does: three streams, preface sent right after start. The test checks that Control reaches the pipeline, both media streams are parked, all streams share one connection, and the connection is still open after the preface timeout. It also checks the dialer never sees a receiver-opened stream. This test failed before the fix and passes now; CI logs show streams 4, 8 and 12 accepted.
    • QUICReceiverAuthorityTests:
      • testANeverReadyDeliveredObjectDoesNotCloseTheConnection
      • testDeliveredObjectsAreBounded
      • testStreamLimitsLeaveRoomForNetworkFrameworksOwnStream
      • testPrefaceReadErrorIsATransportFailureNotAViolation
      • testPrefaceReadClassifiesPeerBytes
    • All existing QUIC security, migration and selection tests still pass. The temporary diagnostic test was removed before this state.
  • Still pending: a physical iPhone retest (steps in the session report).

  • The change is focused, without unrelated refactors

  • Relevant behaviour was verified (tests and/or on device) — tests and loopback QUIC in CI; on-device retest pending

  • Documentation updated if needed

  • No credentials, secrets or signing material included

  • No unrelated generated or build artifacts

…reams only when ready

On iPhone every QUIC dial failed. The listener's new-flow path received
datagrams of an already accepted connection, failed to register a duplicate
flow for the same 4-tuple (NECP ADD_FLOW EEXIST) and took the live
connection down (EINVAL); its streams then failed their preface read with
ENOTCONN, which was misreported as invalidStreamPreface.

- The QUIC listener no longer opts into local-endpoint reuse, which the
  TCP listener needs but a UDP QUIC server does not: a second binding of
  UDP 9001 now fails and retries instead of silently sharing the port.
- Listener parameters come from one factory shared with the loopback
  tests, so CI exercises the production configuration.
- Accepted streams are read only after reaching .ready, and a failed read
  or stream is a transport failure, never a protocol violation.
- Bounded DEBUG logging of accepted connections and ready streams.
…ramework's stream 0

Physical iPhone QUIC failed on every dial, and the same receiver path fails
over macOS loopback once it is actually exercised:

- Network.framework delivers one object of its own per QUIC connection to
  newConnectionHandler (no stream ID, never ready, no data). The receiver
  treated it as a MEOW stream: it counted it against the three-stream
  topology, armed a preface timeout on it and read from it. On iPhone that
  read fails at once (ENOTCONN) and every connection was closed as
  invalidStreamPreface. Delivered objects now count and are read only once
  they are .ready; a never-ready object that fails is dropped. Delivered
  objects stay bounded.
- The sender's streams start at stream ID 4 because stream 0 is Network.
  framework's, so initial_max_streams_bidi = 3 left the sender's third
  stream blocked forever. The limit is now 4, set from one shared place.
- Reverts the listener local-endpoint-reuse change: the evidence did not
  support it.
- The loopback test now runs the production QUICReceiverGroup with three
  streams, production listener parameters and stream limits.
@raiseCatError raiseCatError changed the title fix: make the iOS QUIC listener own UDP 9001 and read streams only when ready fix: accept QUIC streams only once ready and allow for Network.framework's stream 0 Sep 27, 2026
@raiseCatError
raiseCatError marked this pull request as ready for review September 27, 2026 13:14
… gestures

QUIC (Mac sender): NWConnectionGroup `.waiting` is no longer fatal. A
physical Mac -> iPhone dial reported `.waiting(ENETDOWN)` and then `.ready`;
failing on `.waiting` tore the tunnel down before its streams opened. The
state handling moves into a pure QUICGroupStatePolicy: `.waiting` is
uniformly nonterminal, `.failed` stays fatal, `.cancelled` is unchanged,
and the existing 8 s application-handshake timeout still bounds a group
that never becomes ready. Control-stream adoption (becomeReady), endpoint
selection, includePeerToPeer, stream limits, pinned mutual TLS, ALPN and
no-0-RTT are unchanged.

iPad receiver:
- VideoView opts out of the three-finger editing interactions.
- Screen-edge system gestures are deferred only while the remote surface
  is shown and input may reach the Mac.
- While VoiceOver runs, every custom multi-finger recognizer (two-finger
  viewport, two-finger double-tap, three-finger swipe/tap, 4/5-finger
  pinch/spread) steps aside, following runtime VoiceOver changes;
  force-cancel toggles restore the gate's state, never a blind `true`.
- Opt-in (hidden) Function Tray items for Launchpad and Show Desktop,
  which have no reliable keyboard shortcut; saved profiles gain them
  hidden.
- Document why UIRequiresFullScreen stays for now.

Tests cover the group-state policy, the handshake-timeout class, explicit
QUIC never falling back to TCP, a loopback run of the production dialer
adopting the Control stream, the VoiceOver gate and the new tray items.
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