Repository navigation
iPhone: FTW Cloud over QUIC, racing the WebSocket (QUIC step 2) - #74
Conversation
The app session with FTW Cloud now dials ftw-cloud-app/1 over QUIC on UDP 443 first, through Network.framework's NetworkConnection. If QUIC has not opened after 250 ms the WebSocket starts too, and the first to open carries the session; the winner on each network goes first next time. - QUICCloudSocket is one WebSocketConnection: the control stream starts with the target, the box's bulk comes on a stream the service opens, and hb and live may come as datagrams. A keepalive every 15 s holds the NAT mapping; the service's close code comes back as the WebSocket's would. - CloudSockets.racing and CloudTransportMemory are the race and its memory; NetworkWatch names the network by interfaces and gateways. - CloudCarrier drops a message past the Noise window instead of ending the session, since a datagram can arrive after newer messages, and says how it travels. FTW Cloud's settings show "Connected · QUIC". - Contract.cloudTransports and protocol/registry.yaml from srcfl/ftw-webapp#91; cloud-quic-vectors.json from srcfl/ftw. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ept as a hash The simulator can run FTWKit's tests: the one shell helper is macOS only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nly the control stream ends a session In the simulator at 5 % loss one lost QUIC handshake packet lost the race to the WebSocket and kept QUIC second for a day. The losing QUIC dial now goes on for up to 3 s, and if it opens, QUIC goes first next time. The bulk streams and the datagrams no longer end the QUIC session when they stop, as against a service that offers no datagrams. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 441b283848
ℹ️ 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".
| lead?.cancel() | ||
| lead = nil | ||
| memory.remember(transport) |
There was a problem hiding this comment.
Retry QUIC after a remembered WebSocket win
When WebSocket is the remembered first transport and opens within 250 ms, cancelling lead prevents QUIC from ever being started, while memory.remember(transport) refreshes the WebSocket timestamp on every reconnect. Consequently, a single transient QUIC loss can make WebSocket win indefinitely rather than expiring after one day. Keep the delayed QUIC learning probe scheduled when WebSocket starts first, or avoid refreshing the remembered WebSocket result until QUIC has actually been retried.
Useful? React with 👍 / 👎.
Text + Text is deprecated in iOS 26. transportName had also landed between followErasure and its doc comment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- S1: the Noise replay window is 1024 (16 words), as in srcfl/ftw, since a bulk burst can overtake a control message. CloudCarrier drops a replay only when it came as a datagram (WebSocketEvents.onDatagram); on a stream it ends the session. - B2/S5: a QUIC session that ends before its first message, or that the path drops within a minute of it, demotes QUIC on that network for a day, and no QUIC win moves it back meanwhile. Closes from 1000 up other than 1005 and 1006 do not count. CloudTransportMemory keeps only the last day's networks. - S2: CloudView takes the table of a late live whose srcRev is newer and keeps the newer values (Freshness case 17). - cloud-quic-vectors.json from srcfl/ftw#1653. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The replay window stays 1024 with main's note on paced bulk. The QUIC vectors come from srcfl/ftw#1653. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
QUIC, the iPhone's side: the session with FTW Cloud dials QUIC first and falls back to the WebSocket, and nothing above the socket changes. It is paired with srcfl/ftw#1653 (protocol and box), srcfl/ftw-cloud#22 (service) and srcfl/ftw-webapp#91 (registry).
mainis merged in, with the review's fixes (B2/S5, S1, S2). iPhone 0-RTT stays off.What changes
QUICCloudSocketis aWebSocketConnectionon iOS 26'sNetworkConnection<QUIC>, ALPNftw-cloud-app/1, to the WebSocket URL's host and port over UDP.onBinaryas before.hbandlivemay come as datagrams, which reach the newWebSocketEvents.onDatagram.applicationError, soCloudCarrier's close handling is the same. Only the control stream and the writer end the session.CloudSockets.racingraces it againstURLSessionWebSocket. QUIC starts first, and the WebSocket starts after 250 ms, or at once if QUIC is refused. The first to open carries the session, and the other is closed.CloudTransportMemorykeeps the winner per network for a day, and only the last day's networks. The network is named byNetworkWatch(interfaces and gateways) and kept only as a hash.AppModeltakesmakeCloudSocket. The app passes the race; tests leave it nil and getmakeSocketas before. The relay still uses the WebSocket.appwire.ReplayWindowin srcfl/ftw, since a bulk burst can overtake a control message.CloudCarrierdrops a replay only when it came as a datagram; on a stream it ends the session, as a broken message does. It also says how it travels (transportName).CloudView(S2) takes the table of a latelivewhosesrcRevis newer and keeps the newer values (Freshness case 17), so a table overtaken by a datagram is not lost.tunnelorcloud, and freshness stays carrier plus source state.Contract.cloudTransportsandprotocol/registry.yamlfrom registry: cloud_transports, how a session with FTW Cloud travels (QUIC step 2) ftw-webapp#91 (ContractTestschecks the new line);CloudWire.alpnApp;cloud-quic-vectors.jsonfrom FTW Cloud over QUIC: the protocol, and the box's uplink racing the WebSocket with 0-RTT (QUIC steps 2–3) ftw#1653 as it stands (QUICFramingTestsreplays it).LiveCloudTests' shell helper is macOS only, so the FTWKit tests also build for the simulator.No release is forced. Against today's service, which has no UDP listener, QUIC gets no answer and the WebSocket opens 250 ms later, once per network per day. An older app keeps the WebSocket.
Numbers: today's WebSocket against QUIC
A real
ftwcloudd, a box running Core's owncloudlink,appuplinkand command handler, and a test phone in Go (go/protocolandquicdoor.Dial) that pairs with it and speaks the app protocol inside the tunnel. The WebSocket rows are today's path on both legs; the QUIC rows are QUIC on both. Everything runs in one Linux container whose loopback carries netem: 20 ms each way, so every leg has a 40 ms round trip, and the loss applies to each packet on both legs. The readable copy is on. 30 connects and 30 taps per row, 90 s of frames.The connect rows come from a run with the review's fixes in, where a phone's session waits for the QUIC handshake. The other rows come from the run before the fixes, which do not touch a session once it is open.
What it says: QUIC saves round trips where a connection starts, and it keeps loss on one stream from holding up the others. Cold, it is two round trips less: the TCP handshake and the WebSocket upgrade. A phone's reconnect now costs what a cold connect does, since its session waits for the handshake; 0-RTT still saves a box a round trip, whose
challengecomes back in the first flight. Under loss the tails shrink everywhere, and a network switch costs no new session. With no loss, a frame or a tap takes one round trip either way.The copy's
liveat 5 % loss is not a clean win. QUIC sends it as a datagram, never again, and lost 8 of the 90; the WebSocket lost none but brought its late ones up to 309 ms after. A lost one shows as a gap in the freshness rules (hb.liveSeq), never as a live value. Neither row measures the age of the newest value at the phone, which is what a person sees.now.tun), the box session's handshake andhellowith its subscription, to the snapshot.cmd.ack.live, from the box'swallMsto the phone. Over QUIC it goes as a datagram; the counts say how many of the 90 the box sent arrived.On the phone: FTWKit in the iOS simulator
LiveQUICTests(in this PR) runs the app's own stack (AppModel,PairModel,CloudCarrier, the tunnel and the session) against a realftwclouddand a real Core box with the relay unreachable. It runs in an iPhone 17 simulator. The service and the box run in a container whose netem shapes only the phone's leg, 20 ms each way plus OrbStack's own few ms (ping 56–100 ms); the box reaches the cloud unshaped. The readable copy is off. Each row is one pairing, 30 taps and 6 returns from the background.The Mac's own
swift testgave the same at 40 ms: 305 against 180 ms from scan to stream, and 269 against 184 ms back from the background. In an earlier 5 % run the race gave one session to the WebSocket, because one lost QUIC packet made QUIC miss its 250 ms lead. A QUIC dial that loses now goes on to learn, so that one loss no longer keeps QUIC second for a day.Keeping an idle box linked
The box uplink stayed idle for 180 s with the readable copy off, at 40 ms, counted packet by packet on the loopback (IP bytes):
In the first two minutes, both ends also send path MTU probes of about 1.4 KB beside each keepalive. Over the 180 s window that came to 10 KB, a rate of 4.9 MB a day that stops once discovery ends. A PING every 15 s keeps a 30 s NAT mapping open.
Not checked
localhostand a pinned root (DidResumefalse), so the iPhone's reconnect is a full handshake here. The box and the Go phone use 0-RTT. The simulator with the root in its trust store and no pinning did not resume either: across four app connections, one of them a return from the background, the service loggedDidResumefalse on each. The likely cause is the API. iOS 26'sQUIC.TLShas noticketsEnabledorearlyDataEnabled, thoughTLSandDTLShave both. The olderNWProtocolQUIC.OptionsexposessecurityProtocolOptions, wheresec_protocol_options_set_tls_tickets_enabledcan turn tickets on. That is a follow-up.The rig is not in any repository. It is the box as a test binary in Core's
appuplink(with Core's own stubs), a phone in Go overgo/protocolandquicdoor.Dial,run.sh, and netem in an Alpine container on OrbStack. Landing it as a check is a follow-up.Checks
swift testin FTWKit: 460 tests in 74 suites and 254 in 21, all pass after the merge (the live suites are off without their environment). New: the window against a plain record of 3,000 shuffled frames, a burst of a hundred overtaking a control message, a datagram past the window, what counts as QUIC failing, demotion, the memory's day, Freshness case 17.xcodebuild build -scheme FTW -destination 'generic/platform=iOS Simulator', as CI runs it: builds.LiveQUICTestsagainst a localftwclouddand Core box passes on macOS, and in the iPhone 17 simulator at 40 ms with 0, 1 and 5 % loss. The rows are above.ftwclouddwith a Core box on QUIC: About → FTW Cloud reads "Connected · QUIC" in light and dark mode. At the largest text size "· QUIC" wraps to a second line and stays readable. The test box answersGET /api/cloudwith a stub, since it has no HTTP API.LiveTunnelTests(step 2) still passes over the WebSocket with the readable copy off. With the copy on, its six returns from the background did not stream again within 20 s. That happened with the box on the WebSocket too.main(4b595e7) fails the same way against the same stack (box on the WebSocket, copy on): pairing and 50 taps pass, then the first return from the background does not stream within 20 s. So it predates this branch.🤖 Generated with Claude Code