fix: make daemon startup ready handoff reliable
Wait longer for slow daemon startup Fail cleanly when ready handoff does not complete Avoid orphaned daemon processes after startup timeout
This commit is contained in:
+33
-19
@@ -29,6 +29,7 @@ const __dirname = path.dirname(__filename);
|
||||
|
||||
const DEFAULT_PORT = 3000;
|
||||
const DEFAULT_TAIL_LINES = 200;
|
||||
const DAEMON_READY_TIMEOUT_MS = 30000;
|
||||
const LOG_ROTATE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const LOG_ROTATE_KEEP = 5;
|
||||
const TUNNEL_PROFILES_VERSION = 1;
|
||||
@@ -3006,30 +3007,43 @@ const commands = {
|
||||
child.unref();
|
||||
serveSpin?.start(`Starting OpenChamber on port ${targetPort === 0 ? 'auto' : targetPort}...`);
|
||||
|
||||
const resolvedPort = await new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(targetPort);
|
||||
}, 5000);
|
||||
let resolvedPort;
|
||||
try {
|
||||
resolvedPort = await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(new Error(`OpenChamber daemon did not report ready within ${DAEMON_READY_TIMEOUT_MS / 1000}s`));
|
||||
}, DAEMON_READY_TIMEOUT_MS);
|
||||
|
||||
child.on('message', (msg) => {
|
||||
if (settled) return;
|
||||
if (msg && msg.type === 'openchamber:ready' && typeof msg.port === 'number') {
|
||||
child.on('message', (msg) => {
|
||||
if (settled) return;
|
||||
if (msg && msg.type === 'openchamber:ready' && typeof msg.port === 'number') {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve(msg.port);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve(msg.port);
|
||||
}
|
||||
});
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on('exit', () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve(targetPort);
|
||||
child.on('exit', (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`OpenChamber daemon exited before reporting ready${signal ? ` (${signal})` : ` (code ${code ?? 'unknown'})`}`));
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
await terminateProcessTree(child.pid, { gracefulTimeoutMs: 1500, forceTimeoutMs: 1500 });
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof child.disconnect === 'function' && child.connected) {
|
||||
|
||||
@@ -38,68 +38,86 @@ export const createServerStartupRuntime = (dependencies) => {
|
||||
server.once('error', onError);
|
||||
const onListening = async () => {
|
||||
server.off('error', onError);
|
||||
const addressInfo = server.address();
|
||||
activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port;
|
||||
|
||||
try {
|
||||
process.send?.({ type: 'openchamber:ready', port: activePort });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const addressInfo = server.address();
|
||||
activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port;
|
||||
|
||||
const displayHost = (bindHost === '0.0.0.0' || bindHost === '::' || bindHost === '[::]')
|
||||
? 'localhost'
|
||||
: (bindHost.includes(':') ? `[${bindHost}]` : bindHost);
|
||||
console.log(`OpenChamber server listening on ${bindHost}:${activePort}`);
|
||||
console.log(`Health check: http://${displayHost}:${activePort}/health`);
|
||||
console.log(`Web interface: http://${displayHost}:${activePort}`);
|
||||
|
||||
if (startupTunnelRequest) {
|
||||
const startupModeLabel = startupTunnelRequest.mode === TUNNEL_MODE_QUICK
|
||||
? 'Quick Tunnel'
|
||||
: (startupTunnelRequest.mode === TUNNEL_MODE_MANAGED_LOCAL
|
||||
? 'Managed Local Tunnel'
|
||||
: (startupTunnelRequest.mode === TUNNEL_MODE_MANAGED_REMOTE ? 'Managed Remote Tunnel' : 'Tunnel'));
|
||||
console.log(`\nInitializing ${startupModeLabel} for provider '${startupTunnelRequest.provider}'...`);
|
||||
try {
|
||||
const { publicUrl, mode } = await startTunnelWithNormalizedRequest({
|
||||
provider: startupTunnelRequest.provider,
|
||||
mode: startupTunnelRequest.mode,
|
||||
intent: startupTunnelRequest.intent,
|
||||
hostname: startupTunnelRequest.hostname,
|
||||
token: startupTunnelRequest.token,
|
||||
configPath: startupTunnelRequest.configPath,
|
||||
selectedPresetId: '',
|
||||
selectedPresetName: '',
|
||||
});
|
||||
if (publicUrl) {
|
||||
tunnelAuthController.setActiveTunnel({
|
||||
tunnelId: crypto.randomUUID(),
|
||||
publicUrl,
|
||||
mode,
|
||||
});
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const bootstrapTtlMs = settings?.tunnelBootstrapTtlMs === null
|
||||
? null
|
||||
: normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs);
|
||||
const bootstrapToken = tunnelAuthController.issueBootstrapToken({ ttlMs: bootstrapTtlMs });
|
||||
const connectUrl = `${publicUrl.replace(/\/$/, '')}/connect?t=${encodeURIComponent(bootstrapToken.token)}`;
|
||||
if (onTunnelReady) {
|
||||
onTunnelReady(publicUrl, connectUrl);
|
||||
} else {
|
||||
console.log(`\n🌐 Tunnel URL: ${connectUrl}`);
|
||||
console.log('🔑 One-time connect link (expires after first use)\n');
|
||||
}
|
||||
} else if (onTunnelReady) {
|
||||
onTunnelReady(publicUrl, null);
|
||||
if (typeof process.send === 'function') {
|
||||
if (!process.connected) {
|
||||
throw new Error('OpenChamber startup IPC channel disconnected before ready notification');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to start tunnel: ${error.message}`);
|
||||
console.log('Continuing without tunnel...');
|
||||
}
|
||||
}
|
||||
|
||||
resolve();
|
||||
await new Promise((resolveReadyNotification, rejectReadyNotification) => {
|
||||
try {
|
||||
process.send({ type: 'openchamber:ready', port: activePort }, (error) => {
|
||||
if (error) {
|
||||
rejectReadyNotification(error);
|
||||
return;
|
||||
}
|
||||
resolveReadyNotification();
|
||||
});
|
||||
} catch (error) {
|
||||
rejectReadyNotification(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const displayHost = (bindHost === '0.0.0.0' || bindHost === '::' || bindHost === '[::]')
|
||||
? 'localhost'
|
||||
: (bindHost.includes(':') ? `[${bindHost}]` : bindHost);
|
||||
console.log(`OpenChamber server listening on ${bindHost}:${activePort}`);
|
||||
console.log(`Health check: http://${displayHost}:${activePort}/health`);
|
||||
console.log(`Web interface: http://${displayHost}:${activePort}`);
|
||||
|
||||
if (startupTunnelRequest) {
|
||||
const startupModeLabel = startupTunnelRequest.mode === TUNNEL_MODE_QUICK
|
||||
? 'Quick Tunnel'
|
||||
: (startupTunnelRequest.mode === TUNNEL_MODE_MANAGED_LOCAL
|
||||
? 'Managed Local Tunnel'
|
||||
: (startupTunnelRequest.mode === TUNNEL_MODE_MANAGED_REMOTE ? 'Managed Remote Tunnel' : 'Tunnel'));
|
||||
console.log(`\nInitializing ${startupModeLabel} for provider '${startupTunnelRequest.provider}'...`);
|
||||
try {
|
||||
const { publicUrl, mode } = await startTunnelWithNormalizedRequest({
|
||||
provider: startupTunnelRequest.provider,
|
||||
mode: startupTunnelRequest.mode,
|
||||
intent: startupTunnelRequest.intent,
|
||||
hostname: startupTunnelRequest.hostname,
|
||||
token: startupTunnelRequest.token,
|
||||
configPath: startupTunnelRequest.configPath,
|
||||
selectedPresetId: '',
|
||||
selectedPresetName: '',
|
||||
});
|
||||
if (publicUrl) {
|
||||
tunnelAuthController.setActiveTunnel({
|
||||
tunnelId: crypto.randomUUID(),
|
||||
publicUrl,
|
||||
mode,
|
||||
});
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const bootstrapTtlMs = settings?.tunnelBootstrapTtlMs === null
|
||||
? null
|
||||
: normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs);
|
||||
const bootstrapToken = tunnelAuthController.issueBootstrapToken({ ttlMs: bootstrapTtlMs });
|
||||
const connectUrl = `${publicUrl.replace(/\/$/, '')}/connect?t=${encodeURIComponent(bootstrapToken.token)}`;
|
||||
if (onTunnelReady) {
|
||||
onTunnelReady(publicUrl, connectUrl);
|
||||
} else {
|
||||
console.log(`\n🌐 Tunnel URL: ${connectUrl}`);
|
||||
console.log('🔑 One-time connect link (expires after first use)\n');
|
||||
}
|
||||
} else if (onTunnelReady) {
|
||||
onTunnelReady(publicUrl, null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to start tunnel: ${error.message}`);
|
||||
console.log('Continuing without tunnel...');
|
||||
}
|
||||
}
|
||||
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
server.listen(port, bindHost, onListening);
|
||||
|
||||
Reference in New Issue
Block a user