From 1eedcb8964e333eca1d65e5981aa544c0cc3da51 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Thu, 24 Sep 2026 17:12:05 +0200 Subject: [PATCH] Reclaim previews nobody is using any more Nothing gave an instance back. A flow that ended, or an agent that simply stopped calling, left its service running and its copy on disk until somebody noticed - which is how dev-server-instances became the one volume that only ever grows. Two clocks, because they answer different questions. Idle asks whether anybody is still looking: a preview request or an MCP call resets it, and running out stops the process while keeping the copy, so an evaluator who stepped away for a meeting pays a restart and not a reinstall. The absolute one asks whether this has been here long enough to be forgotten, and discards everything - the only thing in the whole stack that hands disk back on its own. Asking after an instance counts as using it, not only preview traffic. The browser reaches a running service straight on its port, which the worker never sees, so an agent mid-loop would otherwise look idle to the clock watching it. What the sweep reclaimed is logged. A preview that vanishes without a word is indistinguishable from one that broke, and the person it vanished under has no way to tell the difference. Co-Authored-By: Claude Opus 5 (1M context) --- dev-server-mcp/src/dev-server-service.js | 69 ++++++++++++++++++- dev-server-mcp/src/worker-index.js | 11 +++ .../test/execution-instances.test.js | 66 ++++++++++++++++++ mcp-stack.compose.yml | 2 + mcp-stack.env.example | 7 ++ 5 files changed, 152 insertions(+), 3 deletions(-) diff --git a/dev-server-mcp/src/dev-server-service.js b/dev-server-mcp/src/dev-server-service.js index 52c733b..df58fb9 100644 --- a/dev-server-mcp/src/dev-server-service.js +++ b/dev-server-mcp/src/dev-server-service.js @@ -92,7 +92,9 @@ export class DevServerService { portAllocator = null, maxInstances = 4, npmRegistry = null, npmCache = null, workspaceLimits = null, instanceHome = "/tmp/dev-server", childPath = "/usr/local/bin:/usr/bin:/bin", proxy = null, javaHome = null, - previewProxy = null, previewBaseUrl = null }) { + previewProxy = null, previewBaseUrl = null, + idleTimeoutMs = 2 * 60 * 60 * 1000, maxLifetimeMs = 24 * 60 * 60 * 1000, + sweepIntervalMs = 60 * 1000 }) { this.services = services; this.instances = new Map(); this.logLimit = logLimit; @@ -111,6 +113,58 @@ export class DevServerService { // is the door a person walks through to look at what the execution built. this.previewProxy = previewProxy; this.previewBaseUrl = previewBaseUrl; + // Two clocks, because they answer different questions. Idle asks "is anybody still looking at + // this", and a person who steps away for a meeting should find it alive when they come back - + // so a preview request resets it, and running out only stops the process, leaving the copy + // and its dependencies for a restart that costs nothing. The absolute one asks "has this been + // here long enough to be forgotten", and running out discards the lot, which is the only thing + // that ever gives the disk back. + this.idleTimeoutMs = idleTimeoutMs; + this.maxLifetimeMs = maxLifetimeMs; + this.sweepIntervalMs = sweepIntervalMs; + this.sweeper = null; + } + + /** Starts the clock. Separate from the constructor so a test can drive sweep() by hand. */ + startSweeping(onReclaimed = () => {}) { + if (this.sweeper) return; + this.sweeper = setInterval(() => { + this.sweep().then((reclaimed) => { if (reclaimed.length) onReclaimed(reclaimed); }).catch(() => {}); + }, this.sweepIntervalMs); + this.sweeper.unref?.(); + } + + stopSweeping() { + if (this.sweeper) clearInterval(this.sweeper); + this.sweeper = null; + } + + /** + * Reclaims what nobody is using any more. Returns what it did, so a caller can log it - a + * preview that vanishes without a word is indistinguishable from one that broke. + */ + async sweep(now = Date.now()) { + const reclaimed = []; + for (const [handle, instance] of [...this.instances]) { + if (!instance.child) continue; + const definition = this.services.get(handle.split("\u0000")[0]); + if (!definition) continue; + const tooOld = now - (instance.startedAtMs ?? now) > this.maxLifetimeMs; + const idle = now - (instance.lastSeen ?? now) > this.idleTimeoutMs; + if (!tooOld && !idle) continue; + try { + if (tooOld && definition.workspaceMode === "execution" && instance.key) { + await this.discard({ service: definition.id, key: instance.key }); + reclaimed.push({ service: definition.id, key: instance.key, reason: "max-lifetime", action: "discarded" }); + } else { + await this.stopHandle(definition, handle); + reclaimed.push({ service: definition.id, key: instance.key, reason: "idle", action: "stopped" }); + } + } catch { + // A single instance refusing to die must not stop the rest from being swept. + } + } + return reclaimed; } definition(service) { @@ -147,7 +201,12 @@ export class DevServerService { status({ service, key }) { const definition = this.definition(service); - return publicStatus(definition, this.instances.get(this.handle(definition, key))); + const instance = this.instances.get(this.handle(definition, key)); + // Asking after an instance counts as using it. The browser reaches a running service directly + // on its port, which the worker never sees, so without this an agent could be mid-loop while + // the idle clock quietly decides nobody wants the thing any more. + if (instance) instance.lastSeen = Date.now(); + return publicStatus(definition, instance); } runningCount() { @@ -170,6 +229,8 @@ export class DevServerService { key: slot, status: "starting", startedAt: new Date().toISOString(), + startedAtMs: Date.now(), + lastSeen: Date.now(), exitedAt: null, exitCode: null, signal: null, @@ -356,7 +417,9 @@ export class DevServerService { logs({ service, key, tailBytes = 4096 }) { const definition = this.definition(service); if (!Number.isInteger(tailBytes) || tailBytes < 1 || tailBytes > this.logLimit) throw new Error(`tailBytes must be between 1 and ${this.logLimit}`); - const logs = this.instances.get(this.handle(definition, key))?.logs ?? ""; + const instance = this.instances.get(this.handle(definition, key)); + if (instance) instance.lastSeen = Date.now(); + const logs = instance?.logs ?? ""; const buffer = Buffer.from(logs); return { service, diff --git a/dev-server-mcp/src/worker-index.js b/dev-server-mcp/src/worker-index.js index 8547166..2afef2f 100644 --- a/dev-server-mcp/src/worker-index.js +++ b/dev-server-mcp/src/worker-index.js @@ -57,6 +57,8 @@ const devServer = new DevServerService({ : null, previewProxy, previewBaseUrl, + idleTimeoutMs: integer(process.env.DEV_SERVER_IDLE_TIMEOUT_SECONDS, 7200) * 1000, + maxLifetimeMs: integer(process.env.DEV_SERVER_MAX_LIFETIME_SECONDS, 86400) * 1000, workspaceLimits: { maxEntries: integer(process.env.DEV_SERVER_MAX_WORKSPACE_ENTRIES, 20000), maxBytes: integer(process.env.DEV_SERVER_MAX_WORKSPACE_BYTES, 512 * 1024 * 1024) @@ -127,12 +129,21 @@ server.listen(integer(process.env.DEV_SERVER_WORKER_PORT, 4000), process.env.DEV process.stderr.write("dev-server worker ready\n"); }); +// Nothing else ever gives an instance back: a flow that ends, or an agent that simply stops +// calling, leaves its service running and its copy on disk until something reclaims them. +devServer.startSweeping((reclaimed) => { + for (const item of reclaimed) { + process.stderr.write(`${JSON.stringify({ timestamp: new Date().toISOString(), server: "dev-server-worker", event: "reclaimed", ...item })}\n`); + } +}); + if (previewProxy) { await previewProxy.listen(); process.stderr.write(`dev-server preview proxy on ${previewProxy.host}:${previewProxy.port}, published at ${previewBaseUrl}/preview/\n`); } async function shutdown() { + devServer.stopSweeping(); await previewProxy?.close().catch(() => {}); await devServer.close(); server.close(() => process.exit(0)); diff --git a/dev-server-mcp/test/execution-instances.test.js b/dev-server-mcp/test/execution-instances.test.js index 887e8c9..1f9070b 100644 --- a/dev-server-mcp/test/execution-instances.test.js +++ b/dev-server-mcp/test/execution-instances.test.js @@ -235,3 +235,69 @@ test("a shared service gets no preview link, having no execution to scope one to assert.equal(started.previewUrl, null); } finally { await item.cleanup(); } }); + +test("an idle preview is stopped but its copy is kept, so a restart costs nothing", async () => { + // Idle asks whether anybody is still looking. A person who stepped away should not also pay for + // a full reinstall when they come back, so the expensive part stays on disk. + const item = stack({ services: { web: WEB } }); + item.service.idleTimeoutMs = 1000; + item.service.maxLifetimeMs = 60 * 60 * 1000; + try { + item.write("exec-a", { "app.txt": "A" }); + await item.service.start({ service: "web", key: "exec-a" }); + + const reclaimed = await item.service.sweep(Date.now() + 5000); + + assert.deepEqual(reclaimed.map((r) => r.action), ["stopped"]); + assert.equal(item.service.status({ service: "web", key: "exec-a" }).status, "stopped"); + assert.ok(fs.existsSync(path.join(item.instances, "exec-a")), "the copy must survive an idle stop"); + } finally { await item.cleanup(); } +}); + +test("a preview past its absolute lifetime is discarded, which is what gives the disk back", async () => { + const item = stack({ services: { web: WEB } }); + item.service.idleTimeoutMs = 60 * 60 * 1000; + item.service.maxLifetimeMs = 1000; + try { + item.write("exec-a", { "app.txt": "A" }); + await item.service.start({ service: "web", key: "exec-a" }); + + const reclaimed = await item.service.sweep(Date.now() + 5000); + + assert.deepEqual(reclaimed.map((r) => r.action), ["discarded"]); + assert.ok(!fs.existsSync(path.join(item.instances, "exec-a")), "the copy must be gone"); + } finally { await item.cleanup(); } +}); + +test("a preview somebody is still reading is left alone", async () => { + // The whole point of the idle clock resetting: the sweep runs on its own schedule, not on the + // evaluator\'s, and must not reclaim something that was touched a moment ago. + const item = stack({ services: { web: WEB } }); + item.service.idleTimeoutMs = 1000; + try { + item.write("exec-a", { "app.txt": "A" }); + const started = await item.service.start({ service: "web", key: "exec-a" }); + item.service.instances.get("web\u0000exec-a").lastSeen = Date.now() + 4000; + + const reclaimed = await item.service.sweep(Date.now() + 2000); + + assert.deepEqual(reclaimed, []); + assert.equal(item.service.status({ service: "web", key: "exec-a" }).status, "running"); + assert.equal(await (await fetch(`http://127.0.0.1:${started.port}/`)).text(), "A"); + } finally { await item.cleanup(); } +}); + +test("asking after an instance counts as using it", async () => { + // The browser reaches a running service straight on its port, which the worker never sees. If + // only preview traffic counted, an agent mid-loop could have its instance swept away. + const item = stack({ services: { web: WEB } }); + try { + item.write("exec-a", { "app.txt": "A" }); + await item.service.start({ service: "web", key: "exec-a" }); + item.service.instances.get("web\u0000exec-a").lastSeen = 0; + + item.service.status({ service: "web", key: "exec-a" }); + + assert.ok(item.service.instances.get("web\u0000exec-a").lastSeen > 0); + } finally { await item.cleanup(); } +}); diff --git a/mcp-stack.compose.yml b/mcp-stack.compose.yml index 45bbddb..863461e 100644 --- a/mcp-stack.compose.yml +++ b/mcp-stack.compose.yml @@ -97,6 +97,8 @@ services: # for the flows where a human has to look at what was built. DEV_SERVER_PREVIEW_BASE_URL: ${DEV_SERVER_PREVIEW_BASE_URL:-} DEV_SERVER_PREVIEW_PORT: ${DEV_SERVER_PREVIEW_PORT:-4500} + DEV_SERVER_IDLE_TIMEOUT_SECONDS: ${DEV_SERVER_IDLE_TIMEOUT_SECONDS:-7200} + DEV_SERVER_MAX_LIFETIME_SECONDS: ${DEV_SERVER_MAX_LIFETIME_SECONDS:-86400} volumes: - type: bind source: ${MCP_WORKSPACE_HOST_PATH} diff --git a/mcp-stack.env.example b/mcp-stack.env.example index a2145cb..e8ce0b2 100644 --- a/mcp-stack.env.example +++ b/mcp-stack.env.example @@ -53,3 +53,10 @@ MCP_BIND_ADDRESS=0.0.0.0 # preview rides the /preview/ route on it. Leave unset on a laptop stack: without it the dev loop # still works (the browser reaches instances internally) and no preview links are handed out. DEV_SERVER_PREVIEW_BASE_URL=https://mcp.sse.cloud.isti.cnr.it + +# Two clocks on a running instance. Idle asks whether anybody is still looking - a preview request +# or an MCP call resets it - and running out stops the process while keeping the copy, so coming +# back costs a restart and not a reinstall. The absolute one runs regardless and discards +# everything, which is the only thing that ever gives the disk back. +DEV_SERVER_IDLE_TIMEOUT_SECONDS=7200 +DEV_SERVER_MAX_LIFETIME_SECONDS=86400