| ... | @@ -1,7 +1,9 @@ | ... | @@ -1,7 +1,9 @@ |
| 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 Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| | 4 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| | 6 | import * as Path from "effect/Path"; |
| 5 | import * as PlatformError from "effect/PlatformError"; | 7 | import * as PlatformError from "effect/PlatformError"; |
| 6 | import * as Tracer from "effect/Tracer"; | 8 | import * as Tracer from "effect/Tracer"; |
| 7 | import { | 9 | import { |
| ... | @@ -15,6 +17,7 @@ import { EnvironmentId } from "@t3tools/contracts"; | ... | @@ -15,6 +17,7 @@ import { EnvironmentId } from "@t3tools/contracts"; |
| 15 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; | 17 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; |
| 16 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | 18 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 17 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 19 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| | 20 | import * as ServerConfigModule from "../config.ts"; |
| 18 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | 21 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 19 | import { CLOUD_CLI_DESIRED_LINK_SECRET } from "./CliState.ts"; | 22 | import { CLOUD_CLI_DESIRED_LINK_SECRET } from "./CliState.ts"; |
| 20 | import * as CliTokenManager from "./CliTokenManager.ts"; | 23 | import * as CliTokenManager from "./CliTokenManager.ts"; |
| ... | @@ -256,6 +259,21 @@ describe("releaseManagedTunnelOnShutdown", () => { | ... | @@ -256,6 +259,21 @@ describe("releaseManagedTunnelOnShutdown", () => { |
| 256 | readonly respond?: () => Response; | 259 | readonly respond?: () => Response; |
| 257 | } | 260 | } |
| 258 | | 261 | |
| | 262 | // Writes the launcher's durable state file into this test's baseDir; the |
| | 263 | // release reads it to detect an in-flight update handoff. |
| | 264 | const writeServiceState = (update: Record<string, unknown>) => |
| | 265 | Effect.gen(function* () { |
| | 266 | const fs = yield* FileSystem.FileSystem; |
| | 267 | const path = yield* Path.Path; |
| | 268 | const config = yield* ServerConfigModule.ServerConfig; |
| | 269 | const statePath = path.join(config.baseDir, "runtime", "service-state.json"); |
| | 270 | yield* fs.makeDirectory(path.dirname(statePath), { recursive: true }); |
| | 271 | yield* fs.writeFileString( |
| | 272 | statePath, |
| | 273 | JSON.stringify({ version: 1, activeVersion: "0.0.30", update }), |
| | 274 | ); |
| | 275 | }); |
| | 276 | |
| 259 | const provideReleaseHarness = | 277 | const provideReleaseHarness = |
| 260 | (harness: ReleaseHarness) => | 278 | (harness: ReleaseHarness) => |
| 261 | <A, E, R>(effect: Effect.Effect<A, E, R>) => | 279 | <A, E, R>(effect: Effect.Effect<A, E, R>) => |
| ... | @@ -306,6 +324,11 @@ describe("releaseManagedTunnelOnShutdown", () => { | ... | @@ -306,6 +324,11 @@ describe("releaseManagedTunnelOnShutdown", () => { |
| 306 | }), | 324 | }), |
| 307 | ), | 325 | ), |
| 308 | ), | 326 | ), |
| | 327 | // The release consults the launcher state file under the configured |
| | 328 | // baseDir, so every harness run gets a scoped temp baseDir. |
| | 329 | Effect.provide(ServerConfigModule.layerTest("/", { prefix: "t3-http-release-test-" })), |
| | 330 | Effect.provide(NodeServices.layer), |
| | 331 | Effect.scoped, |
| 309 | ); | 332 | ); |
| 310 | | 333 | |
| 311 | // The persisted state of a CLI-managed link whose tunnel is releasable. | 334 | // The persisted state of a CLI-managed link whose tunnel is releasable. |
| ... | @@ -390,6 +413,54 @@ describe("releaseManagedTunnelOnShutdown", () => { | ... | @@ -390,6 +413,54 @@ describe("releaseManagedTunnelOnShutdown", () => { |
| 390 | }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); | 413 | }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); |
| 391 | }); | 414 | }); |
| 392 | | 415 | |
| | 416 | it.effect("keeps the tunnel when shutdown hands off to a pending update", () => { |
| | 417 | const { store, values } = makeMemorySecretStore(managedLinkSecrets); |
| | 418 | const applyConfigCalls: Array<unknown> = []; |
| | 419 | const requests: Array<HttpClientRequest.HttpClientRequest> = []; |
| | 420 | |
| | 421 | return Effect.gen(function* () { |
| | 422 | yield* writeServiceState({ |
| | 423 | id: "update-1", |
| | 424 | fromVersion: "0.0.30", |
| | 425 | targetVersion: "0.0.31", |
| | 426 | dbPath: "/tmp/state.sqlite", |
| | 427 | status: "pending", |
| | 428 | }); |
| | 429 | |
| | 430 | const released = yield* releaseManagedTunnelOnShutdown(); |
| | 431 | |
| | 432 | // The launcher restarts a server immediately, so the tunnel is not |
| | 433 | // orphaned; keeping it avoids the hostname route re-propagation that |
| | 434 | // dominates update downtime. The stored config must survive so the |
| | 435 | // next boot respawns the connector against the same tunnel. |
| | 436 | expect(released).toBe(false); |
| | 437 | expect(applyConfigCalls).toEqual([]); |
| | 438 | expect(requests).toEqual([]); |
| | 439 | expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true); |
| | 440 | }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); |
| | 441 | }); |
| | 442 | |
| | 443 | it.effect("still releases when the recorded update already settled", () => { |
| | 444 | const { store, values } = makeMemorySecretStore(managedLinkSecrets); |
| | 445 | const applyConfigCalls: Array<unknown> = []; |
| | 446 | const requests: Array<HttpClientRequest.HttpClientRequest> = []; |
| | 447 | |
| | 448 | return Effect.gen(function* () { |
| | 449 | yield* writeServiceState({ |
| | 450 | id: "update-1", |
| | 451 | fromVersion: "0.0.30", |
| | 452 | targetVersion: "0.0.31", |
| | 453 | status: "committed", |
| | 454 | }); |
| | 455 | |
| | 456 | const released = yield* releaseManagedTunnelOnShutdown(); |
| | 457 | |
| | 458 | expect(released).toBe(true); |
| | 459 | expect(requests).toHaveLength(1); |
| | 460 | expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(false); |
| | 461 | }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); |
| | 462 | }); |
| | 463 | |
| 393 | it.effect("keeps a runtime config that a fast restart replaced mid-release", () => { | 464 | it.effect("keeps a runtime config that a fast restart replaced mid-release", () => { |
| 394 | const { store, values } = makeMemorySecretStore(managedLinkSecrets); | 465 | const { store, values } = makeMemorySecretStore(managedLinkSecrets); |
| 395 | const applyConfigCalls: Array<unknown> = []; | 466 | const applyConfigCalls: Array<unknown> = []; |