feat(core): WS client-mode masking, zero-copy header iteration, shared I/O relay buffer
CI / Build & Test (push) Failing after 6m7s
CI / Build & Test (pull_request) Failing after 4m56s

- WebSocketSession supports client-mode outgoing frame masking (RFC 6455) via
  in-place XOR, reusing the unmask routine already used for inbound frames.
- HeaderMap gains an allocation-free forEach(HeaderConsumer) for callers that
  must handle an open-ended set of header names (e.g. proxying).
- HttpServer relays streaming/chunked response bodies through a shared
  per-connection ThreadLocal buffer instead of relying on InputStream#transferTo
  (which allocates internally) or a fresh byte[8192] per chunked write.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-08-09 20:24:56 +00:00
co-authored by Claude Sonnet 5
parent 524bdeb28b
commit a037456634
6 changed files with 287 additions and 14 deletions
@@ -228,4 +228,37 @@ class HttpServerTest {
assertTrue(second.contains("Connection: keep-alive"));
}
}
/**
* Regression guard for the shared {@code STREAM_RELAY_BUFFER}: both the non-chunked
* ({@code /api/stream}) and chunked ({@code /api/chunked-out}) streaming paths reuse the same
* per-connection buffer now — sending one of each back to back on one connection must not
* leave either response corrupted by the other reusing the array mid-transfer.
*/
@Test
void testKeepAlive_streamedAndChunkedResponsesOnSameConnectionDontCorruptEachOther() throws Exception {
String streamReq = "GET /api/stream HTTP/1.1\r\nHost: localhost\r\n\r\n";
String chunkedReq = "GET /api/chunked-out HTTP/1.1\r\nHost: localhost\r\n\r\n";
try (Socket socket = new Socket("127.0.0.1", port);
OutputStream out = socket.getOutputStream();
InputStream in = socket.getInputStream()) {
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
out.write(streamReq.getBytes(StandardCharsets.UTF_8));
out.write(chunkedReq.getBytes(StandardCharsets.UTF_8));
out.write(streamReq.getBytes(StandardCharsets.UTF_8));
out.flush();
String first = readOneResponse(in);
String second = readOneResponse(in);
String third = readOneResponse(in);
assertTrue(first.contains("Content-Length: 23"));
assertTrue(first.endsWith("streaming response body"));
assertTrue(second.contains("Transfer-Encoding: chunked"));
assertTrue(second.endsWith("streaming response body"));
assertTrue(third.contains("Content-Length: 23"));
assertTrue(third.endsWith("streaming response body"));
}
}
}
@@ -90,4 +90,41 @@ class HeaderMapTest {
assertTrue(map.all("Host").isEmpty());
assertTrue(map.all().isEmpty());
}
// --- forEach ---
@Test
void forEach_visitsEveryHeaderInDeclarationOrder() {
HeaderMap map = parse("Host: localhost", "Accept: text/plain", "Cookie: a=1");
List<String> seen = new java.util.ArrayList<>();
map.forEach((name, value) -> seen.add(toStr(name) + "=" + toStr(value)));
assertEquals(List.of("Host=localhost", "Accept=text/plain", "Cookie=a=1"), seen);
}
@Test
void forEach_emptyMap_neverInvokesConsumer() {
HeaderMap map = new HeaderMap();
map.forEach((name, value) -> fail("must not be called on an empty map"));
}
@Test
void forEach_reusesTheSameTwoViewInstancesAcrossEveryHeader() {
// The zero-allocation contract: forEach must reposition two ByteViews in place, not
// allocate a fresh pair per header — same instances across all three calls here.
HeaderMap map = parse("A: 1", "B: 2", "C: 3");
List<ByteView> names = new java.util.ArrayList<>();
List<ByteView> values = new java.util.ArrayList<>();
map.forEach((name, value) -> { names.add(name); values.add(value); });
assertSame(names.get(0), names.get(1));
assertSame(names.get(1), names.get(2));
assertSame(values.get(0), values.get(1));
assertSame(values.get(1), values.get(2));
}
private static String toStr(ByteView v) {
byte[] b = new byte[v.length()];
for (int i = 0; i < b.length; i++) b[i] = v.byteAt(i);
return new String(b, StandardCharsets.UTF_8);
}
}
@@ -1,14 +1,79 @@
package dev.relism.flash.websocket;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.HeaderMap;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestLine;
import dev.relism.fpr.core.ByteView;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
class WebSocketSessionTest {
private static ByteView viewOf(String s) {
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
return new ByteView() {
public int length() { return bytes.length; }
public byte byteAt(int idx) { return bytes[idx]; }
};
}
@Test
void request_returnsWhatWasPassedToConstructor() {
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/chat"), null, viewOf("HTTP/1.1"), new HeaderMap());
Request req = new Request(line, new byte[0]);
WebSocketSession session = new WebSocketSession(
new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream(), 64, req, false);
assertSame(req, session.request());
}
@Test
void request_defaultsToNullOnThreeArgConstructor() {
WebSocketSession session = new WebSocketSession(new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream(), 64);
assertNull(session.request());
}
@Test
void sendText_masksWhenActingAsClient() throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
WebSocketSession session = new WebSocketSession(
new ByteArrayInputStream(new byte[0]), out, 64, null, true);
byte[] payload = "hi".getBytes();
session.sendText(payload, 0, payload.length);
byte[] bytes = out.toByteArray();
assertEquals((byte) 0x81, bytes[0]); // FIN + TEXT
assertEquals((byte) (0x80 | 2), bytes[1]); // masked bit + length 2
byte m0 = bytes[2], m1 = bytes[3], m2 = bytes[4], m3 = bytes[5];
assertEquals((byte) ('h' ^ m0), bytes[6]);
assertEquals((byte) ('i' ^ m1), bytes[7]);
// The caller's buffer is mutated in place by the mask (documented, zero-copy tradeoff).
assertEquals((byte) ('h' ^ m0), payload[0]);
}
@Test
void sendText_doesNotMaskWhenActingAsServer() throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
WebSocketSession session = new WebSocketSession(new ByteArrayInputStream(new byte[0]), out, 64);
byte[] payload = "hi".getBytes();
session.sendText(payload, 0, payload.length);
byte[] bytes = out.toByteArray();
assertEquals((byte) 0x81, bytes[0]);
assertEquals((byte) 2, bytes[1]); // no masked bit
assertEquals('h', bytes[2]);
assertEquals('i', bytes[3]);
}
@Test
void close_setsClosedAndWritesFrame() throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();