diff --git a/packages/web/bin/cli.js b/packages/web/bin/cli.js index 82517971..d52d5495 100755 --- a/packages/web/bin/cli.js +++ b/packages/web/bin/cli.js @@ -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) { diff --git a/packages/web/server/lib/opencode/server-startup-runtime.js b/packages/web/server/lib/opencode/server-startup-runtime.js index ddcc4700..551badd5 100644 --- a/packages/web/server/lib/opencode/server-startup-runtime.js +++ b/packages/web/server/lib/opencode/server-startup-runtime.js @@ -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);