fix: prevent plan canvas connection starvation

This commit is contained in:
haelyra
2026-08-27 21:24:03 -04:00
parent bb4adc6462
commit 61652b74ff
6 changed files with 197 additions and 222 deletions
+62 -77
View File
@@ -2,7 +2,7 @@
* Integration tests for the Plan Canvas server (scripts/lib/plan-canvas/).
*
* Spins up the real HTTP server in-process and drives it exactly like the
* browser chrome (fetch + SSE) and the agent CLI (long-poll) do.
* browser chrome (finite fetch polling) and the agent CLI (long-poll) do.
*
* Run with: node tests/scripts/plan-canvas.test.js
*/
@@ -63,35 +63,8 @@ function jsonBody(res) {
return JSON.parse(res.body.trim());
}
// Open an SSE stream and collect parsed events into `received`.
function openSse(port, key) {
const received = [];
let close = () => {};
const ready = new Promise((resolve, reject) => {
const req = http.get(
{ host: '127.0.0.1', port, path: `/events/${key}`, agent: false },
res => {
let buffer = '';
res.on('data', chunk => {
buffer += chunk;
let idx;
while ((idx = buffer.indexOf('\n\n')) >= 0) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
const eventMatch = frame.match(/^event: (.+)$/m);
const dataMatch = frame.match(/^data: (.+)$/m);
if (eventMatch && dataMatch) {
received.push({ event: eventMatch[1], data: JSON.parse(dataMatch[1]) });
}
}
});
resolve();
}
);
req.on('error', reject);
close = () => req.destroy();
});
return { received, ready, close: () => close() };
async function browserState(port, key) {
return jsonBody(await request(port, 'GET', `/api/session/${key}/state`));
}
function waitFor(predicate, { timeoutMs = 3000, intervalMs = 20 } = {}) {
@@ -146,7 +119,7 @@ async function main() {
ok: true,
app: 'ecc-plan-canvas',
version: '9.9.9-test',
protocolVersion: 2,
protocolVersion: 3,
runtimeId: PLAN_CANVAS_RUNTIME_ID
});
})) passed++; else failed++;
@@ -236,6 +209,41 @@ async function main() {
}
})) 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'"));
assert.ok(!res.body.includes('new EventSource('));
})) passed++; else failed++;
if (await test('legacy EventSource endpoint retires without reconnecting', async () => {
const res = await request(port, 'GET', `/events/${key}`);
assert.strictEqual(res.statusCode, 204);
assert.strictEqual(res.body, '');
})) passed++; else failed++;
if (await test('finite browser polls keep an actively viewed canvas server alive', async () => {
const idleArtifact = path.join(tmp, 'browser-active.plan.md');
fs.writeFileSync(idleArtifact, '# Plan: Browser Active\n');
const idleStore = createSessionStore({ stateDir: path.join(tmp, 'browser-active-state') });
let shutdowns = 0;
const idleCanvas = createPlanCanvasServer({
store: idleStore,
version: '9.9.9-test',
idleTimeoutMs: 200,
onIdleShutdown: () => { shutdowns += 1; }
});
const bound = await idleCanvas.listen(0);
const opened = jsonBody(await request(bound.port, 'POST', '/api/sessions', { body: { file: idleArtifact } }));
await new Promise(resolve => setTimeout(resolve, 100));
await browserState(bound.port, opened.key);
await new Promise(resolve => setTimeout(resolve, 100));
assert.strictEqual(shutdowns, 0);
await new Promise(resolve => setTimeout(resolve, 120));
assert.strictEqual(shutdowns, 1);
await idleCanvas.close();
})) passed++; else failed++;
if (await test('await with timeoutMs returns waiting when idle', async () => {
const res = await request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}&timeoutMs=50`);
assert.strictEqual(jsonBody(res).status, 'waiting');
@@ -247,10 +255,9 @@ async function main() {
})) passed++; else failed++;
if (await test('browser feedback wakes a blocking await; presence transitions', async () => {
const sse = openSse(port, key);
await sse.ready;
const awaitPromise = request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}`);
await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'listening'));
await waitFor(() => canvas.presenceFor(key) === 'listening');
assert.strictEqual((await browserState(port, key)).presence, 'listening');
const post = await request(port, 'POST', `/api/session/${key}/feedback`, {
body: {
@@ -268,9 +275,9 @@ async function main() {
assert.strictEqual(result.items[0].anchor.selector, 'h2:nth-of-type(1)');
assert.strictEqual(result.items[1].verdict, 'request-changes');
await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'thinking'));
await waitFor(() => sse.received.some(e => e.event === 'chat-sync' && e.data.chat.length === 2));
sse.close();
const state = await browserState(port, key);
assert.strictEqual(state.presence, 'thinking');
assert.strictEqual(state.chat.length, 2);
})) passed++; else failed++;
// Regression: feedback sent with nobody parked on `await` used to leave the
@@ -279,34 +286,30 @@ async function main() {
const queuedArtifact = path.join(tmp, 'queued.plan.md');
fs.writeFileSync(queuedArtifact, '# Plan: Queued\n');
const opened = jsonBody(await request(port, 'POST', '/api/sessions', { body: { file: queuedArtifact } }));
const sse = openSse(port, opened.key);
await sse.ready;
await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'waiting'));
assert.strictEqual((await browserState(port, opened.key)).presence, 'waiting');
const post = await request(port, 'POST', `/api/session/${opened.key}/feedback`, {
body: { items: [{ kind: 'chat', text: 'anyone there?' }] }
});
assert.strictEqual(jsonBody(post).presence, 'queued');
assert.strictEqual(canvas.presenceFor(opened.key), 'queued');
await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'queued'));
assert.strictEqual((await browserState(port, opened.key)).presence, 'queued');
// Draining it hands the batch over and flips the indicator to thinking.
const drained = jsonBody(await request(port, 'GET', `/api/await?key=${opened.key}&timeoutMs=0`));
assert.strictEqual(drained.status, 'feedback');
assert.strictEqual(canvas.presenceFor(opened.key), 'thinking');
sse.close();
assert.strictEqual((await browserState(port, opened.key)).presence, 'thinking');
})) passed++; else failed++;
if (await test('typing endpoint drives the indicator and reply clears it', async () => {
const typingArtifact = path.join(tmp, 'typing.plan.md');
fs.writeFileSync(typingArtifact, '# Plan: Typing\n');
const opened = jsonBody(await request(port, 'POST', '/api/sessions', { body: { file: typingArtifact } }));
const sse = openSse(port, opened.key);
await sse.ready;
const typing = await request(port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'typing' } });
assert.strictEqual(jsonBody(typing).presence, 'typing');
await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'typing'));
assert.strictEqual((await browserState(port, opened.key)).presence, 'typing');
const thinking = await request(port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'thinking' } });
assert.strictEqual(jsonBody(thinking).presence, 'thinking');
@@ -317,8 +320,7 @@ async function main() {
// A landed reply must take the bubble down, not leave it spinning.
await request(port, 'POST', `/api/session/${opened.key}/reply`, { body: { text: 'done' } });
assert.strictEqual(canvas.presenceFor(opened.key), 'waiting');
await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'waiting'));
sse.close();
assert.strictEqual((await browserState(port, opened.key)).presence, 'waiting');
})) passed++; else failed++;
if (await test('thinking and typing states expire instead of sticking', async () => {
@@ -330,8 +332,7 @@ async function main() {
version: '9.9.9-test',
idleTimeoutMs: 0,
thinkingStaleMs: 40,
typingExpiryMs: 20,
presenceSweepMs: 0
typingExpiryMs: 20
});
const bound = await staleCanvas.listen(0);
const opened = jsonBody(await request(bound.port, 'POST', '/api/sessions', { body: { file: staleArtifact } }));
@@ -353,9 +354,7 @@ async function main() {
await staleCanvas.close();
})) passed++; else failed++;
// The stuck pill only self-heals if the decay is pushed to an idle browser
// that is not making any requests of its own.
if (await test('presence sweep pushes the decayed state to an idle browser', async () => {
if (await test('finite browser polling observes a decayed presence state', async () => {
const sweepArtifact = path.join(tmp, 'sweep.plan.md');
fs.writeFileSync(sweepArtifact, '# Plan: Sweep\n');
const sweepStore = createSessionStore({ stateDir: path.join(tmp, 'sweep-state') });
@@ -363,22 +362,15 @@ async function main() {
store: sweepStore,
version: '9.9.9-test',
idleTimeoutMs: 0,
thinkingStaleMs: 50,
presenceSweepMs: 20
thinkingStaleMs: 50
});
const bound = await sweepCanvas.listen(0);
const opened = jsonBody(await request(bound.port, 'POST', '/api/sessions', { body: { file: sweepArtifact } }));
const sse = openSse(bound.port, opened.key);
await sse.ready;
await request(bound.port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'thinking' } });
await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'thinking'));
const before = sse.received.length;
await waitFor(() =>
sse.received.slice(before).some(e => e.event === 'presence' && e.data.state === 'waiting')
);
sse.close();
assert.strictEqual((await browserState(bound.port, opened.key)).presence, 'thinking');
await new Promise(resolve => setTimeout(resolve, 80));
assert.strictEqual((await browserState(bound.port, opened.key)).presence, 'waiting');
await sweepCanvas.close();
})) passed++; else failed++;
@@ -403,25 +395,18 @@ async function main() {
assert.strictEqual(JSON.parse(full.trim()).status, 'feedback');
})) passed++; else failed++;
if (await test('agent reply lands in the chat via SSE chat-sync', async () => {
const sse = openSse(port, key);
await sse.ready;
if (await test('agent reply lands in the finite browser state response', async () => {
const res = await request(port, 'POST', `/api/session/${key}/reply`, { body: { text: 'reworked, please re-check' } });
assert.strictEqual(jsonBody(res).status, 'sent');
await waitFor(() =>
sse.received.some(
e => e.event === 'chat-sync' && e.data.chat.some(m => m.role === 'agent' && m.text.includes('reworked'))
)
);
sse.close();
const state = await browserState(port, key);
assert.ok(state.chat.some(m => m.role === 'agent' && m.text.includes('reworked')));
})) passed++; else failed++;
if (await test('live reload: editing the artifact emits an SSE reload event', async () => {
const sse = openSse(port, key);
await sse.ready;
if (await test('live reload: editing the artifact changes the finite state revision', async () => {
const before = (await browserState(port, key)).artifactVersion;
fs.appendFileSync(artifact, '\n## Addendum\n');
await waitFor(() => sse.received.some(e => e.event === 'reload'), { timeoutMs: 4000 });
sse.close();
const after = (await browserState(port, key)).artifactVersion;
assert.notStrictEqual(after, before);
})) passed++; else failed++;
if (await test('send-and-end delivers the final batch and ends the session', async () => {