fix(plan-canvas): use OS-managed startup lock

This commit is contained in:
haelyra
2026-08-28 17:29:01 -04:00
parent 6ca0540dba
commit cbc35c098a
3 changed files with 28 additions and 68 deletions
+1 -1
View File
@@ -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/<key>` — editor chrome; `GET /artifact/<key>/` — rendered artifact
+20 -59
View File
@@ -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();
}
}
+7 -8
View File
@@ -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 () => {