Skip to content

feat(appuplink): send each reading through FTW Cloud as it lands - #1649

Merged
frahlg merged 12 commits into
masterfrom
claude/send-on-arrival
Oct 16, 2026
Merged

frahlg merged 12 commits into
masterfrom
claude/send-on-arrival

Conversation

@frahlg

@frahlg frahlg commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

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.

Fredrik approved the lane-0 rule change and minor, 16 Oct 2026. Lane 0 was "fixed length and cadence". It is now "fixed length; fixed cadence over the relay; through FTW Cloud a frame on every arrival plus a beat" (AGENTS.md), and the changeset is minor.

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 (real appuplink, tunnel table, cloudlink and appproto handler, relay unreachable), and the iPhone's real AppModel/Session in swift 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 moment Session decodes it. Before = master f5cdb92. After = this branch.

Loopback, ms from arrival at the box to decode on the phone:

before p50 before p95 after p50 after p95
visible, 1 driver at 1 s 603 951 6.7 7.5
visible, 3 drivers at 1 s 539 969 6.9 7.8
visible, 6 drivers at 1 s (about 6 polls a second) – – 22.3 171
visible, 1 driver at 150 ms (about 6.7 polls a second) – – 103 194
hidden (0.2 Hz), 1 driver 2 238 4 727 2 514 4 767

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 → live at 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, d 536):

frames a second MB an hour a display watched all day
1 driver at 1 s 1.16 2.2 54 MB
3 drivers at 1 s 2.99 5.8 138 MB
5+ polls a second 5.0 (the cap) 9.6 230 MB; the 60 % rule turns it to beats after about 14 hours

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 real appSite.Snapshot with 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.
  • Tunnel sessions send on each poll (appuplink.Tunnel.Run). A poll waits 5 ms for polls that finish with it, then Handler.Push sends each visible session one frame: a delta of what changed, or a tick when nothing did. A token bucket caps pushes at 5 a second, 3 at once.
  • A beat when no frame went (Handler.Beat, checked every 250 ms): a tick, or a delta if something changed, once nothing has gone for the subscribed period, 1 s visible and 5 s hidden. A hidden session gets only these.
  • Frames keep their buckets. A source table too big for lane 0 goes in a snapshot, in line with lane 0.
  • The budget. Past 60 % of the tunnel's 24-hour budget sessions get the beat alone; past 90 % no readings, so handshakes, command answers and ends still go.
  • Quiet when nobody watches. The beat runs, and arrivals are read, only while a tunnel session is live; the copy reads them only while a watch is in force.
  • Relay sessions are unchanged: Handler.Tick on the one-second pulse. Their blindness tests pass as they were, and a new test runs a relay session with Arrivals set.
  • The readable copy (cloudlink): while watched, a poll that changed live sends it at once if the last live is 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 for live, rows in "Numbers in one place". AGENTS.md and the go/protocol comments 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's LiveTunnelTests, skipped unless FTW_LIVE_BOX names a directory.

Review fixes

  1. Invariant and timing. Push sends a frame on every poll, changed or not, so frame timing shows the poll rhythm and never which value moved. AGENTS.md rewritten; changeset minor; both approved (above). The workspace AGENTS.md is outside this repo.
  2. A source change could fail to reach the phone. When the source table does not fit the bucket, the box sends one bulk snapshot and records the sources and the block list from it, then lane 0's tick. TestSixDriversSourcesThatDoNotFitGoInASnapshot (six realistic drivers, 512 bucket); TestAnOversizedDeltaGoesAsASnapshotNotALargerFrame replaces the test that asserted the old behaviour.
  3. A dropped frame was recorded as sent. The tunnel returns 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.
  4. The budget. Pushes stop at 60 %, beats at 90 % (TunBudgetUsed from the link). TestTheBudgetStopsPushesThenBeats. Byte numbers re-measured, visible only (above).
  5. Tests and numbers. The real Run loop: 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.
  6. OCPP calls tel.Arrived() after each reading.
  7. A delta could overtake the snapshot. The snapshot goes in line with lane 0 (appproto.InLineSender, which the tunnel's sender implements), so it leaves before any reading sent after it. TestTheSnapshotGoesInLineWithLaneZero.
  8. Wakeups. As above: the beat and arrivals only while a session is live, the copy's arrivals only while watched.
  9. Stale 1 Hz comments: 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 most cloudlink.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.
  10. The privacy wording is rewritten for frames on every poll and for the grant's fields (below).
  11. In iPhone and watch: live numbers as they land through FTW Cloud ftw-app#72.
  12. "Linux converter (amd64)" re-ran green.

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 live the 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)

  • Launch to first live number (new AppModel over 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, then hello and its snapshot).
  • TLS resumption. ftwcloudd issues 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 local ftwcloudd, 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).
  • QUIC and 0-RTT would matter on a cold open only: about two of the seven round trips, roughly 100 ms at 50 ms RTT and 160 ms at 80 ms on LTE. Go's crypto/tls has 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: let hello ride in the box session's message 1, about 70 ms at 50/20.

Proof

  • Package tests with -p 2, -race on appuplink, appproto, cloudlink and telemetry, and make verify.
  • BenchmarkAPushThroughTheTunnel.

Reproduce

# ftw-cloud main: ftwcloudd -listen 127.0.0.1:18843 -tls-cert … -tls-key … -service-key … -noise-key … -state …
FTW_LIVE_BOX=$DIR FTW_LIVE_BOX_URL=wss://127.0.0.1:18843/v1/box FTW_LIVE_BOX_DRIVERS=3 FTW_LIVE_BOX_PERIOD_MS=1000 \
  go test ./internal/appuplink -run TestLiveBoxForTheiPhone -v -timeout 40m
# ftw-app: FTW_LIVE_LATENCY_DIR=$DIR swift test --filter LiveLatencyTests

$DIR holds the service's ca.pem, service.pub and noise.pub; the box writes pair.url, arrivals.log and tun.log, the phone decode.log and open.log.

Overlaps

Master is merged in with #1643 (plan push), #1645 (shard) and #1646 (agents). #1645's one conflict, the test harness's Options in cloudlink/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 in make verify and passed in 9 runs after. AgentState.flushFolds ranges over a map, so audit rows come out in random order and the test reads the last one.

🤖 Generated with Claude Code

frahlg and others added 3 commits October 15, 2026 19:01
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>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-15T17:54:02.029688Z 20dc6a5 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread go/internal/appuplink/tunnel.go Outdated
Comment on lines +207 to +209
case <-t.arrivals:
if flush == nil {
flush = time.After(max(tunGather, p.wait(time.Now())))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread .changeset/send-on-arrival.md Outdated
@@ -0,0 +1,5 @@
---
"ftw": patch

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

frahlg and others added 3 commits October 15, 2026 22:29
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>
@frahlg
frahlg merged commit 4a1d81d into master Oct 16, 2026
17 checks passed
frahlg added a commit that referenced this pull request Oct 16, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant