feat(core): HTTP/2 Phase 6 — Request/Response model refactor

Pools Request/RequestBody/RequestLine/Response per connection (EX-20..EX-24),
following the same reset()/dev-mode-guard idiom Http1HeaderMap already used.
HeaderMap splits into HeaderView (interface) + Http1HeaderMap (impl, DEC-22).
Response gains byte-level structured headers, PreEncodedHeader, and
ResponseSerializer as the single source of truth for a response's header
sequence, consumed by Http1ResponseWriter's single-bulk-write rewrite (EX-27).
ByteTemplate gets O(1) slot lookup plus a buffer-writing overload (EX-28).
Multipart audited: three resource-exhaustion gaps found and fixed — unbounded
buffered part size, part count, and per-part header parsing (EX-38..EX-40) —
and boundary length confirmed already bounded (EX-41).

Re-measuring RequestPipelineBenchmark after the pooling work surfaced one more
per-request allocation underneath it (RequestParser building fresh
RequestByteViews every call) and, while checking the phase's own DoD text, an
unbounded Response.header(...) loop hazard neither had a limit — both fixed
(EX-42, EX-43). The h1 zero-alloc contract now holds: parseAndRoute measures
0.008 B/op (JMH noise floor), down from Phase 4's 120.008 B/op (DEC-20, DEC-23).

MESSAGE-MODEL.md records the pooling model; README gains an "Object lifetime"
section documenting the do-not-retain-past-the-handler contract. 503/503 tests
green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-08-13 15:26:08 +00:00
co-authored by Claude Sonnet 5
parent 0e1bbed42c
commit d882ea255c
51 changed files with 2395 additions and 372 deletions
@@ -25,15 +25,19 @@ import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
/**
* Phase 4's zero-alloc contract: "an h1 {@code GET /users/{id}} request that reads three headers
* The h1 zero-alloc contract: "an h1 {@code GET /users/{id}} request that reads three headers
* and one path param must be 0 B/op end to end except for the user-facing {@code String}s the
* handler explicitly asks for." This benchmark measures the actual current number with
* {@code -prof gc} — see {@code DECISIONS.md}, {@code DEC-20}, for the honest result and why it
* is not literally 0 B/op yet: {@code Request}/{@code RequestBody}/{@code RequestLine} are still
* allocated per request ({@code EX-21}/{@code EX-22}, explicitly Phase 6 scope, not Phase 4's).
* The two benchmark methods below isolate that cost from Phase 4's own scope (header lookups,
* path-param extraction, query decoding) by comparing a route with no header/param access against
* one that performs exactly the access the DoD text describes.
* handler explicitly asks for." This benchmark measures the actual number with {@code -prof gc}.
* At Phase 4 ({@code DEC-20}) {@code parseAndRoute} measured 120.008 B/op, entirely attributable
* to {@code Request}/{@code RequestBody}/{@code RequestLine} construction (explicitly deferred to
* Phase 6, not a Phase 4 regression). Phase 6's pooling ({@code EX-20}{@code EX-24}) plus one
* more allocation this benchmark caught underneath it ({@code EX-42}: {@code RequestParser} was
* still allocating fresh {@code RequestByteView}s per request) closed the gap — see
* {@code DECISIONS.md}, {@code DEC-23}, for the full before/after numbers. {@code parseAndRoute}
* is now 0 B/op (JMH's noise floor); the two benchmark methods below isolate that from the
* unavoidable, DoD-exempted cost of the explicit {@code String} reads a real handler performs
* (header lookups, path-param extraction) by comparing a route with no header/param access
* against one that performs exactly the access the DoD text describes.
*
* <p>Uses a hand-rolled repeating {@link InputStream} (never allocates, cycles the same request
* bytes indefinitely) rather than a fresh {@code ByteArrayInputStream}/{@code BufferedByteSource}
@@ -85,7 +85,7 @@ public class FastPathRouterBenchmark {
dev.relism.flash.models.RequestLine line = new dev.relism.flash.models.RequestLine(
method, pathView, null,
new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8),
new dev.relism.flash.models.HeaderMap()
new dev.relism.flash.models.Http1HeaderMap()
);
return new Request(line, new byte[0]);
}
@@ -4,8 +4,9 @@ import dev.relism.flash.bytes.ByteScan;
import dev.relism.flash.exceptions.MalformedRequestException;
import dev.relism.flash.http.Http1Limits;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.HeaderMap;
import dev.relism.flash.models.Http1HeaderMap;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestBody;
import dev.relism.flash.models.RequestLine;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
import dev.relism.flash.transport.BufferedByteSource;
@@ -56,7 +57,19 @@ public class RequestParser {
private final int maxHeaderBufferSize;
private final InetSocketAddress remoteAddress;
private final SSLSocket sslSocket;
private final HeaderMap headerMap = new HeaderMap();
private final Http1HeaderMap headerMap = new Http1HeaderMap();
// EX-22: one Request/RequestLine per connection, repositioned (never reallocated) per
// request — same idiom as headerMap above.
private final RequestLine requestLine = new RequestLine();
private final Request request = new Request();
private final RequestBody requestBody = new RequestBody();
// EX-42: one pooled RequestByteView per role, repositioned (never reallocated) per request —
// closes the last per-request allocation left after EX-20..EX-24 pooled Request/RequestBody/
// RequestLine/Response themselves. queryView is only reset and used when a query string is
// actually present; RequestLine.getQuery() must keep returning null otherwise (see reset()).
private final FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(null, 0, 0);
private final FastPathViews.RequestByteView queryView = new FastPathViews.RequestByteView(null, 0, 0);
private final FastPathViews.RequestByteView protocolView = new FastPathViews.RequestByteView(null, 0, 0);
private byte[] buffer;
// Unconsumed bytes belonging to the NEXT request.
@@ -154,11 +167,8 @@ public class RequestParser {
if (pathEnd == -1) throw new MalformedRequestException(400, "Invalid request line (path)");
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
? new FastPathViews.RequestByteView(buffer, queryMark + 1, pathEnd - queryMark - 1)
: null;
pathView.reset(buffer, pathStart, queryMark != -1 ? queryMark - pathStart : pathEnd - pathStart);
if (queryMark != -1) queryView.reset(buffer, queryMark + 1, pathEnd - queryMark - 1);
int protocolStart = pathEnd + 1;
int protocolEnd = ByteScan.indexOf(buffer, protocolStart, headerEndIdx, (byte) '\r');
@@ -171,8 +181,7 @@ public class RequestParser {
throw new MalformedRequestException(431, "Request line exceeds " + Http1Limits.MAX_REQUEST_LINE_LENGTH + " bytes");
}
FastPathViews.RequestByteView protocolView =
new FastPathViews.RequestByteView(buffer, protocolStart, protocolEnd - protocolStart);
protocolView.reset(buffer, protocolStart, protocolEnd - protocolStart);
// ── Headers ──────────────────────────────────────────────────────────
@@ -290,14 +299,18 @@ public class RequestParser {
preBufLen = (int) contentLength;
}
RequestLine requestLine = new RequestLine(method, pathView, queryView, protocolView, headerMap);
requestLine.reset(method, pathView, queryMark != -1 ? queryView : null, protocolView, headerMap);
// EX-22: requestBody is this connection's single pooled instance (see its own class
// Javadoc) -- reset() repositions it for the fixed-length/empty case (contentLength == 0
// is handled by the same call: preBufLen is already forced to 0 for it above) or the
// chunked case, never reallocated.
if (isChunked) {
return Request.forParsed(requestLine,
new ChunkedInputStream(in, buffer, bodyStart, preBufLen),
-1L, null, 0, 0, remoteAddress, sslSocket);
requestBody.reset(new ChunkedInputStream(in, buffer, bodyStart, preBufLen), -1L, null, 0, 0);
} else {
requestBody.reset(in, contentLength, buffer, bodyStart, preBufLen);
}
return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen, remoteAddress, sslSocket);
return Request.forParsed(request, requestLine, requestBody, remoteAddress, sslSocket);
}
/**
@@ -1,7 +1,9 @@
package dev.relism.flash.api.multipart;
import dev.relism.flash.http.Http1Limits;
import dev.relism.flash.models.Request;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
@@ -55,6 +57,7 @@ public final class Multipart {
private final List<Part> scanned = new ArrayList<>();
private PartBodyStream active = null; // open file stream; must be drained before next scan
private int partCount = 0; // EX-29: bounds Http1Limits.MAX_MULTIPART_PARTS
// -------------------------------------------------------------------------
// Factory
@@ -156,6 +159,12 @@ public final class Multipart {
Map<String, String> headers = readPartHeaders();
if (headers == null) { done = true; return null; }
// EX-29: without this bound, a peer sending an unbounded number of minimal parts forces
// unbounded growth of `scanned` and unbounded cumulative header-parsing work.
if (++partCount > Http1Limits.MAX_MULTIPART_PARTS) {
throw new IOException("multipart body exceeds max part count (" + Http1Limits.MAX_MULTIPART_PARTS + ")");
}
String disp = headers.get("content-disposition");
String name = extractParam(disp, "name");
String filename = extractParam(disp, "filename");
@@ -168,8 +177,10 @@ public final class Multipart {
// File part — expose streaming body; not cached (stream is consumed once)
p = Part.streaming(name, filename, ct, active);
} else {
// Text part, or full-scan path: buffer body now
byte[] body = active.readAllBytes();
// Text part, or full-scan path: buffer body now. EX-29: bounded, not
// InputStream.readAllBytes() — an unbounded field/file body would otherwise let a
// hostile peer force an arbitrarily large single heap allocation.
byte[] body = readBoundedBody(active);
active = null;
p = Part.buffered(name, filename, ct, body);
scanned.add(p);
@@ -177,6 +188,27 @@ public final class Multipart {
return p;
}
/**
* Reads {@code in} to EOF into a {@code byte[]}, bounded by
* {@link Http1Limits#MAX_MULTIPART_BUFFERED_PART_SIZE} — see that constant's Javadoc for why
* this bound is necessary even though the overall request body already has one.
*/
private static byte[] readBoundedBody(InputStream in) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream(BUF_CAP);
byte[] chunk = new byte[BUF_CAP];
long total = 0;
int n;
while ((n = in.read(chunk)) > 0) {
total += n;
if (total > Http1Limits.MAX_MULTIPART_BUFFERED_PART_SIZE) {
throw new IOException("multipart part body exceeds max buffered size ("
+ Http1Limits.MAX_MULTIPART_BUFFERED_PART_SIZE + " bytes)");
}
out.write(chunk, 0, n);
}
return out.toByteArray();
}
// -------------------------------------------------------------------------
// PartBodyStream — inner class sharing the window buffer
// -------------------------------------------------------------------------
@@ -266,9 +298,16 @@ public final class Multipart {
private Map<String, String> readPartHeaders() throws IOException {
Map<String, String> map = new HashMap<>();
int count = 0;
while (true) {
String line = readLine();
if (line == null || line.isEmpty()) break;
// EX-29: without this bound a peer can send an effectively unlimited number of
// header lines before the blank line that ends a part's header block.
if (++count > Http1Limits.MAX_MULTIPART_PART_HEADER_COUNT) {
throw new IOException("multipart part exceeds max header count ("
+ Http1Limits.MAX_MULTIPART_PART_HEADER_COUNT + ")");
}
int colon = line.indexOf(':');
if (colon > 0)
map.put(line.substring(0, colon).trim().toLowerCase(Locale.ROOT),
@@ -289,6 +328,7 @@ public final class Multipart {
sb.append(new String(win, wPos, i - wPos, StandardCharsets.UTF_8));
int consumed = i - wPos + 2;
wPos += consumed; wLen -= consumed;
checkHeaderLineLength(sb.length());
return sb.toString();
}
}
@@ -298,14 +338,27 @@ public final class Multipart {
sb.append(new String(win, wPos, append, StandardCharsets.UTF_8));
wPos += append; wLen -= append;
}
// EX-29: without this bound, a peer that never sends \r\n keeps this StringBuilder
// growing for as long as it keeps streaming bytes — the multipart-header analogue of
// RequestParser's Http1Limits.MAX_HEADER_VALUE_LENGTH check, which does not apply
// here since these header lines live inside the body, not the top-level HTTP headers.
checkHeaderLineLength(sb.length());
if (srcEof && wLen > 0) {
sb.append(new String(win, wPos, wLen, StandardCharsets.UTF_8));
wPos += wLen; wLen = 0;
checkHeaderLineLength(sb.length());
return sb.toString();
}
}
}
private static void checkHeaderLineLength(int length) throws IOException {
if (length > Http1Limits.MAX_MULTIPART_HEADER_LINE_LENGTH) {
throw new IOException("multipart header line exceeds "
+ Http1Limits.MAX_MULTIPART_HEADER_LINE_LENGTH + " bytes");
}
}
// -------------------------------------------------------------------------
// Utilities
// -------------------------------------------------------------------------
@@ -10,7 +10,7 @@ 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
* validation, and the case-insensitive header-name hash {@link dev.relism.flash.models.Http1HeaderMap}'s
* index uses ({@code EX-09}).
*
* <p>Every method here is {@code static} and allocates nothing. Every SWAR method has a plain
@@ -242,7 +242,7 @@ public final class ByteScan {
/**
* 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
* by {@link dev.relism.flash.models.Http1HeaderMap}'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
@@ -261,7 +261,7 @@ public final class ByteScan {
* 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
* {@link dev.relism.flash.models.Http1HeaderMap}'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).
*/
@@ -17,7 +17,7 @@ import java.nio.charset.StandardCharsets;
*
* <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()}
* object in this codebase ({@code ConnectionScratch}, {@code Http1HeaderMap}). {@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.
@@ -123,6 +123,21 @@ public final class ByteWriter {
}
}
/**
* Writes {@code s}'s ASCII bytes, case preserved. {@code s} must be ASCII-only. Unlike
* {@code new String(...).getBytes(UTF_8)}, writes each character directly into this
* writer's buffer — no intermediate {@code byte[]} ({@code EX-20}: this is what lets
* {@code Response.header(String, String)} avoid the {@code StringBuilder}+concat+
* {@code getBytes} allocation chain it used to pay per call).
*/
public void writeAscii(String s) {
int n = s.length();
ensure(n);
for (int i = 0; i < n; i++) {
buf[len++] = (byte) s.charAt(i);
}
}
/** Big-endian 16-bit write — an HTTP/2 frame's stream-dependent fields, SETTINGS values, etc. */
public void writeUInt16(int value) {
ensure(2);
@@ -3,7 +3,7 @@ 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
* places ({@code Http1HeaderMap.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.
@@ -3,7 +3,7 @@ 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
* {@code Http1HeaderMap.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.
*
@@ -11,7 +11,7 @@ package dev.relism.flash.bytes;
* 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 Http1HeaderMap}) 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.
@@ -3,10 +3,10 @@ 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}).
* {@code Http1HeaderMap.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
* A single reused slice (the shape {@code Http1HeaderMap.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
@@ -18,7 +18,7 @@ package dev.relism.flash.bytes;
* 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
* {@code Http1HeaderMap.view}'s Javadoc for the canonical wording); it is a real, testable hazard, not
* a hypothetical one — see {@code SlicePoolTest#wraparoundAliasesThePreviouslyReturnedSlice} for
* a demonstration.
*/
@@ -9,7 +9,7 @@ package dev.relism.flash.h2.frame;
* <h3>Lifetime contract</h3>
* Valid only until the next {@link Http2FrameReader#readFrame()}/{@code consumeFrame()} call on
* the same reader — same "do not retain past the handler" rule the rest of this codebase's
* buffer-backed flyweights (`HeaderMap`, `WebSocketFrame`) already document. The payload bytes
* buffer-backed flyweights (`Http1HeaderMap`, `WebSocketFrame`) already document. The payload bytes
* are also transient: whatever layer needs to retain a DATA frame's payload past this window
* must copy it out (R3 — the connection read buffer is shared, single-threaded, and reused).
*
@@ -38,7 +38,7 @@ public final class Http1Limits {
* Maximum number of header lines accepted in a single request. Without this bound, a
* request with tens of thousands of one-byte headers passes the total header-block size
* check ({@code maxHeaderBufferSize}) while still forcing every subsequent
* {@code HeaderMap} lookup to scan all of them — turning a small request into quadratic CPU
* {@code Http1HeaderMap} lookup to scan all of them — turning a small request into quadratic CPU
* work per middleware that reads a header ({@code EX-08}, {@code EX-09}).
*/
public static final int MAX_HEADER_COUNT = 100;
@@ -96,4 +96,74 @@ public final class Http1Limits {
* {@link #MAX_HEADER_VALUE_LENGTH}.
*/
public static final int MAX_TRAILER_COUNT = 50;
/**
* {@code EX-27}: response bodies at or below this size are copied into the same scratch
* buffer as the response head (status line + headers) and written with it in a single
* {@code OutputStream.write} call; larger bodies are written in a second {@code write} right
* after the head, since copying a large body into the head buffer first would cost more
* (an extra full-body memcpy) than the syscall it saves. 8 KiB — matches this codebase's
* other "one socket-buffer's worth" constants ({@code ConnectionScratch.RELAY_BUFFER_SIZE},
* {@code BufferedByteSource.DEFAULT_BUFFER_SIZE}) rather than introducing an uncalibrated
* new number; see {@code DECISIONS.md} for the measurement that confirmed this default.
*/
public static final int INLINE_BODY_THRESHOLD = 8192;
/**
* {@code EX-29}: maximum number of parts ({@code Multipart}) accepted in a single
* {@code multipart/form-data} body. Without this bound, a peer can send an unbounded number
* of minimal parts — each cheap individually but forcing unbounded growth of the parser's
* {@code scanned} list and unbounded per-part header-parsing work, the multipart analogue of
* {@link #MAX_CHUNKS_PER_BODY}.
*/
public static final int MAX_MULTIPART_PARTS = 1_000;
/**
* {@code EX-29}: maximum number of header lines ({@code Content-Disposition},
* {@code Content-Type}, …) accepted per multipart part. Real clients send at most two or
* three; without a bound a peer could send an effectively unlimited number before the blank
* line that ends a part's header block, forcing unbounded {@code HashMap} growth per part.
*/
public static final int MAX_MULTIPART_PART_HEADER_COUNT = 20;
/**
* {@code EX-29}: maximum length, in bytes, of a single header line within a multipart part's
* header block. {@code Multipart.readLine} otherwise has no bound of its own to fall back
* on — unlike the top-level HTTP headers (bounded by {@link #MAX_HEADER_VALUE_LENGTH} in
* {@code RequestParser}), a line here with no {@code \r\n} would grow its {@code StringBuilder}
* without limit for as long as the peer keeps streaming bytes.
*/
public static final int MAX_MULTIPART_HEADER_LINE_LENGTH = 8_192;
/**
* {@code EX-29}: maximum size, in bytes, of a single multipart part body that {@code Multipart}
* buffers eagerly into a {@code byte[]} — text fields (always buffered) and, during a full
* {@code parts()}/{@code parts(String)} scan, file bodies too. {@link #MAX_CONTENT_LENGTH}
* bounds the whole request body, but at 4 GiB (and effectively unbounded for a chunked body,
* see {@link #MAX_CHUNKS_PER_BODY} × {@link #MAX_CHUNK_SIZE}) it does nothing to stop a
* single part from exhausting the heap on its own — this is the bound that actually protects
* {@code ByteArrayOutputStream}-style eager buffering. Deliberately does not apply to
* {@code Part.materialize()} on a streaming file part returned by {@code Multipart.file()} —
* that call is documented as an explicit, opt-in heap allocation the caller chooses to pay for.
*/
public static final long MAX_MULTIPART_BUFFERED_PART_SIZE = 10L * 1024 * 1024;
/**
* Maximum combined size, in bytes, of every response header's name + value bytes
* ({@code Response.header(...)}'s growable {@code headerRegion}). Unlike every other bound in
* this class, this one guards against a bug in <em>Flash's own caller</em> rather than a
* hostile peer — a handler that calls {@code header(...)} in an unbounded loop (e.g. echoing
* an unbounded collection into headers) would otherwise grow this connection's scratch region
* without limit for the rest of its lifetime, since it is never shrunk back down between
* requests. Phase 6's zero-alloc DoD names this bound explicitly.
*/
public static final int MAX_RESPONSE_HEADER_BYTES = 65_536;
/**
* Maximum number of {@code Response.header(...)} calls (any overload) accepted on a single
* response. Same rationale as {@link #MAX_RESPONSE_HEADER_BYTES}: bounds the response-side
* analogue of {@link #MAX_HEADER_COUNT}, since an unbounded call count grows the header index
* arrays even if each individual header is small.
*/
public static final int MAX_RESPONSE_HEADER_COUNT = 1_000;
}
@@ -44,6 +44,9 @@ public final class Http1Connection implements ConnectionProtocol {
Object routeScratch = ctx.router().newScratch();
Object wsRouteScratch = ctx.wsRouter().newScratch();
// EX-21: one Response per connection, repositioned (never reallocated) per request.
Response pooledResponse = new Response(200, ContentType.TEXT_PLAIN);
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
@@ -106,7 +109,7 @@ public final class Http1Connection implements ConnectionProtocol {
in.setDeadline(System.nanoTime() + ctx.configuration().getBodyReadTimeoutMs() * 1_000_000L);
boolean keepAlive = Http1KeepAlive.isKeepAlive(request);
Response response = new Response(200, ContentType.TEXT_PLAIN);
Response response = pooledResponse.reset(200, ContentType.TEXT_PLAIN);
RequestHandler handler = ctx.router().route(request, routeScratch);
if (handler == null) handler = ctx.router().getNotFoundHandler();
@@ -129,6 +132,14 @@ public final class Http1Connection implements ConnectionProtocol {
Http1ResponseWriter.writeResponse(out, response, request.method(), actuallyKeepAlive,
ctx.configuration().isSendDate(), ctx.scratch());
request.drain();
// EX-22/EX-21: these instances are about to be repositioned over the next request (or
// dropped, if the connection closes) — poison them in dev mode so any reference the
// handler improperly retained (a captured field, an async callback) fails loudly on
// its next access instead of silently reading whatever comes next. Only the pooled
// Response is recycled: if the handler returned a different instance, that object was
// never pooled in the first place and owes nothing back to this connection.
request.recycle();
if (response == pooledResponse) pooledResponse.recycle();
in.clearDeadline();
if (!actuallyKeepAlive) break;
}
@@ -1,6 +1,8 @@
package dev.relism.flash.http1;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http.DateHeader;
import dev.relism.flash.http.Http1Limits;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.http.HttpStatus;
import dev.relism.flash.models.Response;
@@ -16,9 +18,18 @@ import java.nio.charset.StandardCharsets;
* serialization — routing, handler dispatch, and the request loop live in
* {@link Http1Connection}.
*
* <p>Zero-allocation: the decimal encoding of the status code / {@code Content-Length} and the
* relay buffer used for streaming bodies both come from the connection's {@link ConnectionScratch}
* ({@code EX-06}) instead of a per-call allocation or a {@code ThreadLocal}.
* <h3>{@code EX-27}: one bulk write, not ~10 small ones</h3>
* The status line, {@code Content-Type}, {@code Date}, every custom header, and
* {@code Content-Length}/{@code Connection} are all serialized into
* {@link ConnectionScratch#responseHead} (a reused {@link ByteWriter}) before a single
* {@code OutputStream.write} call — not one small {@code write} per field, and no
* {@link java.io.BufferedOutputStream} coalescing them at the stream layer (this class removes
* the need for one entirely on the h1 response path). A body at or below
* {@link Http1Limits#INLINE_BODY_THRESHOLD} is copied into the same scratch buffer and goes out
* in that same syscall; a larger body is written separately right after, since copying it into
* the head buffer first would cost an extra full-body memcpy the syscall it saves does not pay
* for. Streaming/chunked bodies write the head, then relay their own bytes as they arrive — by
* definition unknown or too large to fold into one buffer up front.
*/
public final class Http1ResponseWriter {
@@ -52,62 +63,76 @@ public final class Http1ResponseWriter {
boolean noContentAllowed = statusCode == 204 || statusCode == 304 || (statusCode >= 100 && statusCode < 200);
boolean suppressBody = noContentAllowed || method == HttpMethod.HEAD;
out.write(HTTP_1_1);
ByteWriter head = scratch.responseHead;
head.reset();
head.writeBytes(HTTP_1_1);
byte[] statusBytes = response.getStatusBytes();
if (statusBytes != null) out.write(statusBytes);
else writeStatusPhrase(out, statusCode, scratch);
out.write(CRLF);
if (statusBytes != null) head.writeBytes(statusBytes);
else writeStatusPhrase(head, statusCode);
head.writeBytes(CRLF);
// EX-15: a Content-Type of ContentType.NONE (empty byte[]) used to still emit the line
// "Content-Type: \r\n" — a header with no value. Skip the line entirely instead.
byte[] contentType = response.getContentType();
if (contentType != null && contentType.length > 0) {
out.write(CONTENT_TYPE);
out.write(contentType);
out.write(CRLF);
head.writeBytes(CONTENT_TYPE);
head.writeBytes(contentType);
head.writeBytes(CRLF);
}
// EX-16: precomputed once per second by a shared daemon thread — one volatile read,
// one write(byte[]), never a per-response format call.
if (sendDate) out.write(DateHeader.bytes());
// one write into the scratch, never a per-response format call.
if (sendDate) head.writeBytes(DateHeader.bytes());
response.writeHeaders(out);
response.writeHeadersInto(head);
if (response.isStreaming()) {
writeStreamingBody(out, response, keepAlive, noContentAllowed, suppressBody, scratch);
writeStreamingBody(out, head, response, keepAlive, noContentAllowed, suppressBody, scratch);
} else {
byte[] body = response.getBody();
int len = body != null ? body.length : 0;
if (!noContentAllowed) {
out.write(CONTENT_LENGTH);
writeLong(out, len, scratch);
out.write(CRLF);
head.writeBytes(CONTENT_LENGTH);
head.writeDecimal(len);
head.writeBytes(CRLF);
}
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
out.write(CRLF);
head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
head.writeBytes(CRLF);
// EX-14: HEAD reports the Content-Length GET would have (above) but never writes
// the body itself.
if (body != null && !suppressBody) out.write(body);
boolean writeBody = body != null && !suppressBody;
if (writeBody && len <= Http1Limits.INLINE_BODY_THRESHOLD) {
// EX-27: small body folded into the same scratch buffer — head + body leave in
// one syscall.
head.writeBytes(body);
out.write(head.array(), 0, head.length());
} else {
out.write(head.array(), 0, head.length());
if (writeBody) out.write(body);
}
}
out.flush();
}
private static void writeStreamingBody(OutputStream out, Response response, boolean keepAlive,
private static void writeStreamingBody(OutputStream out, ByteWriter head, Response response, boolean keepAlive,
boolean noContentAllowed, boolean suppressBody,
ConnectionScratch scratch) throws IOException {
if (!response.isChunked()) {
if (!noContentAllowed) {
out.write(CONTENT_LENGTH);
writeLong(out, response.getStreamLength(), scratch);
out.write(CRLF);
head.writeBytes(CONTENT_LENGTH);
head.writeDecimal(response.getStreamLength());
head.writeBytes(CRLF);
}
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
out.write(CRLF);
head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
head.writeBytes(CRLF);
out.write(head.array(), 0, head.length());
if (!suppressBody) relay(response.getStream(), out, scratch);
} else {
out.write(TRANSFER_CHUNKED);
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
out.write(CRLF);
head.writeBytes(TRANSFER_CHUNKED);
head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
head.writeBytes(CRLF);
out.write(head.array(), 0, head.length());
// A HEAD response still declares the Transfer-Encoding GET would have used (RFC
// 9110 §9.3.2) but writes zero body bytes — not even the final-chunk marker, since
// there is no chunk framing at all for a message with no body.
@@ -125,21 +150,10 @@ public final class Http1ResponseWriter {
while ((n = in.read(buf)) > 0) out.write(buf, 0, n);
}
private static void writeStatusPhrase(OutputStream out, int statusCode, ConnectionScratch scratch) throws IOException {
private static void writeStatusPhrase(ByteWriter head, int statusCode) {
byte[] phrase = HttpStatus.bytesForCode(statusCode);
if (phrase != null) out.write(phrase);
else { writeLong(out, statusCode, scratch); out.write(UNKNOWN_STATUS_SUFFIX); }
}
private static void writeLong(OutputStream out, long value, ConnectionScratch scratch) throws IOException {
if (value == 0) { out.write('0'); return; }
byte[] buf = scratch.decimalBuffer;
int pos = buf.length;
boolean neg = value < 0;
if (neg) value = -value;
do { buf[--pos] = (byte) ('0' + value % 10); value /= 10; } while (value > 0);
if (neg) buf[--pos] = '-';
out.write(buf, pos, buf.length - pos);
if (phrase != null) head.writeBytes(phrase);
else { head.writeDecimal(statusCode); head.writeBytes(UNKNOWN_STATUS_SUFFIX); }
}
private static void writeChunked(OutputStream out, InputStream stream, ConnectionScratch scratch) throws IOException {
@@ -0,0 +1,66 @@
package dev.relism.flash.models;
import dev.relism.fpr.core.ByteView;
import java.util.List;
/**
* The read-side contract every header container implements, protocol-neutral: {@link
* Http1HeaderMap} backs it with an HTTP/1.1 byte-buffer range today; a Phase 10
* {@code Http2HeaderMap} will back it with HPACK-decoded (name, value) pairs. Neither concrete
* shape leaks into this interface — there is no {@code reset(byte[], int, int)} here, since that
* signature only makes sense for a byte-range-backed implementation.
*
* <p>{@link RequestLine#getHeaders()} is typed as this interface (not a concrete class), which
* is what lets Phase 10 hand a {@link Request} an HPACK-backed header container without touching
* a single line of {@code Request}'s own code — the entire point of this phase's refactor (R1:
* h1 and h2 are peers behind a shared abstraction, never one forking the other).
*
* <h3>Lifetime contract</h3>
* Every implementation lives on the connection (h1) or the stream (h2), not per-request, and is
* repositioned in place between requests — never retain an instance past the handler that
* received it. {@code String} values returned by {@link #first}/{@link #all} are safe to retain
* (independent heap copies); {@link ByteView}s returned by {@link #view} and passed to {@link
* HeaderConsumer#accept} are not — see each implementation's own Javadoc for its exact reuse
* window.
*/
public interface HeaderView {
/** Returns the first value of header {@code name} (case-insensitive), or {@code null}. */
String first(String name);
/** Returns all values of header {@code name} in declaration order, or an empty list. */
List<String> all(String name);
/** Returns all header values in declaration order. */
List<String> all();
/** Returns a view over the first value of {@code name}, or {@code null} — see the implementation's own reuse-window contract. */
ByteView view(String name);
/** Case-insensitive comparison of the first value of {@code name} against {@code value}. */
boolean valueEqualsIgnoreCase(String name, String value);
/** Whether any header named {@code name} is present. */
boolean contains(String name);
/** Total number of header lines (not distinct names — a repeated header counts once per line). */
int count();
/**
* Visits every header in declaration order without allocating a per-header object — see each
* implementation's Javadoc for exactly which instances are reused and their validity window.
*/
void forEach(HeaderConsumer consumer);
/**
* 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.
*/
@FunctionalInterface
interface HeaderConsumer {
void accept(ByteView name, ByteView value);
}
}
@@ -12,20 +12,30 @@ import java.util.Arrays;
import java.util.List;
/**
* Lazy, zero-copy header access backed directly by the request parser's byte buffer.
* Strings are allocated only when {@link #first} / {@link #all} / {@link #view} is called;
* the raw bytes are never copied at parse time.
* {@link HeaderView} backed directly by {@code RequestParser}'s byte buffer lazy, zero-copy:
* strings are allocated only when {@link #first}/{@link #all}/{@link #view} is called, the raw
* bytes are never copied at parse time.
*
* <h3>Package placement</h3>
* Despite the {@code Http1} prefix, this class lives in {@code dev.relism.flash.models}, not
* {@code dev.relism.flash.http1}, deliberately: {@code RequestParser} (which owns and resets one
* instance per connection) lives in the root {@code dev.relism.flash} package, and {@code http1}
* already depends on root (via {@code Http1Connection}'s use of {@code RequestParser}) placing
* this class in {@code http1} would require root to import back from {@code http1}, the exact
* kind of package cycle {@code DEC-19} already found and avoided once in this codebase. See
* {@code DECISIONS.md}, {@code DEC-22}, for the full reasoning; this note exists so a future
* reader does not "fix" the location back to what the plan's Files list originally suggested.
*
* <h3>Lifetime contract read carefully</h3>
* One {@code HeaderMap} instance lives on the connection (not per-request). On every
* One {@code Http1HeaderMap} instance lives on the connection (not per-request). On every
* keep-alive request {@link #reset} is called to slide the window over the new header
* section of the <em>same reused buffer</em>. This has two critical implications:
*
* <ol>
* <li><b>Do not retain the {@code HeaderMap} beyond the handler.</b> After the handler
* <li><b>Do not retain the {@code Http1HeaderMap} beyond the handler.</b> After the handler
* returns, the next request reuses and overwrites the buffer. Any {@code String}
* values retrieved via {@link #first}/{@link #all} are safe (they are independent
* heap copies); the {@code HeaderMap} object itself is not.</li>
* heap copies); the {@code Http1HeaderMap} object itself is not.</li>
* <li><b>{@link #view} returns a zero-copy {@link dev.relism.fpr.core.ByteView} slice
* 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
@@ -41,15 +51,10 @@ import java.util.List;
* (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.
* a full case-insensitive name comparison.
*/
@NoArgsConstructor
public class HeaderMap {
public class Http1HeaderMap implements HeaderView {
private static final int INITIAL_INDEX_CAPACITY = 16;
private static final int VIEW_POOL_SIZE = 4;
@@ -122,19 +127,7 @@ public class HeaderMap {
nameHashes = Arrays.copyOf(nameHashes, grown);
}
/**
* Visits every header in declaration order without allocating no per-header {@code
* String}/{@link ByteView}/list-entry object, unlike {@link #all()}. {@code name}/{@code
* value} are the same two {@link ByteView} instances on every call, repositioned in place;
* they are valid only for the duration of that single {@link HeaderConsumer#accept} call
* same "do not retain past the handler" rule as {@link #view}, just per-invocation instead
* of per-request. Prefer a non-capturing or field-reusing {@link HeaderConsumer} (see its
* javadoc) if the call site itself needs to stay allocation-free too.
*
* <p>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.
*/
@Override
public void forEach(HeaderConsumer consumer) {
if (buffer == null) return;
if (nameSlice == null) {
@@ -150,18 +143,6 @@ public class HeaderMap {
}
}
/**
* 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;
@@ -171,14 +152,14 @@ public class HeaderMap {
@Override public byte byteAt(int i) { return buffer[start + i]; }
}
/** Returns the first value of header {@code name} (case-insensitive), or {@code null}. */
@Override
public String first(String name) {
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. */
@Override
public List<String> all(String name) {
if (buffer == null) return List.of();
List<String> result = null;
@@ -192,7 +173,7 @@ public class HeaderMap {
return result != null ? result : List.of();
}
/** Returns all header values in declaration order. */
@Override
public List<String> all() {
if (buffer == null) return List.of();
List<String> result = new ArrayList<>(headerCount);
@@ -202,24 +183,35 @@ public class HeaderMap {
return result;
}
/** Case-insensitive comparison of the first value of {@code name} against {@code value}. */
@Override
public boolean valueEqualsIgnoreCase(String name, String value) {
int i = indexOfHeader(name);
if (i < 0) return false;
return ByteScan.equalsIgnoreCaseAscii(buffer, valueOffsets[i], valueOffsets[i] + valueLengths[i], value);
}
@Override
public boolean contains(String name) {
return indexOfHeader(name) >= 0;
}
@Override
public int count() {
return headerCount;
}
/**
* 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
* {@value #VIEW_POOL_SIZE} more times on this same {@code Http1HeaderMap} 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.
*/
@Override
public ByteView view(String name) {
int i = indexOfHeader(name);
if (i < 0) return null;
@@ -21,12 +21,12 @@ import java.nio.charset.StandardCharsets;
* 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
* {@link Http1HeaderMap#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
* past the handler" lifetime contract as {@link Http1HeaderMap}'s buffer-backed views: the next
* request on the same connection repositions the same arrays.
*/
public class PathParams {
@@ -99,7 +99,7 @@ public class PathParams {
/**
* 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
* true for h1 today) — same reuse-window contract as {@link Http1HeaderMap#view}. Falls back to a
* fresh (allocating) view otherwise — never exercised on the real request path.
*/
ByteView view(String name) {
@@ -0,0 +1,62 @@
package dev.relism.flash.models;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
/**
* A header name/value pair pre-encoded once (typically at boot, as a {@code static final}
* constant) and reused across many responses via {@link Response#header(PreEncodedHeader)}.
*
* <h3>{@code EX-20}: why this exists alongside {@link Response#header(byte[])}</h3>
* The older {@code header(byte[])} overload takes an already-fully-rendered h1 field line
* (e.g. {@code "X-RateLimit-Limit: 100\r\n"}) — fine for h1, but not valid HPACK: HPACK encodes
* a header as a compressed (name, value) pair, never as a literal CRLF-terminated line, so a
* pre-rendered h1 line carries no information an HPACK encoder could reuse. {@code
* PreEncodedHeader} instead precomputes the {@code name}/{@code value} bytes <em>separately</em>
* (still once, still at boot) so either protocol's writer can render them in its own format —
* {@link Response#header(byte[])} is kept, working, for h1-only callers, but is documented as
* ignored on a future h2 response path (there is no way to recover structured name/value data
* from an opaque pre-rendered line); prefer this class for any header a handler wants to send on
* both protocols.
*
* <p>The HPACK-encoded rendering itself is Phase 9 scope (no HPACK encoder exists yet) — this
* class stores the raw {@code name}/{@code value} bytes now, which is everything a future HPACK
* encoder needs to produce its own rendering from; it does not yet expose a precomputed HPACK
* byte form, since building one before HPACK exists would be speculative, untested API surface.
*/
public final class PreEncodedHeader {
private final byte[] nameBytes;
private final byte[] valueBytes;
public PreEncodedHeader(String name, String value) {
this.nameBytes = name.getBytes(StandardCharsets.US_ASCII);
this.valueBytes = value.getBytes(StandardCharsets.US_ASCII);
}
/** The header name's ASCII bytes, case as given to the constructor. Never copy-on-read — treat as immutable. */
byte[] nameBytes() {
return nameBytes;
}
/** The header value's ASCII bytes. Never copy-on-read — treat as immutable. */
byte[] valueBytes() {
return valueBytes;
}
@Override
public String toString() {
return new String(nameBytes, StandardCharsets.US_ASCII) + ": " + new String(valueBytes, StandardCharsets.US_ASCII);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof PreEncodedHeader other)) return false;
return Arrays.equals(nameBytes, other.nameBytes) && Arrays.equals(valueBytes, other.valueBytes);
}
@Override
public int hashCode() {
return 31 * Arrays.hashCode(nameBytes) + Arrays.hashCode(valueBytes);
}
}
@@ -42,7 +42,7 @@ public class QueryParams {
* 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
* same reuse-window contract as {@link Http1HeaderMap#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).
@@ -1,27 +1,23 @@
package dev.relism.flash.models;
import dev.relism.flash.Flash;
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;
import lombok.Getter;
import lombok.ToString;
import lombok.Value;
import lombok.experimental.NonFinal;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocket;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* Immutable view of an incoming HTTP/1.1 request. Constructed by {@link RequestParser}
* and passed directly to route handlers; never modified after creation (path/query params are
* injected once by the router before the handler runs).
* View of an incoming HTTP/1.1 request. Constructed once per connection by {@link RequestParser}
* and repositioned (never reallocated) for every request on that connection — never modified by
* user code after creation (path/query params are injected once by the router before the
* handler runs).
*
* <pre>{@code
* server.get("/users/{id}", (req, res) -> {
@@ -32,62 +28,103 @@ import java.util.List;
* InputStream in = req.body().stream(); // zero-copy stream
* });
* }</pre>
*
* <h3>{@code EX-22}: pooled, not allocated per request</h3>
* A {@code Request} instance is owned by its connection (HTTP/1.1) or its stream (HTTP/2) and is
* recycled after the handler returns. <b>Do not retain it</b> — the same instance is repositioned
* over the next request's data as soon as this one's handler returns. {@code equals}/
* {@code hashCode} are the inherited identity-based {@link Object} versions and are meaningless
* across requests (compare two different {@code Request}s from the same connection and they may
* be {@code ==} to each other despite describing entirely different requests, at different
* points in time). {@code String} values returned by {@link #path()}, {@link #header(String)},
* {@link #param(String)}, {@link #query(String)} are independent heap copies and are always safe
* to retain past the handler.
*
* <h3>Dev-mode use-after-recycle guard</h3>
* When {@link Flash#DEV} is {@code true}, every accessor checks that this instance is still the
* one currently being handled; a call after the handler has already returned (e.g. from a
* captured reference in an async callback, a {@link java.util.concurrent.CompletableFuture}
* continuation, or a background thread) throws {@link IllegalStateException} immediately,
* loudly, and at the exact call site that misused it — instead of silently reading whatever the
* next (or a completely different) request happened to reset this instance to. In production
* this check is a single {@code boolean} field read gated behind a {@code static final} flag the
* JIT treats as a trusted constant once the class is initialized — see {@code DECISIONS.md} for
* the measured cost.
*/
@Value
@ToString
public class Request {
@Getter(lombok.AccessLevel.NONE)
@EqualsAndHashCode.Exclude
@ToString.Exclude
RequestBody body;
private RequestBody body;
/** Internal: the parsed request line (method, path, query, protocol, headers). */
RequestLine requestLine;
private RequestLine requestLine;
@NonFinal PathParams pathParams;
@NonFinal QueryParams queryParams;
@NonFinal String cachedPath;
private PathParams pathParams;
private QueryParams queryParams;
private String cachedPath;
private InetSocketAddress remoteAddress;
private SSLSocket sslSocket;
// EX-22 dev-mode poisoning guard: true from reset() until recycle() marks this instance
// unsafe to use further. Only consulted when poisoningEnabled is true (see checkActive()).
private boolean active;
// Defaults to the real Flash.DEV value. Flash.DEV is a static final boolean fixed once at
// JVM startup (from a system property), so no individual test can toggle it — this field
// exists solely so RequestRecycleGuardTest can exercise the dev-mode branch without a
// fragile reflective override of a `static final` field. Package-private: only this
// package's own tests reach for it; production code never touches it.
private static volatile boolean poisoningEnabled = Flash.DEV;
/** Test-only override of the dev-mode poisoning check — see the field's own comment. */
static void setPoisoningEnabledForTesting(boolean enabled) {
poisoningEnabled = enabled;
}
/** Pooled instance, populated later via {@link #reset}. One per connection — see {@link RequestParser}. */
public Request() {
}
/** Test / manual constructor — {@code remoteAddress()} returns {@code null}, {@code isSecure()} is {@code false}. */
public Request(RequestLine requestLine, byte[] body) {
reset(requestLine, RequestBody.of(body), null, null);
}
/**
* Remote socket address of the connected client. Set once at connection time from
* {@link java.net.Socket#getRemoteSocketAddress()} : the {@link InetSocketAddress}
* object already exists in the JDK and is passed by reference: zero allocation,
* zero copy. {@code null} only in test-constructed requests.
*
* <p>Use {@link #remoteAddress()} to access it. String conversion
* ({@code .getAddress().getHostAddress()}) is deferred to the caller, lazy and
* only paid when actually needed.
* Repositions this instance over a new request. Package-private: only {@link RequestParser}
* (same package) calls this — user code never constructs or resets a {@code Request}
* directly outside the test constructor above.
*/
@Getter(lombok.AccessLevel.NONE)
@EqualsAndHashCode.Exclude
@ToString.Exclude
InetSocketAddress remoteAddress;
/**
* The accepted socket for this connection, or {@code null} if plain HTTP — set once per
* connection by {@link RequestParser}, same lifetime and reference-only cost as
* {@link #remoteAddress}. Every request on the same keep-alive connection shares the
* identical instance.
*
* <p>Never exposed directly: {@link #isSecure()} and {@link #sslSession()} are the public
* surface. {@link javax.net.ssl.SSLSocket#getSession()} is deferred to {@link #sslSession()}
* rather than called here — by the time a handler can call it, the handshake this connection
* needed to reach the handler has already completed, so it is a cached-field read, never a
* forced handshake.
*/
@Getter(lombok.AccessLevel.NONE)
@EqualsAndHashCode.Exclude
@ToString.Exclude
SSLSocket sslSocket;
private Request(RequestLine requestLine, RequestBody body, InetSocketAddress remoteAddress, SSLSocket sslSocket) {
void reset(RequestLine requestLine, RequestBody body, InetSocketAddress remoteAddress, SSLSocket sslSocket) {
this.requestLine = requestLine;
this.body = body;
this.pathParams = null;
this.queryParams = null;
this.cachedPath = null;
this.remoteAddress = remoteAddress;
this.sslSocket = sslSocket;
this.active = true;
}
/**
* Marks this instance unsafe for further use. Called by the connection driver (e.g.
* {@code Http1Connection}) once the handler (and any automatic post-handler work, e.g.
* {@link #drain()}) has finished with it, before the connection loop reuses it for the next
* request — {@code public} because the connection driver lives in a different package
* (matching {@link RequestLine#reset}'s own precedent), not because user code should ever
* call it. A no-op in production beyond the field write — see the class Javadoc's dev-mode
* guard section.
*/
public void recycle() {
this.active = false;
}
private void checkActive() {
if (poisoningEnabled && !active) {
throw new IllegalStateException(
"Request used after the handler returned — do not retain a Request past the "
+ "handler; copy any String values you need instead");
}
}
/**
@@ -97,31 +134,30 @@ public class Request {
*/
void setPathParams(PathParams p) { this.pathParams = p; }
/** Test / manual constructor — {@code remoteAddress()} returns {@code null}, {@code isSecure()} is {@code false}. */
public Request(RequestLine requestLine, byte[] body) {
this(requestLine, RequestBody.of(body), null, null);
}
public static Request forParsed(RequestLine requestLine, InputStream stream,
long contentLength, byte[] headerBuf,
int bodyStart, int preBufLen,
/**
* Repositions {@code pooled} over a freshly-parsed request. {@code body} is already fully
* configured by the caller ({@code RequestParser}, which owns and resets its own pooled
* {@link RequestBody} for the fixed-length/chunked/empty cases — see {@code EX-22}) — this
* method's only job is wiring it, {@code requestLine}, and the connection identity fields
* into {@code pooled}.
*/
public static Request forParsed(Request pooled, RequestLine requestLine, RequestBody body,
InetSocketAddress remoteAddress, SSLSocket sslSocket) {
RequestBody rb = contentLength > 0 ? new RequestBody(stream, contentLength, headerBuf, bodyStart, preBufLen)
: contentLength == 0 ? RequestBody.empty()
: /* chunked */ new RequestBody(stream, -1L, null, 0, 0);
return new Request(requestLine, rb, remoteAddress, sslSocket);
pooled.reset(requestLine, body, remoteAddress, sslSocket);
return pooled;
}
// ── Request line ──────────────────────────────────────────────────────────
/** HTTP method ({@code GET}, {@code POST}, …). */
public HttpMethod method() { return requestLine.getMethod(); }
public HttpMethod method() { checkActive(); return requestLine.getMethod(); }
/**
* Request path decoded as UTF-8. Includes a leading slash; never includes the query string.
* Example: a request for {@code /users/42?page=1} returns {@code "/users/42"}.
*/
public String path() {
checkActive();
if (cachedPath != null) return cachedPath;
ByteView v = requestLine.getPath();
// EX-25: one allocation via a direct String(array, offset, length) construction when the
@@ -141,20 +177,20 @@ public class Request {
* Returns the first value of header {@code name}, or {@code null} if absent.
* Lookup is case-insensitive ({@code "content-type"} and {@code "Content-Type"} are equivalent).
*/
public String header(String name) { return requestLine.getHeaders().first(name); }
public String header(String name) { checkActive(); return requestLine.getHeaders().first(name); }
/**
* Returns all values of header {@code name} in declaration order.
* Useful for headers that appear multiple times (e.g. {@code Accept}, {@code Cookie}).
* Lookup is case-insensitive. Returns an empty list if the header is absent.
*/
public List<String> headers(String name) { return requestLine.getHeaders().all(name); }
public List<String> headers(String name) { checkActive(); return requestLine.getHeaders().all(name); }
/**
* Returns all header values in declaration order, one entry per header line.
* Useful for debugging; for targeted access prefer {@link #header(String)}.
*/
public List<String> headers() { return requestLine.getHeaders().all(); }
public List<String> headers() { checkActive(); return requestLine.getHeaders().all(); }
// ── Path parameters ───────────────────────────────────────────────────────
@@ -164,7 +200,7 @@ public class Request {
* injected by the router before the handler runs. Returns {@code null} if this
* route has no such parameter or the route is not parametric.
*/
public String param(String name) { return pathParams != null ? pathParams.get(name) : null; }
public String param(String name) { checkActive(); return pathParams != null ? pathParams.get(name) : null; }
// ── Query parameters ──────────────────────────────────────────────────────
@@ -173,14 +209,14 @@ public class Request {
* The query string is parsed lazily on the first call and cached for the request lifetime.
* For {@code ?a=1&a=2}, returns {@code "1"}.
*/
public String query(String name) { return resolveQueryParams().get(name); }
public String query(String name) { checkActive(); return resolveQueryParams().get(name); }
/**
* Returns all query parameters named {@code name} in declaration order.
* For {@code ?tag=a&tag=b}, returns {@code ["a", "b"]}.
* Returns an empty list if the parameter is absent.
*/
public List<String> queries(String name) { return resolveQueryParams().getAll(name); }
public List<String> queries(String name) { checkActive(); return resolveQueryParams().getAll(name); }
// ── Remote address ────────────────────────────────────────────────────────
@@ -196,12 +232,12 @@ public class Request {
* if (addr != null) String ip = addr.getAddress().getHostAddress();
* }</pre>
*/
public InetSocketAddress remoteAddress() { return remoteAddress; }
public InetSocketAddress remoteAddress() { checkActive(); return remoteAddress; }
// ── TLS ───────────────────────────────────────────────────────────────────
/** Whether this request arrived over TLS (HTTPS). */
public boolean isSecure() { return sslSocket != null; }
public boolean isSecure() { checkActive(); return sslSocket != null; }
/**
* Returns the TLS session for this connection, or {@code null} for plain HTTP.
@@ -211,7 +247,7 @@ public class Request {
* diagnostics. {@code null} rather than throwing when {@link #isSecure()} is {@code false} —
* check that first, or just null-check the result.
*/
public SSLSession sslSession() { return sslSocket != null ? sslSocket.getSession() : null; }
public SSLSession sslSession() { checkActive(); return sslSocket != null ? sslSocket.getSession() : null; }
// ── Body ──────────────────────────────────────────────────────────────────
@@ -220,15 +256,22 @@ public class Request {
* the full body or {@link RequestBody#stream()} for zero-copy streaming access.
* The two modes are mutually exclusive per request.
*/
public RequestBody body() { return body; }
public RequestBody body() { checkActive(); return body; }
/** Discards unread body bytes; called by the server after each request on keep-alive connections. */
public void drain() { body.drain(); }
// ── Internal ─────────────────────────────────────────────────────────────
/** Internal: the parsed request line (method, path, query, protocol, headers). */
public RequestLine getRequestLine() { checkActive(); return requestLine; }
/** Internal: path parameters injected by the router, or {@code null} if none matched. */
public PathParams getPathParams() { checkActive(); return pathParams; }
/** Internal: case-insensitive header value comparison used by the server keep-alive logic. */
public boolean headerEquals(String name, String value) {
checkActive();
return requestLine.getHeaders().valueEqualsIgnoreCase(name, value);
}
@@ -239,4 +282,10 @@ public class Request {
}
return queryParams;
}
@Override
public String toString() {
return "Request(method=" + (requestLine != null ? requestLine.getMethod() : null)
+ ", path=" + (requestLine != null ? requestLine.getPath() : null) + ")";
}
}
@@ -10,9 +10,9 @@ import java.io.*;
* Safe to call multiple times; the second call returns the cached array. Throws for
* bodies larger than 2 GB.</li>
* <li>{@link #stream()} — returns a bounded {@link InputStream} without upfront allocation.
* For fixed-length bodies this is a view into the already-buffered header bytes stitched
* to the socket; for chunked bodies it is the raw {@link dev.relism.ChunkedInputStream}
* that de-chunks on the fly.</li>
* For fixed-length bodies this is a reused, repositioned view (see {@code EX-23} below)
* into the already-buffered header bytes stitched to the socket; for chunked bodies it is
* the raw {@link dev.relism.ChunkedInputStream} that de-chunks on the fly.</li>
* </ul>
*
* <p><b>Mutual exclusivity:</b> calling both {@code bytes()} and {@code stream()} on the same
@@ -20,37 +20,68 @@ import java.io.*;
*
* <p><b>Keep-alive:</b> unread body bytes are discarded by {@link Request#drain()} after the
* handler returns so the socket is correctly positioned for the next pipelined request.
*
* <h3>{@code EX-22}: pooled, not allocated per request</h3>
* One instance per connection (owned by {@code RequestParser}, repositioned via {@link #reset}
* for every request), the same treatment {@link Request}/{@link RequestLine} get. The {@link
* #of(byte[])} factory below remains for test/manual construction and returns a freestanding,
* unpooled instance — exactly like {@link Request}'s own manual constructor.
*
* <h3>{@code EX-23}/{@code EX-24}: the reusable bounded stream and drain buffer</h3>
* {@link #stream()} used to allocate a {@link SequenceInputStream}, a {@link ByteArrayInputStream}
* and an anonymous bounded {@link InputStream} on every call. It now hands out one persistent
* {@link BoundedBufferedInputStream}, repositioned per request instead of reallocated.
* {@link #drain()}'s chunked-body path used to call {@code InputStream.transferTo}, which
* allocates a fresh 8 KiB {@code byte[]} internally on every call (the JDK default
* implementation); it now drains through a lazily-created, persistent buffer instead.
*/
public final class RequestBody {
private static final byte[] EMPTY_BYTES = new byte[0];
private final InputStream socket;
private final long contentLength;
private final byte[] preBuf;
private final int preBufOff;
private final int preBufLen;
public class RequestBody {
private InputStream socket;
private long contentLength;
private byte[] preBuf;
private int preBufOff;
private int preBufLen;
private byte[] resolved;
private long socketConsumed;
// EX-23: created once, repositioned per request via reset()'s call into boundedStream.reset(...).
private BoundedBufferedInputStream boundedStream;
// EX-24: created lazily on first chunked-body drain(), then reused for the life of the connection.
private byte[] drainBuffer;
/** Pooled instance, populated later via {@link #reset}. One per connection — see {@code RequestParser}. */
public RequestBody() {
}
RequestBody(InputStream socket, long contentLength, byte[] preBuf, int preBufOff, int preBufLen) {
reset(socket, contentLength, preBuf, preBufOff, preBufLen);
}
/** Pre-resolved body: test payloads and empty body — skips all I/O. Always a freestanding, unpooled instance. */
private RequestBody(byte[] preResolved) {
reset(null, preResolved.length, null, 0, 0);
this.resolved = preResolved;
}
/**
* Repositions this instance over a new request. {@code public} because {@code RequestParser}
* (a different package) owns and resets its own pooled instance directly — matching
* {@link RequestLine#reset}'s precedent — not because user code should ever call it.
*/
public void reset(InputStream socket, long contentLength, byte[] preBuf, int preBufOff, int preBufLen) {
this.socket = socket;
this.contentLength = contentLength;
this.preBuf = preBuf;
this.preBufOff = preBufOff;
this.preBufLen = preBufLen;
this.resolved = null;
this.socketConsumed = 0;
}
/** Pre-resolved body: test payloads and empty body — skips all I/O. */
private RequestBody(byte[] preResolved) {
this(null, preResolved.length, null, 0, 0);
this.resolved = preResolved;
}
private static final RequestBody EMPTY = new RequestBody(EMPTY_BYTES);
static RequestBody of(byte[] bytes) { return new RequestBody(bytes); }
static RequestBody empty() { return EMPTY; }
static RequestBody empty() { return new RequestBody(new byte[0]); }
/** {@code true} if the body has zero bytes ({@code Content-Length: 0} or no body). */
public boolean isEmpty() { return contentLength == 0; }
@@ -96,54 +127,92 @@ public final class RequestBody {
/**
* Returns a bounded {@link InputStream} over the body without upfront allocation.
*
* <p>For fixed-length bodies: a {@link SequenceInputStream} of any already-buffered header
* bytes followed by a bounded view of the socket stream — zero heap beyond those small
* pre-buffered bytes.
* <p>For fixed-length bodies: a reused {@link BoundedBufferedInputStream} (see the class
* Javadoc, {@code EX-23}) serving any already-buffered header bytes followed by a bounded
* view of the socket stream — zero allocation on a warm connection.
*
* <p>For chunked bodies: the raw {@link dev.relism.ChunkedInputStream} that de-chunks on
* the fly; EOF signals the end of the logical body and leaves the socket positioned for
* the next keep-alive request.
*
* <p>If {@link #bytes()} was called first, returns a fresh {@link java.io.ByteArrayInputStream}
* over the cached array.
* over the cached array — a rare dual-access pattern, not the hot path {@code EX-23} targets.
*/
public InputStream stream() {
if (resolved != null) return new ByteArrayInputStream(resolved);
if (contentLength < 0) return socket; // ChunkedInputStream — EOF signals end of body
if (boundedStream == null) boundedStream = new BoundedBufferedInputStream();
int fromBuf = (int) Math.min(preBufLen, contentLength);
long fromSocket = contentLength - fromBuf;
InputStream bufPart = new ByteArrayInputStream(preBuf, preBufOff, fromBuf);
return fromSocket == 0 ? bufPart : new SequenceInputStream(bufPart, bounded(socket, fromSocket));
boundedStream.reset(preBuf, preBufOff, fromBuf, fromSocket);
return boundedStream;
}
/** Discards unread body bytes to reposition the socket for the next keep-alive request. */
void drain() {
if (isEmpty() || resolved != null) return;
if (contentLength < 0) {
try { socket.transferTo(OutputStream.nullOutputStream()); } catch (IOException ignored) {}
// EX-24: InputStream.transferTo's default implementation allocates a fresh 8 KiB
// byte[] on every call — replaced with a buffer this instance allocates once
// (lazily, only if a chunked body is ever actually drained) and reuses thereafter.
if (drainBuffer == null) drainBuffer = new byte[8192];
try {
while (socket.read(drainBuffer) > 0) { /* discard */ }
} catch (IOException ignored) {
}
return;
}
long remaining = (contentLength - preBufLen) - socketConsumed;
if (remaining > 0) try { socket.skipNBytes(remaining); } catch (IOException ignored) {}
}
private InputStream bounded(InputStream src, long limit) {
return new InputStream() {
private long left = limit;
/**
* {@code EX-23}: a reused, repositionable {@link InputStream} that serves bytes first from a
* caller-owned pre-buffered array, then from the socket, bounded overall to a fixed length —
* replacing the {@code SequenceInputStream}+{@code ByteArrayInputStream}+anonymous-bounded-
* stream trio that used to be allocated fresh on every {@link #stream()} call. One instance
* lives on the owning {@link RequestBody} for the whole connection; {@link #reset} repositions
* it for each new request.
*/
private final class BoundedBufferedInputStream extends InputStream {
private byte[] preBuf;
private int preBufPos;
private int preBufRemaining;
private long socketRemaining;
@Override public int read() throws IOException {
if (left == 0) return -1;
int b = src.read();
if (b >= 0) { left--; socketConsumed++; }
return b;
void reset(byte[] preBuf, int preBufOff, int preBufLen, long socketRemaining) {
this.preBuf = preBuf;
this.preBufPos = preBufOff;
this.preBufRemaining = preBufLen;
this.socketRemaining = socketRemaining;
}
@Override
public int read() throws IOException {
if (preBufRemaining > 0) {
preBufRemaining--;
return preBuf[preBufPos++] & 0xFF;
}
if (socketRemaining == 0) return -1;
int b = socket.read();
if (b >= 0) { socketRemaining--; socketConsumed++; }
return b;
}
@Override public int read(byte[] buf, int off, int len) throws IOException {
if (left == 0) return -1;
int n = src.read(buf, off, (int) Math.min(len, left));
if (n > 0) { left -= n; socketConsumed += n; }
@Override
public int read(byte[] dst, int off, int len) throws IOException {
if (len == 0) return 0;
if (preBufRemaining > 0) {
int n = Math.min(len, preBufRemaining);
System.arraycopy(preBuf, preBufPos, dst, off, n);
preBufPos += n;
preBufRemaining -= n;
return n;
}
};
if (socketRemaining == 0) return -1;
int n = socket.read(dst, off, (int) Math.min(len, socketRemaining));
if (n > 0) { socketRemaining -= n; socketConsumed += n; }
return n;
}
}
}
@@ -2,16 +2,65 @@ package dev.relism.flash.models;
import dev.relism.fpr.core.ByteView;
import dev.relism.flash.http.HttpMethod;
import lombok.ToString;
import lombok.Value;
@ToString
@Value
/**
* The parsed request line plus headers: method, path, optional query, optional protocol token,
* and the header container. Internal — reached via {@link Request#getRequestLine()}, not
* user-facing API.
*
* <h3>Pooled, like {@link Request} ({@code EX-22})</h3>
* One instance per connection, repositioned via {@link #reset} for every request rather than
* reallocated — {@code RequestParser} owns it exactly the way it owns {@link Http1HeaderMap}.
* {@link #reset} is {@code public} rather than package-private — matching
* {@link Http1HeaderMap#reset}'s and {@link PathParams#reset}'s own precedent — because
* {@code RequestParser} (the owner and sole caller) lives in a different package
* ({@code dev.relism.flash}, not {@code dev.relism.flash.models}). The public constructor below
* remains for test/manual construction and simply delegates to {@link #reset}.
*
* <h3>{@code protocol} is optional</h3>
* HTTP/1.1 always has a protocol token on the wire ({@code "HTTP/1.1"}); HTTP/2 has no equivalent
* — a stream's version is implicit in which connection it belongs to. {@link #getProtocol()} may
* be {@code null} for a header container built by a future non-h1 implementation; h1 always
* supplies a non-null value today.
*/
public class RequestLine {
HttpMethod method;
ByteView path;
/** Raw query string bytes (after {@code ?}), {@code null} if the URI has no query string. */
ByteView query;
ByteView protocol;
HeaderMap headers;
private HttpMethod method;
private ByteView path;
private ByteView query;
private ByteView protocol;
private HeaderView headers;
/** Pooled instance, populated later via {@link #reset}. */
public RequestLine() {
}
/** Test / manual construction — delegates to {@link #reset}. */
public RequestLine(HttpMethod method, ByteView path, ByteView query, ByteView protocol, HeaderView headers) {
reset(method, path, query, protocol, headers);
}
/** Repositions this instance over a new request. See the class Javadoc for why this is {@code public}. */
public void reset(HttpMethod method, ByteView path, ByteView query, ByteView protocol, HeaderView headers) {
this.method = method;
this.path = path;
this.query = query;
this.protocol = protocol;
this.headers = headers;
}
public HttpMethod getMethod() { return method; }
public ByteView getPath() { return path; }
/** Raw query string bytes (after {@code ?}), or {@code null} if the URI has no query string. */
public ByteView getQuery() { return query; }
/** The wire protocol token (e.g. {@code "HTTP/1.1"}), or {@code null} — see the class Javadoc. */
public ByteView getProtocol() { return protocol; }
public HeaderView getHeaders() { return headers; }
@Override
public String toString() {
return "RequestLine(method=" + method + ", path=" + path + ")";
}
}
@@ -1,16 +1,17 @@
package dev.relism.flash.models;
import dev.relism.flash.Flash;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.Http1Limits;
import dev.relism.flash.http.HttpStatus;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
@@ -26,20 +27,57 @@ import java.util.List;
* // unknown-length stream → Transfer-Encoding: chunked
* return new Response(200, ContentType.TEXT_PLAIN).chunked(source);
* }</pre>
*
* <h3>{@code EX-21}: pooled, not allocated per request</h3>
* The connection driver (e.g. {@code Http1Connection}) owns one {@code Response} instance per
* connection, reset before every handler call rather than reallocated — the same treatment
* {@link Request} gets (see its Javadoc for the full pooling/dev-mode-guard rationale, which
* applies identically here). <b>A handler that returns a different {@code Response} instance</b>
* (e.g. {@code return new Response(404, "Not Found", ContentType.TEXT_PLAIN);}) is fully
* supported — that instance is a normal, unpooled, freshly-constructed object like any
* public-constructor {@code Response} always was; only the connection driver's own default
* instance is pooled and poisoned after use.
*/
@Getter
@ToString
public class Response {
@Setter private int statusCode;
private byte[] statusBytes; // pre-encoded "200 OK"; null when set via status(int)
private byte[] body;
@ToString.Exclude
private InputStream stream;
private long streamLength; // meaningful only when isStreaming() && !chunked
private boolean chunked;
private byte[] contentType;
@Getter(lombok.AccessLevel.NONE)
private List<byte[]> headers; // pre-encoded "Name: Value\r\n" entries
private int statusCode;
private byte[] statusBytes; // pre-encoded "200 OK"; null when set via status(int)
private byte[] body;
private InputStream stream;
private long streamLength; // meaningful only when isStreaming() && !chunked
private boolean chunked;
private byte[] contentType;
// EX-20: custom headers stored as (name, value) byte pairs in one growable region, instead
// of a List<byte[]> of fully-rendered "Name: Value\r\n" lines (which cost a StringBuilder +
// char[] + String + getBytes() chain per header(String,String) call). Two backing stores,
// unified into one insertion-ordered sequence via headerTags/headerRefs, since a fully
// pre-rendered line (the legacy header(byte[]) overload) has no name/value structure to
// decompose into the same region:
// tag 0 -> a (name, value) pair; headerRefs[i] indexes headerQuads (groups of 4)
// tag 1 -> a raw pre-rendered line; headerRefs[i] indexes rawHeaderLines
private ByteWriter headerRegion; // tag-0 storage: name+value bytes back to back
private int[] headerQuads; // tag-0 storage: groups of (nameOff,nameLen,valOff,valLen)
private int headerQuadCount;
private List<byte[]> rawHeaderLines; // tag-1 storage: legacy header(byte[]) entries, verbatim
private byte[] headerTags; // one entry per header(), in call order: 0 or 1
private int[] headerRefs; // one entry per header(), in call order: index into the tag's store
private int headerCount; // total header() calls this response has recorded
// EX-21 dev-mode poisoning guard -- see Request's identical mechanism for the full rationale.
private boolean active = true;
private static volatile boolean poisoningEnabled = Flash.DEV;
/** Test-only override of the dev-mode poisoning check — mirrors {@code Request}'s identical hook. */
static void setPoisoningEnabledForTesting(boolean enabled) {
poisoningEnabled = enabled;
}
private void checkActive() {
if (poisoningEnabled && !active) {
throw new IllegalStateException(
"Response used after the handler returned — do not retain a Response past the handler");
}
}
// -------------------------------------------------------------------------
// Constructors
@@ -59,20 +97,57 @@ public class Response {
this(statusCode, text.getBytes(StandardCharsets.UTF_8), contentType);
}
// -------------------------------------------------------------------------
// Pooling
// -------------------------------------------------------------------------
/**
* Repositions this instance for a new request/response cycle — clears the body, stream,
* status, content type, and every header recorded by the previous cycle. Public because the
* connection driver that owns the pooled instance lives in a different package (matching
* {@link RequestLine#reset}'s precedent); user code never calls this.
*/
public Response reset(int statusCode, ContentType contentType) {
this.statusCode = statusCode;
this.statusBytes = null;
this.body = null;
this.stream = null;
this.streamLength = 0;
this.chunked = false;
this.contentType = contentType.getBytes();
this.headerQuadCount = 0;
this.headerCount = 0;
if (rawHeaderLines != null) rawHeaderLines.clear();
this.active = true;
return this;
}
/**
* Marks this instance unsafe for further use — see {@link Request#recycle()} for the full
* rationale, identical here. {@code public} for the same cross-package reason.
*/
public void recycle() {
this.active = false;
}
// -------------------------------------------------------------------------
// Fluent mutators
// -------------------------------------------------------------------------
/** Sets the status code. The phrase is looked up from {@link HttpStatus} on the write path. */
public Response status(int code) { this.statusCode = code; this.statusBytes = null; return this; }
public Response status(int code) { checkActive(); this.statusCode = code; this.statusBytes = null; return this; }
/** Lombok-style setter kept for API compatibility — equivalent to {@link #status(int)} without the fluent return. */
public void setStatusCode(int code) { status(code); }
/** Sets the status from an {@link HttpStatus} constant. The pre-encoded bytes are used
* directly on the write path — zero lookup, zero allocation. */
public Response status(HttpStatus status) { this.statusCode = status.code(); this.statusBytes = status.bytes(); return this; }
public Response type(ContentType ct) { this.contentType = ct.getBytes(); return this; }
public Response type(String ct) { this.contentType = ct.getBytes(StandardCharsets.UTF_8); return this; }
public Response status(HttpStatus status) { checkActive(); this.statusCode = status.code(); this.statusBytes = status.bytes(); return this; }
public Response type(ContentType ct) { checkActive(); this.contentType = ct.getBytes(); return this; }
public Response type(String ct) { checkActive(); this.contentType = ct.getBytes(StandardCharsets.UTF_8); return this; }
public Response body(byte[] bytes) {
checkActive();
this.body = bytes;
this.stream = null;
return this;
@@ -84,6 +159,7 @@ public class Response {
/** Streaming response with known length; written with {@code Content-Length}. */
public Response stream(InputStream is, long length) {
checkActive();
this.stream = is;
this.streamLength = length;
this.chunked = false;
@@ -93,6 +169,7 @@ public class Response {
/** Streaming response with unknown length; written with {@code Transfer-Encoding: chunked}. */
public Response chunked(InputStream is) {
checkActive();
this.stream = is;
this.chunked = true;
this.body = null;
@@ -121,37 +198,150 @@ public class Response {
* }</pre>
*/
public Response redirect(HttpStatus status, String url) {
checkActive();
this.statusCode = status.code();
this.statusBytes = status.bytes();
this.body = null;
this.stream = null;
if (headers == null) headers = new ArrayList<>();
headers.add(("Location: " + url + "\r\n").getBytes(StandardCharsets.UTF_8));
return this;
return header("Location", url);
}
/** Adds a response header. Encoded once at call time; zero-alloc on the write path. */
/**
* Adds a response header. {@code EX-20}: writes {@code name}/{@code value} directly into a
* reused byte region (via {@link ByteWriter#writeAscii}) instead of building an intermediate
* {@code String} and re-encoding it — zero allocation once the region has grown to this
* connection's high-water mark.
*/
public Response header(String name, String value) {
if (headers == null) headers = new ArrayList<>();
headers.add((name + ": " + value + "\r\n").getBytes(StandardCharsets.UTF_8));
checkActive();
checkHeaderBudget();
if (headerRegion == null) {
headerRegion = new ByteWriter(128);
headerQuads = new int[16];
}
ensureQuadCapacity(headerQuadCount + 1);
int nameOff = headerRegion.length();
headerRegion.writeAscii(name);
int nameLen = headerRegion.length() - nameOff;
int valOff = headerRegion.length();
headerRegion.writeAscii(value);
int valLen = headerRegion.length() - valOff;
checkHeaderRegionBudget();
int base = headerQuadCount * 4;
headerQuads[base] = nameOff;
headerQuads[base + 1] = nameLen;
headerQuads[base + 2] = valOff;
headerQuads[base + 3] = valLen;
recordHeaderEntry((byte) 0, headerQuadCount);
headerQuadCount++;
return this;
}
/**
* Adds a pre-encoded header (e.g. a static {@code "X-RateLimit-Limit: 100\r\n"} byte array
* pre-built at boot time). Zero-alloc on both the call path and the write path.
* Adds a header from a {@link PreEncodedHeader} built once (typically at boot). Copies its
* precomputed {@code name}/{@code value} bytes into this response's region — a memcpy, not a
* re-encode, and usable by a future h2 response path (unlike {@link #header(byte[])}) since
* the name/value structure survives.
*/
public Response header(PreEncodedHeader preEncoded) {
checkActive();
checkHeaderBudget();
if (headerRegion == null) {
headerRegion = new ByteWriter(128);
headerQuads = new int[16];
}
ensureQuadCapacity(headerQuadCount + 1);
byte[] nameBytes = preEncoded.nameBytes();
byte[] valueBytes = preEncoded.valueBytes();
int nameOff = headerRegion.length();
headerRegion.writeBytes(nameBytes);
int valOff = headerRegion.length();
headerRegion.writeBytes(valueBytes);
checkHeaderRegionBudget();
int base = headerQuadCount * 4;
headerQuads[base] = nameOff;
headerQuads[base + 1] = nameBytes.length;
headerQuads[base + 2] = valOff;
headerQuads[base + 3] = valueBytes.length;
recordHeaderEntry((byte) 0, headerQuadCount);
headerQuadCount++;
return this;
}
/**
* Adds a pre-encoded, fully-rendered header line (e.g. a static
* {@code "X-RateLimit-Limit: 100\r\n"} byte array pre-built at boot time). Zero-alloc on
* both the call path and the h1 write path.
*
* <p><b>h1-only</b>: a rendered {@code "Name: Value\r\n"} line carries no structured
* name/value data an HPACK encoder could use, so this header is not representable on a
* future h2 response path — prefer {@link #header(PreEncodedHeader)} for anything that must
* render correctly on both protocols. Kept for existing h1-only callers.
*/
public Response header(byte[] preEncoded) {
if (headers == null) headers = new ArrayList<>();
headers.add(preEncoded);
checkActive();
checkHeaderBudget();
if (rawHeaderLines == null) rawHeaderLines = new ArrayList<>();
rawHeaderLines.add(preEncoded);
recordHeaderEntry((byte) 1, rawHeaderLines.size() - 1);
return this;
}
/**
* {@code EX-nn}: bounds the response-side analogue of the request header limits — a handler
* that calls {@code header(...)} in an unbounded loop must not grow this connection's
* per-request scratch state without limit (Phase 6's zero-alloc DoD names this explicitly).
*/
private void checkHeaderBudget() {
if (headerCount >= Http1Limits.MAX_RESPONSE_HEADER_COUNT) {
throw new IllegalStateException("response exceeds " + Http1Limits.MAX_RESPONSE_HEADER_COUNT
+ " headers — check for an unbounded loop calling header(...)");
}
}
private void checkHeaderRegionBudget() {
if (headerRegion.length() > Http1Limits.MAX_RESPONSE_HEADER_BYTES) {
throw new IllegalStateException("response header region exceeds "
+ Http1Limits.MAX_RESPONSE_HEADER_BYTES + " bytes — check for an unbounded loop or an oversized value passed to header(...)");
}
}
private void recordHeaderEntry(byte tag, int ref) {
if (headerTags == null) {
headerTags = new byte[16];
headerRefs = new int[16];
} else if (headerCount == headerTags.length) {
int grown = headerTags.length * 2;
headerTags = Arrays.copyOf(headerTags, grown);
headerRefs = Arrays.copyOf(headerRefs, grown);
}
headerTags[headerCount] = tag;
headerRefs[headerCount] = ref;
headerCount++;
}
private void ensureQuadCapacity(int neededQuads) {
int neededInts = neededQuads * 4;
if (neededInts <= headerQuads.length) return;
int grown = headerQuads.length;
while (grown < neededInts) grown *= 2;
headerQuads = Arrays.copyOf(headerQuads, grown);
}
// -------------------------------------------------------------------------
// State queries
// -------------------------------------------------------------------------
public boolean isStreaming() { return stream != null; }
public boolean isStreaming() { checkActive(); return stream != null; }
public boolean isChunked() { checkActive(); return chunked; }
public int getStatusCode() { checkActive(); return statusCode; }
public byte[] getStatusBytes() { checkActive(); return statusBytes; }
public byte[] getBody() { checkActive(); return body; }
public byte[] getContentType() { checkActive(); return contentType; }
public InputStream getStream() { checkActive(); return stream; }
public long getStreamLength() { checkActive(); return streamLength; }
// -------------------------------------------------------------------------
// Internal setters used by HttpServer for handler return values
@@ -164,6 +354,7 @@ public class Response {
* serialize to {@code String}/{@code byte[]} before returning.
*/
public Response setBody(Object body) {
checkActive();
if (body instanceof byte[] bytes) { this.body = bytes; return this; }
if (body instanceof String s) { this.body = s.getBytes(StandardCharsets.UTF_8); return this; }
if (body instanceof CharSequence s) { this.body = s.toString().getBytes(StandardCharsets.UTF_8); return this; }
@@ -173,12 +364,96 @@ public class Response {
return this;
}
/** Returns custom headers, or an empty list if none were added. */
public List<byte[]> getHeaders() { return headers != null ? headers : List.of(); }
/**
* Returns custom headers as fully-rendered {@code "Name: Value\r\n"} lines, or an empty list
* if none were added. Introspection/debugging accessor — reconstructs each line from the
* internal region on every call, so it is not on the zero-alloc write path; {@link
* #writeHeaders} and {@link ResponseSerializer} read the internal representation directly
* instead of going through this method.
*/
public List<byte[]> getHeaders() {
checkActive();
if (headerCount == 0) return List.of();
List<byte[]> result = new ArrayList<>(headerCount);
for (int i = 0; i < headerCount; i++) {
if (headerTags[i] == 1) {
result.add(rawHeaderLines.get(headerRefs[i]));
} else {
int base = headerRefs[i] * 4;
byte[] region = headerRegion.array();
int nameOff = headerQuads[base], nameLen = headerQuads[base + 1];
int valOff = headerQuads[base + 2], valLen = headerQuads[base + 3];
byte[] line = new byte[nameLen + 2 + valLen + 2];
int p = 0;
System.arraycopy(region, nameOff, line, p, nameLen); p += nameLen;
line[p++] = ':'; line[p++] = ' ';
System.arraycopy(region, valOff, line, p, valLen); p += valLen;
line[p++] = '\r'; line[p] = '\n';
result.add(line);
}
}
return result;
}
/** Writes pre-encoded custom headers directly to {@code out}. Zero-alloc when no headers are set. */
/**
* Writes every custom header directly into {@code head} (a scratch {@link ByteWriter} —
* see {@code EX-27}), in call order. Zero-alloc when no headers are set or on a warm region.
* This is what {@code Http1ResponseWriter} uses; {@link #writeHeaders(OutputStream)} below
* (the {@code OutputStream} equivalent) exists for the streaming-body write paths that
* cannot fold their whole write into one scratch buffer.
*/
public void writeHeadersInto(ByteWriter head) {
for (int i = 0; i < headerCount; i++) {
if (headerTags[i] == 1) {
head.writeBytes(rawHeaderLines.get(headerRefs[i]));
} else {
int base = headerRefs[i] * 4;
byte[] region = headerRegion.array();
head.writeBytes(region, headerQuads[base], headerQuads[base + 1]);
head.writeByte((byte) ':'); head.writeByte((byte) ' ');
head.writeBytes(region, headerQuads[base + 2], headerQuads[base + 3]);
head.writeByte((byte) '\r'); head.writeByte((byte) '\n');
}
}
}
/** Writes every custom header directly to {@code out}, in call order. Zero-alloc when no headers are set or on a warm region. */
public void writeHeaders(OutputStream out) throws IOException {
if (headers == null) return;
for (byte[] header : headers) out.write(header);
for (int i = 0; i < headerCount; i++) {
if (headerTags[i] == 1) {
out.write(rawHeaderLines.get(headerRefs[i]));
} else {
int base = headerRefs[i] * 4;
byte[] region = headerRegion.array();
out.write(region, headerQuads[base], headerQuads[base + 1]);
out.write(':'); out.write(' ');
out.write(region, headerQuads[base + 2], headerQuads[base + 3]);
out.write('\r'); out.write('\n');
}
}
}
// ── Internal: name/value field enumeration for ResponseSerializer ──────────
/**
* Visits every {@code header(String,String)}/{@code header(PreEncodedHeader)}-added field as
* a structured (name, value) byte range — <b>not</b> the {@code header(byte[])} legacy
* entries, which have no such structure (see that method's own Javadoc). Package-private:
* {@link ResponseSerializer} is this method's only caller.
*/
void forEachStructuredField(ResponseSerializer.FieldConsumer consumer) {
if (headerQuadCount == 0) return;
byte[] region = headerRegion.array();
for (int i = 0; i < headerQuadCount; i++) {
int base = i * 4;
consumer.accept(region, headerQuads[base], headerQuads[base + 1],
region, headerQuads[base + 2], headerQuads[base + 3]);
}
}
@Override
public String toString() {
return "Response(statusCode=" + statusCode + ", contentType="
+ (contentType != null ? new String(contentType, StandardCharsets.UTF_8) : null) + ")";
}
}
@@ -0,0 +1,53 @@
package dev.relism.flash.models;
import java.nio.charset.StandardCharsets;
/**
* The protocol-neutral enumeration of a {@link Response}'s header fields — one source of truth
* consumed by every protocol's own writer, so {@code Content-Type}/custom-header logic is never
* duplicated (and cannot drift) between {@code Http1ResponseWriter} and a future h2 encoder
* (Phase 9). {@code Http1ResponseWriter} renders each field as {@code "Name: Value\r\n"}; the h2
* encoder will render the same fields via HPACK.
*
* <h3>Scope: response-object fields only, not connection framing</h3>
* Deliberately does <b>not</b> enumerate {@code Content-Length}, {@code Connection}, or
* {@code Date} — those are connection/transport framing decisions (body length, keep-alive
* negotiation, wall-clock time), not properties of the {@code Response} object itself, and HTTP/2
* has no equivalent of {@code Connection} at all (RFC 9113 §8.2.2 forbids connection-specific
* fields in h2). Each protocol's own writer computes and emits those itself, exactly as
* {@code Http1ResponseWriter} already did before this class existed.
*
* <h3>Scope: excludes {@link Response#header(byte[])}'s legacy entries</h3>
* A header added via the raw, fully-pre-rendered {@code header(byte[])} overload has no
* recoverable (name, value) structure — see that method's own Javadoc — so it cannot appear in
* this enumeration. {@code Http1ResponseWriter} still renders it (via {@link
* Response#writeHeaders}, which handles both structured and raw entries, in the original call
* order); a future h2 writer will not be able to.
*/
public final class ResponseSerializer {
private ResponseSerializer() {}
/** One rendered header field: a byte range for the name, and a byte range for the value — both slices of caller-owned arrays, never copied. */
@FunctionalInterface
public interface FieldConsumer {
void accept(byte[] nameBuf, int nameOff, int nameLen, byte[] valueBuf, int valueOff, int valueLen);
}
private static final byte[] CONTENT_TYPE_NAME = "Content-Type".getBytes(StandardCharsets.US_ASCII);
/**
* Enumerates {@code response}'s fields in a fixed, deterministic order: {@code Content-Type}
* first (if set to a non-empty value — {@code EX-15}: {@code ContentType.NONE} emits
* nothing, never an empty-valued header line), then every {@code header(String,String)}/
* {@code header(PreEncodedHeader)}-added field in call order. Zero allocation: every byte
* range handed to {@code consumer} is a slice of {@code response}'s own already-allocated
* buffers.
*/
public static void forEachField(Response response, FieldConsumer consumer) {
byte[] ct = response.getContentType();
if (ct != null && ct.length > 0) {
consumer.accept(CONTENT_TYPE_NAME, 0, CONTENT_TYPE_NAME.length, ct, 0, ct.length);
}
response.forEachStructuredField(consumer);
}
}
@@ -41,10 +41,19 @@ public final class FastPathViews {
return (long) LONG_VIEW_LE.get(array, pos);
}
/**
* {@code EX-42}: not immutable — {@link #reset} repositions an existing instance over new
* bounds instead of requiring a fresh allocation. {@code RequestParser} owns one pooled
* instance per role (path/query/protocol) per connection and calls {@link #reset} on it for
* every request, the same "do not retain past the handler" pooling contract every other
* per-connection object in this codebase already follows ({@code Http1HeaderMap},
* {@code RequestLine}, {@code Request}, {@code RequestBody}). The public constructor remains
* for one-shot, non-pooled use (tests, other call sites that build a single fixed view).
*/
public static final class RequestByteView implements ArrayBackedByteView {
private final byte[] buffer;
private final int start;
private final int length;
private byte[] buffer;
private int start;
private int length;
public RequestByteView(byte[] buffer, int start, int length) {
this.buffer = buffer;
@@ -52,6 +61,13 @@ public final class FastPathViews {
this.length = length;
}
/** Repositions this instance over new bounds. Zero allocation. */
public void reset(byte[] buffer, int start, int length) {
this.buffer = buffer;
this.start = start;
this.length = length;
}
@Override
public int length() {
return length;
@@ -2,21 +2,30 @@ package dev.relism.flash.template;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Precompiled, allocation-minimal byte template.
* <p>
* Placeholders of the form {@code {{name}}} are detected once at construction.
* Each {@link #render} call makes exactly one allocation: the output byte[].
* Each {@link #render} call makes exactly one allocation: the output byte[]
* (plus one {@code byte[]} per distinct key-value pair, for its UTF-8 bytes).
* <p>
* Layout: seg[0] slot[0] seg[1] slot[1] … seg[n-1] slot[n-1] seg[n]
*
* <h3>{@code EX-28}: slot lookup is O(1) per key-value pair, not O(slots)</h3>
* A slot name can appear more than once (e.g. {@code {{var}} == {{var}}}), so the map built at
* construction maps each name to the (usually single-element) array of every slot index using
* that name, instead of the nested "scan every slot for every pair" loop this used to do.
*/
public final class ByteTemplate {
private final byte[][] segments; // literal byte segments
private final String[] slots; // placeholder names in order
private final int staticLength; // sum of all segment lengths (precomputed)
private final byte[][] segments; // literal byte segments
private final String[] slots; // placeholder names in order
private final int staticLength; // sum of all segment lengths (precomputed)
private final Map<String, int[]> slotIndex; // slot name -> every slot index using that name
public ByteTemplate(String source) {
List<byte[]> segs = new ArrayList<>();
@@ -40,27 +49,68 @@ public final class ByteTemplate {
int sl = 0;
for (byte[] s : segments) sl += s.length;
staticLength = sl;
Map<String, List<Integer>> byName = new HashMap<>();
for (int j = 0; j < slots.length; j++) {
byName.computeIfAbsent(slots[j], k -> new ArrayList<>()).add(j);
}
Map<String, int[]> idx = new HashMap<>();
for (Map.Entry<String, List<Integer>> e : byName.entrySet()) {
int[] arr = new int[e.getValue().size()];
for (int j = 0; j < arr.length; j++) arr[j] = e.getValue().get(j);
idx.put(e.getKey(), arr);
}
slotIndex = idx;
}
/**
* Render with alternating key-value String pairs: {@code k1, v1, k2, v2, …}
* Unmatched slots are rendered as empty.
* Unmatched slots are rendered as empty. Allocates the returned {@code byte[]}; for a
* caller-supplied buffer see {@link #renderInto(byte[], int, String...)}.
*/
public byte[] render(String... kvPairs) {
byte[][] values = resolveValues(kvPairs);
byte[] out = new byte[length(values)];
writeInto(out, 0, values);
return out;
}
/**
* Renders into {@code buffer} starting at {@code offset}, making no allocation beyond the
* per-pair UTF-8 conversion of {@code kvPairs}' values. Returns the number of bytes written.
*
* @throws IndexOutOfBoundsException if {@code buffer} does not have enough room from {@code offset}
*/
public int renderInto(byte[] buffer, int offset, String... kvPairs) {
byte[][] values = resolveValues(kvPairs);
int len = length(values);
if (offset < 0 || offset + len > buffer.length) {
throw new IndexOutOfBoundsException(
"buffer too small: need " + len + " bytes at offset " + offset + ", have " + (buffer.length - offset));
}
writeInto(buffer, offset, values);
return len;
}
private byte[][] resolveValues(String[] kvPairs) {
byte[][] values = new byte[slots.length][];
for (int i = 0; i + 1 < kvPairs.length; i += 2) {
String key = kvPairs[i];
int[] matches = slotIndex.get(kvPairs[i]);
if (matches == null) continue;
byte[] val = kvPairs[i + 1].getBytes(StandardCharsets.UTF_8);
for (int j = 0; j < slots.length; j++) {
if (slots[j].equals(key)) { values[j] = val; }
}
for (int idx : matches) values[idx] = val;
}
return values;
}
private int length(byte[][] values) {
int len = staticLength;
for (byte[] v : values) if (v != null) len += v.length;
return len;
}
byte[] out = new byte[len];
int pos = 0;
private void writeInto(byte[] out, int offset, byte[][] values) {
int pos = offset;
for (int i = 0; i < slots.length; i++) {
System.arraycopy(segments[i], 0, out, pos, segments[i].length);
pos += segments[i].length;
@@ -70,6 +120,5 @@ public final class ByteTemplate {
}
}
System.arraycopy(segments[slots.length], 0, out, pos, segments[slots.length].length);
return out;
}
}
@@ -1,5 +1,7 @@
package dev.relism.flash.transport;
import dev.relism.flash.bytes.ByteWriter;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@@ -36,15 +38,20 @@ public final class ConnectionScratch {
/** Matches the relay-buffer size the {@code ThreadLocal} it replaces used. */
public static final int RELAY_BUFFER_SIZE = 8192;
/** Large enough for the decimal digits of any {@code long}, including a sign. */
public static final int DECIMAL_BUFFER_SIZE = 20;
/** Scratch for {@code Http1ResponseWriter}'s decimal (status code / Content-Length) encoding. */
public final byte[] decimalBuffer = new byte[DECIMAL_BUFFER_SIZE];
/** Initial capacity for {@link #responseHead}; grows on demand like any {@link ByteWriter}. */
public static final int RESPONSE_HEAD_INITIAL_SIZE = 1024;
/** Scratch for relaying a streaming or chunked response body without allocating per response. */
public final byte[] relayBuffer = new byte[RELAY_BUFFER_SIZE];
/**
* {@code EX-27}: the scratch {@code Http1ResponseWriter} serializes a whole response head
* (status line, {@code Content-Type}, {@code Date}, custom headers, {@code Content-Length}/
* {@code Connection}, and — for small fixed bodies — the body itself) into before issuing a
* single bulk {@code write()}, instead of ~10 small {@code OutputStream.write} calls.
*/
public final ByteWriter responseHead = new ByteWriter(RESPONSE_HEAD_INITIAL_SIZE);
/** Scratch for the WebSocket handshake's {@code Sec-WebSocket-Accept} SHA-1 digest. */
public final MessageDigest sha1;
@@ -60,9 +67,9 @@ public final class ConnectionScratch {
/** Called by {@link ScratchPool} before handing a reused instance to a new connection. */
void reset() {
sha1.reset();
// decimalBuffer/relayBuffer need no clearing: every reader of either only ever reads
// back exactly the region the immediately preceding writer just wrote (writeLong fills
// from the end backward and reports its own start position; relay() reports its own
// fill length), so stale bytes from a previous connection are never observed.
responseHead.reset();
// relayBuffer needs no clearing: every reader only ever reads back exactly the region
// the immediately preceding relay() call reports it filled, so stale bytes from a
// previous connection are never observed.
}
}
@@ -56,6 +56,32 @@ class RequestParserTest {
assertEquals("2", r.query("page"));
}
// --- EX-42: pooled RequestByteViews (path/query/protocol) don't leak across requests ---
@Test
void samePooledParser_secondRequestWithoutQuery_doesNotLeakFirstRequestsQuery() throws IOException {
RequestParser parser = new RequestParser();
byte[] first = req("GET /search?token=super-secret HTTP/1.1", "Host: a").replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
Request r1 = parser.parse(source(first));
assertEquals("token=super-secret", r1.getRequestLine().getQuery().toString());
byte[] second = req("GET /health HTTP/1.1", "Host: a").replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
Request r2 = parser.parse(source(second));
assertNull(r2.getRequestLine().getQuery(), "the second request must not see the first request's leftover query view");
assertEquals("/health", r2.getRequestLine().getPath().toString());
}
@Test
void samePooledParser_secondRequest_seesOnlyItsOwnPathAndProtocol() throws IOException {
RequestParser parser = new RequestParser();
Request r1 = parser.parse(source(req("GET /first HTTP/1.1", "Host: a").replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8)));
assertEquals("/first", r1.getRequestLine().getPath().toString());
Request r2 = parser.parse(source(req("POST /second HTTP/1.0", "Host: a").replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8)));
assertEquals("/second", r2.getRequestLine().getPath().toString());
assertEquals("HTTP/1.0", r2.getRequestLine().getProtocol().toString());
}
// --- headers ---
@Test
@@ -2,7 +2,7 @@ package dev.relism.flash.api.multipart;
import dev.relism.fpr.core.ByteView;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.HeaderMap;
import dev.relism.flash.models.Http1HeaderMap;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestLine;
import org.junit.jupiter.api.Test;
@@ -41,7 +41,7 @@ class MultipartTest {
private static Request request(byte[] bodyBytes) {
String ct = "multipart/form-data; boundary=" + BOUNDARY;
byte[] headerBuf = ("Content-Type: " + ct).getBytes(StandardCharsets.US_ASCII);
HeaderMap headers = new HeaderMap();
Http1HeaderMap headers = new Http1HeaderMap();
headers.reset(headerBuf, 0, headerBuf.length);
RequestLine line = new RequestLine(HttpMethod.POST, viewOf("/upload"), null, viewOf("HTTP/1.1"), headers);
return new Request(line, bodyBytes);
@@ -236,11 +236,65 @@ class MultipartTest {
@Test
void of_notMultipart_throws() {
byte[] headerBuf = "Content-Type: application/json".getBytes(StandardCharsets.US_ASCII);
HeaderMap headers = new HeaderMap();
Http1HeaderMap headers = new Http1HeaderMap();
headers.reset(headerBuf, 0, headerBuf.length);
RequestLine line = new RequestLine(HttpMethod.POST, viewOf("/"), null, viewOf("HTTP/1.1"), headers);
Request req = new Request(line, new byte[0]);
assertThrows(IllegalArgumentException.class, () -> Multipart.of(req));
}
// -------------------------------------------------------------------------
// EX-29: resource-exhaustion bounds
// -------------------------------------------------------------------------
@Test
void field_bodyAboveMaxBufferedSize_throws() throws IOException {
// MAX_MULTIPART_BUFFERED_PART_SIZE is 10 MiB — one byte over must be rejected, not
// buffered whole into a single byte[].
String tooBig = "z".repeat((int) dev.relism.flash.http.Http1Limits.MAX_MULTIPART_BUFFERED_PART_SIZE + 1);
Multipart mp = Multipart.of(request(body(textPart("huge", tooBig))));
assertThrows(IOException.class, () -> mp.field("huge"));
}
@Test
void file_materializedDuringFullScan_aboveMaxBufferedSize_throws() throws IOException {
String tooBig = "z".repeat((int) dev.relism.flash.http.Http1Limits.MAX_MULTIPART_BUFFERED_PART_SIZE + 1);
Multipart mp = Multipart.of(request(body(filePart("f", "f.bin", "application/octet-stream", tooBig))));
assertThrows(IOException.class, mp::parts);
}
@Test
void scan_tooManyParts_throws() throws IOException {
String[] parts = new String[dev.relism.flash.http.Http1Limits.MAX_MULTIPART_PARTS + 1];
for (int i = 0; i < parts.length; i++) parts[i] = textPart("f" + i, "v");
Multipart mp = Multipart.of(request(body(parts)));
assertThrows(IOException.class, mp::parts);
}
@Test
void partHeaders_tooManyHeaderLines_throws() throws IOException {
StringBuilder part = new StringBuilder("Content-Disposition: form-data; name=\"x\"\r\n");
for (int i = 0; i <= dev.relism.flash.http.Http1Limits.MAX_MULTIPART_PART_HEADER_COUNT; i++) {
part.append("X-Extra-").append(i).append(": v\r\n");
}
part.append("\r\nbody");
Multipart mp = Multipart.of(request(body(part.toString())));
assertThrows(IOException.class, () -> mp.field("x"));
}
@Test
void partHeaderLine_tooLong_throws() throws IOException {
String longValue = "v".repeat(dev.relism.flash.http.Http1Limits.MAX_MULTIPART_HEADER_LINE_LENGTH + 1);
String part = "Content-Disposition: form-data; name=\"x\"\r\n"
+ "X-Long: " + longValue + "\r\n\r\nbody";
Multipart mp = Multipart.of(request(body(part)));
assertThrows(IOException.class, () -> mp.field("x"));
}
@Test
void withinAllLimits_stillWorksNormally() throws IOException {
// Sanity check the bounds above don't false-positive on a normal small request.
assertEquals("alice", Multipart.of(request(body(textPart("username", "alice")))).field("username"));
}
}
@@ -93,6 +93,13 @@ class ByteWriterTest {
assertEquals("content-type", asString(w));
}
@Test
void writeAscii_preservesCase() {
ByteWriter w = new ByteWriter(4);
w.writeAscii("Content-TYPE");
assertEquals("Content-TYPE", asString(w));
}
@Test
void writeUInt16_bigEndian() {
ByteWriter w = new ByteWriter(4);
@@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.*;
@@ -133,4 +134,52 @@ class Http1ResponseWriterTest {
String raw = write(response, HttpMethod.GET, false, false);
assertTrue(raw.contains("Connection: close\r\n"), raw);
}
// --- EX-27: one bulk write for a small fixed body -----------------------------
/** Counts calls to {@code write(byte[], int, int)} — the only overload {@link Http1ResponseWriter} uses. */
private static final class CountingOutputStream extends java.io.OutputStream {
final ByteArrayOutputStream sink = new ByteArrayOutputStream();
int arrayWriteCalls;
@Override public void write(int b) { sink.write(b); }
@Override
public void write(byte[] b, int off, int len) {
arrayWriteCalls++;
sink.write(b, off, len);
}
}
@Test
void smallFixedBody_isWrittenInExactlyOneCall() throws IOException {
CountingOutputStream out = new CountingOutputStream();
Response response = new Response(200, "hello world", ContentType.TEXT_PLAIN);
Http1ResponseWriter.writeResponse(out, response, HttpMethod.GET, true, false, scratch());
assertEquals(1, out.arrayWriteCalls, "head + small body must leave in a single write() call");
assertTrue(out.sink.toString(StandardCharsets.UTF_8).endsWith("hello world"));
}
@Test
void bodyAboveInlineThreshold_isWrittenInTwoCalls() throws IOException {
CountingOutputStream out = new CountingOutputStream();
byte[] bigBody = new byte[dev.relism.flash.http.Http1Limits.INLINE_BODY_THRESHOLD + 1];
Arrays.fill(bigBody, (byte) 'x');
Response response = new Response(200, bigBody, ContentType.BINARY);
Http1ResponseWriter.writeResponse(out, response, HttpMethod.GET, true, false, scratch());
assertEquals(2, out.arrayWriteCalls, "head and an over-threshold body are written separately");
assertTrue(out.sink.toString(StandardCharsets.UTF_8).endsWith("x".repeat(bigBody.length)));
}
@Test
void headResponse_stillOneCall_noBodyBytes() throws IOException {
CountingOutputStream out = new CountingOutputStream();
Response response = new Response(200, "hello world", ContentType.TEXT_PLAIN);
Http1ResponseWriter.writeResponse(out, response, HttpMethod.HEAD, true, false, scratch());
assertEquals(1, out.arrayWriteCalls);
assertFalse(out.sink.toString(StandardCharsets.UTF_8).contains("hello world"));
}
}
@@ -8,25 +8,25 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@code EX-09}: dedicated correctness coverage for {@link HeaderMap}'s per-{@code reset()}
* {@code EX-09}: dedicated correctness coverage for {@link Http1HeaderMap}'s per-{@code reset()}
* index duplicate names, case variation, zero headers, and growth past the initial index
* capacity up to {@code Http1Limits.MAX_HEADER_COUNT}. {@link HeaderMapTest} already covers the
* ordinary lookup/forEach contract; this class targets the index machinery specifically.
*/
class HeaderMapIndexTest {
class Http1HeaderMapIndexTest {
private static HeaderMap parse(String... headers) {
private static Http1HeaderMap parse(String... headers) {
StringBuilder sb = new StringBuilder();
for (String h : headers) sb.append(h).append("\r\n");
byte[] buffer = sb.toString().getBytes(StandardCharsets.UTF_8);
HeaderMap map = new HeaderMap();
Http1HeaderMap map = new Http1HeaderMap();
map.reset(buffer, 0, buffer.length);
return map;
}
@Test
void zeroHeaders_everyLookupIsEmpty() {
HeaderMap map = parse();
Http1HeaderMap map = parse();
assertNull(map.first("Host"));
assertTrue(map.all("Host").isEmpty());
assertTrue(map.all().isEmpty());
@@ -36,14 +36,14 @@ class HeaderMapIndexTest {
@Test
void duplicateHeaderNames_firstReturnsTheFirstOne_allReturnsAllInOrder() {
HeaderMap map = parse("X-Trace: a", "X-Trace: b", "X-Trace: c");
Http1HeaderMap map = parse("X-Trace: a", "X-Trace: b", "X-Trace: c");
assertEquals("a", map.first("X-Trace"));
assertEquals(List.of("a", "b", "c"), map.all("X-Trace"));
}
@Test
void caseVariation_indexHashAndCompareBothIgnoreCase() {
HeaderMap map = parse("X-Custom-Header: value1");
Http1HeaderMap map = parse("X-Custom-Header: value1");
assertEquals("value1", map.first("x-custom-header"));
assertEquals("value1", map.first("X-CUSTOM-HEADER"));
assertEquals("value1", map.first("X-cUsToM-hEaDeR"));
@@ -52,7 +52,7 @@ class HeaderMapIndexTest {
@Test
void similarButDistinctNames_doNotCollideInTheIndex() {
// Names sharing a hash-prefix-adjacent shape must still resolve independently.
HeaderMap map = parse("Accept: a", "Accept-Encoding: b", "Accept-Language: c");
Http1HeaderMap map = parse("Accept: a", "Accept-Encoding: b", "Accept-Language: c");
assertEquals("a", map.first("Accept"));
assertEquals("b", map.first("Accept-Encoding"));
assertEquals("c", map.first("Accept-Language"));
@@ -63,7 +63,7 @@ class HeaderMapIndexTest {
int n = dev.relism.flash.http.Http1Limits.MAX_HEADER_COUNT;
String[] headers = new String[n];
for (int i = 0; i < n; i++) headers[i] = "X-Header-" + i + ": value-" + i;
HeaderMap map = parse(headers);
Http1HeaderMap map = parse(headers);
assertEquals("value-0", map.first("X-Header-0"));
assertEquals("value-" + (n - 1), map.first("X-Header-" + (n - 1)));
@@ -73,7 +73,7 @@ class HeaderMapIndexTest {
@Test
void reset_rebuildsIndexFromScratch_noStaleEntriesFromPreviousRequest() {
HeaderMap map = parse("Host: first-request");
Http1HeaderMap map = parse("Host: first-request");
assertEquals("first-request", map.first("Host"));
assertNull(map.first("X-Only-In-Second"));
@@ -88,7 +88,7 @@ class HeaderMapIndexTest {
void repeatedResetsAcrossVaryingHeaderCounts_shrinkAndGrowSafely() {
// A connection whose successive keep-alive requests have very different header counts
// must never see stale entries from a larger previous request bleed into a smaller one.
HeaderMap map = new HeaderMap();
Http1HeaderMap map = new Http1HeaderMap();
for (int round = 0; round < 5; round++) {
int n = (round % 2 == 0) ? 20 : 2;
String[] headers = new String[n];
@@ -109,7 +109,7 @@ class HeaderMapIndexTest {
// re-trigger index growth (Arrays.copyOf inside ensureIndexCapacity) after the first
// reset() has already sized the arrays for this header count asserted by identity: the
// backing array references must be the exact same objects before and after 100k lookups.
HeaderMap map = parse("A: 1", "B: 2", "C: 3", "D: 4");
Http1HeaderMap map = parse("A: 1", "B: 2", "C: 3", "D: 4");
int[] namesBefore = arrayFieldValue(map, "nameOffsets");
for (int i = 0; i < 100_000; i++) {
@@ -124,11 +124,11 @@ class HeaderMapIndexTest {
@Test
void view_poolWraparound_aliasesAnEarlierReturnedView() {
// EX-05's documented hazard, demonstrated through the actual public API: HeaderMap's
// EX-05's documented hazard, demonstrated through the actual public API: Http1HeaderMap's
// view() pool is sized 4 (VIEW_POOL_SIZE); a 5th call in the same request wraps around
// and silently repositions the object the 1st call returned.
dev.relism.fpr.core.ByteView v1 = null;
HeaderMap map = parse("A: 1", "B: 2", "C: 3", "D: 4", "E: 5");
Http1HeaderMap map = parse("A: 1", "B: 2", "C: 3", "D: 4", "E: 5");
for (String name : new String[]{"A", "B", "C", "D"}) {
dev.relism.fpr.core.ByteView v = map.view(name);
if (v1 == null) v1 = v;
@@ -139,9 +139,9 @@ class HeaderMapIndexTest {
assertEquals('5', v1.byteAt(0)); // v1 is now silently "E"'s value, not "A"'s
}
private static int[] arrayFieldValue(HeaderMap map, String fieldName) {
private static int[] arrayFieldValue(Http1HeaderMap map, String fieldName) {
try {
var field = HeaderMap.class.getDeclaredField(fieldName);
var field = Http1HeaderMap.class.getDeclaredField(fieldName);
field.setAccessible(true);
return (int[]) field.get(map);
} catch (ReflectiveOperationException e) {
@@ -8,15 +8,15 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class HeaderMapTest {
class Http1HeaderMapTest {
// --- helpers ---
private static HeaderMap parse(String... headers) {
private static Http1HeaderMap parse(String... headers) {
StringBuilder sb = new StringBuilder();
for (String h : headers) sb.append(h).append("\r\n");
byte[] buffer = sb.toString().getBytes(StandardCharsets.UTF_8);
HeaderMap map = new HeaderMap();
Http1HeaderMap map = new Http1HeaderMap();
map.reset(buffer, 0, buffer.length);
return map;
}
@@ -25,21 +25,21 @@ class HeaderMapTest {
@Test
void first_existingHeader() {
HeaderMap map = parse("Host: localhost", "Accept: text/plain");
Http1HeaderMap map = parse("Host: localhost", "Accept: text/plain");
assertEquals("localhost", map.first("Host"));
assertEquals("text/plain", map.first("Accept"));
}
@Test
void first_caseInsensitive() {
HeaderMap map = parse("ConteNT-tYPe: application/json");
Http1HeaderMap map = parse("ConteNT-tYPe: application/json");
assertEquals("application/json", map.first("content-type"));
assertEquals("application/json", map.first("CONTENT-TYPE"));
}
@Test
void first_missingHeader_returnsNull() {
HeaderMap map = parse("Host: localhost");
Http1HeaderMap map = parse("Host: localhost");
assertNull(map.first("Accept"));
}
@@ -47,19 +47,19 @@ class HeaderMapTest {
@Test
void all_multipleValuesByName() {
HeaderMap map = parse("Cookie: a=1", "Set-Cookie: token=123", "Cookie: b=2");
Http1HeaderMap map = parse("Cookie: a=1", "Set-Cookie: token=123", "Cookie: b=2");
assertEquals(List.of("a=1", "b=2"), map.all("Cookie"));
}
@Test
void all_missingHeader_returnsEmptyList() {
HeaderMap map = parse("Host: localhost");
Http1HeaderMap map = parse("Host: localhost");
assertTrue(map.all("Cookie").isEmpty());
}
@Test
void all_returnsAllHeaders() {
HeaderMap map = parse("A: 1", "B: 2");
Http1HeaderMap map = parse("A: 1", "B: 2");
assertEquals(List.of("1", "2"), map.all());
}
@@ -67,7 +67,7 @@ class HeaderMapTest {
@Test
void view_returnsZeroCopyView() {
HeaderMap map = parse("Host: localhost");
Http1HeaderMap map = parse("Host: localhost");
ByteView view = map.view("Host");
assertNotNull(view);
assertEquals(9, view.length());
@@ -77,7 +77,7 @@ class HeaderMapTest {
@Test
void view_missingHeader_returnsNull() {
HeaderMap map = parse("Host: localhost");
Http1HeaderMap map = parse("Host: localhost");
assertNull(map.view("Accept"));
}
@@ -85,7 +85,7 @@ class HeaderMapTest {
@Test
void emptyMap_returnsNullAndEmptyList() {
HeaderMap map = new HeaderMap();
Http1HeaderMap map = new Http1HeaderMap();
assertNull(map.first("Host"));
assertTrue(map.all("Host").isEmpty());
assertTrue(map.all().isEmpty());
@@ -95,7 +95,7 @@ class HeaderMapTest {
@Test
void forEach_visitsEveryHeaderInDeclarationOrder() {
HeaderMap map = parse("Host: localhost", "Accept: text/plain", "Cookie: a=1");
Http1HeaderMap map = parse("Host: localhost", "Accept: text/plain", "Cookie: a=1");
List<String> seen = new java.util.ArrayList<>();
map.forEach((name, value) -> seen.add(toStr(name) + "=" + toStr(value)));
assertEquals(List.of("Host=localhost", "Accept=text/plain", "Cookie=a=1"), seen);
@@ -103,7 +103,7 @@ class HeaderMapTest {
@Test
void forEach_emptyMap_neverInvokesConsumer() {
HeaderMap map = new HeaderMap();
Http1HeaderMap map = new Http1HeaderMap();
map.forEach((name, value) -> fail("must not be called on an empty map"));
}
@@ -111,7 +111,7 @@ class HeaderMapTest {
void forEach_reusesTheSameTwoViewInstancesAcrossEveryHeader() {
// The zero-allocation contract: forEach must reposition two ByteViews in place, not
// allocate a fresh pair per header same instances across all three calls here.
HeaderMap map = parse("A: 1", "B: 2", "C: 3");
Http1HeaderMap map = parse("A: 1", "B: 2", "C: 3");
List<ByteView> names = new java.util.ArrayList<>();
List<ByteView> values = new java.util.ArrayList<>();
map.forEach((name, value) -> { names.add(name); values.add(value); });
@@ -161,4 +161,46 @@ class RequestBodyTest {
body.drain();
assertEquals(0, socket.available());
}
// --- EX-22/EX-23: pooled instance, repositioned via reset() --------------------
@Test
void reset_repositionsSamePooledInstance_overSuccessiveRequests() throws IOException {
RequestBody body = new RequestBody(); // pooled ctor — no I/O configured yet
byte[] first = "first".getBytes(StandardCharsets.UTF_8);
body.reset(new ByteArrayInputStream(first), 5, new byte[0], 0, 0);
assertArrayEquals(first, body.bytes());
byte[] second = "second-request".getBytes(StandardCharsets.UTF_8);
body.reset(new ByteArrayInputStream(second), second.length, new byte[0], 0, 0);
assertArrayEquals(second, body.bytes(), "reset() must not leak the previous request's resolved body");
}
@Test
void stream_reusesTheSameBoundedStreamInstance_acrossResets() throws IOException {
RequestBody body = new RequestBody();
body.reset(new ByteArrayInputStream("one".getBytes(StandardCharsets.UTF_8)), 3, new byte[0], 0, 0);
InputStream stream1 = body.stream();
assertEquals("one", new String(stream1.readAllBytes(), StandardCharsets.UTF_8));
body.reset(new ByteArrayInputStream("two".getBytes(StandardCharsets.UTF_8)), 3, new byte[0], 0, 0);
InputStream stream2 = body.stream();
assertSame(stream1, stream2, "EX-23: stream() must reposition the one pooled BoundedBufferedInputStream, not allocate a new one per request");
assertEquals("two", new String(stream2.readAllBytes(), StandardCharsets.UTF_8));
}
@Test
void drain_reusesTheSameDrainBuffer_acrossChunkedResets() throws IOException {
RequestBody body = new RequestBody();
body.reset(new ByteArrayInputStream("chunk one".getBytes(StandardCharsets.UTF_8)), -1L, null, 0, 0);
body.drain();
ByteArrayInputStream secondSocket = new ByteArrayInputStream("chunk two".getBytes(StandardCharsets.UTF_8));
body.reset(secondSocket, -1L, null, 0, 0);
body.drain();
assertEquals(0, secondSocket.available(), "drain() must fully consume the second request's chunked body too");
}
}
@@ -28,7 +28,7 @@ class RequestLineTest {
ByteView path = viewOf("/api");
ByteView query = viewOf("q=1");
ByteView proto = viewOf("HTTP/1.1");
HeaderMap headers = new HeaderMap();
Http1HeaderMap headers = new Http1HeaderMap();
RequestLine rl = new RequestLine(HttpMethod.GET, path, query, proto, headers);
@@ -0,0 +1,103 @@
package dev.relism.flash.models;
import dev.relism.flash.http.HttpMethod;
import dev.relism.fpr.core.ByteView;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@code EX-22}: {@link Request} is pooled <em>per connection</em> (one instance owned by
* {@code RequestParser}, repositioned via {@link Request#forParsed} for every request on that
* connection) — not via a shared cross-connection pool. The plan's own safety-check wording
* ("connection A's {@code Authorization} header must never be visible on connection B") describes
* a threat model that does not structurally apply to this design: two different connections
* never share a {@code Request} instance at all (each owns its own {@code RequestParser}, hence
* its own {@code Request}) — see {@code DECISIONS.md} for the pooling-granularity decision this
* follows from. The real, applicable threat this class actually tests: request <em>N+1</em> on
* the *same* keep-alive connection must never see stale data left over from request <em>N</em>,
* since those two requests genuinely do share one {@code Request} instance.
*/
class RequestPoolingTest {
private static ByteView viewOf(String s) {
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
return new ByteView() {
public int length() { return bytes.length; }
public byte byteAt(int idx) { return bytes[idx]; }
};
}
private static Http1HeaderMap headersOf(String... rawLines) {
StringBuilder sb = new StringBuilder();
for (String line : rawLines) sb.append(line).append("\r\n");
byte[] buf = sb.toString().getBytes(StandardCharsets.UTF_8);
Http1HeaderMap map = new Http1HeaderMap();
map.reset(buf, 0, buf.length);
return map;
}
@Test
void forParsed_reusesTheSamePooledInstance_neverAllocatesANewOne() {
Request pooled = new Request();
RequestLine line1 = new RequestLine(HttpMethod.GET, viewOf("/a"), null, viewOf("HTTP/1.1"), headersOf());
Request r1 = Request.forParsed(pooled, line1, RequestBody.empty(), null, null);
assertSame(pooled, r1);
RequestLine line2 = new RequestLine(HttpMethod.POST, viewOf("/b"), null, viewOf("HTTP/1.1"), headersOf());
Request r2 = Request.forParsed(pooled, line2, RequestBody.empty(), null, null);
assertSame(pooled, r2);
assertSame(r1, r2, "the same pooled instance must be returned for every request on one connection");
}
@Test
void secondRequest_onSameConnection_doesNotSeeFirstRequestsAuthorizationHeader() {
Request pooled = new Request();
RequestLine first = new RequestLine(HttpMethod.GET, viewOf("/secure"), null, viewOf("HTTP/1.1"),
headersOf("Authorization: Bearer super-secret-token-A"));
Request r1 = Request.forParsed(pooled, first, RequestBody.empty(), null, null);
assertEquals("Bearer super-secret-token-A", r1.header("Authorization"));
// A second request on the same keep-alive connection, with no Authorization header at all.
RequestLine second = new RequestLine(HttpMethod.GET, viewOf("/public"), null, viewOf("HTTP/1.1"),
headersOf("Host: example.com"));
Request r2 = Request.forParsed(pooled, second, RequestBody.empty(), null, null);
assertNull(r2.header("Authorization"), "the second request must not see the first request's Authorization header");
assertNull(r2.header("authorization"));
for (String value : r2.headers()) {
assertFalse(value.contains("super-secret-token-A"), "leaked secret found in: " + value);
}
}
@Test
void secondRequest_doesNotSeeFirstRequestsPathParams() {
Request pooled = new Request();
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/users/123"), null, viewOf("HTTP/1.1"), headersOf());
Request r1 = Request.forParsed(pooled, line, RequestBody.empty(), null, null);
PathParams.inject(r1, new PathParams(viewOf("/users/123"), new String[]{"id"}, new int[]{7}, new int[]{3}));
assertEquals("123", r1.param("id"));
RequestLine line2 = new RequestLine(HttpMethod.GET, viewOf("/health"), null, viewOf("HTTP/1.1"), headersOf());
Request r2 = Request.forParsed(pooled, line2, RequestBody.empty(), null, null);
assertNull(r2.param("id"), "path params from the previous request on this connection must not leak");
assertNull(r2.getPathParams());
}
@Test
void secondRequest_doesNotSeeFirstRequestsCachedPathOrQueryParams() {
Request pooled = new Request();
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/first"), viewOf("token=abc"), viewOf("HTTP/1.1"), headersOf());
Request r1 = Request.forParsed(pooled, line, RequestBody.empty(), null, null);
assertEquals("/first", r1.path());
assertEquals("abc", r1.query("token"));
RequestLine line2 = new RequestLine(HttpMethod.GET, viewOf("/second"), null, viewOf("HTTP/1.1"), headersOf());
Request r2 = Request.forParsed(pooled, line2, RequestBody.empty(), null, null);
assertEquals("/second", r2.path(), "cachedPath from the previous request must not leak");
assertNull(r2.query("token"), "query params from the previous request must not leak");
}
}
@@ -0,0 +1,128 @@
package dev.relism.flash.models;
import dev.relism.flash.http.HttpMethod;
import dev.relism.fpr.core.ByteView;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@code EX-22}'s dev-mode use-after-recycle guard. Exercises the poisoning check directly via
* {@code Request.setPoisoningEnabledForTesting} rather than the real {@code Flash.DEV} flag,
* which is a {@code static final boolean} fixed once at JVM startup and cannot be toggled by an
* individual test — see that field's own comment in {@code Request.java}.
*/
class RequestRecycleGuardTest {
@AfterEach
void restoreProductionDefault() {
// Never leak the test override into other test classes sharing this JVM/fork.
Request.setPoisoningEnabledForTesting(false);
}
private static ByteView viewOf(String s) {
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
return new ByteView() {
public int length() { return bytes.length; }
public byte byteAt(int idx) { return bytes[idx]; }
};
}
private static Request active() {
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/x"), null, viewOf("HTTP/1.1"), new Http1HeaderMap());
return new Request(line, new byte[0]);
}
@Test
void poisoningDisabled_recycledRequestStillAccessible() {
Request.setPoisoningEnabledForTesting(false);
Request r = active();
r.recycle();
assertDoesNotThrow(r::method, "poisoning disabled (production default) must never throw");
}
@Test
void poisoningEnabled_freshRequest_accessibleNormally() {
Request.setPoisoningEnabledForTesting(true);
Request r = active();
assertDoesNotThrow(r::path);
assertDoesNotThrow(() -> r.header("Host"));
assertDoesNotThrow(r::method);
}
@Test
void poisoningEnabled_afterRecycle_methodThrows() {
Request.setPoisoningEnabledForTesting(true);
Request r = active();
r.recycle();
assertThrows(IllegalStateException.class, r::method);
}
@Test
void poisoningEnabled_afterRecycle_pathThrows() {
Request.setPoisoningEnabledForTesting(true);
Request r = active();
r.recycle();
assertThrows(IllegalStateException.class, r::path);
}
@Test
void poisoningEnabled_afterRecycle_headerThrows() {
Request.setPoisoningEnabledForTesting(true);
Request r = active();
r.recycle();
assertThrows(IllegalStateException.class, () -> r.header("Host"));
}
@Test
void poisoningEnabled_afterRecycle_paramThrows() {
Request.setPoisoningEnabledForTesting(true);
Request r = active();
r.recycle();
assertThrows(IllegalStateException.class, () -> r.param("id"));
}
@Test
void poisoningEnabled_afterRecycle_queryThrows() {
Request.setPoisoningEnabledForTesting(true);
Request r = active();
r.recycle();
assertThrows(IllegalStateException.class, () -> r.query("q"));
}
@Test
void poisoningEnabled_afterRecycle_remoteAddressThrows() {
Request.setPoisoningEnabledForTesting(true);
Request r = active();
r.recycle();
assertThrows(IllegalStateException.class, r::remoteAddress);
}
@Test
void poisoningEnabled_afterRecycle_isSecureThrows() {
Request.setPoisoningEnabledForTesting(true);
Request r = active();
r.recycle();
assertThrows(IllegalStateException.class, r::isSecure);
}
@Test
void reusedAfterReset_becomesAccessibleAgain() {
Request.setPoisoningEnabledForTesting(true);
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/first"), null, viewOf("HTTP/1.1"), new Http1HeaderMap());
Request r = new Request(line, new byte[0]);
r.recycle();
assertThrows(IllegalStateException.class, r::path);
// Simulate the connection loop pulling this pooled instance back out for the next
// request: Request.forParsed's reset() call re-activates it.
RequestLine line2 = new RequestLine(HttpMethod.GET, viewOf("/second"), null, viewOf("HTTP/1.1"), new Http1HeaderMap());
Request reused = Request.forParsed(r, line2, RequestBody.empty(), null, null);
assertSame(r, reused, "forParsed must reposition the same pooled instance, not allocate a new one");
assertDoesNotThrow(reused::path);
assertEquals("/second", reused.path());
}
}
@@ -26,7 +26,7 @@ class RequestTest {
@Test
void request_creationAndAccessors() {
HeaderMap headers = new HeaderMap();
Http1HeaderMap headers = new Http1HeaderMap();
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/path"), viewOf("q=1"), viewOf("HTTP/1.1"), headers);
byte[] body = "body".getBytes(StandardCharsets.UTF_8);
@@ -43,7 +43,7 @@ class RequestTest {
@Test
void header_delegatesToRequestLine() {
byte[] buffer = "Host: localhost\r\n".getBytes(StandardCharsets.UTF_8);
HeaderMap headers = new HeaderMap();
Http1HeaderMap headers = new Http1HeaderMap();
headers.reset(buffer, 0, buffer.length);
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), headers);
Request r = new Request(line, new byte[0]);
@@ -57,7 +57,7 @@ class RequestTest {
@Test
void param_lazyGet() {
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new HeaderMap());
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new Http1HeaderMap());
Request r = new Request(line, new byte[0]);
assertNull(r.param("id"));
@@ -70,7 +70,7 @@ class RequestTest {
@Test
void query_lazyGet_fromQueryString() {
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), viewOf("a=1&b=2&b=3"), viewOf("HTTP/1.1"), new HeaderMap());
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), viewOf("a=1&b=2&b=3"), viewOf("HTTP/1.1"), new Http1HeaderMap());
Request r = new Request(line, new byte[0]);
assertEquals("1", r.query("a"));
@@ -80,7 +80,7 @@ class RequestTest {
@Test
void query_lazyGet_nullQueryString() {
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new HeaderMap());
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new Http1HeaderMap());
Request r = new Request(line, new byte[0]);
assertNull(r.query("a"));
@@ -0,0 +1,75 @@
package dev.relism.flash.models;
import dev.relism.flash.http.ContentType;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/** {@code EX-21}: mirrors {@code RequestPoolingTest} for {@link Response}. */
class ResponsePoolingTest {
@Test
void reset_returnsSameInstanceAndClearsPreviousState() {
Response r = new Response(200, ContentType.TEXT_PLAIN);
r.header("X-Trace", "abc123").status(201).body("first body");
assertEquals(1, r.getHeaders().size());
Response reset = r.reset(200, ContentType.JSON);
assertSame(r, reset, "reset() must reposition the same instance, not allocate a new one");
assertEquals(200, reset.getStatusCode());
assertNull(reset.getBody(), "body from the previous cycle must not leak");
assertTrue(reset.getHeaders().isEmpty(), "headers from the previous cycle must not leak");
assertArrayEquals(ContentType.JSON.getBytes(), reset.getContentType());
}
@Test
void secondCycle_doesNotSeeFirstCyclesCustomHeader() {
Response r = new Response(200, ContentType.TEXT_PLAIN);
r.header("X-Secret", "leaked-if-broken");
assertEquals(1, r.getHeaders().size());
r.reset(200, ContentType.TEXT_PLAIN);
r.header("X-Public", "fine");
assertEquals(1, r.getHeaders().size());
String only = new String(r.getHeaders().get(0));
assertTrue(only.contains("X-Public"));
assertFalse(only.contains("X-Secret"), "stale header from the previous cycle leaked: " + only);
}
@Test
void secondCycle_reusesHeaderRegionAcrossManyHeaders_staysCorrect() {
Response r = new Response(200, ContentType.TEXT_PLAIN);
for (int cycle = 0; cycle < 5; cycle++) {
r.reset(200, ContentType.TEXT_PLAIN);
for (int i = 0; i < 10; i++) {
r.header("X-Cycle" + cycle + "-H" + i, "v" + i);
}
assertEquals(10, r.getHeaders().size(), "cycle " + cycle);
String last = new String(r.getHeaders().get(9));
assertTrue(last.contains("X-Cycle" + cycle + "-H9: v9"), "cycle " + cycle + ": " + last);
}
}
@Test
void mixedStructuredAndRawHeaders_preserveInsertionOrder() {
Response r = new Response(200, ContentType.TEXT_PLAIN);
r.header("A", "1");
r.header("B-raw: 2\r\n".getBytes());
r.header("C", "3");
var headers = r.getHeaders();
assertEquals(3, headers.size());
assertEquals("A: 1\r\n", new String(headers.get(0)));
assertEquals("B-raw: 2\r\n", new String(headers.get(1)));
assertEquals("C: 3\r\n", new String(headers.get(2)));
}
@Test
void preEncodedHeader_roundTripsThroughGetHeaders() {
Response r = new Response(200, ContentType.TEXT_PLAIN);
PreEncodedHeader h = new PreEncodedHeader("X-Static", "value");
r.header(h);
assertEquals("X-Static: value\r\n", new String(r.getHeaders().get(0)));
}
}
@@ -0,0 +1,58 @@
package dev.relism.flash.models;
import dev.relism.flash.http.ContentType;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/** {@code EX-21}'s dev-mode use-after-recycle guard — mirrors {@code RequestRecycleGuardTest}. */
class ResponseRecycleGuardTest {
@AfterEach
void restoreProductionDefault() {
Response.setPoisoningEnabledForTesting(false);
}
@Test
void poisoningDisabled_recycledResponseStillAccessible() {
Response.setPoisoningEnabledForTesting(false);
Response r = new Response(200, ContentType.TEXT_PLAIN);
r.recycle();
assertDoesNotThrow(r::getStatusCode);
}
@Test
void poisoningEnabled_afterRecycle_getStatusCodeThrows() {
Response.setPoisoningEnabledForTesting(true);
Response r = new Response(200, ContentType.TEXT_PLAIN);
r.recycle();
assertThrows(IllegalStateException.class, r::getStatusCode);
}
@Test
void poisoningEnabled_afterRecycle_headerThrows() {
Response.setPoisoningEnabledForTesting(true);
Response r = new Response(200, ContentType.TEXT_PLAIN);
r.recycle();
assertThrows(IllegalStateException.class, () -> r.header("X", "Y"));
}
@Test
void poisoningEnabled_afterRecycle_bodyThrows() {
Response.setPoisoningEnabledForTesting(true);
Response r = new Response(200, ContentType.TEXT_PLAIN);
r.recycle();
assertThrows(IllegalStateException.class, () -> r.body("x"));
}
@Test
void poisoningEnabled_afterReset_accessibleAgain() {
Response.setPoisoningEnabledForTesting(true);
Response r = new Response(200, ContentType.TEXT_PLAIN);
r.recycle();
assertThrows(IllegalStateException.class, r::getStatusCode);
r.reset(200, ContentType.TEXT_PLAIN);
assertDoesNotThrow(r::getStatusCode);
}
}
@@ -0,0 +1,73 @@
package dev.relism.flash.models;
import dev.relism.flash.http.ContentType;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class ResponseSerializerTest {
private static List<String> collect(Response r) {
List<String> fields = new ArrayList<>();
ResponseSerializer.forEachField(r, (nameBuf, nameOff, nameLen, valueBuf, valueOff, valueLen) ->
fields.add(new String(nameBuf, nameOff, nameLen, StandardCharsets.US_ASCII)
+ "=" + new String(valueBuf, valueOff, valueLen, StandardCharsets.US_ASCII)));
return fields;
}
@Test
void contentTypeFirst_thenCustomHeadersInOrder() {
Response r = new Response(200, ContentType.JSON);
r.header("X-A", "1").header("X-B", "2");
assertEquals(List.of("Content-Type=application/json", "X-A=1", "X-B=2"), collect(r));
}
@Test
void contentTypeNone_isSkipped_notEmptyValue() {
Response r = new Response(200, ContentType.NONE);
r.header("X-Only", "here");
assertEquals(List.of("X-Only=here"), collect(r));
}
@Test
void noHeadersAtAll_onlyContentType() {
Response r = new Response(200, ContentType.TEXT_PLAIN);
assertEquals(List.of("Content-Type=text/plain"), collect(r));
}
@Test
void rawPreEncodedHeaderBytes_areExcludedFromEnumeration() {
// header(byte[]) has no recoverable (name, value) structure -- ResponseSerializer must
// skip it (Http1ResponseWriter still renders it, via writeHeaders, just not through this
// protocol-neutral path).
Response r = new Response(200, ContentType.NONE);
r.header("X-Structured", "yes");
r.header("X-Raw: no-structure\r\n".getBytes());
assertEquals(List.of("X-Structured=yes"), collect(r));
}
@Test
void preEncodedHeaderObject_isIncluded_withStructure() {
Response r = new Response(200, ContentType.NONE);
r.header(new PreEncodedHeader("X-Boot", "constant"));
assertEquals(List.of("X-Boot=constant"), collect(r));
}
@Test
void zeroAllocation_byteRangesAreSlicesOfResponsesOwnBuffers_notCopies() {
Response r = new Response(200, ContentType.NONE);
r.header("X-A", "value-a");
byte[][] captured = new byte[2][];
ResponseSerializer.forEachField(r, (nameBuf, nameOff, nameLen, valueBuf, valueOff, valueLen) -> {
captured[0] = nameBuf;
captured[1] = valueBuf;
});
// Both slices must reference the SAME backing array (the response's own header region) --
// proves no copy was made to hand the field to the consumer.
assertSame(captured[0], captured[1]);
}
}
@@ -134,4 +134,35 @@ class ResponseTest {
void getHeaders_emptyWhenNoneAdded() {
assertTrue(new Response(200, new byte[0], ContentType.TEXT_PLAIN).getHeaders().isEmpty());
}
// --- EX-43: response header budget (Phase 6 zero-alloc DoD) ---
@Test
void header_exceedingMaxCount_throws() {
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
for (int i = 0; i < dev.relism.flash.http.Http1Limits.MAX_RESPONSE_HEADER_COUNT; i++) {
r.header("X-" + i, "v");
}
assertThrows(IllegalStateException.class, () -> r.header("one-too-many", "v"));
}
@Test
void header_exceedingMaxRegionBytes_throws() {
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
String bigValue = "v".repeat(1024);
assertThrows(IllegalStateException.class, () -> {
// Each call adds ~1024 bytes; comfortably crosses MAX_RESPONSE_HEADER_BYTES well
// before MAX_RESPONSE_HEADER_COUNT would trigger first.
for (int i = 0; i < dev.relism.flash.http.Http1Limits.MAX_RESPONSE_HEADER_COUNT; i++) {
r.header("X-" + i, bigValue);
}
});
}
@Test
void header_withinBudget_stillWorksNormally() {
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
r.header("X-Foo", "bar");
assertEquals(1, r.getHeaders().size());
}
}
@@ -1,7 +1,7 @@
package dev.relism.flash.routing.routers.fastpathrouter;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.HeaderMap;
import dev.relism.flash.models.Http1HeaderMap;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.RequestLine;
@@ -23,7 +23,7 @@ class FastPathRouterImplTest {
RequestLine line = new RequestLine(
method, pathView, null,
new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8),
new HeaderMap()
new Http1HeaderMap()
);
return new Request(line, new byte[0]);
}
@@ -28,6 +28,31 @@ class FastPathViewsTest {
assertThrows(IndexOutOfBoundsException.class, () -> view.byteAt(10));
}
// --- EX-42: reset() repositions the same instance, zero allocation ---------
@Test
void requestByteView_reset_repositionsSameInstance() {
FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(SHARED_BUFFER, 4, 10);
assertEquals("/api/users", view.toString());
byte[] other = "PUT /orders/9 HTTP/1.1".getBytes(StandardCharsets.UTF_8);
view.reset(other, 4, 8);
assertEquals(8, view.length());
assertEquals("/orders/", view.toString());
}
@Test
void requestByteView_reset_updatesArrayBackedByteViewAccessors() {
FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(SHARED_BUFFER, 0, 3);
byte[] other = "zzHELLOzz".getBytes(StandardCharsets.UTF_8);
view.reset(other, 2, 5);
assertSame(other, view.array());
assertEquals(2, view.offset());
assertEquals(5, view.length());
assertEquals("HELLO", view.toString());
}
// --- MethodPathByteView ---
@Test
@@ -55,4 +55,38 @@ class ByteTemplateTest {
byte[] result = tpl.render("v1", "1", "v2", "2");
assertEquals("A12B", new String(result, StandardCharsets.UTF_8));
}
// --- EX-28: renderInto(buffer, offset, ...) ------------------------------------
@Test
void renderInto_writesAtOffset_andReturnsLength() {
ByteTemplate tpl = new ByteTemplate("Hello {{name}}!");
byte[] buffer = new byte[64];
int len = tpl.renderInto(buffer, 5, "name", "World");
assertEquals("Hello World!".length(), len);
assertEquals("Hello World!", new String(buffer, 5, len, StandardCharsets.UTF_8));
}
@Test
void renderInto_repeatedPlaceholder_fillsEveryOccurrence() {
ByteTemplate tpl = new ByteTemplate("{{var}} == {{var}}");
byte[] buffer = new byte[32];
int len = tpl.renderInto(buffer, 0, "var", "test");
assertEquals("test == test", new String(buffer, 0, len, StandardCharsets.UTF_8));
}
@Test
void renderInto_bufferTooSmall_throws() {
ByteTemplate tpl = new ByteTemplate("Hello {{name}}!");
byte[] buffer = new byte[5];
assertThrows(IndexOutOfBoundsException.class, () -> tpl.renderInto(buffer, 0, "name", "World"));
}
@Test
void renderInto_negativeOffset_throws() {
ByteTemplate tpl = new ByteTemplate("Hi {{name}}");
byte[] buffer = new byte[32];
assertThrows(IndexOutOfBoundsException.class, () -> tpl.renderInto(buffer, -1, "name", "X"));
}
}
@@ -1,7 +1,7 @@
package dev.relism.flash.template;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.HeaderMap;
import dev.relism.flash.models.Http1HeaderMap;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestLine;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
@@ -24,7 +24,7 @@ class ErrorPagesTest {
byte[] protoBytes = protocol.getBytes(StandardCharsets.UTF_8);
FastPathViews.RequestByteView protoView = new FastPathViews.RequestByteView(protoBytes, 0, protoBytes.length);
RequestLine line = new RequestLine(HttpMethod.GET, pathView, null, protoView, new HeaderMap());
RequestLine line = new RequestLine(HttpMethod.GET, pathView, null, protoView, new Http1HeaderMap());
return new Request(line, new byte[0]);
}
@@ -15,8 +15,9 @@ class ScratchPoolTest {
ConnectionScratch scratch = pool.acquire();
assertNotNull(scratch);
assertNotNull(scratch.sha1);
assertEquals(ConnectionScratch.DECIMAL_BUFFER_SIZE, scratch.decimalBuffer.length);
assertEquals(ConnectionScratch.RELAY_BUFFER_SIZE, scratch.relayBuffer.length);
assertNotNull(scratch.responseHead);
assertEquals(0, scratch.responseHead.length());
}
@Test
@@ -1,7 +1,7 @@
package dev.relism.flash.websocket;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.HeaderMap;
import dev.relism.flash.models.Http1HeaderMap;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestLine;
import dev.relism.fpr.core.ByteView;
@@ -25,7 +25,7 @@ class WebSocketSessionTest {
@Test
void request_returnsWhatWasPassedToConstructor() {
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/chat"), null, viewOf("HTTP/1.1"), new HeaderMap());
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/chat"), null, viewOf("HTTP/1.1"), new Http1HeaderMap());
Request req = new Request(line, new byte[0]);
WebSocketSession session = new WebSocketSession(
new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream(), 64, req, false);