Stream IMAP message parts instead of buffering whole messages - #10
Stream IMAP message parts instead of buffering whole messages#10ivarsb wants to merge 3 commits into
Conversation
get_message fetched RFC822, which materializes the entire message as one String, then parsed it with Mail and base64-decoded each attachment while the raw message was still reachable. Measured on an 8.27MB message, peak live strings reached 55.6MB (6.7x the wire size): net-imap literals arrive as ASCII-8BIT, and Mail.new on a binary source eagerly allocates two more full-size copies on top of the aliased raw_source, then materializing the part tree copies every part's base64 body again. Worse, RSS never came back. Twelve sequential fetches of a 25MB-attachment message grew RSS by one wire size each time (+34.5MB/fetch, 175MB to 543MB) with the Ruby heap provably flat -- 47,579 live objects and 0.42MB of strings, unchanged. Plain large alloc/free plateaus, so this is fragmentation specific to the Mail parse path rather than generic allocator behaviour. In production that ratchet OOMKilled the pod every ~10 minutes at a 384Mi limit. Now MessageReader walks BODYSTRUCTURE, fetches the header and inline bodies on their own, and streams each attachment with partial fetches (BODY.PEEK[n]<offset.length>) through an incremental base64 decoder into a Tempfile, uploading and discarding it before the next attachment is fetched. Peak memory tracks the 4MiB chunk rather than the message: RSS is flat across 12 fetches, and five concurrent 25MB-attachment fetches peak at 276MB against 614MB before. Verified with a differential harness over 11 fixtures (single-part, alternative, nested related>alternative, non-UTF-8 charset, quoted-printable, attachment without a filename, inline image with a Content-ID, message/rfc822, multi-chunk) comparing against the old whole-message output. Attachment bytes are identical in every case. Three intentional differences: - text_body on a non-multipart message. Mail#text_part searches all_parts, which is empty unless the message is multipart, so the old code returned nil for the body of every plain-text email. It is now returned. - content_type is the bare MIME type rather than the whole Content-Type header; filename is already its own field. - a 7bit/8bit attachment keeps the line endings the server sent, where Mail#decoded normalized CRLF to LF. Body sections are requested with BODY.PEEK so reading cannot set \Seen, and inline parts are rebuilt from a synthesized MIME header so Mail still applies the charset conversion. AttachmentStore gains a streaming upload_io and memoizes its client and presigner, which were being rebuilt twice per attachment at ~11MB of RSS each. Deployment note: attachments are now spooled through Tempfile, so TMPDIR must point at disk. If the container's /tmp is tmpfs the spool is anonymous memory and counts against the cgroup limit, which would defeat the fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Applied findings from a four-angle review of the previous commit. Reuse: net-imap already reconciles the request spec against the response key, which differ (servers answer BODY.PEEK[...] with BODY[...] and append the origin octet on a partial fetch). FetchStruct#part/#header do this, so the hand-rolled body_of prefix scan is gone. Its scan matched any origin, so a server echoing a different one would have been accepted and the loop would have advanced over the wrong bytes; #part matches exactly. A missing section is now distinguishable from end-of-part -- empty means EOF, nil means the server answered something we did not ask for -- so a mid-stream gap raises IncompletePart instead of uploading a zero-byte attachment under a valid-looking presigned URL. The spec fake returns a real Net::IMAP::FetchData so specs exercise that reconciliation rather than a stand-in for it. Correctness of filenames: BODYSTRUCTURE parameters arrive RFC 2231-split across numbered keys (FILENAME*0*, FILENAME*1*) or RFC 2047 encoded. The old casecmp?-based lookup returned nil for both, and since a filename is what marks a part as an attachment, such attachments vanished from the response entirely. Mail::ParameterHash plus Encodings.value_decode handles all three forms. The comment claiming servers vary parameter-name case was also wrong: net-imap's parser upcases them. Efficiency: Base64Stream#push made three full-size copies of every chunk -- measured 15.00 MiB of garbage per 4 MiB chunk. It now strips and slices in place, measured at 3.99 MiB. This required pairing with the caller, which must read chunk.bytesize before handing the buffer over; the chunk fetch and write also moved into their own method so the buffer is unreachable rather than held in a loop local across the next round trip. Rebuilding an inline part now uses setters instead of interpolating a MIME header and re-parsing it, saving about one copy of each inline body. Five concurrent 25MB-attachment fetches now peak at 221MB, against 276MB before this cleanup and 614MB before the streaming change. Simplification: AttachmentStore.upload had no caller left and preserved the fully-resident String upload this work exists to remove -- deleted, with store inlined into upload_io. Part collapsed media_type/subtype into one mime_type member and dropped the never-read content_id. streamable? no longer conflates "we can decode this incrementally" with "the server reported a size"; the chunk loop terminates on the first empty range, so a part of unreported size streams too. Tempfile.create's block form replaces the manual ensure/close!, which also closes a leak: the tempfile used to be created outside the begin, so a fetch raising mid-stream held an fd and its disk until finalization. Also trimmed dead guards in Base64Stream, a guard clause in flatten, and dead code in the fake. The comment on the memoized S3 client claimed ~11MB per client; measured it is ~19MB and ~110ms once, then ~0.8ms -- corrected. Deliberately not changed: - Streaming quoted-printable. Mail's QP decoder normalizes line endings and repairs hard breaks mis-encoded as hex, over the whole string, and is empirically not decomposable across chunk boundaries -- it mismatches at every slice size tested, including when only the trailing 8 bytes are split off. A hand-rolled substitute risks silently corrupting attachment content, which is worse than the bounded-but-larger memory use of an encoding only chosen for text. The one-pass path now logs when taken, and the comment no longer implies large QP parts cannot occur. - Capping inline body size. Inline parts are still fetched whole, so a very large inline HTML body is unbounded. Pre-existing, and truncating a body changes what the tool returns, so it belongs in its own change. - Merging the two inline part fetches into one round trip. Saves one of four fetches but holds both raw bodies at once, on the axis this work exists to bound. - Moving upload and hash assembly out of MessageReader. Offloading attachments is inherent to reading a message without buffering it, and splitting it back out pushes ImapClient over its length limit again or needs a third class. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Streams IMAP message parts to reduce memory usage during attachment processing.
Changes:
- Adds BODYSTRUCTURE-based message reading and chunked Base64 decoding.
- Streams attachments through temporary files to memoized S3 clients.
- Adds coverage for MIME structures, encoding, and streaming behavior.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
lib/mail_mcp.rb |
Loads new streaming components. |
lib/mail_mcp/attachment_store.rb |
Streams IO uploads and memoizes AWS clients. |
lib/mail_mcp/base64_stream.rb |
Adds incremental Base64 decoding. |
lib/mail_mcp/imap_client.rb |
Delegates message retrieval to MessageReader. |
lib/mail_mcp/message_reader.rb |
Fetches and processes individual IMAP sections. |
lib/mail_mcp/message_structure.rb |
Flattens BODYSTRUCTURE into fetchable parts. |
spec/mail_mcp/attachment_store_spec.rb |
Tests streaming S3 uploads. |
spec/mail_mcp/base64_stream_spec.rb |
Tests incremental decoding. |
spec/mail_mcp/imap_client_spec.rb |
Tests per-part message retrieval. |
spec/mail_mcp/message_structure_spec.rb |
Tests MIME structure handling. |
spec/spec_helper.rb |
Loads the fake IMAP server. |
spec/support/fake_imap_server.rb |
Simulates BODYSTRUCTURE and section fetches. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| return [leaf(body, prefix || "1")] unless body.multipart? | ||
|
|
||
| body.parts.flat_map.with_index(1) do |child, number| | ||
| flatten(child, [prefix, number].compact.join(".")) |
There was a problem hiding this comment.
Checked this against the old whole-message path with a multipart/related carrying Content-Disposition: attachment; filename="page.mht". The new output matches the old one exactly:
| field | old (RFC822 + Mail) |
new (per-part) |
|---|---|---|
attachments |
[] |
[] |
text_body |
"real message body" |
"real message body" |
html_body |
"<p>archived page</p>" |
"<p>archived page</p>" |
So there's no regression here — and the reason is that this is Mail's own behaviour rather than an accident of the rewrite. Mail::AttachmentsList (attachments_list.rb:8-19) only ever treats leaf parts as attachments:
elsif p.parts.empty?
p if p.attachment? # only leaves are candidates
else
p.attachments # a node with children recurses; itself never included
endThe multipart node's attachment? really is true and its filename really is "page.mht", but AttachmentsList still skips it. message/rfc822 is the one special case in that method, and it already arrives here as a leaf (BodyTypeMessage#multipart? is false), so forwarded-message attachments are picked up as before — there's a fixture for that.
Emitting the multipart node as an attachment would therefore diverge from Mail and add an attachment to responses the old path never returned. Since this PR's verification is a differential against that old output, I've left the semantics as-is and added a spec so they're deliberate rather than incidental (7d3d12e).
The second half of the observation is fair on its own terms: html_body can come from inside an attached archive. That predates this branch, matches Mail#html_part, and changing it changes what the tool returns — so it belongs in its own change rather than this one.
Copilot flagged flatten's unconditional descent into multipart nodes as dropping an attached multipart and letting its children supply text_body/html_body. Checked against the old whole-message path with a multipart/related carrying Content-Disposition: attachment; filename="page.mht". The new output matches the old one exactly on all three fields: no attachment, text_body from the real body part, html_body from inside the archive. That is Mail's own behaviour, not an accident. Mail::AttachmentsList (attachments_list.rb:8-19) only ever treats leaf parts as attachments -- a part with children recurses and is never included itself, whatever its disposition says. message/rfc822 is its one special case, and that already arrives here as a leaf because BodyTypeMessage#multipart? is false, so it is picked up as an attachment as before. So there is no regression to fix, and emitting the multipart node as an attachment would both diverge from Mail and add an attachment to responses the old path never returned. Added a spec so the semantics are deliberate rather than incidental. The second half of the observation is fair on its own terms: html_body can come from inside an attached archive. That predates this branch, matches Mail#html_part, and changing it changes what the tool returns, so it belongs in its own change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Correction to the original descriptionI've edited the description above. The measurement it originally leaned on does not hold on the platform this actually runs on, so flagging the change rather than quietly rewriting history. What I originally claimed: RSS grew by one message size per fetch, linearly and without plateau, so no memory limit was safe and raising it only bought time. What is actually true: that growth is specific to macOS malloc, where I first measured it. Re-run in
So this is a peak problem, not accumulation. Two consequences:
What is unchanged: the peak multiplier, which is the reason for the PR. The per-request peak scaled with message size (~2.7× wire on glibc, ~3.4× on macOS, and 6.7× counting Ruby-level live strings), and with The ~10 minute restart cadence is therefore how often a burst of concurrent large fetches lands, not a steady climb. |
Problem
The
mail-mcppod was OOMKilled every ~10 minutes at a 384Mi limit —Restart Count: 101, exit code 137, ~16h of crashlooping.Root cause
get_messagefetchedRFC822, which materializes the whole message as one String, parsed it withMail, then base64-decoded each attachment while the raw message was still reachable.Measured on an 8.27MB message, peak live strings hit 55.6MB — 6.7× the wire size:
Mail.newnet-imap literals are always
ASCII-8BIT(documented atnet/imap.rb:3119), andMail.newon a binary source eagerly allocates two more full-size copies on top of the aliasedraw_source; materializing the part tree then copies every part's base64 body again.Why it OOMKilled
config/puma.rbruns a single worker withthreads 1, 5, so that peak is per in-flightrequest. Five concurrent large messages is ~460MB of peak plus boot, which clears 384Mi on
its own — measured at 614MB for five concurrent 25MB-attachment fetches. The ~10 minute
cadence is how often such a burst lands, not a steady climb.
Verified on glibc in
ruby:3.4.9-slimthat RSS does not accumulate across fetches — itplateaus on the first one and stays there, with or without
MALLOC_ARENA_MAX=2:MALLOC_ARENA_MAX=2(20.7MB message, 15MB attachment; 56.5MB above boot ≈ 2.7× wire, matching the peak above.
The same loop on macOS malloc grows ~34.5MB per fetch without plateauing, so that behaviour
is allocator-specific and not what production hits. Container was aarch64; AKS is x86_64,
and glibc's allocator behaves broadly alike across the two.)
So this is a peak-memory problem, not a leak: raising the limit genuinely helps, and
lowering the per-request peak is what makes the limit hold.
Fix
MessageReaderwalksBODYSTRUCTUREand fetches each piece on its own: header, inline bodies, then each attachment via partial fetchesBODY.PEEK[n]<offset.length>through an incremental base64 decoder into aTempfile, uploaded and discarded before the next attachment is fetched.The largest allocation in the path is now a 4MiB chunk regardless of message size, so the per-request peak no longer scales with the message — which is what makes a fixed memory limit hold. That holds by construction, independently of the numbers below.
AttachmentStoregains a streamingupload_ioand memoizes its client and presigner, which were being rebuilt twice per attachment (measured: ~110ms and ~19MB for the first, ~0.8ms after).Verification
A differential harness runs old vs new over 11 fixtures — single-part,
multipart/alternative, nestedrelated>alternative, non-UTF-8 charset, quoted-printable, attachment with no filename, inline image with a Content-ID,message/rfc822, and a multi-chunk 9MB attachment. Attachment bytes are identical in every case; no difference is unexplained. The fake IMAP server derives part addressing fromMail's own tree, so the walker is not tested against itself.put_objectwith a Tempfile body genuinely streams: +18.6MB for a 40MB body.Concurrency and peak numbers are macOS malloc; the glibc figures in Root cause come from
ruby:3.4.9-slim. The peak multiplier agrees across both (~2.7–3.4× wire). The construction argument above is what does not depend on the allocator.Behaviour changes
text_bodyon a non-multipart message.Mail#text_partsearchesall_parts, which is empty unless the message is multipart — so the old code returnednilfor the body of every plain-text email. It is now returned. Pre-existing bug, fixed incidentally.content_typeis the bare MIME type rather than the full Content-Type header;filenameis already its own field.Mail#decodednormalized CRLF to LF. base64 attachments are byte-identical.MessageReader::IncompletePartinstead of uploading a zero-byte attachment under a valid-looking presigned URL.Body sections are requested with
BODY.PEEK, so reading a message cannot set\Seen.Deploying
/tmpin the running container is the overlay filesystem, not tmpfs — verified — so attachment spooling lands on disk and no volume change is needed. It is also on the container's writable layer, which is recreated per restart, so a spool file leaked by a SIGKILL does not accumulate.Worth pairing with this:
container_memory_working_set_bytesfor an hour before tightening. Baseline to beat is 101 restarts / ~10 min lifetime. Since this is a peak problem rather than a leak, the curve should be sawtooth with a stable floor — a rising floor would mean something here is wrong.ephemeral-storagerequests/limits — the spool is currently untracked and the node is at 79%. Worst case isthreads × largest attachment≈ 250MB.timeoutSeconds/failureThresholdon both probes. TheUnhealthyevents seen alongside the OOMKills are a separate mechanism:put_objectis synchronous and holds a Puma thread, so/healthcan queue behind five busy threads. This PR does not address thread occupancy. Do not lower the thread count as a memory measure — it makes probe starvation more likely.Deliberately not in this PR
Mail's QP decoder normalizes line endings and repairs hard breaks mis-encoded as hex across the whole string, and is empirically not decomposable across chunk boundaries — it mismatches at every slice size tested, including when only the trailing 8 bytes are split off. A hand-rolled substitute risks silently corrupting attachment content, which is worse than the bounded-but-larger memory use of an encoding only chosen for text. The one-pass path now logs when taken.@s3 ||=is not atomic, so a first concurrent burst can build up to five clients and discard four. Benign, but moving the ~110ms out of the first request touchesconfig.ru.search_messagesuses@imap.search, which returns sequence numbers, while every other method is UID-based and the tool labels the resultuids. Separate bug, cheap fix.🤖 Generated with Claude Code