fix(plan-canvas): harden PDF export concurrency

This commit is contained in:
haelyra
2026-08-28 17:15:30 -04:00
parent 3fbc1ad164
commit 8dd610f574
10 changed files with 335 additions and 57 deletions
+39 -1
View File
@@ -11,6 +11,7 @@
const { spawn } = require('child_process');
const fs = require('fs');
const net = require('net');
const os = require('os');
const path = require('path');
@@ -137,6 +138,33 @@ function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function startDenyProxy(netImpl = net) {
return new Promise((resolve, reject) => {
const server = netImpl.createServer(socket => {
socket.on('error', () => {});
socket.destroy();
});
const onError = error => reject(errorWithCode(
`Could not isolate PDF renderer network access (${error.message})`,
'PDF_EXPORT_FAILED'
));
server.once('error', onError);
server.listen(0, '127.0.0.1', () => {
server.removeListener('error', onError);
server.on('error', () => {});
server.unref();
resolve({ server, url: `http://127.0.0.1:${server.address().port}` });
});
});
}
function stopDenyProxy(server) {
if (!server) return Promise.resolve();
return new Promise(resolve => {
try { server.close(resolve); } catch { resolve(); }
});
}
async function stopChild(child) {
if (!child || child.exitCode !== null || child.signalCode) return;
const closed = new Promise(resolve => child.once('close', resolve));
@@ -195,9 +223,11 @@ async function exportPdf({
timeoutMs = DEFAULT_EXPORT_TIMEOUT_MS,
spawnImpl = spawn,
fsImpl = fs,
osImpl = os
osImpl = os,
netImpl = net
} = {}) {
const safeUrl = assertLoopbackUrl(url);
const exportUrl = new URL(safeUrl);
const browser = executable || resolveChromiumExecutable({ env, platform, fsImpl });
if (!browser) {
const override = String(env.ECC_PLAN_CANVAS_CHROME_PATH || '').trim();
@@ -214,14 +244,18 @@ async function exportPdf({
const profileDir = path.join(tempDir, 'profile');
fsImpl.mkdirSync(profileDir, { recursive: true });
let child = null;
let denyProxy = null;
try {
denyProxy = await startDenyProxy(netImpl);
const args = [
'--headless=new',
'--disable-background-networking',
'--disable-gpu',
'--disable-component-update',
'--disable-default-apps',
'--disable-extensions',
'--disable-sync',
'--disable-quic',
'--metrics-recording-only',
'--mute-audio',
'--no-first-run',
@@ -229,6 +263,9 @@ async function exportPdf({
'--no-pdf-header-footer',
'--print-to-pdf-no-header',
'--hide-scrollbars',
`--proxy-server=${denyProxy.url}`,
`--proxy-bypass-list=<-loopback>;${exportUrl.origin}`,
`--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE ${exportUrl.hostname}`,
`--user-data-dir=${profileDir}`,
`--print-to-pdf=${outputFile}`,
'--virtual-time-budget=5000',
@@ -247,6 +284,7 @@ async function exportPdf({
return { buffer, filename: pdfFileName(artifactFile) };
} finally {
await stopChild(child);
await stopDenyProxy(denyProxy && denyProxy.server);
fsImpl.rmSync(tempDir, { recursive: true, force: true });
}
}
+15
View File
@@ -205,6 +205,19 @@ function artifactSdkJs() {
});
// --- chrome bridge ---------------------------------------------------------
function exportSnapshot() {
const clone = document.documentElement.cloneNode(true);
clone.querySelectorAll('script,iframe,object,embed,form,base,meta[http-equiv],[data-ecc-plan-canvas]').forEach(el => el.remove());
clone.querySelectorAll('*').forEach(el => {
for (const attr of [...el.attributes]) {
if (attr.name.toLowerCase().startsWith('on') || attr.name.toLowerCase() === 'srcdoc') {
el.removeAttribute(attr.name);
}
}
});
return '<!doctype html>\\n' + clone.outerHTML;
}
window.addEventListener('message', e => {
const msg = e.data || {};
if (msg.type === 'pc:set-mode') {
@@ -212,6 +225,8 @@ function artifactSdkJs() {
if (!annotate) { hl.style.display = 'none'; selhint.style.display = 'none'; closeCard(); }
} else if (msg.type === 'pc:restore-scroll') {
window.scrollTo(msg.x || 0, msg.y || 0);
} else if (msg.type === 'pc:export-snapshot' && typeof msg.requestId === 'string') {
post({ type: 'pc:export-snapshot-result', requestId: msg.requestId, html: exportSnapshot() });
}
});
document.addEventListener('keydown', e => {
+49 -9
View File
@@ -31,6 +31,7 @@ const DEFAULT_PORT = 4517;
const DEFAULT_HOST = '127.0.0.1';
const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
const MAX_BODY_BYTES = 1024 * 1024;
const MAX_PDF_SNAPSHOT_BYTES = 5 * 1024 * 1024;
// How long the "agent is thinking" indicator survives without the agent
// checking back in, before presence decays to the honest queued/waiting.
const DEFAULT_THINKING_STALE_MS = 90 * 1000;
@@ -38,6 +39,19 @@ const DEFAULT_THINKING_STALE_MS = 90 * 1000;
const DEFAULT_TYPING_EXPIRY_MS = 30 * 1000;
const PLAN_CANVAS_PROTOCOL_VERSION = 4;
const TYPING_STATES = new Set(['thinking', 'typing', 'idle']);
const PDF_EXPORT_CSP = [
"default-src 'none'",
"base-uri 'none'",
"connect-src 'none'",
"font-src 'self' data:",
"form-action 'none'",
"frame-src 'none'",
"img-src 'self' data:",
"media-src 'self' data:",
"object-src 'none'",
"script-src 'none'",
"style-src 'self' 'unsafe-inline'"
].join('; ');
// Package versions do not distinguish two worktrees on the same release.
// Fingerprint every module loaded into the detached server so a current CLI
@@ -96,13 +110,13 @@ function resolveIdleTimeoutMs(env = process.env) {
return Number.isInteger(value) && value > 0 ? value : DEFAULT_IDLE_TIMEOUT_MS;
}
function readJsonBody(req) {
function readJsonBody(req, maxBytes = MAX_BODY_BYTES) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', chunk => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
if (size > maxBytes) {
reject(new Error('body too large'));
req.destroy();
return;
@@ -130,8 +144,9 @@ function sendJson(res, statusCode, payload) {
function sendHtml(res, statusCode, html, { csp = true } = {}) {
const headers = { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' };
if (csp) {
headers['content-security-policy'] =
"default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'self'";
headers['content-security-policy'] = typeof csp === 'string'
? csp
: "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'self'";
}
res.writeHead(statusCode, headers);
res.end(html);
@@ -175,6 +190,8 @@ function createPlanCanvasServer({
const typingKeys = new Map(); // key -> ms timestamp the agent signalled composing
let idleTimer = null;
let closed = false;
let pdfExportActive = false;
let pdfSnapshot = null;
// --- presence ---------------------------------------------------------
@@ -391,10 +408,25 @@ function createPlanCanvasServer({
}
const pdfMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/pdf$/);
if (pdfMatch && req.method === 'GET') {
if (pdfMatch && (req.method === 'GET' || req.method === 'POST')) {
const session = store.get(pdfMatch[1]);
if (!session) return sendJson(res, 404, { error: 'unknown session' });
if (pdfExportActive) {
res.setHeader('retry-after', '1');
return sendJson(res, 429, {
error: 'another PDF export is already in progress',
code: 'PDF_EXPORT_BUSY'
});
}
pdfExportActive = true;
try {
if (req.method === 'POST') {
const body = await readJsonBody(req, MAX_PDF_SNAPSHOT_BYTES);
if (typeof body.html !== 'string' || !body.html.trim()) {
return sendJson(res, 400, { error: 'html snapshot is required' });
}
pdfSnapshot = { key: session.key, html: body.html };
}
const pdf = await pdfExporter({
url: `http://${req.headers.host}/artifact/${session.key}/?pdf=1`,
artifactFile: session.file
@@ -403,6 +435,9 @@ function createPlanCanvasServer({
} catch (error) {
const statusCode = error.code === 'PDF_BROWSER_NOT_FOUND' ? 503 : 500;
return sendJson(res, statusCode, { error: error.message, code: error.code || 'PDF_EXPORT_FAILED' });
} finally {
pdfSnapshot = null;
pdfExportActive = false;
}
}
@@ -474,11 +509,14 @@ function createPlanCanvasServer({
res.end();
}
function serveArtifact(res, key, assetPath) {
function serveArtifact(res, key, assetPath, { pdfExport = false } = {}) {
const session = store.get(key);
if (!session) return sendHtml(res, 404, '<h1>Unknown session</h1>');
if (!assetPath) {
if (pdfExport && pdfSnapshot && pdfSnapshot.key === key) {
return sendHtml(res, 200, pdfSnapshot.html, { csp: PDF_EXPORT_CSP });
}
let content;
try {
content = fs.readFileSync(session.file, 'utf8');
@@ -491,13 +529,13 @@ function createPlanCanvasServer({
title: path.basename(session.file),
sdkSrc: '/sdk.js'
});
return sendHtml(res, 200, html, { csp: false });
return sendHtml(res, 200, html, { csp: pdfExport ? PDF_EXPORT_CSP : false });
}
const sdkTag = '<script src="/sdk.js"></script>';
const injected = content.includes('</body>')
? content.replace('</body>', `${sdkTag}\n</body>`)
: `${content}\n${sdkTag}`;
return sendHtml(res, 200, injected, { csp: false });
return sendHtml(res, 200, injected, { csp: pdfExport ? PDF_EXPORT_CSP : false });
}
// Sibling assets resolve relative to the artifact's directory and must
@@ -574,7 +612,9 @@ function createPlanCanvasServer({
const artifactMatch = pathname.match(/^\/artifact\/([a-f0-9]{12})\/(.*)$/);
if (req.method === 'GET' && artifactMatch) {
const assetPath = decodeURIComponent(artifactMatch[2]);
return serveArtifact(res, artifactMatch[1], assetPath || null);
return serveArtifact(res, artifactMatch[1], assetPath || null, {
pdfExport: url.searchParams.get('pdf') === '1'
});
}
if (pathname.startsWith('/api/')) {
return handleApi(req, res, url);
+29 -1
View File
@@ -271,6 +271,7 @@ function canvasClientJs() {
function postToFrame(msg) {
if (frame.contentWindow) frame.contentWindow.postMessage(msg, '*');
}
const snapshotWaiters = new Map();
window.addEventListener('message', e => {
if (e.source !== frame.contentWindow) return;
const msg = e.data || {};
@@ -278,11 +279,32 @@ function canvasClientJs() {
else if (msg.type === 'pc:queue-and-send' && msg.item) { addToQueue(msg.item); send(); }
else if (msg.type === 'pc:scroll') lastScroll = { x: msg.x || 0, y: msg.y || 0 };
else if (msg.type === 'pc:toggle-mode') setAnnotate(!annotate);
else if (msg.type === 'pc:export-snapshot-result' && typeof msg.requestId === 'string') {
const waiter = snapshotWaiters.get(msg.requestId);
if (waiter) {
snapshotWaiters.delete(msg.requestId);
waiter(msg.html);
}
}
else if (msg.type === 'pc:ready') {
postToFrame({ type: 'pc:set-mode', annotate });
postToFrame({ type: 'pc:restore-scroll', x: lastScroll.x, y: lastScroll.y });
}
});
function requestArtifactSnapshot() {
return new Promise(resolve => {
const requestId = Date.now().toString(36) + Math.random().toString(36).slice(2);
const timer = setTimeout(() => {
snapshotWaiters.delete(requestId);
resolve(null);
}, 1500);
snapshotWaiters.set(requestId, html => {
clearTimeout(timer);
resolve(typeof html === 'string' ? html : null);
});
postToFrame({ type: 'pc:export-snapshot', requestId });
});
}
// --- queue ----------------------------------------------------------
function persistQueue() { try { sessionStorage.setItem(QKEY, JSON.stringify(queue)); } catch { /* full */ } }
@@ -442,7 +464,13 @@ function canvasClientJs() {
downloadPdfBtn.textContent = 'Preparing PDF\u2026';
reportExportStatus('Rendering locally\u2026');
try {
const res = await fetch('/api/session/' + key + '/pdf', { cache: 'no-store' });
const snapshot = await requestArtifactSnapshot();
const res = await fetch('/api/session/' + key + '/pdf', {
method: snapshot ? 'POST' : 'GET',
headers: snapshot ? { 'content-type': 'application/json' } : undefined,
body: snapshot ? JSON.stringify({ html: snapshot }) : undefined,
cache: 'no-store'
});
if (!res.ok) {
const detail = await res.json().catch(() => ({}));
throw new Error(detail.error || ('HTTP ' + res.status));
+90 -18
View File
@@ -19,6 +19,7 @@
const fs = require('fs');
const http = require('http');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
@@ -171,6 +172,73 @@ 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 removeStaleServerStartLock(lockPath, staleAfterMs = 60 * 1000) {
try {
const stat = fs.statSync(lockPath);
let owner = null;
try { owner = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch { /* incomplete lock owner */ }
const oldEnough = Date.now() - stat.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
} = {}) {
fs.mkdirSync(lockDir, { recursive: true });
const lockPath = serverStartLockPath(port, lockDir);
const token = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const startedAt = Date.now();
while (true) {
try {
fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, token, createdAt: new Date().toISOString() }), {
flag: 'wx',
mode: 0o600
});
break;
} catch (error) {
if (error.code !== 'EEXIST') throw error;
if (removeStaleServerStartLock(lockPath)) continue;
if (Date.now() - startedAt >= timeoutMs) {
throw new Error(`timed out waiting for Plan Canvas startup lock on port ${port}`);
}
await sleep(50);
}
}
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.
}
}
}
function serverIsCompatible(health) {
return Boolean(
health &&
@@ -186,24 +254,28 @@ function serverIsCompatible(health) {
async function ensureServer({ stateDir, port }) {
const health = await healthCheck(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);
}
fs.mkdirSync(stateDir, { recursive: true });
const logFd = fs.openSync(path.join(stateDir, 'server.log'), 'a');
const child = spawn(process.execPath, [__filename, 'server', '--port', String(port)], {
detached: true,
stdio: ['ignore', logFd, logFd],
env: { ...process.env, ECC_PLAN_CANVAS_STATE_DIR: stateDir }
return withServerStartLock(port, async () => {
const lockedHealth = await healthCheck(port);
if (serverIsCompatible(lockedHealth)) return port;
if (lockedHealth) {
await request(port, 'POST', '/shutdown').catch(() => {});
for (let i = 0; i < 20 && (await healthCheck(port)); i++) await sleep(100);
}
fs.mkdirSync(stateDir, { recursive: true });
const logFd = fs.openSync(path.join(stateDir, 'server.log'), 'a');
const child = spawn(process.execPath, [__filename, 'server', '--port', String(port)], {
detached: true,
stdio: ['ignore', logFd, logFd],
env: { ...process.env, ECC_PLAN_CANVAS_STATE_DIR: stateDir }
});
child.unref();
fs.closeSync(logFd);
for (let i = 0; i < 50; i++) {
await sleep(100);
if (serverIsCompatible(await healthCheck(port))) return port;
}
throw new Error(`plan-canvas server did not become compatible on port ${port}; check ${path.join(stateDir, 'server.log')}`);
});
child.unref();
fs.closeSync(logFd);
for (let i = 0; i < 50; i++) {
await sleep(100);
if (serverIsCompatible(await healthCheck(port))) return port;
}
throw new Error(`plan-canvas server did not become compatible on port ${port}; check ${path.join(stateDir, 'server.log')}`);
}
function openBrowser(url) {
@@ -434,4 +506,4 @@ if (require.main === module) {
});
}
module.exports = { main, ensureServer, healthCheck };
module.exports = { main, ensureServer, healthCheck, withServerStartLock };