@@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url';
33import { dirname , resolve } from 'node:path' ;
44import getPort from 'get-port' ;
55import open from 'open' ;
6- import { runPreflight , writeLock , releaseLock } from './preflight.js' ;
6+ import { runPreflight , writeLockMeta } from './preflight.js' ;
77import { SessionStore } from './session.js' ;
88import { RunManager } from './runManager.js' ;
99import { createRouter } from './routes.js' ;
@@ -28,85 +28,113 @@ async function main(): Promise<void> {
2828 process . exit ( 1 ) ;
2929 }
3030
31- const sessionStore = await SessionStore . load ( preflight . storageRoot , cwd ) ;
32- const port = await getPort ( { port : [ DEFAULT_PREF_PORT , 6801 , 6802 , 6803 , 6804 , 0 ] } ) ;
33- const auth = makeAuthContext ( port ) ;
31+ // The lock is owned from this point forward; release it on any failure so
32+ // a botched startup doesn't leave a stale lock blocking subsequent runs
33+ // until the staleness window expires.
34+ try {
35+ const sessionStore = await SessionStore . load ( preflight . storageRoot , cwd ) ;
36+ const port = await getPort ( { port : [ DEFAULT_PREF_PORT , 6801 , 6802 , 6803 , 6804 , 0 ] } ) ;
37+ const auth = makeAuthContext ( port ) ;
3438
35- const runManager = new RunManager ( {
36- claudeBin,
37- cwd,
38- storageRoot : preflight . storageRoot ,
39- session : sessionStore ,
40- } ) ;
41- await runManager . init ( ) ;
39+ const runManager = new RunManager ( {
40+ claudeBin,
41+ cwd,
42+ storageRoot : preflight . storageRoot ,
43+ session : sessionStore ,
44+ } ) ;
45+ await runManager . init ( ) ;
4246
43- const webRoot = resolveWebRoot ( ) ;
44- const handler = createRouter ( { auth, session : sessionStore , runManager, webRoot, cwd } ) ;
47+ const webRoot = resolveWebRoot ( ) ;
48+ const handler = createRouter ( { auth, session : sessionStore , runManager, webRoot, cwd } ) ;
4549
46- const server = createServer ( ( req , res ) => {
47- Promise . resolve ( handler ( req , res ) ) . catch ( ( err ) => {
48- log . error ( 'http.handler-rejected' , { error : ( err as Error ) . message } ) ;
49- try {
50- res . statusCode = 500 ;
51- res . end ( ) ;
52- } catch {
53- /* */
54- }
50+ const server = createServer ( ( req , res ) => {
51+ Promise . resolve ( handler ( req , res ) ) . catch ( ( err ) => {
52+ log . error ( 'http.handler-rejected' , { error : ( err as Error ) . message } ) ;
53+ try {
54+ res . statusCode = 500 ;
55+ res . end ( ) ;
56+ } catch {
57+ /* */
58+ }
59+ } ) ;
5560 } ) ;
56- } ) ;
5761
58- server . listen ( port , '127.0.0.1' , async ( ) => {
59- await writeLock ( preflight . lockFilePath , process . pid , port ) ;
60- const url = `${ auth . origin } /?t=${ auth . token } ` ;
61- log . info ( 'server.listening' , { url, pid : process . pid } ) ;
62- console . log ( `mdredd listening at ${ url } ` ) ;
63- if ( shouldOpen ) {
64- open ( url ) . catch ( ( err ) => {
65- console . log ( `(could not open browser automatically: ${ err . message } )` ) ;
66- } ) ;
67- }
68- } ) ;
62+ // Bind failures (EADDRINUSE/EACCES) surface via the 'error' event, not
63+ // the listen callback. Without this handler Node would emit an unhandled
64+ // 'error' and exit while still holding the proper-lockfile lock,
65+ // blocking restarts until the stale window expires.
66+ server . once ( 'error' , ( err ) => {
67+ console . error ( `mdredd: failed to bind ${ port } : ${ err . message } ` ) ;
68+ log . error ( 'server.listen-error' , { port, error : err . message } ) ;
69+ void preflight . releaseLock ( ) . finally ( ( ) => process . exit ( 1 ) ) ;
70+ } ) ;
6971
70- // 5s for runners to drain (each runner self-bounds at SIGTERM+2s SIGKILL),
71- // plus 3s slack for lockfile / FS work. Anything still hanging past the hard
72- // timer is force-exited so a stuck child can never wedge the server.
73- const STOP_RUNNERS_TIMEOUT_MS = 5_000 ;
74- const HARD_SHUTDOWN_TIMEOUT_MS = 8_000 ;
72+ server . listen ( port , '127.0.0.1' , ( ) => {
73+ // Keep this callback synchronous so a writeLockMeta rejection cannot
74+ // escape as an unhandled promise rejection. On failure release the
75+ // lock (we just acquired it but never wrote the sidecar) and exit.
76+ writeLockMeta ( preflight . lockFilePath , process . pid , port )
77+ . then ( ( ) => {
78+ const url = `${ auth . origin } /?t=${ auth . token } ` ;
79+ log . info ( 'server.listening' , { url, pid : process . pid } ) ;
80+ console . log ( `mdredd listening at ${ url } ` ) ;
81+ if ( shouldOpen ) {
82+ open ( url ) . catch ( ( err ) => {
83+ console . log ( `(could not open browser automatically: ${ err . message } )` ) ;
84+ } ) ;
85+ }
86+ } )
87+ . catch ( ( err ) => {
88+ console . error ( `mdredd: could not write lock metadata: ${ ( err as Error ) . message } ` ) ;
89+ log . error ( 'server.lock-meta-failed' , { error : ( err as Error ) . message } ) ;
90+ void preflight . releaseLock ( ) . finally ( ( ) => process . exit ( 1 ) ) ;
91+ } ) ;
92+ } ) ;
93+
94+ // 5s for runners to drain (each runner self-bounds at SIGTERM+2s SIGKILL),
95+ // plus 3s slack for lockfile / FS work. Anything still hanging past the hard
96+ // timer is force-exited so a stuck child can never wedge the server.
97+ const STOP_RUNNERS_TIMEOUT_MS = 5_000 ;
98+ const HARD_SHUTDOWN_TIMEOUT_MS = 8_000 ;
7599
76- let shuttingDown = false ;
77- const shutdown = async ( sig : string ) : Promise < void > => {
78- if ( shuttingDown ) return ;
79- shuttingDown = true ;
80- log . info ( 'server.shutdown' , { signal : sig , activeRuns : runManager . activeCount ( ) } ) ;
81- const hardTimer = setTimeout ( ( ) => {
82- log . error ( 'server.shutdown-forced-exit' , { reason : 'hard timeout exceeded' } ) ;
83- process . exit ( 1 ) ;
84- } , HARD_SHUTDOWN_TIMEOUT_MS ) ;
85- hardTimer . unref ( ) ;
86- try {
87- // Stop accepting new HTTP traffic and drop existing keep-alive/SSE
88- // connections so the server's `listening` socket and the SSE keepers
89- // don't hold the event loop open.
90- server . close ( ) ;
91- server . closeAllConnections ?.( ) ;
92- const result = await runManager . stopAll ( STOP_RUNNERS_TIMEOUT_MS ) ;
93- if ( result . timedOut ) {
94- log . warn ( 'server.shutdown.stopAll-timeout' , { stopped : result . stopped } ) ;
100+ let shuttingDown = false ;
101+ const shutdown = async ( sig : string ) : Promise < void > => {
102+ if ( shuttingDown ) return ;
103+ shuttingDown = true ;
104+ log . info ( 'server.shutdown' , { signal : sig , activeRuns : runManager . activeCount ( ) } ) ;
105+ const hardTimer = setTimeout ( ( ) => {
106+ log . error ( 'server.shutdown-forced-exit' , { reason : 'hard timeout exceeded' } ) ;
107+ process . exit ( 1 ) ;
108+ } , HARD_SHUTDOWN_TIMEOUT_MS ) ;
109+ hardTimer . unref ( ) ;
110+ try {
111+ // Stop accepting new HTTP traffic and drop existing keep-alive/SSE
112+ // connections so the server's `listening` socket and the SSE keepers
113+ // don't hold the event loop open.
114+ server . close ( ) ;
115+ server . closeAllConnections ?.( ) ;
116+ const result = await runManager . stopAll ( STOP_RUNNERS_TIMEOUT_MS ) ;
117+ if ( result . timedOut ) {
118+ log . warn ( 'server.shutdown.stopAll-timeout' , { stopped : result . stopped } ) ;
119+ }
120+ await preflight . releaseLock ( ) ;
121+ } catch ( err ) {
122+ log . error ( 'server.shutdown-error' , { error : ( err as Error ) . message } ) ;
123+ } finally {
124+ clearTimeout ( hardTimer ) ;
95125 }
96- await releaseLock ( preflight . lockFilePath ) ;
97- } catch ( err ) {
98- log . error ( 'server.shutdown-error' , { error : ( err as Error ) . message } ) ;
99- } finally {
100- clearTimeout ( hardTimer ) ;
101- }
102- process . exit ( 0 ) ;
103- } ;
104- process . on ( 'SIGINT' , ( ) => {
105- void shutdown ( 'SIGINT' ) ;
106- } ) ;
107- process . on ( 'SIGTERM' , ( ) => {
108- void shutdown ( 'SIGTERM' ) ;
109- } ) ;
126+ process . exit ( 0 ) ;
127+ } ;
128+ process . on ( 'SIGINT' , ( ) => {
129+ void shutdown ( 'SIGINT' ) ;
130+ } ) ;
131+ process . on ( 'SIGTERM' , ( ) => {
132+ void shutdown ( 'SIGTERM' ) ;
133+ } ) ;
134+ } catch ( err ) {
135+ await preflight . releaseLock ( ) . catch ( ( ) => undefined ) ;
136+ throw err ;
137+ }
110138}
111139
112140function resolveWebRoot ( ) : string {
0 commit comments