feat(core): HTTP/2 Phase 0 — groundwork (limits, error model, decision log)

Establishes the package layout, limits/error model and decision-log
convention that every later HTTP/2 phase depends on, per
flash/docs/http2/IMPLEMENTATION-PLAN.md Phase 0.

- dev.relism.flash.h2: package-info (architecture overview), Http2ErrorCode
  (the 14 RFC 9113 §7 codes with precomputed 4-byte wire encodings),
  Http2Exception (connection error -> GOAWAY) and Http2StreamException
  (stream error -> RST_STREAM), neither extending IOException, both with
  stack-trace capture disabled on the hot rejection path.
- Http2Limits: every bound Phase 0 requires (concurrent streams, frame
  size, header list size, CONTINUATION/reset/settings/ping rate bounds,
  flow-control windows, HPACK table size/string length, assembly and idle
  timeouts), each documented with the attack or RFC clause it addresses.
- dev.relism.flash.http.Http1Limits: the h1 bounds needed by EX-03 (strict
  Content-Length) and EX-08 (header count/size limits).
- flash/docs/http2/DECISIONS.md seeded with DEC-01..DEC-11 (the ten
  decisions implied by the plan itself, plus DEC-11 recording that commits
  keep scope `core` rather than adding `h2` to AGENTS.md).
- flash/docs/http2/IMPLEMENTATION-PLAN.md: added the Progress Ledger
  (tracks phase status across sessions) and checked off Phase 0's DoD.

19 new tests, full flash module suite green (226/226).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-08-13 10:59:49 +00:00
co-authored by Claude Sonnet 5
parent 8f1f30b973
commit db6e4a4d0c
13 changed files with 3968 additions and 0 deletions
@@ -0,0 +1,59 @@
package dev.relism.flash.h2;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class Http2ErrorCodeTest {
@Test
void everyCodeRoundTripsThroughFromCode() {
for (Http2ErrorCode code : Http2ErrorCode.values()) {
assertSame(code, Http2ErrorCode.fromCode(code.code()));
}
}
@Test
void bytesAreFourByteBigEndian() {
for (Http2ErrorCode code : Http2ErrorCode.values()) {
assertEquals(4, code.bytes().length, code.name());
int decoded = ((code.bytes()[0] & 0xFF) << 24)
| ((code.bytes()[1] & 0xFF) << 16)
| ((code.bytes()[2] & 0xFF) << 8)
| (code.bytes()[3] & 0xFF);
assertEquals(code.code(), decoded, code.name());
}
}
@Test
void allFourteenRfc9113CodesArePresent() {
assertEquals(14, Http2ErrorCode.values().length);
assertEquals(0x00, Http2ErrorCode.NO_ERROR.code());
assertEquals(0x01, Http2ErrorCode.PROTOCOL_ERROR.code());
assertEquals(0x02, Http2ErrorCode.INTERNAL_ERROR.code());
assertEquals(0x03, Http2ErrorCode.FLOW_CONTROL_ERROR.code());
assertEquals(0x04, Http2ErrorCode.SETTINGS_TIMEOUT.code());
assertEquals(0x05, Http2ErrorCode.STREAM_CLOSED.code());
assertEquals(0x06, Http2ErrorCode.FRAME_SIZE_ERROR.code());
assertEquals(0x07, Http2ErrorCode.REFUSED_STREAM.code());
assertEquals(0x08, Http2ErrorCode.CANCEL.code());
assertEquals(0x09, Http2ErrorCode.COMPRESSION_ERROR.code());
assertEquals(0x0a, Http2ErrorCode.CONNECT_ERROR.code());
assertEquals(0x0b, Http2ErrorCode.ENHANCE_YOUR_CALM.code());
assertEquals(0x0c, Http2ErrorCode.INADEQUATE_SECURITY.code());
assertEquals(0x0d, Http2ErrorCode.HTTP_1_1_REQUIRED.code());
}
@Test
void unknownCodeReturnsNull() {
assertNull(Http2ErrorCode.fromCode(0x0e));
assertNull(Http2ErrorCode.fromCode(-1));
assertNull(Http2ErrorCode.fromCode(Integer.MAX_VALUE));
}
@Test
void bytesInstanceIsStablePerConstant() {
// Precomputed at class init (R4) — must not be rebuilt per call.
assertSame(Http2ErrorCode.PROTOCOL_ERROR.bytes(), Http2ErrorCode.PROTOCOL_ERROR.bytes());
}
}
@@ -0,0 +1,36 @@
package dev.relism.flash.h2;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class Http2ExceptionTest {
@Test
void ofCarriesTheGivenCodeAndMessage() {
Http2Exception e = Http2Exception.of(Http2ErrorCode.FLOW_CONTROL_ERROR, "window exceeded");
assertEquals(Http2ErrorCode.FLOW_CONTROL_ERROR, e.errorCode());
assertEquals("window exceeded", e.getMessage());
}
@Test
void stackTraceCaptureIsDisabled() {
Http2Exception e = Http2Exception.of(Http2ErrorCode.PROTOCOL_ERROR, "bad frame");
assertEquals(0, e.getStackTrace().length);
}
@Test
void singletonsCarryTheAdvertisedCode() {
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, Http2Exception.PROTOCOL_ERROR.errorCode());
assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, Http2Exception.FRAME_SIZE_ERROR.errorCode());
assertEquals(Http2ErrorCode.FLOW_CONTROL_ERROR, Http2Exception.FLOW_CONTROL_ERROR.errorCode());
assertEquals(Http2ErrorCode.COMPRESSION_ERROR, Http2Exception.COMPRESSION_ERROR.errorCode());
assertEquals(Http2ErrorCode.INTERNAL_ERROR, Http2Exception.INTERNAL_ERROR.errorCode());
assertEquals(Http2ErrorCode.SETTINGS_TIMEOUT, Http2Exception.SETTINGS_TIMEOUT.errorCode());
}
@Test
void doesNotExtendIoException() {
assertFalse(java.io.IOException.class.isAssignableFrom(Http2Exception.class));
}
}
@@ -0,0 +1,56 @@
package dev.relism.flash.h2;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class Http2LimitsTest {
@Test
void maxFrameSizeLocalWithinRfcBounds() {
// RFC 9113 §6.5.2 — SETTINGS_MAX_FRAME_SIZE must be within 16384..16777215.
assertTrue(Http2Limits.MAX_FRAME_SIZE_LOCAL >= 16_384);
assertTrue(Http2Limits.MAX_FRAME_SIZE_LOCAL <= 16_777_215);
}
@Test
void everyLimitIsPositive() {
assertTrue(Http2Limits.MAX_CONCURRENT_STREAMS > 0);
assertTrue(Http2Limits.MAX_FRAME_SIZE_LOCAL > 0);
assertTrue(Http2Limits.MAX_HEADER_LIST_SIZE > 0);
assertTrue(Http2Limits.MAX_CONTINUATION_FRAMES_PER_BLOCK > 0);
assertTrue(Http2Limits.MAX_RESET_STREAMS_PER_INTERVAL > 0);
assertTrue(Http2Limits.RESET_RATE_INTERVAL_MS > 0);
assertTrue(Http2Limits.MAX_STREAMS_CREATED_PER_INTERVAL > 0);
assertTrue(Http2Limits.MAX_SETTINGS_ENTRIES_PER_FRAME > 0);
assertTrue(Http2Limits.MAX_PING_QUEUE_DEPTH > 0);
assertTrue(Http2Limits.MAX_EMPTY_DATA_FRAMES_PER_STREAM > 0);
assertTrue(Http2Limits.INITIAL_WINDOW_SIZE_LOCAL > 0);
assertTrue(Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL > 0);
assertTrue(Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL > 0);
assertTrue(Http2Limits.MAX_HPACK_STRING_LENGTH > 0);
assertTrue(Http2Limits.HEADER_BLOCK_ASSEMBLY_TIMEOUT_MS > 0);
assertTrue(Http2Limits.STREAM_IDLE_TIMEOUT_MS > 0);
}
@Test
void streamCreationBoundIsAtLeastTheResetBound() {
// A Rapid Reset defence that only counts resets can be bypassed by a peer that creates
// streams fast enough that the reset counter never saturates within a window boundary;
// the creation bound must be at least as tight.
assertTrue(Http2Limits.MAX_STREAMS_CREATED_PER_INTERVAL >= Http2Limits.MAX_RESET_STREAMS_PER_INTERVAL);
}
@Test
void connectionWindowIsAtLeastAsLargeAsAStreamWindow() {
// Otherwise a single active stream would be bottlenecked by the connection window
// before it ever reaches its own (larger) per-stream window.
assertTrue(Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL >= Http2Limits.INITIAL_WINDOW_SIZE_LOCAL);
}
@Test
void hpackDynamicTableSizeMatchesRfcDefault() {
// RFC 7541 §4.1 default is 4096; nothing in this codebase should silently diverge.
assertEquals(4_096, Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL);
}
}
@@ -0,0 +1,27 @@
package dev.relism.flash.h2;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class Http2StreamExceptionTest {
@Test
void carriesStreamIdAndErrorCode() {
Http2StreamException e = new Http2StreamException(7, Http2ErrorCode.STREAM_CLOSED, "closed");
assertEquals(7, e.streamId());
assertEquals(Http2ErrorCode.STREAM_CLOSED, e.errorCode());
assertEquals("closed", e.getMessage());
}
@Test
void stackTraceCaptureIsDisabled() {
Http2StreamException e = new Http2StreamException(3, Http2ErrorCode.CANCEL, "cancelled");
assertEquals(0, e.getStackTrace().length);
}
@Test
void doesNotExtendIoException() {
assertFalse(java.io.IOException.class.isAssignableFrom(Http2StreamException.class));
}
}
@@ -0,0 +1,24 @@
package dev.relism.flash.http;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class Http1LimitsTest {
@Test
void everyLimitIsPositive() {
assertTrue(Http1Limits.MAX_CONTENT_LENGTH > 0);
assertTrue(Http1Limits.MAX_HEADER_COUNT > 0);
assertTrue(Http1Limits.MAX_HEADER_NAME_LENGTH > 0);
assertTrue(Http1Limits.MAX_HEADER_VALUE_LENGTH > 0);
assertTrue(Http1Limits.MAX_REQUEST_LINE_LENGTH > 0);
}
@Test
void requestLineFitsInsideMaxHeaderValueOrderOfMagnitude() {
// Sanity: the request-line bound should not dwarf the total per-header bound to the
// point of being meaningless as a distinct limit.
assertTrue(Http1Limits.MAX_REQUEST_LINE_LENGTH <= Http1Limits.MAX_CONTENT_LENGTH);
}
}