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:
co-authored by
Claude Sonnet 5
parent
0e1bbed42c
commit
d882ea255c
@@ -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);
|
||||
}
|
||||
}
|
||||
+35
-43
@@ -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 6–10 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);
|
||||
}
|
||||
}
|
||||
+19
-3
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user