11import { assert , describe , it } from "@effect/vitest" ;
2+ import * as Deferred from "effect/Deferred" ;
3+ import * as Duration from "effect/Duration" ;
24import * as Effect from "effect/Effect" ;
5+ import * as Fiber from "effect/Fiber" ;
6+ import * as Queue from "effect/Queue" ;
7+ import * as TestClock from "effect/testing/TestClock" ;
38import * as FileSystem from "effect/FileSystem" ;
49import * as Layer from "effect/Layer" ;
510import * as Option from "effect/Option" ;
611import * as Ref from "effect/Ref" ;
12+ import * as Sink from "effect/Sink" ;
713import * as Stream from "effect/Stream" ;
8- import { HttpClient } from "effect/http" ;
14+ import { HttpClient , HttpClientResponse } from "effect/http" ;
915import { ChildProcessSpawner } from "effect/process" ;
1016
1117import * as DesktopObservability from "../app/DesktopObservability.ts" ;
@@ -21,18 +27,105 @@ import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
2127import * as DesktopBackendPool from "./DesktopBackendPool.ts" ;
2228import type { DesktopBackendStartConfig } from "./DesktopBackendManager.ts" ;
2329
24- function layerPool ( labelRef : Ref . Ref < string > ) : Layer . Layer < DesktopBackendPool . DesktopBackendPool > {
30+ // Models the spawner's release for a backend that ignores SIGTERM, without OS signals.
31+ const makeFakeBackendProcess = Effect . fn ( "test.makeFakeBackendProcess" ) ( function * ( ) {
32+ const started = yield * Deferred . make < void > ( ) ;
33+ const exited = yield * Deferred . make < ChildProcessSpawner . ExitCode > ( ) ;
34+ const signals = yield * Queue . unbounded < "SIGTERM" | "SIGKILL" > ( ) ;
35+ let running = false ;
36+ const killWait = Duration . seconds ( 1 ) ;
37+ const exit = Effect . sync ( ( ) => {
38+ running = false ;
39+ } ) . pipe ( Effect . andThen ( Deferred . succeed ( exited , ChildProcessSpawner . ExitCode ( 0 ) ) ) , Effect . asVoid ) ;
40+ const layer = Layer . succeed (
41+ ChildProcessSpawner . ChildProcessSpawner ,
42+ ChildProcessSpawner . make ( ( command ) =>
43+ Effect . gen ( function * ( ) {
44+ if ( command . _tag !== "StandardCommand" ) {
45+ return yield * Effect . die ( "Expected a standard backend command" ) ;
46+ }
47+ const terminateGrace = Duration . fromInputUnsafe ( command . options . forceKillAfter ?? 0 ) ;
48+ running = true ;
49+ yield * Effect . addFinalizer ( ( ) =>
50+ Effect . gen ( function * ( ) {
51+ yield * Queue . offer ( signals , "SIGTERM" ) ;
52+ yield * Effect . sleep ( terminateGrace ) ;
53+ yield * Queue . offer ( signals , "SIGKILL" ) ;
54+ yield * Effect . sleep ( killWait ) ;
55+ yield * exit ;
56+ } ) ,
57+ ) ;
58+ yield * Deferred . succeed ( started , undefined ) ;
59+ return ChildProcessSpawner . makeHandle ( {
60+ pid : ChildProcessSpawner . ProcessId ( 123 ) ,
61+ stdout : Stream . empty ,
62+ stderr : Stream . empty ,
63+ all : Stream . empty ,
64+ stdin : Sink . drain ,
65+ getInputFd : ( ) => Sink . drain ,
66+ getOutputFd : ( ) => Stream . empty ,
67+ exitCode : Deferred . await ( exited ) ,
68+ isRunning : Effect . sync ( ( ) => running ) ,
69+ kill : ( ) => exit ,
70+ unref : Effect . succeed ( Effect . void ) ,
71+ } ) ;
72+ } ) ,
73+ ) ,
74+ ) ;
75+ return {
76+ layer,
77+ started : Deferred . await ( started ) ,
78+ signals,
79+ isRunning : Effect . sync ( ( ) => running ) ,
80+ killWait,
81+ } ;
82+ } ) ;
83+
84+ const runningConfig = {
85+ executablePath : "/electron" ,
86+ args : [ ] ,
87+ entryPath : "/server/bin.mjs" ,
88+ cwd : "/server" ,
89+ env : { } ,
90+ bootstrap : {
91+ mode : "desktop" ,
92+ noBrowser : true ,
93+ port : 3774 ,
94+ t3Home : "/tmp/t3" ,
95+ host : "127.0.0.1" ,
96+ desktopBootstrapToken : "token" ,
97+ tailscaleServeEnabled : false ,
98+ tailscaleServePort : 443 ,
99+ } ,
100+ bootstrapDelivery : "fd3" ,
101+ extendEnv : true ,
102+ httpBaseUrl : new URL ( "http://127.0.0.1:3774" ) ,
103+ captureOutput : false ,
104+ preflightFailure : Option . none ( ) ,
105+ } satisfies DesktopBackendStartConfig ;
106+
107+ function layerPool (
108+ labelRef : Ref . Ref < string > ,
109+ backend ?: Effect . Success < ReturnType < typeof makeFakeBackendProcess > > ,
110+ ) : Layer . Layer < DesktopBackendPool . DesktopBackendPool > {
25111 return DesktopBackendPool . layer . pipe (
26112 Layer . provideMerge (
27113 Layer . mergeAll (
28- FileSystem . layerNoop ( { } ) ,
29- Layer . succeed (
30- ChildProcessSpawner . ChildProcessSpawner ,
31- ChildProcessSpawner . make ( ( ) => Effect . die ( "unexpected child process spawn" ) ) ,
32- ) ,
114+ FileSystem . layerNoop ( { exists : ( ) => Effect . succeed ( true ) } ) ,
115+ backend ?. layer ??
116+ Layer . succeed (
117+ ChildProcessSpawner . ChildProcessSpawner ,
118+ ChildProcessSpawner . make ( ( ) => Effect . die ( "unexpected child process spawn" ) ) ,
119+ ) ,
33120 Layer . succeed (
34121 HttpClient . HttpClient ,
35- HttpClient . make ( ( ) => Effect . die ( "unexpected HTTP request" ) ) ,
122+ HttpClient . make ( ( request ) =>
123+ backend === undefined
124+ ? Effect . die ( "unexpected HTTP request" )
125+ : Effect . succeed (
126+ HttpClientResponse . fromWeb ( request , new Response ( null , { status : 200 } ) ) ,
127+ ) ,
128+ ) ,
36129 ) ,
37130 Layer . succeed ( DesktopObservability . DesktopBackendOutputLogFactory , {
38131 forInstance : ( ) =>
@@ -90,6 +183,62 @@ function layerPool(labelRef: Ref.Ref<string>): Layer.Layer<DesktopBackendPool.De
90183}
91184
92185describe ( "DesktopBackendPool" , ( ) => {
186+ it . effect ( "unregister during pool quit does not interrupt sibling stops" , ( ) =>
187+ Effect . scoped (
188+ Effect . gen ( function * ( ) {
189+ const backend = yield * makeFakeBackendProcess ( ) ;
190+ yield * Effect . gen ( function * ( ) {
191+ const pool = yield * DesktopBackendPool . DesktopBackendPool ;
192+ const id = DesktopBackendPool . BackendInstanceId ( "wsl:Ubuntu" ) ;
193+ const ready = yield * Deferred . make < void > ( ) ;
194+ const siblingStarted = yield * Deferred . make < void > ( ) ;
195+ const finishSibling = yield * Deferred . make < void > ( ) ;
196+ let siblingCompleted = false ;
197+ const instance = yield * pool . register ( {
198+ id,
199+ label : Effect . succeed ( "WSL (Ubuntu)" ) ,
200+ configResolve : Effect . succeed ( runningConfig ) ,
201+ onReady : ( ) => Deferred . succeed ( ready , undefined ) . pipe ( Effect . asVoid ) ,
202+ } ) ;
203+ yield * instance . start ;
204+ yield * backend . started ;
205+ yield * Deferred . await ( ready ) ;
206+ const quitFiber = yield * Effect . all (
207+ [
208+ instance . stop ( { timeout : Duration . seconds ( 5 ) } ) ,
209+ Deferred . succeed ( siblingStarted , undefined ) . pipe (
210+ Effect . andThen ( Deferred . await ( finishSibling ) ) ,
211+ Effect . andThen (
212+ Effect . sync ( ( ) => {
213+ siblingCompleted = true ;
214+ } ) ,
215+ ) ,
216+ ) ,
217+ ] ,
218+ { concurrency : "unbounded" } ,
219+ ) . pipe ( Effect . forkChild ) ;
220+ yield * Deferred . await ( siblingStarted ) ;
221+ assert . equal ( yield * Queue . take ( backend . signals ) , "SIGTERM" ) ;
222+ const unregisterFiber = yield * pool . unregister ( id ) . pipe ( Effect . forkChild ) ;
223+ yield * TestClock . adjust ( Duration . seconds ( 5 ) ) ;
224+ assert . equal ( yield * Queue . take ( backend . signals ) , "SIGKILL" ) ;
225+ assert . isUndefined ( quitFiber . pollUnsafe ( ) ) ;
226+ assert . isUndefined ( unregisterFiber . pollUnsafe ( ) ) ;
227+ assert . isTrue ( yield * backend . isRunning ) ;
228+ yield * TestClock . adjust ( backend . killWait ) ;
229+ yield * Fiber . join ( unregisterFiber ) ;
230+ yield * Deferred . succeed ( finishSibling , undefined ) ;
231+ yield * Fiber . join ( quitFiber ) ;
232+
233+ assert . isTrue ( siblingCompleted ) ;
234+ assert . isFalse ( yield * backend . isRunning ) ;
235+ assert . isTrue ( Option . isNone ( ( yield * instance . snapshot ) . activePid ) ) ;
236+ assert . isTrue ( Option . isNone ( yield * pool . get ( id ) ) ) ;
237+ } ) . pipe ( Effect . provide ( layerPool ( Ref . makeUnsafe ( "Windows" ) , backend ) ) ) ;
238+ } ) ,
239+ ) ,
240+ ) ;
241+
93242 it . effect ( "unregister stops the registered instance" , ( ) =>
94243 Effect . scoped (
95244 Effect . gen ( function * ( ) {
@@ -99,27 +248,9 @@ describe("DesktopBackendPool", () => {
99248 id,
100249 label : Effect . succeed ( "WSL (Ubuntu)" ) ,
101250 configResolve : Effect . succeed ( {
102- executablePath : "/electron" ,
103- args : [ ] ,
104- entryPath : "/server/bin.mjs" ,
105- cwd : "/server" ,
106- env : { } ,
107- bootstrap : {
108- mode : "desktop" ,
109- noBrowser : true ,
110- port : 3774 ,
111- t3Home : "/tmp/t3" ,
112- host : "127.0.0.1" ,
113- desktopBootstrapToken : "token" ,
114- tailscaleServeEnabled : false ,
115- tailscaleServePort : 443 ,
116- } ,
117- bootstrapDelivery : "fd3" ,
118- extendEnv : true ,
119- httpBaseUrl : new URL ( "http://127.0.0.1:3774" ) ,
120- captureOutput : false ,
251+ ...runningConfig ,
121252 preflightFailure : Option . some ( { reason : "WSL starting" , fatal : false } ) ,
122- } satisfies DesktopBackendStartConfig ) ,
253+ } ) ,
123254 } ) ;
124255 yield * instance . start ;
125256 assert . equal ( ( yield * instance . snapshot ) . restartScheduled , true ) ;
0 commit comments