diff --git a/apps/desktop/AGENTS.md b/apps/desktop/AGENTS.md index aeec0df..be714e6 100644 --- a/apps/desktop/AGENTS.md +++ b/apps/desktop/AGENTS.md @@ -62,8 +62,9 @@ The desktop build is orchestrated by Turbo. It depends on the server's productio ### Server Lifecycle In production, the main process manages the embedded server: - **Spawn**: Spawns server from `server-output/server/index.mjs` using `Bun.spawn`. -- **Port Allocation**: Server is started with `PORT=0` and `HOST=127.0.0.1`. -- **Port Detection**: The main process parses the server's `stdout` using a regex to find the dynamically assigned port. +- **Port Allocation**: A free port is pre-allocated via `node:net` (`createServer` on `127.0.0.1:0`), then passed to the server as the `PORT` environment variable. +- **Readiness Check**: The main process polls the server URL with `fetch` until it responds, rather than parsing stdout. +- **Retry**: If the server fails to become ready (timeout or early exit), the process is killed and a new attempt is made with a fresh port (up to 3 retries). - **Lifecycle**: The server process is tied to the app; it is killed on `SIGTERM`, `SIGINT`, or app exit. If the server process crashes, the app exits with an error. ## Environment Detection @@ -119,7 +120,7 @@ const serverEntryPath = join(PATHS.VIEWS_FOLDER, '..', 'server-output', 'server' **DO:** - Use arrow functions for all components and utility functions. - Ensure `apps/server` is built before building `apps/desktop` (handled by Turbo). -- Parse the server's stdout for the port instead of hardcoding ports in production. +- Pre-allocate a free port and pass it via `PORT` env var instead of parsing stdout. - Handle server process termination to avoid orphan processes. - Use `catalog:` for all dependency versions in `package.json`. diff --git a/apps/desktop/src/bun/index.ts b/apps/desktop/src/bun/index.ts index 63c2b7a..c1c968d 100644 --- a/apps/desktop/src/bun/index.ts +++ b/apps/desktop/src/bun/index.ts @@ -1,9 +1,10 @@ +import { createServer } from 'node:net' import { dirname, join } from 'node:path' import { BrowserWindow, PATHS } from 'electrobun/bun' const DEV_SERVER_URL = 'http://localhost:3000' -const SERVER_READY_TIMEOUT_MS = 30_000 -const PORT_PATTERN = /Listening on:?\s*https?:\/\/[^\s:]+:(\d+)/ +const SERVER_READY_TIMEOUT_MS = 5_000 +const MAX_SPAWN_RETRIES = 3 const isDev = (): boolean => { const env = process.env.ELECTROBUN_BUILD_ENV @@ -14,6 +15,23 @@ const getServerEntryPath = (): string => { return join(PATHS.VIEWS_FOLDER, '..', 'server-output', 'server', 'index.mjs') } +const getFreePort = (): Promise => { + return new Promise((resolve, reject) => { + const srv = createServer() + srv.unref() + srv.once('error', reject) + srv.listen({ port: 0, host: '127.0.0.1', exclusive: true }, () => { + const addr = srv.address() + if (addr && typeof addr === 'object') { + const port = addr.port + srv.close((err) => (err ? reject(err) : resolve(port))) + } else { + srv.close(() => reject(new Error('Unexpected address() result'))) + } + }) + }) +} + const waitForServer = async ( url: string, timeoutMs = SERVER_READY_TIMEOUT_MS, @@ -38,64 +56,39 @@ const spawnServer = async (): Promise<{ const serverEntryPath = getServerEntryPath() const serverDir = dirname(serverEntryPath) - const serverProc = Bun.spawn([process.execPath, serverEntryPath], { - cwd: serverDir, - env: { - ...process.env, - PORT: '0', - HOST: '127.0.0.1', - }, - stdout: 'pipe', - stderr: 'pipe', - }) + for (let attempt = 1; attempt <= MAX_SPAWN_RETRIES; attempt++) { + const port = await getFreePort() + const url = `http://127.0.0.1:${port}` - const port = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - serverProc.kill() - reject( - new Error(`Server did not start within ${SERVER_READY_TIMEOUT_MS}ms`), - ) - }, SERVER_READY_TIMEOUT_MS) - - const reader = serverProc.stdout.getReader() - let buffer = '' - - const read = async () => { - try { - while (true) { - const { done, value } = await reader.read() - if (done) { - clearTimeout(timeout) - reject(new Error('Server process exited before reporting port')) - return - } - - const text = new TextDecoder().decode(value) - buffer += text - process.stdout.write(text) - - const match = PORT_PATTERN.exec(buffer) - if (match?.[1]) { - clearTimeout(timeout) - resolve(match[1]) - return - } - } - } catch (err) { - clearTimeout(timeout) - reject(err) - } - } - - serverProc.exited.then((code) => { - clearTimeout(timeout) - reject(new Error(`Server exited with code ${code} before becoming ready`)) + const serverProc = Bun.spawn([process.execPath, serverEntryPath], { + cwd: serverDir, + env: { + ...process.env, + PORT: String(port), + HOST: '127.0.0.1', + }, + stdio: ['ignore', 'inherit', 'inherit'], }) - read() - }) + const ready = await Promise.race([ + waitForServer(url), + serverProc.exited.then((code) => { + throw new Error(`Server exited with code ${code} before becoming ready`) + }), + ]) - return { process: serverProc, url: `http://127.0.0.1:${port}` } + if (ready) { + return { process: serverProc, url } + } + + serverProc.kill() + await serverProc.exited + console.warn( + `Server failed to become ready on port ${port} (attempt ${attempt}/${MAX_SPAWN_RETRIES})`, + ) + } + + throw new Error(`Server failed to start after ${MAX_SPAWN_RETRIES} attempts`) } const main = async () => {