feat(core): HTTP/2 Phase 5 — frame layer

Implements HTTP/2 frame reading, validation, and writing: FrameType (the
10 RFC 9113 types + per-type validation descriptor), FrameFlags (with
the deliberate END_STREAM/ACK bit collision documented), FrameHeader (a
flyweight, never allocated per frame), Http2FrameReader (length-prefixed
reader over BufferedByteSource, mirroring RequestParser's buffer/
compaction discipline), FrameValidator (table-driven, specific RFC error
code per violation -- not a uniform code per type), Padding (RFC 9113
6.1/6.2), and FrameWriteBuffer (beginFrame/endFrame length back-patching
over Phase 4's ByteWriter).

All 10 frame types round-trip correctly; every RFC-mandated rejection
has its own test asserting the specific error code; the reader is
fuzz-tested against 10,000,000 random inputs (~14s). The zero-alloc
contract is measured, not asserted: reading + validating + consuming a
frame is 0.002 B/op, writing one is ~10^-4 B/op -- both indistinguishable
from zero (DEC-21).

Found and fixed EX-37 while writing Http2FrameReaderTest: BufferedByteSource's
deadline mechanism (EX-07's actual fix) NPE'd against a null socket, which
every isolated unit test in this codebase uses -- it had zero dedicated
test coverage of its own. Fixed to treat a null socket as "no OS-level
timeout to bound" rather than a misuse, and given BufferedByteSourceTest,
which did not exist before.

449/449 tests green, both with and without -Pjmh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-08-13 14:25:12 +00:00
co-authored by Claude Sonnet 5
parent 704a00a551
commit 0e1bbed42c
18 changed files with 1665 additions and 23 deletions
@@ -156,4 +156,13 @@ public final class Http2Limits {
* {@code Socket#setSoTimeout} — that option bounds reads, not writes.
*/
public static final long WRITE_TIMEOUT_MS = 30_000;
/**
* Maximum time, in milliseconds, {@code Http2FrameReader} may wait for a single frame's
* header and payload to fully arrive. Bounds the same slowloris-shaped hazard {@code
* BufferedByteSource}'s deadline mechanism already defends h1 against ({@code EX-07}):
* without it, a peer that sends 9 header bytes and then never sends the declared payload
* would hold this connection's frame reader waiting forever.
*/
public static final long FRAME_READ_TIMEOUT_MS = 20_000;
}
@@ -0,0 +1,39 @@
package dev.relism.flash.h2.frame;
/**
* The frame-header flag bits (RFC 9113 §6), as bitwise constants plus predicate helpers.
*
* <h3>The deliberate collision</h3>
* Bit {@code 0x1} means different things on different frame types: {@link #END_STREAM} on
* {@code DATA}/{@code HEADERS}, {@link #ACK} on {@code SETTINGS}/{@code PING}. They are the same
* bit position because the RFC defines flags per-type, not globally — reusing the numeric value
* is intentional on the wire, not a naming accident here. **Never call {@link #isEndStream} on a
* SETTINGS/PING frame's flags, or {@link #isAck} on a DATA/HEADERS frame's** — each predicate is
* named for the one frame type family it is valid to call it on; mixing them up silently
* misreads an unrelated bit rather than throwing, because the bit pattern is, by construction,
* identical.
*
* <p>RFC 9113 §4.1: flag bits not defined for a frame's type MUST be ignored on receipt and MUST
* NOT be set when sending. This class only ever tests bits it defines for the type the caller is
* working with; undefined bits are never inspected.
*/
public final class FrameFlags {
private FrameFlags() {}
/** DATA/HEADERS: no more frames will be sent for this stream in this direction. */
public static final int END_STREAM = 0x1;
/** SETTINGS/PING: this frame acknowledges the peer's own frame, rather than proposing new values. */
public static final int ACK = 0x1;
/** HEADERS/PUSH_PROMISE/CONTINUATION: the header block is complete — no CONTINUATION follows. */
public static final int END_HEADERS = 0x4;
/** DATA/HEADERS/PUSH_PROMISE: a pad-length byte and trailing padding are present — see {@link Padding}. */
public static final int PADDED = 0x8;
/** HEADERS: deprecated stream-dependency/weight fields are present (RFC 9113 §5.3.2 — parsed and discarded). */
public static final int PRIORITY = 0x20;
public static boolean isEndStream(int flags) { return (flags & END_STREAM) != 0; }
public static boolean isAck(int flags) { return (flags & ACK) != 0; }
public static boolean isEndHeaders(int flags) { return (flags & END_HEADERS) != 0; }
public static boolean isPadded(int flags) { return (flags & PADDED) != 0; }
public static boolean hasPriority(int flags) { return (flags & PRIORITY) != 0; }
}
@@ -0,0 +1,84 @@
package dev.relism.flash.h2.frame;
/**
* A <b>flyweight</b> over one frame's 9-byte header plus its payload location, both still living
* in {@link Http2FrameReader}'s own read buffer. One instance per connection, {@link #reset}
* in place by every {@link Http2FrameReader#readFrame()} call — never allocated per frame
* (mirrors the existing {@code WebSocketFrame} reuse idiom in {@code dev.relism.flash.websocket}).
*
* <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
* 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).
*
* <h3>Reserved bit and unknown types</h3>
* {@link #streamId()} has already had the wire's reserved high bit (RFC 9113 §4.1: "R: A
* reserved 1-bit field... The semantics of this bit are undefined, and the bit MUST be ignored
* when receiving") masked off during {@link #reset} — callers never see it and never need to
* mask it themselves. {@link #type()} is {@code null} for a type code {@link FrameType} does not
* recognise (i.e. {@code typeCode() > FrameType.maxKnown()}); per RFC 9113 §4.1 such frames must
* be ignored, not rejected — {@link #typeCode()} remains available so the caller can still log
* or count it before skipping the payload.
*/
public final class FrameHeader {
private byte[] buf;
private int length;
private int typeCode;
private FrameType type;
private int flags;
private int streamId;
private int payloadOffset;
/** Called by {@link Http2FrameReader} only, once the full 9-byte header is available at {@code buf[off]}. */
void reset(byte[] buf, int off) {
this.buf = buf;
int b0 = buf[off] & 0xFF, b1 = buf[off + 1] & 0xFF, b2 = buf[off + 2] & 0xFF;
this.length = (b0 << 16) | (b1 << 8) | b2;
this.typeCode = buf[off + 3] & 0xFF;
this.type = FrameType.fromCode(typeCode);
this.flags = buf[off + 4] & 0xFF;
// RFC 9113 §4.1: the top bit of byte 5 is reserved and MUST be ignored on receipt —
// masked here, once, rather than requiring every caller to remember to.
int b5 = buf[off + 5] & 0x7F;
int b6 = buf[off + 6] & 0xFF, b7 = buf[off + 7] & 0xFF, b8 = buf[off + 8] & 0xFF;
this.streamId = (b5 << 24) | (b6 << 16) | (b7 << 8) | b8;
this.payloadOffset = off + 9;
}
/** Payload length in bytes, as declared by the frame header (0..2^24-1 before any limit check). */
public int length() {
return length;
}
/** The raw wire type byte, valid even when {@link #type()} is {@code null} (an unrecognised type). */
public int typeCode() {
return typeCode;
}
/** The recognised frame type, or {@code null} if {@link #typeCode()} is not one of RFC 9113's 10. */
public FrameType type() {
return type;
}
/** The raw flags byte — interpret via {@link FrameFlags}, which is type-specific. */
public int flags() {
return flags;
}
/** Stream identifier, reserved bit already masked. {@code 0} means "the connection itself". */
public int streamId() {
return streamId;
}
/** The backing buffer — see the class Javadoc's lifetime contract before retaining a reference. */
public byte[] buffer() {
return buf;
}
/** Offset of the first payload byte within {@link #buffer()}. Payload spans {@code [payloadOffset(), payloadOffset() + length())}. */
public int payloadOffset() {
return payloadOffset;
}
}
@@ -0,0 +1,88 @@
package dev.relism.flash.h2.frame;
/**
* The 10 HTTP/2 frame types (RFC 9113 §6), plus the shared per-type validation rules
* {@link FrameValidator} enforces. Values above {@code 0x9} are not assigned a constant here —
* RFC 9113 §4.1 requires unknown types to be silently ignored (read and discard the payload),
* which {@link Http2FrameReader}'s caller implements by checking {@code type >
* FrameType.maxKnown()} rather than by this enum growing an {@code UNKNOWN} member (an
* {@code UNKNOWN} constant would misleadingly suggest "a recognised category of unrecognised
* frame", when the correct handling is simply "not this table, skip it").
*
* <h3>Per-type validation, table-driven (R4)</h3>
* Each constant carries the RFC-mandated payload length bounds, whether a zero stream id is
* required/forbidden/either, and whether the frame counts toward the CONTINUATION-flood guard
* ({@code EX}-style defence, {@code Http2Limits#MAX_CONTINUATION_FRAMES_PER_BLOCK}) — see
* {@link FrameValidator} for how these are applied and the specific RFC citation per rule.
*/
public enum FrameType {
/** RFC 9113 §6.1. Stream body bytes. Stream id required. Length: 0..MAX_FRAME_SIZE. */
DATA(0x0, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED),
/** RFC 9113 §6.2. Header block fragment (HPACK). Stream id required. */
HEADERS(0x1, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED),
/** RFC 9113 §6.3. Deprecated priority signal — parsed and discarded, never acted on (DEC, Phase 5 task 7). */
PRIORITY(0x2, 5, 5, StreamIdRule.REQUIRED),
/** RFC 9113 §6.4. Stream-level error. Exactly 4 bytes (the error code). Stream id required. */
RST_STREAM(0x3, 4, 4, StreamIdRule.REQUIRED),
/** RFC 9113 §6.5. Connection-level parameters. Length must be a multiple of 6. Stream id must be 0. */
SETTINGS(0x4, 0, Integer.MAX_VALUE, StreamIdRule.FORBIDDEN),
/** RFC 9113 §6.6. Never sent (Flash advertises {@code SETTINGS_ENABLE_PUSH=0}); receiving one from a client is a protocol error. */
PUSH_PROMISE(0x5, 4, Integer.MAX_VALUE, StreamIdRule.REQUIRED),
/** RFC 9113 §6.7. Connection liveness / RTT probe. Exactly 8 bytes of opaque data. Stream id must be 0. */
PING(0x6, 8, 8, StreamIdRule.FORBIDDEN),
/** RFC 9113 §6.8. Connection shutdown notice. At least 8 bytes (last-stream-id + error code). Stream id must be 0. */
GOAWAY(0x7, 8, Integer.MAX_VALUE, StreamIdRule.FORBIDDEN),
/** RFC 9113 §6.9. Flow-control window increment. Exactly 4 bytes. Stream id may be either (0 = connection window). */
WINDOW_UPDATE(0x8, 4, 4, StreamIdRule.EITHER),
/** RFC 9113 §6.10. Continuation of a header block that did not fit one HEADERS/PUSH_PROMISE frame. Stream id required. */
CONTINUATION(0x9, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED);
/** Whether a frame type requires stream id 0, requires it non-zero, or permits either. */
public enum StreamIdRule { REQUIRED, FORBIDDEN, EITHER }
private static final FrameType[] BY_CODE = new FrameType[values().length];
static {
for (FrameType t : values()) {
BY_CODE[t.code] = t;
}
}
private final int code;
private final int minLength;
private final int maxLength;
private final StreamIdRule streamIdRule;
FrameType(int code, int minLength, int maxLength, StreamIdRule streamIdRule) {
this.code = code;
this.minLength = minLength;
this.maxLength = maxLength;
this.streamIdRule = streamIdRule;
}
public int code() {
return code;
}
public int minLength() {
return minLength;
}
public int maxLength() {
return maxLength;
}
public StreamIdRule streamIdRule() {
return streamIdRule;
}
/** The highest type code this enum recognises — anything above must be ignored per RFC 9113 §4.1. */
public static int maxKnown() {
return CONTINUATION.code;
}
/** Looks up the constant for a wire type byte, or {@code null} if it is an unrecognised (to-be-ignored) type. */
public static FrameType fromCode(int code) {
return (code >= 0 && code < BY_CODE.length) ? BY_CODE[code] : null;
}
}
@@ -0,0 +1,90 @@
package dev.relism.flash.h2.frame;
import dev.relism.flash.h2.Http2ErrorCode;
import dev.relism.flash.h2.Http2Exception;
import dev.relism.flash.h2.Http2Limits;
/**
* Table-driven RFC 9113 per-frame-type validation: length bounds, the stream-id
* required/forbidden/either rule, and the two special-cased structural rules ({@code SETTINGS}'
* multiple-of-6 length, {@code PUSH_PROMISE} always rejected from a client) that do not fit a
* generic min/max/stream-id table. Table itself lives on {@link FrameType}'s constants (R4); this
* class is the code that reads it.
*
* <p><b>The error code is not uniform</b> — read the RFC per violation, not just per type. A
* {@code SETTINGS} frame with a bad length is {@code FRAME_SIZE_ERROR}; the same frame with a
* non-zero stream id is {@code PROTOCOL_ERROR}. This class throws the specific code each
* violation's own RFC citation requires, not a single blanket code per type.
*/
public final class FrameValidator {
private FrameValidator() {}
/**
* Validates {@code header} against RFC 9113's rules for its type.
*
* @param insideHeaderBlock whether this frame arrived between a HEADERS/PUSH_PROMISE frame
* lacking {@code END_HEADERS} and its terminating CONTINUATION —
* changes the handling of an unrecognised type (§6.10: a
* {@code PROTOCOL_ERROR}, not the usual silent ignore, since an
* in-progress header block cannot tolerate an interloper frame of
* any kind without desynchronizing HPACK's stateful decode)
* @throws Http2Exception on any RFC violation, with the specific error code the violated
* rule mandates
*/
public static void validate(FrameHeader header, boolean insideHeaderBlock) {
FrameType type = header.type();
if (type == null) {
// RFC 9113 §4.1: unknown frame types MUST be ignored — except inside an in-progress
// header block (§6.10), where anything other than CONTINUATION desynchronizes HPACK.
if (insideHeaderBlock) {
throw Http2Exception.of(Http2ErrorCode.PROTOCOL_ERROR,
"unrecognised frame type " + header.typeCode() + " received inside a header block");
}
return;
}
int length = header.length();
// RFC 9113 §6.5: a SETTINGS frame's length MUST be a multiple of 6 (each entry is a
// 2-byte identifier + 4-byte value). Checked before the generic bounds below, since the
// generic table only expresses a min/max range, not a modulus.
if (type == FrameType.SETTINGS && length % 6 != 0) {
throw Http2Exception.FRAME_SIZE_ERROR;
}
if (length < type.minLength() || length > type.maxLength()) {
throw Http2Exception.FRAME_SIZE_ERROR;
}
// Redundant with Http2FrameReader's own pre-allocation check for frames it read itself,
// but this method must also be correct for a FrameHeader built any other way (tests,
// and — in later phases — frames reassembled from multiple reads), so the bound is
// re-asserted here rather than trusted from the caller.
if (length > Http2Limits.MAX_FRAME_SIZE_LOCAL) {
throw Http2Exception.FRAME_SIZE_ERROR;
}
int streamId = header.streamId();
switch (type.streamIdRule()) {
case REQUIRED -> {
if (streamId == 0) {
throw Http2Exception.of(Http2ErrorCode.PROTOCOL_ERROR, type + " requires a non-zero stream id");
}
}
case FORBIDDEN -> {
if (streamId != 0) {
throw Http2Exception.of(Http2ErrorCode.PROTOCOL_ERROR, type + " must have stream id 0, got " + streamId);
}
}
case EITHER -> { /* WINDOW_UPDATE: 0 (connection window) or non-zero (stream window) both valid */ }
}
// RFC 9113 §8.4 / this codebase's DEC-10: PUSH_PROMISE is a server-to-client-only frame
// (Flash advertises SETTINGS_ENABLE_PUSH=0 and never sends one); receiving one at all
// means the peer believes it is talking to a client, which is always a protocol error.
if (type == FrameType.PUSH_PROMISE) {
throw Http2Exception.of(Http2ErrorCode.PROTOCOL_ERROR, "PUSH_PROMISE received from a client");
}
}
}
@@ -0,0 +1,76 @@
package dev.relism.flash.h2.frame;
import dev.relism.flash.bytes.ByteWriter;
/**
* Serializes HTTP/2 frames into a {@link ByteWriter} scratch buffer with the standard
* length-back-patching technique: {@link #beginFrame} writes a 9-byte header with a placeholder
* length, the caller writes the payload directly through {@link #writer()} (the same
* {@link ByteWriter}), and {@link #endFrame} rewrites the length once it is known — the payload
* size is rarely known before it is serialized (an HPACK-encoded header block, in particular,
* has no cheap way to be measured in advance).
*
* <p>This is the reason {@link Http2FrameWriter} (Phase 3) serializes a complete buffer and
* issues one bulk {@code write}, rather than streaming bytes as they are produced: streaming
* would require knowing the length <em>before</em> the first byte goes out, which back-patching
* deliberately avoids needing.
*
* <h3>Usage</h3>
* <pre>{@code
* FrameWriteBuffer out = new FrameWriteBuffer(new ByteWriter(4096));
* out.beginFrame(FrameType.SETTINGS, 0, 0);
* out.writer().writeUInt16(SETTINGS_MAX_CONCURRENT_STREAMS);
* out.writer().writeUInt32(100);
* out.endFrame();
* // out.writer().array()[0, out.writer().length()) now holds one complete, correctly-lengthed frame
* }</pre>
*
* <h3>Multiple frames, one buffer</h3>
* {@link #beginFrame}/{@link #endFrame} pairs may be repeated on the same instance without a
* {@link ByteWriter#reset()} between them — each pair appends one more complete frame after
* whatever was already written, which is exactly what {@link Http2FrameWriter#write} wants for a
* single bulk write covering several frames (e.g. HEADERS followed immediately by its first
* DATA frame).
*
* <h3>Thread-safety</h3>
* Not thread-safe — exactly one writer at a time, the same convention every other per-connection
* scratch object in this codebase follows.
*/
public final class FrameWriteBuffer {
private final ByteWriter writer;
private int headerStart = -1;
public FrameWriteBuffer(ByteWriter writer) {
this.writer = writer;
}
/** The underlying {@link ByteWriter} — write the frame's payload directly through this between {@link #beginFrame} and {@link #endFrame}. */
public ByteWriter writer() {
return writer;
}
/** Writes a 9-byte frame header with a placeholder length, to be filled in by {@link #endFrame}. */
public void beginFrame(FrameType type, int flags, int streamId) {
if (headerStart != -1) {
throw new IllegalStateException("beginFrame() called again before the previous frame's endFrame()");
}
headerStart = writer.length();
writer.writeUInt24(0); // length placeholder
writer.writeByte((byte) type.code());
writer.writeByte((byte) flags);
writer.writeUInt31(streamId);
}
/** Back-patches the length field written by {@link #beginFrame} now that the payload's size is known. */
public void endFrame() {
if (headerStart == -1) {
throw new IllegalStateException("endFrame() called without a matching beginFrame()");
}
int payloadLength = writer.length() - (headerStart + 9);
byte[] buf = writer.array();
buf[headerStart] = (byte) (payloadLength >>> 16);
buf[headerStart + 1] = (byte) (payloadLength >>> 8);
buf[headerStart + 2] = (byte) payloadLength;
headerStart = -1;
}
}
@@ -0,0 +1,133 @@
package dev.relism.flash.h2.frame;
import dev.relism.flash.h2.Http2Exception;
import dev.relism.flash.h2.Http2Limits;
import dev.relism.flash.transport.BufferedByteSource;
import java.io.EOFException;
import java.io.IOException;
import java.util.Arrays;
/**
* Reads length-prefixed HTTP/2 frames from one connection's {@link BufferedByteSource}. Simpler
* than {@code RequestParser} by construction: HTTP/2 frames declare their length up front (the
* 9-byte header), so nothing is ever scanned for — {@code Http2FrameReader} only ever needs to
* know "do I have N bytes yet", never "where does this end".
*
* <h3>Buffer discipline</h3>
* One growable {@code byte[]} per connection, reused across every frame — the same
* compact-before-grow discipline {@code RequestParser}'s own buffer uses. A frame's declared
* length is checked against {@link Http2Limits#MAX_FRAME_SIZE_LOCAL} <em>before</em> the buffer
* is ever grown to accommodate it (R8): a hostile 16 MB declared length is rejected at the
* length-check, not after an allocation already paid for it.
*
* <h3>Usage</h3>
* <pre>{@code
* FrameHeader header = reader.readFrame();
* if (header == null) { /* clean EOF between frames — connection closing *\/ }
* // ... process header.buffer()[header.payloadOffset(), +header.length()) ...
* reader.consumeFrame(); // MUST be called before the next readFrame()
* }</pre>
*
* <h3>Thread-safety</h3>
* Not thread-safe — exactly one virtual thread (the connection's demux loop) ever calls this,
* the same invariant every other per-connection reader in this codebase assumes.
*/
public final class Http2FrameReader {
private static final int FRAME_HEADER_SIZE = 9;
private static final int INITIAL_BUFFER_SIZE = 16 * 1024;
private final BufferedByteSource in;
private final FrameHeader header = new FrameHeader();
private byte[] buffer;
private int base; // offset of the first unconsumed byte
private int totalRead; // count of valid unconsumed bytes at [base, base + totalRead)
public Http2FrameReader(BufferedByteSource in) {
this(in, INITIAL_BUFFER_SIZE);
}
public Http2FrameReader(BufferedByteSource in, int initialBufferSize) {
this.in = in;
this.buffer = new byte[Math.max(initialBufferSize, FRAME_HEADER_SIZE)];
}
/**
* Reads the next frame's header and payload, bounded by
* {@link Http2Limits#FRAME_READ_TIMEOUT_MS}, and returns the reused {@link FrameHeader}
* flyweight positioned over it — or {@code null} on a clean EOF between frames (the peer
* closed the connection while nothing was in flight; not an error).
*
* <p>The caller MUST call {@link #consumeFrame()} exactly once after processing this frame
* (or deciding to discard it) and before calling this method again.
*
* @throws Http2Exception if the declared length exceeds {@link Http2Limits#MAX_FRAME_SIZE_LOCAL}
* @throws EOFException if the connection closes after a frame has already started arriving
* @throws java.net.SocketTimeoutException if {@link Http2Limits#FRAME_READ_TIMEOUT_MS} elapses
*/
public FrameHeader readFrame() throws IOException {
in.setDeadline(System.nanoTime() + Http2Limits.FRAME_READ_TIMEOUT_MS * 1_000_000L);
try {
if (!ensureAvailable(FRAME_HEADER_SIZE)) {
return null; // clean EOF: nothing buffered yet, peer closed between frames
}
int declaredLength = decodeLength(buffer, base);
// R8: checked BEFORE any further buffer growth or read — a hostile declared length
// never causes an oversized allocation, only a rejection.
if (declaredLength > Http2Limits.MAX_FRAME_SIZE_LOCAL) {
throw Http2Exception.FRAME_SIZE_ERROR;
}
ensureAvailable(FRAME_HEADER_SIZE + declaredLength);
header.reset(buffer, base);
return header;
} finally {
in.clearDeadline();
}
}
/** Advances past the frame last returned by {@link #readFrame()}. Zero-copy, zero-allocation. */
public void consumeFrame() {
int consumed = FRAME_HEADER_SIZE + header.length();
base += consumed;
totalRead -= consumed;
if (totalRead == 0) {
base = 0; // nothing buffered — reset to the front rather than drifting forever
}
}
private static int decodeLength(byte[] buf, int off) {
int b0 = buf[off] & 0xFF, b1 = buf[off + 1] & 0xFF, b2 = buf[off + 2] & 0xFF;
return (b0 << 16) | (b1 << 8) | b2;
}
/**
* Ensures at least {@code need} bytes are available starting at {@link #base}, growing or
* compacting the buffer as necessary. Returns {@code false} only for a clean EOF with
* nothing at all buffered yet (the between-frames case); an EOF after any bytes of the
* current frame have already arrived is a genuine truncation and throws.
*/
private boolean ensureAvailable(int need) throws IOException {
while (totalRead < need) {
if (base + need > buffer.length) {
if (base > 0) {
// Compact: slide unconsumed bytes to the front — frees room without growing.
System.arraycopy(buffer, base, buffer, 0, totalRead);
base = 0;
} else {
// need <= 9 + MAX_FRAME_SIZE_LOCAL always, by readFrame()'s own check before
// the payload-sized call — grow exactly enough, never unbounded.
int grown = buffer.length;
while (grown < need) grown *= 2;
buffer = Arrays.copyOf(buffer, grown);
}
}
int n = in.read(buffer, base + totalRead, buffer.length - base - totalRead);
if (n < 0) {
if (totalRead == 0) return false;
throw new EOFException("connection closed mid-frame (" + totalRead + "/" + need + " bytes read)");
}
totalRead += n;
}
return true;
}
}
@@ -0,0 +1,67 @@
package dev.relism.flash.h2.frame;
import dev.relism.flash.bytes.Pairs;
import dev.relism.flash.h2.Http2ErrorCode;
import dev.relism.flash.h2.Http2Exception;
/**
* RFC 9113 §6.1 (DATA) / §6.2 (HEADERS) padding. When {@link FrameFlags#PADDED} is set, a
* frame's payload is laid out as: 1 pad-length byte, then the actual data (or header-block
* fragment), then that many padding bytes (RFC 9113 gives no meaning to the padding bytes
* themselves — they exist only to obscure payload size from network observers).
*
* <p>Padding is <b>not optional to support</b>: any client may send it on DATA or HEADERS
* regardless of whether the server ever sends padded frames itself.
*
* <h3>Flow control (forward note, not implemented here)</h3>
* RFC 9113 §6.9.1: padding bytes count against the DATA flow-control window even though they
* carry no data — the <em>whole</em> frame payload (pad-length byte + data + padding) is what a
* future Phase 11 flow controller must subtract from the window, not just {@link
* #dataLength(long)}. This class only locates the data range within the payload; it performs no
* flow-control accounting itself.
*/
public final class Padding {
private Padding() {}
/**
* Locates the actual data range within a payload that may or may not be padded. When
* {@code padded} is {@code false}, returns the whole payload unchanged (zero-cost — no
* padding byte to read, no arithmetic beyond the pack). When {@code true}, reads the
* pad-length byte at {@code buf[payloadOffset]}, validates it, and returns the data range
* that follows it.
*
* @return {@code Pairs.pack(dataOffset, dataLength)} — unpack with {@link Pairs#hi}/{@link Pairs#lo}
* @throws Http2Exception ({@code PROTOCOL_ERROR}) if {@code padded} is set but
* {@code payloadLength == 0} (no room for the pad-length byte itself), or if the
* claimed pad length is greater than or equal to the whole payload length (RFC 9113
* §6.1: "If the length of the padding is the length of the frame payload or
* greater, the recipient MUST treat this as a connection error")
*/
public static long unpad(byte[] buf, int payloadOffset, int payloadLength, boolean padded) {
if (!padded) {
return Pairs.pack(payloadOffset, payloadLength);
}
if (payloadLength == 0) {
throw Http2Exception.of(Http2ErrorCode.PROTOCOL_ERROR,
"PADDED flag set but the frame has no payload for the pad-length byte");
}
int padLength = buf[payloadOffset] & 0xFF;
if (padLength >= payloadLength) {
throw Http2Exception.of(Http2ErrorCode.PROTOCOL_ERROR,
"pad length " + padLength + " >= frame payload length " + payloadLength);
}
int dataOffset = payloadOffset + 1;
int dataLength = payloadLength - 1 - padLength;
return Pairs.pack(dataOffset, dataLength);
}
/** Extracts the data offset from a value returned by {@link #unpad}. */
public static int dataOffset(long unpadded) {
return Pairs.hi(unpadded);
}
/** Extracts the data length from a value returned by {@link #unpad}. */
public static int dataLength(long unpadded) {
return Pairs.lo(unpadded);
}
}
@@ -91,10 +91,17 @@ public final class BufferedByteSource extends InputStream {
* Removes the deadline and restores the socket to blocking indefinitely
* ({@code SO_TIMEOUT = 0}). Must be called before any read the caller wants to be
* unbounded (e.g. handing the connection off to a long-lived WebSocket session loop).
*
* <p>{@code EX-37}: a {@code null} socket (the constructor accepts one — every isolated unit
* test in this codebase that constructs a {@code BufferedByteSource} directly over a
* {@code ByteArrayInputStream} passes {@code null}, since there is no real connection to
* bound) is treated as "no OS-level timeout to clear", not an error — only the deadline
* bookkeeping is reset. Production always supplies a real socket, so this changes no
* production behavior; without it, no test can exercise the deadline mechanism at all.
*/
public void clearDeadline() throws IOException {
this.deadlineActive = false;
socket.setSoTimeout(0);
if (socket != null) socket.setSoTimeout(0);
}
// ── InputStream ──────────────────────────────────────────────────────────
@@ -247,6 +254,14 @@ public final class BufferedByteSource extends InputStream {
* active, computes the exact remaining budget and hands it to {@link Socket#setSoTimeout}
* before reading, so a {@link SocketTimeoutException} from {@code in.read} unambiguously
* means the deadline — not merely one read — has elapsed; see the class Javadoc.
*
* <p>{@code EX-37}: the expiry check above (throwing once {@code remainingNanos <= 0}) runs
* regardless of whether a real {@link Socket} is present; only the OS-level
* {@code setSoTimeout} call — meaningless without a socket, and previously called
* unconditionally, which NPE'd the instant any deadline-bounded read ran against a
* {@code null}-socket source — is skipped when {@code socket == null}. See
* {@link #clearDeadline()}'s Javadoc for why {@code null} is a legitimate, tested case, not
* a misuse.
*/
private int fillFromUnderlying(byte[] dst, int off, int len) throws IOException {
if (!deadlineActive) {
@@ -256,9 +271,11 @@ public final class BufferedByteSource extends InputStream {
if (remainingNanos <= 0) {
throw new SocketTimeoutException("Read deadline exceeded");
}
long remainingMillis = (remainingNanos + 999_999L) / 1_000_000L; // round up
int timeoutMs = (int) Math.max(1, Math.min(Integer.MAX_VALUE, remainingMillis));
socket.setSoTimeout(timeoutMs);
if (socket != null) {
long remainingMillis = (remainingNanos + 999_999L) / 1_000_000L; // round up
int timeoutMs = (int) Math.max(1, Math.min(Integer.MAX_VALUE, remainingMillis));
socket.setSoTimeout(timeoutMs);
}
return in.read(dst, off, len);
}
}