| @@ -34,10 +34,18 @@ class Counter extends Context.Service< · 33 unmodified lines |
| 34 | { readonly id: number } | 34 | { readonly id: number } |
| 35 | >()("test/Counter") {} | 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 | Layer.effect(Counter)( | 41 | Layer.effect(Counter)( |
| 39 | Effect.acquireRelease( | 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 | counts.acquires += 1; | 49 | counts.acquires += 1; |
| 42 | return { id: counts.acquires }; | 50 | return { id: counts.acquires }; |
| 43 | }), | 51 | }), |
| @@ -114,6 +122,45 @@ describe("HttpRouter.toWebHandler request scoping", () => { · 70 unmodified lines |
| 114 | expect(counts.acquires).toBe(2); | 122 | expect(counts.acquires).toBe(2); |
| 115 | expect(counts.releases).toBe(2); | 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 | // --------------------------------------------------------------------------- |