fix(emulator): Storage emulator hangs under concurrent requests#10792
fix(emulator): Storage emulator hangs under concurrent requests#10792glorat wants to merge 1 commit into
Conversation
The Storage emulator delegates rule evaluation to a Java subprocess that writes one JSON response per line to stdout. The stdout handler parsed each raw "data" chunk with a single JSON.parse. Node stream chunks do not respect message boundaries, so under concurrent load multiple responses arrive in one chunk (and a single response can be split across chunks); JSON.parse then throws on the concatenation, the error is swallowed, and every batched response is dropped — hanging the corresponding requests forever. Buffer the stdout stream and dispatch only complete newline-delimited lines (handleRuntimeStdout), retaining any incomplete trailing line across chunks. Also: - remove the ~15ms per-request write delay (added for firebase#3915) that was a workaround for this bug and actually worsened batching; - free each pending-request entry on completion (previously leaked outside the firestore cross-service path). Adds unit tests driving handleRuntimeStdout with batched and split-across-chunk stdout; they fail against the old per-chunk parse. Fixes firebase#6194 Fixes firebase#6865
There was a problem hiding this comment.
Code Review
This pull request resolves a hang in the Cloud Storage emulator under concurrent requests by buffering and parsing the rules runtime's stdout on newline boundaries instead of per-chunk. It also fixes a memory leak by deleting pending-request entries upon completion and removes an obsolete 15ms delay workaround. Comprehensive unit tests have been added to verify the new stdout framing logic. The review feedback correctly identifies several instances of console.log() and console.warn() being used for output, which violates the repository style guide's rule to use the central logger instead.
| } | ||
| const id = rap.id ?? rap.server_request_id; | ||
| if (id === undefined) { | ||
| console.log(`Received no ID from server response ${line}`); |
There was a problem hiding this comment.
According to the repository style guide, we should use the central logger (src/logger.ts) instead of console.log() for user-facing output. Please replace this with EmulatorLogger.forEmulator(Emulators.STORAGE).log(...).
| console.log(`Received no ID from server response ${line}`); | |
| EmulatorLogger.forEmulator(Emulators.STORAGE).log("WARN", "Received no ID from server response " + line); |
References
- Use the central logger (src/logger.ts); never use console.log() for user-facing output. (link)
| console.warn(`[RULES] ${rap.status}: ${rap.message}`); | ||
| rap.errors.forEach(console.warn.bind(console)); |
There was a problem hiding this comment.
According to the repository style guide, we should use the central logger (src/logger.ts) instead of console.warn() for user-facing output. Please replace these with EmulatorLogger.forEmulator(Emulators.STORAGE).log(...).
| console.warn(`[RULES] ${rap.status}: ${rap.message}`); | |
| rap.errors.forEach(console.warn.bind(console)); | |
| EmulatorLogger.forEmulator(Emulators.STORAGE).log("WARN", "[RULES] " + rap.status + ": " + rap.message); | |
| rap.errors.forEach((err) => EmulatorLogger.forEmulator(Emulators.STORAGE).log("WARN", err)); |
References
- Use the central logger (src/logger.ts); never use console.log() for user-facing output. (link)
| if (request) { | ||
| request.handler(rap); | ||
| } else { | ||
| console.log(`No handler for event ${line}`); |
There was a problem hiding this comment.
According to the repository style guide, we should use the central logger (src/logger.ts) instead of console.log() for user-facing output. Please replace this with EmulatorLogger.forEmulator(Emulators.STORAGE).log(...).
| console.log(`No handler for event ${line}`); | |
| EmulatorLogger.forEmulator(Emulators.STORAGE).log("DEBUG", "No handler for event " + line); |
References
- Use the central logger (src/logger.ts); never use console.log() for user-facing output. (link)
There was a problem hiding this comment.
Gemini - don't remove the template interpolation?
These are all pre-existing, flagged because lines got moved around. For the scope of a fix, I didn't apply this. But if maintainers want it, I can apply. Also, presumably one should reject Gemini's suggestions of switching away from string interpolation |
Description
Fixes the long-standing Cloud Storage emulator hang under concurrent requests
(Fixes #6194, Fixes #6865; earlier duplicates: #3915, #4768, #4918).
The Storage emulator delegates rule evaluation to a Java subprocess that writes
one JSON response per line to stdout. The stdout handler parsed each raw
"data"chunk with a singleJSON.parse:Node stream
"data"events don't respect message boundaries. Under concurrentload two or more responses arrive in a single chunk, e.g.
{"result":...,"id":1,...}\n{"result":...,"id":2,...}.JSON.parsethrows onthat concatenation, the error is swallowed (logged at INFO and
returned), andevery response in the batch is dropped — so the requests waiting on those
ids never resolve and the upload hangs forever. Larger payloads and more
concurrent requests widen the window, which is why it reproduces with several
concurrent uploads of non-trivial files but not in isolation.
The change:
datahandler now buffers the stream inhandleRuntimeStdoutand only parses complete newline-delimited lines,retaining any incomplete trailing line across chunks — handling both a batched
chunk (many responses at once) and a single response split across two chunks.
workaround for exactly this bug (slowing writes so responses were less likely
to batch). With correct framing it's unnecessary and only slows every request.
deleted only on the firestore cross-service (
overrideId) path, leaking oneentry per request otherwise.
A prior PR, #6615, independently reached the same root cause but stalled on the
CLA and a lack of test coverage. This change takes a simpler approach —
buffering newline-delimited lines directly in the stdout handler, which also
covers a single response split across chunks — and adds the missing tests.
Unit tests (red/green). The new tests in
src/emulator/storage/rules/runtime.spec.tsdrivehandleRuntimeStdoutdirectly against seeded pending requests, at the exact seam where the bug lives.
They are a genuine red/green regression guard: reverting the framing change
(to a per-chunk
JSON.parse) makes all three fail. They cover:The pre-existing
createAuthExpressionValuetests in the same spec still pass.npm run test:compile, eslint (no new warnings), and prettier are all clean.End-to-end against a real seeded Storage emulator. With a permissive ruleset
(so the hang is clearly in stream handling, not rule complexity), N concurrent
uploads are fired and a watchdog flags any that never resolve:
On
mainthis reliably hangs — several uploads never resolve and the watchdogfires (measured 23/25 completing, 2 hung, at N=25/1.5MB). With the fix all N
resolve in well under a second (verified up to N=200). Separately, a full local
e2e suite that previously wedged the emulator after ~4 runs/seed now passes 12/12
consecutively on a single seed.
Sample Commands
N/A — this is an internal fix to the Storage emulator's rules runtime; no
command or flag changes. It affects
firebase emulators:start/:execwith theStorage emulator under concurrent load.