diff --git a/docs/design/plan-canvas.md b/docs/design/plan-canvas.md index 077110343..897f2f9d8 100644 --- a/docs/design/plan-canvas.md +++ b/docs/design/plan-canvas.md @@ -82,7 +82,7 @@ after 30 min, `ECC_PLAN_CANVAS_IDLE_MS`). Feedback is deliver-and-drain: queued handed to exactly one `await` call and persisted to disk until then, so nothing is lost if the poll is interrupted. -- `GET /health` — `{ok, app, version, protocolVersion, runtimeId}`; the CLI reuses a detached server only when its package, protocol, and Canvas-module fingerprint match, preventing an older same-version worktree from serving stale browser code. A per-user, port-scoped startup lock serializes compatibility checks and replacement so concurrent opens reuse the winning server instead of racing two detached launches. +- `GET /health` — `{ok, app, version, protocolVersion, runtimeId}`; the CLI reuses a detached server only when its package, protocol, and Canvas-module fingerprint match, preventing an older same-version worktree from serving stale browser code. An OS-managed, port-scoped startup lock serializes compatibility checks and replacement so concurrent opens reuse the winning server instead of racing two detached launches. The lock is released automatically when its process exits. - `GET /` — session list (ECC chrome) - `POST /api/sessions` `{file, reopen?}` — open/resume; `409 user-ended` unless `reopen` - `GET /canvas/` — editor chrome; `GET /artifact//` — rendered artifact diff --git a/scripts/plan-canvas.js b/scripts/plan-canvas.js index 8c919c5af..9c0a33855 100755 --- a/scripts/plan-canvas.js +++ b/scripts/plan-canvas.js @@ -18,8 +18,8 @@ */ const fs = require('fs'); +const dgram = require('dgram'); const http = require('http'); -const os = require('os'); const path = require('path'); const { spawn } = require('child_process'); @@ -172,67 +172,33 @@ function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } -function serverStartLockPath(port, lockDir = os.tmpdir()) { - const userId = typeof process.getuid === 'function' ? process.getuid() : 'user'; - return path.join(lockDir, `ecc-plan-canvas-${userId}-${validatePort(port)}.lock`); -} - -function processIsAlive(pid) { - if (!Number.isInteger(pid) || pid <= 0) return false; - try { - process.kill(pid, 0); - return true; - } catch (error) { - return error.code === 'EPERM'; - } -} - -function readServerStartLock(lockPath) { - let fd; - try { - fd = fs.openSync(lockPath, 'r'); - const stat = fs.fstatSync(fd); - let owner = null; - try { owner = JSON.parse(fs.readFileSync(fd, 'utf8')); } catch { /* incomplete lock owner */ } - return { mtimeMs: stat.mtimeMs, owner }; - } finally { - if (fd !== undefined) { - try { fs.closeSync(fd); } catch { /* best-effort lock inspection */ } - } - } -} - -function removeStaleServerStartLock(lockPath, staleAfterMs = 60 * 1000) { - try { - const { mtimeMs, owner } = readServerStartLock(lockPath); - const oldEnough = Date.now() - mtimeMs > staleAfterMs; - if (!oldEnough && (!owner || processIsAlive(owner.pid))) return false; - fs.rmSync(lockPath, { force: true }); - return true; - } catch { - return false; - } -} - async function withServerStartLock(port, task, { - lockDir = os.tmpdir(), - timeoutMs = 15 * 1000 + timeoutMs = 15 * 1000, + dgramImpl = dgram } = {}) { - fs.mkdirSync(lockDir, { recursive: true }); - const lockPath = serverStartLockPath(port, lockDir); - const token = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const lockPort = validatePort(port); const startedAt = Date.now(); + let socket = null; while (true) { try { - fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, token, createdAt: new Date().toISOString() }), { - flag: 'wx', - mode: 0o600 + socket = await new Promise((resolve, reject) => { + const candidate = dgramImpl.createSocket('udp4'); + const onError = error => { + candidate.close(); + reject(error); + }; + candidate.once('error', onError); + candidate.bind(lockPort, DEFAULT_HOST, () => { + candidate.removeListener('error', onError); + candidate.on('error', () => {}); + candidate.unref(); + resolve(candidate); + }); }); break; } catch (error) { - if (error.code !== 'EEXIST') throw error; - if (removeStaleServerStartLock(lockPath)) continue; + if (error.code !== 'EADDRINUSE') throw error; if (Date.now() - startedAt >= timeoutMs) { throw new Error(`timed out waiting for Plan Canvas startup lock on port ${port}`); } @@ -243,12 +209,7 @@ async function withServerStartLock(port, task, { try { return await task(); } finally { - try { - const owner = JSON.parse(fs.readFileSync(lockPath, 'utf8')); - if (owner.token === token) fs.rmSync(lockPath, { force: true }); - } catch { - // A stale-lock recovery may already have removed it. - } + if (socket) socket.close(); } } diff --git a/tests/integration/plan-canvas-e2e.test.js b/tests/integration/plan-canvas-e2e.test.js index 9e1b2f8ff..a534e5f71 100644 --- a/tests/integration/plan-canvas-e2e.test.js +++ b/tests/integration/plan-canvas-e2e.test.js @@ -152,22 +152,21 @@ async function main() { await new Promise(resolve => setTimeout(resolve, 40)); active -= 1; return label; - }, { lockDir: tmp, timeoutMs: 2000 }); + }, { timeoutMs: 2000 }); assert.deepStrictEqual(await Promise.all([runLocked('first'), runLocked('second')]), ['first', 'second']); assert.strictEqual(maximumActive, 1); - assert.ok(!fs.readdirSync(tmp).some(name => name.endsWith('.lock'))); }); - await test('port-scoped startup lock recovers a dead owner', async () => { + await test('port-scoped startup lock releases after a failed owner', async () => { const lockPort = port + 3; - const userId = typeof process.getuid === 'function' ? process.getuid() : 'user'; - const lockPath = path.join(tmp, `ecc-plan-canvas-${userId}-${lockPort}.lock`); - fs.writeFileSync(lockPath, JSON.stringify({ pid: 2147483647, token: 'dead-owner' })); + await assert.rejects( + withServerStartLock(lockPort, async () => { throw new Error('owner failed'); }, { timeoutMs: 2000 }), + /owner failed/ + ); assert.strictEqual( - await withServerStartLock(lockPort, async () => 'recovered', { lockDir: tmp, timeoutMs: 2000 }), + await withServerStartLock(lockPort, async () => 'recovered', { timeoutMs: 2000 }), 'recovered' ); - assert.ok(!fs.existsSync(lockPath)); }); await test('concurrent opens serialize replacement of a same-version legacy server', async () => {