Skip to content

feat: developer screen for diagnosing connection freezes - #467

Open
Ryanmello07 wants to merge 20 commits into
urnetwork:mainfrom
Ryanmello07:feat/vpn-reliability-dev-menu-upstream
Open

feat: developer screen for diagnosing connection freezes#467
Ryanmello07 wants to merge 20 commits into
urnetwork:mainfrom
Ryanmello07:feat/vpn-reliability-dev-menu-upstream

Conversation

@Ryanmello07

Copy link
Copy Markdown
Contributor

Adds a Developer screen with runtime reliability controls, failure injection, measurements, and an in-app probe suite.

This does not compile against main yet — it is up for review, not merging. It depends on:

  • urnetwork/sdk#133 — the probe suite (ProbeResult, ProbeSuiteConfig, StartProbeSuite)
  • urnetwork/connect#188 — the reliability metrics
  • The reliability controls (ReliabilitySettings, Exit, DropExit, StallExit), which live in a fork-only SDK file and have not been upstreamed. That is the real blocker; sdk#133 alone is not enough.

Why

VPN freezes here have been diagnosed by how long they felt in a browser. That confounds what the tunnel did with what the browser decided to do about it — a browser sits on its own backoff after a reset, so a working fix and a useless one produce the same number. Several fixes that were correct in isolation changed nothing in use, and nothing could tell them apart.

This screen makes a candidate falsifiable:

  • Toggles and timings take effect on the next packet, so a fix can be switched off and back on during a freeze without reconnecting and destroying the thing being observed. Every timing's "off" value reproduces the behaviour that shipped before the fix it controls.
  • Drop / Stall reproduce a single exit failing while the others keep working, which is what a real outage looks like — unlike Shuffle, which replaces everything at once.
  • Measurements report blast radius (connections lost per provider failure) and recovery time. "Never came back" sits beside recovery time so a change that abandons flows rather than recovering them cannot hide behind a better average.
  • Tests run real requests through the tunnel. The run to do first: start it, stall the exit carrying the flows, and see whether the next results fail in milliseconds or hang for the full timeout. Fast failures mean teardown reaches the client and the delay is browser backoff; hangs mean it does not.

Also makes multi-IP an explicit connect option so the streaming and web modes present the same UI.

Review notes

  • strings.xml is generated — the header says Generated by @urnetwork/localizations (gen/generate.mjs). DO NOT EDIT. The strings here were added by hand and would be clobbered by the next generation run. They need to go through the generator before this could merge; I did not want to guess at that pipeline.
  • Exit labels use takeLast(8) rather than take(8): client ids are ULIDs, and channels in a window open milliseconds apart, so a leading substring renders every row identical and makes Drop/Stall unusable.
  • The docs under docs/superpowers/ record the device measurements behind this, including hypotheses that were disproved (TLS, windowing, exit churn). Happy to drop them from the PR if they do not belong in-tree.

🤖 Generated with Claude Code

Ryanmello07 and others added 20 commits July 28, 2026 03:46
Records the read-only investigation into the multi-exit freeze reports
(DNS/websites hanging until reconnect or browser restart) and the plan
to fix it.

Five ranked causes, all read out of source and none observed at runtime:
UDP flows get no teardown signal (ipOosRst is TCP-only, so DNS and QUIC
blackhole silently where TCP would get a RST); TCP retransmits are
suppressed up to ~30s while a stalled-but-not-dead exit holds the flow;
per-site affinity degrades to per-IP whenever the DNS mux cannot observe
a query; affinity is computed once at flow creation, so a late hostname
splits a site across exits; and the 2-minute idle timeout is far more
aggressive than VPN norms.

Also records what the investigation ruled out -- providers call
DialContext and terminate TCP, so exits are split-TCP user-space NATs,
not packet forwarders. That means a TCP connection cannot survive an
exit change by design, and PMTUD blackholing is largely not the culprit
despite the absent MSS clamp.

Decisions: multi-IP becomes an orthogonal toggle so Web and Streaming
share one UI (Auto untouched); every fix ships behind a runtime toggle,
since the ranking is unverified reasoning and shipping five silent fixes
would leave us unable to tell which mattered; the developer section
ships in all builds rather than debug-only.

Phase 0 blocks the rest: no Go tests run in CI anywhere today and there
is no Go toolchain or JDK locally, so nothing in the data path can be
verified until that exists.

(cherry picked from commit 40e87b3)
Spreading across several exit IPs used to be implied by picking Web, with
a "Fixed IP" switch expressing the opposite. That framing made the two
modes look like they differed in IP behavior when they actually differ in
how providers are selected -- both can spread across exits, and both can
pin to one.

Inverts the switch to "Multiple IPs" so the choice is stated positively
and reads as its own option rather than a qualifier on Web. Web and
Streaming now present identically; Auto keeps the switch disabled because
it balances across both windows and ignores an explicit window size, so
there is nothing there for the switch to act on. Auto's own behavior is
unchanged.

The stored representation is untouched -- a window pinned to exactly one
is still the single-exit case -- so an existing profile reads back the
same way and no migration is needed.

Leaves the now-unused fixed_ip string in place: strings.xml is generated
upstream, and deleting an upstream key invites a conflict on the next
localizations regen for no benefit.

Verified locally: compileGithubDebugKotlin passes, strings.xml has no
duplicate keys and parses.

(cherry picked from commit 3002aac)
Six reliability fixes ship default-on, and the code cannot tell which one
matters for a given user's freeze -- the ranking behind them is reasoning,
not measurement. This is what turns that into something testable.

Each fix gets a switch that takes effect on the next packet rather than on
reconnect. That is the whole point: reconnecting to change a setting
destroys the freeze being investigated, so the toggles have to work while
it is happening. Every switch off restores exactly the behaviour that
shipped before that fix, and "Reset to shipped defaults" puts everything
back, so an experiment can always be undone.

The exit readout lists each provider channel with the flow count pinned to
it, which is how the affinity work becomes visible: a site split across
exits reads as flows spread over several rows instead of collected on one.

Drop kills a single exit and leaves the others working -- the failure the
teardown fixes actually address. Shuffle replaces all of them at once,
which is useful for forcing re-selection but looks nothing like a real
outage. Stall makes an exit swallow packets without acknowledging or
erroring, reproducing the neither-healthy-nor-dead state the tcp collapse
bound exists for, which otherwise only occurs when a provider misbehaves
at exactly the wrong moment.

A plain Settings section above Version info in every flavor, not a hidden
gesture and not BuildConfig.DEBUG-gated: these are meant to be reached
while a connection is misbehaving, and hunting for a secret tap target at
that moment is the wrong experience. Disconnected, the section says so
rather than showing defaults that are not in force.

Verified locally: all four flavors compile, strings.xml has 486 keys with
no duplicates, and no unescaped apostrophes.

(cherry picked from commit 5e612ae)
Device timings with curl phase breakdown reframe the problem. DNS is not
the bottleneck -- 1.5ms warm, 190ms cold. Exit selection is not either;
the warm pass is fast across all eight destinations. The cost is the TLS
handshake at ~510ms against a ~90ms tcp handshake, making every new
connection roughly 10x its off-vpn cost.

Records why that bucket is so large: providers are split-tcp, so
ConnectionState.SynAck answers locally and the provider does not dial the
real server until traffic arrives. The provider->server connect therefore
lands inside the TLS phase. ~510ms is that connect plus TLS round trips
across two legs, not a protocol defect.

Also records a hypothesis that did not survive checking -- that the
transport pays a round trip per packet instead of windowing, which would
have explained the multi-packet certificate flight. transfer.go has an ack
window, a 32-deep pack buffer and a 256KiB in-flight floor. Written down
so the same guess is not made again.

Phase 6 lists four candidates, each to be toggled and measured rather than
assumed: tls resumption across exits, provider-side connection reuse,
destination-aware exit selection, and racing first contact.

Separately, github.com cold showed a normal handshake followed by a 5.5s
stall in the response body. That is the freeze, caught in a measurement
for the first time, and it belongs to the Phase 1 work rather than here.

(cherry picked from commit 30ab292)
Running the same hosts over plain http and https settles it. Http, with
no handshake at all, pays the same ~600ms between tcp connect and first
byte that https spends in its tls window; the request after the handshake
costs only ~120ms. Tls is not expensive -- it just happens to occupy the
window where the provider dials the destination and relays the first
exchange.

Client↔provider is healthy at ~120ms. The ~600ms sits on the
provider→server leg, roughly three round trips, which is more than a bare
tcp connect should need.

Reorders phase 6 accordingly: provider-side connection reuse and
destination-aware exit selection first, plus a new item to check whether
contract setup or policy work delays the provider's dial. Tls session
resumption is struck out.

Also records that `openssl s_client -reconnect` reports New for every
connection under tls 1.3 regardless of whether resumption worked -- the
ticket arrives after the handshake and -reconnect does not wait for it.
Noted so the false negative is not treated as a finding later.

(cherry picked from commit 8ea0429)
Three sequential connections to the same destination held ttfb-conn flat
at 559 / 571 / 521 ms, while conn itself fell from 213ms to 100ms as the
client↔provider path settled. The provider→server leg is not reused
between flows at all -- every new flow pays the full dial.

That confirms provider-side connection pooling as the lever, from
measurement rather than inference.

Promotes the pre-dial overhead check ahead of building the pool. ~550ms
is far more than a dial to an anycast host should cost, and pooling would
mask that overhead rather than remove it.

(cherry picked from commit 0fba286)
Device testing showed the collapse-hold toggle made no observable
difference to a stalled exit: with it on or off, the browser stayed frozen
until it was refreshed. Releasing a retransmit hands it to the same exit
that is swallowing everything, so nothing changes -- recovery needs the
exit removed from the window, and nothing classified it as failed inside
the 30s ack timeout.

This is the control for the fix that does that. On, an exit holding
unacknowledged sends for 3s is dropped and its flows re-race onto a
working one. Off restores the previous behaviour exactly, so the Stall
button can be used to A/B it against a live freeze.

Verified locally: all four flavors compile against the new SDK binding,
strings.xml has 488 keys with no duplicates and parses.

(cherry picked from commit 9a2cc87)
Two changes driven by testing.

The timing controls are values now, not switches. How long to wait before
giving up on an exit trades recovery speed against dropping one that was
slow but alive, and that balance differs per connection -- so it wants
tuning per user rather than a number guessed once. Send stall, tcp collapse
hold, and both idle timeouts each cycle through presets on tap. The first
preset of every list is 0, which reproduces the behaviour that shipped
before the fix it controls, so each can still be A/B'd against a live
freeze.

The tools also move out of the bottom of Settings onto their own screen,
reached from a Developer row. They act on the live connection while
something is going wrong, and the set is expected to grow; burying them
under everything else was already awkward at seven controls.

Also merges the leaderboard duplicate-key fix, which was sitting unmerged
in its own PR -- so the crash was still present in every build tested so
far.

Verified locally: all four flavors compile, strings.xml has 493 keys with
no duplicates and parses.

(cherry picked from commit a44775c)
With a full window every row rendered as the same string. Client ids are
ULIDs, so their leading characters encode creation time, and the channels
in a window open within milliseconds of each other -- a leading substring
throws away exactly the part that differs. Six exits all read "019fa806",
which makes Drop and Stall unusable since you cannot tell which one you
are acting on.

Uses the tail, where the random component is.

(cherry picked from commit b02e3ec)
Measurements are placed first because they are what the rest of the screen is
for. Blast radius and recovery time make a candidate falsifiable: one that
moves neither did not work, however good the reasoning behind it was. "Never
came back" is shown next to recovery time so a change that abandons flows
instead of recovering them cannot hide behind a better average.

The test suite runs probes through the live tunnel. The run to do first is to
start it, stall the exit carrying the flows, and watch whether the next
results fail in milliseconds or hang for the full timeout -- fast failures
mean teardown is reaching the client and the delay is browser backoff, hangs
mean it is not. Probe failure text is shown verbatim for that reason.

Results poll during a run rather than only at the end, since the interesting
moment is the one right after an exit is stalled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 7d7a34e)
These are build artifacts committed on the fork branch for device testing.
They are not part of the change and have no place upstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gomobile only emits a Kotlin property for Go methods named Get*, so
GetProbeResults reads as .probeResults but ProbeSuiteRunning does not --
it stays a call. The other bindings take arguments or return void, so this
was the only site that got it wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 736fb2f)
Every probe reported a 15s timeout regardless of the tunnel's health, so the
control could only mislead. Two harness bugs, neither in the data path: the
dns probe used Go's pure-go resolver, which has no server list on android and
falls back to [::1]:53, and the http probes resolve through a tun built
without resolver settings.

The measurements above are unaffected -- they are collected from real traffic
rather than from probes, and remain the way a candidate fix is judged.

The Go probe suite is left in the sdk to be fixed rather than deleted, but
nothing reaches it from the app until it measures something real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit e5d8ed6)
The previous commit removed the probe block by slicing from its opening
comment to resetReliability, which swallowed the toggle and timing setters
sitting between them. Only the probe members should have gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit fa7fea5)
The counters change while the screen is up -- a stalled exit takes seconds to
be detected and removed -- and the actions here refreshed immediately, which
captured the state before anything had happened. During device testing a
stale zero read as "no provider failures" rather than "nothing measured yet"
and was nearly taken as evidence that detection was broken.

Also records Phase 8: the mainnet stall/drop results, the stall-detection bug
they exposed, and the pre-existing false positive found reviewing its fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 336867e)
The bound responsible for the churn: how long a provider still acknowledging
our sends may return nothing before being dropped. Off leaves only the
unambiguous signal, a provider that acknowledges nothing at all, and is the
comparison point for how much of the churn this branch accounts for.

Nothing above ~30s is offered. The bound is compared against an age derived
from stat buckets that are dropped once past the 30s stats window, so a larger
value never fires and is silently identical to off -- it would present as more
grace while measuring nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 7a03858)
Losing an exit kills every connection pinned to it. In testing, four removals
out of 25 accounted for 92% of all connection loss, the worst taking out 484
at once -- a 35s stall and a laggy recovery. Removal rate was never the thing
being felt; concentration was.

Unlimited restores the shipped behaviour and is the comparison point. Lower
values cut the worst event further at the cost of splitting more sites across
exits, so a site sees more than one egress IP -- which is why this is a knob
rather than a fixed value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 931750c)
The default moved to 16 once recovery times were measured against teardown
sizes; the comment still said 64. Records why the higher presets exist: they
barely move the common case (median teardown was 36 flows) and are there to
A/B the affinity cost rather than as serious candidates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit bfebe44)
beta-104 to beta-106: removals 1.92/min to 0.40/min, median gap 18.5s to
60.5s, over a 128 minute session. Reasons are now attributable: 41
no-receive-ack, 10 no-receive-syn -- the earlier claim that all removals came
from the receive branch was inference, and the syn branch is about a fifth.

Records the teardown size distribution against reported stall durations, which
is what set the flow cap at 16 rather than 64: the median teardown was 36
flows, so a permissive bound would not have touched any of the 15s stalls.

Also records a 68s stall against a 2-flow teardown that none of this explains,
with the two leads that came out of triage -- the tunnel dropping all ICMP,
and the window running below target more often than at it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 879d3c6)
The cap holds on device: teardowns 13-16, never above the bound, on both the
affinity and race paths. Worst single failure 484 -> 16, blast radius 3.9.

Recovery time barely moved while blast radius fell 30x, which rules out
connection rebuild as the cost. 56% of traffic is QUIC, and RFC 9000 requires
QUIC endpoints not to terminate on ICMP -- so UdpTeardownSignal is correctly
built and structurally inert for the majority of flows. The corollary is
better: QUIC keys on Connection ID rather than the 4-tuple, so those flows can
survive an exit dying, and the re-race mechanism for that already exists.

Records the suspicion that the blackhole fix partly undid itself: raising the
receive bound 5s -> 20s cut removals 78% but quadrupled how long a flow
black-holes before rescue, and QUIC's PTO doubles per loss. The recovery clock
starts at removal, so that period is invisible in the metric and felt by the
user. Includes the A/B to settle it, which needs no new build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit af7dd07)
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