@@ -31,6 +31,10 @@ const VIEWPORT_PROFILES = {
3131} ;
3232
3333const DASHBOARD_URL_RE = / ( h t t p : \/ \/ 1 2 7 \. 0 \. 0 \. 1 : \d + \/ ) / ;
34+ const DAEMON_HARNESS_ACTIVE = "TRACEDECAY_DAEMON_HARNESS_ACTIVE" ;
35+ const DASHBOARD_STARTUP_TIMEOUT_MS = 30_000 ;
36+ const DASHBOARD_STOP_TIMEOUT_MS = 5_000 ;
37+ const IS_UNIX = process . platform !== "win32" ;
3438
3539function workspaceRoot ( ) {
3640 return fileURLToPath ( new URL ( ".." , import . meta. url ) ) ;
@@ -40,6 +44,49 @@ function withTrailingSlash(url) {
4044 return url . endsWith ( "/" ) ? url : `${ url } /` ;
4145}
4246
47+ function runnableFile ( candidate ) {
48+ if ( ! candidate ) return null ;
49+ const resolved = path . resolve ( candidate ) ;
50+ try {
51+ if ( ! fs . statSync ( resolved ) . isFile ( ) ) return null ;
52+ if ( IS_UNIX ) fs . accessSync ( resolved , fs . constants . X_OK ) ;
53+ return resolved ;
54+ } catch {
55+ return null ;
56+ }
57+ }
58+
59+ function resolveTracedecayBinary ( ) {
60+ const cargoBinary = runnableFile ( process . env . CARGO_BIN_EXE_tracedecay ) ;
61+ if ( cargoBinary ) return cargoBinary ;
62+
63+ const suffix = process . platform === "win32" ? ".exe" : "" ;
64+ const workspaceBinary = path . join ( workspaceRoot ( ) , "target" , "debug" , `tracedecay${ suffix } ` ) ;
65+ const builtBinary = runnableFile ( workspaceBinary ) ;
66+ if ( builtBinary ) return builtBinary ;
67+
68+ throw new Error ( `built TraceDecay binary not found at ${ workspaceBinary } ` ) ;
69+ }
70+
71+ function runUnderIsolatedDaemon ( ) {
72+ const harness = path . join ( workspaceRoot ( ) , "scripts" , "with-isolated-tracedecay-daemon.sh" ) ;
73+ const tracedecayBinary = resolveTracedecayBinary ( ) ;
74+ const result = spawnSync (
75+ harness ,
76+ [ "--bin" , tracedecayBinary , "--" , process . execPath , ...process . argv . slice ( 1 ) ] ,
77+ {
78+ cwd : workspaceRoot ( ) ,
79+ env : process . env ,
80+ stdio : "inherit" ,
81+ } ,
82+ ) ;
83+ if ( result . error ) throw result . error ;
84+ if ( result . signal ) {
85+ throw new Error ( `isolated daemon harness terminated by ${ result . signal } ` ) ;
86+ }
87+ return result . status ?? 1 ;
88+ }
89+
4390// The dashboard refuses to start without a TraceDecay index, and CI checkouts
4491// (unlike dev workspaces) have no `.tracedecay/`. Build a tiny throwaway
4592// project and index it so the smoke run is hermetic everywhere.
@@ -51,71 +98,137 @@ function createSmokeWorkspace() {
5198 ) ;
5299 // stdin is closed so init's interactive `.gitignore` prompt reads EOF and
53100 // proceeds with the default instead of blocking.
54- const result = spawnSync ( "cargo" , [ "run" , "--" , "init" , dir ] , {
101+ const result = spawnSync ( resolveTracedecayBinary ( ) , [ "init" , dir ] , {
55102 cwd : workspaceRoot ( ) ,
56103 env : process . env ,
57104 stdio : [ "ignore" , "inherit" , "inherit" ] ,
58105 } ) ;
59- if ( result . status !== 0 ) {
106+ if ( result . error || result . status !== 0 ) {
60107 fs . rmSync ( dir , { recursive : true , force : true } ) ;
108+ if ( result . error ) throw result . error ;
61109 throw new Error ( `tracedecay init failed for smoke workspace (code ${ result . status } )` ) ;
62110 }
63111 return dir ;
64112}
65113
66114async function startDashboardServer ( projectPath ) {
67- return new Promise ( ( resolve , reject ) => {
68- const child = spawn (
69- "cargo" ,
70- [ "run" , "--" , "dashboard" , "--port" , "0" , "--path" , projectPath ] ,
71- {
72- cwd : workspaceRoot ( ) ,
73- env : process . env ,
74- stdio : [ "ignore" , "pipe" , "pipe" ] ,
75- } ,
76- ) ;
115+ const child = spawn (
116+ resolveTracedecayBinary ( ) ,
117+ [ "dashboard" , "--port" , "0" , "--path" , projectPath ] ,
118+ {
119+ cwd : workspaceRoot ( ) ,
120+ env : process . env ,
121+ stdio : [ "ignore" , "pipe" , "pipe" ] ,
122+ detached : IS_UNIX ,
123+ } ,
124+ ) ;
77125
78- let settled = false ;
79- let stderrBuffer = "" ;
80- const complete = ( handler , value ) => {
81- if ( settled ) return ;
82- settled = true ;
83- handler ( value ) ;
84- } ;
126+ let closed = false ;
127+ let stderrBuffer = "" ;
128+ let stopPromise = null ;
129+ const exitPromise = new Promise ( ( resolve ) => {
130+ child . once ( "error" , ( error ) => resolve ( { error } ) ) ;
131+ child . once ( "exit" , ( code , signal ) => resolve ( { code, signal } ) ) ;
132+ } ) ;
133+ child . once ( "close" , ( ) => {
134+ closed = true ;
135+ } ) ;
136+ child . stderr . on ( "data" , ( chunk ) => {
137+ stderrBuffer += chunk . toString ( ) ;
138+ } ) ;
85139
86- const stdoutLines = readline . createInterface ( { input : child . stdout } ) ;
87- stdoutLines . on ( "line" , ( line ) => {
88- process . stdout . write ( `[dashboard] ${ line } \n` ) ;
89- const match = line . match ( DASHBOARD_URL_RE ) ;
90- if ( match ) {
91- complete ( resolve , {
92- baseUrl : withTrailingSlash ( match [ 1 ] ) ,
93- child,
94- stop : async ( ) => {
95- child . kill ( "SIGTERM" ) ;
96- await new Promise ( ( done ) => child . once ( "exit" , done ) ) ;
97- } ,
98- } ) ;
99- }
100- } ) ;
140+ const stdoutLines = readline . createInterface ( { input : child . stdout } ) ;
141+ const stderrLines = readline . createInterface ( { input : child . stderr } ) ;
142+ stderrLines . on ( "line" , ( line ) => process . stderr . write ( `[dashboard:stderr] ${ line } \n` ) ) ;
101143
102- const stderrLines = readline . createInterface ( { input : child . stderr } ) ;
103- stderrLines . on ( "line" , ( line ) => {
104- stderrBuffer = `${ stderrBuffer } ${ line } \n` ;
105- process . stderr . write ( `[dashboard:stderr] ${ line } \n` ) ;
106- } ) ;
144+ const processGroupAlive = ( ) => {
145+ if ( child . pid === undefined ) return false ;
146+ if ( ! IS_UNIX ) return child . exitCode === null && child . signalCode === null ;
147+ try {
148+ process . kill ( - child . pid , 0 ) ;
149+ return true ;
150+ } catch ( error ) {
151+ if ( error . code === "ESRCH" ) return false ;
152+ if ( error . code === "EPERM" ) return true ;
153+ throw error ;
154+ }
155+ } ;
156+ const sendSignal = ( signal ) => {
157+ if ( child . pid === undefined ) return ;
158+ try {
159+ if ( IS_UNIX ) process . kill ( - child . pid , signal ) ;
160+ else child . kill ( signal ) ;
161+ } catch ( error ) {
162+ if ( error . code !== "ESRCH" ) throw error ;
163+ }
164+ } ;
165+ const waitForStop = async ( ) => {
166+ const deadline = Date . now ( ) + DASHBOARD_STOP_TIMEOUT_MS ;
167+ while ( Date . now ( ) < deadline ) {
168+ if ( closed && ! processGroupAlive ( ) ) return true ;
169+ await new Promise ( ( resolve ) => setTimeout ( resolve , 50 ) ) ;
170+ }
171+ return closed && ! processGroupAlive ( ) ;
172+ } ;
173+ const stop = ( ) => {
174+ if ( stopPromise ) return stopPromise ;
175+ stopPromise = ( async ( ) => {
176+ try {
177+ if ( ! closed || processGroupAlive ( ) ) sendSignal ( "SIGTERM" ) ;
178+ if ( ! ( await waitForStop ( ) ) ) {
179+ sendSignal ( "SIGKILL" ) ;
180+ if ( ! ( await waitForStop ( ) ) ) {
181+ throw new Error ( "dashboard server did not stop after SIGKILL" ) ;
182+ }
183+ }
184+ } finally {
185+ stdoutLines . close ( ) ;
186+ stderrLines . close ( ) ;
187+ }
188+ } ) ( ) ;
189+ return stopPromise ;
190+ } ;
107191
108- child . once ( "error" , ( err ) => {
109- complete ( reject , err ) ;
110- } ) ;
111- child . once ( "exit" , ( code ) => {
112- if ( settled ) return ;
113- complete (
114- reject ,
115- new Error ( `dashboard server exited before startup (code ${ code } )\n${ stderrBuffer } ` ) ,
192+ try {
193+ const baseUrl = await new Promise ( ( resolve , reject ) => {
194+ let settled = false ;
195+ const complete = ( handler , value ) => {
196+ if ( settled ) return ;
197+ settled = true ;
198+ clearTimeout ( timer ) ;
199+ handler ( value ) ;
200+ } ;
201+ const timer = setTimeout (
202+ ( ) => complete ( reject , new Error (
203+ `dashboard server startup timed out after ${ DASHBOARD_STARTUP_TIMEOUT_MS } ms` ,
204+ ) ) ,
205+ DASHBOARD_STARTUP_TIMEOUT_MS ,
116206 ) ;
207+ stdoutLines . on ( "line" , ( line ) => {
208+ process . stdout . write ( `[dashboard] ${ line } \n` ) ;
209+ const match = line . match ( DASHBOARD_URL_RE ) ;
210+ if ( match ) complete ( resolve , withTrailingSlash ( match [ 1 ] ) ) ;
211+ } ) ;
212+ exitPromise . then ( ( { code, signal, error } ) => {
213+ const detail = error ?. message ?? `code ${ code } ${ signal ? `, signal ${ signal } ` : "" } ` ;
214+ complete ( reject , new Error ( `dashboard server exited before startup (${ detail } )` ) ) ;
215+ } ) ;
117216 } ) ;
118- } ) ;
217+ return { baseUrl, child, stop } ;
218+ } catch ( error ) {
219+ let stopError = null ;
220+ try {
221+ await stop ( ) ;
222+ } catch ( caught ) {
223+ stopError = caught ;
224+ }
225+ const diagnostics = stderrBuffer . trim ( ) ;
226+ const message = error instanceof Error ? error . message : String ( error ) ;
227+ const stopMessage = stopError ? `\nshutdown error: ${ stopError . message } ` : "" ;
228+ throw new Error ( `${ message } ${ diagnostics ? `\n${ diagnostics } ` : "" } ${ stopMessage } ` , {
229+ cause : error ,
230+ } ) ;
231+ }
119232}
120233
121234async function waitForAny ( page , locators , timeoutMs ) {
@@ -357,6 +470,13 @@ async function main() {
357470 }
358471 return profile ;
359472 } ) ;
473+
474+ if ( ! explicitUrl && process . env [ DAEMON_HARNESS_ACTIVE ] !== "1" ) {
475+ console . log ( "Starting hermetic foreground TraceDecay daemon for smoke test..." ) ;
476+ process . exitCode = runUnderIsolatedDaemon ( ) ;
477+ return ;
478+ }
479+
360480 let server = null ;
361481 let workspace = null ;
362482
@@ -365,6 +485,7 @@ async function main() {
365485 server = { baseUrl : explicitUrl , stop : async ( ) => { } } ;
366486 console . log ( `Using existing dashboard URL: ${ explicitUrl } ` ) ;
367487 } else {
488+ console . log ( "Smoke daemon is ready." ) ;
368489 console . log ( "Creating hermetic smoke workspace (tracedecay init)..." ) ;
369490 workspace = createSmokeWorkspace ( ) ;
370491 console . log ( `Starting \`tracedecay dashboard --port 0 --path ${ workspace } \` for smoke test...` ) ;
0 commit comments