Skip to content

fix(emulator): Storage emulator hangs under concurrent requests#10792

Open
glorat wants to merge 1 commit into
firebase:mainfrom
Brief-Tech-Pte:fix/storage-emulator-rules-runtime-stdout-framing
Open

fix(emulator): Storage emulator hangs under concurrent requests#10792
glorat wants to merge 1 commit into
firebase:mainfrom
Brief-Tech-Pte:fix/storage-emulator-rules-runtime-stdout-framing

Conversation

@glorat

@glorat glorat commented Jul 12, 2026

Copy link
Copy Markdown

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 single JSON.parse:

this._childprocess.stdout?.on("data", (buf) => {
  const serializedRuntimeActionResponse = buf.toString("utf-8").trim();
  ...
  rap = JSON.parse(serializedRuntimeActionResponse);

Node stream "data" events don't respect message boundaries. Under concurrent
load two or more responses arrive in a single chunk, e.g.
{"result":...,"id":1,...}\n{"result":...,"id":2,...}. JSON.parse throws on
that concatenation, the error is swallowed (logged at INFO and returned), and
every 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:

  • Frame stdout on newlines. The data handler now buffers the stream in
    handleRuntimeStdout and 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.
  • Remove the ~15ms per-request write delay. It was added for Receiving: {"result":{"permit":false},"id":885,"status":"ok"} and non-resolving Promise.all while fetching from emulator storage #3915 as a
    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.
  • Free each pending-request entry on completion. Entries were previously
    deleted only on the firestore cross-service (overrideId) path, leaking one
    entry 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.ts drive handleRuntimeStdout
directly 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:

  • multiple responses in one chunk (the batched-response bug);
  • a single response split across two chunks;
  • blank-line handling / trailing-partial buffering.

The pre-existing createAuthExpressionValue tests 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:

// repro.mjs — run with:  firebase emulators:exec --only storage "node repro.mjs"
import { initializeApp } from "firebase/app";
import { getStorage, connectStorageEmulator, ref, uploadBytes } from "firebase/storage";

const app = initializeApp({ projectId: "demo-test", storageBucket: "demo-test.appspot.com", apiKey: "x" });
const storage = getStorage(app);
connectStorageEmulator(storage, "127.0.0.1", 9199);

const N = 25;
const payload = new Uint8Array(1_500_000); // ~1.5MB widens the batching window
const uploads = Array.from({ length: N }, (_, i) =>
  uploadBytes(ref(storage, `repro/file-${i}.bin`), payload),
);

const hung = new Promise((_, reject) => setTimeout(() => reject(new Error("HUNG")), 30_000));
await Promise.race([Promise.all(uploads), hung]);
console.log(`all ${N} uploads resolved`);
// storage.rules
rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    match /{allPaths=**} { allow read, write: if true; }
  }
}

On main this reliably hangs — several uploads never resolve and the watchdog
fires (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/:exec with the
Storage emulator under concurrent load.

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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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(...).

Suggested change
console.log(`Received no ID from server response ${line}`);
EmulatorLogger.forEmulator(Emulators.STORAGE).log("WARN", "Received no ID from server response " + line);
References
  1. Use the central logger (src/logger.ts); never use console.log() for user-facing output. (link)

Comment on lines +241 to +242
console.warn(`[RULES] ${rap.status}: ${rap.message}`);
rap.errors.forEach(console.warn.bind(console));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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(...).

Suggested change
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
  1. 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}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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(...).

Suggested change
console.log(`No handler for event ${line}`);
EmulatorLogger.forEmulator(Emulators.STORAGE).log("DEBUG", "No handler for event " + line);
References
  1. Use the central logger (src/logger.ts); never use console.log() for user-facing output. (link)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gemini - don't remove the template interpolation?

@glorat

glorat commented Jul 12, 2026

Copy link
Copy Markdown
Author

Code Review

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.

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

@joehan joehan self-requested a review July 14, 2026 17:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants