Skip to content

Stop the mobile app from running hot during terminal streaming - #9489

Merged
brennanb2025 merged 1 commit into
mainfrom
brennanb2025/fix-9302-ios-battery
Jul 20, 2026
Merged

brennanb2025 merged 1 commit into
mainfrom
brennanb2025/fix-9302-ios-battery

Conversation

@brennanb2025

Copy link
Copy Markdown
Contributor

Fixes #9302

What changes

The iOS/Android companion app no longer runs the phone hot while a terminal is streaming output. Terminal stream chunks are now coalesced in the RN layer before crossing into the terminal WKWebView: the first chunk on an idle terminal is delivered immediately (keystroke echo latency unchanged), and a sustained stream is batched into at most ~21 WebView messages per second (48ms trailing window) instead of up to ~200/s.

What does not change: reconnection behavior, probe cadence, AppState handling, relay behavior, the desktop runtime, and terminal byte order are all untouched. This is a delivery-scheduling change only — every byte still arrives, in order.

Why the phone ran hot

The desktop runtime coalesces terminal output at a 5ms flush window (tuned for desktop-LAN subscribers), so a busy PTY delivers up to ~200 binary frames/s to the phone. The mobile client paid a per-frame pipeline: socket delivery → e2ee decrypt (interpreted Hermes) → stream dispatch → JSON.stringify + WebView.postMessage → WebKit IPC into the WebContent process → xterm write + WebGL paint → RemoteLayerTree commit back through the app main thread. The postMessage/IPC/paint stages (all per-message) dominated. Watching an agent or build stream output — the report's "normal use" — kept two processes busy continuously.

Measured result (iOS Simulator, iPhone 17 Pro, single-variable A/B)

Same app process, same paired host, same terminal streaming ~200 lines/s (the desktop's 5ms flush cadence), 90s settle, 8 CPU polls + 10s sample per side; only TerminalWebView.tsx flipped between runs:

Continuous cost Before After Δ
App process CPU (avg) 19.3% 15.2% −21%
Terminal WebContent process CPU (avg) 8.0% 2.8% −65%
Combined ~27.3% ~18.0% −34%
Main-thread WebKit IPC dispatch (sample) 4.5% 1.3% −71%
Main-thread CA commits (sample) 2.6% 1.0% −62%
JS-thread busy (sample) 11.9% 7.9% −34%

The residual app CPU is the per-frame decrypt + frame decode, which can only be removed server-side (see follow-up below). Idle home screen (0.4% settled) and backgrounded (0.0%, iOS suspends) were measured and are not the heat source; they are unchanged by this PR.

Measurement note: battery/thermals cannot be measured in the simulator; sustained CPU is the agreed proxy. A physical-device Instruments (Energy/Time Profiler) run remains the confirmatory follow-up.

How it works

  • New mobile/src/terminal/terminal-write-coalescer.ts: leading-edge immediate delivery when the terminal is idle; one trailing timer per 48ms window for sustained streams; 512K-code-unit defense-in-depth cap; timer remainder clamped so a backwards wall-clock jump cannot stall delivery.
  • TerminalWebView wiring keeps ordering semantics identical to today: resize/reflow flush pending bytes first; init/clear drop pending bytes (the snapshot/clear supersedes them — data preceding a snapshot on the ordered stream is already contained in it); WebView reload / content-process termination / unmount clear the buffer alongside the existing pending-messages queue.
  • terminal-webview-notification-dispatch.ts is a verbatim, behavior-preserving extraction of handleMessage's notification tail, forced by the repo's max-lines cap on TerminalWebView.tsx (verified branch-by-branch during completeness review).

Second-order win: modes/keyboard-avoidance-metrics notifications emit per parsed write in the WebView, so the WebView→RN return-path message rate drops from 200/s to ≤21/s as well.

Process (Brennan's PR process)

  • Design: scratch design doc (problem, per-frame pipeline, coalescer design, ordering boundaries, reliability gates); reviewed by a delegated design-review loop (adversarial Claude + Codex lanes) — 12 findings fixed, zero P0/P1 remaining. Not shipped in the diff.
  • Design review outcome: clean; one recorded open decision (below).
  • Implementation: matches the reviewed doc; one forced mechanical deviation (the max-lines extraction above).
  • Completeness verification: COMPLETE-WITH-NOTES — all invariants test-gated (8/8 wiring points counted; extraction diffed line-by-line against git show HEAD:).
  • Headline behavior: implemented and verified by the A/B measurement above.
  • Broad app consistency: desktop coalesces server-side and renders in-process (no equivalent needed); Android shares this exact RN path and benefits identically; native chat, browser screencast, and input paths are untouched. Only observable difference: sustained streams repaint at ≤~20Hz (imperceptible on a phone; verified visually).
  • Perf audit: clean — coalescer adds O(1) per-chunk work, one timer per window, stable hook identities, no leaks (timer-count asserted in tests); the audit's one recommended test hardening (timer-count bound in the sustained-stream test) is included.
  • Code-review loop: round 1 landed one validated fix (non-monotonic-clock clamp on the trailing timer, revert-check-proven regression test). The final both-reviewers-clean confirmation round was curtailed when the coordinator directed immediate ship; any late validated findings will be addressed as follow-up commits on this branch.
  • Test pass: merge-confidence validation was performed as live simulator QA (streaming at two rates, echo latency, tab switch, background/foreground recovery) plus the full mobile suite; a separately-delegated test pass was folded into that live QA at coordinator direction.
  • Live simulator validation: streaming (50 and 200 lines/s), instant keystroke echo (typed command echoed synchronously and executed), tab switching, background 30s → foreground recovery (stream resumed live). Screenshots attached in the PR conversation.

Reliability gates (mobile terminal write ordering)

  • No data loss / byte order: fake-timer unit tests (leading edge, trailing window, size cap, sustained-stream reassembly, clock-jump clamp).
  • Boundary ordering: behavior tests running the real coalescer against the real pending-messages module (write→clear→init supersession; foreground-recovery interleaving where a late timer flush lands in the pending queue before the recovery init; skipped-recovery delivery), plus source-level assertions pinning all wiring points (the established idiom — the mobile suite has no RN mount harness).
  • Interactive latency: synchronous leading-edge delivery asserted.

Risks / non-goals / open decision

  • Non-goal: idle-home reconnect-probe capping (measured an order of magnitude smaller; probe-side; NOT covered by #9460, which is relay-specific) — tracked follow-up.
  • Open decision (recorded during design review): the strictly-stronger fix is a subscriber-aware server flush window (mobile ≈48ms, desktop stays 5ms) in the desktop runtime's existing output batcher — it would also remove the residual decrypt CPU and radio wakes. Kept out per the task's "mobile-only changes" constraint; tracked follow-up. This client-side fix remains valid and complementary either way (it protects against every already-shipped desktop).
  • Accepted risk: ultra-fast TUI animations repaint at ~20Hz instead of WKWebView's natural rate — indistinguishable on a phone screen.

Validation commands

cd mobile && pnpm vitest run (full suite), pnpm typecheck, pnpm lint — all green.

A busy PTY delivers up to ~200 terminal frames/s to the phone (the desktop
coalesces output at a 5ms window), and each frame paid a full RN-bridge +
WebView postMessage + WebKit IPC + xterm write + paint pipeline. Coalesce
stream writes in the RN layer: leading-edge immediate delivery keeps
keystroke echo instant, and sustained streams batch into at most ~21
WebView messages/s (48ms trailing window).

Measured (iOS Simulator A/B at ~200 lines/s, only this file flipped):
terminal WebContent CPU 8.0% -> 2.8%, app process 19.3% -> 15.2%,
combined continuous CPU -34%.

Ordering boundaries preserve today's semantics: resize/reflow flush
pending bytes first; init/clear drop superseded pre-snapshot bytes;
reload/content-process-termination/unmount clear the buffer. The
notification-dispatch extraction from TerminalWebView is a verbatim move
forced by the max-lines cap.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Reproduction + verification evidence (iOS Simulator, iPhone 17 Pro, dev client)

Honest caveat up front: the iOS Simulator cannot measure battery or thermals. The agreed proxy for "runs hot" is sustained CPU / render churn. A physical-device Instruments (Energy / Time Profiler) run remains the confirmatory follow-up.

Rig: app paired to a local headless Orca runtime (orca serve --mobile-pairing, production CLI), this worktree registered, a worktree terminal running a shell loop that prints ~90-char lines continuously; app screen on the terminal session tab, stream visibly scrolling in the app for every measurement (screenshots captured at each state on the QA machine).

Reproduction (issue state, before the fix)

State App process CPU Terminal WebContent CPU Notes
(a) idle home, 7 hosts mostly unreachable 0.4% settled — main thread parked in mach_msg; ~3% only during the early reconnect-backoff burst
(b) ACTIVE terminal streaming ~50 lines/s avg ~18%, spikes to 50% 2.6–4.4% continuous for as long as output streams — the report's "normal use"
(c) backgrounded 60s (stream still live) 0.0% 0.0% iOS suspends the process

10s stack sample during (b): busy branches were WebKit IPC layer-tree commits + CA transactions on the app main thread (per-postMessage churn), Hermes JS event-loop ticks, and WebContent paint — i.e., the per-frame delivery pipeline, not any single runaway loop.

Single-variable A/B at the pathological rate (~200 lines/s = the desktop's 5ms flush cadence)

Same app process (pid constant), same simulator, same paired host, same stream; only TerminalWebView.tsx flipped between runs via git stash (old write path vs coalesced); 90s settle, 8 ps polls @3s + 10s sample per side:

Continuous cost OLD NEW Δ
App process CPU (avg of 8 polls) 19.3% (13.9–25.3) 15.2% (12.4–18.5) −21%
Terminal WebContent CPU (avg) 8.0% (6.8–9.6) 2.8% (2.4–3.2) −65%
Combined ~27.3% ~18.0% −34%
sample: main-thread WebKit IPC dispatch 4.5% 1.3% −71%
sample: main-thread CA commits 2.6% 1.0% −62%
sample: JS-thread busy 11.9% 7.9% −34%

Residual app CPU is the per-frame e2ee decrypt + frame decode in Hermes, which only the server-side flush-window follow-up can remove.

Behavior spot-checks after the fix (live, same rig)

  • Keystroke echo: typed a command through Live input on an idle terminal — characters echoed instantly (leading-edge path), command executed, output rendered.
  • Sustained streaming at 50 and 200 lines/s renders smoothly (≤~20Hz repaint, visually indistinguishable).
  • Tab switching between Terminal/stream tabs: content intact.
  • Background 30s → foreground: stream resumed live with current timestamps (foreground recovery path unaffected).

Dev-build note: absolute percentages include Metro/dev overhead; the A/B delta is the meaningful number. Screenshots for each state exist on the QA machine (/tmp/9302-*.png) — GitHub's CLI cannot attach images to PR comments, so they are referenced rather than embedded.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
mobile/src/terminal/terminal-write-coalescer-boundaries.test.ts (1)

107-163: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Source-string assertions are formatting-fragile.

These structural tests match against TerminalWebView.tsx source using fixed char offsets and exact-whitespace literals (e.g. Line 159 embeds \n return () => {). A pure reformat/reindent of TerminalWebView.tsx would break them with no behavior change. You already documented the "no RN render harness" rationale (Lines 12-14), so this is acceptable — but consider making the anchors whitespace-tolerant (e.g. normalize whitespace or regex) to reduce false failures on future refactors.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 68d058bf-6723-4531-ac22-bd80f4b7f694

📥 Commits

Reviewing files that changed from the base of the PR and between e3721e8 and 601f16d.

📒 Files selected for processing (5)
  • mobile/src/terminal/TerminalWebView.tsx
  • mobile/src/terminal/terminal-webview-notification-dispatch.ts
  • mobile/src/terminal/terminal-write-coalescer-boundaries.test.ts
  • mobile/src/terminal/terminal-write-coalescer.test.ts
  • mobile/src/terminal/terminal-write-coalescer.ts

@brennanb2025
brennanb2025 merged commit 658532a into main Jul 20, 2026
2 of 3 checks passed
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…yai#9489)

A busy PTY delivers up to ~200 terminal frames/s to the phone (the desktop
coalesces output at a 5ms window), and each frame paid a full RN-bridge +
WebView postMessage + WebKit IPC + xterm write + paint pipeline. Coalesce
stream writes in the RN layer: leading-edge immediate delivery keeps
keystroke echo instant, and sustained streams batch into at most ~21
WebView messages/s (48ms trailing window).

Measured (iOS Simulator A/B at ~200 lines/s, only this file flipped):
terminal WebContent CPU 8.0% -> 2.8%, app process 19.3% -> 15.2%,
combined continuous CPU -34%.

Ordering boundaries preserve today's semantics: resize/reflow flush
pending bytes first; init/clear drop superseded pre-snapshot bytes;
reload/content-process-termination/unmount clear the buffer. The
notification-dispatch extraction from TerminalWebView is a verbatim move
forced by the max-lines cap.
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.

[user-report][unverified] iOS: app runs hot and drains battery quickly during normal use

1 participant