mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-01 22:15:17 +02:00
fix(plan-canvas): separate startup lock endpoint
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. An OS-managed, port-scoped startup lock serializes compatibility checks and replacement so concurrent opens reuse the winning server instead of racing two detached launches. The lock is released automatically when its process exits.
|
||||
- `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. An OS-managed, port-scoped startup lock on a separate derived UDP endpoint serializes compatibility checks and replacement, so concurrent opens reuse the winning server without colliding with UDP traffic on the Canvas service port. The lock is released automatically when its process exits.
|
||||
- `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
|
||||
|
||||
@@ -25,8 +25,8 @@ artifact as a real PDF file without sending the plan to an external converter.
|
||||
- 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 7/7, Plan Canvas server tests pass 35/35,
|
||||
and the end-to-end review workflow passes 13/13.
|
||||
- FULL SUITE: the final review-hardened implementation passes all 4,010
|
||||
and the end-to-end review workflow passes 14/14.
|
||||
- FULL SUITE: the final review-hardened implementation passes all 4,011
|
||||
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
|
||||
|
||||
+11
-1
@@ -172,11 +172,21 @@ function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function serverStartLockPort(port) {
|
||||
const servicePort = validatePort(port);
|
||||
// Keep the kernel-managed mutex independent of the TCP service endpoint.
|
||||
// The rotation is one-to-one for ordinary non-privileged service ports, so
|
||||
// separate Canvas ports do not contend with one another.
|
||||
if (servicePort < 1024) return 49152 + servicePort;
|
||||
const nonPrivilegedPortCount = 65535 - 1024 + 1;
|
||||
return 1024 + ((servicePort - 1024 + Math.floor(nonPrivilegedPortCount / 2)) % nonPrivilegedPortCount);
|
||||
}
|
||||
|
||||
async function withServerStartLock(port, task, {
|
||||
timeoutMs = 15 * 1000,
|
||||
dgramImpl = dgram
|
||||
} = {}) {
|
||||
const lockPort = validatePort(port);
|
||||
const lockPort = serverStartLockPort(port);
|
||||
const startedAt = Date.now();
|
||||
let socket = null;
|
||||
let socketError = null;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const dgram = require('dgram');
|
||||
const { EventEmitter } = require('events');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
@@ -170,6 +171,23 @@ async function main() {
|
||||
);
|
||||
});
|
||||
|
||||
await test('unrelated UDP traffic on the Canvas port does not block startup', async () => {
|
||||
const servicePort = port + 4;
|
||||
const unrelatedSocket = dgram.createSocket('udp4');
|
||||
await new Promise((resolve, reject) => {
|
||||
unrelatedSocket.once('error', reject);
|
||||
unrelatedSocket.bind(servicePort, '127.0.0.1', resolve);
|
||||
});
|
||||
try {
|
||||
assert.strictEqual(
|
||||
await withServerStartLock(servicePort, async () => 'started', { timeoutMs: 2000 }),
|
||||
'started'
|
||||
);
|
||||
} finally {
|
||||
unrelatedSocket.close();
|
||||
}
|
||||
});
|
||||
|
||||
await test('port-scoped startup lock propagates socket failures', async () => {
|
||||
class FailingLockSocket extends EventEmitter {
|
||||
bind(_port, _host, callback) { setImmediate(callback); }
|
||||
@@ -178,7 +196,7 @@ async function main() {
|
||||
}
|
||||
const socket = new FailingLockSocket();
|
||||
await assert.rejects(
|
||||
withServerStartLock(port + 4, async () => {
|
||||
withServerStartLock(port + 5, async () => {
|
||||
socket.emit('error', new Error('simulated UDP failure'));
|
||||
}, { dgramImpl: { createSocket: () => socket }, timeoutMs: 2000 }),
|
||||
error => error.code === 'PLAN_CANVAS_START_LOCK_FAILED' && error.message.includes('simulated UDP failure')
|
||||
|
||||
Reference in New Issue
Block a user