Repository navigation
fix(appuplink): join the new hour's relay handle when the hour turns - #1605
Conversation
The relay refuses the old epoch from its first heartbeat after the hour, but closes each old room only on its turn in a five-minute spread. The box moved only when closed, so a phone that opened the app in between waited in an empty room for up to five minutes (seen 09:00-09:03:18 on 13 Oct 2026). The box now joins the new hour's handle when its clock turns, after 0-5 s of jitter, and keeps the old one until the relay closes it. A 4409 naming the old epoch means the relay has not turned yet: the box keeps the old room and tries again, at most four times. 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: 034be96dce
ℹ️ 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 n := len(u.live); n > 0 { | ||
| text = u.live[n-1].text |
There was a problem hiding this comment.
Avoid reclaiming deadman rows on the surviving socket
When the relay has not advanced its epoch yet, each speculative link is immediately rejected with 4409, but its serve goroutine still launches ResyncDeadman; after the HTTP posts finish and the rejected link has been removed, this lookup selects the old surviving socket and sends every deadman <id> claim on it again. The relay increments repeated claims but releases each ID only once when that socket closes, so after rotation these stale claims can prevent an armed dead-man notification from ever firing. Bind the resync to the connection that initiated it, and only claim after that connection has been accepted, rather than retargeting it to whichever socket is newest.
AGENTS.md reference: AGENTS.md:L22-L23
Useful? React with 👍 / 👎.
Problem
A phone that opens the app in the first minutes of an hour can wait up to five minutes before it reaches the box over the relay.
Seen on 13 Oct 2026 in the FTW Cloud C3 end-to-end test (local Core on
claude/cloud-c3-core, production relay, iOS simulator). The app dialled the new hour's handle from 09:00. The box loggedrelay epoch rotated ... epoch=497743at 09:03:18. For those three minutes the band said "Reconnecting to your box", and the box saw no app session. The app gotreadyat 09:03:23. Cold starts outside the window took 399–426 ms.Cause
Three rules, each fine alone, leave a hole together:
relay/src/server.ts:445, "exact, not tolerant").rotationOffsetMs(handle, 300_000)(server.ts:641).The app picks its epoch from the wall clock on every dial, and iOS dials on every foreground. So an app opened after the relay turns, and before the box's room gets its turn, waits alone in the new room.
Fix
When its clock passes the hour, the box also joins the new hour's handle, after 0–5 s of jitter. It keeps the old handle until the relay closes that room, so a phone already talking to the box keeps it, and a phone that dials the new handle finds it.
Uplink.livenow holds every connection that is up (one, or two in the overlap). A revoke drops sessions on both. Dead-man claims go on the newest socket; the relay counts claims per socket, so closing the old one releases nothing the new one holds.I chose the box over the two other places it could live:
server.ts(feat(stats): restore GitHub history and track FTW Cloud energy usage ftw-webapp#50).Numbers
Simulation of 400,000 app opens at random moments in the hour, with the relay's 15 s heartbeat, its 300 s spread and the box's 0–5 s jitter:
The cost: each box holds a second socket for up to five minutes each hour. While the relay lags behind the hour, it refuses on average 1.5 early tries per box per hour.
Privacy: the relay could already link the two handles by IP and by the reconnect seconds later; the relay README says rotation does not hide either. Holding both sockets for a few minutes does not add a link it lacked. The new handle now appears with every other box's in the first seconds of the hour, not alone just after the old one closes.
Tests
go/internal/appuplink/rotation_test.goaddsroomRelay, a fake ofserver.tswith a room per handle, the exact epoch rule and per-room rotation. The test is the relay's clock and moves the box's clock by hand.TestAPhoneOpenedAfterTheHourFindsTheBox: the case from 13 Oct. It fails onmaster("a phone on the new hour's handle found no box until the relay rotated the old room") and passes here.TestAPhoneConnectedAcrossTheHourKeepsTheBox: a session in the old room still answers after the hour; after the room's turn the phone finds the box on the new handle.TestTheBoxWaitsForARelayThatHasNotTurned: a refused early try leaves the box in the old room, and it joins once the relay turns.go test -race -count=5 ./internal/appuplink/andmake verifypass.Follow-ups, not in this PR
relay/src/server.ts, where Add configreload watcher tests + fix Stop() double-close panic #50 is open.server.ts:513counts adeadman <id>claim each time a socket says it, butserver.ts:556releases it once per socket. A box claims again on the same socket after a subscription change, so the switch may never arm after that socket closes. I have not tested this.🤖 Generated with Claude Code