fix: restart stale plan canvas servers

This commit is contained in:
haelyra
2026-08-27 19:48:15 -04:00
parent 5dc6d85ca4
commit 7e0d115eb5
3 changed files with 73 additions and 11 deletions
+35 -1
View File
@@ -10,6 +10,7 @@
*/
const { EventEmitter } = require('events');
const crypto = require('crypto');
const fs = require('fs');
const http = require('http');
const path = require('path');
@@ -36,8 +37,33 @@ const DEFAULT_THINKING_STALE_MS = 90 * 1000;
const DEFAULT_TYPING_EXPIRY_MS = 30 * 1000;
// Presence is push-based, so expiring states need a tick to re-broadcast on.
const DEFAULT_PRESENCE_SWEEP_MS = 5 * 1000;
const PLAN_CANVAS_PROTOCOL_VERSION = 2;
const TYPING_STATES = new Set(['thinking', 'typing', 'idle']);
// Package versions do not distinguish two worktrees on the same release.
// Fingerprint every module loaded into the detached server so a current CLI
// never reuses stale browser or protocol code from an older checkout.
function computeRuntimeId() {
const sources = [
['loopback-guard.js', path.join(__dirname, '..', 'loopback-guard.js')],
['markdown.js', path.join(__dirname, 'markdown.js')],
['sdk.js', path.join(__dirname, 'sdk.js')],
['server.js', __filename],
['sessions.js', path.join(__dirname, 'sessions.js')],
['ui.js', path.join(__dirname, 'ui.js')]
];
const digest = crypto.createHash('sha256');
for (const [name, sourcePath] of sources) {
digest.update(name);
digest.update('\0');
digest.update(fs.readFileSync(sourcePath));
digest.update('\0');
}
return digest.digest('hex').slice(0, 16);
}
const PLAN_CANVAS_RUNTIME_ID = computeRuntimeId();
const CONTENT_TYPES = {
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
@@ -541,7 +567,13 @@ function createPlanCanvasServer({
Promise.resolve()
.then(() => {
if (req.method === 'GET' && pathname === '/health') {
return sendJson(res, 200, { ok: true, app: 'ecc-plan-canvas', version });
return sendJson(res, 200, {
ok: true,
app: 'ecc-plan-canvas',
version,
protocolVersion: PLAN_CANVAS_PROTOCOL_VERSION,
runtimeId: PLAN_CANVAS_RUNTIME_ID
});
}
if (req.method === 'POST' && pathname === '/shutdown') {
sendJson(res, 200, { status: 'stopping' });
@@ -628,6 +660,8 @@ module.exports = {
DEFAULT_PORT,
DEFAULT_THINKING_STALE_MS,
DEFAULT_TYPING_EXPIRY_MS,
PLAN_CANVAS_PROTOCOL_VERSION,
PLAN_CANVAS_RUNTIME_ID,
createPlanCanvasServer,
resolveIdleTimeoutMs,
resolvePort
+32 -8
View File
@@ -30,6 +30,8 @@ const {
} = require('./lib/plan-canvas/sessions');
const {
DEFAULT_HOST,
PLAN_CANVAS_PROTOCOL_VERSION,
PLAN_CANVAS_RUNTIME_ID,
createPlanCanvasServer,
resolveIdleTimeoutMs,
resolvePort
@@ -168,12 +170,21 @@ function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Start (or reuse) the detached canvas server and return its port. A version
// mismatch after an ECC update restarts the server so browser and CLI never
// disagree about the protocol.
function serverIsCompatible(health) {
return Boolean(
health &&
health.version === VERSION &&
health.protocolVersion === PLAN_CANVAS_PROTOCOL_VERSION &&
health.runtimeId === PLAN_CANVAS_RUNTIME_ID
);
}
// Start (or reuse) the detached canvas server and return its port. Worktrees
// can share a package version while carrying different Canvas code, so the
// health handshake binds reuse to the exact server runtime as well.
async function ensureServer({ stateDir, port }) {
const health = await healthCheck(port);
if (health && health.version === VERSION) return port;
if (serverIsCompatible(health)) return port;
if (health) {
await request(port, 'POST', '/shutdown').catch(() => {});
for (let i = 0; i < 20 && (await healthCheck(port)); i++) await sleep(100);
@@ -189,9 +200,9 @@ async function ensureServer({ stateDir, port }) {
fs.closeSync(logFd);
for (let i = 0; i < 50; i++) {
await sleep(100);
if (await healthCheck(port)) return port;
if (serverIsCompatible(await healthCheck(port))) return port;
}
throw new Error(`plan-canvas server did not become healthy on port ${port}; check ${path.join(stateDir, 'server.log')}`);
throw new Error(`plan-canvas server did not become compatible on port ${port}; check ${path.join(stateDir, 'server.log')}`);
}
function openBrowser(url) {
@@ -218,7 +229,13 @@ async function cmdStatus({ stateDir, port }) {
return { server: 'not running', hint: 'open an artifact to start one', stateDir };
}
const sessions = await request(port, 'GET', '/api/sessions');
return { server: `http://${DEFAULT_HOST}:${port}`, version: health.version, sessions: sessions.body.sessions };
return {
server: `http://${DEFAULT_HOST}:${port}`,
version: health.version,
protocolVersion: health.protocolVersion,
runtimeId: health.runtimeId,
sessions: sessions.body.sessions
};
}
async function cmdOpen(file, args, { stateDir, port }) {
@@ -364,7 +381,14 @@ async function cmdServer(args, { stateDir, port }) {
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(
serverInfoPath(stateDir),
JSON.stringify({ pid: process.pid, port: bound.port, version: VERSION, startedAt: new Date().toISOString() }, null, 2)
JSON.stringify({
pid: process.pid,
port: bound.port,
version: VERSION,
protocolVersion: PLAN_CANVAS_PROTOCOL_VERSION,
runtimeId: PLAN_CANVAS_RUNTIME_ID,
startedAt: new Date().toISOString()
}, null, 2)
);
// Sessions restored from disk resume their file watchers.
for (const session of store.list()) {
+6 -2
View File
@@ -14,7 +14,10 @@ const os = require('os');
const path = require('path');
const { createSessionStore } = require('../../scripts/lib/plan-canvas/sessions');
const { createPlanCanvasServer } = require('../../scripts/lib/plan-canvas/server');
const {
PLAN_CANVAS_RUNTIME_ID,
createPlanCanvasServer
} = require('../../scripts/lib/plan-canvas/server');
async function test(name, fn) {
try {
@@ -143,7 +146,8 @@ async function main() {
ok: true,
app: 'ecc-plan-canvas',
version: '9.9.9-test',
protocolVersion: 2
protocolVersion: 2,
runtimeId: PLAN_CANVAS_RUNTIME_ID
});
})) passed++; else failed++;