Files
Flash5/flash/src/main/java/dev/relism/flash/models/QueryParams.java
T
Zakaria El OrcheandClaude Sonnet 5 d882ea255c 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>
2026-08-13 15:26:08 +00:00

168 lines
6.9 KiB
Java

package dev.relism.flash.models;
import dev.relism.flash.bytes.ArrayBackedByteView;
import dev.relism.flash.bytes.Pairs;
import dev.relism.flash.bytes.SlicePool;
import dev.relism.fpr.core.ByteView;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/**
* Lazy query parameter access ({@code ?key=value&...}). Backed by a zero-copy {@link ByteView}
* over the raw query string bytes, no parsing at construction, values percent-decoded on demand.
*
* <p>Percent-decoding ({@code %XX} → byte, {@code +} → space) follows the
* {@code application/x-www-form-urlencoded} convention used by all browsers.
*/
public class QueryParams {
public static final QueryParams EMPTY = new QueryParams(null);
private static final int VIEW_POOL_SIZE = 4;
private final ByteView raw;
// EX-05: created lazily, only if view() is ever actually called — QueryParams itself is
// recreated per request (see Request#resolveQueryParams), so an eagerly-constructed pool
// would cost VIEW_POOL_SIZE allocations on every request that touches query params at all,
// even the (currently: every) request that never calls view().
private SlicePool viewPool;
public QueryParams(ByteView raw) {
this.raw = raw;
}
public String get(String name) {
long r = findFirst(name);
if (r < 0) return null;
return decode(Pairs.hi(r), Pairs.lo(r));
}
/**
* Returns a view over the first raw (not percent-decoded) value of {@code name}, or
* {@code null}. {@code EX-05}: drawn from a small internal {@link SlicePool} when
* {@link #raw} is array-backed (always true for h1 today) instead of allocated per call —
* same reuse-window contract as {@link 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).
*/
ByteView view(String name) {
long r = findFirst(name);
if (r < 0) return null;
int s = Pairs.hi(r), l = Pairs.lo(r);
if (raw instanceof ArrayBackedByteView abv) {
if (viewPool == null) viewPool = new SlicePool(VIEW_POOL_SIZE);
return viewPool.acquire(abv.array(), abv.offset() + s, l);
}
final int fs = s, fl = l;
return new ByteView() {
public int length() { return fl; }
public byte byteAt(int idx) { return raw.byteAt(fs + idx); }
};
}
public List<String> getAll(String name) {
if (raw == null) return List.of();
List<String> result = null;
int i = 0, len = raw.length();
while (i < len) {
int keyStart = i;
while (i < len && raw.byteAt(i) != '=' && raw.byteAt(i) != '&') i++;
int keyLen = i - keyStart;
if (i < len && raw.byteAt(i) == '=') {
i++;
int valStart = i;
while (i < len && raw.byteAt(i) != '&') i++;
if (keyMatches(keyStart, keyLen, name)) {
if (result == null) result = new ArrayList<>();
result.add(decode(valStart, i - valStart));
}
}
if (i < len && raw.byteAt(i) == '&') i++;
}
return result != null ? result : List.of();
}
// ── Internals ─────────────────────────────────────────────────────────────
/** Returns {@code (valStart << 32) | valLen}, or {@code -1} if not found. */
private long findFirst(String name) {
if (raw == null) return -1L;
int i = 0, len = raw.length();
while (i < len) {
int keyStart = i;
while (i < len && raw.byteAt(i) != '=' && raw.byteAt(i) != '&') i++;
int keyLen = i - keyStart;
if (i < len && raw.byteAt(i) == '=') {
i++;
int valStart = i;
while (i < len && raw.byteAt(i) != '&') i++;
if (keyMatches(keyStart, keyLen, name)) return Pairs.pack(valStart, i - valStart);
}
if (i < len && raw.byteAt(i) == '&') i++;
}
return -1L;
}
private boolean keyMatches(int start, int len, String name) {
if (len != name.length()) return false;
for (int i = 0; i < len; i++) if (raw.byteAt(start + i) != (byte) name.charAt(i)) return false;
return true;
}
/**
* Percent-decodes a value slice from {@code raw} into a UTF-8 String.
* {@code %XX} triplets are decoded to their byte values; {@code +} decodes as space.
* Invalid {@code %} sequences are passed through as-is.
*
* <p>{@code EX-26}: the overwhelmingly common query value contains neither {@code %} nor
* {@code +} — scanned for first; when clean and {@link #raw} is array-backed, the
* {@code String} is built directly from the backing array in one allocation, skipping the
* scratch {@code byte[]} copy this method used to make unconditionally for every value.
*/
private String decode(int start, int length) {
boolean clean = true;
for (int i = 0; i < length; i++) {
byte b = raw.byteAt(start + i);
if (b == '%' || b == '+') { clean = false; break; }
}
if (clean) {
if (raw instanceof ArrayBackedByteView abv) {
return new String(abv.array(), abv.offset() + start, length, StandardCharsets.UTF_8);
}
byte[] out = new byte[length];
for (int i = 0; i < length; i++) out[i] = raw.byteAt(start + i);
return new String(out, StandardCharsets.UTF_8);
}
byte[] out = new byte[length]; // upper bound — decoded is never longer
int w = 0;
for (int i = 0; i < length; i++) {
byte b = raw.byteAt(start + i);
if (b == '+') {
out[w++] = ' ';
} else if (b == '%' && i + 2 < length) {
int hi = hexVal(raw.byteAt(start + i + 1));
int lo = hexVal(raw.byteAt(start + i + 2));
if (hi >= 0 && lo >= 0) {
out[w++] = (byte) ((hi << 4) | lo);
i += 2;
} else {
out[w++] = b; // not a valid escape — keep literal %
}
} else {
out[w++] = b;
}
}
return new String(out, 0, w, StandardCharsets.UTF_8);
}
private static int hexVal(byte b) {
if (b >= '0' && b <= '9') return b - '0';
if (b >= 'a' && b <= 'f') return b - 'a' + 10;
if (b >= 'A' && b <= 'F') return b - 'A' + 10;
return -1;
}
}