You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
platform: HttpApp.toWebHandlerLayerWith memoises the layer build inside the first request — an aborted first request permanently hangs the isolate on Cloudflare workerd (error 1101) #6319
On Cloudflare Workers (workerd), HttpApp.toWebHandlerLayerWith (and everything built on it: HttpApp.toWebHandler, RpcServer.toWebHandler, …) memoises its layer build in a promise that is created inside the first request:
workerd cancels a request's IoContext when the client disconnects, and pending promises created inside a cancelled request's context never settle — they are not rejected, they simply stay pending forever. So if the first request into a cold isolate is client-aborted while the layer build is in flight:
handlerPromise is created inside that request's IoContext and never settles.
handlerCache is never assigned.
Every subsequent request takes the handlerPromise.then(...) path and chains onto a promise that will never settle.
The isolate is permanently wedged; Cloudflare eventually reports error 1101 "Worker's code had hung and would never generate a response" for every request until the isolate is evicted (which can take a long time under steady traffic, since hung requests keep the isolate alive).
Reproduction
Deploy a minimal worker whose fetch handler delegates to a toWebHandler-built handler (any non-trivial layer so the first build takes a few ms), then from a cold isolate:
# burst of client-aborted requests against a cold isolateforiin$(seq 1 20);do curl -m 0.05 -s https://your-worker.example.workers.dev/ &done;wait# now a normal request:
curl -s https://your-worker.example.workers.dev/ # hangs → eventually 1101
We hit this in production (Cloudflare Workers app using @effect/rpc's RpcServer.toWebHandler, which routes through toWebHandlerLayerWith): a single aborted first request (e.g. a browser aborting an SSR navigation) permanently hung the isolate. Confirmed via wrangler tail and reproduced deterministically with the curl burst above.
What is the expected behavior?
Either the memoised build survives an aborted first request, or the failure is recoverable — one aborted request should never permanently poison an isolate.
Possible directions:
Guard the memo: clear handlerPromise when the build promise rejects, and/or race the build against a timeout so a never-settling promise is abandoned and rebuilt by the next request.
Detach the build from the request context: on runtimes where a "waitUntil"-style API exists, anchoring the build promise to it prevents cancellation; alternatively document that the build should be kicked off eagerly at module scope.
Expose eager init: an explicit way to start (and await) the layer build outside any request (e.g. returning the build promise, or an init() handle), plus a docs note for workerd users. Before lazily build HttpLayerRouter web handlers #5206/consolidate Http web handler layer apis #5208 the build ran eagerly via Effect.runPromise at construction time, which did not have this failure mode on workerd.
Additional context
The same lazy-memo-inside-first-request pattern has bitten other libraries on workerd, e.g. better-auth (better-auth/better-auth#10315).
Workarounds we ship today: build the handler at module scope so the memo is primed outside any request where possible, plus a watchdog that rebuilds our runtime when a call exceeds a bound that only a wedge can exceed.
What version of Effect is running?
effect@3.21.2,@effect/platform@0.96.1What steps can reproduce the bug?
On Cloudflare Workers (workerd),
HttpApp.toWebHandlerLayerWith(and everything built on it:HttpApp.toWebHandler,RpcServer.toWebHandler, …) memoises its layer build in a promise that is created inside the first request:workerd cancels a request's IoContext when the client disconnects, and pending promises created inside a cancelled request's context never settle — they are not rejected, they simply stay pending forever. So if the first request into a cold isolate is client-aborted while the layer build is in flight:
handlerPromiseis created inside that request's IoContext and never settles.handlerCacheis never assigned.handlerPromise.then(...)path and chains onto a promise that will never settle.Reproduction
Deploy a minimal worker whose fetch handler delegates to a
toWebHandler-built handler (any non-trivial layer so the first build takes a few ms), then from a cold isolate:We hit this in production (Cloudflare Workers app using
@effect/rpc'sRpcServer.toWebHandler, which routes throughtoWebHandlerLayerWith): a single aborted first request (e.g. a browser aborting an SSR navigation) permanently hung the isolate. Confirmed viawrangler tailand reproduced deterministically with the curl burst above.What is the expected behavior?
Either the memoised build survives an aborted first request, or the failure is recoverable — one aborted request should never permanently poison an isolate.
Possible directions:
handlerPromisewhen the build promise rejects, and/or race the build against a timeout so a never-settling promise is abandoned and rebuilt by the next request.init()handle), plus a docs note for workerd users. Before lazily build HttpLayerRouter web handlers #5206/consolidate Http web handler layer apis #5208 the build ran eagerly viaEffect.runPromiseat construction time, which did not have this failure mode on workerd.Additional context
RpcServer.toHttpApp(MinimalPingRpc).pipe(Effect.map(HttpApp.toWebHandler))#4990 (RPC protocol hang), From Discord: Unexpected Behavior in HTTP Middleware: Unhandled Errors Cause Request Hang #3391 (middleware error hang), HTTP server returns 503 instead of 499 when client disconnects during nested fiber execution #5968 (client-disconnect status), From Discord: AWS Lambda environment fail with.toWebHandler#3129 (LambdatoWebHandler) — none cover this memoisation freeze.