From 64a3cd6d7daab21f6bfbc00faf0104b6fd361267 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 6 Aug 2026 05:02:27 -0700 Subject: [PATCH] fix(server): separate trial cleanup from handoff retention --- apps/server/src/cloud/http.test.ts | 2 ++ apps/server/src/cloud/http.ts | 28 ++++++++++++++++------------ apps/server/src/server.ts | 4 ++-- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index d3cd9c8a47d4cf820407955630eedf42a872c30b..0f24e6f34176e782fa6399b502e17fc1c0b4171e 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -35,6 +35,7 @@ import { consumeCloudReplayGuards, isSupportedLinkProviderKind, linkProofScopes, + pendingServiceUpdateExists, reconcileDesiredCloudLink, releaseManagedTunnelOnShutdown, } from "./http.ts"; @@ -474,6 +475,7 @@ describe("releaseManagedTunnelOnShutdown", () => { const config = yield* ServerConfigModule.ServerConfig; yield* fs.writeFileString(path.join(config.baseDir, "runtime", SERVICE_STOP_MARKER_FILE), ""); + expect(yield* pendingServiceUpdateExists).toBe(true); const released = yield* releaseManagedTunnelOnShutdown(); expect(released).toBe(true); diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 40cb043a8cdcfa808e4be79ae35ef073c6872fe5..5c744bbfb9d8d6a8eef1df5487aa007d6b878540 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -635,17 +635,9 @@ export const reconcileDesiredCloudLink = Effect.fn("environment.cloud.reconcileD }, ); -// The launcher records an in-flight update in its durable state file. Read -// straight from disk: the launcher owns that file, and this runs while the -// server is tearing down. Unreadable or unparseable state means no pending -// update — the release then proceeds as before. -// -// A pending update alone is not proof a replacement server is coming: an -// explicit launcher stop (`t3 service uninstall`, `systemctl stop`) during -// the pending window also tears this server down, with nothing starting -// afterwards. The launcher marks that case with a stop-marker file just -// before it signals the child, so pending + no marker is the update handoff. -export const pendingUpdateHandoffExists = Effect.gen(function* () { +// The launcher owns this durable state, so read it directly both when a trial +// decides whether it owns pre-activation cleanup and while a server tears down. +export const pendingServiceUpdateExists = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -653,9 +645,21 @@ export const pendingUpdateHandoffExists = Effect.gen(function* () { const stateText = yield* fs .readFileString(path.join(runtimeDir, SERVICE_STATE_FILE)) .pipe(Effect.option); - if (Option.isNone(stateText) || !serviceStateHasPendingUpdate(stateText.value)) { + return Option.isSome(stateText) && serviceStateHasPendingUpdate(stateText.value); +}); + +// A pending update alone is not proof a replacement server is coming: an +// explicit launcher stop (`t3 service uninstall`, `systemctl stop`) during +// the pending window also tears this server down. The launcher marks that case +// just before it signals the child, so pending + no marker is the handoff. +const pendingUpdateHandoffExists = Effect.gen(function* () { + if (!(yield* pendingServiceUpdateExists)) { return false; } + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runtimeDir = path.join(config.baseDir, "runtime"); const stopping = yield* fs .exists(path.join(runtimeDir, SERVICE_STOP_MARKER_FILE)) .pipe(Effect.orElseSucceed(() => false)); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 431d888dca84009dfbd0151c6540d1f12f1316a6..1d824afbd1beaee7d93a1588ac03c00ca7a7879a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -83,7 +83,7 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { connectHttpApiLayer, - pendingUpdateHandoffExists, + pendingServiceUpdateExists, reconcileDesiredCloudLink, releaseManagedTunnelOnShutdown, } from "./cloud/http.ts"; @@ -579,7 +579,7 @@ export const makeServerLayer = Layer.unwrap( // while the launcher's explicit-stop marker allows it to be released. // Other runtimes wait for activation so a failed standby cannot tear // down the active runtime's tunnel. - const cleanupBeforeActivation = yield* pendingUpdateHandoffExists; + const cleanupBeforeActivation = yield* pendingServiceUpdateExists; if (cleanupBeforeActivation) { yield* Effect.addFinalizer(() => releaseManagedTunnel); } -- 2.54.0