Skip to content

fix(android): keep opening a PC while its page arrives over a slow link - #3850

Open
devswha wants to merge 4 commits into
mainfrom
fix/android-slow-open
Open

devswha wants to merge 4 commits into
mainfrom
fix/android-slow-open

Conversation

@devswha

@devswha devswha commented Nov 14, 2026 •

Copy link
Copy Markdown
Owner

At the head (1e4f266): before the PC answers, the error page comes after 20 s (later only while bytes keep arriving, until 5 s of quiet, never past 60 s); once it answers the open is never cut off. The cover stays until the client is up, asked every 200 ms once the page shows, with the progress bar. A stall offers Keep waiting while the page is still arriving, and only Try again once it has fully loaded and the app did not start.

Why

A user on a slow link (about 10 KB/s, VPN, mobile data) got "Can't reach your PC ... did not answer within 20 seconds" for a public server that was answering. The cover's timer was a wall clock: whatever was still arriving at 20 s was stopped and the PC was blamed (off, home network, firewall).

Reproduced on an Android 15 emulator (API 35) with this branch's base, against a test Ovis behind a throttling proxy (rate and round trip set per run). The PC's first byte arrived in 1-4 s in every run.

Link main this PR
10 KB/s, 0 / 300 ms error page at 20 s usable at 125 s
25 KB/s, 300 ms error page at 22 s usable at 51 s
50 KB/s, 300 ms usable at 27 s usable at 27 s
50 KB/s, 800 ms usable at 28 s usable at 30 s
50 KB/s, 2000 ms error page at 22 s usable at 31 s
100 KB/s, 300 ms usable at 14.5 s usable at 14.5 s
100 KB/s, 2000 ms usable at 18 s usable at 21 s (main would cut a run this slow)

Usable = the client replaced index.html's loading spinner. The time is the page's weight, not the app: about 90 KB before the first paint and about 1.1 MB (the main script, 978 KB brotli) before the client runs. That is a separate follow-up, not in this PR.

What changes

  • OpeningWatch (new, pure Java): the cover's stage from what arrived and when. Once the PC's page has answered (onPageStarted for a page of the PC: its response committed) the open is never cut off: after 5 s the cover says "Slow connection. Still opening X... N KB so far"; after 20 s with no new bytes it says nothing has come for 20 s and offers Keep waiting and Try again (Change PC stays).
  • Before that answer the error page comes once the answer limit (the same 20 s, or what the connect screen's check left) has passed with nothing arriving for 5 s, and at 60 s in any case. More than 1 KB received (TrafficStats for the app's uid) counts as arriving, so a slow TLS handshake puts the limit off, while a server that completes its handshake and never responds gets "accepted the connection but sent no page". The uid total also counts the app's other traffic, which is why it can only put the limit off, never past 60 s.
  • The cover's 1 s tick stops in the background (onStop) and looks again at once on onStart.
  • MainActivity: the one-shot timer becomes a 1 s tick that asks OpeningWatch; gaveUp, the error page and its reason are as before for a real no-answer.
  • publicHttp (the connect screen's plain-http warning) now reuses homeHost, and a test checks that error.html's own copy agrees with it on 25 addresses.
  • Copy: a timeout to a public server ("Nothing came back from X within 20 seconds. The server may be down, or this phone's connection (or a VPN on it) may not reach it.") no longer talks of home networks and firewalls; a home network or tailnet address keeps the old sentence. homeHost in shell.js, the same test in error.html. English and Korean.
  • The probe's 5 s (PROBE_TIMEOUT_MS) is kept: it is per phase (connect, then each read, which covers the TLS handshake). Measured through the same proxy: a first TLS handshake took 0.7 s at 300 ms, 1.2 s at 800 ms and 2.4 s at 2000 ms round trip, and the whole /api/health 4.5 s at 10 KB/s and 2000 ms, inside both the 5 s per phase and the 10 s budget.

Fast failures stay fast (emulator, this PR)

Case Error page at Wording
Accepts the connection, never sends 23 s (20 s + the 1 s tick + load) did not answer within 20 seconds
Answers, but not Ovis 4.5 s This is not Ovis
Port closed 3.0 s refused the connection
PC off (unroutable address) 22 s did not answer within 20 seconds
Sends 2 KB of an unfinished header, then nothing (stands in for TLS then no response) 21.8 s accepted the connection but sent no page

Screens (emulator)

Before, 10 KB/s: the error page at 20 s while the page was arriving.

before

After, 10 KB/s at 8 s / stalled 20 s (Keep waiting, Try again) / after Keep waiting:

slow
stalled
keep waiting

A connection that never sends a page, the error page at 22 s:

connected, no page

No answer at all, still the error page at 20 s:

no answer

Progress bar (second commit)

Once the PC answers, the cover now stays until the client is up (its #root no longer holds index.html's loading spinner; a page with no root, such as a sign-in or the error page, lifts it as before). On a 10 KB/s link that spinner was otherwise the screen for about two minutes. The bar fills from the bytes received (TrafficStats) against the sizes of the page and the files it starts with: the page asks a HEAD of each (so with its own encoding and cookies), and when any of them gives no Content-Length (a proxy that streams compressed files) the bar is indeterminate. It never goes back and holds at 95 % until the client is up; the text says "409 KB of 1,227 KB", or "N KB so far" without a total. Time to a usable client is unchanged: 125.6 s at 10 KB/s, 27.0 s at 50 KB/s.

10 KB/s, 40 s
10 KB/s, 115 s, held below full
50 KB/s, 16 s
Korean
Korean, stalled

A second open (no change needed)

Nothing in the app clears the WebView's cache, and /assets/* is immutable for a year while index.html revalidates (no-cache). On main a second open at 10 KB/s is usable in 2.0 s once a first open has finished; at 10 KB/s main never let one finish (it stopped every load at 20 s), so nothing was ever cached. With this PR: first open 126 s, second open 1.6 s at 10 KB/s. Also measured: a new release (a new script hash in index.html) is fetched on the next open (1,152,733 bytes over the wire), the old release's file comes back from cache (transferSize 0), and a second PC on another origin downloads its own copy while the first PC still opens in 2.1 s. API answers are no-store or carry no validators, so no PC data is cached.

Note on the measurement: the first table above came from a throttling proxy that queued the whole link FIFO, which delays the first paint behind the script but does not change the time to a usable client (total bytes over the rate); the bar runs used a proxy that shares the link round-robin between connections.

Not in this PR

  • iOS: in its own PR (the cover with a bar, the same wording); iOS hides its cover at the first response, so it never cut a slow page off, but it showed a blank page and a spinner for minutes.

A store update is needed for users to get this (Android 1.0.1 is the build on Play); the owner plans it with the 0.5.4 release.

Validation

  • ./gradlew :app:testDebugUnitTest :app:assembleRelease at 1e4f266: passed (all unit-test files, 0 failures; release build successful).
  • bun run check fast: passed at the head (typecheck, build, unit tests 17261 pass / 0 fail) at the head.
  • ./gradlew :app:testDebugUnitTest :app:assembleDebug: passed; OpeningWatchTest 11/11, with the clock and byte counts injected (no sleeps).
  • bun test scripts/app-shell.test.ts: 12/12.
  • Manual on the emulator: the tables and screens above; at the head, 10 KB/s opened at 125 s again, and the no-answer, not-Ovis, connected-no-page and stalled cases were re-run. Korean copy not exercised on the emulator.

The cover gave up after 20 s of wall clock, so a slow link that was
answering (10-25 KB/s, or 50 KB/s at 2 s latency) got "Can't reach your
PC" mid-load. It now gives up only when nothing came back in 20 s,
says the connection is slow with the bytes so far, and offers Keep
waiting after 20 s with no new bytes (OpeningWatch, clock and byte
counts injected for its tests). A timeout to a public server no longer
blames a home network or the PC's firewall.
@coderabbitai

coderabbitai Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

Explain Change →

No actionable comments were generated in the recent review.

Summary

Summary by CodeRabbit

  • New Features
    • Android now distinguishes slow or stalled page loads from connections that receive no response. The opening screen shows the amount received during a slow transfer and offers Keep waiting if progress stops.
  • Bug Fixes
    • Android stops waiting and displays an error when no response arrives before the timeout.
    • Connection errors now provide guidance suited to public servers or home and tailnet addresses, rather than attributing every timeout to a home-network issue.
  • Documentation
    • Updated Android guidance explains slow-transfer behavior, timeouts, and approximate transfer sizes.

The Android app now tracks response and byte progress during page opening, reports slow or stalled transfers, and times out when no answer arrives. Web timeout guidance now distinguishes home-network and tailnet hosts from public hosts.

Changes: Connection feedback

Layer / File(s) Summary
Android opening progress and timeout
app/android/app/src/main/java/sh/ovis/app/OpeningWatch.java, app/android/app/src/main/java/sh/ovis/app/MainActivity.java, app/android/app/src/test/java/sh/ovis/app/OpeningWatchTest.java, docs/native-app.md, changelog.d/android-slow-link-keeps-opening.md
OpeningWatch tracks response and byte progress. MainActivity updates the opening cover, stops loads that receive no answer by the deadline, and offers “Keep waiting” after a stall. Tests cover the watch states and timeout cases. The documentation and changelog describe the updated behavior.
Host-specific web timeout guidance
app/web/shell.js, app/web/error.html, scripts/app-shell.test.ts
homeHost classifies local and tailnet hosts. Timeout messages and error-page explanations now differ for those hosts and public hosts. Tests cover host classification.

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

Change: Bug fix

Merge Risk: Minimal · up to c9c23

This change keeps a slow PC page opening instead of cutting it off after 20 seconds, and it shows progress and a Keep waiting option. It also gives clearer timeout guidance for public servers. No concrete merge-blocking problem was found; a store update is needed for users to receive it.

Pre-merge checks: Passed 4

Check name Status Explanation
Title check Passed The title names the main change: Android keeps an open PC page going while it arrives over a slow link. It matches the changeset, which adds OpeningWatch to track arrival and slow or stalled transfers…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description check Passed The description follows the template. It has a Change section that explains the problem, the resulting behavior, and the related follow-ups. It also has a Validation section with the commands run and …
Full details: Title check

Explanation

The title names the main change: Android keeps an open PC page going while it arrives over a slow link. It matches the changeset, which adds OpeningWatch to track arrival and slow or stalled transfers, and the changelog describing the same fix.

Full details: Description check

Explanation

The description follows the template. It has a Change section that explains the problem, the resulting behavior, and the related follow-ups. It also has a Validation section with the commands run and their results. It lists the iOS and store-update follow-ups as out of scope. It does not link an issue that a maintainer agreed to, which the template asks for on features. This is a fix, so the link is a minor gap, not a blocker.

Finishing Touches

Generate unit tests (beta)

  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Recent review info

Run configuration

Commits

Reviewing files that changed from the base of the PR and between 70e9ed7 and c9c23d1.

Files selected for processing: 8

  • app/android/app/src/main/java/sh/ovis/app/MainActivity.java
  • app/android/app/src/main/java/sh/ovis/app/OpeningWatch.java
  • app/android/app/src/test/java/sh/ovis/app/OpeningWatchTest.java
  • app/web/error.html
  • app/web/shell.js
  • changelog.d/android-slow-link-keeps-opening.md
  • docs/native-app.md
  • scripts/app-shell.test.ts

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

…are homeHost

Review of #3850: bytes alone (a TLS handshake) no longer count as the
PC's answer. Before the page commits, the open gives up once the limit
has passed with 5 s of quiet, and at 60 s in any case, with "accepted
the connection but sent no page". The stalled cover offers Try again,
the tick stops in the background, publicHttp reuses homeHost, and a test
keeps error.html's copy of homeHost in step with shell.js.
@devswha

devswha commented Nov 14, 2026

Copy link
Copy Markdown
Owner Author

Gate review at c9c23d1 (BLOCK), each finding and how 4b521fb resolves it:

  1. MEDIUM, a connection that completes TLS and never responds hung on the cover. Fixed: bytes no longer count as the answer. Before the page commits, the open gives up once the limit has passed with 5 s of quiet, and at 60 s in any case, with "accepted the connection but sent no page". The endless slow and stalled stages are only for an open whose page answered. Tests: aHandshakeWithNoResponseAfterItStillGivesUp, bytesWithoutAnAnswerNeverWaitPastTheCap; bytesStillComingBeforeTheAnswerPutTheLimitOff replaces the test that locked in the old rule. On the emulator, a server that sends 2 KB of an unfinished header and then nothing got the error page at 21.8 s. A self-signed TLS server could not stand in, as the WebView refuses its certificate at once. The stalled cover also has Try again now.
  2. LOW, TrafficStats counts the whole uid. Documented in OpeningWatch, and bounded: before an answer those bytes can only put the limit off, never past 60 s.
  3. LOW, three copies of the home-address ranges. publicHttp now calls homeHost. error.html keeps its standalone copy as a named function, and scripts/app-shell.test.ts evaluates it against homeHost on 25 addresses. One side effect: plain http to *.localhost no longer counts as public, which matches loopbackHost.
  4. LOW, the new timeout copy was untested. Added: openFailure(..., "timeout") names the home network for a LAN address, and names neither the home network nor a firewall for https://herdr.theworld.codes.
  5. LOW, the tick ran in the background. It stops in onStop and looks again at once in onStart, and it already ends when the cover goes or the error page loads.

Checks at 4b521fb: bun run check fast passed; ./gradlew :app:testDebugUnitTest :app:assembleDebug passed (OpeningWatchTest 9/9); bun test scripts/app-shell.test.ts 12/12.

@devswha

devswha commented Nov 14, 2026

Copy link
Copy Markdown
Owner Author

Gate review at 4b521fb: CLEAR. Its LOW notes are left as follow-ups rather than pushed here, which would restart the review and CI:

  • On a phone where TrafficStats is unsupported (-1), a page still loading slowly shows as stalled 20 s after its answer: no bytes are ever counted. Fix: never enter STALLED without a byte count, and test at answeredAt + STALL_MS.
  • "accepted the connection but sent no page" rests on the whole app's byte count, so other traffic over 1 KB during the wait can give that reason to a PC that is off. Softer wording, or only when no other check ran.
  • A slow sign-in page in front of the PC (the signingIn path) is not counted as the answer, so it is cut at 60 s (main cut it at 20 s).
  • Bytes received in the background are credited when the app returns to the front (bounded by QUIET_MS and the 60 s cap).
  • The onPageStarted -> answered() wiring and the onStart/onStop tick are covered by the emulator runs only.

…s up

Once the PC answers, the cover now stays over index.html's loading spinner
until the client replaces it, with a bar: the bytes received against the
sizes of the page and the files it starts with (a HEAD of each from the
page), indeterminate when a size is unknown, never going back and held at
95 percent until the client is up. The text says how much of the total
has come, in English and Korean.
…t it waits on

Merge-pass review of #3850: the cover is asked of the page every 200 ms
once it shows (it lifted up to a second late, fast and cached opens too);
a page with no root while it still parses is undecided, not "no client";
once the page has fully loaded a stall says the app did not start and
offers Try again, not Keep waiting; the page-size check leaves out the
query (a pairing code); a test keeps index.html's #root > .boot, which
the cover waits on; the timeout copy test no longer pins English prose.
@devswha

devswha commented Nov 14, 2026

Copy link
Copy Markdown
Owner Author

Merge-pass review at 39c26c6, resolved in 1e4f266:

  • MEDIUM, cover lifted up to ~1 s late: once the page shows, it is asked every 200 ms (pollPage, also on return to the front). Measured on the emulator: the cover lifts 271 ms (cold) and 394 ms (cached) after the client is up, those figures including the probe's own screenshot overhead.
  • MEDIUM, #root > .boot unpinned / Keep waiting after a full load: scripts/app-shell.test.ts now fails if index.html's root does not start with .boot. Once the PC's page has fully loaded (onPageLoaded), a stall says "The page from X arrived, but the app did not start." with Try again and Change PC, no Keep waiting.
  • Codex MEDIUM, missing root while parsing: client is false (undecided) while document.readyState is loading; only a parsed page without a root lifts the cover.
  • LOW, ?pair=CODE repeated: the size check HEADs location.origin + location.pathname.
  • LOW, prose pin: the timeout copy test now only asserts the home and public explanations differ.
  • LOW, off-PC wording on the app-wide byte count: left as noted in the code; bounded by the 60 s cap.

Checks at 1e4f266: bun run check fast passed (17262 pass / 0 fail); ./gradlew :app:testDebugUnitTest :app:assembleDebug passed (OpeningWatchTest 11/11); bun test scripts/app-shell.test.ts 13/13; on the emulator, the 10 KB/s bar (404 of 1,227 KB at 40 s) and the lift times above.

@devswha

devswha commented Nov 14, 2026

Copy link
Copy Markdown
Owner Author

Merge-pass leftovers at 1e4f266, not pushed (each push restarts CI and the reviews):

  • Codex MEDIUM, 60 s cap before an answer: deliberate. The uid byte count is app-wide, so bytes without the page's answer can only put the limit off, never past 60 s; this is what closed the earlier regression (a TLS-only server holding the cover). An answer that has not come in 60 s of trickle is a real failure for a first page, and the error page offers Try again. The 1,024-byte floor only applies before anything has arrived (TCP's own packets), not as a progress threshold afterwards.
  • LOW, answered only on onPageStarted: WebView fires it for every main-frame load that commits, before onPageCommitVisible, so a PC page that shows has already passed it; left as is.
  • LOW, no CI for the Android app: run here at this head: ./gradlew :app:testDebugUnitTest :app:assembleRelease passed (unit tests 0 failures, release build successful). Worth running again before the store build from the release commit.
  • LOW, PR text: updated (a summary of the behaviour at this head is at the top).

This branch has not been deployed

No deployments
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