A deadline has to live outside the code it limits
A handler that never yields defeats timers inside the isolate, so the deadline for agent-generated JavaScript has to be a process kill from outside it.

export default { async fetch() { while (true) {} } }
That’s a complete Worker, and it settled the design of a service I built for running agent-generated JavaScript. The service loads generated code into V8 isolates inside Cloudflare’s open-source workerd, under a control plane written in Rust. Code written by a model needs a deadline, and the deadline has to hold at exactly the moment the code stops cooperating, so a handler that never returns and never yields is the case worth designing for. There are two obvious places to put that deadline, and this handler defeats both.
The first is the harness, the JavaScript that loads the generated code and calls it. The harness can start a timer and race it against the handler, which works for as long as the handler yields. An isolate runs on one cooperative thread, though, so the timer’s callback is waiting for the same event loop that while (true) is sitting on. The timer expires and nothing is free to notice.
The second is the engine. Cloudflare’s Dynamic Workers resource-limit documentation describes limits: { cpuMs, subRequests } and says that exceeding either limit throws an exception. In June 2026 I loaded that handler with cpuMs: 50 on workerd 1.20260608.1 on Linux x64 and gave the client four seconds. The four seconds ran out with no exception and no response. That local build hadn’t delivered the bound I needed, so I wasn’t going to base the service’s deadline on it.
Then the test driver sent SIGKILL to the engine, waited half a second and confirmed that the process was gone, and the spinning isolate went with it. The proof’s conclusion was short: the control plane has to enforce an out-of-process request timeout and recycle the process.
Killing from the outside
Each engine is one workerd process listening on a Unix socket in a private directory, and the control plane sits outside it. Stripped down to its shape, dispatching a job looks like this:
match timeout(wall_ms, unix_request(job)).await {
Ok(reply) => Outcome::Response { /* status, body, wall_ms */ },
Err(_elapsed) => {
let _ = self.recycle_unlocked().await; // kill, wait, spawn, health-check
Outcome::Timeout { wall_ms }
}
}
A job’s execution limit defaults to 5 seconds and is clamped between 100 milliseconds and 30 seconds. The timer starts around the Unix-socket request, after acquiring the per-engine lock and ensuring a process is alive. On expiry, the controller kills the process, waits for it to exit and attempts to start a replacement, with a separate 15-second readiness budget. Queueing and replacement therefore sit outside the execution deadline, and the timeout path returns a timeout even if replacement fails. The timer can act because it runs in another process, which the kernel can schedule while JavaScript is spinning, and SIGKILL doesn’t need the target’s agreement. The engineering is in what follows from choosing that boundary.
What the kill costs
The kill takes the whole process with it, and a process is a much coarser unit than an isolate. In the proof, loading a new worker and getting its first response took 3 milliseconds at the median and 5 at the 99th percentile over 200 runs, whereas a replacement process has to come back through that health check. A kill also takes out anything else running in the process. In the July 2026 design, each engine ran one job at a time behind a lock, a round-robin pool supplied parallelism across engines, and an engine found dead was respawned before its next job. That kept each kill’s immediate effect to one active job, while the pool paid the replacement cost.
The deadline is also a wall-clock limit, which is a different thing from a CPU limit. A job waiting on a slow upstream dies on the same schedule as a job spinning on a core, and the kill says nothing about how much CPU either of them used. What it bounds is the active request time before the controller begins reclaiming the engine. The engine’s own cpuMs setting is still passed through, defaulting to 50 milliseconds, but the deadline doesn’t depend on it.
One failure would have been easy to miss. The timer above lives in a Rust future. If cancelling an HTTP request drops that future, it drops the watchdog too. An agent that submits a runaway job and hangs up would take the watchdog with it and leave the loop spinning with nobody watching. So the pool spawns each dispatch as a detached task and awaits only its handle: a disconnect cancels the waiting, and the deadline and the recycle still happen.
The pool also recycles engines on a schedule, for a different reason. The same proof found that stock workerd’s Date.now() advances during execution, so generated code has a clock to time things with, and Spectre-class attacks depend on that kind of measurement. The harness deletes SharedArrayBuffer and Atomics. When scheduled recycling is enabled, replacement attempts start at staggered offsets to spread the disruption across the pool. Memory points the same way: across 200 cold and 200 warm runs, the engine’s footprint grew from about 52 MB to about 194 MB.
The authority available before timeout
The deadline limits duration. Capabilities decide what generated code can do before it expires, with a run holding only the authority it was explicitly given. Outbound network is off by default: with globalOutbound: null, fetch throws an error telling the worker that it must use capabilities to talk to the outside world. When a job is allowed out, its traffic goes through a gateway that accepts only HTTPS, rejects credentials embedded in URLs, checks the hostname against an allow-list and stops at five redirects and 1 MiB of body, leaving private-address protection to workerd’s public-only network. Secrets work the same way: a request names the ones it needs and carries a run-scoped token, the control plane redeems that token against a key-value store with its own service credential, and a name outside the grant simply doesn’t resolve.
Below those capabilities, eval and new Function are blocked, and on Linux a seccomp denylist can refuse operations like mount, module loading, ptrace, kexec, namespace changes and bpf, though it’s opt-in and leaves socket calls alone. Underneath all of that, the isolation is workerd’s and V8’s.
What I’d carry over to any system running code it doesn’t trust is the test that decided the design: write a handler that never yields, then check whether the mechanism enforcing its deadline can still act. A timer waiting on that same event loop cannot.
Chris Chabot, November 2026