Product: Aspose.Email for .NET
Versions affected: 26.3.0 and 24.9.0 (identical behaviour on both)
Platform: .NET 8.0.28, win-x64, Windows Server, Workstation GC, licensed
Summary
Aspose.Email.Tools.FileFormatUtil.DetectFileFormat(Stream) allocates roughly 1.85 GiB for a 373-byte message stream. MailMessage.Load() on the identical stream allocates 142 KB — so format detection costs roughly 14,000x more memory than actually parsing the message.
The allocated size has no relation to the input size. Across five samples it takes one of exactly two values, and which one appears to be decided solely by whether the message has a Received: header:
| sample | size | Received: header |
DetectFileFormat allocates |
|---|---|---|---|
minimal.eml |
237 B | no | 540,800,635 bytes (516 MB) |
3001.eml |
6.99 KB | no | 540,972,851 bytes (516 MB) |
with-received.eml |
373 B | yes | 1,986,721,987 bytes (1.85 GiB) |
3020.eml |
28.31 KB | yes | 1,986,948,556 bytes (1.85 GiB) |
3021.eml |
76.52 KB | yes | 1,987,260,733 bytes (1.85 GiB) |
A 237-byte file and a 6.99 KB file both allocate 516 MB. A 373-byte file and a 76.52 KB file both allocate 1.85 GiB. Adding a single Received: header to minimal.eml — a 136-byte change — moves it from 516 MB to 1.85 GiB.
Reproduction
The two synthetic samples are attached (.eml is not an allowed attachment type here, so they are zipped). Both are entirely synthetic; their full contents are below so the case can be reproduced without the attachment.
with-received.eml (373 bytes) — allocates 1.85 GiB:
Received: from MAIL01.example.com (192.0.2.1) by MAIL02.example.com with
Microsoft SMTP Server; Thu, 4 Sep 2026 10:00:00 +0000
From: sender@example.com
To: recipient@example.com
Subject: Test with Received header
Date: Thu, 04 Sep 2026 10:00:00 +0000
Message-ID: <received@example.com>
MIME-Version: 1.0
Content-Type: text/plain; charset=us-ascii
Hello world.
minimal.eml (237 bytes) — identical but with the Received: header removed, allocates 516 MB:
From: sender@example.com
To: recipient@example.com
Subject: Minimal test message
Date: Thu, 04 Sep 2026 10:00:00 +0000
Message-ID: <minimal@example.com>
MIME-Version: 1.0
Content-Type: text/plain; charset=us-ascii
Hello world.
byte[] bytes = File.ReadAllBytes("with-received.eml");
// Detection
using (MemoryStream stream = new MemoryStream(bytes, writable: false))
{
long before = GC.GetTotalAllocatedBytes(precise: true);
_ = FileFormatUtil.DetectFileFormat(stream).FileFormatType;
Console.WriteLine($"DetectFileFormat : {GC.GetTotalAllocatedBytes(precise: true) - before:N0} bytes");
}
// Parsing the same stream, for comparison
using (MemoryStream stream = new MemoryStream(bytes, writable: false))
{
long before = GC.GetTotalAllocatedBytes(precise: true);
_ = MailMessage.Load(stream);
Console.WriteLine($"MailMessage.Load : {GC.GetTotalAllocatedBytes(precise: true) - before:N0} bytes");
}
Timings
Each stage runs once un-measured (to absorb JIT) then three measured runs:
| sample | stage | warm ms |
|---|---|---|
with-received.eml (373 B) |
DetectFileFormat |
12 / 1691 / 1889 |
with-received.eml (373 B) |
MailMessage.Load |
1 / 1 / 5 |
3020.eml (28 KB) |
DetectFileFormat |
23 / 1521 / 1975 |
3020.eml (28 KB) |
MailMessage.Load |
6 / 6 / 6 |
The cost grows as the process accumulates these arrays. The first such allocation takes ~12-23 ms (the OS hands out zero pages lazily). Once a few exist, the same call takes 1.5-2.0 seconds.
Call stack
Captured from a production process dump (obfuscated frames are Aspose internals):
Interop.Kernel32.ReadFile(SafeHandle, Byte*, Int32, Int32 ByRef, NativeOverlapped*)
System.IO.Strategies.OSFileStreamStrategy.Read(Span<Byte>)
System.IO.Strategies.BufferedFileStreamStrategy.ReadSpan(Span<Byte>, ArraySegment<Byte>)
System.IO.FileStream.Read(Span<Byte>)
System.IO.BinaryReader.ReadBytes(Int32) <-- allocates new byte[count] up front
XX.v()
UX..ctor(XX)
Aspose.Email.Tools.FileFormatUtil.c(Stream)
Aspose.Email.Tools.FileFormatUtil.DetectFileFormat(Stream)
BinaryReader.ReadBytes(int count) allocates new byte[count] before reading anything, then trims the result if fewer bytes were available. So passing a count of ~1.9e9 allocates the full 1.85 GiB regardless of how short the stream actually is. It looks like the count is not clamped to the remaining stream length.
In the dump the single byte[] is exactly 1,986,618,747 bytes, which appeared five times over.
Impact
In our service this runs per message during text extraction. Each .eml puts a ~1.85 GiB array on its own dedicated Large Object Heap segment. Under sustained load the process plateaus at ~11.8 GB (six such segments), and 20 TB was allocated over 5.5 hours to process ~10,400 small messages.
A dump taken mid-load showed 5 of these arrays live — 9.93 GB of a 12.05 GB managed heap in five objects — with 4 of the 5 already unreachable. The rest of the application accounted for ~130 MB.
The GC does keep up, so this is a peak/throughput problem rather than a leak, but the plateau starves everything else on the machine.
Questions
- Is the read length in
DetectFileFormatintended to be unbounded, or should it be clamped to the remaining stream length? A short stream should not require a ~1.9e9-element buffer. - Why would the presence of a
Received:header change the buffer size from 516 MB to 1.85 GiB? - Can a fix be scheduled? This is currently a live production issue for us.
Thanks.
aspose-email-detectfileformat-repro.zip (76.2 KB)