Skip to content

/upload: an oversized file crashes the whole API process ("Controller is already closed") #266

Description

@rshah2395

Summary
When a single uploaded file exceeds MAX_FILE_SIZE, the API returns 413 (correct), but immediately afterwards an uncaught TypeError: Invalid state: Controller is already closed is thrown. The global uncaughtException handler then triggers gracefulShutdown(), so the API process exits and restarts. Every in-flight upload and execution for all users is lost, and the caller (LibreChat) gets Failed to provision 1 file(s) to the code environment.

Environment

  • main @ 337ddd6, self-hosted via docker compose, API on Bun (Dockerfile.api)
  • default MAX_FILE_SIZE (25 MiB); the upload was a ~27 MB PDF sent by LibreChat

Logs (reproducible: happened 4 times in a row with the same file, and the API restarted each time)

warn  [...] Upload: Session ID: sS_Cqu0XzWPCy2C9Xr3-g | ...
warn  [...] File size limit exceeded: Darlehensvertrag.pdf | Session: sS_Cqu0XzWPCy2C9Xr3-g
error Uncaught Exception Invalid state: Controller is already closed
      TypeError: Invalid state: Controller is already closed
          at enqueue (unknown)
          at onData (internal:webstreams_adapters:289:29)
          at emit (node:events:100:22)englischen Issue-Text schreiben, wenn du willst. (disable recaps in /config)

❯ ja

  Ran 2 shell commands

Der lokale Stand ist aktuell. Ich lese die betroffenen Code-Stellen selbst nach, damit das Issue exakt zitiert.

  Ran 1 shell command

Hier ist der Issue-Text zum Kopieren. Ich habe ihn gegen den aktuellen main geprüft (337ddd6, lokal identisch mit Upstream), die Code-Stellen sind wörtlich zitiert.

### `/upload`: an oversized file crashes the whole API process ("Controller is already closed")

**Summary**
When a single uploaded file exceeds `MAX_FILE_SIZE`, the API returns 413 (correct), but immediately afterwards an uncaught `TypeError: Invalid state: Controller is already closed` is thrown. The global `uncaughtException` handler then triggers `gracefulShutdown()`, so the API process exits and restarts. Every in-flight upload and execution for all users is lost, and the caller (LibreChat) gets `Failed to provision 1 file(s) to the code environment`.

**Environment**
- `v1.5.1` (cf0e668), also verified unchanged on `main` @ 337ddd6; self-hosted via docker compose, API on Bun (`Dockerfile.api`)
- default `MAX_FILE_SIZE` (25 MiB); the upload was a ~27 MB PDF sent by LibreChat

**Logs** (reproducible: happened 4 times in a row with the same file, and the API restarted each time)

warn [...] Upload: Session ID: sS_Cqu0XzWPCy2C9Xr3-g | ...
warn [...] File size limit exceeded: Darlehensvertrag.pdf | Session: sS_Cqu0XzWPCy2C9Xr3-g
error Uncaught Exception Invalid state: Controller is already closed
TypeError: Invalid state: Controller is already closed
at enqueue (unknown)
at onData (internal:webstreams_adapters:289:29)
at emit (node:events:100:22)
at (internal:streams/readable:376:45)
at flow (internal:streams/readable:604:57)
...
info Initiating graceful shutdown...
warn Post-process busboy error for session sS_Cqu0XzWPCy2C9Xr3-g: Unexpected end of form
info Graceful shutdown completed
info Starting API service (no workers)...


.toWeb(file)` (`service/src/service/router.ts` ~L67-87). On `limit`, the handler aborts the fetch and then resumes the same Node stream (`router.ts` ~L490-500):

```ts
file.on('limit', () => {
  ...
  abortController.abort();
  file.resume();
  res.status(413).json({ error: 'File size limit exceeded' });
});

abort() cancels the fetch and closes the web ReadableStream created by Readable.toWeb. The node→web adapter is still listening for data on file. So when file.resume() (and busboy draining the remaining part) emits more data, the adapter calls controller.enqueue() on a closed controller and throws inside an event-emitter callback. No local handler catches it, so it reaches service/src/api-server.ts ~L81-84:

process.on('uncaughtException', async (error) => {
  logger.error('Uncaught Exception', error);
  await gracefulShutdown();
});

The same abort-then-resume pattern also appears in the /upload timeout and error paths (~L503-507, ~L557-560) and in the /upload/batch limit handler (~L701-705). Those paths are presumably affected too, though I only observed the single-file size-limit case.

Expected
The oversized file gets a 413 (or a per-file error in batch), and the process keeps running.

Possible fix directions

  • Detach before draining: stop the web adapter from receiving further data (e.g. file.unpipe()/remove listeners, or pipe into a sink stream), rather than calling resume() on the stream owned by Readable.toWeb.
  • Or enforce the size limit on the web-stream side (a counting TransformStream that errors the stream), and let busboy drain into a no-op sink.
  • Defensively, don't shut the whole API down for errors that belong to a single reques

Workaround
Raise MAX_FILE_SIZE (it also has to be passed to the api service in compose, and i. This only moves the threshold.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions