@@ -5,8 +5,6 @@ import * as Fiber from "effect/Fiber";
55import * as FileSystem from "effect/FileSystem" ;
66import * as Layer from "effect/Layer" ;
77import * as Path from "effect/Path" ;
8- import * as PlatformError from "effect/PlatformError" ;
9- import * as Schedule from "effect/Schedule" ;
108import * as Sink from "effect/Sink" ;
119import * as Stream from "effect/Stream" ;
1210import * as TestClock from "effect/testing/TestClock" ;
@@ -229,36 +227,33 @@ describe("tailscale", () => {
229227 } ) ;
230228} ) ;
231229
232- // The wrapper keeps the default SIGTERM disposition while its `sleep` child
233- // inherits an ignored one, so SIGTERM ends the leader and leaves a group member.
234- const sigtermIgnoringTailscale = ( readyPath : string ) => `#!/bin/sh
235- trap '' TERM
236- sleep 60 &
237- child=$!
238- trap - TERM
239- echo "$$ $child" > '${ readyPath } '
230+ // The leader dies on SIGTERM while a subshell in its process group catches it and
231+ // lingers, like a helper that outlives `tailscale`. Every member holds fd 3, the
232+ // write end of the `group` FIFO, so reading it reaches EOF once the whole group is
233+ // gone. A survivor SIGKILL never reached reports itself on the way out, so a leak
234+ // fails an assertion instead of hanging the read.
235+ const lingeringTailscale = ( readyPath : string , groupPath : string ) => `#!/bin/sh
236+ exec 3> '${ groupPath } '
237+ ( trap 'sleep 3; echo survived >&3; exit' TERM
238+ sleep 60 &
239+ echo ready > '${ readyPath } '
240+ wait ) &
240241wait
241242` ;
242243
243- const isProcessAlive = ( pid : number ) =>
244- Effect . try ( ( ) => process . kill ( pid , 0 ) ) . pipe (
245- Effect . match ( { onFailure : ( ) => false , onSuccess : ( ) => true } ) ,
246- ) ;
247-
248244describe . skipIf ( HostProcess . Platform . defaultValue ( ) === "win32" ) ( "tailscale probe cleanup" , ( ) => {
249- it . effect ( "kills a timed-out status probe whose process group ignores SIGTERM" , ( ) =>
245+ it . effect ( "kills a timed-out status probe whose process group outlives SIGTERM" , ( ) =>
250246 Effect . gen ( function * ( ) {
251247 const fs = yield * FileSystem . FileSystem ;
252248 const path = yield * Path . Path ;
253249 const spawner = yield * ChildProcessSpawner . ChildProcessSpawner ;
254250 const directory = yield * fs . makeTempDirectoryScoped ( { prefix : "t3-tailscale-" } ) ;
255251 const readyPath = path . join ( directory , "ready" ) ;
256- // The fixture reports its PIDs through a FIFO so the test blocks on the
257- // fixture being up instead of polling for a file.
258- yield * spawner . exitCode ( ChildProcess . make ( "mkfifo" , [ readyPath ] ) ) ;
252+ const groupPath = path . join ( directory , "group" ) ;
253+ yield * spawner . exitCode ( ChildProcess . make ( "mkfifo" , [ readyPath , groupPath ] ) ) ;
259254 yield * fs . writeFileString (
260255 path . join ( directory , "tailscale" ) ,
261- sigtermIgnoringTailscale ( readyPath ) ,
256+ lingeringTailscale ( readyPath , groupPath ) ,
262257 { mode : 0o700 } ,
263258 ) ;
264259 yield * Effect . acquireRelease (
@@ -272,41 +267,27 @@ describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("tailscale prob
272267 process . env . PATH = originalPath ;
273268 } ) ,
274269 ) ;
275-
276- const fiber = yield * readTailscaleStatus . pipe ( Effect . flip , Effect . forkScoped ) ;
277- const pids : Array < number > = [ ] ;
270+ // Interrupting a FIFO read leaves its threadpool thread parked until a
271+ // writer opens the FIFO, so opening both releases readers a failed run left.
278272 yield * Effect . addFinalizer ( ( ) =>
279273 Effect . forEach (
280- pids ,
281- ( pid ) => Effect . try ( ( ) => process . kill ( pid , "SIGKILL" ) ) . pipe ( Effect . ignore ) ,
274+ [ readyPath , groupPath ] ,
275+ ( fifo ) => fs . open ( fifo , { flag : "r+" } ) . pipe ( Effect . scoped , Effect . ignore ) ,
282276 { discard : true } ,
283277 ) ,
284278 ) ;
285- // Interrupting a FIFO read leaves its threadpool thread parked until a
286- // writer opens the FIFO, so a failed wait opens it to release the reader.
287- const report = yield * fs . readFileString ( readyPath ) . pipe (
288- Effect . raceFirst ( Fiber . join ( fiber ) . pipe ( Effect . flatMap ( Effect . fail ) ) ) ,
289- Effect . timeout ( "5 seconds" ) ,
290- TestClock . withLive ,
291- Effect . onError ( ( ) => fs . open ( readyPath , { flag : "r+" } ) . pipe ( Effect . scoped , Effect . ignore ) ) ,
292- ) ;
293- pids . push ( ...report . trim ( ) . split ( " " ) . map ( Number ) ) ;
294- assert . deepEqual ( yield * Effect . forEach ( pids , isProcessAlive ) , [ true , true ] ) ;
295279
296- yield * TestClock . adjust ( "3500 millis" ) ;
297- const error = yield * Fiber . join ( fiber ) ;
280+ const group = yield * fs . readFileString ( groupPath ) . pipe ( Effect . forkScoped ) ;
281+ const probe = yield * readTailscaleStatus . pipe ( Effect . flip , Effect . forkScoped ) ;
282+ yield * fs
283+ . readFileString ( readyPath )
284+ . pipe ( Effect . raceFirst ( Fiber . join ( probe ) . pipe ( Effect . flatMap ( Effect . fail ) ) ) ) ;
285+
286+ yield * TestClock . adjust ( "1 minute" ) ;
287+ const error = yield * Fiber . join ( probe ) ;
298288
299289 assert . instanceOf ( error , TailscaleCommandTimeoutError ) ;
300- // SIGKILL lands asynchronously, so give the group a bounded real-time window to go.
301- const alive = yield * Effect . forEach ( pids , isProcessAlive ) . pipe (
302- Effect . repeat ( {
303- until : ( states ) => states . every ( ( state ) => ! state ) ,
304- schedule : Schedule . spaced ( "20 millis" ) ,
305- times : 100 ,
306- } ) ,
307- TestClock . withLive ,
308- ) ;
309- assert . deepEqual ( alive , [ false , false ] ) ;
290+ assert . equal ( yield * Fiber . join ( group ) , "" , "a process group member outlived the probe" ) ;
310291 } ) . pipe ( Effect . provide ( NodeServices . layer ) ) ,
311292 ) ;
312293} ) ;
0 commit comments