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) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-24 17:12:05 +02:00
parent 2e34c209e6
commit 1eedcb8964
5 changed files with 152 additions and 3 deletions

View File

@ -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,

View File

@ -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));

View File

@ -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(); }
});

View File

@ -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}

View File

@ -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