feat(core): WS client-mode masking, zero-copy header iteration, shared I/O relay buffer
- 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:
co-authored by
Claude Sonnet 5
parent
524bdeb28b
commit
a037456634
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user