11import { spawn , ChildProcess } from 'child_process' ;
2- import { promises as fs } from 'fs' ;
2+ import { promises as fs , existsSync as fsExistsSync } from 'fs' ;
33import path from 'path' ;
44import { fileURLToPath } from 'url' ;
55import { createRequire } from 'module' ;
@@ -20,6 +20,8 @@ export class IBGatewayManager {
2020 private cleanupHandlersRegistered = false ;
2121 private currentPort : number = 5000 ;
2222 private backgroundStartupPromise : Promise < void > | null = null ;
23+ private spawnFailure : { reason : string ; details ?: string } | null = null ;
24+ private static readonly STDERR_TAIL_BYTES = 4096 ;
2325
2426 constructor ( ) {
2527 this . gatewayDir = path . join ( __dirname , '../ib-gateway' ) ;
@@ -134,19 +136,44 @@ export class IBGatewayManager {
134136 // Removed forceKillGateway - we never kill gateway processes anymore
135137
136138 private getJavaPath ( ) : string {
137- const platform = `${ process . platform } -${ process . arch } ` ;
138139 const isWindows = process . platform === 'win32' ;
139140 const javaExecutable = isWindows ? 'java.exe' : 'java' ;
140-
141+
142+ let platform = `${ process . platform } -${ process . arch } ` ;
143+ if ( process . platform === 'linux' && IBGatewayManager . isMuslLibc ( ) ) {
144+ platform = `${ platform } -musl` ;
145+ }
146+
141147 const runtimePath = path . join ( this . jreDir , platform , 'bin' , javaExecutable ) ;
142-
143- if ( ! require ( 'fs' ) . existsSync ( runtimePath ) ) {
148+
149+ if ( ! fsExistsSync ( runtimePath ) ) {
144150 throw new Error ( `Custom runtime not found for platform: ${ platform } . Expected at: ${ runtimePath } ` ) ;
145151 }
146-
152+
147153 return runtimePath ;
148154 }
149155
156+ // Detect whether the current Linux system uses musl libc (Alpine, etc.) rather than glibc.
157+ // The bundled glibc JRE cannot exec on musl — its ELF interpreter /lib64/ld-linux-x86-64.so.2
158+ // does not exist there, producing an opaque ENOENT at spawn time.
159+ static isMuslLibc ( ) : boolean {
160+ if ( process . platform !== 'linux' ) {
161+ return false ;
162+ }
163+ // process.report.getReport() exposes glibcVersionRuntime when glibc is present.
164+ try {
165+ const report = ( process as { report ?: { getReport : ( ) => { header ?: { glibcVersionRuntime ?: string } } } } ) . report ;
166+ const glibcRuntime = report ?. getReport ?.( ) . header ?. glibcVersionRuntime ;
167+ if ( typeof glibcRuntime === 'string' && glibcRuntime . length > 0 ) {
168+ return false ;
169+ }
170+ } catch {
171+ // Fall through to filesystem check.
172+ }
173+ // Fallback: presence of the musl loader in its standard path.
174+ return fsExistsSync ( '/lib/ld-musl-x86_64.so.1' ) || fsExistsSync ( '/lib/ld-musl-aarch64.so.1' ) ;
175+ }
176+
150177 async ensureGatewayExists ( ) : Promise < void > {
151178 const gatewayPath = path . join ( this . gatewayDir , 'clientportal.gw' ) ;
152179 const runScript = path . join ( gatewayPath , 'bin/run.sh' ) ;
@@ -251,7 +278,8 @@ export class IBGatewayManager {
251278 }
252279
253280 this . isStarting = true ;
254-
281+ this . spawnFailure = null ;
282+
255283 try {
256284 await this . ensureGatewayExists ( ) ;
257285
@@ -319,6 +347,10 @@ export class IBGatewayManager {
319347 stdio : [ 'ignore' , 'pipe' , 'pipe' ]
320348 } ) ;
321349
350+ // Buffer the tail of stderr so we can include it in a failure reason if the process
351+ // dies before the gateway's HTTP port comes up.
352+ let stderrTail = '' ;
353+
322354 this . gatewayProcess . stdout ?. on ( 'data' , ( data ) => {
323355 const output = data . toString ( ) . trim ( ) ;
324356 if ( output ) {
@@ -332,20 +364,32 @@ export class IBGatewayManager {
332364 } ) ;
333365
334366 this . gatewayProcess . stderr ?. on ( 'data' , ( data ) => {
335- const output = data . toString ( ) . trim ( ) ;
336- if ( output && ! output . includes ( 'WARNING' ) ) {
337- Logger . error ( `[Gateway Error] ${ output } ` ) ;
367+ const chunk = data . toString ( ) ;
368+ stderrTail = ( stderrTail + chunk ) . slice ( - IBGatewayManager . STDERR_TAIL_BYTES ) ;
369+ const trimmed = chunk . trim ( ) ;
370+ if ( trimmed && ! trimmed . includes ( 'WARNING' ) ) {
371+ Logger . error ( `[Gateway Error] ${ trimmed } ` ) ;
338372 }
339373 } ) ;
340374
341375 this . gatewayProcess . on ( 'error' , ( error ) => {
342376 Logger . error ( '❌ Gateway process error:' , error . message ) ;
377+ this . spawnFailure = {
378+ reason : this . diagnoseSpawnError ( error , bundledJavaPath ) ,
379+ details : error . message ,
380+ } ;
343381 this . isStarting = false ;
344382 this . isReady = false ;
345383 } ) ;
346384
347385 this . gatewayProcess . on ( 'exit' , ( code , signal ) => {
348386 this . log ( `🛑 Gateway process exited with code ${ code } , signal ${ signal } ` ) ;
387+ if ( ! this . isReady && code !== 0 && code !== null ) {
388+ this . spawnFailure = {
389+ reason : `IB Gateway process exited with code ${ code } before becoming ready` ,
390+ details : stderrTail . trim ( ) || `(no stderr captured; signal=${ signal ?? 'none' } )` ,
391+ } ;
392+ }
349393 this . gatewayProcess = null ;
350394 this . isStarting = false ;
351395 this . isReady = false ;
@@ -371,6 +415,11 @@ export class IBGatewayManager {
371415 let attempts = 0 ;
372416
373417 while ( attempts < maxAttempts ) {
418+ // Bail out early if the child process already failed — no point polling for 30s.
419+ if ( this . spawnFailure ) {
420+ throw this . buildSpawnFailureError ( ) ;
421+ }
422+
374423 try {
375424 // Try to connect to the gateway port
376425 const response = await this . checkGatewayHealth ( ) ;
@@ -384,15 +433,40 @@ export class IBGatewayManager {
384433
385434 attempts ++ ;
386435 await new Promise ( resolve => setTimeout ( resolve , 1000 ) ) ;
387-
436+
388437 if ( attempts % 5 === 0 ) {
389438 this . log ( `⏳ Still waiting for gateway... (${ attempts } /${ maxAttempts } )` ) ;
390439 }
391440 }
392441
442+ if ( this . spawnFailure ) {
443+ throw this . buildSpawnFailureError ( ) ;
444+ }
393445 throw new Error ( 'IB Gateway failed to start within 30 seconds' ) ;
394446 }
395447
448+ private buildSpawnFailureError ( ) : Error {
449+ const failure = this . spawnFailure ! ;
450+ const detail = failure . details ? `\nDetails: ${ failure . details } ` : '' ;
451+ return new Error ( `${ failure . reason } ${ detail } ` ) ;
452+ }
453+
454+ private diagnoseSpawnError ( error : NodeJS . ErrnoException , javaPath : string ) : string {
455+ if ( error . code === 'ENOENT' && process . platform === 'linux' && IBGatewayManager . isMuslLibc ( ) ) {
456+ // Defensive: getJavaPath should have already routed to runtime/linux-*-musl. If we still hit
457+ // ENOENT on musl it's almost certainly a missing musl runtime directory in this build.
458+ return `Failed to spawn bundled JRE at ${ javaPath } : musl libc detected but the musl JRE was not found. ` +
459+ `If you built this package locally, ensure runtime/linux-x64-musl and runtime/linux-arm64-musl are present.` ;
460+ }
461+ if ( error . code === 'ENOENT' ) {
462+ return `Failed to spawn bundled JRE at ${ javaPath } : file not found or its dynamic loader is missing on this system.` ;
463+ }
464+ if ( error . code === 'EACCES' ) {
465+ return `Failed to spawn bundled JRE at ${ javaPath } : permission denied (the file may not be executable).` ;
466+ }
467+ return `Failed to spawn IB Gateway: ${ error . message } ` ;
468+ }
469+
396470 private async checkGatewayHealth ( ) : Promise < boolean > {
397471 // Import https dynamically to avoid issues with module resolution
398472 const https = await import ( 'https' ) ;
0 commit comments