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 @@...@@ -1,4 +1,5 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";3import * as Cause from "effect/Cause";
3import * as Deferred from "effect/Deferred";4import * as Deferred from "effect/Deferred";
4import * as Duration from "effect/Duration";5import * as Duration from "effect/Duration";
...@@ -14,6 +15,7 @@ import * as TestClock from "effect/testing/TestClock";...@@ -14,6 +15,7 @@ import * as TestClock from "effect/testing/TestClock";
1415
15import * as ElectronUpdater from "../electron/ElectronUpdater.ts";16import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
16import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";17import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
18import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
17import * as DesktopState from "../app/DesktopState.ts";19import * as DesktopState from "../app/DesktopState.ts";
18import * as DesktopUpdates from "./DesktopUpdates.ts";20import * as DesktopUpdates from "./DesktopUpdates.ts";
19import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts";21import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts";
...@@ -559,6 +561,55 @@ describe("DesktopUpdates", () => {...@@ -559,6 +561,55 @@ describe("DesktopUpdates", () => {
559 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));561 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
560 });562 });
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
562 it.effect("keeps windows and restarts backends when quitAndInstall fails", () => {613 it.effect("keeps windows and restarts backends when quitAndInstall fails", () => {
563 const harness = makeHarness({614 const harness = makeHarness({
564 quitAndInstall: Effect.fail(615 quitAndInstall: Effect.fail(
...@@ -583,6 +634,8 @@ describe("DesktopUpdates", () => {...@@ -583,6 +634,8 @@ describe("DesktopUpdates", () => {
583 assert.isTrue(result.accepted);634 assert.isTrue(result.accepted);
584 assert.isFalse(yield* Ref.get(desktopState.quitting));635 assert.isFalse(yield* Ref.get(desktopState.quitting));
585 assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]);636 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);
586 }),639 }),
587 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));640 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
588 });641 });
apps/desktop/src/updates/DesktopUpdates.ts+31-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 DESKTOP_UPDATE_RESTART_MARKER_FILE,
2 DesktopUpdateChannelSchema,3 DesktopUpdateChannelSchema,
3 type DesktopRuntimeInfo,4 type DesktopRuntimeInfo,
4 type DesktopUpdateActionResult,5 type DesktopUpdateActionResult,
...@@ -501,8 +502,35 @@ export const make = Effect.gen(function* () {...@@ -501,8 +502,35 @@ export const make = Effect.gen(function* () {
501 );502 );
502 }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate"));503 }).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
504 const resetInstallAction = Effect.all(528 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 ],
506 { discard: true },534 { discard: true },
507 );535 );
508536
...@@ -517,6 +545,7 @@ export const make = Effect.gen(function* () {...@@ -517,6 +545,7 @@ export const make = Effect.gen(function* () {
517 if (!ownsRecovery) return;545 if (!ownsRecovery) return;
518546
519 yield* Ref.set(desktopState.quitting, false);547 yield* Ref.set(desktopState.quitting, false);
548 yield* removeUpdateRestartMarker;
520 yield* Effect.gen(function* () {549 yield* Effect.gen(function* () {
521 const instances = yield* pool.list;550 const instances = yield* pool.list;
522 const restartExit = yield* Effect.forEach(instances, (instance) => instance.start, {551 const restartExit = yield* Effect.forEach(instances, (instance) => instance.start, {
...@@ -586,6 +615,7 @@ export const make = Effect.gen(function* () {...@@ -586,6 +615,7 @@ export const make = Effect.gen(function* () {
586 yield* Ref.set(desktopState.quitting, true);615 yield* Ref.set(desktopState.quitting, true);
587616
588 return yield* Effect.gen(function* () {617 return yield* Effect.gen(function* () {
618 yield* writeUpdateRestartMarker;
589 // Stop every backend in the pool, not just the primary. With619 // Stop every backend in the pool, not just the primary. With
590 // parallel WSL + Windows backends, leaving the WSL instance up620 // parallel WSL + Windows backends, leaving the WSL instance up
591 // means quitAndInstall's app.quit() exits before the pool's621 // means quitAndInstall's app.quit() exits before the pool's
apps/desktop/src/updates/updatesTestHarness.ts+18
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import type { DesktopUpdateState } from "@t3tools/contracts";2import type { DesktopUpdateState } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";6import * as Option from "effect/Option";
67
...@@ -203,7 +204,23 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {...@@ -203,7 +204,23 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
203 } satisfies DesktopAppSettings.DesktopAppSettings["Service"])204 } satisfies DesktopAppSettings.DesktopAppSettings["Service"])
204 : DesktopAppSettings.layer;205 : 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
206 const layer = DesktopUpdates.layer.pipe(222 const layer = DesktopUpdates.layer.pipe(
223 Layer.provide(fileSystemLayer),
207 Layer.provideMerge(updaterLayer),224 Layer.provideMerge(updaterLayer),
208 Layer.provideMerge(windowLayer),225 Layer.provideMerge(windowLayer),
209 Layer.provideMerge(backendLayer),226 Layer.provideMerge(backendLayer),
...@@ -226,6 +243,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {...@@ -226,6 +243,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
226 checkCount: () => checkCount,243 checkCount: () => checkCount,
227 quitAndInstalls: () => quitAndInstallCount,244 quitAndInstalls: () => quitAndInstallCount,
228 installSteps,245 installSteps,
246 updateRestartMarkers,
229 downloadCount: () => downloadCount,247 downloadCount: () => downloadCount,
230 feedUrls: () => feedUrls,248 feedUrls: () => feedUrls,
231 fullChangelog: () => fullChangelog,249 fullChangelog: () => fullChangelog,
apps/server/src/cloud/http.test.ts+49-1
...@@ -1,11 +1,13 @@...@@ -1,11 +1,13 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
3import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";5import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";7import * as Option from "effect/Option";
7import * as Path from "effect/Path";8import * as Path from "effect/Path";
8import * as PlatformError from "effect/PlatformError";9import * as PlatformError from "effect/PlatformError";
10import * as TestClock from "effect/testing/TestClock";
9import * as Tracer from "effect/Tracer";11import * as Tracer from "effect/Tracer";
10import {12import {
11 HttpClient,13 HttpClient,
...@@ -14,7 +16,7 @@ import {...@@ -14,7 +16,7 @@ import {
14 type HttpClientRequest,16 type HttpClientRequest,
15} from "effect/unstable/http";17} from "effect/unstable/http";
1618
17import { EnvironmentId } from "@t3tools/contracts";19import { DESKTOP_UPDATE_RESTART_MARKER_FILE, EnvironmentId } from "@t3tools/contracts";
18import { RelayClientTracer } from "@t3tools/shared/relayTracing";20import { RelayClientTracer } from "@t3tools/shared/relayTracing";
19import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";21import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
20import * as ServerSecretStore from "../auth/ServerSecretStore.ts";22import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
...@@ -285,6 +287,20 @@ describe("releaseManagedTunnelOnShutdown", () => {...@@ -285,6 +287,20 @@ describe("releaseManagedTunnelOnShutdown", () => {
285 );287 );
286 });288 });
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
288 const provideReleaseHarness =304 const provideReleaseHarness =
289 (harness: ReleaseHarness) =>305 (harness: ReleaseHarness) =>
290 <A, E, R>(effect: Effect.Effect<A, E, R>) =>306 <A, E, R>(effect: Effect.Effect<A, E, R>) =>
...@@ -454,6 +470,38 @@ describe("releaseManagedTunnelOnShutdown", () => {...@@ -454,6 +470,38 @@ describe("releaseManagedTunnelOnShutdown", () => {
454 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));470 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
455 });471 });
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
457 it.effect("still releases a pending update when the launcher is stopping", () => {505 it.effect("still releases a pending update when the launcher is stopping", () => {
458 // `t3 service uninstall` or `systemctl stop` during the pending window:506 // `t3 service uninstall` or `systemctl stop` during the pending window:
459 // the launcher writes its stop marker before signalling the child, so no507 // 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 {...@@ -11,6 +11,7 @@ import {
11 EnvironmentHttpConflictError,11 EnvironmentHttpConflictError,
12 EnvironmentHttpInternalServerError,12 EnvironmentHttpInternalServerError,
13 EnvironmentHttpUnauthorizedError,13 EnvironmentHttpUnauthorizedError,
14 DESKTOP_UPDATE_RESTART_MARKER_FILE,
14} from "@t3tools/contracts";15} from "@t3tools/contracts";
15import {16import {
16 RelayCloudEnvironmentHealthProofPayload,17 RelayCloudEnvironmentHealthProofPayload,
...@@ -42,6 +43,7 @@ import {...@@ -42,6 +43,7 @@ import {
42 verifyRelayJwt,43 verifyRelayJwt,
43} from "@t3tools/shared/relayJwt";44} from "@t3tools/shared/relayJwt";
44import { isSecureRelayUrl } from "@t3tools/shared/relayUrl";45import { isSecureRelayUrl } from "@t3tools/shared/relayUrl";
46import * as Clock from "effect/Clock";
45import * as DateTime from "effect/DateTime";47import * as DateTime from "effect/DateTime";
46import * as Crypto from "effect/Crypto";48import * as Crypto from "effect/Crypto";
47import * as Duration from "effect/Duration";49import * as Duration from "effect/Duration";
...@@ -93,6 +95,8 @@ const CLOUD_HEALTH_NONCE_PREFIX = "cloud-health-nonce-";...@@ -93,6 +95,8 @@ const CLOUD_HEALTH_NONCE_PREFIX = "cloud-health-nonce-";
93const CLOUD_HEALTH_JTI_PREFIX = "cloud-health-jti-";95const CLOUD_HEALTH_JTI_PREFIX = "cloud-health-jti-";
94const CLOUD_PROOF_MAX_LIFETIME_SECONDS = 5 * 60;96const CLOUD_PROOF_MAX_LIFETIME_SECONDS = 5 * 60;
95const CLOUD_PROOF_CLOCK_SKEW_SECONDS = 60;97const 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);
96const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);100const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
97const CLOUD_CREDENTIAL_RESPONSE_HEADERS = {101const CLOUD_CREDENTIAL_RESPONSE_HEADERS = {
98 "cache-control": "no-store",102 "cache-control": "no-store",
...@@ -663,6 +667,29 @@ const pendingUpdateHandoffExists = Effect.gen(function* () {...@@ -663,6 +667,29 @@ const pendingUpdateHandoffExists = Effect.gen(function* () {
663 return !stopping;667 return !stopping;
664});668});
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
666// Cloudflare bills per provisioned tunnel, so an environment that goes offline693// Cloudflare bills per provisioned tunnel, so an environment that goes offline
667// must not leave its tunnel behind. Releasing deletes only the tunnel — the694// must not leave its tunnel behind. Releasing deletes only the tunnel — the
668// relay keeps the link and its hostname reservation, and the next startup's695// relay keeps the link and its hostname reservation, and the next startup's
...@@ -685,16 +712,16 @@ export const releaseManagedTunnelOnShutdown = Effect.fn(...@@ -685,16 +712,16 @@ export const releaseManagedTunnelOnShutdown = Effect.fn(
685 if (!(yield* readCliDesiredCloudLink) || (yield* readCliDesiredLinkMode) !== "managed") {712 if (!(yield* readCliDesiredCloudLink) || (yield* readCliDesiredLinkMode) !== "managed") {
686 return false;713 return false;
687 }714 }
688 // A shutdown that hands off to a pending remote update is not the715 // A shutdown that hands off to a pending update is not the environment
689 // environment going offline: the launcher immediately brings a server back716 // going offline: the service launcher or the desktop app immediately brings
690 // (the new version, or the old one after a rollback). Deleting the tunnel717 // a server back (the new version, or the old one after a rollback). Deleting
691 // here forces that server to provision a replacement UUID, and the public718 // the tunnel here forces that server to provision a replacement UUID, and the
692 // hostname's route to the new tunnel takes 1-2 minutes to propagate — the719 // public hostname's route to the new tunnel takes 1-2 minutes to propagate —
693 // dominant cost of an update restart. Keep the tunnel instead: the next720 // the dominant cost of an update restart. Keep the tunnel instead: the next
694 // boot respawns the connector from the stored config and is reachable as721 // boot respawns the connector from the stored config and is reachable as
695 // soon as it connects, and the reconcile confirms the still-live tunnel722 // soon as it connects, and the reconcile confirms the still-live tunnel
696 // without replacing it.723 // without replacing it.
697 if (yield* pendingUpdateHandoffExists) {724 if ((yield* desktopUpdateRestartPending) || (yield* pendingUpdateHandoffExists)) {
698 yield* Effect.logInfo("Keeping the managed tunnel across the update restart");725 yield* Effect.logInfo("Keeping the managed tunnel across the update restart");
699 return false;726 return false;
700 }727 }
packages/contracts/src/desktopBootstrap.ts+6
...@@ -23,3 +23,9 @@ export const DesktopBackendBootstrap = Schema.Struct({...@@ -23,3 +23,9 @@ export const DesktopBackendBootstrap = Schema.Struct({
23});23});
2424
25export type DesktopBackendBootstrap = typeof DesktopBackendBootstrap.Type;25export 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";