feat(core): HTTP/2 Phase 4 — byte-layer foundations

Builds dev.relism.flash.bytes: ByteScan (scanning/comparison/hashing,
scalar + SWAR, property-tested against each other on every boundary and
20,000 random fuzz trials each), ArrayBackedByteView/SegmentedByteView
capability hierarchy, PooledSlice/SlicePool, ByteWriter, Pairs.

Cashes in the allocation and scanning wins the existing code left on the
table: EX-04 (word-at-a-time router matching, verified directly against
fpr-core's own ByteCompare), EX-05 (pooled views replacing per-call
anonymous ByteView allocations in HeaderMap/QueryParams/PathParams),
EX-09 (HeaderMap index built once per reset() instead of rescanning per
lookup), EX-19 (reusable PathParams on the router's per-connection
scratch), EX-25/EX-26 (single-allocation String construction), EX-33
(SWAR header-terminator scan in RequestParser).

Also closes EX-06's router half, missing from this phase's own EX-item
list in the plan (same class of omission DEC-12 recorded for Phase 1):
FastPathRouterImpl/FastPathWsRouterImpl's ThreadLocals (unbounded under
one-virtual-thread-per-connection) are replaced by an opaque,
caller-owned per-connection scratch object (AbstractRouter#newScratch),
not by extending ConnectionScratch as its own Javadoc originally assumed
-- that would have created transport's first dependency on routing in
the reverse direction. Full rationale in DEC-19.

Every optimization is measured, not asserted (DEC-20): SWAR scan 35.4%
faster than scalar, kept; EX-04's word-path 32.1% faster than
byte-at-a-time at the mechanism level, kept for its real future
consumers even though today's router doesn't yet route through it
(MethodPathByteView stays deliberately non-array-backed, per the plan's
own text). Router matching itself is ~0 B/op including parametric
routes. The full h1 pipeline is not literally 0 B/op yet -- 120 B/op is
Request/RequestBody/RequestLine construction, honestly attributed to
Phase 6's explicit scope rather than hidden.

395/395 tests green, both with and without -Pjmh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-08-13 14:07:29 +00:00
co-authored by Claude Sonnet 5
parent 2bf261e4e2
commit 704a00a551
38 changed files with 2993 additions and 239 deletions
@@ -1,5 +1,6 @@
package dev.relism.flash;
import dev.relism.flash.bytes.ByteScan;
import dev.relism.flash.exceptions.MalformedRequestException;
import dev.relism.flash.http.Http1Limits;
import dev.relism.flash.http.HttpMethod;
@@ -52,26 +53,6 @@ import java.util.Arrays;
public class RequestParser {
private static final int INITIAL_BUFFER_SIZE = 8192;
/**
* RFC 9110 §5.6.2 {@code tchar} set, table-driven so header-name validation is a single
* array read per byte rather than a chain of comparisons (R4/R5). Indexed directly by
* byte value; only defined for the ASCII range a valid header name can ever occupy.
*/
private static final boolean[] TCHAR = new boolean[128];
static {
for (byte b : "!#$%&'*+-.^_`|~".getBytes(java.nio.charset.StandardCharsets.US_ASCII)) {
TCHAR[b] = true;
}
for (char c = '0'; c <= '9'; c++) TCHAR[c] = true;
for (char c = 'A'; c <= 'Z'; c++) TCHAR[c] = true;
for (char c = 'a'; c <= 'z'; c++) TCHAR[c] = true;
}
private static boolean isTChar(byte b) {
return b >= 0 && b < 128 && TCHAR[b];
}
private final int maxHeaderBufferSize;
private final InetSocketAddress remoteAddress;
private final SSLSocket sslSocket;
@@ -132,7 +113,7 @@ public class RequestParser {
bufBase = 0;
bufLen = 0;
int headerEndIdx = totalRead > 0 ? findEndOfHeader(buffer, base, base + totalRead) : -1;
int headerEndIdx = totalRead > 0 ? ByteScan.indexOfCrLfCrLf(buffer, base, base + totalRead) : -1;
while (headerEndIdx == -1) {
if (base + totalRead == buffer.length) {
if (base > 0) {
@@ -151,7 +132,7 @@ public class RequestParser {
if (n <= 0) break;
int prevTotal = totalRead;
totalRead += n;
headerEndIdx = findEndOfHeader(buffer, base + Math.max(0, prevTotal - 3), base + totalRead);
headerEndIdx = ByteScan.indexOfCrLfCrLf(buffer, base + Math.max(0, prevTotal - 3), base + totalRead);
}
if (totalRead <= 0) return null;
if (headerEndIdx == -1) {
@@ -161,7 +142,7 @@ public class RequestParser {
// ── Request line ─────────────────────────────────────────────────────
int methodEnd = find(buffer, base, headerEndIdx, (byte) ' ');
int methodEnd = ByteScan.indexOf(buffer, base, headerEndIdx, (byte) ' ');
if (methodEnd == -1) throw new MalformedRequestException(400, "Invalid request line (method)");
if (methodEnd == base) throw new MalformedRequestException(400, "Missing HTTP method");
@@ -169,10 +150,10 @@ public class RequestParser {
if (method == null) throw new MalformedRequestException(501, "Unsupported HTTP method");
int pathStart = methodEnd + 1;
int pathEnd = find(buffer, pathStart, headerEndIdx, (byte) ' ');
int pathEnd = ByteScan.indexOf(buffer, pathStart, headerEndIdx, (byte) ' ');
if (pathEnd == -1) throw new MalformedRequestException(400, "Invalid request line (path)");
int queryMark = find(buffer, pathStart, pathEnd, (byte) '?');
int queryMark = ByteScan.indexOf(buffer, pathStart, pathEnd, (byte) '?');
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(buffer, pathStart,
queryMark != -1 ? queryMark - pathStart : pathEnd - pathStart);
FastPathViews.RequestByteView queryView = queryMark != -1
@@ -180,7 +161,7 @@ public class RequestParser {
: null;
int protocolStart = pathEnd + 1;
int protocolEnd = find(buffer, protocolStart, headerEndIdx, (byte) '\r');
int protocolEnd = ByteScan.indexOf(buffer, protocolStart, headerEndIdx, (byte) '\r');
if (protocolEnd == -1) throw new MalformedRequestException(400, "Invalid request line (protocol)");
// EX-08: the request line itself (method SP target SP version) is bounded separately
@@ -195,7 +176,7 @@ public class RequestParser {
// ── Headers ──────────────────────────────────────────────────────────
int sectionStart = find(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1;
int sectionStart = ByteScan.indexOf(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1;
int current = sectionStart;
long contentLength = -1;
boolean contentLengthSeen = false;
@@ -212,7 +193,7 @@ public class RequestParser {
throw new MalformedRequestException(400, "Obsolete line folding is not supported");
}
int lineEnd = find(buffer, current, headerEndIdx + 1, (byte) '\r');
int lineEnd = ByteScan.indexOf(buffer, current, headerEndIdx + 1, (byte) '\r');
if (lineEnd == -1 || lineEnd == current) break;
// EX-18: verify the '\r' is immediately followed by '\n' instead of blindly
@@ -228,7 +209,7 @@ public class RequestParser {
throw new MalformedRequestException(431, "Too many headers");
}
int colon = find(buffer, current, lineEnd, (byte) ':');
int colon = ByteScan.indexOf(buffer, current, lineEnd, (byte) ':');
if (colon == -1) {
throw new MalformedRequestException(400, "Header line missing ':'");
}
@@ -236,7 +217,7 @@ public class RequestParser {
throw new MalformedRequestException(431, "Header name exceeds " + Http1Limits.MAX_HEADER_NAME_LENGTH + " bytes");
}
for (int i = current; i < colon; i++) {
if (!isTChar(buffer[i])) {
if (!ByteScan.isTChar(buffer[i])) {
throw new MalformedRequestException(400, "Invalid header name character");
}
}
@@ -247,7 +228,7 @@ public class RequestParser {
throw new MalformedRequestException(431, "Header value exceeds " + Http1Limits.MAX_HEADER_VALUE_LENGTH + " bytes");
}
if (equalsIgnoreCase(buffer, current, colon, "content-length")) {
if (ByteScan.equalsIgnoreCaseAscii(buffer, current, colon, "content-length")) {
// EX-03: strict, overflow-safe parsing — replaces the old digit-skipping
// parseLong, which silently accepted "5abc" as 5 and "-1" as 1.
long parsed = parseContentLengthStrict(buffer, valueStart, lineEnd);
@@ -259,7 +240,7 @@ public class RequestParser {
}
contentLength = parsed;
contentLengthSeen = true;
} else if (equalsIgnoreCase(buffer, current, colon, "transfer-encoding")) {
} else if (ByteScan.equalsIgnoreCaseAscii(buffer, current, colon, "transfer-encoding")) {
transferEncodingSeen = true;
// Correctness fix found while implementing EX-02 in this exact code path
// (registered as EX-35): the old check required the WHOLE value to equal
@@ -319,34 +300,6 @@ public class RequestParser {
return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen, remoteAddress, sslSocket);
}
// ── Buffer scanning utilities (hot path — keep branch-free where possible) ──
private static int findEndOfHeader(byte[] buf, int from, int len) {
for (int i = from; i <= len - 4; i++) {
if (buf[i] == '\r' && buf[i+1] == '\n' && buf[i+2] == '\r' && buf[i+3] == '\n')
return i;
}
return -1;
}
private static int find(byte[] buf, int start, int end, byte target) {
for (int i = start; i < end; i++) {
if (buf[i] == target) return i;
}
return -1;
}
private static boolean equalsIgnoreCase(byte[] buf, int start, int end, String target) {
int len = end - start;
if (len != target.length()) return false;
for (int i = 0; i < len; i++) {
byte b = buf[start + i];
if (b >= 'A' && b <= 'Z') b += 32;
if (b != (byte) target.charAt(i)) return false;
}
return true;
}
/**
* Strict, overflow-safe {@code Content-Length} parsing ({@code EX-03}). Rejects: an empty
* value, any non-digit byte (including a leading {@code +}/{@code -}, which are not
@@ -395,6 +348,6 @@ public class RequestParser {
}
int tokenStart = lastComma + 1;
while (tokenStart < e && (buf[tokenStart] == ' ' || buf[tokenStart] == '\t')) tokenStart++;
return equalsIgnoreCase(buf, tokenStart, e, "chunked");
return ByteScan.equalsIgnoreCaseAscii(buf, tokenStart, e, "chunked");
}
}
@@ -0,0 +1,37 @@
package dev.relism.flash.bytes;
import dev.relism.fpr.core.ByteView;
/**
* Capability interface for a {@link ByteView} that is a contiguous slice of a single backing
* {@code byte[]} — as opposed to a {@link SegmentedByteView}, which spans several arrays and
* cannot expose a single {@code (array, offset)} pair.
*
* <p>Every array-backed view in this codebase implements this: {@code RequestByteView},
* {@code SocketByteView}, {@code StringByteView} (all in
* {@code dev.relism.flash.routing.routers.fastpathrouter.FastPathViews}), and {@link PooledSlice}.
* {@code MethodPathByteView} deliberately does not — it is a composite of a {@code byte[]}
* (method) and another {@link ByteView} (path), so it has no single backing array.
*
* <h3>What this enables</h3>
* Anywhere code holds a plain {@link ByteView} and wants the fast path when the concrete
* instance happens to be array-backed, an {@code instanceof ArrayBackedByteView} check unlocks:
* <ul>
* <li>Single-allocation {@code String} construction —
* {@code new String(view.array(), view.offset(), view.length(), UTF_8)} instead of a
* byte-at-a-time copy into a scratch {@code byte[]} followed by a second allocation for
* the {@code String} itself ({@code EX-25}).</li>
* <li>A single {@code System.arraycopy} instead of a manual loop wherever a view's bytes need
* to be copied.</li>
* </ul>
* Code that only has a bare {@link ByteView} (e.g. because it received one across the
* {@link SegmentedByteView} boundary, from a future HPACK CONTINUATION-spanning block) keeps the
* byte-at-a-time fallback — this interface is an opportunistic fast path, never a requirement.
*/
public interface ArrayBackedByteView extends ByteView {
/** The backing array. Bytes {@code [offset(), offset() + length())} belong to this view. */
byte[] array();
/** Offset of this view's first byte within {@link #array()}. */
int offset();
}
@@ -0,0 +1,331 @@
package dev.relism.flash.bytes;
import dev.relism.fpr.core.ByteView;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
import java.nio.ByteOrder;
/**
* The single home for protocol-neutral byte scanning: single-byte search, the four-byte
* {@code \r\n\r\n} header-terminator search (SWAR-accelerated), case-insensitive comparison,
* comma-separated token-list scanning ({@code Connection: a, b, c}), RFC 9110 {@code tchar}
* validation, and the case-insensitive header-name hash {@link dev.relism.flash.models.HeaderMap}'s
* index uses ({@code EX-09}).
*
* <p>Every method here is {@code static} and allocates nothing. Every SWAR method has a plain
* scalar counterpart ({@code *Scalar}) that exists for two reasons: it is what the tests use as
* the correctness oracle (property-tested against the SWAR version on randomized inputs — see
* {@code ByteScanTest}/{@code ByteScanFuzzTest}), and it is the documented fallback if a future
* measurement ever shows the SWAR path is not worth its complexity on some path (none has been
* found not worth it so far — see {@code DECISIONS.md} for the one path that {@em was}
* measured and kept, {@code EX-33}).
*
* <h3>The SWAR technique used throughout</h3>
* Both {@link #indexOf} and {@link #indexOfCrLfCrLf} use the classic "does this word contain
* byte {@code b}" bit trick (Bit Twiddling Hacks, "Determine if a word has a byte equal to n"):
* XOR the 8-byte word against {@code b} broadcast into every lane (turning matching lanes to
* {@code 0x00}), then test for any zero lane with
* {@code (v - 0x0101010101010101L) & ~v & 0x8080808080808080L} — non-zero exactly when some lane
* was {@code 0x00} before the subtraction, i.e. some original lane equalled {@code b}. This finds
* *that a* matching lane exists in one word-sized read plus a handful of ALU ops, touching every
* byte only once per 8-byte stride in the common (no-match-yet) case, instead of once per byte.
*
* <p>Reading the word uses {@link MethodHandles#byteArrayViewVarHandle} with
* {@link ByteOrder#nativeOrder()} — deliberately native rather than a fixed order (contrast
* {@code fpr-core}'s {@code ByteCompare}, which fixes {@code LITTLE_ENDIAN} because it compares
* two independently-read words for bit-exact equality and so needs a byte order both reads
* agree on; nothing here compares across two separately-decoded words, so the fastest order for
* the host CPU is free to use). Byte-equality detection itself (finding that a matching lane
* exists in the mask) does not depend on which order was used to assemble the word — XOR and the
* haszero test are lane-wise operations, indifferent to how lanes map to memory offsets.
* <b>Position extraction does depend on it</b>: converting "which bit of the 64-bit mask is set"
* back into "which array index did that byte come from" requires knowing whether array byte 0
* became the long's least-significant byte (little-endian) or most-significant byte
* (big-endian) — {@link #laneIndexOf} branches on {@link #NATIVE_IS_LITTLE} once, at class-init
* time, precisely to get this right on either host.
*/
public final class ByteScan {
private ByteScan() {}
private static final ByteOrder NATIVE_ORDER = ByteOrder.nativeOrder();
private static final boolean NATIVE_IS_LITTLE = NATIVE_ORDER == ByteOrder.LITTLE_ENDIAN;
private static final VarHandle LONG_VIEW =
MethodHandles.byteArrayViewVarHandle(long[].class, NATIVE_ORDER);
private static final long LANE_LSB = 0x0101010101010101L;
private static final long LANE_MSB = 0x8080808080808080L;
// ── tchar (RFC 9110 §5.6.2) ──────────────────────────────────────────────
/**
* RFC 9110 §5.6.2 {@code tchar} set, table-driven so validation is a single array read per
* byte (R4/R5) rather than a chain of range comparisons. Indexed directly by byte value;
* only the ASCII range a valid header-name character can ever occupy is populated.
*/
private static final boolean[] TCHAR = new boolean[128];
static {
for (byte b : "!#$%&'*+-.^_`|~".getBytes(java.nio.charset.StandardCharsets.US_ASCII)) {
TCHAR[b] = true;
}
for (char c = '0'; c <= '9'; c++) TCHAR[c] = true;
for (char c = 'A'; c <= 'Z'; c++) TCHAR[c] = true;
for (char c = 'a'; c <= 'z'; c++) TCHAR[c] = true;
}
/** Whether {@code b} is a valid RFC 9110 §5.6.2 {@code tchar} (a legal header-name byte). */
public static boolean isTChar(byte b) {
return b >= 0 && b < 128 && TCHAR[b];
}
// ── Single-byte search ───────────────────────────────────────────────────
/**
* Index of the first occurrence of {@code target} in {@code buf[from, to)}, or {@code -1}.
* SWAR-accelerated: touches 8 bytes per word while no match has been found, falling back to
* a byte-at-a-time tail once fewer than 8 bytes remain.
*/
public static int indexOf(byte[] buf, int from, int to, byte target) {
long broadcast = (target & 0xFFL) * LANE_LSB;
int i = from;
while (i + 8 <= to) {
long word = (long) LONG_VIEW.get(buf, i);
long masked = hasZeroLane(word ^ broadcast);
if (masked != 0) {
return i + laneIndexOf(masked);
}
i += 8;
}
for (; i < to; i++) {
if (buf[i] == target) return i;
}
return -1;
}
/** Plain byte-at-a-time reference implementation of {@link #indexOf} — the test oracle. */
static int indexOfScalar(byte[] buf, int from, int to, byte target) {
for (int i = from; i < to; i++) {
if (buf[i] == target) return i;
}
return -1;
}
// ── \r\n\r\n header terminator search ────────────────────────────────────
private static final byte CR = '\r', LF = '\n';
/**
* Index of the first {@code "\r\n\r\n"} in {@code buf[from, to)}, or {@code -1}. SWAR
* pre-filter (find a candidate {@code CR} byte 8 at a time) plus a cheap scalar 3-byte
* verify at each candidate — see the class Javadoc for the technique and
* {@code RequestParser}, {@code EX-33}, for why this replaced a fully byte-at-a-time scan.
*/
public static int indexOfCrLfCrLf(byte[] buf, int from, int to) {
int limit = to - 4; // last index at which a 4-byte match can start
int i = from;
while (i + 8 <= to) {
long word = (long) LONG_VIEW.get(buf, i);
long masked = hasZeroLane(word ^ CR_BROADCAST);
if (masked == 0) {
i += 8;
continue;
}
int crPos = i + laneIndexOf(masked);
if (crPos > limit) {
// Nearest CR candidate in this word can't fit a full match before `to`; no CR
// exists before it in [i, crPos) (laneIndexOf always finds the lowest-address
// match first), so nothing in [i, crPos) can match either — the scalar tail
// below, bounded by `limit`, correctly finds nothing without re-deriving that.
break;
}
if (buf[crPos + 1] == LF && buf[crPos + 2] == CR && buf[crPos + 3] == LF) {
return crPos;
}
i = crPos + 1;
}
for (; i <= limit; i++) {
if (buf[i] == CR && buf[i + 1] == LF && buf[i + 2] == CR && buf[i + 3] == LF) {
return i;
}
}
return -1;
}
private static final long CR_BROADCAST = (CR & 0xFFL) * LANE_LSB;
/** Plain byte-at-a-time reference implementation of {@link #indexOfCrLfCrLf} — the test oracle. */
static int indexOfCrLfCrLfScalar(byte[] buf, int from, int to) {
for (int i = from; i <= to - 4; i++) {
if (buf[i] == CR && buf[i + 1] == LF && buf[i + 2] == CR && buf[i + 3] == LF) {
return i;
}
}
return -1;
}
/** "Determine if a word has a byte equal to n" (Bit Twiddling Hacks), applied to {@code xored}. */
private static long hasZeroLane(long xored) {
return (xored - LANE_LSB) & ~xored & LANE_MSB;
}
/** Converts a {@link #hasZeroLane} result into the array-index offset of its lowest matching lane. */
private static int laneIndexOf(long masked) {
return NATIVE_IS_LITTLE
? Long.numberOfTrailingZeros(masked) >>> 3
: 7 - (Long.numberOfLeadingZeros(masked) >>> 3);
}
// ── Case-insensitive comparison ──────────────────────────────────────────
private static byte foldAsciiUpper(byte b) {
return (b >= 'A' && b <= 'Z') ? (byte) (b + 32) : b;
}
/** Case-insensitive (ASCII) equality of {@code buf[start, end)} against {@code target}. */
public static boolean equalsIgnoreCaseAscii(byte[] buf, int start, int end, String target) {
int len = end - start;
if (len != target.length()) return false;
for (int i = 0; i < len; i++) {
if (foldAsciiUpper(buf[start + i]) != foldAsciiUpper((byte) target.charAt(i))) return false;
}
return true;
}
/** Case-insensitive (ASCII) equality of two byte-array ranges. */
public static boolean equalsIgnoreCaseAscii(byte[] a, int aStart, int aLen, byte[] b, int bStart, int bLen) {
if (aLen != bLen) return false;
for (int i = 0; i < aLen; i++) {
if (foldAsciiUpper(a[aStart + i]) != foldAsciiUpper(b[bStart + i])) return false;
}
return true;
}
/** Case-insensitive (ASCII) equality of {@code view[start, end)} against {@code target}. */
public static boolean equalsIgnoreCase(ByteView view, int start, int end, String target) {
int len = end - start;
if (len != target.length()) return false;
for (int i = 0; i < len; i++) {
if (foldAsciiUpper(view.byteAt(start + i)) != foldAsciiUpper((byte) target.charAt(i))) return false;
}
return true;
}
// ── Comma-separated token lists (e.g. `Connection: keep-alive, Upgrade`) ────
/**
* Whether the comma-separated, OWS-tolerant token list {@code view} contains {@code token}
* (case-insensitive). The shared scanner behind both {@code Http1KeepAlive.isKeepAlive} and
* the {@code Connection: Upgrade} check ({@code EX-13}) — a single home so the two can never
* drift apart the way a whole-value {@code equals} check once did.
*/
public static boolean tokenListContains(ByteView view, String token) {
int len = view.length(), i = 0;
while (i < len) {
while (i < len && view.byteAt(i) == ' ') i++;
int start = i;
while (i < len && view.byteAt(i) != ',') i++;
if (tokenEqualsIgnoreCase(view, start, i, token)) return true;
i++;
}
return false;
}
/** Case-insensitive compare of {@code view[start, end)}, trimming trailing spaces, against {@code token}. */
public static boolean tokenEqualsIgnoreCase(ByteView view, int start, int end, String token) {
int wlen = end - start;
while (wlen > 0 && view.byteAt(start + wlen - 1) == ' ') wlen--;
return equalsIgnoreCase(view, start, start + wlen, token);
}
// ── Header-name hash (EX-09) ─────────────────────────────────────────────
/**
* Case-insensitive (ASCII fold) 32-bit FNV-1a hash of {@code buf[start, start + len)}. Used
* by {@link dev.relism.flash.models.HeaderMap}'s per-request index to compare a cheap hash
* before falling back to a full case-insensitive {@code memcmp}-equivalent
* ({@link #equalsIgnoreCaseAscii}) — two header names that differ anywhere hash differently
* with overwhelming probability, so the common "not the header I'm looking for" case resolves
* in one hash compare instead of a byte-by-byte scan.
*/
public static int hashNameIgnoreCaseAscii(byte[] buf, int start, int len) {
int hash = 0x811C9DC5; // FNV-1a 32-bit offset basis
for (int i = 0; i < len; i++) {
hash ^= (foldAsciiUpper(buf[start + i]) & 0xFF);
hash *= 0x01000193; // FNV-1a 32-bit prime
}
return hash;
}
/**
* Same hash as {@link #hashNameIgnoreCaseAscii(byte[], int, int)}, computed directly from a
* lookup-key {@code String} (e.g. {@code "Content-Type"}) instead of already-scanned bytes —
* the two must agree bit-for-bit on equivalent ASCII content for
* {@link dev.relism.flash.models.HeaderMap}'s index (hash the request-declared bytes once at
* {@code reset()}; hash the caller's lookup key once per {@code first()}/{@code all()} call;
* compare the two cheap hashes before ever touching a full case-insensitive comparison).
*/
public static int hashNameIgnoreCaseAscii(String name) {
int hash = 0x811C9DC5;
int len = name.length();
for (int i = 0; i < len; i++) {
hash ^= (foldAsciiUpper((byte) name.charAt(i)) & 0xFF);
hash *= 0x01000193;
}
return hash;
}
// ── Decimal / hex parsing ────────────────────────────────────────────────
/** Sentinel returned by {@link #parseDecimalStrict} on any malformed or out-of-range input. */
public static final long PARSE_INVALID = -1L;
/**
* Strict, overflow-safe unsigned decimal parse of {@code buf[start, end)}: rejects an empty
* range, any non-{@code '0'..'9'} byte, more than 19 digits, and arithmetic overflow past
* {@link Long#MAX_VALUE}. Returns {@link #PARSE_INVALID} rather than throwing — the same
* shape {@code RequestParser}'s own {@code Content-Length} parser already hand-rolls (kept
* separate there since it also needs to throw a specific, differently-worded
* {@code MalformedRequestException} per failure mode); this is the general-purpose version
* for callers (HPACK integer decoding, frame-length fields) that just need a valid/invalid
* signal.
*/
public static long parseDecimalStrict(byte[] buf, int start, int end) {
int len = end - start;
if (len == 0 || len > 19) return PARSE_INVALID;
long value = 0;
for (int i = start; i < end; i++) {
byte c = buf[i];
if (c < '0' || c > '9') return PARSE_INVALID;
int digit = c - '0';
if (value > (Long.MAX_VALUE - digit) / 10) return PARSE_INVALID;
value = value * 10 + digit;
}
return value;
}
/**
* Parses up to {@code maxDigits} hex digits (ASCII, either case) from {@code buf[start, end)}
* as an unsigned value. Returns {@link #PARSE_INVALID} if the range is empty, contains a
* non-hex-digit byte, or would need more than {@code maxDigits} digits to represent (the
* caller's bound against, e.g., a chunk-size line with an implausible number of digits).
*/
public static long parseHexStrict(byte[] buf, int start, int end, int maxDigits) {
int len = end - start;
if (len == 0 || len > maxDigits) return PARSE_INVALID;
long value = 0;
for (int i = start; i < end; i++) {
int digit = hexDigit(buf[i]);
if (digit < 0) return PARSE_INVALID;
value = (value << 4) | digit;
}
return value;
}
private static int hexDigit(byte b) {
if (b >= '0' && b <= '9') return b - '0';
if (b >= 'a' && b <= 'f') return b - 'a' + 10;
if (b >= 'A' && b <= 'F') return b - 'A' + 10;
return -1;
}
}
@@ -0,0 +1,154 @@
package dev.relism.flash.bytes;
import java.nio.charset.StandardCharsets;
/**
* Index-based writer into a growable {@code byte[]} scratch buffer. Every {@code write*} method
* bounds-checks and grows the backing array only when the write would not otherwise fit —
* on an already-warm buffer (the steady-state case: the buffer has already grown to the
* connection's high-water mark), no method here allocates.
*
* <p>This is the infrastructure {@code EX-27} (Phase 6, collapsing {@code Http1ResponseWriter}'s
* ~10 small writes into one) and the Phase 5 frame layer serialize into: build a complete
* message into a {@code ByteWriter}-backed scratch buffer, then issue one bulk
* {@code write(buffer, 0, length())} — the same "serialize outside the lock, one bulk write"
* discipline {@link dev.relism.flash.h2.frame.Http2FrameWriter} already established for the h2
* writer (see its Javadoc's "Layer 1"), extended to the byte layer both protocols share.
*
* <h3>Lifetime and thread-safety contract</h3>
* Not thread-safe — exactly one writer at a time, matching every other per-connection scratch
* object in this codebase ({@code ConnectionScratch}, {@code HeaderMap}). {@link #reset()}
* repositions this writer to the start of its backing array for the next message; the backing
* array itself is never shrunk back down, only grown — the same amortized-to-zero-allocation
* growth policy {@code RequestParser}'s read buffer already uses.
*/
public final class ByteWriter {
private byte[] buf;
private int len;
public ByteWriter(int initialCapacity) {
this.buf = new byte[Math.max(initialCapacity, 16)];
}
/** Repositions this writer to the start of its buffer, ready for the next message. */
public void reset() {
len = 0;
}
/** The backing buffer. Valid content is {@code [0, length())} — never assume {@code buf.length == length()}. */
public byte[] array() {
return buf;
}
/** How many bytes have been written since the last {@link #reset()}. */
public int length() {
return len;
}
private void ensure(int additional) {
int needed = len + additional;
if (needed <= buf.length) return;
int grown = buf.length * 2;
while (grown < needed) grown *= 2;
byte[] next = new byte[grown];
System.arraycopy(buf, 0, next, 0, len);
buf = next;
}
public void writeByte(byte b) {
ensure(1);
buf[len++] = b;
}
public void writeBytes(byte[] src) {
writeBytes(src, 0, src.length);
}
public void writeBytes(byte[] src, int off, int srcLen) {
ensure(srcLen);
System.arraycopy(src, off, buf, len, srcLen);
len += srcLen;
}
/**
* Writes {@code value}'s ASCII decimal digits (no sign — callers write {@code '-'} via
* {@link #writeByte} first if needed). {@code value} must be non-negative.
*/
public void writeDecimal(long value) {
if (value < 0) throw new IllegalArgumentException("writeDecimal requires a non-negative value: " + value);
if (value == 0) {
writeByte((byte) '0');
return;
}
// Digits emerge least-significant-first; stage them in a small fixed buffer (at most 20
// digits for any long) and copy in reverse — avoids a second pass to compute digit count.
byte[] digits = new byte[20];
int n = 0;
long v = value;
while (v > 0) {
digits[n++] = (byte) ('0' + (v % 10));
v /= 10;
}
ensure(n);
for (int i = n - 1; i >= 0; i--) buf[len++] = digits[i];
}
private static final byte[] HEX_DIGITS = "0123456789abcdef".getBytes(StandardCharsets.US_ASCII);
/** Writes {@code value}'s lowercase hex digits, no leading zeros (except for {@code value == 0}, which writes {@code "0"}). */
public void writeHex(int value) {
if (value == 0) {
writeByte((byte) '0');
return;
}
byte[] digits = new byte[8];
int n = 0;
int v = value;
while (v != 0) {
digits[n++] = HEX_DIGITS[v & 0xF];
v >>>= 4;
}
ensure(n);
for (int i = n - 1; i >= 0; i--) buf[len++] = digits[i];
}
/** Writes {@code s}'s ASCII bytes, lower-cased. {@code s} must be ASCII-only. */
public void writeAsciiLower(String s) {
int n = s.length();
ensure(n);
for (int i = 0; i < n; i++) {
char c = s.charAt(i);
if (c >= 'A' && c <= 'Z') c += 32;
buf[len++] = (byte) c;
}
}
/** Big-endian 16-bit write — an HTTP/2 frame's stream-dependent fields, SETTINGS values, etc. */
public void writeUInt16(int value) {
ensure(2);
buf[len++] = (byte) (value >>> 8);
buf[len++] = (byte) value;
}
/** Big-endian 24-bit write — an HTTP/2 frame header's length field. */
public void writeUInt24(int value) {
ensure(3);
buf[len++] = (byte) (value >>> 16);
buf[len++] = (byte) (value >>> 8);
buf[len++] = (byte) value;
}
/** Big-endian 31-bit write (top bit always 0) — an HTTP/2 stream identifier. */
public void writeUInt31(int value) {
writeUInt32(value & 0x7FFFFFFF);
}
/** Big-endian 32-bit write — an HTTP/2 window-size increment, SETTINGS value, etc. */
public void writeUInt32(int value) {
ensure(4);
buf[len++] = (byte) (value >>> 24);
buf[len++] = (byte) (value >>> 16);
buf[len++] = (byte) (value >>> 8);
buf[len++] = (byte) value;
}
}
@@ -0,0 +1,42 @@
package dev.relism.flash.bytes;
/**
* The allocation-free idiom for returning two {@code int}s from a method without an object:
* pack both into one {@code long}, unpack at the call site. Already used, hand-rolled, in four
* places ({@code HeaderMap.findFirst}, {@code QueryParams.findFirst}, and others) before this
* class existed — this is the single named home for the shifts so they are not duplicated (and
* potentially inconsistently duplicated — e.g. one copy masking with {@code 0xFFFFFFFFL} and
* another forgetting to) five times over.
*
* <h3>Why this works</h3>
* A {@code long} is 64 bits; each packed {@code int} is 32. {@link #pack} left-shifts the high
* half into the top 32 bits and OR's the low half into the bottom 32. {@link #lo} must mask with
* {@code 0xFFFFFFFFL} rather than simply cast to {@code int} after no mask, because a right-shift
* of a negative {@code long} sign-extends — the mask discards everything above bit 31 before the
* narrowing cast happens implicitly. {@link #hi} needs no mask: a right-shift by 32 already
* leaves only the original high bits in the low 32 positions of the result.
*
* <h3>Encoding convention used across this codebase</h3>
* Every {@code findFirst}-shaped method in this codebase packs {@code (start << 32) | length},
* i.e. {@code hi() == start} and {@code lo() == length}. {@code -1L} is the shared "not found"
* sentinel (a valid {@code (start, length)} pair can never be negative, since both halves are
* non-negative offsets/lengths).
*/
public final class Pairs {
private Pairs() {}
/** Packs two {@code int}s into one {@code long}: {@code hi} in the upper 32 bits, {@code lo} in the lower 32. */
public static long pack(int hi, int lo) {
return ((long) hi << 32) | (lo & 0xFFFFFFFFL);
}
/** Extracts the upper 32 bits packed by {@link #pack}. */
public static int hi(long packed) {
return (int) (packed >> 32);
}
/** Extracts the lower 32 bits packed by {@link #pack}. */
public static int lo(long packed) {
return (int) (packed & 0xFFFFFFFFL);
}
}
@@ -0,0 +1,53 @@
package dev.relism.flash.bytes;
/**
* A mutable, reusable {@link ArrayBackedByteView} — the {@code EX-05} fix. Replaces the
* per-call {@code new ByteView() { ... }} anonymous-class allocation that used to live in
* {@code HeaderMap.view}, {@code QueryParams.view}, and {@code PathParams.view}: instead of
* allocating a fresh view object (plus its capturing instance) on every call, a small
* {@link SlicePool} of these hands out an existing instance, repositioned in place.
*
* <h3>Lifetime contract</h3>
* A {@code PooledSlice} handed out by {@link SlicePool#acquire} is valid only until the pool
* wraps around and reuses the same slot — see {@link SlicePool}'s own Javadoc for the exact
* "valid until the Nth subsequent acquire, or end of request" rule the owning class (e.g.
* {@code HeaderMap}) documents precisely for its own {@code view()} method. Never retain a
* {@code PooledSlice} past that window, for the same reason the old anonymous view could not be
* retained past the handler: the bytes (and, here, additionally the slice object itself) are
* about to be repositioned out from under a stale reference.
*/
public final class PooledSlice implements ArrayBackedByteView {
private byte[] array;
private int offset;
private int length;
/** Repositions this slice over {@code array[offset, offset + length)}. Zero allocation. */
public void reset(byte[] array, int offset, int length) {
this.array = array;
this.offset = offset;
this.length = length;
}
@Override
public byte[] array() {
return array;
}
@Override
public int offset() {
return offset;
}
@Override
public int length() {
return length;
}
@Override
public byte byteAt(int index) {
if (index < 0 || index >= length) {
throw new IndexOutOfBoundsException("Index " + index + " out of bounds for length " + length);
}
return array[offset + index];
}
}
@@ -0,0 +1,81 @@
package dev.relism.flash.bytes;
import dev.relism.fpr.core.ByteView;
/**
* A {@link ByteView} over up to {@code K} discontiguous {@code byte[]} segments, presented as one
* logical byte sequence. Exists for the one case in this codebase where a "single contiguous
* slice of one buffer" model (every other {@link ByteView} implementation) does not hold: an
* HPACK header block whose encoding spans more than one {@code CONTINUATION} frame (RFC 9113
* §6.10), where each frame's payload lives in its own connection-buffer region.
*
* <h3>Deliberately not array-backed</h3>
* This does not implement {@link ArrayBackedByteView} — there is no single {@code (array,
* offset)} pair that describes it — and {@link #supportsLong()} returns {@code false}
* unconditionally rather than attempting a cross-segment 8-byte read ({@code EX-04}'s word-at-a-
* time path is only sound for a genuinely contiguous backing array; see
* {@code FastPathViews.MethodPathByteView} for the other deliberately-segmented view in this
* codebase, which makes the same choice for the same reason).
*
* <h3>Reusable, not allocated per block</h3>
* {@link #reset} repositions this view over a new set of segments without allocating — the same
* idiom {@link PooledSlice} uses for the contiguous case. The {@code segments}/{@code offsets}/
* {@code lengths} arrays passed to {@link #reset} are retained by reference, not copied; the
* caller owns their lifetime (typically the connection's HPACK scratch, sized to
* {@code Http2Limits.MAX_CONTINUATION_FRAMES_PER_BLOCK}).
*
* <h3>Cost model</h3>
* {@link #byteAt} walks the segment table to find which segment an index falls in — O(segments),
* not O(1) — because this view exists precisely for the rare, deliberately-bounded case
* (at most {@code MAX_CONTINUATION_FRAMES_PER_BLOCK} segments); optimizing it further would add
* complexity for a path that, by construction, is never hot.
*/
public final class SegmentedByteView implements ByteView {
private byte[][] segments;
private int[] offsets;
private int[] lengths;
private int count;
private int totalLength;
/**
* Repositions this view over {@code segments[0..count)}, where segment {@code i} contributes
* bytes {@code segments[i][offsets[i], offsets[i] + lengths[i])}. Zero allocation: the three
* arrays are retained by reference.
*/
public void reset(byte[][] segments, int[] offsets, int[] lengths, int count) {
this.segments = segments;
this.offsets = offsets;
this.lengths = lengths;
this.count = count;
int total = 0;
for (int i = 0; i < count; i++) total += lengths[i];
this.totalLength = total;
}
@Override
public int length() {
return totalLength;
}
@Override
public byte byteAt(int index) {
if (index < 0 || index >= totalLength) {
throw new IndexOutOfBoundsException("Index " + index + " out of bounds for length " + totalLength);
}
int remaining = index;
for (int i = 0; i < count; i++) {
int len = lengths[i];
if (remaining < len) {
return segments[i][offsets[i] + remaining];
}
remaining -= len;
}
throw new IndexOutOfBoundsException("Index " + index + " out of bounds for length " + totalLength);
}
/** Always {@code false} — see the class Javadoc for why a cross-segment word read is unsound. */
@Override
public boolean supportsLong() {
return false;
}
}
@@ -0,0 +1,52 @@
package dev.relism.flash.bytes;
/**
* A small, fixed-size ring of {@link PooledSlice} instances — one per {@code ConnectionScratch}-
* held call site that used to allocate a fresh {@code ByteView} per call ({@code EX-05}:
* {@code HeaderMap.view}, {@code QueryParams.view}, {@code PathParams.view}).
*
* <h3>Why a ring, not a single reused slice</h3>
* A single reused slice (the shape {@code HeaderMap.forEach} already uses for its two
* {@code nameSlice}/{@code valueSlice} fields) is correct only when the caller is guaranteed to
* finish with one slice before the next is produced — true for a single {@code forEach} callback
* invocation, false for {@code view()}: a handler might reasonably call
* {@code headers.view("A")} and {@code headers.view("B")} and want to compare both. A ring of
* {@code size} slices lets up to {@code size} calls' results stay simultaneously valid.
*
* <h3>Lifetime contract</h3>
* A slice returned by {@link #acquire} is valid until either the request ends, or {@link #acquire}
* is called {@code size} more times on the same pool (at which point the ring has wrapped around
* and repositioned that same slot for a new caller) — whichever comes first. This must be
* restated precisely on every method that hands out a slice from a pool (see
* {@code HeaderMap.view}'s Javadoc for the canonical wording); it is a real, testable hazard, not
* a hypothetical one — see {@code SlicePoolTest#wraparoundAliasesThePreviouslyReturnedSlice} for
* a demonstration.
*/
public final class SlicePool {
private final PooledSlice[] slices;
private int next = 0;
/** A ring of {@code size} reusable slices. {@code size} must be at least 1. */
public SlicePool(int size) {
if (size < 1) throw new IllegalArgumentException("SlicePool size must be at least 1: " + size);
slices = new PooledSlice[size];
for (int i = 0; i < size; i++) slices[i] = new PooledSlice();
}
/** How many slices this pool cycles through before a caller's slice is reused. */
public int size() {
return slices.length;
}
/**
* Returns the next slice in the ring, repositioned over {@code array[offset, offset + length)}.
* Zero allocation — the returned instance already existed.
*/
public PooledSlice acquire(byte[] array, int offset, int length) {
PooledSlice slice = slices[next];
next++;
if (next == slices.length) next = 0;
slice.reset(array, offset, length);
return slice;
}
}
@@ -39,6 +39,11 @@ public final class Http1Connection implements ConnectionProtocol {
OutputStream out = ctx.out();
byte[] idleProbe = new byte[1];
// EX-06 (router half): created once per connection, exactly like `parser` above, and
// reused across every request on this connection — see AbstractRouter#newScratch.
Object routeScratch = ctx.router().newScratch();
Object wsRouteScratch = ctx.wsRouter().newScratch();
while (!ctx.stopped().getAsBoolean()) {
// EX-07: wait for the next request to begin, bounded by the generous
// idle-keep-alive timeout — sitting idle between keep-alive requests is normal, not
@@ -77,7 +82,7 @@ public final class Http1Connection implements ConnectionProtocol {
if (request.method() == HttpMethod.GET && WebSocketUpgrade.isWebSocketUpgrade(request)) {
in.clearDeadline(); // the WS session loop is long-lived; it paces itself
WebSocketHandler wsHandler = ctx.wsRouter().route(request);
WebSocketHandler wsHandler = ctx.wsRouter().route(request, wsRouteScratch);
if (wsHandler == null) {
out.write(WebSocketUpgrade.REJECT_400);
out.flush();
@@ -103,7 +108,7 @@ public final class Http1Connection implements ConnectionProtocol {
boolean keepAlive = Http1KeepAlive.isKeepAlive(request);
Response response = new Response(200, ContentType.TEXT_PLAIN);
RequestHandler handler = ctx.router().route(request);
RequestHandler handler = ctx.router().route(request, routeScratch);
if (handler == null) handler = ctx.router().getNotFoundHandler();
try {
@@ -1,10 +1,14 @@
package dev.relism.flash.models;
import dev.relism.flash.bytes.ByteScan;
import dev.relism.flash.bytes.SlicePool;
import dev.relism.flash.http.Http1Limits;
import dev.relism.fpr.core.ByteView;
import lombok.NoArgsConstructor;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
@@ -23,31 +27,99 @@ import java.util.List;
* values retrieved via {@link #first}/{@link #all} are safe (they are independent
* heap copies); the {@code HeaderMap} object itself is not.</li>
* <li><b>{@link #view} returns a zero-copy {@link dev.relism.fpr.core.ByteView} slice
* into the live buffer.</b> Storing this view and reading it after the handler
* returns (e.g. in an async callback, a {@link java.util.concurrent.CompletableFuture}
* into the live buffer, drawn from a small {@link SlicePool} (see {@link #view}'s own
* Javadoc for the exact reuse window).</b> Storing this view and reading it after the
* handler returns (e.g. in an async callback, a {@link java.util.concurrent.CompletableFuture}
* continuation, or a virtual-thread handoff) is a <em>data race</em> — the bytes
* may have been overwritten by the next request. Copy to a {@code String} or
* {@code byte[]} before leaving the synchronous handler scope.</li>
* </ol>
*
* <h3>{@code EX-09}: an index built once per {@link #reset}, not rescanned per lookup</h3>
* {@link #reset} scans the header section exactly once and records, per header, its name/value
* byte offsets and a case-insensitive 32-bit hash of the name — into {@code int[]} arrays grown
* (never shrunk) to this connection's high-water mark. Every lookup method
* ({@link #first}, {@link #all}, {@link #view}, {@link #valueEqualsIgnoreCase}) then walks that
* small index instead of rescanning raw bytes: a hash compare (cheap) before ever falling back to
* a full case-insensitive name comparison. A realistic middleware chain performs 610 lookups per
* request (OIDC reads {@code Authorization}/{@code Cookie}, the limiter reads
* {@code X-Forwarded-For}, CORS reads {@code Origin}, keep-alive reads {@code Connection}); before
* this, each of those rescanned the entire header block from scratch — O(n·m). Now the header
* section is scanned once regardless of how many lookups follow — strictly less total work even
* for a single lookup, and asymptotically better for the realistic multi-lookup case.
*/
@NoArgsConstructor
public class HeaderMap {
private static final int INITIAL_INDEX_CAPACITY = 16;
private static final int VIEW_POOL_SIZE = 4;
private byte[] buffer;
private int sectionStart;
private int sectionEnd;
// Lazily created, then reused for the life of this HeaderMap (i.e. the connection —
// see the class javadoc) across every #forEach call and every header within a call.
// Same idiom as #view's per-call anonymous ByteView, just amortized to zero allocations
// instead of two per header: the slices are repositioned in place, not reallocated.
// EX-09 index — grown (never shrunk) to this connection's high-water mark, rebuilt in place
// by every reset() call. Entry i's name is buffer[nameOffsets[i], nameOffsets[i]+nameLengths[i]),
// its value is buffer[valueOffsets[i], valueOffsets[i]+valueLengths[i]).
private int headerCount;
private int[] nameOffsets = new int[INITIAL_INDEX_CAPACITY];
private int[] nameLengths = new int[INITIAL_INDEX_CAPACITY];
private int[] valueOffsets = new int[INITIAL_INDEX_CAPACITY];
private int[] valueLengths = new int[INITIAL_INDEX_CAPACITY];
private int[] nameHashes = new int[INITIAL_INDEX_CAPACITY];
// EX-05: pooled, reused slices for view() — see its own Javadoc for the reuse window.
private final SlicePool viewPool = new SlicePool(VIEW_POOL_SIZE);
// forEach's own pair, reused across every header of every call — same idiom as viewPool,
// just a fixed pair rather than a ring, since forEach's contract only needs one name/value
// pair valid at a time (see forEach's Javadoc).
private Slice nameSlice;
private Slice valueSlice;
/** Resets this map to the header section {@code buffer[sectionStart, sectionEnd)}. */
/** Resets this map to the header section {@code buffer[sectionStart, sectionEnd)}, rebuilding the {@code EX-09} index. */
public void reset(byte[] buffer, int sectionStart, int sectionEnd) {
this.buffer = buffer;
this.sectionStart = sectionStart;
this.sectionEnd = sectionEnd;
buildIndex();
}
private void buildIndex() {
headerCount = 0;
if (buffer == null) return;
int i = sectionStart;
while (i < sectionEnd) {
int lineEnd = findCR(i);
int colon = findColon(i, lineEnd);
if (colon != -1) {
int vs = skipSpaces(colon + 1, lineEnd);
ensureIndexCapacity(headerCount + 1);
nameOffsets[headerCount] = i;
nameLengths[headerCount] = colon - i;
valueOffsets[headerCount] = vs;
valueLengths[headerCount] = lineEnd - vs;
nameHashes[headerCount] = ByteScan.hashNameIgnoreCaseAscii(buffer, i, colon - i);
headerCount++;
}
i = lineEnd + 2;
}
}
private void ensureIndexCapacity(int needed) {
if (needed <= nameOffsets.length) return;
// EX-08 (Http1Limits.MAX_HEADER_COUNT) already rejects any request with more headers
// than this before it ever reaches reset() — this can only fire while growing toward
// that ceiling, never past it. Asserted, not silently truncated: an index that silently
// dropped headers past this point would be a correctness bug, not a capacity one.
assert needed <= Http1Limits.MAX_HEADER_COUNT
: "header count " + needed + " exceeds Http1Limits.MAX_HEADER_COUNT — RequestParser should have rejected this already";
int grown = nameOffsets.length;
while (grown < needed) grown *= 2;
nameOffsets = Arrays.copyOf(nameOffsets, grown);
nameLengths = Arrays.copyOf(nameLengths, grown);
valueOffsets = Arrays.copyOf(valueOffsets, grown);
valueLengths = Arrays.copyOf(valueLengths, grown);
nameHashes = Arrays.copyOf(nameHashes, grown);
}
/**
@@ -69,19 +141,12 @@ public class HeaderMap {
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;
for (int i = 0; i < headerCount; i++) {
nameSlice.start = nameOffsets[i];
nameSlice.len = nameLengths[i];
valueSlice.start = valueOffsets[i];
valueSlice.len = valueLengths[i];
consumer.accept(nameSlice, valueSlice);
}
}
@@ -108,26 +173,21 @@ public class HeaderMap {
/** Returns the first value of header {@code name} (case-insensitive), or {@code null}. */
public String first(String name) {
long r = findFirst(name);
if (r < 0) return null;
int s = (int) (r >> 32), l = (int) (r & 0xFFFFFFFFL);
return new String(buffer, s, l, StandardCharsets.UTF_8);
int i = indexOfHeader(name);
if (i < 0) return null;
return new String(buffer, valueOffsets[i], valueLengths[i], StandardCharsets.UTF_8);
}
/** Returns all values of header {@code name} in declaration order, or an empty list. */
public List<String> all(String name) {
if (buffer == null) return List.of();
List<String> result = null;
int i = sectionStart;
while (i < sectionEnd) {
int lineEnd = findCR(i);
int colon = findColon(i, lineEnd);
if (colon != -1 && keyMatches(i, colon - i, name)) {
int vs = skipSpaces(colon + 1, lineEnd);
int hash = ByteScan.hashNameIgnoreCaseAscii(name);
for (int i = 0; i < headerCount; i++) {
if (nameHashes[i] == hash && ByteScan.equalsIgnoreCaseAscii(buffer, nameOffsets[i], nameOffsets[i] + nameLengths[i], name)) {
if (result == null) result = new ArrayList<>();
result.add(new String(buffer, vs, lineEnd - vs, StandardCharsets.UTF_8));
result.add(new String(buffer, valueOffsets[i], valueLengths[i], StandardCharsets.UTF_8));
}
i = lineEnd + 2;
}
return result != null ? result : List.of();
}
@@ -135,61 +195,47 @@ public class HeaderMap {
/** Returns all header values in declaration order. */
public List<String> all() {
if (buffer == null) return List.of();
List<String> result = new ArrayList<>();
int i = sectionStart;
while (i < sectionEnd) {
int lineEnd = findCR(i);
int colon = findColon(i, lineEnd);
if (colon != -1) {
int vs = skipSpaces(colon + 1, lineEnd);
result.add(new String(buffer, vs, lineEnd - vs, StandardCharsets.UTF_8));
}
i = lineEnd + 2;
List<String> result = new ArrayList<>(headerCount);
for (int i = 0; i < headerCount; i++) {
result.add(new String(buffer, valueOffsets[i], valueLengths[i], StandardCharsets.UTF_8));
}
return result;
}
/** Case-insensitive comparison of the first value of {@code name} against {@code value}. */
public boolean valueEqualsIgnoreCase(String name, String value) {
long r = findFirst(name);
if (r < 0) return false;
int vs = (int) (r >> 32), vl = (int) (r & 0xFFFFFFFFL);
if (vl != value.length()) return false;
for (int i = 0; i < vl; i++) {
byte b = buffer[vs + i];
if (b >= 'A' && b <= 'Z') b += 32;
char c = value.charAt(i);
if (c >= 'A' && c <= 'Z') c += 32;
if (b != (byte) c) return false;
}
return true;
int i = indexOfHeader(name);
if (i < 0) return false;
return ByteScan.equalsIgnoreCaseAscii(buffer, valueOffsets[i], valueOffsets[i] + valueLengths[i], value);
}
/** Returns a zero-copy {@link ByteView} over the first value of {@code name}, or {@code null}. */
/**
* Returns a zero-copy {@link ByteView} over the first value of {@code name}, or {@code null}.
*
* <h3>{@code EX-05}: pooled, not allocated per call</h3>
* The returned view is drawn from a small internal {@link SlicePool} rather than allocated
* fresh. It stays valid until either the request ends, or {@link #view} is called
* {@value #VIEW_POOL_SIZE} more times on this same {@code HeaderMap} — whichever comes
* first — at which point the ring wraps around and silently repositions the same instance
* over different bytes. A handler that needs more than {@value #VIEW_POOL_SIZE} views alive
* at once should copy the earlier ones to {@code String}/{@code byte[]} before requesting more.
*/
public ByteView view(String name) {
long r = findFirst(name);
if (r < 0) return null;
final int s = (int) (r >> 32), l = (int) (r & 0xFFFFFFFFL);
return new ByteView() {
public int length() { return l; }
public byte byteAt(int i) { return buffer[s + i]; }
};
int i = indexOfHeader(name);
if (i < 0) return null;
return viewPool.acquire(buffer, valueOffsets[i], valueLengths[i]);
}
/** Returns {@code (valStart << 32) | valLen}, or {@code -1} if not found. */
private long findFirst(String name) {
if (buffer == null) return -1L;
int i = sectionStart;
while (i < sectionEnd) {
int lineEnd = findCR(i);
int colon = findColon(i, lineEnd);
if (colon != -1 && keyMatches(i, colon - i, name)) {
int vs = skipSpaces(colon + 1, lineEnd);
return ((long) vs << 32) | (lineEnd - vs);
/** Index into the {@code EX-09} arrays of the first header named {@code name}, or {@code -1}. */
private int indexOfHeader(String name) {
if (buffer == null) return -1;
int hash = ByteScan.hashNameIgnoreCaseAscii(name);
for (int i = 0; i < headerCount; i++) {
if (nameHashes[i] == hash && ByteScan.equalsIgnoreCaseAscii(buffer, nameOffsets[i], nameOffsets[i] + nameLengths[i], name)) {
return i;
}
i = lineEnd + 2;
}
return -1L;
return -1;
}
private int findCR(int from) {
@@ -206,16 +252,4 @@ public class HeaderMap {
while (from < end && buffer[from] == ' ') from++;
return from;
}
private boolean keyMatches(int start, int len, String name) {
if (len != name.length()) return false;
for (int i = 0; i < len; i++) {
byte b = buffer[start + i];
if (b >= 'A' && b <= 'Z') b += 32;
char c = name.charAt(i);
if (c >= 'A' && c <= 'Z') c += 32;
if (b != (byte) c) return false;
}
return true;
}
}
@@ -1,5 +1,7 @@
package dev.relism.flash.models;
import dev.relism.flash.bytes.ArrayBackedByteView;
import dev.relism.flash.bytes.SlicePool;
import dev.relism.flash.routing.AbstractRouter;
import dev.relism.fpr.core.ByteView;
@@ -7,19 +9,67 @@ import java.nio.charset.StandardCharsets;
/**
* Path parameters captured during routing, stored as byte offsets into the path view.
* {@link #get} allocates a String on call; {@link #view} is zero-copy.
* {@link #get} allocates a {@code String} on call (in one allocation when {@link #source} is
* {@link ArrayBackedByteView} — {@code EX-25} — two otherwise); {@link #view} is zero-copy.
*
* <h3>Reusable instances ({@code EX-19})</h3>
* The public constructor below builds a one-shot, fixed-size instance (used by
* {@code AbstractWsRouter} and by tests) — {@code names.length} is taken as the exact param
* count. {@code FastPathRouterImpl}'s per-connection scratch instead owns a single long-lived
* {@code PathParams} whose backing arrays are grown to the connection's high-water mark and
* never reallocated after warmup; because those arrays can be larger than the current request's
* actual param count, that path uses {@link #reset}, which — unlike the constructor — takes the
* live count explicitly rather than inferring it from array length. Both this constructor and
* {@link #reset} are {@code public} rather than package-private (matching
* {@link HeaderMap#reset}'s own precedent for a reusable buffer-backed object): the router
* implementation that owns the reusable instance lives in a different package
* ({@code dev.relism.flash.routing.routers.fastpathrouter}), and {@code PathParams.inject}'s
* own doc explains why this codebase prefers a small public surface here over a cross-package
* friend-access workaround. A {@code PathParams} obtained this way has the same "do not retain
* past the handler" lifetime contract as {@link HeaderMap}'s buffer-backed views: the next
* request on the same connection repositions the same arrays.
*/
public class PathParams {
private final ByteView source;
private static final int VIEW_POOL_SIZE = 4;
private ByteView source;
private final String[] names;
private final int[] starts;
private final int[] lens;
private int count;
// EX-05: created lazily, only if view() is ever actually called.
private SlicePool viewPool;
public PathParams(ByteView source, String[] names, int[] starts, int[] lens) {
this.source = source;
this.names = names;
this.starts = starts;
this.lens = lens;
this.count = names.length;
}
/**
* Builds an instance meant only for {@link #reset}: no source yet, and {@code count} starts
* at 0 until the first {@link #reset} call. {@code names}/{@code starts}/{@code lens} may be
* larger than any single request's param count — see the class Javadoc.
*/
public PathParams(String[] names, int[] starts, int[] lens) {
this.source = null;
this.names = names;
this.starts = starts;
this.lens = lens;
this.count = 0;
}
/**
* Repositions this instance over a new request: {@code count} (which may be less than
* {@code names.length} — see the class Javadoc) params are now valid, read out of the same
* backing arrays the constructor was given, against the new {@code source}. Zero allocation.
*/
public void reset(ByteView source, int count) {
this.source = source;
this.count = count;
}
/**
@@ -34,23 +84,41 @@ public class PathParams {
public String get(String name) {
int i = indexOf(name);
if (i < 0) return null;
byte[] bytes = new byte[lens[i]];
for (int j = 0; j < lens[i]; j++) bytes[j] = source.byteAt(starts[i] + j);
int start = starts[i], len = lens[i];
// EX-25: a single-copy String construction when the source is a contiguous array slice
// (always true for h1 today) instead of a byte-at-a-time copy into a scratch array
// followed by a second allocation for the String itself.
if (source instanceof ArrayBackedByteView abv) {
return new String(abv.array(), abv.offset() + start, len, StandardCharsets.UTF_8);
}
byte[] bytes = new byte[len];
for (int j = 0; j < len; j++) bytes[j] = source.byteAt(start + j);
return new String(bytes, StandardCharsets.UTF_8);
}
/**
* Returns a zero-copy view over path param {@code name}, or {@code null}. {@code EX-05}:
* drawn from a small internal {@link SlicePool} when {@link #source} is array-backed (always
* true for h1 today) — same reuse-window contract as {@link HeaderMap#view}. Falls back to a
* fresh (allocating) view otherwise — never exercised on the real request path.
*/
ByteView view(String name) {
int i = indexOf(name);
if (i < 0) return null;
final int s = starts[i], l = lens[i];
int s = starts[i], l = lens[i];
if (source instanceof ArrayBackedByteView abv) {
if (viewPool == null) viewPool = new SlicePool(VIEW_POOL_SIZE);
return viewPool.acquire(abv.array(), abv.offset() + s, l);
}
final int fs = s, fl = l;
return new ByteView() {
public int length() { return l; }
public byte byteAt(int idx) { return source.byteAt(s + idx); }
public int length() { return fl; }
public byte byteAt(int idx) { return source.byteAt(fs + idx); }
};
}
private int indexOf(String name) {
for (int i = 0; i < names.length; i++) if (names[i].equals(name)) return i;
for (int i = 0; i < count; i++) if (names[i].equals(name)) return i;
return -1;
}
}
@@ -1,5 +1,8 @@
package dev.relism.flash.models;
import dev.relism.flash.bytes.ArrayBackedByteView;
import dev.relism.flash.bytes.Pairs;
import dev.relism.flash.bytes.SlicePool;
import dev.relism.fpr.core.ByteView;
import java.nio.charset.StandardCharsets;
@@ -15,9 +18,16 @@ import java.util.List;
*/
public class QueryParams {
public static final QueryParams EMPTY = new QueryParams(null);
private static final int VIEW_POOL_SIZE = 4;
private final ByteView raw;
// EX-05: created lazily, only if view() is ever actually called — QueryParams itself is
// recreated per request (see Request#resolveQueryParams), so an eagerly-constructed pool
// would cost VIEW_POOL_SIZE allocations on every request that touches query params at all,
// even the (currently: every) request that never calls view().
private SlicePool viewPool;
public QueryParams(ByteView raw) {
this.raw = raw;
}
@@ -25,16 +35,30 @@ public class QueryParams {
public String get(String name) {
long r = findFirst(name);
if (r < 0) return null;
return decode((int) (r >> 32), (int) (r & 0xFFFFFFFFL));
return decode(Pairs.hi(r), Pairs.lo(r));
}
/**
* Returns a view over the first raw (not percent-decoded) value of {@code name}, or
* {@code null}. {@code EX-05}: drawn from a small internal {@link SlicePool} when
* {@link #raw} is array-backed (always true for h1 today) instead of allocated per call —
* same reuse-window contract as {@link HeaderMap#view}: valid until either the request ends
* or {@link #view} is called {@value #VIEW_POOL_SIZE} more times on this instance, whichever
* comes first. Falls back to a fresh (allocating) view when {@link #raw} is not array-backed
* — never exercised on the real request path (see {@link ArrayBackedByteView}'s Javadoc).
*/
ByteView view(String name) {
long r = findFirst(name);
if (r < 0) return null;
final int s = (int) (r >> 32), l = (int) (r & 0xFFFFFFFFL);
int s = Pairs.hi(r), l = Pairs.lo(r);
if (raw instanceof ArrayBackedByteView abv) {
if (viewPool == null) viewPool = new SlicePool(VIEW_POOL_SIZE);
return viewPool.acquire(abv.array(), abv.offset() + s, l);
}
final int fs = s, fl = l;
return new ByteView() {
public int length() { return l; }
public byte byteAt(int idx) { return raw.byteAt(s + idx); }
public int length() { return fl; }
public byte byteAt(int idx) { return raw.byteAt(fs + idx); }
};
}
@@ -62,7 +86,7 @@ public class QueryParams {
// ── Internals ─────────────────────────────────────────────────────────────
/** Returns (valStart << 32) | valLen, or -1 if not found. */
/** Returns {@code (valStart << 32) | valLen}, or {@code -1} if not found. */
private long findFirst(String name) {
if (raw == null) return -1L;
int i = 0, len = raw.length();
@@ -74,7 +98,7 @@ public class QueryParams {
i++;
int valStart = i;
while (i < len && raw.byteAt(i) != '&') i++;
if (keyMatches(keyStart, keyLen, name)) return ((long) valStart << 32) | (i - valStart);
if (keyMatches(keyStart, keyLen, name)) return Pairs.pack(valStart, i - valStart);
}
if (i < len && raw.byteAt(i) == '&') i++;
}
@@ -91,8 +115,27 @@ public class QueryParams {
* Percent-decodes a value slice from {@code raw} into a UTF-8 String.
* {@code %XX} triplets are decoded to their byte values; {@code +} decodes as space.
* Invalid {@code %} sequences are passed through as-is.
*
* <p>{@code EX-26}: the overwhelmingly common query value contains neither {@code %} nor
* {@code +} — scanned for first; when clean and {@link #raw} is array-backed, the
* {@code String} is built directly from the backing array in one allocation, skipping the
* scratch {@code byte[]} copy this method used to make unconditionally for every value.
*/
private String decode(int start, int length) {
boolean clean = true;
for (int i = 0; i < length; i++) {
byte b = raw.byteAt(start + i);
if (b == '%' || b == '+') { clean = false; break; }
}
if (clean) {
if (raw instanceof ArrayBackedByteView abv) {
return new String(abv.array(), abv.offset() + start, length, StandardCharsets.UTF_8);
}
byte[] out = new byte[length];
for (int i = 0; i < length; i++) out[i] = raw.byteAt(start + i);
return new String(out, StandardCharsets.UTF_8);
}
byte[] out = new byte[length]; // upper bound — decoded is never longer
int w = 0;
for (int i = 0; i < length; i++) {
@@ -1,6 +1,7 @@
package dev.relism.flash.models;
import dev.relism.flash.RequestParser;
import dev.relism.flash.bytes.ArrayBackedByteView;
import dev.relism.fpr.core.ByteView;
import dev.relism.flash.http.HttpMethod;
import lombok.EqualsAndHashCode;
@@ -123,6 +124,12 @@ public class Request {
public String path() {
if (cachedPath != null) return cachedPath;
ByteView v = requestLine.getPath();
// EX-25: one allocation via a direct String(array, offset, length) construction when the
// view is a contiguous array slice (always true for h1 today), instead of a byte-at-a-time
// copy into a scratch array followed by a second allocation for the String itself.
if (v instanceof ArrayBackedByteView abv) {
return cachedPath = new String(abv.array(), abv.offset(), v.length(), StandardCharsets.UTF_8);
}
byte[] buf = new byte[v.length()];
for (int i = 0; i < v.length(); i++) buf[i] = v.byteAt(i);
return cachedPath = new String(buf, StandardCharsets.UTF_8);
@@ -6,7 +6,6 @@ import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl;
import dev.relism.flash.Flash;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
import dev.relism.fpr.core.ByteView;
import dev.relism.flash.template.ErrorPages;
import java.nio.charset.StandardCharsets;
@@ -101,14 +100,33 @@ public abstract class AbstractRouter {
// ── Routing ──────────────────────────────────────────────────────────────
public abstract RequestHandler route(Request request);
/**
* Creates a fresh per-connection scratch object for {@link #route}, or {@code null} if this
* router implementation keeps no reusable per-connection state. Called once per connection
* by the connection driver (e.g. {@code Http1Connection}), which holds the opaque result and
* passes it back into every {@link #route} call for that connection's whole lifetime — the
* same "create once per connection, reuse across requests" shape already used there for
* {@code RequestParser}.
*
* <p>{@code EX-06}'s router-half fix: a {@code ThreadLocal} here would mean "one per virtual
* thread", which under this codebase's one-virtual-thread-per-connection model is "one per
* connection with no upper bound and no pooling" — exactly the failure mode
* {@code ConnectionScratch} already exists to avoid for every other per-connection buffer.
* An explicit, caller-owned scratch object achieves the same per-connection reuse without
* that unbounded-growth risk, and without requiring {@code routing} to depend on
* {@code transport}'s {@code ConnectionScratch} type (this package has no such dependency
* today — see {@code DECISIONS.md}, {@code DEC-19}, for why that boundary was kept rather
* than extending {@code ConnectionScratch} itself, which is what an earlier draft of this
* fix assumed).
*/
public Object newScratch() {
return null;
}
public abstract RequestHandler route(Request request, Object scratch);
protected abstract AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler);
protected static void setPathParams(Request request, String[] names, ByteView source, int[] starts, int[] lens) {
PathParams.inject(request, new PathParams(source, names, starts, lens));
}
@FunctionalInterface
public interface ExceptionHandler {
Object handle(Exception exception, Request request, Response response);
@@ -15,7 +15,17 @@ public abstract class AbstractWsRouter {
return addRoute(method, PathUtils.sanitize(path), handler);
}
public abstract WebSocketHandler route(Request request);
/**
* Creates a fresh per-connection scratch object for {@link #route}, or {@code null} if this
* router keeps no reusable per-connection state — see {@link AbstractRouter#newScratch} for
* the full rationale ({@code EX-06}'s router-half fix), mirrored here for the WebSocket
* router.
*/
public Object newScratch() {
return null;
}
public abstract WebSocketHandler route(Request request, Object scratch);
protected abstract AbstractWsRouter addRoute(HttpMethod method, String path, WebSocketHandler handler);
@@ -6,15 +6,21 @@ import dev.relism.fpr.core.MatchResult;
import dev.relism.fpr.core.RouterBuilder;
import dev.relism.fpr.core.dsl.StringRouteParser;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.PathParams;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.routing.AbstractRouter;
import java.util.Arrays;
/**
* Router backed by the {@code fpr-core} byte-level state machine. Routes are compiled lazily
* on the first request and recompiled when routes are added after startup. Matching runs on a
* virtual {@code METHOD + path} byte sequence in a single pass; {@link MatchResult} and
* {@link FastPathViews.MethodPathByteView} are reused per-thread to avoid hot-path allocations.
* virtual {@code METHOD + path} byte sequence in a single pass; the per-connection
* {@link RouteScratch} ({@link #newScratch}) owns the reused {@link MatchResult},
* {@link FastPathViews.MethodPathByteView} and path-param arrays that would otherwise allocate
* (or, before {@code EX-06}'s router-half fix, sit in an unbounded {@code ThreadLocal}) on every
* request.
*/
public class FastPathRouterImpl extends AbstractRouter {
private final RouterBuilder<RequestHandler> builder = new RouterBuilder<>();
@@ -23,21 +29,49 @@ public class FastPathRouterImpl extends AbstractRouter {
public FastPathRouterImpl() {}
private static final class FastPathRouterContext {
private static final ThreadLocal<MatchResult<RequestHandler>> RESULT_HOLDER =
ThreadLocal.withInitial(() -> new MatchResult<>(32, 128));
private static final ThreadLocal<FastPathViews.MethodPathByteView> COMBINED_VIEW_HOLDER =
ThreadLocal.withInitial(FastPathViews.MethodPathByteView::new);
/**
* Per-connection reusable matching state — {@code EX-06}'s router half and {@code EX-19}
* together. Created once per connection by {@link #newScratch} and threaded back into every
* {@link #route} call for that connection's lifetime (see {@link AbstractRouter#newScratch}
* for why this replaced the two {@code ThreadLocal}s this class used to hold).
*
* <p>{@code paramNames}/{@code paramStarts}/{@code paramLens} ({@code EX-19}) start small and
* grow (doubling, via {@link #ensureParamCapacity}) to the connection's high-water mark —
* the number of path params the most param-heavy route matched on this connection ever
* needed — and are never shrunk back down or reallocated once warm, the same amortized policy
* {@code RequestParser}'s read buffer already uses. {@code pathParams} is the single
* {@link PathParams} instance repositioned (via {@link PathParams#reset}) over those arrays
* every time a match has params, instead of a fresh {@code PathParams} per request.
*/
static final class RouteScratch {
final MatchResult<RequestHandler> matchResult = new MatchResult<>(32, 128);
final FastPathViews.MethodPathByteView combinedView = new FastPathViews.MethodPathByteView();
public static MatchResult<RequestHandler> getResult() {
return RESULT_HOLDER.get();
}
String[] paramNames = new String[8];
int[] paramStarts = new int[8];
int[] paramLens = new int[8];
PathParams pathParams = new PathParams(paramNames, paramStarts, paramLens);
public static FastPathViews.MethodPathByteView getCombinedView() {
return COMBINED_VIEW_HOLDER.get();
void ensureParamCapacity(int count) {
if (count <= paramNames.length) return;
int grown = paramNames.length;
while (grown < count) grown *= 2;
paramNames = Arrays.copyOf(paramNames, grown);
paramStarts = Arrays.copyOf(paramStarts, grown);
paramLens = Arrays.copyOf(paramLens, grown);
// The arrays PathParams reads are now different instances — rebuild it. This is the
// only case in which a RouteScratch allocates past connection setup, and only on a
// connection whose route mix keeps needing more params than ever seen before; it
// never happens again once this connection's high-water mark stabilizes.
pathParams = new PathParams(paramNames, paramStarts, paramLens);
}
}
@Override
public Object newScratch() {
return new RouteScratch();
}
@Override
protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) {
builder.add(StringRouteParser.parse(method.name() + path), handler);
@@ -46,15 +80,16 @@ public class FastPathRouterImpl extends AbstractRouter {
}
@Override
public RequestHandler route(Request request) {
public RequestHandler route(Request request, Object scratchObj) {
ensureCompiled();
RouteScratch scratch = (RouteScratch) scratchObj;
MatchResult<RequestHandler> result = FastPathRouterContext.getResult();
MatchResult<RequestHandler> result = scratch.matchResult;
result.reset();
HttpMethod method = request.getRequestLine().getMethod();
ByteView pathView = request.getRequestLine().getPath();
FastPathViews.MethodPathByteView combinedView = FastPathRouterContext.getCombinedView();
FastPathViews.MethodPathByteView combinedView = scratch.combinedView;
combinedView.reset(method.getBytes(), pathView);
int labelId = router.match(combinedView, result);
@@ -65,18 +100,20 @@ public class FastPathRouterImpl extends AbstractRouter {
int count = result.paramCount();
if (count > 0) {
int methodLen = method.getBytes().length;
String[] all = cachedParamNames;
String[] names = new String[count];
int[] starts = new int[count];
int[] lens = new int[count];
scratch.ensureParamCapacity(count);
int methodLen = method.getBytes().length;
String[] all = cachedParamNames;
String[] names = scratch.paramNames;
int[] starts = scratch.paramStarts;
int[] lens = scratch.paramLens;
for (int i = 0; i < count; i++) {
names[i] = all[result.keyIdAt(i)];
starts[i] = result.startAt(i) - methodLen;
lens[i] = result.lenAt(i);
}
setPathParams(request, names, pathView, starts, lens);
scratch.pathParams.reset(pathView, count);
PathParams.inject(request, scratch.pathParams);
}
return result.handler();
@@ -1,15 +1,47 @@
package dev.relism.flash.routing.routers.fastpathrouter;
import dev.relism.flash.bytes.ArrayBackedByteView;
import dev.relism.fpr.core.ByteView;
import lombok.NoArgsConstructor;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
/** {@link dev.relism.fpr.core.ByteView} implementations used on the router and parser hot paths. */
@NoArgsConstructor
public final class FastPathViews {
public static final class RequestByteView implements ByteView {
/**
* {@code EX-04}: {@code fpr-core}'s decompiled {@code ByteCompare} (its word-at-a-time
* router-matching fast path — see {@code ByteCompare.equals}/{@code indexOf}) reads a
* comparison word via {@code MethodHandles.byteArrayViewVarHandle(long[].class,
* ByteOrder.LITTLE_ENDIAN)} and compares it bit-for-bit against whatever
* {@link ByteView#longAt} returns. For that comparison to be correct, {@code longAt} must
* therefore return the <em>identical</em> little-endian-assembled value for the same 8
* bytes — fixed to {@code LITTLE_ENDIAN} specifically (not {@code nativeOrder()}) so the
* contract holds on every host regardless of the JVM's native byte order, matching
* {@code ByteCompare}'s own fixed choice exactly. Confirmed by decompiling
* {@code fpr-core-1.1.1}'s {@code ByteCompare.class} (its {@code LONG_VIEW} field), not
* merely assumed — see {@code FastPathViewsLongAtTest} for the runtime verification the
* plan requires beyond reading bytecode.
*/
private static final VarHandle LONG_VIEW_LE =
MethodHandles.byteArrayViewVarHandle(long[].class, ByteOrder.LITTLE_ENDIAN);
/**
* Reads 8 bytes at {@code array[pos, pos + 8)} as fpr-core's {@code ByteCompare} expects a
* {@link ByteView#longAt} implementation to. Caller-guaranteed contract (never asserted here
* — {@code ByteCompare} itself never calls this without first checking {@code pos + 8 <=
* length}, so a defensive check here would be dead code on every real call path; see
* {@code EX-04}'s registry entry): {@code pos + 8 <= array.length}.
*/
private static long longAtLittleEndian(byte[] array, int pos) {
return (long) LONG_VIEW_LE.get(array, pos);
}
public static final class RequestByteView implements ArrayBackedByteView {
private final byte[] buffer;
private final int start;
private final int length;
@@ -33,13 +65,49 @@ public final class FastPathViews {
return buffer[start + index];
}
@Override
public byte[] array() {
return buffer;
}
@Override
public int offset() {
return start;
}
/** {@code EX-04}: array-backed and contiguous — the word-at-a-time router path applies. */
@Override
public boolean supportsLong() {
return true;
}
@Override
public long longAt(int index) {
return longAtLittleEndian(buffer, start + index);
}
@Override
public String toString() {
return new String(buffer, start, length, StandardCharsets.UTF_8);
}
}
/** Mutable composite view: method bytes + path. Reused via ThreadLocal, call reset() before use. */
/**
* Mutable composite view: method bytes + path. Reused per connection, call {@link #reset}
* before use (see {@code FastPathRouterImpl}'s per-connection scratch, {@code EX-06}).
*
* <h3>{@code EX-04}: deliberately not array-backed, {@code supportsLong()} stays {@code false}</h3>
* Unlike every other view in this file, this one is a composite of two independent sources
* (a raw {@code byte[]} for the method, and another {@link ByteView} — itself possibly
* array-backed — for the path). There is no single backing array a word-at-a-time read could
* span, and a byte index near the method/path boundary could straddle both sources entirely,
* making a single contiguous 8-byte read structurally impossible in general (not merely
* unimplemented) — the same reasoning {@link dev.relism.flash.bytes.SegmentedByteView}
* documents for the analogous HPACK CONTINUATION case. Falls back to the inherited
* {@link ByteView#supportsLong} default ({@code false}); {@code fpr-core}'s router-matching
* path already handles that correctly (it only takes the word-at-a-time branch when
* {@code supportsLong()} is {@code true}).
*/
public static final class MethodPathByteView implements ByteView {
private byte[] method;
private ByteView path;
@@ -62,7 +130,7 @@ public final class FastPathViews {
}
}
public static class SocketByteView implements ByteView {
public static class SocketByteView implements ArrayBackedByteView {
private final byte[] data;
public SocketByteView(byte[] data) {
@@ -78,9 +146,30 @@ public final class FastPathViews {
public byte byteAt(int index) {
return data[index];
}
@Override
public byte[] array() {
return data;
}
@Override
public int offset() {
return 0;
}
/** {@code EX-04}: array-backed and contiguous — the word-at-a-time router path applies. */
@Override
public boolean supportsLong() {
return true;
}
@Override
public long longAt(int index) {
return longAtLittleEndian(data, index);
}
}
public static class StringByteView implements ByteView {
public static class StringByteView implements ArrayBackedByteView {
private final byte[] bytes;
public StringByteView(String str) {
@@ -96,5 +185,26 @@ public final class FastPathViews {
public byte byteAt(int index) {
return bytes[index];
}
@Override
public byte[] array() {
return bytes;
}
@Override
public int offset() {
return 0;
}
/** {@code EX-04}: array-backed and contiguous — the word-at-a-time router path applies. */
@Override
public boolean supportsLong() {
return true;
}
@Override
public long longAt(int index) {
return longAtLittleEndian(bytes, index);
}
}
}
@@ -10,12 +10,35 @@ import dev.relism.flash.models.Request;
import dev.relism.flash.routing.AbstractWsRouter;
import dev.relism.flash.websocket.WebSocketHandler;
/**
* WebSocket-upgrade counterpart of {@link FastPathRouterImpl} — same {@code fpr-core} matching
* engine, same {@code EX-06} router-half fix (an explicit per-connection {@link RouteScratch}
* via {@link #newScratch} in place of the {@code ThreadLocal}s this class used to hold). Unlike
* {@link FastPathRouterImpl}, its path-param extraction is not covered by {@code EX-19} (that
* registry entry names {@code FastPathRouterImpl.route} specifically) and still allocates a
* fresh {@code PathParams} per matched, parametric WebSocket upgrade — WebSocket upgrades are
* inherently rare relative to ordinary requests (one per connection, not one per message), so
* this was not flagged as a hot-path allocation concern.
*/
public final class FastPathWsRouterImpl extends AbstractWsRouter {
private final RouterBuilder<WebSocketHandler> builder = new RouterBuilder<>();
private volatile FastPathRouter<ByteView, WebSocketHandler> router;
private String[] cachedParamNames;
/** Per-connection reusable matching state — see {@link FastPathRouterImpl.RouteScratch}'s
* javadoc for the full {@code EX-06} rationale; this router's scratch is smaller since
* {@code EX-19}'s path-param reuse does not apply here (see the class Javadoc). */
static final class RouteScratch {
final MatchResult<WebSocketHandler> matchResult = new MatchResult<>(32, 128);
final FastPathViews.MethodPathByteView combinedView = new FastPathViews.MethodPathByteView();
}
@Override
public Object newScratch() {
return new RouteScratch();
}
@Override
protected AbstractWsRouter addRoute(HttpMethod method, String path, WebSocketHandler handler) {
builder.add(StringRouteParser.parse(method.name() + path), handler);
@@ -24,15 +47,16 @@ public final class FastPathWsRouterImpl extends AbstractWsRouter {
}
@Override
public WebSocketHandler route(Request request) {
public WebSocketHandler route(Request request, Object scratchObj) {
ensureCompiled();
RouteScratch scratch = (RouteScratch) scratchObj;
MatchResult<WebSocketHandler> result = Context.result();
MatchResult<WebSocketHandler> result = scratch.matchResult;
result.reset();
HttpMethod method = request.getRequestLine().getMethod();
ByteView pathView = request.getRequestLine().getPath();
FastPathViews.MethodPathByteView combined = Context.combined();
FastPathViews.MethodPathByteView combined = scratch.combinedView;
combined.reset(method.getBytes(), pathView);
int labelId = router.match(combined, result);
@@ -58,14 +82,4 @@ public final class FastPathWsRouterImpl extends AbstractWsRouter {
@Override
public void compile() { ensureCompiled(); }
private static final class Context {
private static final ThreadLocal<MatchResult<WebSocketHandler>> RESULT =
ThreadLocal.withInitial(() -> new MatchResult<>(32, 128));
private static final ThreadLocal<FastPathViews.MethodPathByteView> COMBINED =
ThreadLocal.withInitial(FastPathViews.MethodPathByteView::new);
static MatchResult<WebSocketHandler> result() { return RESULT.get(); }
static FastPathViews.MethodPathByteView combined() { return COMBINED.get(); }
}
}
@@ -23,10 +23,13 @@ import java.security.NoSuchAlgorithmException;
* pool when the connection closes. Never shared between two connections at once — there is no
* synchronization here because none is needed.
*
* <p>Extended in Phase 4 with the router's reusable {@code MatchResult}/path-view fields
* (currently still {@code ThreadLocal} in {@code FastPathRouterImpl}, per {@code EX-06}'s own
* multi-phase assignment — see {@code DECISIONS.md} for why Phase 2 does not also absorb that
* part of the fix) and in later phases with HTTP/2 write/HPACK scratch.
* <p>{@code EX-06}'s router half (the {@code FastPathRouterImpl}/{@code FastPathWsRouterImpl}
* {@code ThreadLocal}s) is fixed in Phase 4, but deliberately <em>not</em> by extending this
* class: {@code routing} has no dependency on {@code transport} today, and folding the router's
* scratch fields in here would have created one — see {@code DECISIONS.md}, {@code DEC-19}, for
* the opaque-per-connection-object mechanism ({@code AbstractRouter#newScratch}) used instead.
* This class gains HTTP/2 write/HPACK scratch in later phases, where {@code h2} already depends
* on {@code transport} and no such boundary concern applies.
*/
public final class ConnectionScratch {