Repository navigation
Description
What version of Effect is running?
[email protected], @effect/[email protected]
What 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:
// packages/platform/src/HttpApp.ts (current main, introduced in #5206/#5208)
function handler(request: Request, context?: Context.Context<never> | undefined): Promise<Response> {
if (handlerCache) {
return handlerCache(request, context)
}
handlerPromise ??= Effect.gen(function*() {
const runtime = yield* (options.memoMap
? Layer.toRuntimeWithMemoMap(layer, options.memoMap)
: Layer.toRuntime(layer))
return handlerCache = toWebHandlerRuntime(runtime)(
yield* options.toHandler(runtime),
options.middleware
)
}).pipe(
Scope.extend(scope),
Effect.runPromise
)
return handlerPromise.then((f) => f(request, context))
}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.- 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 isolate
for i in $(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 1101We 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
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. - 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 viaEffect.runPromiseat 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).
- Related-but-different reports we checked before filing: RPC hanging request with
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. - 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.
Activity
feat(server): web-standard fetch handler entry