fix(mobile): iOS app gets compressed websocket frames#24931

closed4
main
GitHub fork: fix/i21183-ios-app-s-websocket-never-negotiates-per
opened14d agobyOonly21mil
View on GitHubTwo-way sync enabledPR last imported 1d ago

Problem

The iOS app downloads every WebSocket frame uncompressed (#21183). The server has negotiated permessage-deflate since #4705, but only when the client offers it. On iOS the runtime uses React Native's global WebSocket, SocketRocket, which never sends Sec-WebSocket-Extensions. Android (OkHttp) and desktop (Chromium) offer it. The reporter measured the same providerStatuses push at 558 KB to an iPhone and 141 KB to desktop.

Change

iOS now opens the app's WebSocket with URLSessionWebSocketTask, which offers permessage-deflate in its handshake.

  • apps/mobile/modules/t3-websocket: an Apple-only Expo module with connect, sendText, sendBinary and close, and events keyed by a JS-assigned id. The session skips registered URLProtocol classes, since dev clients install ones that drop the socket after the 101. The receive limit is raised from URLSession's 1 MB default to 128 MB because thread snapshots can be larger. A task that ends without a close frame reports 1006, as browsers do, so the connection supervisor reconnects. One that ends cleanly without didCloseWith reports 1005, so the JS socket never sticks in CLOSING.
  • apps/mobile/src/lib/nativeWebSocket.ts: a Socket.WebSocketLike over that module, provided as the mobile runtime's WebSocketConstructor. A native open that arrives after close() is ignored, so an open timeout can't revive a dropped socket. The layer subscribes to native events when built and unsubscribes when released. Socket ids keep counting across Fast Refresh, so a late close from an older URLSession task can't close a newer socket.
  • apps/mobile/src/lib/runtime.ts: uses that layer in place of Socket.layerWebSocketConstructorGlobal.

Android has no T3WebSocket native module, and neither do iOS binaries built before this change, so both keep the global WebSocket unchanged. The constructor takes only a URL and subprotocols, which is all its two callers pass, and throws on other options rather than dropping them.

Scope and approval

Fixes #21183. A maintainer's triage comment asked for an iOS capture of the upgrade request, and the reporter supplied it. #4794 was closed on the reading that #4705 covered it, but #4705 is the server half. There is no maintainer agreement on this approach yet. The choice for maintainers to confirm is a small app-owned native module for the main socket rather than a third-party WebSocket library. This awaits maintainer review.

Out of scope: the preview and device-stream sockets in client-runtime still use the global WebSocket.

Verification

  • protectedData.test.ts, which builds the real mobile runtime, gains a check that iOS dials through T3WebSocket. On main it fails at its assertion because the runtime hands iOS the global WebSocket. Its expo mock now answers per module name.
  • nativeWebSocket.test.ts, 4 tests: text and binary round trip, server close codes, a late native open, and an old socket's close arriving after Fast Refresh opened its replacement.
  • Every mobile test file that imports or mocks lib/runtime or the new module passes: those two plus connection, storage, preferences, pending-task-editor-writes, queued-run-edit, thread-outbox-removal, CloudAuthProvider, remoteRegistration, AddProjectScreen, thread-settings-sheet-state and app-updates.
  • vp run typecheck in apps/mobile, plus vp fmt --check and vp lint on the changed files: clean.
  • iOS 26.5 simulator, Debug builds of main and this branch's first commit (the second changes no native code), each paired to a local server. tcpdump on the loopback port recorded the /ws upgrade, query string dropped. On main the app's request has no Sec-WebSocket-Extensions and the 101 carries none. On this branch the request offers permessage-deflate and the 101 accepts it.
  • On this branch the app sent a message over the new socket and the server's events came back on it. The provider error is expected: the test server had no provider.

Not checked:

  • A physical iPhone, or a Release build.
  • Bytes on the wire. The test server had almost no data, so the size difference is the reporter's measurement.
  • wss through T3 Connect or a reverse proxy, network changes, backgrounding, and frames over 1 MB.
  • Android on a device. Its path is unchanged: the module is Apple-only and the layer checks Platform.OS.

Prior PRs

  • #4794 by t3dotgg built this module first and was closed in the backlog sweep, not on its merits. This PR keeps its Swift design, adds the clean-completion 1005 case, and rewrites the TypeScript side for Effect 4's WebSocketLike.
  • #4705 by t3dotgg enabled permessage-deflate on the server.
  • The diagnosis and both device captures in #21183 are by 1kaahu.

The upgrade headers the server saw from the iOS app on main (SocketRocket) and on this branch (URLSession).

iOS upgrade headers before and after

This branch on the iOS simulator: a message sent over the new socket, and the server's reply events back on it.

iOS app on this branch after a round trip

The new runtime test on main: the iOS runtime dials through the global WebSocket.

Test failing on main

The runtime test and the adapter tests passing on this branch.

Test passing on this branch

Generated with Claude Opus 5.5 in T3 Code Nightly

Checks

21 successful · 13 skipped
All checks passed
5318f29

Added 2 commits

  • fix(mobile): iOS negotiates websocket compression7386968
  • fix(mobile): iOS socket layer survives Fast Refresh and test mockse72a884
Synced from GitHub

Approvability

Verdict: Needs Human Approval

Macroscope's review found this PR requires human approval — This PR adds a new iOS-native WebSocket stack and makes it the default transport for the mobile runtime, affecting production connection lifecycle and native build integration. The scope is broader than a self-contained bug fix and warrants maintainer review.

You can add or adjust custom eligibility rules. Learn more.

Synced from GitHub

Review in Change Stack →

No actionable comments were generated in the recent review.

External image Recent review info
External image Run configuration
External image Commits

Reviewing files that changed from the base of the PR and between e72a8843b29976e7de07cd808552600e70a375c5 and 5318f29b5d45d48168d2a27638be598fae38b2ff.

External image Files selected for processing: 3
  • apps/mobile/src/lib/nativeWebSocket.test.ts
  • apps/mobile/src/lib/nativeWebSocket.ts
  • apps/mobile/src/lib/protectedData.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

Walkthrough

The PR adds an iOS Expo WebSocket module backed by URLSessionWebSocketTask and an Effect adapter. The runtime selects the native constructor when the module is available. Tests cover adapter behavior and runtime selection.

Changes: iOS WebSocket

Layer / File(s)Summary
Native WebSocket transport
apps/mobile/modules/t3-websocket/expo-module.config.json, apps/mobile/modules/t3-websocket/ios/T3WebSocket.podspec, apps/mobile/modules/t3-websocket/ios/T3WebSocketModule.swift
Registers the Apple Expo module. The module manages URLSessionWebSocketTask connections, sends and receives text and binary messages, and emits connection events.
Effect adapter and runtime integration
apps/mobile/src/lib/nativeWebSocket.ts, apps/mobile/src/lib/runtime.ts, apps/mobile/src/lib/nativeWebSocket.test.ts, apps/mobile/src/lib/protectedData.test.ts
Adds an Effect adapter that routes native events and binary messages. The runtime selects the adapter when the native module is available. Tests cover adapter behavior and runtime selection.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

mermaid
1sequenceDiagram
2 participant Runtime
3 participant NativeWebSocket
4 participant T3WebSocketModule
5 participant URLSessionWebSocketTask
6 Runtime->>NativeWebSocket: Create socket with URL and protocols
7 NativeWebSocket->>T3WebSocketModule: Connect with socket ID, URL, and protocols
8 T3WebSocketModule->>URLSessionWebSocketTask: Resume connection
9 URLSessionWebSocketTask-->>T3WebSocketModule: Deliver open, message, or close callback
10 T3WebSocketModule-->>NativeWebSocket: Emit event with socket ID
11 NativeWebSocket-->>Runtime: Dispatch WebSocket event

Suggested labels: bugfix, 📱 Native Change

Suggested reviewers: nekohasekai, juliusmarminge

Merge Risk: External image Minimal · up to 5318f

No actionable issue is established for the iOS main-socket change; it is mergeable after normal checks.

Pre-merge checks: Passed 4
Check nameStatusExplanation
Title checkPassedThe title clearly identifies the main change: enabling compressed WebSocket frames in the iOS mobile app.
Linked Issues checkPassedIssue #21183 requires the iOS app's main WebSocket to offer permessage-deflate and receive compressed frames. The PR adds the Apple-only T3WebSocket module with URLSessionWebSocketTask, wires it…
Out of Scope Changes checkPassedThe supplied change summary limits changes to the native WebSocket module, its Effect WebSocketLike adapter, runtime wiring, and related tests. These changes support issue #21183. The PR leaves prev…
Description checkPassedThe description follows the required template. It explains the problem, implementation, scope, verification results, limitations, linked issue, screenshots, and agent model and harness. It also clearl…
Full details: Linked Issues check

Explanation

Issue #21183 requires the iOS app's main WebSocket to offer permessage-deflate and receive compressed frames. The PR adds the Apple-only T3WebSocket module with URLSessionWebSocketTask, wires it into the mobile runtime, and retains the global WebSocket fallback for Android and older iOS binaries. The reported iOS simulator capture shows the request offering permessage-deflate and the server accepting it. Tests cover iOS runtime selection, text and binary frames, close handling, late open handling, and Fast Refresh socket replacement. The repository diff was unavailable for the current incremental comparison, so this conclusion uses the supplied PR summary and previous reviewed evidence.

Full details: Out of Scope Changes check

Explanation

The supplied change summary limits changes to the native WebSocket module, its Effect WebSocketLike adapter, runtime wiring, and related tests. These changes support issue #21183. The PR leaves preview and device-stream sockets in client-runtime unchanged, as required by the stated scope. No unrelated change is established. The repository diff was unavailable for the current incremental comparison, so this conclusion uses the supplied PR summary and previous reviewed evidence.

Full details: Description check

Explanation

The description follows the required template. It explains the problem, implementation, scope, verification results, limitations, linked issue, screenshots, and agent model and harness. It also clearly states that maintainer approval for the proposed approach is still pending.

Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Added 2 commits

  • fix(mobile): discard websocket messages after closing starts5318f29
  • chore(mobile): merge main into ios compression fix8bcadf7

Note

Written by gpt-6.1-sol on behalf of Maria

updated this branch directly, preserving @only21mil's commits and merging current main (e762f09e265b). the original URLSession transport, native registration, fallback, and fast refresh design remain intact. no replacement pr or unrelated changes.

added one production line: discard queued native messages once the socket leaves OPEN. a new regression in the existing adapter test failed before that guard and passes with it. added checks for layer teardown removing listeners and closing sockets, plus android and older-ios global-socket fallback. two independent reviews approved head 5318f29b5d45d48168d2a27638be598fae38b2ff without remaining findings.

verification on blacksmith:

  • current main fails the existing ios routing assertion because it calls the global websocket constructor.
  • the candidate passes all 12 focused tests: routing/fallback, text/binary round trip, server close, late open, stale message, teardown, fast refresh, and protected-storage startup.
  • vp run check:changed passes format, lint, both knip checks, mobile/scripts typecheck, and the two affected test files.

unverified: native ios compilation and actual compression negotiation, physical-device/release behavior, background/resume, network changes, proxy/T3 Connect, and large frames. this linux vm has no apple runtime. the original author's simulator captures remain author-reported evidence. no UI behavior or layout changed, so browser captures would not verify this transport.

maintainer approval is still needed for making the app-owned native transport the ios default. before merging, exercise the native app through background/resume and a network interruption, confirm reconnection delivers a new message, and check the negotiated extension on its /ws upgrade. preview/device-stream sockets remain outside this fix, as in the original.

Note

Written by gpt-6.1-sol on behalf of Maria

Verified in a real dev environment

disposition: unverifiable for the ios fix on this linux vm. these checks cover neighboring web behavior, not the changed native transport.

read the description, full diff, discussion, review comments, commit authors, and checks. the pr is open, with no commits by @juliusmarminge or @t3dotgg. checked exact head 5318f29b5d45d48168d2a27638be598fae38b2ff without changing it.

  • installed and ran github/main (48a292364ab37574c67fb377b5227e5d281d9203) in /home/user/main, then stopped its captured server pid before starting the pr. installed the exact pr head and ran it with explicit isolated state: vp run dev --home-dir /home/user/t3code/.t3.
  • drove the actual web app with repository playwright and /usr/bin/google-chrome. added a project pointing at the real /home/user/main git repo, created a thread, selected codex gpt-6.1-sol, and submitted the same prompt on both revisions. both returned transport verification ok through the real local provider proxy. no mocks, fixtures, or demo data.
  • on the pr, reloaded the client and saw persisted history. a fresh /ws upgrade returned 101, offering permessage-deflate; client_max_window_bits and accepting permessage-deflate. the web path also negotiated compression on main.
  • after browser offline/online, sent a follow-up in the same thread and received reconnect verification ok. this does not establish that an underlying tcp connection dropped, or prove native reconnection.
  • inspected the exported clip, checked uploaded playback to the end and seeking in the t3 browser player, and verified the uploaded transcript. existing head checks are green where run; native preview and some deploy checks were skipped. no new source changes or check:changed run in this verification pass.

command and handshake transcript

real codex follow-up on the pr web client after reload and browser offline/online

Not verified

the original ios bug could not be reproduced on main, and the changed ios behavior could not be exercised here. this linux x86_64 vm has no apple sdk, xcodebuild, swift toolchain, or native ios client. chrome uses its own websocket transport and never executes T3WebSocketModule.swift or the mobile runtime selection. a successful browser turn is therefore insufficient evidence for this fix.

still required on an apple host: build and pair the native app on main and this head; capture the actual /ws upgrade; confirm the main app does not offer compression and the pr does; run a real provider turn; exercise background/resume and a genuine connection interruption followed by another turn. native compilation, physical-device/release behavior, android and older-ios fallback, large frames, and t3 connect/proxy behavior remain unverified. no production fix was attempted without reproducing a failure in the changed path.

Synced from GitHub

Note

🤖 Claude Opus 5.5 responding on behalf of Daniel Briskin

I ran this on a physical iPad. It negotiates compression and cuts the streaming data about 6x, but it didn't change whole-device power over Wi-Fi.

Setup: iPad mini (A17 Pro), iPadOS 26.7.2, Release build, server reached over Tailscale. The first runs were accidentally in landscape. I re-ran the power trial in portrait to match the #27325 bench, and the numbers below are from that run. I applied this PR's diff onto dac8b11ddc, because that's where the #27325 power bench is pinned.

  • Works: the app connects, opens threads and streams a replayed turn normally.
  • Bytes (measured in landscape; orientation doesn't change what the server sends): during the same 120 s streaming window, the server sent 122 KB to the app, against 755 KB without this PR. I counted the Tailscale extension's bytes on the server host.
  • Power: a 2-block A/B trial on the streaming phase reads −25 / +7 mW per block in portrait, inside the bench's ~25 mW resolution. The landscape run read −6 / +1.

Not checked: reconnects, background/foreground, relay and tunnel paths, and cellular. Cellular is where fewer bytes should save the most power: in a sysdiagnose power log from the same user, about half of the non-display energy is cellular radio. This bench only uses Wi-Fi.

Thanks @only21mil, this was the right fix. While reworking #30732 we ended up building the same URLSession approach in #32288, which sits on top of #32287, and we're landing that one so the stack ships as a unit.

From your PR, it keeps:

  • the protocolClasses = [] session, so dev-client URL protocols can't drop the socket
  • the 128 MiB receive limit
  • the 1005/1006 close codes when a task ends without a close frame

It also adds:

  • writes after close are dropped instead of throwing
  • the close reason reaches the wire
  • disposing the layer finalizes open sockets
  • live byte counts on a pool iPhone, over ws and over wss through Tailscale Serve: about 8× smaller on the wire

Credit to you, to @t3dotgg for #4794, and to @1kaahu for the diagnosis in #21183 is in that PR, which closes #21183. Closing this one in favour of it.