Repository navigation
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
Not sure
Steps to reproduce
- Check out the commit below with dependencies installed (
vp i). - Create
packages/client-runtime/src/connection/repro-route-backoff.test.tsfrom lines 1-308 ofpackages/client-runtime/src/connection/supervisor.test.ts(its imports, fixtures, andmakeHarness, which drives the realEnvironmentSupervisorwith a fake route check, fake sessions, and a controllable wakeup stream), then append the block shown under Relevant output or evidence. - Run
vp test run --silent=false packages/client-runtime/src/connection/repro-route-backoff.test.tsfrom the repository root and read the JSON lines it prints. - Delete the test file.
Deterministic: the test uses the virtual test clock and printed the same result on two runs. A real device was not exercised.
Expected behavior
docs/user/remote-access.md:134-136: "While connected over a later route, T3 Code checks the earlier ones when your network changes, when you return to the app, and every minute, and moves back as soon as one works." Returning to the app after turning Tailscale on should check the Tailscale route and move to it within seconds, as the network-changed case already does. The code's own intent agrees: every app-return wakeup calls requestBetterRouteCheck (supervisor.ts:767-772). PR #27101, which added the backoff, says "a returning route is picked up immediately" after a network change; turning Tailscale on does not reliably produce one of the events that count as a network change here (see Additional context).
Actual behavior
In packages/client-runtime (shared by web, desktop, and mobile), while an environment is connected over T3 Connect and its earlier Tailscale route does not answer (for example because Tailscale is off on this device), the supervisor checks that route at 1, 3, 7, 15, and 31 minutes, then every 30 minutes. When Tailscale then comes up and the user returns to the app, nothing rechecks the route: the web/desktop application-active wakeup and the mobile application-active-probe wakeup each ran zero route checks in the 5 seconds after the wakeup, and the client stayed on T3 Connect until the backoff expired at minute 61, 26 minutes after the route started answering at minute 35. A network-changed wakeup at the same point checked the route at once and moved to it at minute 36. The supervisor does request a better-route check on every app-return wakeup (supervisor.ts:767-772), but checkBetterRoutes filters out routes still inside their silent backoff (supervisor.ts:392-397), and only network-changed, application-active-long, and an online/offline transition clear that backoff (supervisor.ts:1242-1268).
Impact and scope: Impact: P2 - The client keeps using T3 Connect for up to 30 minutes after a faster Tailscale route becomes reachable, and returning to the app, the documented trigger, does not help. Reloading the web or desktop window, or restarting the mobile app, should work around it because it creates a new supervisor with an empty backoff table (source-based, not run); disconnecting and reconnecting the environment in place does not clear the table, which only forgetSilentRoutes resets.
Affected: environments with a Tailscale route (or any direct route) ranked ahead of T3 Connect, after that route has been unreachable for a while during a T3 Connect session; the delay grows with how long it was unreachable, up to the 30-minute cap (SILENT_ROUTE_BACKOFF_MAX_MS, supervisor.ts:71). A common trigger is starting a session with Tailscale off on the client, or with Tailscale still connecting, and turning it on later. Web and desktop are affected on every return: they emit only application-active when the page becomes visible (apps/web/src/connection/platform.ts:125-143). Mobile is affected when the app returns after less than 10 s in the background, or from the inactive state without backgrounding, which emits application-active-probe; a return after 10 s or more emits application-active-long, which does clear the backoff (apps/mobile/src/connection/platform.ts:119-140, apps/mobile/src/connection/app-state-wakeups.ts:3-18).
Impact
Minor bug or occasional failure
Version or commit
main @ 86ff9d0
Environment
Linux x86_64, Node.js v26.13.1, Vite+ test runner (Vitest 5.0.5) as pinned by the repository. Shared client runtime, so the behavior is not platform-specific.
Logs or stack traces
Block appended to the copied harness (it uses TARGET, RELAY_TARGET, preparedFor, awaitState, and makeHarness from those lines):
const TAILNET_TARGET = new BearerConnectionTarget({
environmentId: TARGET.environmentId,
label: TARGET.label,
connectionId: "bearer:tailnet",
});
// A saved Tailscale HTTPS route, then T3 Connect.
const TAILNET_THEN_RELAY_ENTRY: ConnectionCatalogEntry = {
target: TAILNET_TARGET,
profile: Option.some(
new BearerConnectionProfile({
connectionId: TAILNET_TARGET.connectionId,
environmentId: TARGET.environmentId,
label: TARGET.label,
httpBaseUrl: "https://host-a.tailnet-name.ts.net/",
wsBaseUrl: "wss://host-a.tailnet-name.ts.net/",
}),
),
alternateRoutes: [{ target: RELAY_TARGET, profile: Option.none() }],
enabled: true,
};
const activeRoute = (supervisor: EnvironmentSupervisor.EnvironmentSupervisor["Service"]) =>
Effect.map(SubscriptionRef.get(supervisor.prepared), (prepared) =>
Option.match(prepared, { onNone: () => "none", onSome: (value) => value.target._tag }),
);
describe("repro: backed-off Tailscale route", () => {
it.effect.each(["application-active", "application-active-probe", "network-changed"] as const)(
"Tailscale starts answering at minute 35, then wakeup %s",
(reason) =>
Effect.gen(function* () {
const reachable = yield* Ref.make(false);
const checks = yield* Ref.make(0);
const harness = yield* makeHarness({
checkRoute: (route) =>
route.target._tag === "BearerConnectionTarget"
? Ref.update(checks, (n) => n + 1).pipe(
Effect.andThen(Ref.get(reachable)),
Effect.map((up) => (up ? "answered" : "silent")),
)
: Effect.succeed("unchecked"),
prepare: (_attempt, target) => Effect.succeed(preparedFor(target)),
});
const supervisor = yield* EnvironmentSupervisor.make(TAILNET_THEN_RELAY_ENTRY, {
initiallyDesired: true,
}).pipe(Effect.provide(harness.dependencies));
yield* awaitState(supervisor.state, (s) => s.phase === "connected" && s.generation === 1);
const startedOn = yield* activeRoute(supervisor);
yield* Ref.set(checks, 0);
const checkedAtMinutes: Array<number> = [];
let seen = 0;
for (let minute = 1; minute <= 35; minute += 1) {
yield* TestClock.adjust("60 seconds");
const now = yield* Ref.get(checks);
if (now !== seen) (checkedAtMinutes.push(minute), (seen = now));
}
yield* Ref.set(reachable, true); // Tailscale comes up on this device
yield* harness.wake(reason); // the user returns to the app
yield* TestClock.adjust("5 seconds");
const checksWithin5s = (yield* Ref.get(checks)) - seen;
let movedAtMinute: number | null = null;
for (let minute = 36; minute <= 70 && movedAtMinute === null; minute += 1) {
yield* TestClock.adjust("60 seconds");
if ((yield* activeRoute(supervisor)) === "BearerConnectionTarget") movedAtMinute = minute;
}
console.log(JSON.stringify({ wakeup: reason, startedOn, checkedAtMinutes, checksWithin5s, movedAtMinute }));
}),
);
});Observed output (RelayConnectionTarget is T3 Connect, BearerConnectionTarget is the Tailscale route):
{"wakeup":"application-active","startedOn":"RelayConnectionTarget","checkedAtMinutes":[1,3,7,15,31],"checksWithin5s":0,"movedAtMinute":61}
{"wakeup":"application-active-probe","startedOn":"RelayConnectionTarget","checkedAtMinutes":[1,3,7,15,31],"checksWithin5s":0,"movedAtMinute":61}
{"wakeup":"network-changed","startedOn":"RelayConnectionTarget","checkedAtMinutes":[1,3,7,15,31],"checksWithin5s":1,"movedAtMinute":36}
Source references at the commit above: packages/client-runtime/src/connection/supervisor.ts:67-71 (60 s check interval, 30-minute backoff cap), supervisor.ts:161-168 (backOffSilentRoute), supervisor.ts:392-397 (routes inside their backoff are skipped), supervisor.ts:767-772 (app-return wakeups request a check), supervisor.ts:1242-1268 (only an online/offline transition, network-changed, or application-active-long call forgetSilentRoutes), and packages/client-runtime/src/connection/wakeups.ts:20-22.
Screenshots, recordings, or supporting files
No response
Workaround
See Impact and scope under Actual behavior.
Additional details
- Interface: Library:
EnvironmentSupervisorinpackages/client-runtime, consumed by the web, desktop, and mobile connection catalogs. - Earliest failure stage: Core processing
Analysis or suspected cause
Source-based: the check that app-return wakeups request runs, but against the same backoff table, which only network-change signals clear, so the request is a no-op for any route in backoff. The existing test "checks a dead LAN route less often until the network changes" (supervisor.test.ts) covers the backoff and the network-change reset but not the app-return path the user docs promise.
Additional context
Whether a platform reports Tailscale connecting as a network change is NOT_ENOUGH_INFO and was not tested on a device: on web and desktop, network-changed comes only from navigator.connection.type changing and is skipped when the browser reports no type (apps/web/src/connection/platform.ts:99-121), and the online event fires only after an offline period; on mobile it fires only when the reported network type (Wi-Fi, cellular, and so on) changes (apps/mobile/src/connection/platform.ts:86-117). A device trace of the wakeup reasons emitted when Tailscale is switched on would settle it. Even where such a signal does fire, the documented app-return recheck does not happen.
References
#33484 (open; client never upgrades from T3 Connect to a Tailscale route because its preflight never reports ready; here the preflight is never run at all while the route is backed off), #27101 (merged; introduced the 2 to 30 minute backoff and states that a network change resets it)
Found by reading T3 Code's Tailscale integration against the Tailscale source (tailscale/tailscale@071bf947, v1.105.0); written up with AI assistance (Claude Code).
Activity
juliusmarminge commentedon Nov 12, 2026
I tried to reproduce this on the current build and could not.
On origin/main fc2b5de I added a parameterized test to packages/client-runtime/src/connection/supervisor.test.ts, next to 'checks a dead LAN route less often until the network changes'. It drives the real EnvironmentSupervisor through the file's harness with LAN_THEN_RELAY_ENTRY and a direct route that never answers, so the supervisor connects over T3 Connect. The route is silent at minutes 1, 3 and 7, so the next periodic check is not due until minute 15. At minute 8 the test sends an 'application-active' or 'application-active-probe' wakeup, advances the TestClock 5 s, and expects one check of the direct route. Command:
vp test run packages/client-runtime/src/connection/supervisor.test.ts -t "backed-off route|dead LAN route less often". On main both new cases fail with 'expected +0 to be 1' and the existing network-changed case passes. With a temporary edit that calls forgetSilentRoutes on every wakeup, all three pass; I reverted that edit. Not run in a live app: that needs a real T3 Connect session, which is not available on the VMs, so this is the shared client-runtime supervisor under its own test harness, not a device run.Closing as not planned. If it still happens for you, reply with the exact steps and the version you are on and it will be looked at again.