OpenChamber spawns the OpenCode server as an external child binary (detached on Unix), so a hard crash, SIGKILL, or Ctrl+C of the host before graceful teardown could leave it running. Orphaned servers then accumulate and contend on the shared SQLite DB, causing severe startup slowdowns. Add a per-process registry plus a startup reaper, mirroring the pattern OpenCode's own CLI daemon uses for its detached server: - One file per spawned process at ~/.config/openchamber/managed-opencode/<pid>.json. Per-process files avoid the read-modify-write clobber race between concurrent runtimes/windows that a single shared file would suffer. - On spawn, record the child (pid, owner pid, port, binary, host runtime). - On graceful close/restart, delete the record. - On startup, reap only our own, verified, genuinely-orphaned processes: recorded by us AND still a live `opencode serve` on the recorded port AND whose spawner is provably gone (reparented to pid 1, or recorded owner dead). It never touches a process a live instance is using, the user's standalone server, the official desktop app, or the TUI. Wire it into every runtime that spawns the server: - web/desktop via the OpenCode lifecycle (register on spawn, unregister on close/restart, reap at startup). The restart-for-config-change flow inherits this automatically through the same kill/spawn paths. - VS Code carries a parity implementation (it does not bundle the web package) that reads/writes the same registry directory and uses the same algorithm. - Tag the actual host runtime (desktop/web/ssh-remote/vscode) for observability. Also tighten teardown so the registry stays accurate and orphans die promptly instead of only on the next start: - The web server now also handles SIGHUP and SIGUSR2 (terminal close and the nodemon restart used by dev:server:watch / dev:web:hmr). - Electron now installs SIGINT/SIGTERM/SIGHUP handlers that run the same background teardown as a normal quit, covering Ctrl+C on electron:dev. External OpenCode servers (OPENCODE_SKIP_START) are intentionally excluded: we never manage or kill processes we did not spawn.
163 lines
5.9 KiB
JavaScript
163 lines
5.9 KiB
JavaScript
export const createServerStartupRuntime = (dependencies) => {
|
|
const {
|
|
process,
|
|
crypto,
|
|
server,
|
|
normalizeTunnelBootstrapTtlMs,
|
|
readSettingsFromDiskMigrated,
|
|
tunnelAuthController,
|
|
startTunnelWithNormalizedRequest,
|
|
gracefulShutdown,
|
|
getSignalsAttached,
|
|
setSignalsAttached,
|
|
syncToHmrState,
|
|
TUNNEL_MODE_QUICK,
|
|
TUNNEL_MODE_MANAGED_LOCAL,
|
|
TUNNEL_MODE_MANAGED_REMOTE,
|
|
} = dependencies;
|
|
|
|
const resolveBindHost = (host) =>
|
|
host
|
|
|| (typeof process.env.OPENCHAMBER_HOST === 'string' && process.env.OPENCHAMBER_HOST.trim().length > 0
|
|
? process.env.OPENCHAMBER_HOST.trim()
|
|
: '127.0.0.1');
|
|
|
|
const startListeningAndMaybeTunnel = async ({
|
|
port,
|
|
bindHost,
|
|
startupTunnelRequest,
|
|
onTunnelReady,
|
|
}) => {
|
|
let activePort = port;
|
|
|
|
await new Promise((resolve, reject) => {
|
|
const onError = (error) => {
|
|
server.off('error', onError);
|
|
reject(error);
|
|
};
|
|
server.once('error', onError);
|
|
const onListening = async () => {
|
|
server.off('error', onError);
|
|
try {
|
|
const addressInfo = server.address();
|
|
activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port;
|
|
|
|
if (typeof process.send === 'function') {
|
|
if (!process.connected) {
|
|
throw new Error('OpenChamber startup IPC channel disconnected before ready notification');
|
|
}
|
|
|
|
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);
|
|
});
|
|
|
|
return { activePort };
|
|
};
|
|
|
|
const attachProcessHandlers = ({ attachSignals }) => {
|
|
if (attachSignals && !getSignalsAttached()) {
|
|
const handleSignal = async () => {
|
|
await gracefulShutdown();
|
|
};
|
|
// Cover every signal a shell or dev harness may use to stop/restart us, so
|
|
// the managed OpenCode child is always torn down gracefully instead of
|
|
// orphaned: SIGINT/SIGQUIT (Ctrl+C/Ctrl+\), SIGTERM (kill/default), SIGHUP
|
|
// (terminal close), SIGUSR2 (nodemon restart for `dev:server:watch`).
|
|
process.on('SIGTERM', handleSignal);
|
|
process.on('SIGINT', handleSignal);
|
|
process.on('SIGQUIT', handleSignal);
|
|
process.on('SIGHUP', handleSignal);
|
|
process.on('SIGUSR2', handleSignal);
|
|
setSignalsAttached(true);
|
|
syncToHmrState();
|
|
}
|
|
|
|
process.on('unhandledRejection', (reason, promise) => {
|
|
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
|
});
|
|
|
|
process.on('uncaughtException', (error) => {
|
|
console.error('Uncaught Exception:', error);
|
|
gracefulShutdown();
|
|
});
|
|
};
|
|
|
|
return {
|
|
resolveBindHost,
|
|
startListeningAndMaybeTunnel,
|
|
attachProcessHandlers,
|
|
};
|
|
};
|