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:
parent
2e34c209e6
commit
1eedcb8964
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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(); }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue