diff --git a/src/servers/sidecar/vnc/vnc-manager.ts b/src/servers/sidecar/vnc/vnc-manager.ts index 372d93a3..c23af54b 100644 --- a/src/servers/sidecar/vnc/vnc-manager.ts +++ b/src/servers/sidecar/vnc/vnc-manager.ts @@ -169,10 +169,33 @@ async function isPortOpen(port: number): Promise { } } -// x11vnc stays in the foreground, so readiness is the listening port rather than exit code. -async function waitForPort(port: number): Promise { +// A listener on the port is NOT proof our server started. The running mirror lives in module state, so +// a sidecar restart forgets it while the process keeps running orphaned — and then this check sees the +// ORPHAN listening and reports success, leaving the platform convinced it started something the browser +// is not looking at. That is not hypothetical: on 2026-08-01 an Xvnc left over from the virtual-desktop +// experiment held 5900, and the mirror reported healthy while the browser was served a stale XFCE +// session. So reclaim the port first, and treat our own process dying as failure. +async function reclaimPort(port: number): Promise { + if (!(await isPortOpen(port))) return; + + console.log(`[vnc] port ${port} already held — killing the orphan before starting`); + Bun.spawnSync({ cmd: ['fuser', '-k', '-TERM', `${port}/tcp`], stdout: 'ignore', stderr: 'ignore' }); + + for (let i = 0; i < 20; i++) { + await Bun.sleep(100); + if (!(await isPortOpen(port))) return; + } + + Bun.spawnSync({ cmd: ['fuser', '-k', '-KILL', `${port}/tcp`], stdout: 'ignore', stderr: 'ignore' }); + await Bun.sleep(300); +} + +// x11vnc stays in the foreground, so readiness is the listening port rather than exit code — but only +// once we know the process we spawned is the one alive to own it. +async function waitForPort(port: number, proc: { exitCode: number | null }): Promise { const deadline = Date.now() + READY_TIMEOUT_MS; while (Date.now() < deadline) { + if (proc.exitCode !== null) return false; if (await isPortOpen(port)) return true; await Bun.sleep(100); } @@ -188,6 +211,9 @@ export async function startSession(params: VncStartParams): Promise<{ port: numb const homeDir = getOwnerHomeDir(params.email); const { passwdFile } = await ensureVncPassword(homeDir); + // An orphan from a previous sidecar life would still hold the port and be mistaken for a healthy start. + await reclaimPort(MIRROR_PORT); + const displayNum = resolveDisplayNum(); const display = `:${displayNum}`; @@ -265,7 +291,7 @@ export async function startSession(params: VncStartParams): Promise<{ port: numb } })(); - if (!(await waitForPort(MIRROR_PORT))) { + if (!(await waitForPort(MIRROR_PORT, proc))) { try { proc.kill(); } catch {