diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 3dc12f41c5504bda6ffc421ed4aa13a862221cca..40cb043a8cdcfa808e4be79ae35ef073c6872fe5 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -645,7 +645,7 @@ export const reconcileDesiredCloudLink = Effect.fn("environment.cloud.reconcileD // 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. -const pendingUpdateHandoffExists = Effect.gen(function* () { +export const pendingUpdateHandoffExists = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 49a3a31940f711ade8890906f32e339a8aa99509..431d888dca84009dfbd0151c6540d1f12f1316a6 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -83,6 +83,7 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { connectHttpApiLayer, + pendingUpdateHandoffExists, reconcileDesiredCloudLink, releaseManagedTunnelOnShutdown, } from "./cloud/http.ts"; @@ -559,26 +560,34 @@ export const makeServerLayer = Layer.unwrap( yield* Deferred.succeed(cloudLinkParked, undefined).pipe(Effect.orDie); return; } + const releaseManagedTunnel = releaseManagedTunnelOnShutdown().pipe( + Effect.timeout("10 seconds"), + Effect.tap((released) => + released ? Effect.logInfo("Released the managed tunnel on shutdown") : Effect.void, + ), + Effect.catchCause((cause) => + Effect.logWarning( + "Failed to release the managed tunnel on shutdown; the next link reuses it", + { cause }, + ), + ), + Effect.asVoid, + ); + // A launcher trial can be stopped before activation. The previous + // server is already gone, so the trial owns cleanup immediately; the + // pending-state check keeps the tunnel for normal commit or rollback, + // 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; + if (cleanupBeforeActivation) { + yield* Effect.addFinalizer(() => releaseManagedTunnel); + } yield* forkParked( Effect.gen(function* () { - // Only an activated runtime owns the tunnel cleanup finalizer. - yield* Effect.addFinalizer(() => - releaseManagedTunnelOnShutdown().pipe( - Effect.timeout("10 seconds"), - Effect.tap((released) => - released - ? Effect.logInfo("Released the managed tunnel on shutdown") - : Effect.void, - ), - Effect.catchCause((cause) => - Effect.logWarning( - "Failed to release the managed tunnel on shutdown; the next link reuses it", - { cause }, - ), - ), - Effect.asVoid, - ), - ); + if (!cleanupBeforeActivation) { + yield* Effect.addFinalizer(() => releaseManagedTunnel); + } if (!(yield* CloudCliState.readCliDesiredCloudLink)) return; const server = yield* HttpServer.HttpServer; const address = server.address; diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index 90c76b85b3196e42f5442879e1dc02c960c4f76f..816bcacc73cabf7edf32e3968683afa5947b9462 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -1,6 +1,3 @@ -// @effect-diagnostics-next-line nodeBuiltinImport:off - sync marker poll outside the test clock. -import * as NodeFS from "node:fs"; - import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -120,25 +117,6 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { // An explicit stop leaves the marker that tells a child shutting down // mid-update that no replacement server is coming. assert.isTrue(yield* fs.exists(path.join(root, "runtime", SERVICE_STOP_MARKER_FILE))); - - // The next launcher start clears the stale marker so a later update - // handoff keeps its tunnel again. Recovery runs before any stop - // transition, so polling until the marker disappears observes it; the - // poll uses real timers because it.effect runs under the test clock. - const restarted = new Launcher( - root, - yield* Effect.promise(() => readServiceState(statePath)), - ); - const runningAgain = restarted.run(); - const markerPath = path.join(root, "runtime", SERVICE_STOP_MARKER_FILE); - yield* Effect.promise(async () => { - while (NodeFS.existsSync(markerPath)) { - // @effect-diagnostics-next-line globalTimers:off - real delay under the test clock. - await new Promise((resolve) => setTimeout(resolve, 25)); - } - }); - yield* Effect.promise(() => restarted.stop("SIGTERM")); - yield* Effect.promise(() => runningAgain); }), );