diff --git a/flash/src/main/java/dev/relism/flash/HttpServer.java b/flash/src/main/java/dev/relism/flash/HttpServer.java index c354ee5..a00853d 100644 --- a/flash/src/main/java/dev/relism/flash/HttpServer.java +++ b/flash/src/main/java/dev/relism/flash/HttpServer.java @@ -34,9 +34,14 @@ import java.util.concurrent.atomic.AtomicReference; * Pure I/O transport layer. Owns the {@link ServerSocket}, the virtual-thread * executor, and the keep-alive accept loop. Routing is delegated to HTTP and WS routers. * - *
Exists for callers that must handle an open-ended set of header names — e.g. a reverse + * proxy forwarding whatever the client sent — where {@link #first}/{@link #all}'s per-name + * lookup isn't usable because the set of names isn't known upfront. + */ + public void forEach(HeaderConsumer consumer) { + if (buffer == null) return; + if (nameSlice == null) { + nameSlice = new Slice(); + valueSlice = new Slice(); + } + int i = sectionStart; + while (i < sectionEnd) { + int lineEnd = findCR(i); + int colon = findColon(i, lineEnd); + if (colon != -1) { + int vs = skipSpaces(colon + 1, lineEnd); + nameSlice.start = i; + nameSlice.len = colon - i; + valueSlice.start = vs; + valueSlice.len = lineEnd - vs; + consumer.accept(nameSlice, valueSlice); + } + i = lineEnd + 2; + } + } + + /** + * Callback for {@link #forEach}. Implement with a reusable, field-holding instance (reset + * before each {@code forEach} call) rather than a capturing lambda if the call site itself + * needs to be allocation-free too — a capturing lambda is its own per-call allocation, same + * as anywhere else on a hot path (see {@code docs/CODE-STYLE.md} in the Pathway project for + * the idiom this mirrors). + */ + @FunctionalInterface + public interface HeaderConsumer { + void accept(ByteView name, ByteView value); + } + + /** Mutable zero-copy slice into {@link #buffer} — see {@link #forEach}. */ + private final class Slice implements ByteView { + int start; + int len; + + @Override public int length() { return len; } + @Override public byte byteAt(int i) { return buffer[start + i]; } + } + /** Returns the first value of header {@code name} (case-insensitive), or {@code null}. */ public String first(String name) { long r = findFirst(name); diff --git a/flash/src/main/java/dev/relism/flash/websocket/WebSocketSession.java b/flash/src/main/java/dev/relism/flash/websocket/WebSocketSession.java index 9e2256a..1f10cef 100644 --- a/flash/src/main/java/dev/relism/flash/websocket/WebSocketSession.java +++ b/flash/src/main/java/dev/relism/flash/websocket/WebSocketSession.java @@ -1,9 +1,12 @@ package dev.relism.flash.websocket; +import dev.relism.flash.models.Request; + import java.io.EOFException; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.atomic.AtomicBoolean; /** @@ -39,21 +42,42 @@ public final class WebSocketSession { private final InputStream in; private final OutputStream out; private final byte[] readBuf; + private final Request request; + private final boolean maskOutgoing; private final AtomicBoolean open = new AtomicBoolean(true); private int closeCode = 1000; - private final byte[] hdrScratch = new byte[10]; + /** 1 opcode byte + up to 8 extended-length bytes + up to 4 mask-key bytes (masked mode only). */ + private final byte[] hdrScratch = new byte[14]; public WebSocketSession(InputStream in, OutputStream out, int bufferSize) { - this.in = in; - this.out = out; - this.readBuf = new byte[bufferSize]; + this(in, out, bufferSize, null, false); + } + + /** + * @param request the HTTP request that upgraded to this session, or {@code null} if the + * caller has no use for it (e.g. a session opened as a WS client rather + * than accepted as a WS server). Stored as-is, no copy. + * @param maskOutgoing {@code true} if this session is acting as a WS client — RFC 6455 + * requires client-to-server frames to be masked, unlike the server-to-client + * direction {@link #writeFrame} originally only supported. See {@link + * #writeFrame} for how masking is applied without allocating. + */ + public WebSocketSession(InputStream in, OutputStream out, int bufferSize, Request request, boolean maskOutgoing) { + this.in = in; + this.out = out; + this.readBuf = new byte[bufferSize]; + this.request = request; + this.maskOutgoing = maskOutgoing; } public boolean isOpen() { return open.get(); } public int closeCode() { return closeCode; } + /** The request that upgraded this connection, or {@code null} — see the 4-arg constructor. */ + public Request request() { return request; } + // ── Public send API ──────────────────────────────────────────────────── public void sendText(byte[] utf8, int off, int len) throws IOException { @@ -146,8 +170,9 @@ public final class WebSocketSession { // ── Private ──────────────────────────────────────────────────────────── /** - * Encodes the WS frame header into {@link #hdrScratch} (at most 10 bytes), - * then writes header + payload in two bulk calls to the raw socket stream. + * Encodes the WS frame header into {@link #hdrScratch} (at most 14 bytes: 1 opcode + up to 8 + * extended-length + up to 4 mask-key), then writes header + payload in two bulk calls to the + * raw socket stream. * *
No {@code flush()} — {@code out} is the unbuffered socket {@link OutputStream} * (see {@code HttpServer#process}). Each {@code write()} lands directly in the @@ -156,19 +181,28 @@ public final class WebSocketSession { * (header then payload) will be merged into a single TCP segment by the kernel * because they arrive faster than the ACK from the peer — exactly the coalescing * we want, at zero cost. + * + *
{@link #maskOutgoing} (client mode): RFC 6455 requires every client-to-server frame
+ * to be masked. The mask key is generated into {@link #hdrScratch} (no new allocation — same
+ * fixed field every frame reuses) and the payload is masked in place via {@link
+ * #unmaskInPlace} — XOR is its own inverse, so the exact routine {@link #readFrame} already
+ * uses to unmask an inbound payload masks an outbound one too, with no separate code path and
+ * no copy. This mutates the caller's {@code payload} array as a side effect: callers using
+ * masked mode must not reuse that buffer expecting it unchanged after the call.
*/
private void writeFrame(byte opcode, byte[] payload, int off, int len) throws IOException {
synchronized (out) {
int hlen = 0;
hdrScratch[hlen++] = (byte) (0x80 | opcode);
+ int maskBit = maskOutgoing ? 0x80 : 0x00;
if (len <= 125) {
- hdrScratch[hlen++] = (byte) len;
+ hdrScratch[hlen++] = (byte) (maskBit | len);
} else if (len <= 0xFFFF) {
- hdrScratch[hlen++] = 126;
+ hdrScratch[hlen++] = (byte) (maskBit | 126);
hdrScratch[hlen++] = (byte) ((len >> 8) & 0xFF);
hdrScratch[hlen++] = (byte) (len & 0xFF);
} else {
- hdrScratch[hlen++] = 127;
+ hdrScratch[hlen++] = (byte) (maskBit | 127);
hdrScratch[hlen++] = 0; hdrScratch[hlen++] = 0;
hdrScratch[hlen++] = 0; hdrScratch[hlen++] = 0;
hdrScratch[hlen++] = (byte) ((len >> 24) & 0xFF);
@@ -176,6 +210,17 @@ public final class WebSocketSession {
hdrScratch[hlen++] = (byte) ((len >> 8) & 0xFF);
hdrScratch[hlen++] = (byte) (len & 0xFF);
}
+ if (maskOutgoing) {
+ // ThreadLocalRandom needs no seeding/allocation per call; the four mask bytes are
+ // carved out of one int, never boxed.
+ int mask = ThreadLocalRandom.current().nextInt();
+ byte m0 = (byte) (mask >>> 24), m1 = (byte) (mask >>> 16), m2 = (byte) (mask >>> 8), m3 = (byte) mask;
+ hdrScratch[hlen++] = m0;
+ hdrScratch[hlen++] = m1;
+ hdrScratch[hlen++] = m2;
+ hdrScratch[hlen++] = m3;
+ unmaskInPlace(payload, off, len, m0, m1, m2, m3);
+ }
out.write(hdrScratch, 0, hlen);
out.write(payload, off, len);
// No flush — TCP_NODELAY handles delivery. See Javadoc above.
diff --git a/flash/src/test/java/dev/relism/flash/HttpServerTest.java b/flash/src/test/java/dev/relism/flash/HttpServerTest.java
index 1d32d6e..ff4705f 100644
--- a/flash/src/test/java/dev/relism/flash/HttpServerTest.java
+++ b/flash/src/test/java/dev/relism/flash/HttpServerTest.java
@@ -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"));
+ }
+ }
}
diff --git a/flash/src/test/java/dev/relism/flash/models/HeaderMapTest.java b/flash/src/test/java/dev/relism/flash/models/HeaderMapTest.java
index dc9d14b..08a6ad0 100644
--- a/flash/src/test/java/dev/relism/flash/models/HeaderMapTest.java
+++ b/flash/src/test/java/dev/relism/flash/models/HeaderMapTest.java
@@ -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