1 /*
2 * Copyright 2026 The Netty Project
3 *
4 * The Netty Project licenses this file to you under the Apache License,
5 * version 2.0 (the "License"); you may not use this file except in compliance
6 * with the License. You may obtain a copy of the License at:
7 *
8 * https://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 * License for the specific language governing permissions and limitations
14 * under the License.
15 */
16
17 package io.netty.handler.codec;
18
19 import io.netty.buffer.ByteBuf;
20 import io.netty.buffer.ByteBufAllocator;
21 import io.netty.buffer.CompositeByteBuf;
22 import io.netty.handler.codec.ByteToMessageDecoder.Cumulator;
23 import io.netty.util.internal.ObjectUtil;
24
25 /**
26 * "Adaptive" cumulator: cumulate {@link ByteBuf}s by dynamically switching
27 * between merge and compose strategies.
28 */
29 public final class AdaptiveCumulator implements Cumulator {
30 private final int composeMinSize;
31
32 /**
33 * @param composeMinSize Determines the minimal size of the buffer that should
34 * be composed (added as a new component of the
35 * {@link CompositeByteBuf}). If the total size of the
36 * last component (tail) and the incoming buffer is
37 * below this value, the incoming buffer is appended to
38 * the tail, and the new component is not added.
39 */
40 public AdaptiveCumulator(int composeMinSize) {
41 ObjectUtil.checkPositiveOrZero(composeMinSize, "composeMinSize");
42 this.composeMinSize = composeMinSize;
43 }
44
45 /**
46 * "Adaptive" cumulator: cumulate {@link ByteBuf}s by dynamically switching
47 * between merge and compose strategies.
48 *
49 * <p>
50 * This cumulator applies a heuristic to make a decision whether to track a
51 * reference to the buffer with bytes received from the network stack in an
52 * array ("zero-copy"), or to merge into the last component (the tail) by
53 * performing a memory copy.
54 *
55 * <p>
56 * It is necessary as a protection from a potential attack on the
57 * {@link io.netty.handler.codec.ByteToMessageDecoder#COMPOSITE_CUMULATOR}.
58 * Consider a pathological case when an attacker sends TCP packages containing a
59 * single byte of data, and forcing the cumulator to track each one in a
60 * separate buffer. The cost is memory overhead for each buffer, and extra
61 * compute to read the cumulation.
62 *
63 * <p>
64 * Implemented heuristic establishes a minimal threshold for the total size of
65 * the tail and incoming buffer, below which they are merged. The sum of the
66 * tail and the incoming buffer is used to avoid a case where attacker
67 * alternates the size of data packets to trick the cumulator into always
68 * selecting compose strategy.
69 *
70 * <p>
71 * Merging strategy attempts to minimize unnecessary memory writes. When
72 * possible, it expands the tail capacity and only copies the incoming buffer
73 * into available memory.
74 * Otherwise, when both tail and the buffer must be copied, the tail is
75 * reallocated (or fully replaced) with a new buffer of exponentially increasing
76 * capacity (bounded to {@link #composeMinSize}) to ensure runtime
77 * {@code O(n^2)} is amortized to {@code O(n)}.
78 */
79 @Override
80 @SuppressWarnings("ReferenceEquality")
81 public ByteBuf cumulate(ByteBufAllocator alloc, ByteBuf cumulation, ByteBuf in) {
82 if (cumulation == in) {
83 in.release();
84 return cumulation;
85 }
86 if (!cumulation.isReadable()) {
87 cumulation.release();
88 return in;
89 }
90 CompositeByteBuf composite = null;
91 boolean cumulationTransferred = false;
92 try {
93 if (isOwnedCompositeBuf(cumulation)) {
94 composite = (CompositeByteBuf) cumulation;
95 cumulationTransferred = true;
96 // Writer index must equal capacity if we are going to "write"
97 // new components to the end
98 if (composite.writerIndex() != composite.capacity()) {
99 composite.capacity(composite.writerIndex());
100 }
101 } else {
102 composite = alloc.compositeBuffer(Integer.MAX_VALUE);
103 composite.addFlattenedComponents(true, cumulation);
104 cumulationTransferred = true;
105 }
106 ByteBuf b = in;
107 in = null;
108 addInput(alloc, composite, b);
109
110 CompositeByteBuf result = composite;
111 composite = null;
112 return result;
113 } catch (Throwable t) {
114 // If an exception was thrown AFTER cumulation was successfully wrapped,
115 // calling composite.release() in 'finally' will drop its refCount to 0.
116 // We prevent this by calling retain() here on the exception path to keep it alive.
117 if (cumulationTransferred && composite != null && composite != cumulation) {
118 cumulation.retain();
119 }
120 throw t;
121 } finally {
122 if (in != null) {
123 // We must release if the ownership was not transferred as otherwise it may
124 // produce a leak
125 in.release();
126 }
127 // Also release any new buffer allocated if we're not returning it
128 if (composite != null && composite != cumulation) {
129 composite.release();
130 }
131 }
132 }
133
134 private static boolean isOwnedCompositeBuf(ByteBuf buf) {
135 return buf instanceof CompositeByteBuf && buf.refCnt() == 1;
136 }
137
138 private void addInput(ByteBufAllocator alloc, CompositeByteBuf composite, ByteBuf in) {
139 if (shouldCompose(composite, in, composeMinSize)) {
140 composite.addFlattenedComponents(true, in);
141 } else {
142 // The total size of the new data and the last component are below the
143 // threshold. Merge them.
144 mergeWithCompositeTail(alloc, composite, in);
145 }
146 }
147
148 private static boolean shouldCompose(CompositeByteBuf composite, ByteBuf in, int composeMinSize) {
149 int componentCount = composite.numComponents();
150 if (componentCount == 0) {
151 return true;
152 }
153 int inputSize = in.readableBytes();
154 int tailStart = composite.toByteIndex(componentCount - 1);
155 long tailSize = composite.writerIndex() - tailStart;
156 return tailSize + inputSize >= composeMinSize;
157 }
158
159 /**
160 * Append the given {@link ByteBuf} {@code in} to {@link CompositeByteBuf}
161 * {@code composite} by expanding or replacing the tail component of the {@link
162 * CompositeByteBuf}.
163 *
164 * <p>
165 * The goal is to prevent {@code O(n^2)} runtime in a pathological case, that
166 * forces copying the tail component into a new buffer, for each incoming
167 * single-byte buffer.
168 * We append the new bytes to the tail, when a write (or a fast write) is
169 * possible.
170 *
171 * <p>
172 * Otherwise, the tail is replaced with a new buffer, with the capacity
173 * increased enough to achieve runtime amortization.
174 *
175 * <p>
176 * We assume that implementations of
177 * {@link ByteBufAllocator#calculateNewCapacity(int, int)},
178 * are similar to
179 * {@link io.netty.buffer.AbstractByteBufAllocator#calculateNewCapacity(int, int)},
180 * which doubles buffer capacity by normalizing it to the closest power of two.
181 * This assumption is verified in unit tests for this method.
182 */
183 private static void mergeWithCompositeTail(
184 ByteBufAllocator alloc, CompositeByteBuf composite, ByteBuf in) {
185 int inputSize = in.readableBytes();
186 int tailComponentIndex = composite.numComponents() - 1;
187 int tailStart = composite.toByteIndex(tailComponentIndex);
188 int tailSize = composite.writerIndex() - tailStart;
189 int newTailSize = inputSize + tailSize;
190
191 ByteBuf tail = composite.component(tailComponentIndex);
192 ByteBuf newTail = null;
193 // Use componentSlice() to get the correct view of the indices.
194 ByteBuf componentView = composite.componentSlice(tailComponentIndex);
195 try {
196 // Ideal case: The tail is not shared and can be expanded in-place.
197 // In-place expansion should happen only if the component represents the full
198 // capacity of the underlying buffer because if tail.capacity() >
199 // componentView.capacity(), it indicates the component is a partial slice
200 // containing hidden "discarded" bytes. Expanding such a slice in-place would
201 // "resurrect" those discarded bytes leading to silent data corruption.
202 if (tail.refCnt() == 1 && !tail.isReadOnly() && tail.capacity() == componentView.capacity()
203 && newTailSize <= tail.maxCapacity()) {
204 // Take ownership of the tail.
205 newTail = tail.retain();
206
207 // Synchronize indices based on the component's view in the composite buffer.
208 newTail.setIndex(componentView.readerIndex(), componentView.writerIndex());
209
210 /*
211 * The tail is a readable non-composite buffer, so writeBytes() handles
212 * everything for us.
213 *
214 * - ensureWritable() performs a fast resize when possible (f.e. PooledByteBuf
215 * simply updates its boundary to the end of consecutive memory run assigned to
216 * this buffer)
217 * - when the required size doesn't fit into writableBytes(), a new buffer is
218 * allocated, and the capacity is calculated with alloc.calculateNewCapacity()
219 * - note that maxFastWritableBytes() would normally allow a fast expansion of
220 * PooledByteBuf is not called because CompositeByteBuf.component() returns a
221 * duplicate, wrapped buffer.
222 * Unwrapping buffers is unsafe, and potential benefit of fast writes may not be
223 * as pronounced because the capacity is doubled with each reallocation.
224 */
225 newTail.writeBytes(in);
226 } else {
227 // Fallback strategy: Reallocate a new buffer to merge the tail and input.
228 // This ensures absolute index consistency and prevents data corruption
229 // from hidden offsets in sliced or derived buffers.
230 newTail = alloc.buffer(alloc.calculateNewCapacity(newTailSize, Integer.MAX_VALUE));
231 newTail.setBytes(0, composite, tailStart, tailSize)
232 .setBytes(tailSize, in, in.readerIndex(), inputSize)
233 .writerIndex(newTailSize);
234 in.readerIndex(in.writerIndex());
235 }
236
237 // Store readerIndex to avoid out-of-bounds writerIndex during replacement.
238 int prevReader = composite.readerIndex();
239
240 // Remove the old tail and add the new one.
241 composite.removeComponent(tailComponentIndex).setIndex(0, tailStart);
242
243 // newTail ownership successfully transferred to the composite buffer.
244 // We null out newTail before adding it to avoid a double-release if addFlattenedComponents throws.
245 ByteBuf b = newTail;
246 newTail = null;
247 composite.addFlattenedComponents(true, b);
248
249 // Restore the reader. We do this before releasing 'in' so that if it fails,
250 // the caller's finally block will handle releasing 'in' without a double-free.
251 composite.readerIndex(prevReader);
252 } finally {
253 in.release();
254 // If new tail's ownership isn't transferred to the composite buf.
255 // Release it to prevent a leak.
256 if (newTail != null) {
257 newTail.release();
258 }
259 }
260 }
261 }