Skip to content

android: don't let repeated login taps cancel each other; surface failures - #895

Open
denisulmer wants to merge 1 commit into
tailscale:mainfrom
denisulmer:fix/login-button-race-18304
Open

denisulmer wants to merge 1 commit into
tailscale:mainfrom
denisulmer:fix/login-button-race-18304

Conversation

@denisulmer

Copy link
Copy Markdown

Updates tailscale/tailscale#18304

Problem

Since late 2025 many users report that tapping Log in in the Android app does nothing (#18304, 45+ comments, Pixel/Samsung/OnePlus/Android TV/ChromeOS, Android 8–17, app 1.90.9 through 1.98.8). #746 and #747 (shipped in 1.96.0) fixed one race, but reports continue on 1.96.4 (1, 2, 3) and 1.98.8 (4).

Workarounds that users report as working all point at timing or retry problems rather than one specific browser: tapping repeatedly (jahinzee, ChenJiasen-123), force-stopping or rebooting (mwdle), connect/disconnect loops (Walsh-Colin), and the login page showing up late (Jebble). Users behind DPI firewalls also see the button "do nothing" because errors are never shown (erikGav).

Root cause

  1. Repeated login requests cancel each other. Every IpnViewModel.login() runs editPrefs() → start() → startLoginInteractive(). In the backend, Start() calls resetControlClientLocked() (ipn/ipnlocal/local.go), which cancels the in-flight register request and clears authURL. A second tap (Log in, Reauthenticate, add account, or a concurrent start) before the URL arrives therefore cancels the request that would have produced it. That is the control: TryLogin: register request: context canceled in the logs posted on the issue. android: fix silent login failure and double browser #746 removed one source of this but not repeated login() calls.
  2. Failures were silent. MainView calls viewModel.login() with the default no-op completion handler. editPrefs/start/startLoginInteractive errors and browser-launch errors were only logged, and there was no timeout if no BrowseToURL arrived.
  3. Identical URLs were dropped. Notifier.browseToURL is a StateFlow that was only cleared after a successful login. StartLoginInteractive re-sends the cached URL when it is still valid; that identical value was conflated by the StateFlow, so no browser opened.
  4. The browser was launched from the background. The browseToURL collector in MainActivity.init ran regardless of lifecycle. A URL that arrived while the activity was stopped (slow network, VPN dialog, user switched away) tried startActivity from the background, which Android 10+ blocks silently.

Changes

  • IpnViewModel.login(): a login phase (IDLE / STARTING / AWAITING_URL) shared across view models. A plain interactive login requested while one is in progress no longer calls editPrefs()/start(). It is ignored while start() is in flight, and after that it only calls startLoginInteractive() to re-request the URL. Auth-key and custom-control-URL logins keep their existing behaviour.
  • Login watchdog (application scope): if neither a BrowseToURL nor a transition to Running/NeedsMachineAuth arrives within 30s, the phase is reset (so the next tap restarts cleanly) and a toast explains it. Failures in the interactive login chain also show a toast. Auth-key and custom-URL screens keep their existing error dialogs.
  • Notifier: adds a browseToURLCount that increments on every BrowseToURL notification, so repeated identical URLs are still observable after the URL has been consumed.
  • MainActivity: the browseToURL collector uses repeatOnLifecycle(STARTED) and clears the URL after launching the browser (not on Android TV, where the QR view still uses it). A toast is shown when neither Custom Tabs nor a plain ACTION_VIEW can open the URL.
  • Three new strings: login_failed, login_timeout, login_no_browser.

Testing

  • make tailscale-debug.apk: builds (JDK 21, SDK platform 36, build-tools 36.0.0, NDK 23.1.7779620, Linux x86_64).
  • make fmt-check (ktfmt): passes.
  • make test (JVM unit tests): passes.
  • make release-lint (lintVitalRelease): passes.
  • Not yet tested on a device or emulator. Manual checks still needed:
    • Fresh install: Log in → browser opens.
    • Tap Log in several times quickly → a single login completes.
    • Return from the browser without finishing, tap again → the same URL reopens.
    • Airplane mode / blocked control server → toast after about 30s.
    • Android TV QR flow.
    • Reauthenticate and add account from the account switcher.

Limitations / follow-ups

  • No new unit tests. IpnViewModel depends on App.get() and the LocalAPI, and there is no existing test harness for the login flow.
  • The phase is kept in process memory, which is fine because the backend lives in the same process.
  • On TV the QR code is briefly blank when login restarts.
  • This does not change the foreground-service-before-login behaviour from android: start IPNService foreground before browser opens in login #747. startForegroundForLogin() uses the systemExempted type before VPN consent and may fail (only logged) on Android 14+. That is worth a separate look.
  • Users whose network blocks the control server will still fail to log in, but now they get a message.

…lures

Tapping "Log in" (or "Reauthenticate") could appear to do nothing:

-Every IpnViewModel.login() call runs editPrefs() + start(). In the Go
backend, start() calls resetControlClientLocked(), which cancels the
in-flight register request and clears the cached auth URL. Repeated
taps (or a tap racing another start) therefore keep cancelling the
request that would produce the BrowseToURL, which is the
"TryLogin: register request: context canceled" seen in the logs.
-Failures in the login chain were only logged; there was no feedback
and no timeout when no auth URL arrived.
-Notifier.browseToURL is a StateFlow that was only cleared after a
successful login, so the backend re-sending the same URL (which
StartLoginInteractive does for a still-valid URL) was conflated away and
no browser opened.
-The browseToURL collector ran regardless of lifecycle, so a URL that
arrived while the app was in the background tried to start the browser
from the background, which Android silently blocks.

This change:
-Tracks the login phase across view models. A plain interactive login
requested while one is in progress no longer calls editPrefs()/start():
it is ignored while start() is in flight, and afterwards only calls
startLoginInteractive() to re-request the URL.
-Arms a watchdog: if neither an auth URL nor a completed login is seen
within 30s, the login is reset and the user is told. Failures of the
interactive login chain are shown as a toast.
-Collects browseToURL only while MainActivity is STARTED and consumes
the URL after launching the browser, so the same URL can be delivered
again and is not reopened on activity restart. A counter in Notifier
lets the watchdog see repeated URLs.
-Shows a message when no browser can open the login URL.

Updates tailscale/tailscale#18304

Signed-off-by: Denis Ulmer <22558132+denisulmer@users.noreply.github.com>
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