Skip to content

Run code when a sandbox stops

Last updated View as MarkdownAgent setup

The runtime does not wake a Durable Object when its container stops. Your Durable Object watches the container while the Durable Object is in memory, and checks for a missed stop on every command and alarm. In this example, the Durable Object also stops idle sandboxes itself, so the record shows why each one stopped.

Prerequisites

Record each stop

  1. At the top of src/index.ts, define the check interval, the idle limit, the inactivity timeout, and the record your Durable Object keeps:

    src/index.tsts
    const CHECK_INTERVAL_MS = 60 * 1000;
    // Stop a sandbox after this long without requests
    const IDLE_LIMIT_MS = 10 * 60 * 1000;
    const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
    
    interface SandboxRecord {
    	state: "running" | "stopped";
    	lastActivity: number;
    	reason?: string;
    	exitCode?: number | null;
    	stoppedAt?: number;
    }

    Keep the inactivity timeout longer than the check interval, so the container keeps running between alarms. While monitor() waits, the inactivity timeout does not start, so the alarm enforces the idle limit. If the alarms stop, the timeout still stops the container after the Durable Object leaves memory. For more information, refer to Sandbox lifetime.

  2. In MyContainer, add a constructor, a method that watches the container, and a method that records the stop:

    src/index.tsts
    export class MyContainer extends DurableObject<Env> {
    	// ...
    
    	constructor(ctx: DurableObjectState, env: Env) {
    		super(ctx, env);
    		const container = ctx.container;
    		// A restarted Durable Object loses the earlier `monitor()` call and the
    		// inactivity timeout
    		if (container?.running) {
    			void ctx.blockConcurrencyWhile(() =>
    				container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS),
    			);
    			this.watch();
    		}
    	}
    
    	watch() {
    		this.ctx.container?.monitor().then(
    			() => this.onStop("exited", 0),
    			(error: unknown) => {
    				const exitCode = (error as { exitCode?: number }).exitCode;
    				if (exitCode === undefined) {
    					return this.onStop(String(error), null);
    				}
    				return this.onStop("exited", exitCode);
    			},
    		);
    	}
    
    	async onStop(reason: string, exitCode: number | null) {
    		const record = await this.ctx.storage.get<SandboxRecord>("sandbox");
    		// Record each stop once, even when two checks report it
    		if (record?.state !== "running") {
    			return;
    		}
    
    		await this.ctx.storage.put<SandboxRecord>("sandbox", {
    			...record,
    			state: "stopped",
    			reason,
    			exitCode,
    			stoppedAt: Date.now(),
    		});
    		await this.ctx.storage.deleteAlarm();
    		console.log(JSON.stringify({ event: "sandbox.stopped", reason, exitCode }));
    	}
    }

    When the main process fails, monitor() rejects with an error that has an exitCode. When your code calls destroy() with a reason, monitor() rejects with that reason. Put your own code after the record is saved.

  3. Add a method that records a stop that monitor() missed:

    src/index.tsts
    export class MyContainer extends DurableObject<Env> {
    	// ...
    
    	async reconcile() {
    		const record = await this.ctx.storage.get<SandboxRecord>("sandbox");
    		if (record?.state === "running" && !this.ctx.container?.running) {
    			// A stop that happened while the Durable Object was not in memory has
    			// no exit code
    			await this.onStop("unknown", null);
    		}
    	}
    }
  4. Replace exec(). It checks for a missed stop, watches a container it starts, schedules the next check, and records activity when the command finishes:

    src/index.tsts
    export class MyContainer extends DurableObject<Env> {
    	// ...
    
    	// Count the commands that are running. A request in progress keeps the
    	// Durable Object in memory, so the count does not need storage
    	busy = 0;
    
    	async exec(argv: string[]) {
    		const container = this.ctx.container;
    		if (!container) {
    			throw new Error("The container binding is not configured");
    		}
    
    		await this.reconcile();
    		if (!container.running) {
    			container.start({
    				image: "cloudflare/debian-trixie",
    				entrypoint: ["sleep", "infinity"],
    				enableInternet: false,
    			});
    			await container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS);
    			this.watch();
    		}
    		await this.ctx.storage.put<SandboxRecord>("sandbox", {
    			state: "running",
    			lastActivity: Date.now(),
    		});
    		await this.ctx.storage.setAlarm(Date.now() + CHECK_INTERVAL_MS);
    
    		this.busy++;
    		try {
    			const process = await container.exec(argv);
    			const output = await process.output();
    			return {
    				stdout: new TextDecoder().decode(output.stdout),
    				exitCode: output.exitCode,
    			};
    		} finally {
    			this.busy--;
    			const record = await this.ctx.storage.get<SandboxRecord>("sandbox");
    			if (record?.state === "running") {
    				await this.ctx.storage.put<SandboxRecord>("sandbox", {
    					...record,
    					lastActivity: Date.now(),
    				});
    			}
    		}
    	}
    }

    The alarm does not stop the sandbox while a command is running, even when the command runs longer than the idle limit.

  5. Add an alarm handler that checks the sandbox and stops it after IDLE_LIMIT_MS without requests. Then add methods that return the record and stop the sandbox:

    src/index.tsts
    export class MyContainer extends DurableObject<Env> {
    	// ...
    
    	async alarm() {
    		await this.reconcile();
    		const record = await this.ctx.storage.get<SandboxRecord>("sandbox");
    		if (record?.state !== "running") {
    			return;
    		}
    
    		if (this.busy === 0 && Date.now() - record.lastActivity > IDLE_LIMIT_MS) {
    			await this.ctx.container?.destroy("idle");
    			return;
    		}
    		await this.ctx.storage.setAlarm(Date.now() + CHECK_INTERVAL_MS);
    	}
    
    	async status() {
    		return (await this.ctx.storage.get<SandboxRecord>("sandbox")) ?? null;
    	}
    
    	async stop() {
    		await this.ctx.container?.destroy("stopped");
    	}
    }

    The reason passed to destroy() reaches monitor(), so an idle stop records idle and a stop through stop() records stopped.

    A Durable Object has one alarm. If your class already has an alarm() handler, merge the stop and idle checks into it, and set the alarm to the earliest time that any check needs. For more information, refer to Alarms.

  6. Add routes to your Worker. GET returns the record, DELETE stops the sandbox, and POST runs a command:

    src/index.tsts
    export default {
    	async fetch(request: Request, env: Env): Promise<Response> {
    		const sandbox = env.MY_CONTAINER.getByName("sandbox");
    
    		if (request.method === "GET") {
    			return Response.json(await sandbox.status());
    		}
    
    		if (request.method === "DELETE") {
    			await sandbox.stop();
    			return new Response(null, { status: 204 });
    		}
    
    		const { argv } = (await request.json()) as { argv: string[] };
    		return Response.json(await sandbox.exec(argv));
    	},
    };

    Authenticate callers first, so other people cannot use or stop the sandbox. For more information, refer to Sandbox security.

  7. Deploy your Worker:

    npx wrangler deploy
  8. Run a command, stop the sandbox, and read the record, on the workers.dev URL that Wrangler prints:

    curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev --request POST --json '{"argv":["uname","-s"]}'
    curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev --request DELETE
    curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev
    {
    	"state": "stopped",
    	"lastActivity": 1790270030663,
    	"reason": "stopped",
    	"exitCode": null,
    	"stoppedAt": 1790270031496
    }

    Run another command and send no requests for 10 minutes. The next alarm stops the sandbox, and the record shows "reason": "idle". A deploy while the sandbox runs does not record a stop.

Reasons in the record

The reason field shows how the sandbox stopped:

Reason Meaning
exited The main process exited. exitCode holds its exit code.
idle or stopped Your Durable Object called destroy() with this reason.
unknown The container stopped while the Durable Object was not in memory, and a later command or alarm found it stopped. The exit code is not available.
Any other text The error from monitor() when the container failed.

Was this helpful?