authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-24 16:47:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-24 16:47:14-07:00
logd4a33457cb0da797728f4846a9da8592d7d81d36
treeadfc8025448382983e1da3dcfe264b7fbc3f88b3
parent9a91177c3ecadaaa088d00e467491de4be9d7ce3
signature Signed by PGP key B5690EEEBB952194

fix(desktop): desktop updates reconnect in seconds, not minutes (#12006)

Desktop updates deleted the managed tunnel at shutdown, so remote clients spent about two minutes reconnecting after an 11 second restart. The desktop app now writes a restart marker before it stops its backends for any install, local or remote. The server keeps the tunnel when it finds a fresh marker at shutdown and consumes it. Failed or interrupted installs delete the marker. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

6 files changed, 191 insertions(+), 9 deletions(-)

apps/desktop/src/updates/DesktopUpdates.test.ts+53
......@@ -1,4 +1,5 @@
11import { assert, describe, it } from "@effect/vitest";
2import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts";
23import * as Cause from "effect/Cause";
34import * as Deferred from "effect/Deferred";
45import * as Duration from "effect/Duration";
......@@ -14,6 +15,7 @@ import * as TestClock from "effect/testing/TestClock";
1415
1516import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
1617import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
18import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1719import * as DesktopState from "../app/DesktopState.ts";
1820import * as DesktopUpdates from "./DesktopUpdates.ts";
1921import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts";
......@@ -559,6 +561,55 @@ describe("DesktopUpdates", () => {
559561 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
560562 });
561563
564 it.effect("marks the backend stop for an install as an update restart", () => {
565 let markersAtStop: ReadonlyArray<string> = [];
566 const harness = makeHarness({
567 stopBackend: Effect.sync(() => {
568 markersAtStop = [...harness.updateRestartMarkers];
569 }),
570 });
571
572 return Effect.scoped(
573 Effect.gen(function* () {
574 const environment = yield* DesktopEnvironment.DesktopEnvironment;
575 const updates = yield* DesktopUpdates.DesktopUpdates;
576 yield* updates.configure;
577 harness.emit("update-downloaded", { version: "1.2.4" });
578 yield* flushCallbacks;
579
580 assert.isTrue((yield* updates.install).accepted);
581 assert.deepEqual(markersAtStop, [
582 environment.path.join(environment.baseDir, "runtime", DESKTOP_UPDATE_RESTART_MARKER_FILE),
583 ]);
584 }),
585 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
586 });
587
588 it.effect("drops the update restart marker when an install is interrupted", () =>
589 Effect.gen(function* () {
590 const stopping = yield* Deferred.make<void>();
591 const harness = makeHarness({
592 stopBackend: Deferred.succeed(stopping, undefined).pipe(Effect.andThen(Effect.never)),
593 });
594
595 yield* Effect.scoped(
596 Effect.gen(function* () {
597 const updates = yield* DesktopUpdates.DesktopUpdates;
598 yield* updates.configure;
599 harness.emit("update-downloaded", { version: "1.2.4" });
600 yield* flushCallbacks;
601
602 const installFiber = yield* updates.install.pipe(Effect.forkScoped);
603 yield* Deferred.await(stopping);
604 assert.equal(harness.updateRestartMarkers.size, 1);
605
606 yield* Fiber.interrupt(installFiber);
607 assert.equal(harness.updateRestartMarkers.size, 0);
608 }),
609 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
610 }),
611 );
612
562613 it.effect("keeps windows and restarts backends when quitAndInstall fails", () => {
563614 const harness = makeHarness({
564615 quitAndInstall: Effect.fail(
......@@ -583,6 +634,8 @@ describe("DesktopUpdates", () => {
583634 assert.isTrue(result.accepted);
584635 assert.isFalse(yield* Ref.get(desktopState.quitting));
585636 assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]);
637 // The restarted old backend must release its tunnel on a later quit.
638 assert.equal(harness.updateRestartMarkers.size, 0);
586639 }),
587640 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
588641 });
apps/desktop/src/updates/DesktopUpdates.ts+31-1
......@@ -1,4 +1,5 @@
11import {
2 DESKTOP_UPDATE_RESTART_MARKER_FILE,
23 DesktopUpdateChannelSchema,
34 type DesktopRuntimeInfo,
45 type DesktopUpdateActionResult,
......@@ -501,8 +502,35 @@ export const make = Effect.gen(function* () {
501502 );
502503 }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate"));
503504
505 // Tells the primary backend that the coming stop is an update restart, so it
506 // keeps its managed tunnel for the backend the updated app starts. Best
507 // effort: without the marker the backend only re-provisions its tunnel.
508 const updateRestartMarkerDir = environment.path.join(environment.baseDir, "runtime");
509 const updateRestartMarkerPath = environment.path.join(
510 updateRestartMarkerDir,
511 DESKTOP_UPDATE_RESTART_MARKER_FILE,
512 );
513 const writeUpdateRestartMarker = fileSystem
514 .makeDirectory(updateRestartMarkerDir, { recursive: true })
515 .pipe(
516 Effect.andThen(fileSystem.writeFileString(updateRestartMarkerPath, "")),
517 Effect.catch((error) =>
518 logUpdaterWarning("Could not write the update restart marker.", { errorTag: error._tag }),
519 ),
520 );
521
522 // A failed or interrupted install brings no updated backend, so a later
523 // quit must release the tunnel.
524 const removeUpdateRestartMarker = fileSystem
525 .remove(updateRestartMarkerPath, { force: true })
526 .pipe(Effect.ignore);
527
504528 const resetInstallAction = Effect.all(
505 [finishUpdateAction("install"), Ref.set(desktopState.quitting, false)],
529 [
530 finishUpdateAction("install"),
531 Ref.set(desktopState.quitting, false),
532 removeUpdateRestartMarker,
533 ],
506534 { discard: true },
507535 );
508536
......@@ -517,6 +545,7 @@ export const make = Effect.gen(function* () {
517545 if (!ownsRecovery) return;
518546
519547 yield* Ref.set(desktopState.quitting, false);
548 yield* removeUpdateRestartMarker;
520549 yield* Effect.gen(function* () {
521550 const instances = yield* pool.list;
522551 const restartExit = yield* Effect.forEach(instances, (instance) => instance.start, {
......@@ -586,6 +615,7 @@ export const make = Effect.gen(function* () {
586615 yield* Ref.set(desktopState.quitting, true);
587616
588617 return yield* Effect.gen(function* () {
618 yield* writeUpdateRestartMarker;
589619 // Stop every backend in the pool, not just the primary. With
590620 // parallel WSL + Windows backends, leaving the WSL instance up
591621 // means quitAndInstall's app.quit() exits before the pool's
apps/desktop/src/updates/updatesTestHarness.ts+18
......@@ -1,6 +1,7 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import type { DesktopUpdateState } from "@t3tools/contracts";
33import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
45import * as Layer from "effect/Layer";
56import * as Option from "effect/Option";
67
......@@ -203,7 +204,23 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
203204 } satisfies DesktopAppSettings.DesktopAppSettings["Service"])
204205 : DesktopAppSettings.layer;
205206
207 // Tracks the restart markers installs leave, so installs stay free of real
208 // disk I/O that would outrun the tests' settle loops.
209 const updateRestartMarkers = new Set<string>();
210 const fileSystemLayer = FileSystem.layerNoop({
211 makeDirectory: () => Effect.void,
212 writeFileString: (path) =>
213 Effect.sync(() => {
214 updateRestartMarkers.add(path);
215 }),
216 remove: (path) =>
217 Effect.sync(() => {
218 updateRestartMarkers.delete(path);
219 }),
220 });
221
206222 const layer = DesktopUpdates.layer.pipe(
223 Layer.provide(fileSystemLayer),
207224 Layer.provideMerge(updaterLayer),
208225 Layer.provideMerge(windowLayer),
209226 Layer.provideMerge(backendLayer),
......@@ -226,6 +243,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
226243 checkCount: () => checkCount,
227244 quitAndInstalls: () => quitAndInstallCount,
228245 installSteps,
246 updateRestartMarkers,
229247 downloadCount: () => downloadCount,
230248 feedUrls: () => feedUrls,
231249 fullChangelog: () => fullChangelog,
apps/server/src/cloud/http.test.ts+49-1
......@@ -1,11 +1,13 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { describe, expect, it } from "@effect/vitest";
3import * as Duration from "effect/Duration";
34import * as Effect from "effect/Effect";
45import * as FileSystem from "effect/FileSystem";
56import * as Layer from "effect/Layer";
67import * as Option from "effect/Option";
78import * as Path from "effect/Path";
89import * as PlatformError from "effect/PlatformError";
10import * as TestClock from "effect/testing/TestClock";
911import * as Tracer from "effect/Tracer";
1012import {
1113 HttpClient,
......@@ -14,7 +16,7 @@ import {
1416 type HttpClientRequest,
1517} from "effect/unstable/http";
1618
17import { EnvironmentId } from "@t3tools/contracts";
19import { DESKTOP_UPDATE_RESTART_MARKER_FILE, EnvironmentId } from "@t3tools/contracts";
1820import { RelayClientTracer } from "@t3tools/shared/relayTracing";
1921import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
2022import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
......@@ -285,6 +287,20 @@ describe("releaseManagedTunnelOnShutdown", () => {
285287 );
286288 });
287289
290 // Writes the marker the desktop app leaves just before it stops its backend
291 // to install an update, and returns when it was written.
292 const writeDesktopUpdateRestartMarker = Effect.gen(function* () {
293 const fs = yield* FileSystem.FileSystem;
294 const path = yield* Path.Path;
295 const config = yield* ServerConfigModule.ServerConfig;
296 const runtimeDir = path.join(config.baseDir, "runtime");
297 const markerPath = path.join(runtimeDir, DESKTOP_UPDATE_RESTART_MARKER_FILE);
298 yield* fs.makeDirectory(runtimeDir, { recursive: true });
299 yield* fs.writeFileString(markerPath, "");
300 const { mtime } = yield* fs.stat(markerPath);
301 return Option.getOrThrow(mtime).getTime();
302 });
303
288304 const provideReleaseHarness =
289305 (harness: ReleaseHarness) =>
290306 <A, E, R>(effect: Effect.Effect<A, E, R>) =>
......@@ -454,6 +470,38 @@ describe("releaseManagedTunnelOnShutdown", () => {
454470 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
455471 });
456472
473 it.effect("keeps the tunnel once when the desktop app restarts it for an update", () => {
474 const { store, values } = makeMemorySecretStore(managedLinkSecrets);
475 const applyConfigCalls: Array<unknown> = [];
476 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
477
478 return Effect.gen(function* () {
479 yield* TestClock.setTime(yield* writeDesktopUpdateRestartMarker);
480
481 expect(yield* releaseManagedTunnelOnShutdown()).toBe(false);
482 expect(requests).toEqual([]);
483 expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true);
484
485 // The shutdown consumed the marker, so a later quit releases the tunnel.
486 expect(yield* releaseManagedTunnelOnShutdown()).toBe(true);
487 expect(requests).toHaveLength(1);
488 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
489 });
490
491 it.effect("releases the tunnel when the desktop update marker is stale", () => {
492 const { store } = makeMemorySecretStore(managedLinkSecrets);
493 const applyConfigCalls: Array<unknown> = [];
494 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
495
496 return Effect.gen(function* () {
497 const writtenAt = yield* writeDesktopUpdateRestartMarker;
498 yield* TestClock.setTime(writtenAt + Duration.toMillis(Duration.minutes(2)));
499
500 expect(yield* releaseManagedTunnelOnShutdown()).toBe(true);
501 expect(requests).toHaveLength(1);
502 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
503 });
504
457505 it.effect("still releases a pending update when the launcher is stopping", () => {
458506 // `t3 service uninstall` or `systemctl stop` during the pending window:
459507 // the launcher writes its stop marker before signalling the child, so no
apps/server/src/cloud/http.ts+34-7
......@@ -11,6 +11,7 @@ import {
1111 EnvironmentHttpConflictError,
1212 EnvironmentHttpInternalServerError,
1313 EnvironmentHttpUnauthorizedError,
14 DESKTOP_UPDATE_RESTART_MARKER_FILE,
1415} from "@t3tools/contracts";
1516import {
1617 RelayCloudEnvironmentHealthProofPayload,
......@@ -42,6 +43,7 @@ import {
4243 verifyRelayJwt,
4344} from "@t3tools/shared/relayJwt";
4445import { isSecureRelayUrl } from "@t3tools/shared/relayUrl";
46import * as Clock from "effect/Clock";
4547import * as DateTime from "effect/DateTime";
4648import * as Crypto from "effect/Crypto";
4749import * as Duration from "effect/Duration";
......@@ -93,6 +95,8 @@ const CLOUD_HEALTH_NONCE_PREFIX = "cloud-health-nonce-";
9395const CLOUD_HEALTH_JTI_PREFIX = "cloud-health-jti-";
9496const CLOUD_PROOF_MAX_LIFETIME_SECONDS = 5 * 60;
9597const CLOUD_PROOF_CLOCK_SKEW_SECONDS = 60;
98// The desktop app stops its backends within seconds of writing the marker.
99const DESKTOP_UPDATE_RESTART_MARKER_TTL = Duration.minutes(1);
96100const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
97101const CLOUD_CREDENTIAL_RESPONSE_HEADERS = {
98102 "cache-control": "no-store",
......@@ -663,6 +667,29 @@ const pendingUpdateHandoffExists = Effect.gen(function* () {
663667 return !stopping;
664668});
665669
670// The desktop app writes its marker right before it stops this server to
671// install an update, whether a remote client or the local app started it.
672// Reading consumes it, so shutdown checks it first. Only a fresh marker counts,
673// so a marker the server never read (a hard kill) cannot keep the tunnel on a
674// later quit.
675const desktopUpdateRestartPending = Effect.gen(function* () {
676 const config = yield* ServerConfig.ServerConfig;
677 const fs = yield* FileSystem.FileSystem;
678 const path = yield* Path.Path;
679 const markerPath = path.join(config.baseDir, "runtime", DESKTOP_UPDATE_RESTART_MARKER_FILE);
680 const marker = yield* fs.stat(markerPath).pipe(Effect.option);
681 if (Option.isNone(marker)) {
682 return false;
683 }
684 yield* fs.remove(markerPath).pipe(Effect.ignore);
685 const now = yield* Clock.currentTimeMillis;
686 return Option.match(marker.value.mtime, {
687 onNone: () => false,
688 onSome: (writtenAt) =>
689 now - writtenAt.getTime() < Duration.toMillis(DESKTOP_UPDATE_RESTART_MARKER_TTL),
690 });
691});
692
666693// Cloudflare bills per provisioned tunnel, so an environment that goes offline
667694// must not leave its tunnel behind. Releasing deletes only the tunnel — the
668695// relay keeps the link and its hostname reservation, and the next startup's
......@@ -685,16 +712,16 @@ export const releaseManagedTunnelOnShutdown = Effect.fn(
685712 if (!(yield* readCliDesiredCloudLink) || (yield* readCliDesiredLinkMode) !== "managed") {
686713 return false;
687714 }
688 // A shutdown that hands off to a pending remote update is not the
689 // environment going offline: the launcher immediately brings a server back
690 // (the new version, or the old one after a rollback). Deleting the tunnel
691 // here forces that server to provision a replacement UUID, and the public
692 // hostname's route to the new tunnel takes 1-2 minutes to propagate — the
693 // dominant cost of an update restart. Keep the tunnel instead: the next
715 // A shutdown that hands off to a pending update is not the environment
716 // going offline: the service launcher or the desktop app immediately brings
717 // a server back (the new version, or the old one after a rollback). Deleting
718 // the tunnel here forces that server to provision a replacement UUID, and the
719 // public hostname's route to the new tunnel takes 1-2 minutes to propagate —
720 // the dominant cost of an update restart. Keep the tunnel instead: the next
694721 // boot respawns the connector from the stored config and is reachable as
695722 // soon as it connects, and the reconcile confirms the still-live tunnel
696723 // without replacing it.
697 if (yield* pendingUpdateHandoffExists) {
724 if ((yield* desktopUpdateRestartPending) || (yield* pendingUpdateHandoffExists)) {
698725 yield* Effect.logInfo("Keeping the managed tunnel across the update restart");
699726 return false;
700727 }
packages/contracts/src/desktopBootstrap.ts+6
......@@ -23,3 +23,9 @@ export const DesktopBackendBootstrap = Schema.Struct({
2323});
2424
2525export type DesktopBackendBootstrap = typeof DesktopBackendBootstrap.Type;
26
27/** Written to `<t3Home>/runtime` just before the desktop app stops its
28 backend to install an update. The updated app starts a new backend right
29 away, so a backend that sees a fresh marker at shutdown keeps its managed
30 tunnel. */
31export const DESKTOP_UPDATE_RESTART_MARKER_FILE = "desktop-update-restart";