FD leak on 304 Not Modified: opened read stream is never destroyed (leads to EMFILE)
Nobody has claimed this yet.
Assessment
- Difficulty
- 1/5
- Estimated time
- Under an hour
- Newbie friendliness
- 92/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- javascript, node.js
- Domain
- backend
Research direction
Start in src/index.js and inspect the conditional If-None-Match branch after the read stream is created. Reproduce repeated 304 responses against a cacheable asset, then verify that the stream is released and the process file-descriptor count no longer grows.
Written by the indexing model from the issue text.
Description
Summary
When serve-handler responds with 304 Not Modified, the read stream that was opened
earlier in the request handler is never consumed or destroyed. Because the file
descriptor (FD) held by that stream is only released when the stream is piped (200) or
explicitly destroyed, every 304 response leaks exactly one FD. Under normal browser
traffic (most requests are conditional If-None-Match requests that resolve to 304),
FDs accumulate until the process hits the OS limit and throws EMFILE: too many open files, crashing the server.
Affected version
serve-handler6.1.7- Also present on the current
mainbranch (the 304 branch does not destroy the stream).
Root cause
In src/index.js, the handler opens the stream before evaluating the conditional
request:
let stream = null;
try {
stream = await handlers.createReadStream(absolutePath, streamOpts); // FD acquired here
} catch (err) {
return internalError(absolutePath, response, acceptsJSON, current, handlers, config, err);
}
const headers = await getHeaders(handlers, config, current, absolutePath, stats);
// ...
if (request.headers.range == null && headers.ETag && headers.ETag === request.headers['if-none-match']) {
response.statusCode = 304;
response.end(); // 304 returned
return; // <-- stream is never piped or destroyed => FD leak
}
response.writeHead(response.statusCode || 200, headers);
stream.pipe(response); // 200 path: stream is consumed, FD released on completion
On the 200 path the stream is piped to the response, so its FD is released when the
transfer finishes. On the 304 path the function returns without piping or destroying
the stream, so the underlying FD stays open forever.
Impact
- Every
304response leaks 1 FD. - FDs never decrease while the process is alive (verified: FD count stayed flat for hours
under no load, and kept climbing under load until it reached the FD limit). - Once the limit is reached, subsequent
open()calls fail withEMFILE, crashing the
process. In containerized deployments this manifests as intermittent 5xx errors and
restarts.
Observed error:
node:events:486
throw er; // Unhandled 'error' event
Error: EMFILE: too many open files, open '.../_next/static/chunks/xxxx.js'
errno: -24, code: 'EMFILE', syscall: 'open'
Steps to reproduce
- Serve any static directory with
serve/serve-handler. - Request a cacheable asset repeatedly so the client sends
If-None-Matchand the server
responds304(e.g. reload a page many times). - Observe the process FD count (e.g.
ls /proc/<pid>/fd | wc -l) grow by ~1 per304
and never decrease. - Continue until the FD limit is reached; the process throws
EMFILEand crashes.
Proposed fix
Destroy the opened stream before returning on the 304 branch, so its FD is released:
if (request.headers.range == null && headers.ETag && headers.ETag === request.headers['if-none-match']) {
response.statusCode = 304;
stream.destroy();
response.end();
return;
}
I'm happy to open a PR with this change if it's acceptable.
---
## Fix diff (for the PR)
```diff
diff --git a/src/index.js b/src/index.js
index 564f012..ed1c5b2 100644
--- a/src/index.js
+++ b/src/index.js
@@ -759,6 +759,7 @@ module.exports = async (request, response, config = {}, methods = {}) => {
// eslint-disable-next-line no-eq-null
if (request.headers.range == null && headers.ETag && headers.ETag === request.headers['if-none-match']) {
response.statusCode = 304;
+ stream.destroy();
response.end();
return;
- Dominant language
- JavaScript
- Stars
- 618
- Forks
- 117
- PR merge metrics
- No merged PRs in 30d
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from vercel/serve-handler
-
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
vercel/serve-handler#231 · 2 comments · 5 reactions ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
vercel/serve-handler#165 · 1 comment ·
-
Difficulty 4/5 3-5 days Newbie friendliness 42/100
vercel/serve-handler#223 ·
-
Difficulty 3/5 1-2 days Newbie friendliness 52/100
vercel/serve-handler#210 · 1 comment · 3 reactions ·
-
Difficulty 3/5 1-2 days Newbie friendliness 45/100
vercel/serve-handler#205 · 1 comment · 3 reactions ·
All issues in vercel/serve-handler
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
HarperFast/skills#96 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Automattic/studio#4908 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 74/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
sugarlabs/musicblocks#8847 ·
-
client-controller-update ta-bot-triage team-money-movement
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
MetaMask/metamask-mobile#36594 ·