| 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,4 +1,5 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts"; | ||
| 2 | import * as Cause from "effect/Cause"; | 3 | import * as Cause from "effect/Cause"; |
| 3 | import * as Deferred from "effect/Deferred"; | 4 | import * as Deferred from "effect/Deferred"; |
| 4 | import * as Duration from "effect/Duration"; | 5 | import * 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"; |
| 14 | 15 | ||
| 15 | import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; | 16 | import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; |
| 16 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | 17 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 18 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | ||
| 17 | import * as DesktopState from "../app/DesktopState.ts"; | 19 | import * as DesktopState from "../app/DesktopState.ts"; |
| 18 | import * as DesktopUpdates from "./DesktopUpdates.ts"; | 20 | import * as DesktopUpdates from "./DesktopUpdates.ts"; |
| 19 | import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; | 21 | import { 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 | }); |
| 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 | 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 @@ |
| 1 | import { | 1 | import { |
| 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")); |
| 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 | 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 | ); |
| 508 | 536 | ||
| ... | @@ -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; |
| 518 | 546 | ||
| 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); |
| 587 | 616 | ||
| 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. With | 619 | // Stop every backend in the pool, not just the primary. With |
| 590 | // parallel WSL + Windows backends, leaving the WSL instance up | 620 | // parallel WSL + Windows backends, leaving the WSL instance up |
| 591 | // means quitAndInstall's app.quit() exits before the pool's | 621 | // 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 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import type { DesktopUpdateState } from "@t3tools/contracts"; | 2 | import type { DesktopUpdateState } from "@t3tools/contracts"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 4 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | 6 | import * as Option from "effect/Option"; |
| 6 | 7 | ||
| ... | @@ -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; |
| 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 | 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 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { describe, expect, it } from "@effect/vitest"; | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | 5 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 7 | import * as Path from "effect/Path"; | 8 | import * as Path from "effect/Path"; |
| 8 | import * as PlatformError from "effect/PlatformError"; | 9 | import * as PlatformError from "effect/PlatformError"; |
| 10 | import * as TestClock from "effect/testing/TestClock"; | ||
| 9 | import * as Tracer from "effect/Tracer"; | 11 | import * as Tracer from "effect/Tracer"; |
| 10 | import { | 12 | import { |
| 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"; |
| 16 | 18 | ||
| 17 | import { EnvironmentId } from "@t3tools/contracts"; | 19 | import { DESKTOP_UPDATE_RESTART_MARKER_FILE, EnvironmentId } from "@t3tools/contracts"; |
| 18 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; | 20 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; |
| 19 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | 21 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 20 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 22 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| ... | @@ -285,6 +287,20 @@ describe("releaseManagedTunnelOnShutdown", () => { | ... | @@ -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 | 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 | }); |
| 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 | 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 no | 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,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"; |
| 15 | import { | 16 | import { |
| 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"; |
| 44 | import { isSecureRelayUrl } from "@t3tools/shared/relayUrl"; | 45 | import { isSecureRelayUrl } from "@t3tools/shared/relayUrl"; |
| 46 | import * as Clock from "effect/Clock"; | ||
| 45 | import * as DateTime from "effect/DateTime"; | 47 | import * as DateTime from "effect/DateTime"; |
| 46 | import * as Crypto from "effect/Crypto"; | 48 | import * as Crypto from "effect/Crypto"; |
| 47 | import * as Duration from "effect/Duration"; | 49 | import * 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-"; |
| 93 | const CLOUD_HEALTH_JTI_PREFIX = "cloud-health-jti-"; | 95 | const CLOUD_HEALTH_JTI_PREFIX = "cloud-health-jti-"; |
| 94 | const CLOUD_PROOF_MAX_LIFETIME_SECONDS = 5 * 60; | 96 | const CLOUD_PROOF_MAX_LIFETIME_SECONDS = 5 * 60; |
| 95 | const CLOUD_PROOF_CLOCK_SKEW_SECONDS = 60; | 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 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); | 100 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); |
| 97 | const CLOUD_CREDENTIAL_RESPONSE_HEADERS = { | 101 | const 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 | }); |
| 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 | // Cloudflare bills per provisioned tunnel, so an environment that goes offline | 693 | // Cloudflare bills per provisioned tunnel, so an environment that goes offline |
| 667 | // must not leave its tunnel behind. Releasing deletes only the tunnel — the | 694 | // 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's | 695 | // 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 the | 715 | // A shutdown that hands off to a pending update is not the environment |
| 689 | // environment going offline: the launcher immediately brings a server back | 716 | // going offline: the service launcher or the desktop app immediately brings |
| 690 | // (the new version, or the old one after a rollback). Deleting the tunnel | 717 | // 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 public | 718 | // 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 — the | 719 | // 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 next | 720 | // 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 as | 721 | // boot respawns the connector from the stored config and is reachable as |
| 695 | // soon as it connects, and the reconcile confirms the still-live tunnel | 722 | // 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 | }); |
| 24 | 24 | ||
| 25 | export type DesktopBackendBootstrap = typeof DesktopBackendBootstrap.Type; | 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"; |