Skip to content

fix(request): release cloned request bodies via buffer-once replay - #397

Open
SynthLuvr wants to merge 1 commit into
honojs:mainfrom
SynthLuvr:issue-347-clone-memory
Open

fix(request): release cloned request bodies via buffer-once replay#397
SynthLuvr wants to merge 1 commit into
honojs:mainfrom
SynthLuvr:issue-347-clone-memory

Conversation

@SynthLuvr

Copy link
Copy Markdown

Fixes #347

Problem

clone() tee()s the socket-backed body stream. The unread branch of a tee queues every chunk until it is read, so when middleware does c.req.raw.clone().json() and the original body is never read, the entire raw body stays resident for as long as the request graph is reachable — logger/tracing contexts, error objects, and profilers routinely retain the request past the response, which shows up as the linear memory growth reported in the issue.

Approach

  • clone() now reads the body exactly once into a Buffer (via the existing readBodyDirect fast path) and hands the original request and every clone a lightweight replay stream backed by that shared buffer. No tee is created, so nothing queues an unread copy.
  • When the response completes, the listener drops the shared buffer and cancels replay streams nobody is reading. Locked streams that are actively being read are left alone and finish naturally. A retained request therefore no longer pins the raw body.
  • The release hook on the synchronous cacheable fast path listens on 'close' rather than 'finish' so the buffer is also released when the client disconnects before the response is flushed — and it is only attached when a shared body exists, so responses without cloned bodies keep the path free of close listeners.

Reading an unread clone after the response has completed rejects with TypeError: Body is unusable, matching what a native Request does when its body stream has been canceled.

Tests

  • test/request.test.ts: clones replay the body while leaving the original readable; unread clones are canceled on release; clones being read keep delivering
  • test/listener.test.ts: the original body stays readable after a clone has consumed it (integration); the synchronous cacheable fast path releases the shared body on close
  • test/server.test.ts: GC-based memory-leak regression next to the existing ones — the shared buffer is collected after the response completes even while the request is still retained

Verified locally: format, lint, typecheck, build + publint, and the full vitest suite.

…onojs#347)

clone() no longer tee()s the socket-backed body stream, whose unread
branch queues the entire body until it is read or GC'd - pinned for as
long as the request graph is reachable (logger/tracing contexts, error
objects, profilers). Instead the body is read once into a shared Buffer
(via the existing readBodyDirect fast path) and the original request +
clones replay from it. When the response completes, the listener
releases the shared buffer and cancels replay streams that were never
read.

- Hook the release of the synchronous cacheable fast path to 'close'
  rather than 'finish' so the buffer is also released when the client
  disconnects early, and only attach the listener when a shared body
  exists - the path stays free of close listeners otherwise
- Export bodySharedBufferKey so tests can watch the shared buffer with
  a FinalizationRegistry, next to the existing memory leak tests
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.

V2 Memory Leak ( solved: c.req.raw.clone().json() )

1 participant