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
@@ -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.
}
}