FD leak on 304 Not Modified: opened read stream is never destroyed (leads to EMFILE)

Open Beginner friendly
#235 0 comments 0 reactions 0 assignees View on GitHub

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-handler 6.1.7
  • Also present on the current main branch (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 304 response 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 with EMFILE, 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

  1. Serve any static directory with serve / serve-handler.
  2. Request a cacheable asset repeatedly so the client sends If-None-Match and the server
    responds 304 (e.g. reload a page many times).
  3. Observe the process FD count (e.g. ls /proc/<pid>/fd | wc -l) grow by ~1 per 304
    and never decrease.
  4. Continue until the FD limit is reached; the process throws EMFILE and 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from vercel/serve-handler

All issues in vercel/serve-handler

Similar issues

More JavaScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.