Skip to content

Fix response stream cleanup on client disconnect (#2804) - #2851

Merged
Akanksha Jain (jainakanksha-msft) merged 2 commits into
Azure:mainfrom
yasiruk:fix/destroy-download-body-on-disconnect
Nov 5, 2026
Merged

Akanksha Jain (jainakanksha-msft) merged 2 commits into
Azure:mainfrom
yasiruk:fix/destroy-download-body-on-disconnect

Conversation

@yasiruk

Copy link
Copy Markdown
Contributor

First and foremost thank you for the great product. I have been using Azurite for the last three years and it has made local development a breeze.

Why

Ran into HTTP 500 errors after a while when a client performed a large number of HTTP range requests on a single blob.
Following is an excerpt of the log related to the AggregateError: EMFILE: too many open files error:

error: ErrorMiddleware: Received an error, fill error information to HTTP response
error: ErrorMiddleware: ErrorName=AggregateError ErrorMessage=EMFILE: too many open files, open '/tmp/azurite/__blobstorage__/07282f07-dc04-463f-86dc-deca4665780f' ErrorStack="AggregateError: EMFILE: too many open files, open '...'
    at ReadStream.<anonymous> (node:internal/streams/destroy:81:22)
    ...
    at onConstruct (node:internal/streams/destroy:291:14)
error: ErrorMiddleware: Set HTTP code: 500
info: EndMiddleware: End response. TotalTimeInMS=5 StatusCode=500 ... "content-length":"1038090240","content-range":"bytes 35651584-1073741823/1073741824", ...

Turned out the issue was already reported in #2804

How to reproduce the error

You can use this docker image (which is based on azurite:3.37.0) to reproduce the error easily.

# syntax=docker/dockerfile:1
# Downloads that the client closes early leak file handles in Azurite.
#   docker build -t azurite-leak . && docker run --rm azurite-leak
ARG AZURITE_IMAGE=mcr.microsoft.com/azure-storage/azurite:3.37.0
FROM ${AZURITE_IMAGE}

COPY <<'EOF' /repro.mjs
import { createHmac } from "node:crypto";
import { readFileSync, readdirSync } from "node:fs";
import http from "node:http";

// Azurite's well-known development account, and an account SAS for it.
const account = "devstoreaccount1";
const key = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==";
const expiry = new Date(Date.now() + 3600_000).toISOString().slice(0, 19) + "Z";
const stringToSign = [account, "rwc", "b", "co", "", expiry, "", "", "2021-08-06", "", ""].join("\n");
const sig = createHmac("sha256", Buffer.from(key, "base64")).update(stringToSign).digest("base64");
const sas = new URLSearchParams({ sv: "2021-08-06", ss: "b", srt: "co", sp: "rwc", se: expiry, sig });
const url = (path, query = "") => `http://127.0.0.1:10000/${account}/repro${path}?${query}&${sas}`;

const put = async (target, body) => {
  const res = await fetch(target, { method: "PUT", body });
  if (!res.ok) throw new Error(`PUT ${res.status}: ${await res.text()}`);
};
const openFiles = () => readdirSync(`/proc/${process.env.AZURITE_PID}/fd`).length;

// A 64 MB blob of 1 MB blocks.
for (let retry = 0; ; retry++) {
  try { await put(url("", "restype=container")); break; }
  catch (error) { if (retry > 50) throw error; await new Promise((r) => setTimeout(r, 200)); }
}
const blockIds = [];
for (let i = 0; i < 64; i++) {
  const blockId = Buffer.from(String(i).padStart(6, "0")).toString("base64");
  await put(url("/blob", `comp=block&blockid=${encodeURIComponent(blockId)}`), Buffer.alloc(1024 * 1024));
  blockIds.push(blockId);
}
await put(url("/blob", "comp=blocklist"),
  `<BlockList>${blockIds.map((id) => `<Latest>${id}</Latest>`).join("")}</BlockList>`);

// Like ffmpeg seeking: request "bytes=0-", read the first chunk, and close the connection.
const abortedDownload = () => new Promise((resolve) => {
  let status;
  const req = http.get(url("/blob"), { headers: { Range: "bytes=0-" } }, (res) => {
    status = res.statusCode;
    res.once("data", () => req.destroy());
  });
  req.on("error", () => {});
  req.on("close", () => resolve(status));
});

console.log(`Azurite open files at the start: ${openFiles()}`);
for (let i = 1; i <= 200; i++) {
  const status = await abortedDownload();
  if (i % 20 === 0 || status !== 206) console.log(`Request ${i}: ${status}, Azurite open files: ${openFiles()}`);
  if (status === 500) {
    const errors = readFileSync("/tmp/debug.log", "utf8").split("\n").filter((line) => line.includes("ErrorMessage="));
    console.log(`Azurite ran out of file handles. Its debug log says:\n${errors[0].slice(0, 400)}`);
    process.exit(1);
  }
}
console.log("No 500 after 200 aborted downloads.");
EOF

# A low open file limit, so the 500 shows after a few requests. Azurite prints
# its access log, and writes the errors to its debug log.
ENTRYPOINT ["sh", "-c", "ulimit -n 1024; azurite-blob --location /tmp/azurite --debug /tmp/debug.log & AZURITE_PID=$! exec node /repro.mjs"]

Copilot AI balanced review requested due to automatic review settings October 14, 2026 11:53

Copilot AI 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.

🟢 Approval recommended

The cleanup behavior is consistently implemented and covered by focused cross-service and HTTP regression tests.

0 open findings

What changed in this PR

Fixes leaked extent file descriptors by destroying response bodies when clients disconnect.

Changes:

  • Adds cleanup across Blob, Queue, and Table serializers.
  • Handles late extent-stream errors after cancellation.
  • Adds serializer, persistence, and HTTP regression tests.
  1. Impact analysis

    • Package: Azurite
    • Version change: None
    • Usage: Streamed storage responses
    • Impact and risk: Prevents EMFILE failures; low residual risk.
  2. Build and conflict resolution

    • Commands run: None
    • Issues found: None
    • Result: Static review completed.
  3. Tests

    • Tests added or updated: Serializer, FSExtentStore, and Blob disconnect tests
    • Coverage: All serializers plus Loki and SQL Blob paths
    • Result: Appropriate regression coverage.
  4. Test suite

    • Commands run: None
    • Passing: Not independently verified
    • Pending or failing: CI validation pending.
  5. Changelog

    • Entry added: Yes
    • Location: Upcoming release, General section
  6. PR comments

    • Threads reviewed: Issue #2804 requirements
    • Actions taken: Reviewed all changed files
    • Unresolved items: None
  7. Self-rating

    • Score: 9/10
    • Reasoning: Complete static review with focused cross-service and persistence analysis.
  8. Iteration

    • Improvements made after self-review: Verified middleware lifecycle, response adapters, and test inclusion.
  9. Summary

    • Why the update matters: Releases file descriptors after aborted downloads.
    • Risk if not applied: Repeated disconnects can exhaust file handles.
    • Final status: Ready for approval.
File Description
src/​blob/​generated/​utils/​serializer.ts Cleans up aborted Blob response bodies.
src/​queue/​generated/​utils/​serializer.ts Adds equivalent Queue cleanup.
src/​table/​generated/​utils/​serializer.ts Adds equivalent Table cleanup.
src/​common/​persistence/​FSExtentStore.ts Handles errors from discarded late streams.
tests/​common/​serializer.test.ts Tests cancellation across all serializers.
tests/​blob/​fsStore.test.ts Tests late extent errors after cancellation.
tests/​blob/​apis/​blob.test.ts Tests HTTP client disconnect scenarios.
ChangeLog.md Documents the user-visible fix.

🧠 Review effort: Balanced


💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@yasiruk

Copy link
Copy Markdown
Contributor Author

Yasiru Kassapa (Yasiru Kassapa (@yasiruk)) please read the following Contributor License Agreement(CLA). If you agree with the CLA, please reply with the following information.

@microsoft-github-policy-service agree [company="{your company}"]

Options:

  • (default - no company specified) I have sole ownership of intellectual property rights to my Submissions and I am not making Submissions in the course of work for my employer.
@microsoft-github-policy-service agree
  • (when company given) I am making Submissions in the course of work for my employer (or my employer has intellectual property rights in my Submissions by contract or applicable law). I have permission from my employer to make Submissions and enter into this Agreement on behalf of my employer. By signing below, the defined term “You” includes me and my employer.
@microsoft-github-policy-service agree company="Microsoft"

Contributor License Agreement

@microsoft-github-policy-service agree

Copilot AI balanced review requested due to automatic review settings November 5, 2026 08:51

Copilot AI 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.

🟢 Approval recommended

No active review issues remain for the client-disconnect cleanup changes, although validation commands were not run.

No open issues 🎉

Review effort: Balanced

Copilot AI 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.

🟢 Approval recommended

No active review issues remain for the disconnect-cleanup changes, though tests and builds were not run.

No open issues 🎉

Review effort: Max

@jainakanksha-msft
Akanksha Jain (jainakanksha-msft) merged commit 716539c into Azure:main Nov 5, 2026
45 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants