mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-12 12:47:57 +02:00
fix(plan-canvas): harden PDF export concurrency
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user