Skip to content

Fix multipart file parts hanging when parser limits are exceeded mid-file - #7155

Closed
tarikermis wants to merge 2 commits into
Effect-TS:mainfrom
tarikermis:fix-multipart-limits-mid-file-hang
Closed

tarikermis wants to merge 2 commits into
Effect-TS:mainfrom
tarikermis:fix-multipart-limits-mid-file-hang

Conversation

@tarikermis

Copy link
Copy Markdown

What & why

Multipart.toPersisted (and Multipart.persisted in @effect/platform-bun) hangs indefinitely when a multipart parser limit such as maxTotalSize is exceeded mid-file during an upload. The handler never completes and the request eventually times out without any response.

Root cause: when the parser reports a limit violation, it drops the offending chunk without forwarding it to the boundary scanner, so the in-progress file part's terminating boundary is never seen and its content stream never closes. The file part's pull loop in Multipart.makeChannel only terminated on that boundary (finished), so it kept pumping forever (a busy loop after upstream EOF) without ever observing the parser error. This is the same failure mode that was fixed upstream in multipasta (tim-smart/multipasta#40); the fix there landed in the web-stream wrapper, whose equivalent here is makeChannel.

Note: on main the external multipasta dependency is gone — the parser is vendored in effect/unstable/http/MultipartParser — so instead of a dependency bump this fixes the vendored wrapper. The issue is labeled 3.0; happy to backport or adjust if a 3.x-line fix (multipasta bump) is wanted separately.

Changes

  • Multipart.makeChannel: propagate parser errors (and upstream stream failures) to in-progress file part streams via a fileExit exit, checked after buffered chunks drain. A part that completed before the parser errored still ends cleanly (finished wins), and only the in-progress part fails.
  • toPersisted's default file writer: only wrap PlatformError as InternalError; parser MultipartErrors now keep their reason (e.g. BodyTooLarge → 413 instead of a masked InternalError → 500). Same for File.contentEffect, which previously wrapped every error as InternalError.
  • Regression tests (3): maxTotalSize mid-file, maxFileSize mid-file, and a two-file case asserting a completed first file persists fully before the second file fails.
  • Changeset: effect patch.

Reproduction & verification

  • New tests hang on current main (an event-loop-starving busy loop — verified by stashing the fix; no in-process timeout can preempt it) and pass with the fix.
  • End-to-end under real Bun (1.3.14): BunMultipart.persisted(request) with maxTotalSize: 1024 and a chunked 8 KB upload — hangs pre-fix (killed after 15 s), fails fast with MultipartError: BodyTooLarge post-fix.
  • Checks run: pnpm test --run test/unstable/http/Multipart.test.ts (13/13), HttpServerRequest.test.ts + HttpEffect.test.ts (40/40), @effect/platform-node MultipartParser.test.ts (84/84), bun node_modules/vitest/vitest.mjs run --project @effect/platform-bun (13/13), pnpm check (tsc -b, clean), pnpm lint-fix (0 errors).

Review & limitations

  • Diff reviewed with kiro-cli (claude-opus-5); two rounds of findings addressed (default writeFile path now tested, upstream-halt passes the Cause through instead of squashing, multi-file ordering test added). Final pass: LGTM.
  • The @effect/platform-bun test project has no multipart coverage; Bun verification was done with an ad-hoc script (deleted after use) rather than a committed test.
  • No explicit timeout guard in the regression tests: the pre-fix failure mode starves the event loop, so no in-process timer (including vitest's own testTimeout) can fire — noted in a comment above the tests.

Closes #6284

…file

When a parser limit such as maxTotalSize is exceeded while a file part
is in progress, the parser reports the error but never emits the part's
terminating boundary. The file content stream only checked for that
boundary, so consumers like Multipart.toPersisted waited forever.

Propagate parser (and upstream) failures to in-progress file part
streams so they fail instead of hanging, mirroring the upstream
multipasta fix (tim-smart/multipasta#40). Also stop masking parser
errors as InternalError in toPersisted's default file writer and in
contentEffect, so limit violations surface with their actual reason
(e.g. BodyTooLarge -> 413).

Closes Effect-TS#6284
@changeset-bot

changeset-bot Bot commented Aug 9, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1e4026a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 30 packages
Name Type
effect Patch
@effect/ai-anthropic Patch
@effect/ai-openai Patch
@effect/ai-openai-compat Patch
@effect/ai-openrouter Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/openapi-generator Patch
@effect/opentelemetry Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node Patch
@effect/platform-node-shared Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/vitest Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@effect-slopcop effect-slopcop Bot added 4.0 bug Something isn't working labels Aug 9, 2026
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
basic.ts 6.92 KB 6.92 KB 0.00 KB (0.00%)
batching.ts 9.72 KB 9.72 KB 0.00 KB (0.00%)
brand.ts 6.60 KB 6.60 KB 0.00 KB (0.00%)
cache.ts 10.59 KB 10.59 KB 0.00 KB (0.00%)
config.ts 20.91 KB 20.91 KB 0.00 KB (0.00%)
differ.ts 19.77 KB 19.77 KB 0.00 KB (0.00%)
http-client.ts 21.52 KB 21.52 KB 0.00 KB (0.00%)
logger.ts 10.81 KB 10.81 KB 0.00 KB (0.00%)
metric.ts 8.86 KB 8.86 KB 0.00 KB (0.00%)
optic.ts 6.68 KB 6.68 KB 0.00 KB (0.00%)
pubsub.ts 14.86 KB 14.86 KB 0.00 KB (0.00%)
queue.ts 11.54 KB 11.54 KB 0.00 KB (0.00%)
schedule.ts 10.71 KB 10.71 KB 0.00 KB (0.00%)
schema-class.ts 19.48 KB 19.48 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 29.41 KB 29.41 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 25.63 KB 25.63 KB 0.00 KB (0.00%)
schema-string-transformation.ts 13.55 KB 13.55 KB 0.00 KB (0.00%)
schema-string.ts 11.09 KB 11.09 KB 0.00 KB (0.00%)
schema-template-literal.ts 15.38 KB 15.38 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 21.52 KB 21.52 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 24.00 KB 24.00 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 18.74 KB 18.74 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 18.57 KB 18.57 KB 0.00 KB (0.00%)
schema-toFormatter.ts 18.43 KB 18.43 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 22.59 KB 22.59 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 19.08 KB 19.08 KB 0.00 KB (0.00%)
schema.ts 18.73 KB 18.73 KB 0.00 KB (0.00%)
stm.ts 12.59 KB 12.59 KB 0.00 KB (0.00%)
stream.ts 9.67 KB 9.67 KB 0.00 KB (0.00%)

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

ℹ️ One minor note inline — the fix itself is correct, well-tested, and I verified it empirically.

Reviewed changes

  • makeChannel in-progress part termination — parser errors and upstream stream failures are now routed to in-progress file part streams through a shared fileExit, surfaced after buffered chunks drain; completed parts still end cleanly (finished wins), and only the in-progress part fails. Mirrors the upstream multipasta fix (tim-smart/multipasta#40).
  • Error reason preservation — FileImpl.contentEffect and defaultWriteFile no longer blanket-wrap every failure as InternalError; only PlatformError is wrapped, so limit errors keep their real reason (BodyTooLarge/FileTooLarge → 413 instead of a masked 500).
  • Regression tests — three new cases (maxTotalSize mid-file, maxFileSize mid-file, multi-file ordering showing a completed first part persists before the second fails).

Verification I ran: reverting Multipart.ts to main makes the new tests hang (killed by a 25 s shell timeout), confirming they genuinely reproduce the bug; with the fix, Multipart.test.ts (13), HttpServerRequest.test.ts + HttpEffect.test.ts (29), and @effect/platform-node MultipartParser.test.ts (84) all pass. I also traced for a scenario where parser.end()'s EndNotReached could overwrite the specific limit reason via the re-fired onError — it can't: once exit is set, the parts loop short-circuits before any further pump, so the EOF path never runs while an error is pending. The changeset is accurate and the @effect/platform-node path is a separate Node-stream implementation that pushes errors into the file Readable, so it has no hang.

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment thread packages/effect/src/unstable/http/Multipart.ts Outdated
Map the upstream cause's failure values to InternalError MultipartErrors
(via Cause.map, preserving defects and interruptions) instead of routing
the raw cause into fileExit, so File.content and File.contentEffect keep
their declared MultipartError error type without a cast.

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

✅ No new issues found.

Reviewed changes

  • fileExit upstream-error contract fix — in-progress file part streams now map upstream stream failures to an InternalError MultipartError via Cause.map instead of storing the raw cause, keeping File.contentEffect's documented Effect<Uint8Array, MultipartError> contract intact while parser limit reasons (BodyTooLarge/FileTooLarge) still pass through with their specific reason.
  • Binding regression test — a new case streams upload bytes from a ReadableStream that errors mid-file and asserts the in-progress part's contentEffect fails with an InternalError MultipartError. I verified it's genuinely binding: it fails with the raw Error: boom on the pre-fix source and passes with the fix.

I ran the full Multipart.test.ts (14/14 pass), confirmed the new test fails on the pre-fix fileExit mapping, and typechecked packages/effect clean.

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@tim-smart

Copy link
Copy Markdown
Contributor

Addressed in another PR :)

This branch had an error being deployed

1 failed deployment
fork — 1e4026ab Deployed Aug 9, 2026 by tarikermis via approval-gate #11562
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0 bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@effect/platform-bun multipart.toPersisted hangs when maxTotalSize is exceeded mid-file

2 participants