Version
@cloudflare/containers 0.3.7 (latest at time of filing)
Summary
When a caller abandons a body-bearing response from containerFetch() without consuming the body, the inflight-request counter never decrements, isActivityExpired() renews the activity timeout on every alarm pass, and the container (plus its supervisor DO) runs — and bills — indefinitely. sleepAfter never fires. The only thing that ends the instance is a deploy rolling the image.
Where
In containerFetch() (dist/lib/container.js), the decrement for body-bearing responses is deferred until the body has been fully piped to the consumer:
if (res.body !== null) {
const { readable, writable } = new IdentityTransformStream();
res.body?.pipeTo(writable).finally(() => {
this.decrementInflight();
});
return new Response(readable, res);
}
If the returned readable is never read (client disconnected, caller timed out and abandoned the await, isolate evicted), pipeTo stalls on backpressure forever once the body exceeds the transform stream's buffer, so the .finally() never runs.
Meanwhile the activity check treats a pinned counter as endless activity:
isActivityExpired() {
if (this.inflightRequests > 0) {
this.renewActivityTimeout(); // renews forever
return false;
}
return this.sleepAfterMs <= Date.now();
}
Small bodies that fit the stream buffer complete the pipeTo regardless of a reader, so this only bites once responses exceed the buffer — which is exactly the large-payload case (file transcodes, archives, model output).
How we hit it
An ffmpeg transcode container returns multi-megabyte mp4 bodies to a Cloudflare Workflow; each step does const res = await container.fetch(...); await res.arrayBuffer(). Any step abandoned between those two awaits (step timeout, retry, deploy mid-run) leaks one inflight slot, and that instance never sleeps again despite sleepAfter = '20m'.
Over an 11-day window we accumulated ~1,000 idle instance-hours (memory/disk GiB-seconds ÷ instance size) against ~13 hours of actual vCPU work (~98.7% idle). Instances only stopped on deploys. On standard-3 that idle time was ~$79 of an ~$86 invoice.
Expected
An abandoned response body should not keep the container awake indefinitely. Some options:
- Tie the pending
pipeTo to a deadline or to the request's abort signal, so an unread body eventually settles the inflight slot.
- Renew activity only while bytes are actually flowing, rather than unconditionally whenever
inflightRequests > 0.
- At minimum, document that abandoned reads of body-bearing responses pin the container awake, and recommend an application-level watchdog.
Workaround
We subclassed Container to track last-request time ourselves and used schedule() (which the alarm loop processes even in the leaked state) to run a reaper that force-stops (stop(), falling back to destroy()) after 30 minutes with no new requests.
Version
@cloudflare/containers0.3.7 (latest at time of filing)Summary
When a caller abandons a body-bearing response from
containerFetch()without consuming the body, the inflight-request counter never decrements,isActivityExpired()renews the activity timeout on every alarm pass, and the container (plus its supervisor DO) runs — and bills — indefinitely.sleepAfternever fires. The only thing that ends the instance is a deploy rolling the image.Where
In
containerFetch()(dist/lib/container.js), the decrement for body-bearing responses is deferred until the body has been fully piped to the consumer:If the returned
readableis never read (client disconnected, caller timed out and abandoned the await, isolate evicted),pipeTostalls on backpressure forever once the body exceeds the transform stream's buffer, so the.finally()never runs.Meanwhile the activity check treats a pinned counter as endless activity:
Small bodies that fit the stream buffer complete the
pipeToregardless of a reader, so this only bites once responses exceed the buffer — which is exactly the large-payload case (file transcodes, archives, model output).How we hit it
An ffmpeg transcode container returns multi-megabyte mp4 bodies to a Cloudflare Workflow; each step does
const res = await container.fetch(...); await res.arrayBuffer(). Any step abandoned between those two awaits (step timeout, retry, deploy mid-run) leaks one inflight slot, and that instance never sleeps again despitesleepAfter = '20m'.Over an 11-day window we accumulated ~1,000 idle instance-hours (memory/disk GiB-seconds ÷ instance size) against ~13 hours of actual vCPU work (~98.7% idle). Instances only stopped on deploys. On
standard-3that idle time was ~$79 of an ~$86 invoice.Expected
An abandoned response body should not keep the container awake indefinitely. Some options:
pipeToto a deadline or to the request's abort signal, so an unread body eventually settles the inflight slot.inflightRequests > 0.Workaround
We subclassed
Containerto track last-request time ourselves and usedschedule()(which the alarm loop processes even in the leaked state) to run a reaper that force-stops (stop(), falling back todestroy()) after 30 minutes with no new requests.