| @@ -34,10 +34,18 @@ class Counter extends Context.Service< · 33 unmodified lines |
| 34 | 34 | { readonly id: number } |
| 35 | 35 | >()("test/Counter") {} |
| 36 | 36 | |
| 37 | | −const makeCounterLive = (counts: { acquires: number; releases: number }) => |
| 37 | +const makeCounterLive = ( |
| 38 | + counts: { acquires: number; releases: number }, |
| 39 | + acquireDelayMs = 0, |
| 40 | +) => |
| 38 | 41 | Layer.effect(Counter)( |
| 39 | 42 | Effect.acquireRelease( |
| 40 | | − Effect.sync(() => { |
| 43 | + Effect.gen(function* () { |
| 44 | + // Yield to the event loop inside acquire to force concurrent |
| 45 | + // request fibers to overlap on the shared boot MemoMap. |
| 46 | + if (acquireDelayMs > 0) { |
| 47 | + yield* Effect.sleep(`${acquireDelayMs} millis`); |
| 48 | + } |
| 41 | 49 | counts.acquires += 1; |
| 42 | 50 | return { id: counts.acquires }; |
| 43 | 51 | }), |
| @@ -114,6 +122,45 @@ describe("HttpRouter.toWebHandler request scoping", () => { · 70 unmodified lines |
| 114 | 122 | expect(counts.acquires).toBe(2); |
| 115 | 123 | expect(counts.releases).toBe(2); |
| 116 | 124 | }); |
| 125 | + |
| 126 | + // Concurrent regression: Cloudflare Workers serves multiple in-flight |
| 127 | + // requests from the same isolate. `Layer.build(layer)` (used by |
| 128 | + // `requestScopedMiddleware`) inherits the boot-level `CurrentMemoMap` |
| 129 | + // installed by `HttpRouter.toWebHandler`, so two requests that race |
| 130 | + // through the middleware before either's scope closes BOTH reuse the |
| 131 | + // first request's memoized layer build — sharing one postgres.js socket |
| 132 | + // across two request handlers, which the runtime forbids: |
| 133 | + // "Cannot perform I/O on behalf of a different request" |
| 134 | + // |
| 135 | + // The fix must give each request a fresh MemoMap so memoization is |
| 136 | + // request-local. Without it, this test acquires only once (and would |
| 137 | + // crash in prod on the second concurrent request's I/O). |
| 138 | + it("requestScopedMiddleware does NOT share a build across concurrent requests", async () => { |
| 139 | + const counts = { acquires: 0, releases: 0 }; |
| 140 | + // 5ms async sleep inside acquire forces the two request fibers to |
| 141 | + // overlap on the layer build, the same shape as Cloudflare Workers |
| 142 | + // serving multiple in-flight requests from one isolate. |
| 143 | + const App = Routes.pipe( |
| 144 | + Layer.provide(requestScopedMiddleware(makeCounterLive(counts, 5)).layer), |
| 145 | + Layer.provideMerge(HttpServer.layerServices), |
| 146 | + ); |
| 147 | + const handler = HttpRouter.toWebHandler(App, { disableLogger: true }) |
| 148 | + .handler; |
| 149 | + |
| 150 | + const [a, b] = await Promise.all([ |
| 151 | + handler(new Request("http://test.local/")), |
| 152 | + handler(new Request("http://test.local/")), |
| 153 | + ]); |
| 154 | + |
| 155 | + const aBody = await a.json(); |
| 156 | + const bBody = await b.json(); |
| 157 | + // Two concurrent requests must see two distinct acquired counters. |
| 158 | + // Otherwise both fibers share one postgres socket -> Cloudflare |
| 159 | + // Workers I/O isolation crash in prod. |
| 160 | + expect(new Set([aBody.id, bBody.id])).toEqual(new Set([1, 2])); |
| 161 | + expect(counts.acquires).toBe(2); |
| 162 | + expect(counts.releases).toBe(2); |
| 163 | + }); |
| 117 | 164 | }); |
| 118 | 165 | |
| 119 | 166 | // --------------------------------------------------------------------------- |