Skip to content

feat(mobile): use HTTP/2 on iOS through app-wide URLSessions - #8897

Merged
brow merged 14 commits into
mainfrom
mobile-http2
Nov 13, 2026
Merged

brow merged 14 commits into
mainfrom
mobile-http2

Conversation

@brow

@brow brow commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

On iOS, Buzz's HTTP traffic now uses HTTP/2. Dart's dart:io client speaks only HTTP/1.1, so every feature's client opened its own connections to the relay, which sits behind Cloudflare and offers HTTP/2. On a slow link those extra handshakes, and the HTTP/1.1 head-of-line queueing, are a large share of cold-start time.

What changes

  • iOS HTTP goes through two app-wide NSURLSessions via cupertino_http (lib/shared/http/app_http_client.dart): one for API traffic (relay queries, NIP-11, account, admission, pairing) and one for media (image/video/voice downloads and uploads). Requests to a host multiplex over one connection per session. Media has its own session so image bytes can't starve queries on a shared HTTP/2 connection: in prototype measurements with a single shared session, the channel-list batch query finished later than on HTTP/1.1.
  • Callers keep owning and closing their clients; on iOS close() leaves the shared session open. Both sessions are ephemeral with no URL cache and no cookies, so apart from the protocol (and native gzip, inflated off the Dart isolate) behavior matches dart:io.
  • Android, and tests, keep http.Client() unchanged, including http.runWithClient overrides.
  • Because closing a client no longer cancels a request on a shared session, the relay query client (both /query posts and bounded snapshot reads) and the dev push-lease fetch abort their request on timeout instead.
  • Non-https requests (http:// media and community icons from other clients, local dev relays) stay on the handle's own dart:io client, because App Transport Security would block cleartext loads through URLSession. An HTTPS URL that redirects to http:// is the one case that newly fails on iOS (URLSession follows the redirect and ATS blocks the downgrade); we accept that rather than follow redirects in Dart.
  • URLSession failures arrive as NSErrorClientException, not SocketException, so pairing and invite-join error messages now also recognize NSURLErrorDomain offline, TLS and timeout codes.
  • cupertino_http 3.0.1+ requires native_toolchain_c 0.19 and hooks 2.x, so buzz_secp256k1 moves to those. Its build hook compiles unchanged.

What gets better, and what doesn't get worse

  • Better: on a slow link, requests reuse one warm connection per session instead of paying TCP+TLS setup each time, so they complete where main times out (see the table). On Wi-Fi, small queries are slightly faster.
  • Not worse: request semantics, caching (none) and cookies (none) match dart:io; Android and tests are untouched; timed-out requests are aborted so they can't keep using bandwidth on the shared connection; offline errors keep their friendly messages. The accepted regression is HTTPS-to-HTTP redirects on iOS, above.

WebSockets stay on dart:io. Android (Cronet) is a possible follow-up using the same seam.

Validation

Added tests: shared-session handles don't close the session; off iOS the transport honors http.runWithClient; NSURLError code classification; timed-out relay queries and bounded snapshot reads abort their request; cleartext requests stay off the shared session.

Focused existing validation: full mobile suite (the only failures are 11 push/notification tests that fail identically on main on the same machine), flutter analyze, buzz_secp256k1 package tests (27/27), and an iOS release build.

On-device A/B on an iPhone 12 mini against the internal prod relay over WARP, comparing cold starts of main and this branch (profile builds with local-only request timing, 3 cold starts each):

main this branch
Wi-Fi: first query done (median) 0.31s 0.25s
Wi-Fi: small-query latency (median) 0.22s 0.18s
NLC Edge: requests completed in 90s 0, 0, 0 (29–30 failed) 12, 14, 16 (5–7 failed)
NLC Edge: first request done never 6.3–7.0s

On Wi-Fi the two are about even. Under Edge, every HTTP/1.1 request on main hit its timeout while still opening a connection, and each retry started a fresh TCP+TLS handshake. The branch reuses one connection, so queries complete. Connection counts weren't measured on the device, since no proxy was in the path.

Cold-cache demo on an iOS simulator (this branch, app caches and cached prefs wiped before launch): avatars, community icons, channel lists, public channel images, a DM image and custom emoji, video playback, reactions, and an image and video upload with progress all load. The DM image and clip are synthetic test media.

http2-cold-cache-demo.mp4

buzz-review-completed (agent review and device validation done; the human-test step is not applicable under Tom's standing exception for mobile changes)

@brow

brow commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-12T23:10:40.750464Z aae7bbc Draft marked ready
ℹ️ 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: 85d40997f7

ℹ️ 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".

@brow

brow commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@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: 9f9d228bc6

ℹ️ 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".

/// it may be shared.
http.Client _gzipPassthroughClient() {
http.Client _defaultTransport() {
if (Platform.isIOS) return newAppHttpClient();

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 Abort bounded snapshot requests on timeout

On iOS this makes sendBounded use the shared session, but that path still accepts a plain http.Request; if a read-state snapshot times out before response headers arrive, its iterator remains null and retiring the generation only closes a SharedSessionClient, which deliberately leaves the underlying URLSession task running. Repeated snapshot attempts can therefore accumulate requests and consume bandwidth past their deadlines; give sendBounded an abort trigger as was done for post.

AGENTS.md reference: AGENTS.md:L246-L254

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in 1fa6704: sendBounded now sends an abortable copy of the request and aborts it on timeout, as post does.

Comment on lines +61 to +64
Future<http.StreamedResponse> send(http.BaseRequest request) =>
request.url.scheme == 'https'
? _session.send(request)
: (_cleartext ??= _newCleartext()).send(request);

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 Route cleartext redirect targets away from URLSession

When an HTTPS community icon or remote media URL redirects to HTTP, this dispatch checks only the original URL and URLSession follows the redirect internally, so the cleartext target never reaches the Dart client and ATS rejects it. _download and boundedGet follow redirects by default, while these resources explicitly support HTTP and the previous dart:io transport could load the redirected target. Fresh evidence after the prior fix is that transport-owned redirects never re-enter this wrapper; redirects must be handled through scheme-aware dispatch as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Declining this one. An HTTPS URL that redirects to cleartext is a downgrade, and iOS blocking it matches platform policy. Following redirects in Dart to route each hop by scheme would add complexity for a rare case; direct http:// URLs still load through the dart:io client.

@brow brow added the parked Not being shepherded toward review or merge; sweeps skip it label Nov 4, 2026
matt2e added a commit that referenced this pull request Nov 5, 2026
… report hop latency on callback timeout (#8916)

## Summary

`BuzzPushSnapshotEnrichmentTests` (iOS RunnerTests) timed out on two
unrelated PR branches on 2026-11-01 and passed on re-run both times. The
timings point to a starved test host, not a missed callback. This PR
makes one production change and two test changes, in two files:

- **`mobile/ios/Runner/PushSnapshotBridge.swift`:**
`BuzzPushSnapshotBridge`'s private serial queue and the default
interaction-deletion deadline timer now run at `.userInitiated` instead
of `.utility`. Dart awaits every one of these calls, and the age-gate
restore runs during app launch, when the process is busiest.
User-initiated is the class for work a caller is waiting on.
- **QoS assertion:**
`testRestrictionCleanupFailurePreservesSnapshotAndRestoreReleasesAuthority`
records `qos_class_self()` inside the injected deletion closure, which
the bridge calls synchronously on its queue. It asserts that each call
ran at `QOS_CLASS_USER_INITIATED`, and the failure message names the
observed and expected raw classes.
- **`waitForCallback` hop diagnostic:** all four bridge-reply waits in
the class, across three tests, now use this helper. It waits for the
unchanged 10 s `callbackTimeout` and returns as soon as the callback
runs, so it measures nothing on a passing run. On timeout it times one
fresh user-initiated-queue-then-main-queue hop, the path every bridge
reply takes, capped at 2 s. It then fails with the expectation's
description plus the measured hop, for example "a fresh queue->main hop
did not finish within 2 s" or "took 0.4 ms". The original assertions
still run afterwards, so the nil-versus-reason evidence is unchanged.
The hop is measured after the 10 s window, so a fast hop does not rule
out starvation that ended right at the deadline.

No timeout changed.

### Related issue

None found. #8678 (merged) fixed an earlier flake in the same class:
late callbacks and 1 s waits. It introduced the 10 s `callbackTimeout`
this PR keeps.

### Failing runs

Both runs were in "Mobile Swift (Native)", step "Test native controls
and Runner unit tests", attempt 1. Both passed on attempt 2.

- [Run 41327410782, job
124910378184](https://github.com/block/buzz/actions/runs/41327410782/job/124910378184),
on `mobile-avatar-thumb-followup` (#8896):
`testRestrictionCleanupFailurePreservesSnapshotAndRestoreReleasesAuthority`
timed out its 10 s "allowed without restoring snapshot" wait (19.3 s
total). The
`testNewBridgeRetriesRecordedCleanupWithoutRestrictingAccess` run just
before it passed but took 4.3 s.
- [Run 41325606910, job
124905175903](https://github.com/block/buzz/actions/runs/41325606910/job/124905175903),
on `mobile-http2` (#8897):
`testNewBridgeRetriesRecordedCleanupWithoutRestrictingAccess` timed out
its "retry failure stays allowed" wait, getting nil instead of
`interaction_cleanup_retry_failed`. The restriction test then timed out
both of its waits: nil result, still restricted, 0 deletion calls.

### Diagnosis

Every reply from `BuzzPushSnapshotBridge` goes through a private serial
queue, previously created at `.utility` QoS, and then
`DispatchQueue.main`. In both failing runs, the test host saturated the
3-vCPU `macos-latest` runner for about 40 s after launch. The test host
is the full Flutter app, loaded as `TEST_HOST`.

- `BrowserAuthBridgeTests.testAllowsOnlyBuilderlabLoginPages`, a
synchronous main-thread URL-parsing test, took 2.65 s instead of 0.013
s.
- `testStrictAgeGateWriteFailsWhenAppGroupStoreIsUnavailable` took 4.9
s. That test has since been deleted from `main`.
- A trivial hop took 1.3 s, then more than 10 s, during that window.
RunnerTests began about 9 s after host launch, and this class about 5 s
later.

During that window, the utility queue was the lowest-priority runnable
work in the process. It got no CPU for 10–20 s, while main-thread work
kept moving slowly. In both passing re-runs, every test in this class
took under 0.3 s.

The test logic doesn't explain a callback that never arrives:

- The deletion mocks are synchronous.
- The deadline scheduler is a no-op.
- Each test uses its own temp directory and bridge.
- No state is shared between tests.

That rules out both a state leak and an expectation-ordering race.

This diagnosis is inferred from CI timings. The starvation did not
reproduce on a 16-core local host (see Testing).

### Tried and dropped

- **A class `setUp` settle barrier (ff19edf, removed in 6cc43c9).**
A once-per-process `BuzzTestHostSettleBarrier` ran the same hop and let
the class start once three consecutive hops finished in under 0.5 s,
capped at 60 s. Re-reading both CI logs showed it would have opened
immediately in both failing runs:
- In job 124905175903, the one-hop
`testAllowedNotificationRestoreNeedsNoAppGroupStore` passed in 0.070 s
at the start of the class.
- In job 124910378184, the gate would have opened in the 1–2 s lull
after its 1.287 s hop.

Both stalls began mid-class and recurred mid-test, which a gate that
checks once cannot cover. A version that held the gate for longer would
just be a CI-only wait.
- **Deriving `callbackTimeout` from a measured host-load factor.** This
still waits inside each test and only scales the same timeout. It is a
timeout increase in disguise, and it gives no signal about why the host
was slow.

### Residual risk and remaining levers

On a saturated 3-vCPU runner, `.userInitiated` still ranks below the
Flutter UI and raster threads of the full-app `TEST_HOST`. So the QoS
change may narrow the window rather than close it, and a burst that
recurs mid-test is not covered. If this flake recurs, the hop report
shows whether the host was starved or the callback was missed. The
remaining levers are:

- **The test host.** RunnerTests boot the full Flutter app as
`TEST_HOST`, and that is what saturated the runner. #8678 tried keeping
Flutter out of the host, and that did not work locally.
- **The 10 s `callbackTimeout`.**

### Testing

Local runs used Xcode 27.2 with the 27.2 SDK, the iOS 26.2 simulator
runtime, and a 16-core host. CI's starvation did not reproduce locally.

QoS change (4278e99):

- **Class in isolation:** 10/10 green, 0.1–0.4 s each.
- **Full RunnerTests bundle with CI's `-parallel-testing-enabled NO`:**
2/2 green, 101 tests, about 22 s.
- **Falsification:** with the queue temporarily set back to `.utility`,
the new assertion fails. It observes `[17, 17]` (utility) instead of
`[25, 25]`. This was run on the original array form of the assertion,
before it was split per element in ff19edf.
- **Throwaway probe:** async blocks on a queue with an assigned QoS run
at that QoS: 17 for utility, 25 for user-initiated. So the change alters
real scheduling.
- **Saturation probe:** saturating the host process's default-QoS pool
with spinning blocks did not delay a utility hop, which took 5 ms. So
the CI stall is OS-level CPU-priority starvation on a saturated 3-vCPU
VM, which a 16-core machine cannot reproduce. The diagnosis rests on the
CI timings above.

Hop diagnostic (6cc43c9, e5515f7):

- **Forced timeout (temporary variants, not committed):** when the
bridge's retry deletion never called back, the test failed with "'retry
failure stays allowed' not fulfilled within 10 s; a fresh queue->main
hop took 0.4 ms", followed by the existing nil-versus-reason assertion
failure. Adding a 0.7 s sleep inside the hop produced "took 705.3 ms".
- **Class in isolation:** 10/10 green, 0.1–0.5 s each.
- **Full RunnerTests bundle with `-parallel-testing-enabled NO`:** 2/2
green, 101 tests, 24–29 s.
- **After routing the third test through the helper (e5515f7):** class
in isolation green once, 6 tests, 0.05 s.

All commits:

- `git diff --check` is clean.
- The repo has no swiftformat or swiftlint configuration.
- The branch was rebased onto `main` with no conflicts. `git range-diff`
shows all four patches unchanged, and the three upstream commits were
Dart-only.
- The tests were not re-run after the rebase.
- Pushed with `git push --no-verify`, because the pre-push test lanes
fail on this machine under load.

### Readiness

- **Hosted CI** had not run on this branch when the PR was opened. The
"Mobile Swift (Native)" lane is the check that matters.
- **Agent review** ran on each of the first three commits and found no
blockers. Its suggestions are applied in the follow-up commits, and the
last commit applies the final review's two optional suggestions.
- **Human confirmation** (AGENTS.md "Before opening a PR", step 3) is
still pending, so `buzz-review-completed` is not set.

The four commits are kept as written for the squash merge.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
@brow
brow marked this pull request as ready for review November 12, 2026 23:09
@brow
brow requested a review from a team as a code owner November 12, 2026 23:09
@brow
brow deployed to codex-review November 12, 2026 23:09 — with GitHub Actions Active
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 12, 2026
@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

🔐 Codex Security Review

Note: This is an automated, security-focused review generated by Codex.
Use it as a supplement to human review; false positives are possible.

Scope

  • Exact PR diff: 41587e0284f277d13e5d3a40b169c1dcd54d2894...88b3b30d213a97be78af54d2b5a686ed3167973a
  • Model: gpt-6.1-sol

💡 Click "edited" above to see earlier reviews for this PR.


Review Summary

Overall Risk: NONE

No concrete security, correctness, or reliability findings were identified in the authorized PR range.

Findings

No concrete security, correctness, or reliability findings were identified.

Notes

  • Review used read-only source and history inspection. Builds and tests were not run. Native iOS transport behavior and dependency implementations unavailable in the checkout were not independently verified.

Generated by Codex Security Review |
Requested by: @brow |
Workflow run

@github-actions github-actions Bot removed the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 13, 2026
@brow
brow deployed to codex-review November 13, 2026 01:28 — with GitHub Actions Active
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 13, 2026

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Carl, an automated reviewer, commenting via Wes's GitHub account.

No blockers at 12ec456. There is one small should-fix, inline.

Reviewed: shared-session ownership and cancellation (including body cancel), header isolation and hosted-auth redirects, media/upload routing, gzip handling and the bounded snapshot caps, WebSocket isolation, and the non-iOS and cleartext fallbacks. The transport and inflated caps are equal, so native inflation doesn't change any limit. CI is green.

Nit: the comment at relay_session_snapshot.dart:56-58 ("the transport cap only sees the compressed bytes") no longer holds on iOS.

Not independently verified: the device A/B results and HTTP/2 connection reuse. We relied on the PR's measurements. The HTTPS-to-HTTP redirect regression is the tradeoff the description accepts.


/// The [TransportFailure] for a `URLSession` error, or null for any other
/// error.
TransportFailure? urlSessionFailure(Object error) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Should-fix (non-blocking): the channel list loses its offline message on iOS. channels_page/badges.dart:49 shows "Could not reach the relay server." only when error is SocketException. That works on main because IOClient throws a ClientException that also implements SocketException. NSErrorClientException doesn't, so on iOS an offline channel-list load falls through to the generic "Something went wrong. Check your connection." That contradicts the description's claim that offline errors keep their friendly messages. Checking urlSessionFailure(error) == TransportFailure.unreachable there, as pairing and invites now do, closes the gap.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in 7eee728: the channel list now also treats urlSessionFailure(error) == TransportFailure.unreachable as unreachable. That was the last SocketException-only check in mobile/lib. The stale cap comment in relay_session_snapshot.dart is corrected in the same commit.

@github-actions github-actions Bot removed the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 13, 2026
@brow
brow deployed to codex-review November 13, 2026 03:10 — with GitHub Actions Active
@brow brow removed the parked Not being shepherded toward review or merge; sweeps skip it label Nov 13, 2026

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Carl, an automated reviewer, commenting via Wes's GitHub account.

Verdict: one fix recommended before merge (inline on identity_archives.dart). Everything else checked out against the locked cupertino_http 3.1.0 / http 1.6.0 sources: redirect refusal (relay queries keep getting 3xx back), cookie and cache parity, abort/close ownership, body pause/resume/cancel, request copies (including streamed-body backpressure), NSURLError mapping, Android and runWithClient parity, and the buzz_secp256k1 hooks bump. Size caps still count the bytes actually delivered after native gunzip. CI is green at 7eee728.

Validation gaps (iOS device, not blocking):

  • A redirect that follows to another origin with an Authorization header: Foundation decides whether the header is stripped, and source review can't confirm it.
  • URLSession's default request timeout (60 s idle) on stalled uploads, or on slow server responses after a large upload. Callers with no Dart deadline now inherit it.
  • A network change mid-session (Wi-Fi to cellular). Retiring a timed-out query generation no longer tears down the connection on iOS, so recovery from a wedged connection now depends on URLSession.

To merge: resolve the inline finding.


final relayInformationClientProvider = Provider<http.Client>((ref) {
final client = http.Client();
final client = newAppHttpClient();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Recommend fixing before merge: two relay-info readers leave response bodies unread on the shared session, and iOS keeps downloading them.

This handle and the one in relay_author_provider.dart:9 are app-lifetime handles, and now both sit on the shared API session. Two callers drop responses without reading them:

  • identity_archives.dart:33 returns on a non-200 status without touching response.stream.
  • relay_author_provider.dart:38 returns on a non-200 status after building a StreamIterator but before any moveNext, so chunks.cancel() never subscribes.
  • Both put .timeout(3s) on send() with no abort trigger, so a response that arrives after the timeout is orphaned the same way.

With dart:io, an unread body stalled at the socket buffers. cupertino_http 3.1.0 has no backpressure toward the network (cupertino_client.dart:329-331 always does dataController.add), so the native task downloads the whole body into an unread controller. The request also stays in SharedSessionClient._inFlight until the handle closes, which for these handles is app exit. This was confirmed with a probe against SharedSessionClient at this head: both patterns stayed in _inFlight until close().

Concrete scenario: on Edge, this PR's own table puts the first request at 6.3–7.0 s, so the 3 s header timeout fires on cold start. Each orphaned NIP-11 response then keeps sharing the API HTTP/2 connection with the queries this PR is speeding up, and its body sits in memory for the life of the app. That contradicts the PR's claim that timed-out requests are aborted so they stop using bandwidth.

Fix: move both readers onto the existing bounded, abortable fetchNip11Document (relay/nip11_document.dart:16). The smaller alternative is an AbortableRequest whose trigger completes in finally. Also correct the _inFlight doc (app_http_client.dart:61-63): a request only leaves the set once its body has been listened to.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in 96d2eac. Both readers now send an AbortableRequest whose trigger completes in finally, so a non-200 response, a header timeout or any other early exit aborts the session task. Their existing 3 s header and body timeouts are unchanged, so fetchNip11Document's single 10 s deadline didn't fit them as is. New tests in identity_archives_test.dart and agent_instructions_test.dart cover a refused response for each reader. The _inFlight doc now says a request also leaves the set when the caller's trigger fires, and that an unlistened body stays until then.

..maxRedirects = request.maxRedirects
..persistentConnection = request.persistentConnection;
// addStream keeps backpressure: the body is read only as the session
// reads the copy, so upload progress still tracks bytes actually sent.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Optional: this comment overstates the guarantee. Progress counts bytes URLSession has pulled, which includes toNSInputStream's 4 KiB read-ahead plus URLSession's own buffer, not bytes sent on the wire. That matches raw cupertino_http, so only the wording needs to change.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Agreed. The comment now says progress tracks bytes the session has taken, read-ahead buffers included, as with plain cupertino_http (96d2eac).

square-tomb and others added 2 commits November 13, 2026 07:12
Dart's dart:io client speaks only HTTP/1.1, so each feature's client opened
its own connections to the relay, which sits behind Cloudflare and offers
HTTP/2. On iOS, HTTP now goes through two app-wide NSURLSessions via
cupertino_http: one for API calls and one for media, so image bytes cannot
starve queries on a shared HTTP/2 connection. Android keeps http.Client().

cupertino_http 3.0.1+ needs native_toolchain_c 0.19 and hooks 2.x, so
buzz_secp256k1 moves to those; its build hook is unchanged.

Closing a client no longer closes a shared connection, so the relay query
client now aborts a timed-out request instead of relying on retiring its
client.

Signed-off-by: Tom Brow <[email protected]>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <[email protected]>
…etches

URLSession failures arrive as NSErrorClientException, not SocketException,
so pairing and invite join showed generic or raw NSError text when offline.
Classify NSURLErrorDomain codes as unreachable, insecure or timed out.

Closing a shared-session client no longer stops a stalled request, so the
dev push-lease NIP-11 fetch now aborts its request when it finishes.

Signed-off-by: Tom Brow <[email protected]>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <[email protected]>
square-tomb and others added 11 commits November 13, 2026 07:12
App Transport Security blocks cleartext URLSession loads from public hosts,
but saved community icons, remote media and local dev relays may use http://.
Those requests keep each handle's own dart:io client, as before.

Signed-off-by: Tom Brow <[email protected]>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <[email protected]>
…sport

Also restores the transport import pairing's error helper needs after
main removed the pairing HTTP provider.

Signed-off-by: Tom Brow <[email protected]>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <[email protected]>
…sion

Retiring a generation only closes a SharedSessionClient, which leaves the
URLSession task running, so sendBounded now sends an abortable copy of the
request and aborts it on timeout, as post already does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Tom Brow <[email protected]>
Closing a dart:io client cancels its in-flight requests, and callers rely
on that: a community switch disposes the relay session and media upload
clients to stop their work. A SharedSessionClient now sends each HTTPS
request with an abort trigger that fires when the handle closes (or when
the request's own trigger does), and refuses sends after close, while
the shared URLSession stays open for other handles.

Also download community icon images on the media session, route the
saved-identity profile and agent-card clients through the shared
transport, and give invite joins friendly messages for URLSession
timeouts and TLS failures.

Signed-off-by: Tom Brow <[email protected]>
…est copy

Piping a streamed body into the abortable copy with listen drained it at
once, so media upload progress would report bytes as sent before the
transport read them. addStream pauses the source until the session reads
the copy.

Signed-off-by: Tom Brow <[email protected]>
…ncelled before its first event

Signed-off-by: Tom Brow <[email protected]>
URLSession failures don't implement SocketException, so an offline
channel-list load on iOS fell through to the generic message. Check the
URLSession failure too, as pairing and invites do. Also correct the
snapshot cap comment, which no longer holds where gzip inflates natively.

Signed-off-by: Tom Brow <[email protected]>
The relay author and archive readers returned on a non-200 status, or
timed out waiting for headers, without reading or aborting the response.
On a shared URLSession the task kept downloading that body for the life
of the app. Give both an abort trigger completed on every exit. Also
correct the in-flight and upload-progress comments.

Signed-off-by: Tom Brow <[email protected]>
@brow
brow deployed to codex-review November 13, 2026 15:15 — with GitHub Actions Active
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 13, 2026
sendBounded aborted only on a timeout. A 200 whose Content-Length is over
the cap threw before the body was listened to, and cancelling an unlistened
iterator does nothing, so the shared session kept downloading the body.

Signed-off-by: Tom Brow <[email protected]>
@brow
brow deployed to codex-review November 13, 2026 15:26 — with GitHub Actions Active
@github-actions github-actions Bot added codex-security-review-current The posted Codex security review matches its recorded range. and removed codex-security-review-current The posted Codex security review matches its recorded range. labels Nov 13, 2026

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Carl, an automated reviewer, commenting via Wes's GitHub account.

Re-review at 88b3b30: no blockers. The earlier finding is resolved. identity_archives and relay_author_provider now send an AbortableRequest and complete its trigger in finally, so a non-200 status, a header timeout or any other early exit cancels the session task. sendBounded now also aborts on every exit, which covers an over-cap Content-Length too. The _inFlight and upload-progress comments are accurate. The new tests fail if the abort is removed. The rebase onto main changed nothing else in the PR besides lockfile context. CI is green.

The iOS device gaps from the first review still stand (cross-origin redirect Authorization handling, default URLSession timeouts on stalled transfers, and a network change mid-session). They aren't merge blockers.

@brow
brow merged commit a2d4ba0 into main Nov 13, 2026
53 checks passed
@brow
brow deleted the mobile-http2 branch November 13, 2026 18:49
bostonaholic added a commit that referenced this pull request Nov 13, 2026
Co-authored-by: mboston.Engineer <932d3b6ebea6cc83e091551ae46d1c7e7f8f7d0fa8e9cef5e6facc8544af413b@buzz.block.builderlab.xyz>

* origin/main:
  fix(mobile): require Builderlab consent for existing identities (#8823)
  feat(mobile): use HTTP/2 on iOS through app-wide URLSessions (#8897)
  fix(relay): publish a git push only when refs changed; check the protocol by refinement (#9425)
  fix(mobile): show shared chats under Solo (#9348)
  chore(release): release Buzz Desktop version 0.5.29 (#9456)
  feat(mobile): render messages with gpt_markdown 1.3.4 plusparse (#8945)
  fix(acp): bound observer retention by bytes (#7987) (#9436)
  Tune release codegen for faster/smaller binaries (#9434)
  feat(desktop): add dismissible banner pointing to the new buzz app (#9379)
  fix(mobile): keep publishing presence while the app is open (#9441)
  feat(mobile): share into Buzz from the iOS share sheet (#9433)
  fix(mobile): qualify every namesake mention and keep authored mention casing (#9325)
  fix(mobile): match browser GIF timing in the iOS reaction sheet (#9437)
  fix(mobile): play short-delay animation frames at browser speed (#9432)
  buzz-search: read profile names without the profile_name_fields function (#9397)
  buzz-db: build the community deletion lock key in Rust (#9396)
  fix(cli): pair for the active community, skip stopped desktops, accept a bare relay host (#9417)
  test(mobile): run push tests without a gateway dart-define (#9423)
  fix(mobile): never pop Home when archiving from channel settings (#9387)
  perf(acp): reduce observer CPU and lock overhead (#9381)

Signed-off-by: Matthew Boston <[email protected]>
bostonaholic added a commit that referenced this pull request Nov 13, 2026
…count-backfill

Co-authored-by: mboston.Engineer <932d3b6ebea6cc83e091551ae46d1c7e7f8f7d0fa8e9cef5e6facc8544af413b@buzz.block.builderlab.xyz>

* bostonaholic--agent-reply-count:
  fix(mobile): require Builderlab consent for existing identities (#8823)
  feat(mobile): use HTTP/2 on iOS through app-wide URLSessions (#8897)
  fix(relay): publish a git push only when refs changed; check the protocol by refinement (#9425)
  fix(mobile): show shared chats under Solo (#9348)
  chore(release): release Buzz Desktop version 0.5.29 (#9456)
  feat(mobile): render messages with gpt_markdown 1.3.4 plusparse (#8945)
  fix(acp): bound observer retention by bytes (#7987) (#9436)
  Tune release codegen for faster/smaller binaries (#9434)
  feat(desktop): add dismissible banner pointing to the new buzz app (#9379)
  fix(mobile): keep publishing presence while the app is open (#9441)
  feat(mobile): share into Buzz from the iOS share sheet (#9433)
  fix(mobile): qualify every namesake mention and keep authored mention casing (#9325)
  fix(mobile): match browser GIF timing in the iOS reaction sheet (#9437)
  fix(mobile): play short-delay animation frames at browser speed (#9432)
  buzz-search: read profile names without the profile_name_fields function (#9397)
  buzz-db: build the community deletion lock key in Rust (#9396)
  fix(cli): pair for the active community, skip stopped desktops, accept a bare relay host (#9417)
  test(mobile): run push tests without a gateway dart-define (#9423)
  fix(mobile): never pop Home when archiving from channel settings (#9387)
  perf(acp): reduce observer CPU and lock overhead (#9381)

Signed-off-by: Matthew Boston <[email protected]>
brow added a commit that referenced this pull request Nov 13, 2026
…9435)

Follows #8897 (iOS HTTP/2), now on main.

## What

Android gets the same app-wide shared sessions as iOS, backed by Cronet
(`cronet_http`) instead of `NSURLSession`:

- One Cronet engine per traffic class (API, media), so requests to a
host multiplex over one HTTP/2 connection and gzip is inflated natively,
off the Dart isolate.
- HTTP/2 on, HTTP cache off, QUIC off. QUIC would be a separate behavior
change, and `cronet_http`'s changelog lists DNS failures with QUIC on
cellular.
- Streamed uploads (media and voice notes) stay on `dart:io` on Android:
`CronetClient` reads a request body into memory before sending it, which
would break upload progress and copy large attachments in full.
- If no Cronet provider is available (no Google Play services), the
engine fails to build and the app keeps `dart:io`, as today.
- Transport error classification covers Cronet failures alongside
`NSURLSession` ones, so invite and pairing errors keep their friendly
messages.

## Measurements

Android emulator (API 36, arm64), profile builds of this branch and
main, signed in to the Feedback community. 8 cold starts per build,
alternating builds in blocks of 4. Times are for relay `/query` requests
started in the first 8 s.

| | main (`dart:io`) | this branch (Cronet) |
|---|---|---|
| First query response | 6.99 s | 1.99 s |
| Startup burst settled | 8.36 s | 2.78 s |
| Burst after the first connection | 1.44 s | 0.77 s |
| Query latency after the first connection, median / p90 | 165 / 1567 ms
| 106 / 158 ms |

**Unexplained stall:** most of main's startup gap is a ~5 s stall before
its first response on every cold start. Its cause is unconfirmed; my
guess is `dart:io` loading its CA store on the emulator, which HTTP/2
itself wouldn't fix, so the first two rows likely overstate the win on
real devices. The rows after the first connection exclude the stall and
are the fairer comparison. I also tried emulated EDGE throttling, but
the emulator's throttle did not slow its traffic, so there are no
slow-link numbers yet.

## Regressions

None found. Devices without Play services keep `dart:io`. Cleartext
`http://` requests keep the handle's own `dart:io` client, as on iOS.

## Testing

- Full mobile suite passes (`flutter test`, 4784 tests).
- Automated app run on the emulator: onboarding, Feedback nsec import,
channel and DM list load, and 16 cold starts with request timing. Media
loading wasn't separately exercised.
- No visible UI change, so no screenshots.

---------

Signed-off-by: Tom Brow <[email protected]>
Co-authored-by: Tom Brow <[email protected]>

This branch was successfully deployed

1 active deployment
codex-review — 88b3b30d Deployed Nov 13, 2026 by brow via Run Codex Security Review #14940
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

codex-security-review-current The posted Codex security review matches its recorded range.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants