11import * as NodeServices from "@effect/platform-node/NodeServices" ;
22import { describe , expect , it } from "@effect/vitest" ;
33import * as Effect from "effect/Effect" ;
4+ import * as FileSystem from "effect/FileSystem" ;
5+ import * as Layer from "effect/Layer" ;
46import * as Option from "effect/Option" ;
7+ import * as Path from "effect/Path" ;
58import * as PlatformError from "effect/PlatformError" ;
69import * as Tracer from "effect/Tracer" ;
710import {
@@ -15,6 +18,14 @@ import { EnvironmentId } from "@t3tools/contracts";
1518import { RelayClientTracer } from "@t3tools/shared/relayTracing" ;
1619import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts" ;
1720import * as ServerSecretStore from "../auth/ServerSecretStore.ts" ;
21+ import * as ServerConfigModule from "../config.ts" ;
22+ import { writeServiceState } from "../serviceLauncher.ts" ;
23+ import {
24+ SERVICE_LAUNCHER_PROTOCOL ,
25+ SERVICE_STATE_FILE ,
26+ SERVICE_STOP_MARKER_FILE ,
27+ type ServiceUpdateRecord ,
28+ } from "./serviceProtocol.ts" ;
1829import * as ServerEnvironment from "../environment/ServerEnvironment.ts" ;
1930import { CLOUD_CLI_DESIRED_LINK_SECRET } from "./CliState.ts" ;
2031import * as CliTokenManager from "./CliTokenManager.ts" ;
@@ -24,6 +35,7 @@ import {
2435 consumeCloudReplayGuards ,
2536 isSupportedLinkProviderKind ,
2637 linkProofScopes ,
38+ pendingServiceUpdateExists ,
2739 reconcileDesiredCloudLink ,
2840 releaseManagedTunnelOnShutdown ,
2941} from "./http.ts" ;
@@ -256,6 +268,23 @@ describe("releaseManagedTunnelOnShutdown", () => {
256268 readonly respond ?: ( ) => Response ;
257269 }
258270
271+ // Writes the launcher's durable state file into this test's baseDir with
272+ // the launcher's own writer; the release reads it to detect an in-flight
273+ // update handoff.
274+ const writeLauncherState = ( update : ServiceUpdateRecord ) =>
275+ Effect . gen ( function * ( ) {
276+ const path = yield * Path . Path ;
277+ const config = yield * ServerConfigModule . ServerConfig ;
278+ const statePath = path . join ( config . baseDir , "runtime" , SERVICE_STATE_FILE ) ;
279+ yield * Effect . promise ( ( ) =>
280+ writeServiceState ( statePath , {
281+ protocol : SERVICE_LAUNCHER_PROTOCOL ,
282+ activeVersion : "0.0.30" ,
283+ update,
284+ } ) ,
285+ ) ;
286+ } ) ;
287+
259288 const provideReleaseHarness =
260289 ( harness : ReleaseHarness ) =>
261290 < A , E , R > ( effect : Effect . Effect < A , E , R > ) =>
@@ -306,6 +335,14 @@ describe("releaseManagedTunnelOnShutdown", () => {
306335 } ) ,
307336 ) ,
308337 ) ,
338+ // The release consults the launcher state file under the configured
339+ // baseDir, so every harness run gets a scoped temp baseDir.
340+ Effect . provide (
341+ ServerConfigModule . layerTest ( "/" , { prefix : "t3-http-release-test-" } ) . pipe (
342+ Layer . provideMerge ( NodeServices . layer ) ,
343+ ) ,
344+ ) ,
345+ Effect . scoped ,
309346 ) ;
310347
311348 // The persisted state of a CLI-managed link whose tunnel is releasable.
@@ -390,6 +427,84 @@ describe("releaseManagedTunnelOnShutdown", () => {
390427 } ) . pipe ( provideReleaseHarness ( { store, applyConfigCalls, requests } ) ) ;
391428 } ) ;
392429
430+ it . effect ( "keeps the tunnel when shutdown hands off to a pending update" , ( ) => {
431+ const { store, values } = makeMemorySecretStore ( managedLinkSecrets ) ;
432+ const applyConfigCalls : Array < unknown > = [ ] ;
433+ const requests : Array < HttpClientRequest . HttpClientRequest > = [ ] ;
434+
435+ return Effect . gen ( function * ( ) {
436+ yield * writeLauncherState ( {
437+ id : "update-1" ,
438+ fromVersion : "0.0.30" ,
439+ targetVersion : "0.0.31" ,
440+ dbPath : "/tmp/state.sqlite" ,
441+ status : "pending" ,
442+ } ) ;
443+
444+ const released = yield * releaseManagedTunnelOnShutdown ( ) ;
445+
446+ // The launcher restarts a server immediately, so the tunnel is not
447+ // orphaned; keeping it avoids the hostname route re-propagation that
448+ // dominates update downtime. The stored config must survive so the
449+ // next boot respawns the connector against the same tunnel.
450+ expect ( released ) . toBe ( false ) ;
451+ expect ( applyConfigCalls ) . toEqual ( [ ] ) ;
452+ expect ( requests ) . toEqual ( [ ] ) ;
453+ expect ( values . has ( CLOUD_ENDPOINT_RUNTIME_CONFIG ) ) . toBe ( true ) ;
454+ } ) . pipe ( provideReleaseHarness ( { store, applyConfigCalls, requests } ) ) ;
455+ } ) ;
456+
457+ it . effect ( "still releases a pending update when the launcher is stopping" , ( ) => {
458+ // `t3 service uninstall` or `systemctl stop` during the pending window:
459+ // the launcher writes its stop marker before signalling the child, so no
460+ // replacement server is coming and the tunnel must not be kept.
461+ const { store, values } = makeMemorySecretStore ( managedLinkSecrets ) ;
462+ const applyConfigCalls : Array < unknown > = [ ] ;
463+ const requests : Array < HttpClientRequest . HttpClientRequest > = [ ] ;
464+
465+ return Effect . gen ( function * ( ) {
466+ yield * writeLauncherState ( {
467+ id : "update-1" ,
468+ fromVersion : "0.0.30" ,
469+ targetVersion : "0.0.31" ,
470+ dbPath : "/tmp/state.sqlite" ,
471+ status : "pending" ,
472+ } ) ;
473+ const fs = yield * FileSystem . FileSystem ;
474+ const path = yield * Path . Path ;
475+ const config = yield * ServerConfigModule . ServerConfig ;
476+ yield * fs . writeFileString ( path . join ( config . baseDir , "runtime" , SERVICE_STOP_MARKER_FILE ) , "" ) ;
477+
478+ expect ( yield * pendingServiceUpdateExists ) . toBe ( true ) ;
479+ const released = yield * releaseManagedTunnelOnShutdown ( ) ;
480+
481+ expect ( released ) . toBe ( true ) ;
482+ expect ( requests ) . toHaveLength ( 1 ) ;
483+ expect ( values . has ( CLOUD_ENDPOINT_RUNTIME_CONFIG ) ) . toBe ( false ) ;
484+ } ) . pipe ( provideReleaseHarness ( { store, applyConfigCalls, requests } ) ) ;
485+ } ) ;
486+
487+ it . effect ( "still releases when the recorded update already settled" , ( ) => {
488+ const { store, values } = makeMemorySecretStore ( managedLinkSecrets ) ;
489+ const applyConfigCalls : Array < unknown > = [ ] ;
490+ const requests : Array < HttpClientRequest . HttpClientRequest > = [ ] ;
491+
492+ return Effect . gen ( function * ( ) {
493+ yield * writeLauncherState ( {
494+ id : "update-1" ,
495+ fromVersion : "0.0.30" ,
496+ targetVersion : "0.0.31" ,
497+ status : "committed" ,
498+ } ) ;
499+
500+ const released = yield * releaseManagedTunnelOnShutdown ( ) ;
501+
502+ expect ( released ) . toBe ( true ) ;
503+ expect ( requests ) . toHaveLength ( 1 ) ;
504+ expect ( values . has ( CLOUD_ENDPOINT_RUNTIME_CONFIG ) ) . toBe ( false ) ;
505+ } ) . pipe ( provideReleaseHarness ( { store, applyConfigCalls, requests } ) ) ;
506+ } ) ;
507+
393508 it . effect ( "keeps a runtime config that a fast restart replaced mid-release" , ( ) => {
394509 const { store, values } = makeMemorySecretStore ( managedLinkSecrets ) ;
395510 const applyConfigCalls : Array < unknown > = [ ] ;
0 commit comments