Repository: helidon-io/helidon (2026-03-20T13:12:34Z)

Issue:
Intermittent failures with ServerResponse.outputStream() and static file serving
## Environment Details
* Helidon Version: 4.4.0
* Helidon SE
* JDK version: 25
* OS: Macos 26 / Linux something (GitHub Actions env)

----------

## Problem Description
I've seen two maybe related problems while using outputStream(). The same issues seem to affect `helidon-webserver-static-content`. See Steps to reproduce below for details.

If using chunked transfer, I occasionally see "missing CR"  or "Malformed encoding found in chunked-encoding" or something similar depending on client.

If sending content-length header, the transfer occasionally just gets stuck completely. `StaticContentFeature` does this, so it is also affected.

100MB files trigger the problems more often, but I've managed to reproduce them also with 10MB files.

These have been tested with multiple client (`curl`, `java.net.http.HttpClient` and `HttpURLConnection`).

On server side I get the following trace:
```
Caused by: java.io.UncheckedIOException: java.io.IOException: Broken pipe
	at io.helidon.common.socket.NioSocket.write(NioSocket.java:150)
	at io.helidon.common.socket.SocketWriter.writeNow(SocketWriter.java:81)
	at io.helidon.common.socket.SocketWriterDirect.write(SocketWriterDirect.java:43)
	at io.helidon.webserver.http1.Http1ServerResponse$BlockingOutputStream.writeChunked(Http1ServerResponse.java:818)
	at io.helidon.webserver.http1.Http1ServerResponse$BlockingOutputStream.write(Http1ServerResponse.java:744)
	at io.helidon.webserver.http1.Http1ServerResponse$BlockingOutputStream.write(Http1ServerResponse.java:579)
	at java.base/java.io.BufferedOutputStream.write(BufferedOutputStream.java:183)
	at io.helidon.webserver.http1.Http1ServerResponse$ClosingBufferedOutputStream.write(Http1ServerResponse.java:869)
...
Caused by: java.io.IOException: Broken pipe
	at java.base/sun.nio.ch.SocketDispatcher.write0(Native Method)
	at java.base/sun.nio.ch.SocketDispatcher.write(SocketDispatcher.java:65)
	at java.base/sun.nio.ch.IOUtil.writeFromNativeBuffer(IOUtil.java:137)
	at java.base/sun.nio.ch.IOUtil.write(IOUtil.java:102)
	at java.base/sun.nio.ch.IOUtil.write(IOUtil.java:58)
	at java.base/sun.nio.ch.SocketChannelImpl.implWrite(SocketChannelImpl.java:562)
	at java.base/sun.nio.ch.SocketChannelImpl.write(SocketChannelImpl.java:614)
	at io.helidon.common.socket.NioSocket.write(NioSocket.java:147)
	... 21 more  
```

But I'm not sure whether this is actually before or after client has already encountered the error and closed the connection.


curl's error
```
curl 'http://localhost:8080/dynamic' > foo
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 6144k    0 6144k    0     0   113M      0 --:--:-- --:--:-- --:--:--  113M
curl: (56) Malformed encoding found in chunked-encoding
```

and HttpURLConnections's
```
Exception in thread "main" java.io.IOException: missing CR
	at java.base/sun.net.www.http.ChunkedInputStream.processRaw(ChunkedInputStream.java:406)
	at java.base/sun.net.www.http.ChunkedInputStream.readAheadBlocking(ChunkedInputStream.java:574)
	at java.base/sun.net.www.http.ChunkedInputStream.readAhead(ChunkedInputStream.java:611)
	at java.base/sun.net.www.http.ChunkedInputStream.read(ChunkedInputStream.java:705)
	at java.base/java.io.FilterInputStream.read(FilterInputStream.java:119)
	at java.base/sun.net.www.protocol.http.HttpURLConnection$HttpInputStream.read(HttpURLConnection.java:3308)
	at java.base/sun.net.www.protocol.http.HttpURLConnection$HttpInputStream.read(HttpURLConnection.java:3301)
```

## Steps to reproduce

Here's a sample program that provides 
* `/fixed` -> content length header, gets stuck
* `/chunked` -> CR failure
* `/dynamic` -> CR failure, no file system used to rule that out
* `/static` -> same as `/fixed` but through `StaticContentFeature`

```java
import io.helidon.http.HeaderNames;
import io.helidon.webserver.WebServer;
import io.helidon.webserver.staticcontent.StaticContentFeature;

import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;

public class Main {

    static void main() throws Exception {
        var path = Files.createTempFile("helidon-output-test", null);
        path.toFile().deleteOnExit();

        var size = 100_000_000;
        fillTestFile(path, size);

        WebServer.builder()
            .port(8080)
            .routing(routing -> {
                routing.any("/fixed", (_, res) -> {
                    res.status(200);
                    res.header(HeaderNames.CONTENT_TYPE_NAME, "octet-stream");
                    res.contentLength(size);
                    try (var out = res.outputStream(); var in = Files.newInputStream(path)) {
                        in.transferTo(out);
                    }
                });
                routing.any("/chunked", (_, res) -> {
                    res.status(200);
                    res.header(HeaderNames.CONTENT_TYPE_NAME, "octet-stream");
                    try (var out = res.outputStream(); var in = Files.newInputStream(path)) {
                        in.transferTo(out);
                    }
                });
                routing.any("/dynamic", (_, res) -> {
                    res.status(200);
                    res.header(HeaderNames.CONTENT_TYPE_NAME, "octet-stream");
                    try (var out = res.outputStream()) {
                        fillOutput(out, size);
                    }
                });
            })
            .addFeature(StaticContentFeature.builder()
                .addPath(p -> p.location(path).context("/static"))
                .build())
            .build()
            .start();
    }

    private static void fillOutput(OutputStream out, long size) throws IOException {
        var source = "xy".repeat(524_288).getBytes();

        var remaining = size;
        while (remaining > 0) {
            var chunk = (int) Math.min(source.length, remaining);
            out.write(source, 0, chunk);
            remaining -= chunk;
        }
    }

    private static void fillTestFile(Path path, long size) throws IOException {
        try (var os = Files.newOutputStream(path)) {
            fillOutput(os, size);
        }
    }

}

```

Malformed chunked body after upgrading from 4.3.4 to 4.4.0
## Environment Details
* Helidon Version: 4.4.0
* Helidon MP
* JDK version: 25.0.2
* OS: Alpaquita Linux Container

----------

## Problem Description

After upgrading our service from Helidon **4.3.4** to **4.4.0**, we started seeing rare, nondeterministic failures in service-to-service integrations.

The failures happen only occasionally and are difficult to reproduce locally, but they can be triggered during load testing in the deployed environment.

The client receives errors indicating that the HTTP/1.1 chunked response is malformed, for example:

```
java.io.IOException: Chunk header size too long: 2050
	at java.net.http/jdk.internal.net.http.HttpClientImpl.send(HttpClientImpl.java:938)
	at java.net.http/jdk.internal.net.http.HttpClientFacade.send(HttpClientFacade.java:133)
	at WorkflowEmployeesSummaryTestRunner.executeScenarioRequests(WorkflowEmployeesSummaryTestRunner.java:157)
	at TestRunnerUtils.lambda$executeScenario$0(TestRunnerUtils.java:61)
	at java.base/java.lang.VirtualThread.run(VirtualThread.java:456)
Caused by: java.io.IOException: Chunk header size too long: 2050
	at java.net.http/jdk.internal.net.http.ResponseContent$ChunkedBodyParser.tryReadChunkLen(ResponseContent.java:256)
	at java.net.http/jdk.internal.net.http.ResponseContent$ChunkedBodyParser.tryReadOneHunk(ResponseContent.java:346)
	at java.net.http/jdk.internal.net.http.ResponseContent$ChunkedBodyParser.tryPushOneHunk(ResponseContent.java:435)
	at java.net.http/jdk.internal.net.http.ResponseContent$ChunkedBodyParser.accept(ResponseContent.java:204)
	at java.net.http/jdk.internal.net.http.ResponseContent$ChunkedBodyParser.accept(ResponseContent.java:131)
	at java.net.http/jdk.internal.net.http.Http1Response$BodyReader.handle(Http1Response.java:708)
	at java.net.http/jdk.internal.net.http.Http1Response$BodyReader.handle(Http1Response.java:636)
	at java.net.http/jdk.internal.net.http.Http1Response$Receiver.accept(Http1Response.java:528)
	at java.net.http/jdk.internal.net.http.Http1Response$BodyReader.tryAsyncReceive(Http1Response.java:666)
	at java.net.http/jdk.internal.net.http.Http1AsyncReceiver.flush(Http1AsyncReceiver.java:233)
	at java.net.http/jdk.internal.net.http.common.SequentialScheduler$LockingRestartableTask.run(SequentialScheduler.java:182)
	at java.net.http/jdk.internal.net.http.common.SequentialScheduler$CompleteRestartableTask.run(SequentialScheduler.java:149)
	at java.net.http/jdk.internal.net.http.common.SequentialScheduler$SchedulableTask.run(SequentialScheduler.java:207)
	at java.base/java.util.concurrent.ThreadPerTaskExecutor$TaskRunner.run(ThreadPerTaskExecutor.java:291)
	... 1 more
Done executing all scenarios in 30160ms, now waiting extra for remaining requests...
Finished executing scenarios: 1500 with all requests count: 1486 with avg scenario time: 34.624ms, min scenario time: 26ms, max scenario time: 171ms, avg request time: 34.241588156123825ms, min request time: 26ms, max request time: 96ms
```

We also sometimes see:

```
java.io.IOException: Illegal character in chunk size: 110
java.io.IOException: Illegal character in chunk size: 114
java.io.IOException: Illegal character in chunk size: 115
```

When trying to use `mitmproxy` for gathering information about http responses issues it returns to the client given body in place of IOException issued by standard JDK HTTP Client:

```
<html>
            <head>
                <title>502 Bad Gateway</title>
            </head>
            <body>
            <h1>502 Bad Gateway</h1>
            <p>ProtocolException(&quot;Error in HTTP connection: HttpSyntaxException(&#x27;Malformed chunked body&#x27;)&quot;)</p>
            </body>
        </html>
<html>
            <head>
                <title>502 Bad Gateway</title>
            </head>
            <body>
            <h1>502 Bad Gateway</h1>
            <p>ProtocolException(&quot;Error in HTTP connection: HttpSyntaxException(&#x27;Malformed chunked body&#x27;)&quot;)</p>
            </body>
        </html>
<html>
```

These failures started only after the upgrade to **4.4.0**. We did not observe them on **4.3.4**.

## Steps to reproduce

This is not easily reproducible locally, but we can trigger it in the deployed environment with load testing.

1. Deploy the Helidon MP service on version 4.4.0
2. Execute load against the affected endpoint
3. Example test parameters:
target RPS: 50
duration: 30 seconds

Example output from one run:

```
Now executing scenario users count: 1500 with requests per scenario: 1 and target RPS: 50 for 30 seconds.
java.io.IOException: Chunk header size too long: 2050
...
Done executing all scenarios in 30160ms, now waiting extra for remaining requests...
Finished executing scenarios: 1500 with all requests count: 1486 with avg scenario time: 34.624ms, min scenario time: 26ms, max scenario time: 171ms, avg request time: 34.241588156123825ms, min request time: 26ms, max request time: 96ms
```




Discussion:
Fixed by #11384 expected in `4.4.1`
As a workaround disable nio usage:
```yaml
server:
  use-nio: false
```
Thx for detailed report!

What's interesting, requiring HTTP/2 causes chunks to contain repeated data. Such a script:

```
import http2 from 'node:http2'

async function makeHttp2Request(graphQuery, requestContext) {
    const { access_token, username } =
        requestContext.session.get('sessionKey') || {}
    const client = http2.connect('https://URI/')

    const req = client.request({
        ':method': 'POST',
        ':path': 'PATH',
        'Content-Type': 'application/json',
        Cookie: `_session=${JSON.stringify({ access_token, username })}`,
    })

    // Send body
    req.write(JSON.stringify(graphQuery))
    req.end()

    let data = ''
    req.on('data', (chunk) => {
        data += chunk
    })

    req.on('end', async () => {
        console.log('Raw Response:', data)

        try {
            const parsed = JSON.parse(data)

            console.log('WRITING JSON')
            await fs.writeFile(
                'response-object.json',
                JSON.stringify(parsed, null, 2)
            )
        } catch (e) {
            console.log('WRITING TXT')
            textResponses += 1

            await fs.writeFile('response-string.txt', data)
        }

        client.close() // Important!
    })

    req.on('error', (err) => {
        console.error('HTTP2 Request Error:', err)
        client.close()
    })
}
```

sometimes returns json data with repetitions like:

```
lhist.uni.lodz.pl"}}]}lhist.uni.lodz.pl"}}]}
```

and it further causes json parser to fail on such response
Could be the same [issue](https://github.com/helidon-io/helidon/issues/11340) that I reported yesterday? Although I got slightly different error from client.
Yep, it looks like the exact behaviour depends on used http client, but the problem is probably the same :)
Fixed by #11384 expected in `4.4.1`
As a workaround disable nio usage:
```yaml
server:
  use-nio: false
```
Thx for detailed report!



Patch:
diff --git a/common/socket/src/main/java/io/helidon/common/socket/NioSocket.java b/common/socket/src/main/java/io/helidon/common/socket/NioSocket.java
index c3ff018ca1c..d2d9fd77dbd 100644
--- a/common/socket/src/main/java/io/helidon/common/socket/NioSocket.java
+++ b/common/socket/src/main/java/io/helidon/common/socket/NioSocket.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2025 Oracle and/or its affiliates.
+ * Copyright (c) 2025, 2026 Oracle and/or its affiliates.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -144,7 +144,11 @@ public void write(BufferData buffer) {
                 writeBuffer.clear();
                 buffer.writeTo(writeBuffer, buffer.available());
                 writeBuffer.flip();
-                delegate.write(writeBuffer);
+                // SocketChannel.write may complete partially, so we must drain the staged bytes before
+                // advancing the source buffer again.
+                while (writeBuffer.hasRemaining()) {
+                    delegate.write(writeBuffer);
+                }
             }
         } catch (IOException e) {
             throw new UncheckedIOException(e);


Test patch:
diff --git a/common/socket/src/test/java/io/helidon/common/socket/NioSocketTest.java b/common/socket/src/test/java/io/helidon/common/socket/NioSocketTest.java
new file mode 100644
index 00000000000..5cedd932c0b
--- /dev/null
+++ b/common/socket/src/test/java/io/helidon/common/socket/NioSocketTest.java
@@ -0,0 +1,260 @@
+/*
+ * Copyright (c) 2026 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.helidon.common.socket;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.net.Socket;
+import java.net.SocketAddress;
+import java.net.SocketOption;
+import java.nio.ByteBuffer;
+import java.nio.channels.SocketChannel;
+import java.nio.channels.spi.SelectorProvider;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import io.helidon.common.buffers.BufferData;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class NioSocketTest {
+
+    @Test
+    void writeHandlesPartialSocketChannelWrites() {
+        byte[] expected = jsonPayload(900);
+
+        assertWrittenBytes(13, expected);
+    }
+
+    @Test
+    void writerHandlesPartialSocketChannelWritesForFixedLengthStreamingResponse() {
+        byte[] json = jsonPayload(600);
+        int firstSplit = json.length / 3;
+        int secondSplit = (json.length * 2) / 3;
+        byte[] firstBodyPart = Arrays.copyOfRange(json, 0, firstSplit);
+        byte[] secondBodyPart = Arrays.copyOfRange(json, firstSplit, secondSplit);
+        byte[] thirdBodyPart = Arrays.copyOfRange(json, secondSplit, json.length);
+
+        byte[] headers = ("""
+                HTTP/1.1 200 OK\r
+                Content-Length: %d\r
+                Connection: keep-alive\r
+                \r
+                """.formatted(json.length)).getBytes(StandardCharsets.US_ASCII);
+
+        byte[] firstWrite = concat(headers, firstBodyPart);
+
+        assertWrittenBytes(17, firstWrite, secondBodyPart, thirdBodyPart);
+    }
+
+    @Test
+    void writerHandlesPartialSocketChannelWritesForChunkedStreamingResponse() {
+        byte[] json = jsonPayload(450);
+        int split = json.length / 2;
+        byte[] firstChunkPayload = Arrays.copyOfRange(json, 0, split);
+        byte[] secondChunkPayload = Arrays.copyOfRange(json, split, json.length);
+
+        byte[] headers = ("""
+                HTTP/1.1 200 OK\r
+                Transfer-Encoding: chunked\r
+                Connection: keep-alive\r
+                \r
+                """).getBytes(StandardCharsets.US_ASCII);
+
+        assertWrittenBytes(19,
+                           headers,
+                           chunk(firstChunkPayload),
+                           chunk(secondChunkPayload),
+                           "0\r\n\r\n".getBytes(StandardCharsets.US_ASCII));
+    }
+
+    private static void assertWrittenBytes(int maxBytesPerWrite, byte[]... writes) {
+        CapturingSocketChannel channel = new CapturingSocketChannel(maxBytesPerWrite);
+        SocketWriter writer = SocketWriter.create(NioSocket.server(channel, "child", "server"));
+
+        ByteArrayOutputStream expected = new ByteArrayOutputStream();
+        for (byte[] write : writes) {
+            expected.writeBytes(write);
+            writer.write(BufferData.create(write));
+        }
+
+        String expectedText = expected.toString(StandardCharsets.UTF_8);
+        String actualText = new String(channel.writtenBytes(), StandardCharsets.UTF_8);
+        assertEquals(expectedText, actualText);
+    }
+
+    private static byte[] chunk(byte[] payload) {
+        byte[] size = Integer.toHexString(payload.length).getBytes(StandardCharsets.US_ASCII);
+        return concat(size,
+                      "\r\n".getBytes(StandardCharsets.US_ASCII),
+                      payload,
+                      "\r\n".getBytes(StandardCharsets.US_ASCII));
+    }
+
+    private static byte[] jsonPayload(int items) {
+        String itemJson = IntStream.range(0, items)
+                .mapToObj(i -> "    " + """
+                        {"id":%d,"name":"item-%d","active":%s,"score":%d}"""
+                        .formatted(i, i, i % 2 == 0, i * 7))
+                .collect(Collectors.joining(",\n"));
+
+        return """
+                {
+                  "items": [
+                %s
+                  ],
+                  "meta": {
+                    "count": %d,
+                    "source": "issue-11340"
+                  }
+                }
+                """.formatted(itemJson, items).getBytes(StandardCharsets.UTF_8);
+    }
+
+    private static byte[] concat(byte[]... parts) {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        for (byte[] part : parts) {
+            out.writeBytes(part);
+        }
+        return out.toByteArray();
+    }
+
+    private static final class CapturingSocketChannel extends SocketChannel {
+        private final ByteArrayOutputStream written = new ByteArrayOutputStream();
+        private final int maxBytesPerWrite;
+
+        private CapturingSocketChannel(int maxBytesPerWrite) {
+            super(SelectorProvider.provider());
+            this.maxBytesPerWrite = maxBytesPerWrite;
+        }
+
+        byte[] writtenBytes() {
+            return written.toByteArray();
+        }
+
+        @Override
+        public int write(ByteBuffer src) {
+            int writtenBytes = Math.min(maxBytesPerWrite, src.remaining());
+            byte[] bytes = new byte[writtenBytes];
+            src.get(bytes);
+            written.writeBytes(bytes);
+            return writtenBytes;
+        }
+
+        @Override
+        public long write(ByteBuffer[] srcs, int offset, int length) {
+            long totalWritten = 0;
+            for (int i = offset; i < offset + length; i++) {
+                if (!srcs[i].hasRemaining()) {
+                    continue;
+                }
+                totalWritten += write(srcs[i]);
+                break;
+            }
+            return totalWritten;
+        }
+
+        @Override
+        public int read(ByteBuffer dst) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public long read(ByteBuffer[] dsts, int offset, int length) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public SocketChannel bind(SocketAddress local) {
+            return this;
+        }
+
+        @Override
+        public <T> SocketChannel setOption(SocketOption<T> name, T value) {
+            return this;
+        }
+
+        @Override
+        public <T> T getOption(SocketOption<T> name) {
+            return null;
+        }
+
+        @Override
+        public Set<SocketOption<?>> supportedOptions() {
+            return Collections.emptySet();
+        }
+
+        @Override
+        public SocketChannel shutdownInput() {
+            return this;
+        }
+
+        @Override
+        public SocketChannel shutdownOutput() {
+            return this;
+        }
+
+        @Override
+        public Socket socket() {
+            return null;
+        }
+
+        @Override
+        public boolean isConnected() {
+            return true;
+        }
+
+        @Override
+        public boolean isConnectionPending() {
+            return false;
+        }
+
+        @Override
+        public boolean connect(SocketAddress remote) {
+            return true;
+        }
+
+        @Override
+        public boolean finishConnect() {
+            return true;
+        }
+
+        @Override
+        public SocketAddress getRemoteAddress() {
+            return null;
+        }
+
+        @Override
+        public SocketAddress getLocalAddress() {
+            return null;
+        }
+
+        @Override
+        protected void implCloseSelectableChannel() throws IOException {
+        }
+
+        @Override
+        protected void implConfigureBlocking(boolean block) throws IOException {
+        }
+    }
+}
diff --git a/common/testing/http-junit5/src/main/java/io/helidon/common/testing/http/junit5/SocketHttpClient.java b/common/testing/http-junit5/src/main/java/io/helidon/common/testing/http/junit5/SocketHttpClient.java
index ff023339a4a..02f9d2a6e13 100644
--- a/common/testing/http-junit5/src/main/java/io/helidon/common/testing/http/junit5/SocketHttpClient.java
+++ b/common/testing/http-junit5/src/main/java/io/helidon/common/testing/http/junit5/SocketHttpClient.java
@@ -23,6 +23,7 @@
 import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
 import java.io.UncheckedIOException;
+import java.net.InetSocketAddress;
 import java.net.Socket;
 import java.net.SocketException;
 import java.nio.charset.StandardCharsets;
@@ -56,9 +57,12 @@ public class SocketHttpClient implements AutoCloseable {
 
     private static final String EOL = "\r\n";
     private static final Pattern FIRST_LINE_PATTERN = Pattern.compile("HTTP/\\d+\\.\\d+ (\\d\\d\\d) (.*)");
+    private static final Consumer<Socket> NO_OP_SOCKET_CONFIGURATOR = socket -> {
+    };
     private final String host;
     private final int port;
     private final Duration timeout;
+    private final Consumer<Socket> socketConfigurator;
 
     private Socket socket;
     private boolean connected;
@@ -73,9 +77,22 @@ public class SocketHttpClient implements AutoCloseable {
      * @param timeout socket timeout
      */
     protected SocketHttpClient(String host, int port, Duration timeout) {
+        this(host, port, timeout, NO_OP_SOCKET_CONFIGURATOR);
+    }
+
+    /**
+     * Create a new client connecting to the specified coordinates.
+     *
+     * @param host host to connect to
+     * @param port port to connect to
+     * @param timeout socket timeout
+     * @param socketConfigurator socket configuration applied before connect
+     */
+    protected SocketHttpClient(String host, int port, Duration timeout, Consumer<Socket> socketConfigurator) {
         this.host = host;
         this.port = port;
         this.timeout = timeout;
+        this.socketConfigurator = socketConfigurator == null ? NO_OP_SOCKET_CONFIGURATOR : socketConfigurator;
     }
 
     /**
@@ -90,6 +107,22 @@ public static SocketHttpClient create(String host, int port, Duration timeout) {
         return new SocketHttpClient(host, port, timeout);
     }
 
+    /**
+     * Socket client that allows sending any content with custom socket configuration.
+     *
+     * @param host host to connect to
+     * @param port port to connect to
+     * @param timeout socket timeout
+     * @param socketConfigurator socket configuration applied before connect
+     * @return a new (disconnected) client
+     */
+    public static SocketHttpClient create(String host,
+                                          int port,
+                                          Duration timeout,
+                                          Consumer<Socket> socketConfigurator) {
+        return new SocketHttpClient(host, port, timeout, socketConfigurator);
+    }
+
     /**
      * Socket client that allows sending any content.
      * Uses localhost and timeout of 5 seconds.
@@ -101,6 +134,18 @@ public static SocketHttpClient create(int port) {
         return create("localhost", port, Duration.ofSeconds(5));
     }
 
+    /**
+     * Socket client that allows sending any content.
+     * Uses localhost and timeout of 5 seconds.
+     *
+     * @param port port to connect to
+     * @param socketConfigurator socket configuration applied before connect
+     * @return a new (disconnected) client
+     */
+    public static SocketHttpClient create(int port, Consumer<Socket> socketConfigurator) {
+        return create("localhost", port, Duration.ofSeconds(5), socketConfigurator);
+    }
+
     /**
      * Generates at least {@code bytes} number of bytes as a sequence of decimal numbers delimited
      * by newlines.
@@ -536,7 +581,9 @@ public void connect(Duration timeout) {
             connected = false;
         }
         try {
-            socket = new Socket(host, port);
+            socket = new Socket();
+            socketConfigurator.accept(socket);
+            socket.connect(new InetSocketAddress(host, port), (int) timeout.toMillis());
             socket.setSoTimeout((int) timeout.toMillis());
             socketInputStream = socket.getInputStream();
             connected = true;
diff --git a/webserver/tests/static-content/src/test/java/io/helidon/webserver/tests/staticcontent/FixedLengthPartialWriteTest.java b/webserver/tests/static-content/src/test/java/io/helidon/webserver/tests/staticcontent/FixedLengthPartialWriteTest.java
new file mode 100644
index 00000000000..b6c44d3713b
--- /dev/null
+++ b/webserver/tests/static-content/src/test/java/io/helidon/webserver/tests/staticcontent/FixedLengthPartialWriteTest.java
@@ -0,0 +1,288 @@
+/*
+ * Copyright (c) 2026 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.helidon.webserver.tests.staticcontent;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UncheckedIOException;
+import java.net.Socket;
+import java.net.SocketTimeoutException;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import io.helidon.common.socket.SocketOptions;
+import io.helidon.common.testing.http.junit5.SocketHttpClient;
+import io.helidon.webserver.WebServerConfig;
+import io.helidon.webserver.http.HttpRules;
+import io.helidon.webserver.http.ServerRequest;
+import io.helidon.webserver.http.ServerResponse;
+import io.helidon.webserver.spi.ServerFeature;
+import io.helidon.webserver.staticcontent.StaticContentFeature;
+import io.helidon.webserver.testing.junit5.ServerTest;
+import io.helidon.webserver.testing.junit5.SetUpFeatures;
+import io.helidon.webserver.testing.junit5.SetUpRoute;
+import io.helidon.webserver.testing.junit5.SetUpServer;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Regression test for issue 11340.
+ *
+ * <p>The issue reported broken fixed-length HTTP/1.1 responses when the NIO socket implementation hit
+ * partial writes. The reproducer covered both a handler that set {@code Content-Length} before streaming
+ * through {@code outputStream()} and a {@code StaticContentFeature} file response, so this test exercises
+ * both with a slow raw-socket client and verifies the full JSON body arrives intact.
+ */
+@ServerTest
+class FixedLengthPartialWriteTest {
+    private enum ResponseState {
+        INCOMPLETE,
+        COMPLETE,
+        MALFORMED
+    }
+
+    private static final String CRLF = "\r\n";
+    private static final int ITEM_COUNT = 600;
+    private static final int INITIAL_READ_DELAY_MILLIS = 100;
+    private static final int READ_DELAY_MILLIS = 1;
+    private static final int READ_BUFFER_SIZE = 256;
+    private static final int SOCKET_TIMEOUT_MILLIS = 2_000;
+    private static final String ITEM_PAYLOAD = "x".repeat(192);
+    private static final String FIXED_RESPONSE_JSON = responseJson("fixed-length");
+    private static final String STATIC_RESPONSE_JSON = responseJson("static-content");
+    private static final Path STATIC_FILE = createStaticFile();
+
+    private final URI uri;
+
+    FixedLengthPartialWriteTest(URI uri) {
+        this.uri = uri;
+    }
+
+    @SetUpFeatures
+    static List<ServerFeature> setupFeatures() {
+        return List.of(StaticContentFeature.builder()
+                               .addPath(path -> path.context("/static")
+                                       .location(STATIC_FILE))
+                               .build());
+    }
+
+    @SetUpServer
+    static void setupServer(WebServerConfig.Builder builder) {
+        builder.useNio(true);
+        builder.writeBufferSize(0);
+        builder.connectionOptions(SocketOptions.builder()
+                                          .socketSendBufferSize(1024)
+                                          .build());
+    }
+
+    @SetUpRoute
+    static void routing(HttpRules rules) {
+        rules.get("/fixed", FixedLengthPartialWriteTest::fixed);
+    }
+
+    @Test
+    void fixedLengthResponseSurvivesPartialNioWrites() throws Exception {
+        assertEquals(FIXED_RESPONSE_JSON, requestSlowly("/fixed"));
+    }
+
+    @Test
+    void staticContentResponseSurvivesPartialNioWrites() throws Exception {
+        assertEquals(STATIC_RESPONSE_JSON, requestSlowly("/static"));
+    }
+
+    private static void fixed(ServerRequest req, ServerResponse res) throws IOException {
+        byte[] bytes = FIXED_RESPONSE_JSON.getBytes(StandardCharsets.UTF_8);
+        res.contentLength(bytes.length);
+        try (InputStream inputStream = new ByteArrayInputStream(bytes);
+             OutputStream outputStream = res.outputStream()) {
+            inputStream.transferTo(outputStream);
+        }
+    }
+
+    private String requestSlowly(String path) throws Exception {
+        try (SocketHttpClient socketHttpClient = socketHttpClient()) {
+            socketHttpClient.requestRaw(httpRequest(uri, path));
+
+            Thread.sleep(INITIAL_READ_DELAY_MILLIS);
+
+            String rawResponse = readSlowly(socketHttpClient.socketInputStream());
+            return decodeFixedLengthJson(rawResponse);
+        }
+    }
+
+    private SocketHttpClient socketHttpClient() {
+        return SocketHttpClient.create(uri.getHost(),
+                                       uri.getPort(),
+                                       Duration.ofMillis(SOCKET_TIMEOUT_MILLIS),
+                                       FixedLengthPartialWriteTest::configureSocket);
+    }
+
+    private static void configureSocket(Socket socket) {
+        try {
+            socket.setReceiveBufferSize(READ_BUFFER_SIZE);
+        } catch (IOException e) {
+            throw new UncheckedIOException("Failed to configure test client socket", e);
+        }
+    }
+
+    private static String httpRequest(URI uri, String path) {
+        return """
+                GET %s HTTP/1.1\r
+                Host: %s:%d\r
+                Connection: close\r
+                \r
+                """.formatted(path, uri.getHost(), uri.getPort());
+    }
+
+    private static String readSlowly(InputStream inputStream) throws IOException, InterruptedException {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        byte[] buffer = new byte[READ_BUFFER_SIZE];
+
+        try {
+            while (true) {
+                int read = inputStream.read(buffer);
+                if (read == -1) {
+                    return out.toString(StandardCharsets.UTF_8);
+                }
+                out.write(buffer, 0, read);
+                String currentResponse = out.toString(StandardCharsets.UTF_8);
+                ResponseState responseState = responseState(currentResponse);
+                if (responseState != ResponseState.INCOMPLETE) {
+                    return currentResponse;
+                }
+                Thread.sleep(READ_DELAY_MILLIS);
+            }
+        } catch (SocketTimeoutException e) {
+            String partialResponse = out.toString(StandardCharsets.UTF_8);
+            try {
+                decodeFixedLengthJson(partialResponse);
+            } catch (AssertionError assertionError) {
+                throw assertionError;
+            }
+            throw new AssertionError("Timed out while reading response:\n" + abbreviate(partialResponse), e);
+        }
+    }
+
+    private static ResponseState responseState(String rawResponse) {
+        int headersEnd = rawResponse.indexOf(CRLF + CRLF);
+        if (headersEnd < 0) {
+            return ResponseState.INCOMPLETE;
+        }
+
+        Optional<Integer> contentLength = contentLength(rawResponse.substring(0, headersEnd));
+        if (contentLength.isEmpty()) {
+            return ResponseState.MALFORMED;
+        }
+
+        String body = rawResponse.substring(headersEnd + (CRLF + CRLF).length());
+        int actualLength = body.getBytes(StandardCharsets.UTF_8).length;
+        if (actualLength < contentLength.get()) {
+            return ResponseState.INCOMPLETE;
+        }
+        if (actualLength == contentLength.get()) {
+            return ResponseState.COMPLETE;
+        }
+        return ResponseState.MALFORMED;
+    }
+
+    private static String decodeFixedLengthJson(String rawResponse) {
+        int headersEnd = rawResponse.indexOf(CRLF + CRLF);
+        assertTrue(headersEnd >= 0, () -> "Missing response headers terminator:\n" + abbreviate(rawResponse));
+
+        String headers = rawResponse.substring(0, headersEnd);
+        assertTrue(headers.startsWith("HTTP/1.1 200"),
+                   () -> "Unexpected status line:\n" + abbreviate(headers));
+        assertFalse(headers.toLowerCase(Locale.ROOT).contains("transfer-encoding: chunked"),
+                    () -> "Expected fixed-length response, but got chunked transfer-encoding:\n" + abbreviate(headers));
+
+        int contentLength = contentLength(headers)
+                .orElseThrow(() -> new AssertionError("Missing Content-Length header:\n" + abbreviate(headers)));
+
+        String body = rawResponse.substring(headersEnd + (CRLF + CRLF).length());
+        assertEquals(contentLength,
+                     body.getBytes(StandardCharsets.UTF_8).length,
+                     () -> "Expected Content-Length " + contentLength + " but received:\n" + abbreviate(rawResponse));
+        return body;
+    }
+
+    private static Optional<Integer> contentLength(String headers) {
+        return headers.lines()
+                .skip(1)
+                .map(String::trim)
+                .filter(line -> line.toLowerCase(Locale.ROOT).startsWith("content-length:"))
+                .findFirst()
+                .map(line -> Integer.parseInt(line.substring("content-length:".length()).trim()));
+    }
+
+    private static String responseJson(String variant) {
+        String items = IntStream.range(0, ITEM_COUNT)
+                .mapToObj(i -> "    " + """
+                        {"id":%d,"name":"item-%d","active":%s,"payload":"%s"}"""
+                        .formatted(i, i, i % 2 == 0, ITEM_PAYLOAD))
+                .collect(Collectors.joining(",\n"));
+
+        return """
+                {
+                  "variant": "%s",
+                  "items": [
+                %s
+                  ],
+                  "meta": {
+                    "count": %d,
+                    "kind": "fixed-length"
+                  }
+                }
+                """.formatted(variant, items, ITEM_COUNT);
+    }
+
+    private static Path createStaticFile() {
+        try {
+            Path file = Files.createTempFile("helidon-11340-", ".json");
+            Files.writeString(file, STATIC_RESPONSE_JSON, StandardCharsets.UTF_8);
+            return file;
+        } catch (IOException e) {
+            throw new UncheckedIOException("Failed to prepare static content for the test", e);
+        }
+    }
+
+    private static String abbreviate(String text) {
+        int maxLength = 4000;
+        if (text.length() <= maxLength) {
+            return text;
+        }
+        int tailLength = 1000;
+        int headLength = maxLength - tailLength - 25;
+        return text.substring(0, headLength)
+                + "\n... truncated ...\n"
+                + text.substring(text.length() - tailLength);
+    }
+}
diff --git a/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/ChunkedStreamingPartialWriteTest.java b/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/ChunkedStreamingPartialWriteTest.java
new file mode 100644
index 00000000000..3921d77bf74
--- /dev/null
+++ b/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/ChunkedStreamingPartialWriteTest.java
@@ -0,0 +1,324 @@
+/*
+ * Copyright (c) 2026 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.helidon.webserver.tests;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UncheckedIOException;
+import java.net.Socket;
+import java.net.SocketTimeoutException;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Locale;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import io.helidon.common.socket.SocketOptions;
+import io.helidon.common.testing.http.junit5.SocketHttpClient;
+import io.helidon.webserver.WebServerConfig;
+import io.helidon.webserver.http.HttpRules;
+import io.helidon.webserver.http.ServerRequest;
+import io.helidon.webserver.http.ServerResponse;
+import io.helidon.webserver.testing.junit5.ServerTest;
+import io.helidon.webserver.testing.junit5.SetUpRoute;
+import io.helidon.webserver.testing.junit5.SetUpServer;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Regression test for issue 11353.
+ *
+ * <p>This reproduces the chunked streaming corruption reported there by forcing the webserver through the
+ * NIO socket path, using a small socket send buffer, and reading the response slowly enough to trigger
+ * partial {@code SocketChannel.write(...)} calls. Before the fix, those partial writes could drop bytes,
+ * corrupt the chunk framing, and leave the client with an incomplete JSON response.
+ */
+@ServerTest
+public class ChunkedStreamingPartialWriteTest {
+    private enum ResponseState {
+        INCOMPLETE,
+        COMPLETE,
+        MALFORMED
+    }
+
+    private static final String CRLF = "\r\n";
+    private static final int ITEM_COUNT = 600;
+    private static final int INITIAL_READ_DELAY_MILLIS = 100;
+    private static final int READ_DELAY_MILLIS = 1;
+    private static final int READ_BUFFER_SIZE = 256;
+    private static final int SOCKET_TIMEOUT_MILLIS = 2_000;
+    private static final String ITEM_PAYLOAD = "x".repeat(192);
+    private static final String REQUEST_JSON = """
+            {"requestId":"chunked-streaming","payload":"%s"}
+            """.formatted("r".repeat(1024));
+
+    private final URI uri;
+
+    ChunkedStreamingPartialWriteTest(URI uri) {
+        this.uri = uri;
+    }
+
+    @SetUpServer
+    static void setupServer(WebServerConfig.Builder builder) {
+        builder.useNio(true);
+        builder.writeBufferSize(0);
+        builder.connectionOptions(SocketOptions.builder()
+                                          .socketSendBufferSize(1024)
+                                          .build());
+    }
+
+    @SetUpRoute
+    static void routing(HttpRules rules) {
+        rules.post("/chunked-streaming", ChunkedStreamingPartialWriteTest::streamJsonResponse);
+    }
+
+    @Test
+    void chunkedStreamingResponseSurvivesPartialNioWrites() throws Exception {
+        String expectedJson = responseJson(REQUEST_JSON);
+        String actualJson = requestSlowly(REQUEST_JSON);
+        assertEquals(expectedJson, actualJson);
+    }
+
+    private static void streamJsonResponse(ServerRequest req, ServerResponse res) throws IOException {
+        String requestJson = req.content().as(String.class);
+        String responseJson = responseJson(requestJson);
+        int firstSplit = responseJson.length() / 3;
+        int secondSplit = (responseJson.length() * 2) / 3;
+        String firstChunk = responseJson.substring(0, firstSplit);
+        String secondChunk = responseJson.substring(firstSplit, secondSplit);
+        String thirdChunk = responseJson.substring(secondSplit);
+
+        try (OutputStream outputStream = res.outputStream()) {
+            outputStream.write(firstChunk.getBytes(StandardCharsets.UTF_8));
+            outputStream.write(secondChunk.getBytes(StandardCharsets.UTF_8));
+            outputStream.write(thirdChunk.getBytes(StandardCharsets.UTF_8));
+        }
+    }
+
+    private String requestSlowly(String requestJson) throws Exception {
+        try (SocketHttpClient socketHttpClient = socketHttpClient()) {
+            socketHttpClient.requestRaw(httpRequest(uri, requestJson));
+
+            Thread.sleep(INITIAL_READ_DELAY_MILLIS);
+
+            String rawResponse = readSlowly(socketHttpClient.socketInputStream());
+            return decodeChunkedJson(rawResponse);
+        }
+    }
+
+    private SocketHttpClient socketHttpClient() {
+        return SocketHttpClient.create(uri.getHost(),
+                                       uri.getPort(),
+                                       Duration.ofMillis(SOCKET_TIMEOUT_MILLIS),
+                                       ChunkedStreamingPartialWriteTest::configureSocket);
+    }
+
+    private static void configureSocket(Socket socket) {
+        try {
+            socket.setReceiveBufferSize(READ_BUFFER_SIZE);
+        } catch (IOException e) {
+            throw new UncheckedIOException("Failed to configure test client socket", e);
+        }
+    }
+
+    private static String httpRequest(URI uri, String requestJson) {
+        int contentLength = requestJson.getBytes(StandardCharsets.UTF_8).length;
+        return """
+                POST /chunked-streaming HTTP/1.1\r
+                Host: %s:%d\r
+                Content-Type: application/json\r
+                Content-Length: %d\r
+                Connection: close\r
+                \r
+                %s""".formatted(uri.getHost(), uri.getPort(), contentLength, requestJson);
+    }
+
+    private static String readSlowly(InputStream inputStream) throws IOException, InterruptedException {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        byte[] buffer = new byte[READ_BUFFER_SIZE];
+
+        try {
+            while (true) {
+                int read = inputStream.read(buffer);
+                if (read == -1) {
+                    return out.toString(StandardCharsets.UTF_8);
+                }
+                out.write(buffer, 0, read);
+                String currentResponse = out.toString(StandardCharsets.UTF_8);
+                ResponseState responseState = responseState(currentResponse);
+                if (responseState != ResponseState.INCOMPLETE) {
+                    return currentResponse;
+                }
+                Thread.sleep(READ_DELAY_MILLIS);
+            }
+        } catch (SocketTimeoutException e) {
+            String partialResponse = out.toString(StandardCharsets.UTF_8);
+            try {
+                decodeChunkedJson(partialResponse);
+            } catch (AssertionError assertionError) {
+                throw assertionError;
+            }
+            throw new AssertionError("Timed out while reading response:\n" + abbreviate(partialResponse), e);
+        }
+    }
+
+    private static ResponseState responseState(String rawResponse) {
+        int headersEnd = rawResponse.indexOf(CRLF + CRLF);
+        if (headersEnd < 0) {
+            return ResponseState.INCOMPLETE;
+        }
+
+        String headers = rawResponse.substring(0, headersEnd).toLowerCase(Locale.ROOT);
+        if (!headers.contains("transfer-encoding: chunked")) {
+            return ResponseState.INCOMPLETE;
+        }
+
+        String chunkedBody = rawResponse.substring(headersEnd + (CRLF + CRLF).length());
+        int index = 0;
+
+        while (true) {
+            int sizeLineEnd = chunkedBody.indexOf(CRLF, index);
+            if (sizeLineEnd < 0) {
+                return ResponseState.INCOMPLETE;
+            }
+
+            String chunkSizeLine = chunkedBody.substring(index, sizeLineEnd);
+            String chunkSizeToken = chunkSizeLine.contains(";")
+                    ? chunkSizeLine.substring(0, chunkSizeLine.indexOf(';'))
+                    : chunkSizeLine;
+
+            final int chunkSize;
+            try {
+                chunkSize = Integer.parseInt(chunkSizeToken, 16);
+            } catch (NumberFormatException e) {
+                return ResponseState.MALFORMED;
+            }
+
+            index = sizeLineEnd + CRLF.length();
+            if (chunkSize == 0) {
+                return chunkedBody.startsWith(CRLF, index) ? ResponseState.COMPLETE : ResponseState.INCOMPLETE;
+            }
+
+            int chunkEnd = index + chunkSize;
+            if (chunkEnd > chunkedBody.length()) {
+                return ResponseState.INCOMPLETE;
+            }
+            if (!chunkedBody.startsWith(CRLF, chunkEnd)) {
+                return ResponseState.INCOMPLETE;
+            }
+
+            index = chunkEnd + CRLF.length();
+        }
+    }
+
+    private static String decodeChunkedJson(String rawResponse) {
+        int headersEnd = rawResponse.indexOf(CRLF + CRLF);
+        assertTrue(headersEnd >= 0, () -> "Missing response headers terminator:\n" + abbreviate(rawResponse));
+
+        String headers = rawResponse.substring(0, headersEnd);
+        assertTrue(headers.startsWith("HTTP/1.1 200"),
+                   () -> "Unexpected status line:\n" + abbreviate(headers));
+        assertTrue(headers.toLowerCase(Locale.ROOT).contains("transfer-encoding: chunked"),
+                   () -> "Expected chunked transfer-encoding:\n" + abbreviate(headers));
+
+        return decodeChunkedBody(rawResponse.substring(headersEnd + (CRLF + CRLF).length()), rawResponse);
+    }
+
+    private static String decodeChunkedBody(String chunkedBody, String rawResponse) {
+        StringBuilder decoded = new StringBuilder(chunkedBody.length());
+        int index = 0;
+
+        while (true) {
+            int sizeLineEnd = chunkedBody.indexOf(CRLF, index);
+            assertTrue(sizeLineEnd >= 0,
+                       () -> "Missing chunk-size delimiter:\n" + abbreviate(rawResponse));
+
+            String chunkSizeLine = chunkedBody.substring(index, sizeLineEnd);
+            String chunkSizeToken = chunkSizeLine.contains(";")
+                    ? chunkSizeLine.substring(0, chunkSizeLine.indexOf(';'))
+                    : chunkSizeLine;
+
+            final int chunkSize;
+            try {
+                chunkSize = Integer.parseInt(chunkSizeToken, 16);
+            } catch (NumberFormatException e) {
+                throw new AssertionError("Invalid chunk size '" + chunkSizeLine + "' in response:\n"
+                                                 + abbreviate(rawResponse),
+                                         e);
+            }
+
+            index = sizeLineEnd + CRLF.length();
+
+            if (chunkSize == 0) {
+                assertTrue(chunkedBody.startsWith(CRLF, index),
+                           () -> "Missing terminating CRLF after the final chunk:\n" + abbreviate(rawResponse));
+                return decoded.toString();
+            }
+
+            int chunkEnd = index + chunkSize;
+            assertTrue(chunkEnd <= chunkedBody.length(),
+                       () -> "Chunk size " + chunkSize + " exceeds remaining response bytes:\n"
+                               + abbreviate(rawResponse));
+
+            decoded.append(chunkedBody, index, chunkEnd);
+
+            assertTrue(chunkedBody.startsWith(CRLF, chunkEnd),
+                       () -> "Missing CRLF after chunk payload:\n" + abbreviate(rawResponse));
+
+            index = chunkEnd + CRLF.length();
+        }
+    }
+
+    private static String responseJson(String requestJson) {
+        String items = IntStream.range(0, ITEM_COUNT)
+                .mapToObj(i -> "    " + """
+                        {"id":%d,"name":"item-%d","active":%s,"payload":"%s"}"""
+                        .formatted(i, i, i % 2 == 0, ITEM_PAYLOAD))
+                .collect(Collectors.joining(",\n"));
+
+        return """
+                {
+                  "request": %s,
+                  "items": [
+                %s
+                  ],
+                  "meta": {
+                    "count": %d,
+                    "kind": "chunked-streaming"
+                  }
+                }
+                """.formatted(requestJson, items, ITEM_COUNT);
+    }
+
+    private static String abbreviate(String text) {
+        int maxLength = 4000;
+        if (text.length() <= maxLength) {
+            return text;
+        }
+        int tailLength = 1000;
+        int headLength = maxLength - tailLength - 25;
+        return text.substring(0, headLength)
+                + "\n... truncated ...\n"
+                + text.substring(text.length() - tailLength);
+    }
+}