Repository navigation
iPhone: the box session goes through FTW Cloud, the relay as fallback (one door, step 2) - #67
Conversation
…lback TunnelCarrier carries ftw.session.v1 inside the /v1/app session; its message 1 rides now.tun. FallbackCarrier moves to the relay when the tunnel does not carry. QR v3 names the home; hello_ok.cloud fills it in. The carrier is reported as tunnel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d the whole app Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eachable; Android reads QR v3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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: 4956949855
ℹ️ 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".
| if !retryable || !carrying { | ||
| toRelay() |
There was a problem hiding this comment.
Preserve the recovery window across tunnel retries
When a previously carrying tunnel drops, this method clears carrying and starts the five-second recovery timer. If the cloud connection's first retry also closes—for example during a brief network flap or service restart—the next callback hits !carrying here and switches immediately to the relay even though the recovery window has not elapsed. Because fallback is one-way for the rest of the foreground session, preserve separate state for “has ever carried” or an active recovery window and defer takeover until that timer expires.
Useful? React with 👍 / 👎.
… no restart loop on an end The box takes a device's slot on the phone's first transport frame, so its handshake answer alone does not show the session is this phone's. An end cannot say whether the session was replaced or unknown, so the backoff after one starts over only after a session carried for 30 s. From review: message 1 survives a retry on the other service key, a wake gives the tunnel the fallback's wait again, the kept home id wins over a cached identity, the pairing proof rides while a code is held, and the relay taking over is logged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One-door plan, step 2: the iPhone's box session goes through FTW Cloud, with the relay as fallback for one release.
Paired PRs
ftwcloudd): step 1,tunon/v1/appand/v1/box, the link from first pairing, pairing through the service, QR v3 behindappenroll.QRNamesHome. This PR follows their protocol text,docs/protocol-cloud.md"Tunnel".cloud.tunat hello.cloud.tun, carrier statetunnel.protocol/registry.yamlhere is its copy.enrollment.tsreads QR v3.QRNamesHomestays off. This PR, #89 and the Android parser here are the gate for turning it on.What changes
The tunnel carrier (
Carrier/TunnelCarrier.swift). Runs the box session (ftw.session.v1, Noise IK to the box key, byte for byte) astun {sid, d}inside the existing/v1/appsession. It sits underNoiseCarrier(inner:)whereRelayCarriersat.Sessionis untouched.now.tun, so the box session is up one round trip after TLS. If the app session is already past message 1, it goes the moment message 2 is in.sid:Carrier.beginSession(), whichNoiseCarriercalls before message 1 and every other carrier ignores. A late frame or end of an older session is dropped.dends the session. It may mean the box dropped it, another session of this device took the slot, or the box does not hold it; the phone cannot tell which. A new session starts after a backoff (full jitter, 0.5 s to 60 s) that starts over only after a session carried for 30 s, so a replaced session never restarts in a loop.wake()dials the app session afresh, as the relay carrier does. Closing the carrier ends the session at the box and leaves the app session up for the readable copy.now.pair, SHA-256(ftw.cloud.pair.v1\0‖ home ‖ code): that admits a new phone from a v3 code, or one paired again after it was removed. FTW Cloud ignores it from a phone the roster lists. The code itself rides only inside the box session's message 1, encrypted to the box.The fallback (
Carrier/FallbackCarrier.swift, for one release). Tunnel first. Until the box has answered a transport frame through it (its handshake answer alone does not count), the relay takes over at once on a refusal (4403), an unreachable service, or a session ended before that answer, and after 5 s without one (an older box lists the phone withouttun, and FTW Cloud then says nothing). A tunnel that carried and drops, or that a wake dials afresh, gets the same 5 s to carry again. One carrier at a time: the tunnel and the relay share the device's one slot at the box, so the tunnel closes and ends its session at the box before the relay dials, and the relay is never built while the tunnel carries. The next carrier, built when the app comes to the front, tries the tunnel again. The log says when the relay took over and why (energy.ftw.app, categorycarrier).When the phone uses the tunnel (
SiteModel.tunnelCloud). It needs a home id and a box that takestun: the box saidcloud.tunat its last hello, or it has never spoken to this phone and a v3 code named the home. Otherwise the relay, as before, with no wait.The home id. From a v3 code at pairing, or from
hello_ok.cloudat the first session, kept inStoredSite.cloudHome. The FTW Cloud session now starts from the kept id too, not only from the box's word at hello.Honest freshness. A new carrier state
tunnel(registry). The band says "Live, encrypted via FTW Cloud" / "Live, krypterat via FTW Cloud", and the node footer "ENCRYPTED VIA FTW CLOUD". The relay keeps its own words, so a fallback is visible.cloudstays the readable copy.The readable copy. A box from #1635 names its home in
hello_ok.cloudwith the copy off too, with empty tables.SiteModel.cloudOnnow means the copy is on (non-empty tables, as the protocol text says). Without this,CloudConsent.ensurewould read a linked box as "already on" and never turn the copy on for an owner who agreed. Push registration, and the notification prompt with it, now waits for the copy to be on. A hello withoutcloudno longer closes an FTW Cloud session that carries the box session.QR v3 in
Enrollment.swiftand Android'sEnrollment.kt, v2 still read. Both take only a home id FTW Cloud could have cut, elseE_QR_HOME.Files outside this PR's own area. One line each in
WatchShare.swift(the watch getstunnelas the carrier) andWatchGlance.swift(atunnelreading is lit), U3's area; neither touches U3's open lines. A watch build older than this phone build cannot decode the carriertunnel; the watch app ships in the same bundle.FTWApp.swiftgets a debug-only-FTWRelayURL, to prove the app with the relay pointed nowhere.Proof
Unit tests (FTWKit 426 + FTWShared 227, all green; Kotlin
:shared:jvmTestgreen; the app, watch and widgets build for the iOS Simulator):CloudWireTests: Go's vectors from #1635 replay byte for byte: thetunframes in an app session,now.pairandnow.pair+now.tunmessage 1, the pairing hash and the device id.TunnelCarrierTestsagainst a fake service and a fake box that runs the responder handshake: message 1 rides message 1 and the session is up 30 ms after a 10 ms socket open (three legs); message 1 waits for message 2 when the app session is already dialling; each handshake gets a new sid; an end from the box starts a new session; a box that ends every session at the first transport frame gets eight sessions in a minute, not two hundred, and a session that carried 30 s restarts at once; a late frame of an older session is dropped; a wake dials afresh; close ends the session at the box and leaves the app session; a retry on the other service key carries message 1 again; a new phone pairs through the service with only the hash outside Noise.FallbackCarrierTests: the relay stays undialled while the tunnel carries; refused, unreachable, ended before any answer, ended after only the handshake answer, and silent each move to the relay; a carrying tunnel that drops briefly stays; one that stays down moves after 5 s, and so does one that a wake dials into silence.OneDoorShellTests, the wholeAppModelagainst a simulated box behind FTW Cloud: a v3 code pairs and streams through the tunnel with the relay unreachable, and comes back from the background through it; a box withoutcloud.tunis reached through the relay with no wait; a refusing FTW Cloud falls back to the relay.End to end (
LiveTunnelTests, off unlessFTW_LIVE_TUNNEL_DIRis set):ftwclouddbuilt from srcfl/ftw-cloud#15 (462813d); a box from srcfl/ftw#1639 on #1635 in process, with its real app uplink, cloud link, tunnel table and command handler, and its relay pointed at a closed port; this app'sAppModel,PairModeland FTWKit stack on the Mac with its relay atwss://127.0.0.1:9. The phone scanned the v3 link, paired through FTW Cloud, streamed, set the mode 50 times and came back from the background 5 times. The relay was dialled 0 times; the box set the mode 50 times.cmd.ackmedian / p95 / maxcmd.resultmedian / p95With delay, tap to ack is one round trip to the cloud plus one to the box (70 ms) and 6 ms of work. The plan's budget is under 300 ms median in Sweden.
Not yet: the app in the simulator against this stack, and a device on Wi-Fi and LTE. Two other sessions hold the simulators; this PR gets a comment when the simulator run is done.
Next release: remove the relay path
Once Core with
cloud.tun(#1635, #1639) is out and FTW Cloud runs #15, the next iPhone release deletes:RelayCarrier.swift,Rendezvous.swift,FallbackCarrier.swift,Origin.relayURL,AppModel.relayURLand-FTWRelayURL.NoiseCarrier:foreignFrameErrors, and taking only frames of message 2's length while a handshake runs. The tunnel delivers only its own session's frames.CarrierStatus.isBusyand the "FTW is busy" band.StoredSite.rendezvousSecretas a way in: a home with no home id then asks to be paired again. Phones learn the id fromhello_ok.cloudduring this release.FakeRelay, the relay cases inRemoteTests, the relay path inLiveBoxTestsandLiveCloudTests.Android keeps
Relay.ktandRendezvous.ktuntil step 4, and the registry keepsrelayuntil the web app moves in step 5.🤖 Generated with Claude Code