Repository navigation
feat(appuplink): send each reading through FTW Cloud as it lands - #1649
Conversation
WIP: arrivals from the driver registry, Push and Beat on tunnel sessions, the readable copy's live on arrival while watched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ox rig for the iPhone tests 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: 20dc6a50b1
ℹ️ 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".
| case <-t.arrivals: | ||
| if flush == nil { | ||
| flush = time.After(max(tunGather, p.wait(time.Now()))) |
There was a problem hiding this comment.
Restore a fixed cadence for tunneled lane 0
When a phone watches through FTW Cloud, this arrival-triggered flush sends a lane 0 frame only after telemetry changes, exposing the household's device/readings rhythm to the service. That breaks the repository's non-negotiable requirement that lane 0 retain both fixed length and fixed cadence, so tunnel delivery must remain cadence-driven rather than arrival-driven.
AGENTS.md reference: AGENTS.md:L71-L74
Useful? React with 👍 / 👎.
| @@ -0,0 +1,5 @@ | |||
| --- | |||
| "ftw": patch | |||
There was a problem hiding this comment.
Mark this visible capability as a minor change
This changes user-visible phone behavior from one-second polling to arrival-driven streaming, but the changeset labels it as a patch. The release policy reserves patches for fixes and requires a minor changeset for a visible new capability, including owner approval, so leaving this as patch produces the wrong release classification.
AGENTS.md reference: AGENTS.md:L167-L171
Useful? React with 👍 / 👎.
A frame on every poll, so timing shows the poll rhythm and not which value moved. A dropped frame is not recorded as sent (appproto.ErrDropped). A source table too big for lane 0 goes in a snapshot, sent in line with lane 0 so no delta overtakes it. Pushes stop at 60 % of the tunnel budget, beats at 90 %. The beat runs and arrivals are heard only while a session is live, and the copy hears them only while watched. OCPP readings signal arrivals. Lane 0's invariant: fixed length, fixed cadence over the relay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # go/internal/cloudlink/link_test.go
The agents test from #1646 builds a tunnel with the new arrivals argument. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The replay window stays 1024 with #1649's note on lane 0 and paced bulk. The QUIC vectors take master's newest roster. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Live numbers through FTW Cloud now leave the box as each driver poll lands, instead of waiting for the next tick of a one-second clock. Part of the one-door work (#1635), the plan's speed section. Paired with srcfl/ftw-app#72 (the live trace, the watch and the iPhone half of the measurement). Either can merge first.
Before and after
A reading's trip from the box's driver to the iPhone session's decode, end to end: a real local
ftwcloudd(srcfl/ftw-cloud main 96daff5), the box in process (realappuplink, tunnel table,cloudlinkandappprotohandler, relay unreachable), and the iPhone's realAppModel/Sessioninswift test. Pretend drivers read at 0.8–1.2 times their period, so readings land at every phase of the old clock. Each grid reading is the wall-clock millisecond it arrived; the phone stamps the momentSessiondecodes it. Before = master f5cdb92. After = this branch.Loopback, ms from arrival at the box to decode on the phone:
Past five polls a second the pace (5 frames a second, 3 at once) holds a reading for up to one 200 ms token, which the third and fourth rows show. Hidden is unchanged on purpose: the phone asked for one frame in five seconds because nobody is looking.
50 ms round trip phone–cloud, 20 ms box–cloud (TCP delay proxies), 1 driver: before p50 424, p95 1 017 ms; after p50 45.2, p95 46.5 ms (076a060). The box's own share after is 6.3 ms (the 5 ms gather and the send); the rest is the network.
The readable copy while a phone watches (poll →
liveat the service, production 250 ms tick, 60 polls): before p50 292, p95 1 197 ms; after p50 2.8, p95 192 ms. The p95 is the one-second floor that keeps the copy's byte budget where it was.Bytes while visible (lane 0
d; a tun on the wire is 583 bytes,d536):Hidden is 0.2 frames a second, 0.4 MB an hour. My first version's "2.6 MB an hour" was averaged over hidden time; these are visible only.
CPU. One push for one session costs 2.5 µs on an M5 (handler, padding, Noise and tun framing;
BenchmarkAPushThroughTheTunnel) plus 1.0 µs for the box's realappSite.Snapshotwith four drivers (a scratch benchmark). A Pi 4's Cortex-A72 runs this kind of Go about 8–10 times slower, so 30–40 µs, and about 50 µs a frame with the TLS write. At the cap, 5 frames a second to 4 phones, that is about 1 ms of CPU a second, 0.1 % of one core; one phone at 1–3 frames a second is about 0.01 %. An estimate, not a Pi measurement.What changes
telemetry.Store.Arrived/Arrivals. The driver registry signals once per finished poll, after the poll's readings are in the store; OCPP chargers, which send unpolled, signal after each reading. One poll is one signal, so a frame never carries half a poll: the house load is derived from all of it.appuplink.Tunnel.Run). A poll waits 5 ms for polls that finish with it, thenHandler.Pushsends each visible session one frame: adeltaof what changed, or atickwhen nothing did. A token bucket caps pushes at 5 a second, 3 at once.Handler.Beat, checked every 250 ms): atick, or adeltaif something changed, once nothing has gone for the subscribed period, 1 s visible and 5 s hidden. A hidden session gets only these.watchis in force.Handler.Tickon the one-second pulse. Their blindness tests pass as they were, and a new test runs a relay session withArrivalsset.cloudlink): while watched, a poll that changedlivesends it at once if the lastliveis a period old, otherwise when the period ends. Same rate, same budget.docs/protocol-cloud.md: "Lane 0 through the tunnel" and "What the service learns", the cadence rule forlive, rows in "Numbers in one place".AGENTS.mdand thego/protocolcomments say the fixed cadence is the relay's. ADR 0008's risk table names the timing for the privacy notice.TestLiveBoxForTheiPhone(appuplink/livebox_test.go): the box half of the measurement and of ftw-app'sLiveTunnelTests, skipped unlessFTW_LIVE_BOXnames a directory.Review fixes
Pushsends a frame on every poll, changed or not, so frame timing shows the poll rhythm and never which value moved.AGENTS.mdrewritten; changesetminor; both approved (above). The workspaceAGENTS.mdis outside this repo.TestSixDriversSourcesThatDoNotFitGoInASnapshot(six realistic drivers, 512 bucket);TestAnOversizedDeltaGoesAsASnapshotNotALargerFramereplaces the test that asserted the old behaviour.appproto.ErrDropped; on it the handler records nothing (lastSent,lastSources,lastFrameMs), so the next push or beat carries the same news, and a dropped snapshot is sent again first. A dropped ack still lets the box act, as before, so the result reaches the phone.TestADroppedFrameIsSentAgain,TestADroppedSnapshotGoesAgainFirst,TestAReadingTheLinkDroppedGoesWithTheNextFrame,TestADroppedTunDoesNotEndTheSession.TunBudgetUsedfrom the link).TestTheBudgetStopsPushesThenBeats. Byte numbers re-measured, visible only (above).TestFastPollsArePacedToFiveFramesASecond,TestEveryLiveSessionHearsAPoll, drops and the budget (3, 4),TestHiddenThenVisible,TestARelaySessionKeepsItsPulseWhileArrivalsCome. The p95 at 5+ polls a second and the CPU estimate are above. The fluke is 1 in 3,125 (0.2⁵), now said so.tel.Arrived()after each reading.appproto.InLineSender, which the tunnel's sender implements), so it leaves before any reading sent after it.TestTheSnapshotGoesInLineWithLaneZero.drivers/host.go,appwire/transport.go,cloudlink/conn.go,handler.go. The replay window holds: a full bulk queue paced at 90 % of the service's byte limit waits at mostcloudlink.MaxBulkWait(9.0 s), in which a session sends at most 3 + 5 × 9.0 = 48 lane 0 frames, plus a few command answers, under the window of 64.TestLaneZeroCannotPushABulkFrameOutOfTheReplayWindow.What the service learns
It cannot read a frame, and every lane 0 frame is the same size. While a phone watches, a frame goes on every finished poll, changed or not, so their timing shows when the box reads its devices, and never which reading moved, by how much or which way. A snapshot is larger, and goes when the control revision or the source table changes. With the copy off, the poll rhythm is new to the service, for the minutes someone watches. With the copy on, the service reads in
livethe values of the fields the grant lists, and the rhythm adds when the box read them. While no phone has a session nothing goes, and a hidden session sends every 5 s whatever happens.Cold open and TLS (measured, nothing built)
AppModelover a paired store, tunnel only): loopback p50 20 ms, p95 22 ms. At 50/20 ms round trips: p50 276–296 ms, p95 283–299 ms over two runs: five round trips to the service and two on to the box (TCP, TLS 1.3, the WebSocket upgrade, the app session's Noise handshake with the box's message 1 inside it, thenhelloand its snapshot).ftwclouddissues TLS 1.3 tickets (Go's default), and a Go client with a session cache resumes on every connection after the first; the exporter value both ends compute is equal and new on each resumed connection, so the box's binding holds. URLSession resumed none of 54 connections against the localftwcloudd, with a session per socket as the app does, one shared ephemeral session, or one default session. The local test trusts a private CA through the session delegate, which may be why; with production's public certificate it is unproven. It costs no time: TLS 1.3 takes one round trip with or without resumption (53–58 ms at 50 ms RTT).crypto/tlshas no 0-RTT, so it needs quic-go and HTTP/3 on the service and WebSockets over HTTP/3 or a stream protocol of our own on the phone. A cheaper cut to look at first: lethelloride in the box session's message 1, about 70 ms at 50/20.Proof
-p 2,-raceonappuplink,appproto,cloudlinkandtelemetry, andmake verify.BenchmarkAPushThroughTheTunnel.Reproduce
$DIRholds the service'sca.pem,service.pubandnoise.pub; the box writespair.url,arrivals.logandtun.log, the phonedecode.logandopen.log.Overlaps
Master is merged in with #1643 (plan push), #1645 (shard) and #1646 (agents). #1645's one conflict, the test harness's
Optionsincloudlink/link_test.go, keeps both; #1646's new agents test builds a tunnel with the new arrivals argument.Seen while verifying, not from this branch:
TestASupportGrantEndsWhenItsTicketCloses(#1646) failed once inmake verifyand passed in 9 runs after.AgentState.flushFoldsranges over a map, so audit rows come out in random order and the test reads the last one.🤖 Generated with Claude Code