Skip to content

feat(runtime-sdk): add lifespan="worker" to asgi.fetch for a worker-lifetime lifespan - #239

Draft
whitphx wants to merge 1 commit into
cloudflare:mainfrom
whitphx:feat/asgi-worker-lifespan
Draft

feat(runtime-sdk): add lifespan="worker" to asgi.fetch for a worker-lifetime lifespan#239
whitphx wants to merge 1 commit into
cloudflare:mainfrom
whitphx:feat/asgi-worker-lifespan

Conversation

@whitphx

@whitphx whitphx commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Not merge-ready. This is a proposal, opened to discuss a new feature that needs a maintainer decision on the design. The open questions at the end are the parts I would want settled before this is worth merging.

asgi.fetch runs a full lifespan startup and shutdown around every request. That is right for a stateless app, but an app whose lifespan owns state meant to outlive a request (a connection pool, a warmed cache, a model loaded once, the session state behind a WebSocket) rebuilds it on each call and tears it down again.

This adds an opt-in mode, with the default unchanged:

asgi.fetch(app, request, env, ctx, lifespan="worker")   # default: "request"

"worker" starts the app's lifespan once per isolate and leaves it running, so the state the lifespan populates reaches every later request through scope["state"], which each request still receives as its own shallow copy. Concurrent cold-start requests share one startup rather than each driving its own, and a failed startup is dropped so the next request retries instead of one transient error becoming permanent.

Discussions

  1. Shutdown. There is no isolate-teardown hook provided from CF Workers, so lifespan.shutdown is never sent.

  2. Waiting on a startup another request began. A request arriving during cold start waits on a future created in the first request's IoContext, and workerd drops that wake-up once the first request has ended (handle_cross_request_promise_resolution). The future stays cached unresolved, so every later request waits on it too and the app never starts in that isolate. There is no exposure after startup completes, since the state is cached as a plain value from then on.

    Disabling the compat flag would bring back the bugs it prevents, so _ensure_worker_lifespan bounds the wait instead and forgets the cached future on expiry, turning a dead isolate into one failed request and a retry.

    Is a deadline the right shape, or is there a better handoff between contexts? WORKER_LIFESPAN_STARTUP_TIMEOUT defaults to 30s, which is a guess: too low re-drives a slow cold start and starts the app twice.

  3. API shape. The mode is a keyword on fetch, so the SDK caches the lifespan in a module-level dict keyed by the app. An explicit holder would put that lifetime in the caller's hands instead:

    resident = asgi.WorkerLifespan(app)   # module scope, so isolate scope
    ...
    return await resident.fetch(request, env, ctx)

    That drops the SDK-side globals, makes "once per isolate" visible in the user's own code rather than implied, and reaches asgi.entrypoint, which hardcodes fetch(...) and so cannot opt into the keyword form at all. The cost is new public API surface. I went with the keyword because it is the smaller change, but the holder shape may be the better one to live with.

Test Plan

$ uv run pytest 'tests/test_in_workerd.py::test_in_workerd[asgi-3.13]' -v

(run from packages/runtime-sdk)

The in-worker harness runs everything inside one long-lived request, so two things it cannot show: a response body truncated by a missing waitUntil, and the bounded wait above doing its job (workerd cancels the hung sub-request on its own before the deadline matters). The streaming test covers the mode working rather than those regressions, and the timeout is unverified by the suite.

@whitphx
whitphx force-pushed the feat/asgi-worker-lifespan branch 2 times, most recently from c260f0f to 3f53f17 Compare August 30, 2026 13:02
@whitphx
whitphx marked this pull request as draft August 30, 2026 13:44
@whitphx
whitphx force-pushed the feat/asgi-worker-lifespan branch from 3f53f17 to f293b5b Compare August 30, 2026 13:48
fetch() runs a full lifespan startup and shutdown around every request, so
an app whose lifespan owns state that outlives a request loses it each
time. Add fetch(..., lifespan="worker"), which starts the app once per
isolate, shares one startup across concurrent cold-start requests, and
retries if that startup failed. Shutdown is left to isolate teardown.
The default is unchanged.
@whitphx
whitphx force-pushed the feat/asgi-worker-lifespan branch from f293b5b to 70b56f9 Compare August 30, 2026 14:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant