fix: accept QUIC streams only once ready and allow for Network.framework's stream 0 - #19
Open
raiseCatError wants to merge 5 commits into
Open
raiseCatError wants to merge 5 commits into
raiseCatError wants to merge 5 commits into
Conversation
…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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.NWConnectionGroup.newConnectionHandlerdelivers one extra object beyond the sender's streams. It has no stream ID, never gets pastpreparing, and carries no data. CI diagnostics recorded 3 objects for 2 sender streams and 4 for 3, in every variant.start.initial socket-flow … No output handler, thenReceive failed with error "Socket is not connected", thenquicListener: closing connection N appError=invalidStreamPrefaceon every connection..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, notinvalidStreamPreface. Delivered objects stay bounded (at most 3 + 1).initial_max_streams_bidi = 3, the third stream stayedpreparingforever; CI showed this for Audio in one run and Control in another.QUICReceiverLimits.transportBidirectionalStreamLimit), set in one shared place (QUICReceiverListener.configureStreamLimits). The application still enforces exactly one stream per channel, three in total.Also:
QUICReceiverListener.listenerParameters, which the loopback tests now use too.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 existsandEINVALconnection 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.testProductionReceiverGroupAcceptsAllThreeChannelsruns the production listener parameters, stream limits andQUICReceiverGroupover real loopback QUIC. The sender dials the wayMacSenderTransportController.openQUICStreamsdoes: three streams, preface sent right afterstart. 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:testANeverReadyDeliveredObjectDoesNotCloseTheConnectiontestDeliveredObjectsAreBoundedtestStreamLimitsLeaveRoomForNetworkFrameworksOwnStreamtestPrefaceReadErrorIsATransportFailureNotAViolationtestPrefaceReadClassifiesPeerBytesStill 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