@@ -122,39 +122,36 @@ internal async Task<DecryptionContext> DecryptStreamAsync(
122122 // increase ArrayPool hit rate. Without bucketing we might rent many subtly different lengths
123123 // (e.g., 1372, 1419, 1510 ...) that the shared pool keeps as distinct buckets, increasing LOH
124124 // pressure and fragmentation over time when large docs with varying token sizes are processed.
125- static void EnsureCapacity ( ref byte [ ] scratch , int needed , ArrayPoolManager pool )
125+ // Shared bucket helper so main streaming buffer growth also reuses the same discrete sizes.
126+ static int Bucket ( int value )
126127 {
127- if ( scratch != null && scratch . Length >= needed )
128+ const int minBucket = 64 ;
129+ if ( value <= minBucket )
128130 {
129- return ; // already large enough
131+ return minBucket ;
130132 }
131133
132- // Round to next power-of-two up to MaxBufferSizeBytes to maximize reuse while capping growth.
133- // Minimum practical bucket of 64 bytes (caller often already does Math.Max(x, 64)).
134- static int Bucket ( int value )
134+ if ( value >= MaxBufferSizeBytes )
135135 {
136- const int minBucket = 64 ;
137- if ( value <= minBucket )
138- {
139- return minBucket ;
140- }
136+ return MaxBufferSizeBytes ;
137+ }
141138
142- if ( value >= MaxBufferSizeBytes )
143- {
144- return MaxBufferSizeBytes ;
145- }
139+ // next power of two
140+ uint v = ( uint ) ( value - 1 ) ;
141+ v |= v >> 1 ;
142+ v |= v >> 2 ;
143+ v |= v >> 4 ;
144+ v |= v >> 8 ;
145+ v |= v >> 16 ;
146+ int pow2 = ( int ) ( v + 1 ) ;
147+ return pow2 > MaxBufferSizeBytes ? MaxBufferSizeBytes : pow2 ;
148+ }
146149
147- // next power of two
148- uint v = ( uint ) ( value - 1 ) ;
149- v |= v >> 1 ;
150- v |= v >> 2 ;
151- v |= v >> 4 ;
152- v |= v >> 8 ;
153- v |= v >> 16 ;
154- int pow2 = ( int ) ( v + 1 ) ;
155-
156- // safeguard (should not exceed MaxBufferSizeBytes due to earlier check)
157- return pow2 > MaxBufferSizeBytes ? MaxBufferSizeBytes : pow2 ;
150+ static void EnsureCapacity ( ref byte [ ] scratch , int needed , ArrayPoolManager pool )
151+ {
152+ if ( scratch != null && scratch . Length >= needed )
153+ {
154+ return ; // already large enough
158155 }
159156
160157 int bucketed = Bucket ( needed ) ;
@@ -220,12 +217,12 @@ static string PathLabel(string a, string b)
220217 }
221218 else
222219 {
223- // Multi-segment or escaped; use stackalloc for small sizes to avoid renting
224- const int stackThreshold = 256 ;
225- if ( srcLen <= stackThreshold )
220+ // Multi-segment or escaped; use stackalloc for small sizes (up to 4 KB) to avoid renting
221+ const int base64StackThreshold = 4096 ;
222+ if ( srcLen <= base64StackThreshold )
226223 {
227- Span < byte > local = stackalloc byte [ stackThreshold ] ;
228- int copied = reader . CopyString ( local ) ;
224+ Span < byte > local = stackalloc byte [ srcLen ] ;
225+ int copied = reader . CopyString ( local ) ; // copied should == srcLen (escaped path may differ)
229226 OperationStatus status = Base64 . DecodeFromUtf8 (
230227 local [ ..copied ] ,
231228 cipher ,
@@ -782,19 +779,22 @@ long TransformDecryptBuffer(
782779 }
783780
784781 // Small-payload fast path: if stream is seekable and remaining length <= 2KB, read once and parse once.
782+ // Enhancement: stackalloc for <=1KB to avoid renting; for >1KB use bucketed pool sizes to reduce distinct lengths.
785783 if ( inputStream . CanSeek )
786784 {
787785 long remaining = inputStream . Length - inputStream . Position ;
788786 if ( remaining > 0 && remaining <= 2048 )
789787 {
790788 int len = ( int ) remaining ;
791- byte [ ] oneShot = arrayPoolManager . Rent ( len ) ;
789+ int rentSize = Bucket ( len ) ; // bucketed size for small one-shot payload
790+ byte [ ] oneShot = arrayPoolManager . Rent ( rentSize ) ;
792791 try
793792 {
794793 int total = 0 ;
795794 while ( total < len )
796795 {
797- int r = await inputStream . ReadAsync ( oneShot . AsMemory ( total , len - total ) , cancellationToken ) ;
796+ int toRead = Math . Min ( len - total , oneShot . Length - total ) ;
797+ int r = await inputStream . ReadAsync ( oneShot . AsMemory ( total , toRead ) , cancellationToken ) ;
798798 if ( r == 0 )
799799 {
800800 break ;
@@ -803,10 +803,8 @@ long TransformDecryptBuffer(
803803 total += r ;
804804 }
805805
806- int read = total ;
806+ int read = Math . Min ( total , len ) ;
807807 bytesRead += read ;
808-
809- // Process the full payload in one pass and mark final block to skip the streaming loop.
810808 _ = TransformDecryptBuffer (
811809 oneShot . AsSpan ( 0 , read ) ,
812810 ref state ,
@@ -824,7 +822,6 @@ long TransformDecryptBuffer(
824822 ref skippingEi ,
825823 ref skipEiFirstTokenPending ,
826824 ref skipEiContainerDepth ) ;
827-
828825 isFinalBlock = true ;
829826 }
830827 finally
@@ -837,7 +834,7 @@ long TransformDecryptBuffer(
837834 {
838835 // Non-seekable small payload probe: optimistically read up to 2KB once; if stream ends, we finish in a single pass.
839836 const int ProbeSize = 2048 ;
840- buffer = arrayPoolManager . Rent ( ProbeSize ) ; // reuse as main buffer if more data follows
837+ buffer = arrayPoolManager . Rent ( Bucket ( ProbeSize ) ) ; // bucketed
841838 int read = await inputStream . ReadAsync ( buffer . AsMemory ( 0 , ProbeSize ) , cancellationToken ) ;
842839 bytesRead += read ;
843840 if ( read > 0 )
@@ -906,7 +903,8 @@ long TransformDecryptBuffer(
906903 if ( dataSize > 0 && leftOver == dataSize )
907904 {
908905 int target = Math . Max ( buffer . Length * 2 , leftOver + BufferGrowthMinIncrement ) ;
909- int capped = Math . Min ( MaxBufferSizeBytes , target ) ;
906+ int bucketedGrow = Bucket ( target ) ;
907+ int capped = Math . Min ( MaxBufferSizeBytes , bucketedGrow ) ;
910908 if ( buffer . Length >= capped )
911909 {
912910 throw new InvalidOperationException (
0 commit comments