Skip to content

fix(node): drain rejected upload bodies - #1952

Closed
spa5k wants to merge 1 commit into
middleapi:mainfrom
spa5k:feat/fix-node-upload-closed-stream
Closed

fix(node): drain rejected upload bodies#1952
spa5k wants to merge 1 commit into
middleapi:mainfrom
spa5k:feat/fix-node-upload-closed-stream

Conversation

@spa5k

@spa5k spa5k commented Aug 25, 2026

Copy link
Copy Markdown

Summary

  • drain the remaining Node request body after an upload parser or size limit rejects it
  • avoid canceling the Readable.toWeb adapter while IncomingMessage data is still arriving
  • cover raw file and multipart uploads, including per-file limits with an unlimited aggregate limit

Why

A per-file multipart limit stopped async iteration early. That canceled the Web stream created from the Node request. Node could then deliver a queued data event to the closed Web stream controller and throw ERR_INVALID_STATE.

The limiter now uses a native async generator. It prevents source cancellation and discards remaining request bytes in the background.

Tests

  • pnpm exec vitest run packages/node: 171 passed
  • focused regression on Node 22, 24, and 26: passed
  • package type diagnostics: 0 errors and 0 warnings
  • ESLint and git diff check: passed

@pullfrog pullfrog 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.

ℹ️ The fix is correct and the drain mechanism works. Minor suggestion inline-referenced below.

Reviewed changes

  • limitStream rewritten as an async generator — replaces pipeThrough(new TransformStream(...)) with for await (... of stream.values({ preventCancel: true })) so the source Web stream is never cancelled (avoiding Node's ERR_INVALID_STATE), throwing PAYLOAD_TOO_LARGE past the limit, and draining the rejected body via stream.pipeTo(new WritableStream()) in a finally.
  • Always apply limitStream — dropped the two fileLimit === Infinity ? stream : limitStream(...) shortcuts in the multipart and spool paths; with Infinity the wrapper is a harmless pass-through.

ℹ️ No regression test pinned for the fixed crash

This is a subtle stream-semantics fix, and the exact scenario that originally crashed — a per-file multipart limit with an unlimited aggregate, a raw raw or event-streamed body that gets rejected mid-flight — has no repo test asserting it. A test would guard against the pipeThrough/cancel regression returning (e.g. in packages/node/tests/uploads-large-files.test.ts).

Technical details
# No regression test for rejected-upload drain/crash

## Affected sites
- /tmp-file-upload-handler.test / uploads-large-files e2e — nothing asserts the callback path this change exposes

## Required outcome
- A test that sends a per-file-limited multipart (or a spooled / streamed body) that trips the limit mid-arrival at a low rate, then asserts the request rejects with PAYLOAD_TOO_LARGE and the server completes without an unhandled ERR_INVALID_STATE (and, ideally, that the response arrives and any connection/socket is not left holding the unread body).

## Suggested approach
- Add an e2e test in `packages/node/tests/uploads-large-files.test.ts` mirroring the existing per-file-limit e2e, but where the body is still being delivered when the limit trips (no content-length fast path), so the drain path is exercised.
- Optionally assert the underlying socket is drained (server can serve an immediate follow-up request).

Pullfrog  | Fix it ➔View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@dinwwwh

dinwwwh commented Aug 26, 2026

Copy link
Copy Markdown
Member

Thanks for the thorough diagnosis — it was right, and it led to the proper fix landing upstream: middleapi/standardserver#76 (released in 0.8.1) drops Readable.toWeb for a pull-based converter, so the ERR_INVALID_STATE crash is impossible by construction, and canceled http1 request bodies are abandoned instead of destroyed so the 413 stays deliverable.

I verified against this exact scenario (per-file multipart limit, flooded 20 MB uploads): on 0.8.0 the rejection resets the connection (ECONNRESET, no status); after upgrading to 0.8.1 with this PR's change absent, all flooded uploads get a clean 413 and the server survives. We'll ship the dependency bump instead, so this workaround is no longer needed.

@dinwwwh dinwwwh closed this Aug 26, 2026
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.

2 participants