Stream DO wake loops must decay: the 2026-09-01 preview duration runaway
Summary#
From 2026-09-01 ~10:00 UTC to 2026-09-02 ~15:00 UTC the dev/preview Cloudflare
account burned 500k+ Durable Object duration hours ($300/hour at peak,
~$5k+ total) with zero user traffic. Contained on 2026-09-02 by running
pnpm erase-data --env preview_N on 12 slots (tombstones every DO). Detection
now exists (.depot/workflows/do-duration-probe.yml → Slack #error-pulse).
This task is the root-cause fix: the loops themselves.
Observed mechanism (verified live via wrangler tail os-preview-9)#
- ~16,000+ DO-equivalents continuously active on one preview slot; ~450k successful DO invocations/hour, around the clock, on an idle slot.
- Per-DO cycle every ~10s (
KEEPALIVE_ALARM_LEAD_MS):ALARM → wakeStreamProcessor/handleAlarm → getEventPage/appendCoreEvent → proxySetAlarm → ALARM. Outcomes overwhelmingly "ok" — this is not one crash-looping object, it is a whole population of streams that wake "successfully" and never conclude. - Objects stay resident between invocations (activeTime far exceeds
invocation wall time;
maxWs=0), i.e. cross-DO RPC sessions / in-flight background work pin them — same class as the June 2026 subscription-pin leak (apps/os/tasks/do-duration-leak/DECISION_LOG.md).
Why the designed safeguards didn't hold#
Each mechanism below is individually defensible; together they form a non-decaying oscillator for any stream whose work can never settle (erased spec-run projects, 403-damaged workers, parked 503 slots — previews mass-produce all three):
- Every wake appends a
stream/wokenevent (stream-durable-object.ts#finishInitialization). The wake itself creates a new durable event, which is new delivery work, which can fail, which schedules a retry, which wakes the stream. The prod OOM incident stream accumulated 770+stream/wokenevents in 3h. - The keepalive crash-loop breaker resets too eagerly
(
packages/iterate/src/processors/stream-processor-keepalive.ts): the revival backoff (10s→1m→5m→30m→6h) only holds if no quiet-clean confirmation intervenes. A cycle that contains a clean settlement (e.g. the revival pass resolves, delivery acks) resets the budget every round, so the 10s lead never decays. - Every deploy resets every keepalive budget (by design — version change = "the fix probably shipped"). On a preview account with dozens of deploys a day, wedged populations are perpetually re-budgeted.
- Nothing bounds the population. Preview slots leased by long-lived open PRs are never erased (GC only reclaims expired leases), so every spec run deposits more never-settling streams into slots that keep them for days.
Fix directions (each independently valuable)#
- Keepalive: make the budget reset require quiet-clean without an intervening revival (or: N consecutive quiet-clean fires), so revive→clean→revive cycles still climb the backoff ladder.
- Delivery retry: permanently-failing subscribers (project worker 503/gone,
config-repo 403) must reach a halted/parked state with a slow re-probe,
not a warm retry loop. Check
stream-event-sender.tshalt machinery for why it didn't engage here. -
stream/woken: don't append when the previous event is already an undeliveredwoken(or coalesce; or exempt woken-only appends from waking processors/subscribers). The event log growing on every wake is itself the fuel. - Deploy-resets-budget: cap re-budgets per version-change per stream, or make the fresh budget start further up the ladder when the record shows prior plateau.
- Preview GC: periodically DO-reset slots that are leased but idle (no deploy/e2e in >3h) — leases protect the slot assignment, not the DO population. Alternatively: spec teardown erases the streams it created.
- Related in-flight work: PR #2572 (bound oversized settlements), PR #2573 (crash-loop quarantine for OOMing DOs), PR #2575 (repro). Those cover the OOM strain; this task covers the "successfully looping forever" strain.
Evidence breadcrumbs#
- CF GraphQL:
durableObjectsPeriodicGroupsper-namespace activeTime; top namespaces wereos-preview-{9,17,1,13,4,16,3,6,12,8}/StreamDurableObject. apps/os/scripts/do-duration-probe.tsnow trips on both signatures; rundoppler run --config dev -- pnpm tsx apps/os/scripts/do-duration-probe.ts.- prd has a chronic ~120 DO-hours/hour baseline worth its own look (the June leak was declared fixed; either it wasn't fully, or agents/schedulers legitimately hold this much — verify).