Skip to content

Commit 84739bc

Browse files
NitayRabiclaude
andcommitted
fix(gateway): detect musl libc and surface real spawn errors
Two related improvements to make the bundled-JRE gateway work in Alpine-based images (supergateway, node:lts-alpine) and to give a better error when it doesn't. - IBGatewayManager.isMuslLibc() (new, static): detects musl by checking process.report.getReport().header.glibcVersionRuntime, falling back to the presence of /lib/ld-musl-{x86_64,aarch64}.so.1. getJavaPath() now appends -musl to the platform key on Linux+musl so it picks runtime/linux-x64-musl or runtime/linux-arm64-musl. - Spawn diagnostics: capture spawn 'error' and non-zero 'exit' events plus a 4KB stderr tail, and reject waitForGateway with the actual reason (ENOENT on the JRE, exit code, stderr) instead of the generic "IB Gateway failed to start within 30 seconds" timeout. ENOENT on Linux+musl gets a specific hint about a missing musl runtime directory. Adds test/gateway-manager.test.ts with 6 cases covering isMuslLibc() across glibc / musl / non-Linux / process.report failure paths. README updated to mention Alpine support. Verified end-to-end: rebuilt the npm tarball locally, installed in supercorp/supergateway, sent an MCP tool call. The previous error "IB Gateway failed to start within 30 seconds" is now replaced by "Authentication required" (gateway is up, listening on 5000, running from runtime/linux-x64-musl/bin/java). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fcc6cb7 commit 84739bc

3 files changed

Lines changed: 171 additions & 12 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ your IB account to retrieve market data, check positions, and place trades.
4545
**No additional installations required!** This package includes:
4646

4747
- Pre-configured IB Gateway for all platforms (Linux, macOS, Windows)
48-
- Java Runtime Environment (JRE) for IB Gateway
48+
- Java Runtime Environment (JRE) for IB Gateway, including a musl-libc build for Alpine-based containers (e.g. `node:lts-alpine`, supergateway)
4949
- All necessary dependencies
5050

5151
You only need:

src/gateway-manager.ts

Lines changed: 85 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { spawn, ChildProcess } from 'child_process';
2-
import { promises as fs } from 'fs';
2+
import { promises as fs, existsSync as fsExistsSync } from 'fs';
33
import path from 'path';
44
import { fileURLToPath } from 'url';
55
import { 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');

test/gateway-manager.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// test/gateway-manager.test.ts
2+
import { describe, it, expect, vi, afterEach } from 'vitest';
3+
import * as fs from 'fs';
4+
import { IBGatewayManager } from '../src/gateway-manager.js';
5+
6+
vi.mock('fs', async () => {
7+
const actual = await vi.importActual<typeof fs>('fs');
8+
return {
9+
...actual,
10+
existsSync: vi.fn(actual.existsSync),
11+
};
12+
});
13+
14+
describe('IBGatewayManager.isMuslLibc', () => {
15+
const originalPlatform = process.platform;
16+
const originalReport = (process as unknown as { report?: unknown }).report;
17+
18+
afterEach(() => {
19+
Object.defineProperty(process, 'platform', { value: originalPlatform });
20+
Object.defineProperty(process, 'report', { configurable: true, value: originalReport });
21+
vi.mocked(fs.existsSync).mockReset();
22+
vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => {
23+
// Default: nothing exists unless a test opts in.
24+
return false;
25+
});
26+
});
27+
28+
it('returns false on non-Linux platforms without consulting libc', () => {
29+
Object.defineProperty(process, 'platform', { value: 'darwin' });
30+
vi.mocked(fs.existsSync).mockReturnValue(true); // would lie if consulted
31+
expect(IBGatewayManager.isMuslLibc()).toBe(false);
32+
});
33+
34+
it('returns false on Linux when process.report exposes a glibc runtime version', () => {
35+
Object.defineProperty(process, 'platform', { value: 'linux' });
36+
Object.defineProperty(process, 'report', { configurable: true, value: {
37+
getReport: () => ({ header: { glibcVersionRuntime: '2.36' } }),
38+
} });
39+
expect(IBGatewayManager.isMuslLibc()).toBe(false);
40+
});
41+
42+
it('returns true on Linux when glibcVersionRuntime is missing and the musl loader is on disk', () => {
43+
Object.defineProperty(process, 'platform', { value: 'linux' });
44+
Object.defineProperty(process, 'report', { configurable: true, value: {
45+
getReport: () => ({ header: {} }),
46+
} });
47+
vi.mocked(fs.existsSync).mockImplementation(
48+
(p: fs.PathLike) => p === '/lib/ld-musl-x86_64.so.1',
49+
);
50+
expect(IBGatewayManager.isMuslLibc()).toBe(true);
51+
});
52+
53+
it('returns true when only the aarch64 musl loader is present', () => {
54+
Object.defineProperty(process, 'platform', { value: 'linux' });
55+
Object.defineProperty(process, 'report', { configurable: true, value: {
56+
getReport: () => ({ header: {} }),
57+
} });
58+
vi.mocked(fs.existsSync).mockImplementation(
59+
(p: fs.PathLike) => p === '/lib/ld-musl-aarch64.so.1',
60+
);
61+
expect(IBGatewayManager.isMuslLibc()).toBe(true);
62+
});
63+
64+
it('returns false when neither glibc nor a musl loader is detectable', () => {
65+
Object.defineProperty(process, 'platform', { value: 'linux' });
66+
Object.defineProperty(process, 'report', { configurable: true, value: {
67+
getReport: () => ({ header: {} }),
68+
} });
69+
vi.mocked(fs.existsSync).mockReturnValue(false);
70+
expect(IBGatewayManager.isMuslLibc()).toBe(false);
71+
});
72+
73+
it('falls back to the filesystem check if process.report.getReport throws', () => {
74+
Object.defineProperty(process, 'platform', { value: 'linux' });
75+
Object.defineProperty(process, 'report', { configurable: true, value: {
76+
getReport: () => {
77+
throw new Error('not available');
78+
},
79+
} });
80+
vi.mocked(fs.existsSync).mockImplementation(
81+
(p: fs.PathLike) => p === '/lib/ld-musl-x86_64.so.1',
82+
);
83+
expect(IBGatewayManager.isMuslLibc()).toBe(true);
84+
});
85+
});

0 commit comments

Comments
 (0)