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
@@ -56,6 +56,32 @@ class RequestParserTest {
|
||||
assertEquals("2", r.query("page"));
|
||||
}
|
||||
|
||||
// --- EX-42: pooled RequestByteViews (path/query/protocol) don't leak across requests ---
|
||||
|
||||
@Test
|
||||
void samePooledParser_secondRequestWithoutQuery_doesNotLeakFirstRequestsQuery() throws IOException {
|
||||
RequestParser parser = new RequestParser();
|
||||
byte[] first = req("GET /search?token=super-secret HTTP/1.1", "Host: a").replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
|
||||
Request r1 = parser.parse(source(first));
|
||||
assertEquals("token=super-secret", r1.getRequestLine().getQuery().toString());
|
||||
|
||||
byte[] second = req("GET /health HTTP/1.1", "Host: a").replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
|
||||
Request r2 = parser.parse(source(second));
|
||||
assertNull(r2.getRequestLine().getQuery(), "the second request must not see the first request's leftover query view");
|
||||
assertEquals("/health", r2.getRequestLine().getPath().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void samePooledParser_secondRequest_seesOnlyItsOwnPathAndProtocol() throws IOException {
|
||||
RequestParser parser = new RequestParser();
|
||||
Request r1 = parser.parse(source(req("GET /first HTTP/1.1", "Host: a").replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8)));
|
||||
assertEquals("/first", r1.getRequestLine().getPath().toString());
|
||||
|
||||
Request r2 = parser.parse(source(req("POST /second HTTP/1.0", "Host: a").replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8)));
|
||||
assertEquals("/second", r2.getRequestLine().getPath().toString());
|
||||
assertEquals("HTTP/1.0", r2.getRequestLine().getProtocol().toString());
|
||||
}
|
||||
|
||||
// --- headers ---
|
||||
|
||||
@Test
|
||||
|
||||
@@ -2,7 +2,7 @@ package dev.relism.flash.api.multipart;
|
||||
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.HeaderMap;
|
||||
import dev.relism.flash.models.Http1HeaderMap;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestLine;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -41,7 +41,7 @@ class MultipartTest {
|
||||
private static Request request(byte[] bodyBytes) {
|
||||
String ct = "multipart/form-data; boundary=" + BOUNDARY;
|
||||
byte[] headerBuf = ("Content-Type: " + ct).getBytes(StandardCharsets.US_ASCII);
|
||||
HeaderMap headers = new HeaderMap();
|
||||
Http1HeaderMap headers = new Http1HeaderMap();
|
||||
headers.reset(headerBuf, 0, headerBuf.length);
|
||||
RequestLine line = new RequestLine(HttpMethod.POST, viewOf("/upload"), null, viewOf("HTTP/1.1"), headers);
|
||||
return new Request(line, bodyBytes);
|
||||
@@ -236,11 +236,65 @@ class MultipartTest {
|
||||
@Test
|
||||
void of_notMultipart_throws() {
|
||||
byte[] headerBuf = "Content-Type: application/json".getBytes(StandardCharsets.US_ASCII);
|
||||
HeaderMap headers = new HeaderMap();
|
||||
Http1HeaderMap headers = new Http1HeaderMap();
|
||||
headers.reset(headerBuf, 0, headerBuf.length);
|
||||
RequestLine line = new RequestLine(HttpMethod.POST, viewOf("/"), null, viewOf("HTTP/1.1"), headers);
|
||||
Request req = new Request(line, new byte[0]);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> Multipart.of(req));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// EX-29: resource-exhaustion bounds
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void field_bodyAboveMaxBufferedSize_throws() throws IOException {
|
||||
// MAX_MULTIPART_BUFFERED_PART_SIZE is 10 MiB — one byte over must be rejected, not
|
||||
// buffered whole into a single byte[].
|
||||
String tooBig = "z".repeat((int) dev.relism.flash.http.Http1Limits.MAX_MULTIPART_BUFFERED_PART_SIZE + 1);
|
||||
Multipart mp = Multipart.of(request(body(textPart("huge", tooBig))));
|
||||
assertThrows(IOException.class, () -> mp.field("huge"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void file_materializedDuringFullScan_aboveMaxBufferedSize_throws() throws IOException {
|
||||
String tooBig = "z".repeat((int) dev.relism.flash.http.Http1Limits.MAX_MULTIPART_BUFFERED_PART_SIZE + 1);
|
||||
Multipart mp = Multipart.of(request(body(filePart("f", "f.bin", "application/octet-stream", tooBig))));
|
||||
assertThrows(IOException.class, mp::parts);
|
||||
}
|
||||
|
||||
@Test
|
||||
void scan_tooManyParts_throws() throws IOException {
|
||||
String[] parts = new String[dev.relism.flash.http.Http1Limits.MAX_MULTIPART_PARTS + 1];
|
||||
for (int i = 0; i < parts.length; i++) parts[i] = textPart("f" + i, "v");
|
||||
Multipart mp = Multipart.of(request(body(parts)));
|
||||
assertThrows(IOException.class, mp::parts);
|
||||
}
|
||||
|
||||
@Test
|
||||
void partHeaders_tooManyHeaderLines_throws() throws IOException {
|
||||
StringBuilder part = new StringBuilder("Content-Disposition: form-data; name=\"x\"\r\n");
|
||||
for (int i = 0; i <= dev.relism.flash.http.Http1Limits.MAX_MULTIPART_PART_HEADER_COUNT; i++) {
|
||||
part.append("X-Extra-").append(i).append(": v\r\n");
|
||||
}
|
||||
part.append("\r\nbody");
|
||||
Multipart mp = Multipart.of(request(body(part.toString())));
|
||||
assertThrows(IOException.class, () -> mp.field("x"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void partHeaderLine_tooLong_throws() throws IOException {
|
||||
String longValue = "v".repeat(dev.relism.flash.http.Http1Limits.MAX_MULTIPART_HEADER_LINE_LENGTH + 1);
|
||||
String part = "Content-Disposition: form-data; name=\"x\"\r\n"
|
||||
+ "X-Long: " + longValue + "\r\n\r\nbody";
|
||||
Multipart mp = Multipart.of(request(body(part)));
|
||||
assertThrows(IOException.class, () -> mp.field("x"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withinAllLimits_stillWorksNormally() throws IOException {
|
||||
// Sanity check the bounds above don't false-positive on a normal small request.
|
||||
assertEquals("alice", Multipart.of(request(body(textPart("username", "alice")))).field("username"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,13 @@ class ByteWriterTest {
|
||||
assertEquals("content-type", asString(w));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeAscii_preservesCase() {
|
||||
ByteWriter w = new ByteWriter(4);
|
||||
w.writeAscii("Content-TYPE");
|
||||
assertEquals("Content-TYPE", asString(w));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeUInt16_bigEndian() {
|
||||
ByteWriter w = new ByteWriter(4);
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@@ -133,4 +134,52 @@ class Http1ResponseWriterTest {
|
||||
String raw = write(response, HttpMethod.GET, false, false);
|
||||
assertTrue(raw.contains("Connection: close\r\n"), raw);
|
||||
}
|
||||
|
||||
// --- EX-27: one bulk write for a small fixed body -----------------------------
|
||||
|
||||
/** Counts calls to {@code write(byte[], int, int)} — the only overload {@link Http1ResponseWriter} uses. */
|
||||
private static final class CountingOutputStream extends java.io.OutputStream {
|
||||
final ByteArrayOutputStream sink = new ByteArrayOutputStream();
|
||||
int arrayWriteCalls;
|
||||
|
||||
@Override public void write(int b) { sink.write(b); }
|
||||
|
||||
@Override
|
||||
public void write(byte[] b, int off, int len) {
|
||||
arrayWriteCalls++;
|
||||
sink.write(b, off, len);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void smallFixedBody_isWrittenInExactlyOneCall() throws IOException {
|
||||
CountingOutputStream out = new CountingOutputStream();
|
||||
Response response = new Response(200, "hello world", ContentType.TEXT_PLAIN);
|
||||
Http1ResponseWriter.writeResponse(out, response, HttpMethod.GET, true, false, scratch());
|
||||
|
||||
assertEquals(1, out.arrayWriteCalls, "head + small body must leave in a single write() call");
|
||||
assertTrue(out.sink.toString(StandardCharsets.UTF_8).endsWith("hello world"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bodyAboveInlineThreshold_isWrittenInTwoCalls() throws IOException {
|
||||
CountingOutputStream out = new CountingOutputStream();
|
||||
byte[] bigBody = new byte[dev.relism.flash.http.Http1Limits.INLINE_BODY_THRESHOLD + 1];
|
||||
Arrays.fill(bigBody, (byte) 'x');
|
||||
Response response = new Response(200, bigBody, ContentType.BINARY);
|
||||
Http1ResponseWriter.writeResponse(out, response, HttpMethod.GET, true, false, scratch());
|
||||
|
||||
assertEquals(2, out.arrayWriteCalls, "head and an over-threshold body are written separately");
|
||||
assertTrue(out.sink.toString(StandardCharsets.UTF_8).endsWith("x".repeat(bigBody.length)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void headResponse_stillOneCall_noBodyBytes() throws IOException {
|
||||
CountingOutputStream out = new CountingOutputStream();
|
||||
Response response = new Response(200, "hello world", ContentType.TEXT_PLAIN);
|
||||
Http1ResponseWriter.writeResponse(out, response, HttpMethod.HEAD, true, false, scratch());
|
||||
|
||||
assertEquals(1, out.arrayWriteCalls);
|
||||
assertFalse(out.sink.toString(StandardCharsets.UTF_8).contains("hello world"));
|
||||
}
|
||||
}
|
||||
|
||||
+16
-16
@@ -8,25 +8,25 @@ import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* {@code EX-09}: dedicated correctness coverage for {@link HeaderMap}'s per-{@code reset()}
|
||||
* {@code EX-09}: dedicated correctness coverage for {@link Http1HeaderMap}'s per-{@code reset()}
|
||||
* index — duplicate names, case variation, zero headers, and growth past the initial index
|
||||
* capacity up to {@code Http1Limits.MAX_HEADER_COUNT}. {@link HeaderMapTest} already covers the
|
||||
* ordinary lookup/forEach contract; this class targets the index machinery specifically.
|
||||
*/
|
||||
class HeaderMapIndexTest {
|
||||
class Http1HeaderMapIndexTest {
|
||||
|
||||
private static HeaderMap parse(String... headers) {
|
||||
private static Http1HeaderMap parse(String... headers) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String h : headers) sb.append(h).append("\r\n");
|
||||
byte[] buffer = sb.toString().getBytes(StandardCharsets.UTF_8);
|
||||
HeaderMap map = new HeaderMap();
|
||||
Http1HeaderMap map = new Http1HeaderMap();
|
||||
map.reset(buffer, 0, buffer.length);
|
||||
return map;
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroHeaders_everyLookupIsEmpty() {
|
||||
HeaderMap map = parse();
|
||||
Http1HeaderMap map = parse();
|
||||
assertNull(map.first("Host"));
|
||||
assertTrue(map.all("Host").isEmpty());
|
||||
assertTrue(map.all().isEmpty());
|
||||
@@ -36,14 +36,14 @@ class HeaderMapIndexTest {
|
||||
|
||||
@Test
|
||||
void duplicateHeaderNames_firstReturnsTheFirstOne_allReturnsAllInOrder() {
|
||||
HeaderMap map = parse("X-Trace: a", "X-Trace: b", "X-Trace: c");
|
||||
Http1HeaderMap map = parse("X-Trace: a", "X-Trace: b", "X-Trace: c");
|
||||
assertEquals("a", map.first("X-Trace"));
|
||||
assertEquals(List.of("a", "b", "c"), map.all("X-Trace"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void caseVariation_indexHashAndCompareBothIgnoreCase() {
|
||||
HeaderMap map = parse("X-Custom-Header: value1");
|
||||
Http1HeaderMap map = parse("X-Custom-Header: value1");
|
||||
assertEquals("value1", map.first("x-custom-header"));
|
||||
assertEquals("value1", map.first("X-CUSTOM-HEADER"));
|
||||
assertEquals("value1", map.first("X-cUsToM-hEaDeR"));
|
||||
@@ -52,7 +52,7 @@ class HeaderMapIndexTest {
|
||||
@Test
|
||||
void similarButDistinctNames_doNotCollideInTheIndex() {
|
||||
// Names sharing a hash-prefix-adjacent shape must still resolve independently.
|
||||
HeaderMap map = parse("Accept: a", "Accept-Encoding: b", "Accept-Language: c");
|
||||
Http1HeaderMap map = parse("Accept: a", "Accept-Encoding: b", "Accept-Language: c");
|
||||
assertEquals("a", map.first("Accept"));
|
||||
assertEquals("b", map.first("Accept-Encoding"));
|
||||
assertEquals("c", map.first("Accept-Language"));
|
||||
@@ -63,7 +63,7 @@ class HeaderMapIndexTest {
|
||||
int n = dev.relism.flash.http.Http1Limits.MAX_HEADER_COUNT;
|
||||
String[] headers = new String[n];
|
||||
for (int i = 0; i < n; i++) headers[i] = "X-Header-" + i + ": value-" + i;
|
||||
HeaderMap map = parse(headers);
|
||||
Http1HeaderMap map = parse(headers);
|
||||
|
||||
assertEquals("value-0", map.first("X-Header-0"));
|
||||
assertEquals("value-" + (n - 1), map.first("X-Header-" + (n - 1)));
|
||||
@@ -73,7 +73,7 @@ class HeaderMapIndexTest {
|
||||
|
||||
@Test
|
||||
void reset_rebuildsIndexFromScratch_noStaleEntriesFromPreviousRequest() {
|
||||
HeaderMap map = parse("Host: first-request");
|
||||
Http1HeaderMap map = parse("Host: first-request");
|
||||
assertEquals("first-request", map.first("Host"));
|
||||
assertNull(map.first("X-Only-In-Second"));
|
||||
|
||||
@@ -88,7 +88,7 @@ class HeaderMapIndexTest {
|
||||
void repeatedResetsAcrossVaryingHeaderCounts_shrinkAndGrowSafely() {
|
||||
// A connection whose successive keep-alive requests have very different header counts
|
||||
// must never see stale entries from a larger previous request bleed into a smaller one.
|
||||
HeaderMap map = new HeaderMap();
|
||||
Http1HeaderMap map = new Http1HeaderMap();
|
||||
for (int round = 0; round < 5; round++) {
|
||||
int n = (round % 2 == 0) ? 20 : 2;
|
||||
String[] headers = new String[n];
|
||||
@@ -109,7 +109,7 @@ class HeaderMapIndexTest {
|
||||
// re-trigger index growth (Arrays.copyOf inside ensureIndexCapacity) after the first
|
||||
// reset() has already sized the arrays for this header count — asserted by identity: the
|
||||
// backing array references must be the exact same objects before and after 100k lookups.
|
||||
HeaderMap map = parse("A: 1", "B: 2", "C: 3", "D: 4");
|
||||
Http1HeaderMap map = parse("A: 1", "B: 2", "C: 3", "D: 4");
|
||||
int[] namesBefore = arrayFieldValue(map, "nameOffsets");
|
||||
|
||||
for (int i = 0; i < 100_000; i++) {
|
||||
@@ -124,11 +124,11 @@ class HeaderMapIndexTest {
|
||||
|
||||
@Test
|
||||
void view_poolWraparound_aliasesAnEarlierReturnedView() {
|
||||
// EX-05's documented hazard, demonstrated through the actual public API: HeaderMap's
|
||||
// EX-05's documented hazard, demonstrated through the actual public API: Http1HeaderMap's
|
||||
// view() pool is sized 4 (VIEW_POOL_SIZE); a 5th call in the same request wraps around
|
||||
// and silently repositions the object the 1st call returned.
|
||||
dev.relism.fpr.core.ByteView v1 = null;
|
||||
HeaderMap map = parse("A: 1", "B: 2", "C: 3", "D: 4", "E: 5");
|
||||
Http1HeaderMap map = parse("A: 1", "B: 2", "C: 3", "D: 4", "E: 5");
|
||||
for (String name : new String[]{"A", "B", "C", "D"}) {
|
||||
dev.relism.fpr.core.ByteView v = map.view(name);
|
||||
if (v1 == null) v1 = v;
|
||||
@@ -139,9 +139,9 @@ class HeaderMapIndexTest {
|
||||
assertEquals('5', v1.byteAt(0)); // v1 is now silently "E"'s value, not "A"'s
|
||||
}
|
||||
|
||||
private static int[] arrayFieldValue(HeaderMap map, String fieldName) {
|
||||
private static int[] arrayFieldValue(Http1HeaderMap map, String fieldName) {
|
||||
try {
|
||||
var field = HeaderMap.class.getDeclaredField(fieldName);
|
||||
var field = Http1HeaderMap.class.getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
return (int[]) field.get(map);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
+15
-15
@@ -8,15 +8,15 @@ import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class HeaderMapTest {
|
||||
class Http1HeaderMapTest {
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static HeaderMap parse(String... headers) {
|
||||
private static Http1HeaderMap parse(String... headers) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String h : headers) sb.append(h).append("\r\n");
|
||||
byte[] buffer = sb.toString().getBytes(StandardCharsets.UTF_8);
|
||||
HeaderMap map = new HeaderMap();
|
||||
Http1HeaderMap map = new Http1HeaderMap();
|
||||
map.reset(buffer, 0, buffer.length);
|
||||
return map;
|
||||
}
|
||||
@@ -25,21 +25,21 @@ class HeaderMapTest {
|
||||
|
||||
@Test
|
||||
void first_existingHeader() {
|
||||
HeaderMap map = parse("Host: localhost", "Accept: text/plain");
|
||||
Http1HeaderMap map = parse("Host: localhost", "Accept: text/plain");
|
||||
assertEquals("localhost", map.first("Host"));
|
||||
assertEquals("text/plain", map.first("Accept"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void first_caseInsensitive() {
|
||||
HeaderMap map = parse("ConteNT-tYPe: application/json");
|
||||
Http1HeaderMap map = parse("ConteNT-tYPe: application/json");
|
||||
assertEquals("application/json", map.first("content-type"));
|
||||
assertEquals("application/json", map.first("CONTENT-TYPE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void first_missingHeader_returnsNull() {
|
||||
HeaderMap map = parse("Host: localhost");
|
||||
Http1HeaderMap map = parse("Host: localhost");
|
||||
assertNull(map.first("Accept"));
|
||||
}
|
||||
|
||||
@@ -47,19 +47,19 @@ class HeaderMapTest {
|
||||
|
||||
@Test
|
||||
void all_multipleValuesByName() {
|
||||
HeaderMap map = parse("Cookie: a=1", "Set-Cookie: token=123", "Cookie: b=2");
|
||||
Http1HeaderMap map = parse("Cookie: a=1", "Set-Cookie: token=123", "Cookie: b=2");
|
||||
assertEquals(List.of("a=1", "b=2"), map.all("Cookie"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void all_missingHeader_returnsEmptyList() {
|
||||
HeaderMap map = parse("Host: localhost");
|
||||
Http1HeaderMap map = parse("Host: localhost");
|
||||
assertTrue(map.all("Cookie").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void all_returnsAllHeaders() {
|
||||
HeaderMap map = parse("A: 1", "B: 2");
|
||||
Http1HeaderMap map = parse("A: 1", "B: 2");
|
||||
assertEquals(List.of("1", "2"), map.all());
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ class HeaderMapTest {
|
||||
|
||||
@Test
|
||||
void view_returnsZeroCopyView() {
|
||||
HeaderMap map = parse("Host: localhost");
|
||||
Http1HeaderMap map = parse("Host: localhost");
|
||||
ByteView view = map.view("Host");
|
||||
assertNotNull(view);
|
||||
assertEquals(9, view.length());
|
||||
@@ -77,7 +77,7 @@ class HeaderMapTest {
|
||||
|
||||
@Test
|
||||
void view_missingHeader_returnsNull() {
|
||||
HeaderMap map = parse("Host: localhost");
|
||||
Http1HeaderMap map = parse("Host: localhost");
|
||||
assertNull(map.view("Accept"));
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ class HeaderMapTest {
|
||||
|
||||
@Test
|
||||
void emptyMap_returnsNullAndEmptyList() {
|
||||
HeaderMap map = new HeaderMap();
|
||||
Http1HeaderMap map = new Http1HeaderMap();
|
||||
assertNull(map.first("Host"));
|
||||
assertTrue(map.all("Host").isEmpty());
|
||||
assertTrue(map.all().isEmpty());
|
||||
@@ -95,7 +95,7 @@ class HeaderMapTest {
|
||||
|
||||
@Test
|
||||
void forEach_visitsEveryHeaderInDeclarationOrder() {
|
||||
HeaderMap map = parse("Host: localhost", "Accept: text/plain", "Cookie: a=1");
|
||||
Http1HeaderMap map = parse("Host: localhost", "Accept: text/plain", "Cookie: a=1");
|
||||
List<String> seen = new java.util.ArrayList<>();
|
||||
map.forEach((name, value) -> seen.add(toStr(name) + "=" + toStr(value)));
|
||||
assertEquals(List.of("Host=localhost", "Accept=text/plain", "Cookie=a=1"), seen);
|
||||
@@ -103,7 +103,7 @@ class HeaderMapTest {
|
||||
|
||||
@Test
|
||||
void forEach_emptyMap_neverInvokesConsumer() {
|
||||
HeaderMap map = new HeaderMap();
|
||||
Http1HeaderMap map = new Http1HeaderMap();
|
||||
map.forEach((name, value) -> fail("must not be called on an empty map"));
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ class HeaderMapTest {
|
||||
void forEach_reusesTheSameTwoViewInstancesAcrossEveryHeader() {
|
||||
// The zero-allocation contract: forEach must reposition two ByteViews in place, not
|
||||
// allocate a fresh pair per header — same instances across all three calls here.
|
||||
HeaderMap map = parse("A: 1", "B: 2", "C: 3");
|
||||
Http1HeaderMap map = parse("A: 1", "B: 2", "C: 3");
|
||||
List<ByteView> names = new java.util.ArrayList<>();
|
||||
List<ByteView> values = new java.util.ArrayList<>();
|
||||
map.forEach((name, value) -> { names.add(name); values.add(value); });
|
||||
@@ -161,4 +161,46 @@ class RequestBodyTest {
|
||||
body.drain();
|
||||
assertEquals(0, socket.available());
|
||||
}
|
||||
|
||||
// --- EX-22/EX-23: pooled instance, repositioned via reset() --------------------
|
||||
|
||||
@Test
|
||||
void reset_repositionsSamePooledInstance_overSuccessiveRequests() throws IOException {
|
||||
RequestBody body = new RequestBody(); // pooled ctor — no I/O configured yet
|
||||
|
||||
byte[] first = "first".getBytes(StandardCharsets.UTF_8);
|
||||
body.reset(new ByteArrayInputStream(first), 5, new byte[0], 0, 0);
|
||||
assertArrayEquals(first, body.bytes());
|
||||
|
||||
byte[] second = "second-request".getBytes(StandardCharsets.UTF_8);
|
||||
body.reset(new ByteArrayInputStream(second), second.length, new byte[0], 0, 0);
|
||||
assertArrayEquals(second, body.bytes(), "reset() must not leak the previous request's resolved body");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stream_reusesTheSameBoundedStreamInstance_acrossResets() throws IOException {
|
||||
RequestBody body = new RequestBody();
|
||||
|
||||
body.reset(new ByteArrayInputStream("one".getBytes(StandardCharsets.UTF_8)), 3, new byte[0], 0, 0);
|
||||
InputStream stream1 = body.stream();
|
||||
assertEquals("one", new String(stream1.readAllBytes(), StandardCharsets.UTF_8));
|
||||
|
||||
body.reset(new ByteArrayInputStream("two".getBytes(StandardCharsets.UTF_8)), 3, new byte[0], 0, 0);
|
||||
InputStream stream2 = body.stream();
|
||||
assertSame(stream1, stream2, "EX-23: stream() must reposition the one pooled BoundedBufferedInputStream, not allocate a new one per request");
|
||||
assertEquals("two", new String(stream2.readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void drain_reusesTheSameDrainBuffer_acrossChunkedResets() throws IOException {
|
||||
RequestBody body = new RequestBody();
|
||||
|
||||
body.reset(new ByteArrayInputStream("chunk one".getBytes(StandardCharsets.UTF_8)), -1L, null, 0, 0);
|
||||
body.drain();
|
||||
|
||||
ByteArrayInputStream secondSocket = new ByteArrayInputStream("chunk two".getBytes(StandardCharsets.UTF_8));
|
||||
body.reset(secondSocket, -1L, null, 0, 0);
|
||||
body.drain();
|
||||
assertEquals(0, secondSocket.available(), "drain() must fully consume the second request's chunked body too");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class RequestLineTest {
|
||||
ByteView path = viewOf("/api");
|
||||
ByteView query = viewOf("q=1");
|
||||
ByteView proto = viewOf("HTTP/1.1");
|
||||
HeaderMap headers = new HeaderMap();
|
||||
Http1HeaderMap headers = new Http1HeaderMap();
|
||||
|
||||
RequestLine rl = new RequestLine(HttpMethod.GET, path, query, proto, headers);
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package dev.relism.flash.models;
|
||||
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* {@code EX-22}: {@link Request} is pooled <em>per connection</em> (one instance owned by
|
||||
* {@code RequestParser}, repositioned via {@link Request#forParsed} for every request on that
|
||||
* connection) — not via a shared cross-connection pool. The plan's own safety-check wording
|
||||
* ("connection A's {@code Authorization} header must never be visible on connection B") describes
|
||||
* a threat model that does not structurally apply to this design: two different connections
|
||||
* never share a {@code Request} instance at all (each owns its own {@code RequestParser}, hence
|
||||
* its own {@code Request}) — see {@code DECISIONS.md} for the pooling-granularity decision this
|
||||
* follows from. The real, applicable threat this class actually tests: request <em>N+1</em> on
|
||||
* the *same* keep-alive connection must never see stale data left over from request <em>N</em>,
|
||||
* since those two requests genuinely do share one {@code Request} instance.
|
||||
*/
|
||||
class RequestPoolingTest {
|
||||
|
||||
private static ByteView viewOf(String s) {
|
||||
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
|
||||
return new ByteView() {
|
||||
public int length() { return bytes.length; }
|
||||
public byte byteAt(int idx) { return bytes[idx]; }
|
||||
};
|
||||
}
|
||||
|
||||
private static Http1HeaderMap headersOf(String... rawLines) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String line : rawLines) sb.append(line).append("\r\n");
|
||||
byte[] buf = sb.toString().getBytes(StandardCharsets.UTF_8);
|
||||
Http1HeaderMap map = new Http1HeaderMap();
|
||||
map.reset(buf, 0, buf.length);
|
||||
return map;
|
||||
}
|
||||
|
||||
@Test
|
||||
void forParsed_reusesTheSamePooledInstance_neverAllocatesANewOne() {
|
||||
Request pooled = new Request();
|
||||
RequestLine line1 = new RequestLine(HttpMethod.GET, viewOf("/a"), null, viewOf("HTTP/1.1"), headersOf());
|
||||
Request r1 = Request.forParsed(pooled, line1, RequestBody.empty(), null, null);
|
||||
assertSame(pooled, r1);
|
||||
|
||||
RequestLine line2 = new RequestLine(HttpMethod.POST, viewOf("/b"), null, viewOf("HTTP/1.1"), headersOf());
|
||||
Request r2 = Request.forParsed(pooled, line2, RequestBody.empty(), null, null);
|
||||
assertSame(pooled, r2);
|
||||
assertSame(r1, r2, "the same pooled instance must be returned for every request on one connection");
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondRequest_onSameConnection_doesNotSeeFirstRequestsAuthorizationHeader() {
|
||||
Request pooled = new Request();
|
||||
|
||||
RequestLine first = new RequestLine(HttpMethod.GET, viewOf("/secure"), null, viewOf("HTTP/1.1"),
|
||||
headersOf("Authorization: Bearer super-secret-token-A"));
|
||||
Request r1 = Request.forParsed(pooled, first, RequestBody.empty(), null, null);
|
||||
assertEquals("Bearer super-secret-token-A", r1.header("Authorization"));
|
||||
|
||||
// A second request on the same keep-alive connection, with no Authorization header at all.
|
||||
RequestLine second = new RequestLine(HttpMethod.GET, viewOf("/public"), null, viewOf("HTTP/1.1"),
|
||||
headersOf("Host: example.com"));
|
||||
Request r2 = Request.forParsed(pooled, second, RequestBody.empty(), null, null);
|
||||
|
||||
assertNull(r2.header("Authorization"), "the second request must not see the first request's Authorization header");
|
||||
assertNull(r2.header("authorization"));
|
||||
for (String value : r2.headers()) {
|
||||
assertFalse(value.contains("super-secret-token-A"), "leaked secret found in: " + value);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondRequest_doesNotSeeFirstRequestsPathParams() {
|
||||
Request pooled = new Request();
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/users/123"), null, viewOf("HTTP/1.1"), headersOf());
|
||||
Request r1 = Request.forParsed(pooled, line, RequestBody.empty(), null, null);
|
||||
PathParams.inject(r1, new PathParams(viewOf("/users/123"), new String[]{"id"}, new int[]{7}, new int[]{3}));
|
||||
assertEquals("123", r1.param("id"));
|
||||
|
||||
RequestLine line2 = new RequestLine(HttpMethod.GET, viewOf("/health"), null, viewOf("HTTP/1.1"), headersOf());
|
||||
Request r2 = Request.forParsed(pooled, line2, RequestBody.empty(), null, null);
|
||||
assertNull(r2.param("id"), "path params from the previous request on this connection must not leak");
|
||||
assertNull(r2.getPathParams());
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondRequest_doesNotSeeFirstRequestsCachedPathOrQueryParams() {
|
||||
Request pooled = new Request();
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/first"), viewOf("token=abc"), viewOf("HTTP/1.1"), headersOf());
|
||||
Request r1 = Request.forParsed(pooled, line, RequestBody.empty(), null, null);
|
||||
assertEquals("/first", r1.path());
|
||||
assertEquals("abc", r1.query("token"));
|
||||
|
||||
RequestLine line2 = new RequestLine(HttpMethod.GET, viewOf("/second"), null, viewOf("HTTP/1.1"), headersOf());
|
||||
Request r2 = Request.forParsed(pooled, line2, RequestBody.empty(), null, null);
|
||||
assertEquals("/second", r2.path(), "cachedPath from the previous request must not leak");
|
||||
assertNull(r2.query("token"), "query params from the previous request must not leak");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package dev.relism.flash.models;
|
||||
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* {@code EX-22}'s dev-mode use-after-recycle guard. Exercises the poisoning check directly via
|
||||
* {@code Request.setPoisoningEnabledForTesting} rather than the real {@code Flash.DEV} flag,
|
||||
* which is a {@code static final boolean} fixed once at JVM startup and cannot be toggled by an
|
||||
* individual test — see that field's own comment in {@code Request.java}.
|
||||
*/
|
||||
class RequestRecycleGuardTest {
|
||||
|
||||
@AfterEach
|
||||
void restoreProductionDefault() {
|
||||
// Never leak the test override into other test classes sharing this JVM/fork.
|
||||
Request.setPoisoningEnabledForTesting(false);
|
||||
}
|
||||
|
||||
private static ByteView viewOf(String s) {
|
||||
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
|
||||
return new ByteView() {
|
||||
public int length() { return bytes.length; }
|
||||
public byte byteAt(int idx) { return bytes[idx]; }
|
||||
};
|
||||
}
|
||||
|
||||
private static Request active() {
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/x"), null, viewOf("HTTP/1.1"), new Http1HeaderMap());
|
||||
return new Request(line, new byte[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningDisabled_recycledRequestStillAccessible() {
|
||||
Request.setPoisoningEnabledForTesting(false);
|
||||
Request r = active();
|
||||
r.recycle();
|
||||
assertDoesNotThrow(r::method, "poisoning disabled (production default) must never throw");
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_freshRequest_accessibleNormally() {
|
||||
Request.setPoisoningEnabledForTesting(true);
|
||||
Request r = active();
|
||||
assertDoesNotThrow(r::path);
|
||||
assertDoesNotThrow(() -> r.header("Host"));
|
||||
assertDoesNotThrow(r::method);
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_afterRecycle_methodThrows() {
|
||||
Request.setPoisoningEnabledForTesting(true);
|
||||
Request r = active();
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, r::method);
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_afterRecycle_pathThrows() {
|
||||
Request.setPoisoningEnabledForTesting(true);
|
||||
Request r = active();
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, r::path);
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_afterRecycle_headerThrows() {
|
||||
Request.setPoisoningEnabledForTesting(true);
|
||||
Request r = active();
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, () -> r.header("Host"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_afterRecycle_paramThrows() {
|
||||
Request.setPoisoningEnabledForTesting(true);
|
||||
Request r = active();
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, () -> r.param("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_afterRecycle_queryThrows() {
|
||||
Request.setPoisoningEnabledForTesting(true);
|
||||
Request r = active();
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, () -> r.query("q"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_afterRecycle_remoteAddressThrows() {
|
||||
Request.setPoisoningEnabledForTesting(true);
|
||||
Request r = active();
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, r::remoteAddress);
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_afterRecycle_isSecureThrows() {
|
||||
Request.setPoisoningEnabledForTesting(true);
|
||||
Request r = active();
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, r::isSecure);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reusedAfterReset_becomesAccessibleAgain() {
|
||||
Request.setPoisoningEnabledForTesting(true);
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/first"), null, viewOf("HTTP/1.1"), new Http1HeaderMap());
|
||||
Request r = new Request(line, new byte[0]);
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, r::path);
|
||||
|
||||
// Simulate the connection loop pulling this pooled instance back out for the next
|
||||
// request: Request.forParsed's reset() call re-activates it.
|
||||
RequestLine line2 = new RequestLine(HttpMethod.GET, viewOf("/second"), null, viewOf("HTTP/1.1"), new Http1HeaderMap());
|
||||
Request reused = Request.forParsed(r, line2, RequestBody.empty(), null, null);
|
||||
assertSame(r, reused, "forParsed must reposition the same pooled instance, not allocate a new one");
|
||||
assertDoesNotThrow(reused::path);
|
||||
assertEquals("/second", reused.path());
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ class RequestTest {
|
||||
|
||||
@Test
|
||||
void request_creationAndAccessors() {
|
||||
HeaderMap headers = new HeaderMap();
|
||||
Http1HeaderMap headers = new Http1HeaderMap();
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/path"), viewOf("q=1"), viewOf("HTTP/1.1"), headers);
|
||||
byte[] body = "body".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
@@ -43,7 +43,7 @@ class RequestTest {
|
||||
@Test
|
||||
void header_delegatesToRequestLine() {
|
||||
byte[] buffer = "Host: localhost\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
HeaderMap headers = new HeaderMap();
|
||||
Http1HeaderMap headers = new Http1HeaderMap();
|
||||
headers.reset(buffer, 0, buffer.length);
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), headers);
|
||||
Request r = new Request(line, new byte[0]);
|
||||
@@ -57,7 +57,7 @@ class RequestTest {
|
||||
|
||||
@Test
|
||||
void param_lazyGet() {
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new HeaderMap());
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new Http1HeaderMap());
|
||||
Request r = new Request(line, new byte[0]);
|
||||
|
||||
assertNull(r.param("id"));
|
||||
@@ -70,7 +70,7 @@ class RequestTest {
|
||||
|
||||
@Test
|
||||
void query_lazyGet_fromQueryString() {
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), viewOf("a=1&b=2&b=3"), viewOf("HTTP/1.1"), new HeaderMap());
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), viewOf("a=1&b=2&b=3"), viewOf("HTTP/1.1"), new Http1HeaderMap());
|
||||
Request r = new Request(line, new byte[0]);
|
||||
|
||||
assertEquals("1", r.query("a"));
|
||||
@@ -80,7 +80,7 @@ class RequestTest {
|
||||
|
||||
@Test
|
||||
void query_lazyGet_nullQueryString() {
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new HeaderMap());
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new Http1HeaderMap());
|
||||
Request r = new Request(line, new byte[0]);
|
||||
|
||||
assertNull(r.query("a"));
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package dev.relism.flash.models;
|
||||
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** {@code EX-21}: mirrors {@code RequestPoolingTest} for {@link Response}. */
|
||||
class ResponsePoolingTest {
|
||||
|
||||
@Test
|
||||
void reset_returnsSameInstanceAndClearsPreviousState() {
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN);
|
||||
r.header("X-Trace", "abc123").status(201).body("first body");
|
||||
assertEquals(1, r.getHeaders().size());
|
||||
|
||||
Response reset = r.reset(200, ContentType.JSON);
|
||||
assertSame(r, reset, "reset() must reposition the same instance, not allocate a new one");
|
||||
assertEquals(200, reset.getStatusCode());
|
||||
assertNull(reset.getBody(), "body from the previous cycle must not leak");
|
||||
assertTrue(reset.getHeaders().isEmpty(), "headers from the previous cycle must not leak");
|
||||
assertArrayEquals(ContentType.JSON.getBytes(), reset.getContentType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondCycle_doesNotSeeFirstCyclesCustomHeader() {
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN);
|
||||
r.header("X-Secret", "leaked-if-broken");
|
||||
assertEquals(1, r.getHeaders().size());
|
||||
|
||||
r.reset(200, ContentType.TEXT_PLAIN);
|
||||
r.header("X-Public", "fine");
|
||||
|
||||
assertEquals(1, r.getHeaders().size());
|
||||
String only = new String(r.getHeaders().get(0));
|
||||
assertTrue(only.contains("X-Public"));
|
||||
assertFalse(only.contains("X-Secret"), "stale header from the previous cycle leaked: " + only);
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondCycle_reusesHeaderRegionAcrossManyHeaders_staysCorrect() {
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN);
|
||||
for (int cycle = 0; cycle < 5; cycle++) {
|
||||
r.reset(200, ContentType.TEXT_PLAIN);
|
||||
for (int i = 0; i < 10; i++) {
|
||||
r.header("X-Cycle" + cycle + "-H" + i, "v" + i);
|
||||
}
|
||||
assertEquals(10, r.getHeaders().size(), "cycle " + cycle);
|
||||
String last = new String(r.getHeaders().get(9));
|
||||
assertTrue(last.contains("X-Cycle" + cycle + "-H9: v9"), "cycle " + cycle + ": " + last);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void mixedStructuredAndRawHeaders_preserveInsertionOrder() {
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN);
|
||||
r.header("A", "1");
|
||||
r.header("B-raw: 2\r\n".getBytes());
|
||||
r.header("C", "3");
|
||||
|
||||
var headers = r.getHeaders();
|
||||
assertEquals(3, headers.size());
|
||||
assertEquals("A: 1\r\n", new String(headers.get(0)));
|
||||
assertEquals("B-raw: 2\r\n", new String(headers.get(1)));
|
||||
assertEquals("C: 3\r\n", new String(headers.get(2)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preEncodedHeader_roundTripsThroughGetHeaders() {
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN);
|
||||
PreEncodedHeader h = new PreEncodedHeader("X-Static", "value");
|
||||
r.header(h);
|
||||
assertEquals("X-Static: value\r\n", new String(r.getHeaders().get(0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package dev.relism.flash.models;
|
||||
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** {@code EX-21}'s dev-mode use-after-recycle guard — mirrors {@code RequestRecycleGuardTest}. */
|
||||
class ResponseRecycleGuardTest {
|
||||
|
||||
@AfterEach
|
||||
void restoreProductionDefault() {
|
||||
Response.setPoisoningEnabledForTesting(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningDisabled_recycledResponseStillAccessible() {
|
||||
Response.setPoisoningEnabledForTesting(false);
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN);
|
||||
r.recycle();
|
||||
assertDoesNotThrow(r::getStatusCode);
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_afterRecycle_getStatusCodeThrows() {
|
||||
Response.setPoisoningEnabledForTesting(true);
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN);
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, r::getStatusCode);
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_afterRecycle_headerThrows() {
|
||||
Response.setPoisoningEnabledForTesting(true);
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN);
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, () -> r.header("X", "Y"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_afterRecycle_bodyThrows() {
|
||||
Response.setPoisoningEnabledForTesting(true);
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN);
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, () -> r.body("x"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_afterReset_accessibleAgain() {
|
||||
Response.setPoisoningEnabledForTesting(true);
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN);
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, r::getStatusCode);
|
||||
r.reset(200, ContentType.TEXT_PLAIN);
|
||||
assertDoesNotThrow(r::getStatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package dev.relism.flash.models;
|
||||
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ResponseSerializerTest {
|
||||
|
||||
private static List<String> collect(Response r) {
|
||||
List<String> fields = new ArrayList<>();
|
||||
ResponseSerializer.forEachField(r, (nameBuf, nameOff, nameLen, valueBuf, valueOff, valueLen) ->
|
||||
fields.add(new String(nameBuf, nameOff, nameLen, StandardCharsets.US_ASCII)
|
||||
+ "=" + new String(valueBuf, valueOff, valueLen, StandardCharsets.US_ASCII)));
|
||||
return fields;
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentTypeFirst_thenCustomHeadersInOrder() {
|
||||
Response r = new Response(200, ContentType.JSON);
|
||||
r.header("X-A", "1").header("X-B", "2");
|
||||
assertEquals(List.of("Content-Type=application/json", "X-A=1", "X-B=2"), collect(r));
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentTypeNone_isSkipped_notEmptyValue() {
|
||||
Response r = new Response(200, ContentType.NONE);
|
||||
r.header("X-Only", "here");
|
||||
assertEquals(List.of("X-Only=here"), collect(r));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noHeadersAtAll_onlyContentType() {
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN);
|
||||
assertEquals(List.of("Content-Type=text/plain"), collect(r));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rawPreEncodedHeaderBytes_areExcludedFromEnumeration() {
|
||||
// header(byte[]) has no recoverable (name, value) structure -- ResponseSerializer must
|
||||
// skip it (Http1ResponseWriter still renders it, via writeHeaders, just not through this
|
||||
// protocol-neutral path).
|
||||
Response r = new Response(200, ContentType.NONE);
|
||||
r.header("X-Structured", "yes");
|
||||
r.header("X-Raw: no-structure\r\n".getBytes());
|
||||
assertEquals(List.of("X-Structured=yes"), collect(r));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preEncodedHeaderObject_isIncluded_withStructure() {
|
||||
Response r = new Response(200, ContentType.NONE);
|
||||
r.header(new PreEncodedHeader("X-Boot", "constant"));
|
||||
assertEquals(List.of("X-Boot=constant"), collect(r));
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroAllocation_byteRangesAreSlicesOfResponsesOwnBuffers_notCopies() {
|
||||
Response r = new Response(200, ContentType.NONE);
|
||||
r.header("X-A", "value-a");
|
||||
byte[][] captured = new byte[2][];
|
||||
ResponseSerializer.forEachField(r, (nameBuf, nameOff, nameLen, valueBuf, valueOff, valueLen) -> {
|
||||
captured[0] = nameBuf;
|
||||
captured[1] = valueBuf;
|
||||
});
|
||||
// Both slices must reference the SAME backing array (the response's own header region) --
|
||||
// proves no copy was made to hand the field to the consumer.
|
||||
assertSame(captured[0], captured[1]);
|
||||
}
|
||||
}
|
||||
@@ -134,4 +134,35 @@ class ResponseTest {
|
||||
void getHeaders_emptyWhenNoneAdded() {
|
||||
assertTrue(new Response(200, new byte[0], ContentType.TEXT_PLAIN).getHeaders().isEmpty());
|
||||
}
|
||||
|
||||
// --- EX-43: response header budget (Phase 6 zero-alloc DoD) ---
|
||||
|
||||
@Test
|
||||
void header_exceedingMaxCount_throws() {
|
||||
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
for (int i = 0; i < dev.relism.flash.http.Http1Limits.MAX_RESPONSE_HEADER_COUNT; i++) {
|
||||
r.header("X-" + i, "v");
|
||||
}
|
||||
assertThrows(IllegalStateException.class, () -> r.header("one-too-many", "v"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void header_exceedingMaxRegionBytes_throws() {
|
||||
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
String bigValue = "v".repeat(1024);
|
||||
assertThrows(IllegalStateException.class, () -> {
|
||||
// Each call adds ~1024 bytes; comfortably crosses MAX_RESPONSE_HEADER_BYTES well
|
||||
// before MAX_RESPONSE_HEADER_COUNT would trigger first.
|
||||
for (int i = 0; i < dev.relism.flash.http.Http1Limits.MAX_RESPONSE_HEADER_COUNT; i++) {
|
||||
r.header("X-" + i, bigValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void header_withinBudget_stillWorksNormally() {
|
||||
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
r.header("X-Foo", "bar");
|
||||
assertEquals(1, r.getHeaders().size());
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
package dev.relism.flash.routing.routers.fastpathrouter;
|
||||
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.HeaderMap;
|
||||
import dev.relism.flash.models.Http1HeaderMap;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.models.RequestLine;
|
||||
@@ -23,7 +23,7 @@ class FastPathRouterImplTest {
|
||||
RequestLine line = new RequestLine(
|
||||
method, pathView, null,
|
||||
new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8),
|
||||
new HeaderMap()
|
||||
new Http1HeaderMap()
|
||||
);
|
||||
return new Request(line, new byte[0]);
|
||||
}
|
||||
|
||||
+25
@@ -28,6 +28,31 @@ class FastPathViewsTest {
|
||||
assertThrows(IndexOutOfBoundsException.class, () -> view.byteAt(10));
|
||||
}
|
||||
|
||||
// --- EX-42: reset() repositions the same instance, zero allocation ---------
|
||||
|
||||
@Test
|
||||
void requestByteView_reset_repositionsSameInstance() {
|
||||
FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(SHARED_BUFFER, 4, 10);
|
||||
assertEquals("/api/users", view.toString());
|
||||
|
||||
byte[] other = "PUT /orders/9 HTTP/1.1".getBytes(StandardCharsets.UTF_8);
|
||||
view.reset(other, 4, 8);
|
||||
assertEquals(8, view.length());
|
||||
assertEquals("/orders/", view.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestByteView_reset_updatesArrayBackedByteViewAccessors() {
|
||||
FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(SHARED_BUFFER, 0, 3);
|
||||
byte[] other = "zzHELLOzz".getBytes(StandardCharsets.UTF_8);
|
||||
view.reset(other, 2, 5);
|
||||
|
||||
assertSame(other, view.array());
|
||||
assertEquals(2, view.offset());
|
||||
assertEquals(5, view.length());
|
||||
assertEquals("HELLO", view.toString());
|
||||
}
|
||||
|
||||
// --- MethodPathByteView ---
|
||||
|
||||
@Test
|
||||
|
||||
@@ -55,4 +55,38 @@ class ByteTemplateTest {
|
||||
byte[] result = tpl.render("v1", "1", "v2", "2");
|
||||
assertEquals("A12B", new String(result, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
// --- EX-28: renderInto(buffer, offset, ...) ------------------------------------
|
||||
|
||||
@Test
|
||||
void renderInto_writesAtOffset_andReturnsLength() {
|
||||
ByteTemplate tpl = new ByteTemplate("Hello {{name}}!");
|
||||
byte[] buffer = new byte[64];
|
||||
int len = tpl.renderInto(buffer, 5, "name", "World");
|
||||
|
||||
assertEquals("Hello World!".length(), len);
|
||||
assertEquals("Hello World!", new String(buffer, 5, len, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderInto_repeatedPlaceholder_fillsEveryOccurrence() {
|
||||
ByteTemplate tpl = new ByteTemplate("{{var}} == {{var}}");
|
||||
byte[] buffer = new byte[32];
|
||||
int len = tpl.renderInto(buffer, 0, "var", "test");
|
||||
assertEquals("test == test", new String(buffer, 0, len, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderInto_bufferTooSmall_throws() {
|
||||
ByteTemplate tpl = new ByteTemplate("Hello {{name}}!");
|
||||
byte[] buffer = new byte[5];
|
||||
assertThrows(IndexOutOfBoundsException.class, () -> tpl.renderInto(buffer, 0, "name", "World"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderInto_negativeOffset_throws() {
|
||||
ByteTemplate tpl = new ByteTemplate("Hi {{name}}");
|
||||
byte[] buffer = new byte[32];
|
||||
assertThrows(IndexOutOfBoundsException.class, () -> tpl.renderInto(buffer, -1, "name", "X"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package dev.relism.flash.template;
|
||||
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.HeaderMap;
|
||||
import dev.relism.flash.models.Http1HeaderMap;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestLine;
|
||||
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
|
||||
@@ -24,7 +24,7 @@ class ErrorPagesTest {
|
||||
byte[] protoBytes = protocol.getBytes(StandardCharsets.UTF_8);
|
||||
FastPathViews.RequestByteView protoView = new FastPathViews.RequestByteView(protoBytes, 0, protoBytes.length);
|
||||
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, pathView, null, protoView, new HeaderMap());
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, pathView, null, protoView, new Http1HeaderMap());
|
||||
return new Request(line, new byte[0]);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,9 @@ class ScratchPoolTest {
|
||||
ConnectionScratch scratch = pool.acquire();
|
||||
assertNotNull(scratch);
|
||||
assertNotNull(scratch.sha1);
|
||||
assertEquals(ConnectionScratch.DECIMAL_BUFFER_SIZE, scratch.decimalBuffer.length);
|
||||
assertEquals(ConnectionScratch.RELAY_BUFFER_SIZE, scratch.relayBuffer.length);
|
||||
assertNotNull(scratch.responseHead);
|
||||
assertEquals(0, scratch.responseHead.length());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package dev.relism.flash.websocket;
|
||||
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.HeaderMap;
|
||||
import dev.relism.flash.models.Http1HeaderMap;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestLine;
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
@@ -25,7 +25,7 @@ class WebSocketSessionTest {
|
||||
|
||||
@Test
|
||||
void request_returnsWhatWasPassedToConstructor() {
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/chat"), null, viewOf("HTTP/1.1"), new HeaderMap());
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/chat"), null, viewOf("HTTP/1.1"), new Http1HeaderMap());
|
||||
Request req = new Request(line, new byte[0]);
|
||||
WebSocketSession session = new WebSocketSession(
|
||||
new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream(), 64, req, false);
|
||||
|
||||
Reference in New Issue
Block a user