11import { assert , describe , it } from "@effect/vitest" ;
22import * as HostProcess from "@t3tools/shared/HostProcess" ;
33import * as Deferred from "effect/Deferred" ;
4+ import * as Duration from "effect/Duration" ;
45import * as Effect from "effect/Effect" ;
56import * as Exit from "effect/Exit" ;
7+ import * as Fiber from "effect/Fiber" ;
68import * as Layer from "effect/Layer" ;
79import * as Scope from "effect/Scope" ;
810import * as Sink from "effect/Sink" ;
@@ -18,8 +20,13 @@ const encoder = new TextEncoder();
1820
1921// Stands in for the tailscale CLI: `serve --bg` is refused until the daemon
2022// accepts it (operator permission granted, or Tailscale.app finished starting).
21- // With `hangs`, the attempt after the refusals never answers.
22- const makeFakeTailscale = ( refusedServeAttempts : number , hangs = false ) => {
23+ // With `hangs`, the attempt after the refusals never answers. With `ackDelay`,
24+ // the daemon applies the mapping at once but the CLI exits only after the delay.
25+ const makeFakeTailscale = (
26+ refusedServeAttempts : number ,
27+ hangs = false ,
28+ ackDelay ?: Duration . Input ,
29+ ) => {
2330 const state = { serveAttempts : 0 , configured : false , disables : 0 } ;
2431 const handle = ( args : ReadonlyArray < string > ) => {
2532 if ( args [ 0 ] === "serve" && args [ 1 ] === "--bg" ) {
@@ -31,7 +38,7 @@ const makeFakeTailscale = (refusedServeAttempts: number, hangs = false) => {
3138 return undefined ;
3239 }
3340 state . configured = true ;
34- return { code : 0 } ;
41+ return { code : 0 , ackDelay } ;
3542 }
3643 if ( args [ 0 ] === "serve" && args . at ( - 1 ) === "off" ) {
3744 state . disables += 1 ;
@@ -49,7 +56,9 @@ const makeFakeTailscale = (refusedServeAttempts: number, hangs = false) => {
4956 exitCode :
5057 result === undefined
5158 ? Effect . never
52- : Effect . succeed ( ChildProcessSpawner . ExitCode ( result . code ) ) ,
59+ : Effect . succeed ( ChildProcessSpawner . ExitCode ( result . code ) ) . pipe (
60+ Effect . delay ( "ackDelay" in result && result . ackDelay ? result . ackDelay : 0 ) ,
61+ ) ,
5362 isRunning : Effect . succeed ( false ) ,
5463 kill : ( ) => Effect . void ,
5564 unref : Effect . succeed ( Effect . void ) ,
@@ -65,8 +74,12 @@ const makeFakeTailscale = (refusedServeAttempts: number, hangs = false) => {
6574 return { state, spawner } ;
6675} ;
6776
68- const startTailscaleServe = Effect . fn ( function * ( refusedServeAttempts : number , hangs = false ) {
69- const fake = makeFakeTailscale ( refusedServeAttempts , hangs ) ;
77+ const startTailscaleServe = Effect . fn ( function * (
78+ refusedServeAttempts : number ,
79+ hangs = false ,
80+ ackDelay ?: Duration . Input ,
81+ ) {
82+ const fake = makeFakeTailscale ( refusedServeAttempts , hangs , ackDelay ) ;
7083 const parked = yield * Deferred . make < void > ( ) ;
7184 const scope = yield * Scope . make ( ) ;
7285 yield * Layer . buildWithScope (
@@ -136,6 +149,21 @@ describe("TailscaleServe", () => {
136149 } ) ,
137150 ) ;
138151
152+ it . effect ( "disables Serve on shutdown while the CLI has yet to acknowledge it" , ( ) =>
153+ Effect . gen ( function * ( ) {
154+ const { state, scope } = yield * startTailscaleServe ( 0 , false , "2 seconds" ) ;
155+ yield * Effect . yieldNow ;
156+ assert . equal ( state . serveAttempts , 1 ) ;
157+ assert . isTrue ( state . configured , "the daemon never received the mapping" ) ;
158+
159+ const shutdown = yield * Scope . close ( scope , Exit . void ) . pipe ( Effect . forkChild ) ;
160+ yield * TestClock . adjust ( "2 seconds" ) ;
161+ yield * Fiber . join ( shutdown ) ;
162+
163+ assert . equal ( state . disables , 1 , "a mapping the daemon applied outlived shutdown" ) ;
164+ } ) ,
165+ ) ;
166+
139167 it . effect ( "stops retrying on shutdown and leaves Serve alone if no attempt succeeded" , ( ) =>
140168 Effect . gen ( function * ( ) {
141169 const { state, scope } = yield * startTailscaleServe ( Number . POSITIVE_INFINITY ) ;
0 commit comments