| author | |
| committer | |
| log | d4a33457cb0da797728f4846a9da8592d7d81d36 |
| tree | adfc8025448382983e1da3dcfe264b7fbc3f88b3 |
| parent | 9a91177c3ecadaaa088d00e467491de4be9d7ce3 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts"; | |
| 2 | 3 | import * as Cause from "effect/Cause"; |
| 3 | 4 | import * as Deferred from "effect/Deferred"; |
| 4 | 5 | import * as Duration from "effect/Duration"; |
| ... | ... | @@ -14,6 +15,7 @@ import * as TestClock from "effect/testing/TestClock"; |
| 14 | 15 | |
| 15 | 16 | import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; |
| 16 | 17 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 18 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 17 | 19 | import * as DesktopState from "../app/DesktopState.ts"; |
| 18 | 20 | import * as DesktopUpdates from "./DesktopUpdates.ts"; |
| 19 | 21 | import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; |
| ... | ... | @@ -559,6 +561,55 @@ describe("DesktopUpdates", () => { |
| 559 | 561 | ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); |
| 560 | 562 | }); |
| 561 | 563 | |
| 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 | 613 | it.effect("keeps windows and restarts backends when quitAndInstall fails", () => { |
| 563 | 614 | const harness = makeHarness({ |
| 564 | 615 | quitAndInstall: Effect.fail( |
| ... | ... | @@ -583,6 +634,8 @@ describe("DesktopUpdates", () => { |
| 583 | 634 | assert.isTrue(result.accepted); |
| 584 | 635 | assert.isFalse(yield* Ref.get(desktopState.quitting)); |
| 585 | 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 | 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 | 1 | import { |
| 2 | DESKTOP_UPDATE_RESTART_MARKER_FILE, | |
| 2 | 3 | DesktopUpdateChannelSchema, |
| 3 | 4 | type DesktopRuntimeInfo, |
| 4 | 5 | type DesktopUpdateActionResult, |
| ... | ... | @@ -501,8 +502,35 @@ export const make = Effect.gen(function* () { |
| 501 | 502 | ); |
| 502 | 503 | }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate")); |
| 503 | 504 | |
| 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 | 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 | 534 | { discard: true }, |
| 507 | 535 | ); |
| 508 | 536 | |
| ... | ... | @@ -517,6 +545,7 @@ export const make = Effect.gen(function* () { |
| 517 | 545 | if (!ownsRecovery) return; |
| 518 | 546 | |
| 519 | 547 | yield* Ref.set(desktopState.quitting, false); |
| 548 | yield* removeUpdateRestartMarker; | |
| 520 | 549 | yield* Effect.gen(function* () { |
| 521 | 550 | const instances = yield* pool.list; |
| 522 | 551 | const restartExit = yield* Effect.forEach(instances, (instance) => instance.start, { |
| ... | ... | @@ -586,6 +615,7 @@ export const make = Effect.gen(function* () { |
| 586 | 615 | yield* Ref.set(desktopState.quitting, true); |
| 587 | 616 | |
| 588 | 617 | return yield* Effect.gen(function* () { |
| 618 | yield* writeUpdateRestartMarker; | |
| 589 | 619 | // Stop every backend in the pool, not just the primary. With |
| 590 | 620 | // parallel WSL + Windows backends, leaving the WSL instance up |
| 591 | 621 | // means quitAndInstall's app.quit() exits before the pool's |
apps/desktop/src/updates/updatesTestHarness.ts+18| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import type { DesktopUpdateState } from "@t3tools/contracts"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 4 | 5 | import * as Layer from "effect/Layer"; |
| 5 | 6 | import * as Option from "effect/Option"; |
| 6 | 7 | |
| ... | ... | @@ -203,7 +204,23 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 203 | 204 | } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) |
| 204 | 205 | : DesktopAppSettings.layer; |
| 205 | 206 | |
| 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 | 222 | const layer = DesktopUpdates.layer.pipe( |
| 223 | Layer.provide(fileSystemLayer), | |
| 207 | 224 | Layer.provideMerge(updaterLayer), |
| 208 | 225 | Layer.provideMerge(windowLayer), |
| 209 | 226 | Layer.provideMerge(backendLayer), |
| ... | ... | @@ -226,6 +243,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 226 | 243 | checkCount: () => checkCount, |
| 227 | 244 | quitAndInstalls: () => quitAndInstallCount, |
| 228 | 245 | installSteps, |
| 246 | updateRestartMarkers, | |
| 229 | 247 | downloadCount: () => downloadCount, |
| 230 | 248 | feedUrls: () => feedUrls, |
| 231 | 249 | fullChangelog: () => fullChangelog, |
apps/server/src/cloud/http.test.ts+49-1| ... | ... | @@ -1,11 +1,13 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | import * as Duration from "effect/Duration"; | |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| 4 | 5 | import * as FileSystem from "effect/FileSystem"; |
| 5 | 6 | import * as Layer from "effect/Layer"; |
| 6 | 7 | import * as Option from "effect/Option"; |
| 7 | 8 | import * as Path from "effect/Path"; |
| 8 | 9 | import * as PlatformError from "effect/PlatformError"; |
| 10 | import * as TestClock from "effect/testing/TestClock"; | |
| 9 | 11 | import * as Tracer from "effect/Tracer"; |
| 10 | 12 | import { |
| 11 | 13 | HttpClient, |
| ... | ... | @@ -14,7 +16,7 @@ import { |
| 14 | 16 | type HttpClientRequest, |
| 15 | 17 | } from "effect/unstable/http"; |
| 16 | 18 | |
| 17 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 19 | import { DESKTOP_UPDATE_RESTART_MARKER_FILE, EnvironmentId } from "@t3tools/contracts"; | |
| 18 | 20 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; |
| 19 | 21 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 20 | 22 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| ... | ... | @@ -285,6 +287,20 @@ describe("releaseManagedTunnelOnShutdown", () => { |
| 285 | 287 | ); |
| 286 | 288 | }); |
| 287 | 289 | |
| 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 | 304 | const provideReleaseHarness = |
| 289 | 305 | (harness: ReleaseHarness) => |
| 290 | 306 | <A, E, R>(effect: Effect.Effect<A, E, R>) => |
| ... | ... | @@ -454,6 +470,38 @@ describe("releaseManagedTunnelOnShutdown", () => { |
| 454 | 470 | }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); |
| 455 | 471 | }); |
| 456 | 472 | |
| 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 | 505 | it.effect("still releases a pending update when the launcher is stopping", () => { |
| 458 | 506 | // `t3 service uninstall` or `systemctl stop` during the pending window: |
| 459 | 507 | // 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 | 11 | EnvironmentHttpConflictError, |
| 12 | 12 | EnvironmentHttpInternalServerError, |
| 13 | 13 | EnvironmentHttpUnauthorizedError, |
| 14 | DESKTOP_UPDATE_RESTART_MARKER_FILE, | |
| 14 | 15 | } from "@t3tools/contracts"; |
| 15 | 16 | import { |
| 16 | 17 | RelayCloudEnvironmentHealthProofPayload, |
| ... | ... | @@ -42,6 +43,7 @@ import { |
| 42 | 43 | verifyRelayJwt, |
| 43 | 44 | } from "@t3tools/shared/relayJwt"; |
| 44 | 45 | import { isSecureRelayUrl } from "@t3tools/shared/relayUrl"; |
| 46 | import * as Clock from "effect/Clock"; | |
| 45 | 47 | import * as DateTime from "effect/DateTime"; |
| 46 | 48 | import * as Crypto from "effect/Crypto"; |
| 47 | 49 | import * as Duration from "effect/Duration"; |
| ... | ... | @@ -93,6 +95,8 @@ const CLOUD_HEALTH_NONCE_PREFIX = "cloud-health-nonce-"; |
| 93 | 95 | const CLOUD_HEALTH_JTI_PREFIX = "cloud-health-jti-"; |
| 94 | 96 | const CLOUD_PROOF_MAX_LIFETIME_SECONDS = 5 * 60; |
| 95 | 97 | const CLOUD_PROOF_CLOCK_SKEW_SECONDS = 60; |
| 98 | // The desktop app stops its backends within seconds of writing the marker. | |
| 99 | const DESKTOP_UPDATE_RESTART_MARKER_TTL = Duration.minutes(1); | |
| 96 | 100 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); |
| 97 | 101 | const CLOUD_CREDENTIAL_RESPONSE_HEADERS = { |
| 98 | 102 | "cache-control": "no-store", |
| ... | ... | @@ -663,6 +667,29 @@ const pendingUpdateHandoffExists = Effect.gen(function* () { |
| 663 | 667 | return !stopping; |
| 664 | 668 | }); |
| 665 | 669 | |
| 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. | |
| 675 | const 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 | 693 | // Cloudflare bills per provisioned tunnel, so an environment that goes offline |
| 667 | 694 | // must not leave its tunnel behind. Releasing deletes only the tunnel — the |
| 668 | 695 | // relay keeps the link and its hostname reservation, and the next startup's |
| ... | ... | @@ -685,16 +712,16 @@ export const releaseManagedTunnelOnShutdown = Effect.fn( |
| 685 | 712 | if (!(yield* readCliDesiredCloudLink) || (yield* readCliDesiredLinkMode) !== "managed") { |
| 686 | 713 | return false; |
| 687 | 714 | } |
| 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 | |
| 694 | 721 | // boot respawns the connector from the stored config and is reachable as |
| 695 | 722 | // soon as it connects, and the reconcile confirms the still-live tunnel |
| 696 | 723 | // without replacing it. |
| 697 | if (yield* pendingUpdateHandoffExists) { | |
| 724 | if ((yield* desktopUpdateRestartPending) || (yield* pendingUpdateHandoffExists)) { | |
| 698 | 725 | yield* Effect.logInfo("Keeping the managed tunnel across the update restart"); |
| 699 | 726 | return false; |
| 700 | 727 | } |
packages/contracts/src/desktopBootstrap.ts+6| ... | ... | @@ -23,3 +23,9 @@ export const DesktopBackendBootstrap = Schema.Struct({ |
| 23 | 23 | }); |
| 24 | 24 | |
| 25 | 25 | export 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. */ | |
| 31 | export const DESKTOP_UPDATE_RESTART_MARKER_FILE = "desktop-update-restart"; |