Files
openchamber/packages/web/server/lib/opencode/server-startup-runtime.js
T
Bohdan Triapitsyn 2ff5428c69 feat(opencode): never leave orphaned OpenCode server processes
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.
2026-06-24 16:51:17 +03:00

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,
};
};