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
@@ -0,0 +1,181 @@
package dev.relism.flash.h2.frame;
import dev.relism.flash.h2.Http2ErrorCode;
import dev.relism.flash.h2.Http2Exception;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/** One test per RFC-mandated rejection, asserting the specific {@link Http2ErrorCode} — not merely that {@link Http2Exception} was thrown. */
class FrameValidatorTest {
private static byte[] rawFrame(int length, int typeCode, int flags, int streamId) {
byte[] buf = new byte[9];
buf[0] = (byte) (length >>> 16);
buf[1] = (byte) (length >>> 8);
buf[2] = (byte) length;
buf[3] = (byte) typeCode;
buf[4] = (byte) flags;
buf[5] = (byte) (streamId >>> 24);
buf[6] = (byte) (streamId >>> 16);
buf[7] = (byte) (streamId >>> 8);
buf[8] = (byte) streamId;
return buf;
}
private static FrameHeader headerOf(int length, FrameType type, int flags, int streamId) {
byte[] buf = rawFrame(length, type.code(), flags, streamId);
FrameHeader header = new FrameHeader();
// reset() is package-private; same package as this test.
header.reset(buf, 0);
return header;
}
private static Http2ErrorCode codeOf(FrameHeader header, boolean insideHeaderBlock) {
Http2Exception ex = assertThrows(Http2Exception.class, () -> FrameValidator.validate(header, insideHeaderBlock));
return ex.errorCode();
}
// ── Length bounds, per type ──────────────────────────────────────────────
@Test
void ping_wrongLength_isFrameSizeError() {
FrameHeader h = headerOf(7, FrameType.PING, 0, 0);
assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, codeOf(h, false));
}
@Test
void rstStream_wrongLength_isFrameSizeError() {
FrameHeader h = headerOf(3, FrameType.RST_STREAM, 0, 1);
assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, codeOf(h, false));
}
@Test
void windowUpdate_wrongLength_isFrameSizeError() {
FrameHeader h = headerOf(5, FrameType.WINDOW_UPDATE, 0, 1);
assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, codeOf(h, false));
}
@Test
void priority_wrongLength_isFrameSizeError() {
FrameHeader h = headerOf(4, FrameType.PRIORITY, 0, 1);
assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, codeOf(h, false));
}
@Test
void goaway_tooShort_isFrameSizeError() {
FrameHeader h = headerOf(7, FrameType.GOAWAY, 0, 0);
assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, codeOf(h, false));
}
@Test
void goaway_exactlyEightBytes_isValid() {
FrameHeader h = headerOf(8, FrameType.GOAWAY, 0, 0);
assertDoesNotThrow(() -> FrameValidator.validate(h, false));
}
@Test
void settings_notMultipleOfSix_isFrameSizeError() {
FrameHeader h = headerOf(7, FrameType.SETTINGS, 0, 0);
assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, codeOf(h, false));
}
@Test
void settings_multipleOfSix_isValid() {
FrameHeader h = headerOf(12, FrameType.SETTINGS, 0, 0);
assertDoesNotThrow(() -> FrameValidator.validate(h, false));
}
@Test
void settings_zeroLength_isValid() {
// An empty SETTINGS frame (0 entries) is legal -- e.g. the initial connection SETTINGS
// with no non-default values, or a SETTINGS ACK.
FrameHeader h = headerOf(0, FrameType.SETTINGS, FrameFlags.ACK, 0);
assertDoesNotThrow(() -> FrameValidator.validate(h, false));
}
// ── Stream id rules ──────────────────────────────────────────────────────
@Test
void settings_nonZeroStreamId_isProtocolError() {
FrameHeader h = headerOf(0, FrameType.SETTINGS, 0, 1);
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, false));
}
@Test
void ping_nonZeroStreamId_isProtocolError() {
FrameHeader h = headerOf(8, FrameType.PING, 0, 3);
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, false));
}
@Test
void goaway_nonZeroStreamId_isProtocolError() {
FrameHeader h = headerOf(8, FrameType.GOAWAY, 0, 5);
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, false));
}
@Test
void data_zeroStreamId_isProtocolError() {
FrameHeader h = headerOf(0, FrameType.DATA, 0, 0);
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, false));
}
@Test
void headers_zeroStreamId_isProtocolError() {
FrameHeader h = headerOf(0, FrameType.HEADERS, 0, 0);
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, false));
}
@Test
void rstStream_zeroStreamId_isProtocolError() {
FrameHeader h = headerOf(4, FrameType.RST_STREAM, 0, 0);
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, false));
}
@Test
void windowUpdate_zeroStreamId_isValid_connectionWindow() {
FrameHeader h = headerOf(4, FrameType.WINDOW_UPDATE, 0, 0);
assertDoesNotThrow(() -> FrameValidator.validate(h, false));
}
@Test
void windowUpdate_nonZeroStreamId_isValid_streamWindow() {
FrameHeader h = headerOf(4, FrameType.WINDOW_UPDATE, 0, 9);
assertDoesNotThrow(() -> FrameValidator.validate(h, false));
}
// ── PUSH_PROMISE from a client ───────────────────────────────────────────
@Test
void pushPromise_fromClient_isAlwaysProtocolError() {
FrameHeader h = headerOf(4, FrameType.PUSH_PROMISE, 0, 1);
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, false));
}
// ── Unknown frame types ──────────────────────────────────────────────────
@Test
void unknownType_outsideHeaderBlock_isIgnoredNotRejected() {
byte[] buf = rawFrame(3, 0x20, 0, 1); // 0x20 is not a recognised type
FrameHeader h = new FrameHeader();
h.reset(buf, 0);
assertNull(h.type());
assertDoesNotThrow(() -> FrameValidator.validate(h, false));
}
@Test
void unknownType_insideHeaderBlock_isProtocolError() {
byte[] buf = rawFrame(3, 0x20, 0, 1);
FrameHeader h = new FrameHeader();
h.reset(buf, 0);
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, true));
}
// ── Frame-size ceiling ────────────────────────────────────────────────────
@Test
void declaredLengthAboveMaxFrameSize_isFrameSizeError() {
FrameHeader h = headerOf(dev.relism.flash.h2.Http2Limits.MAX_FRAME_SIZE_LOCAL + 1, FrameType.DATA, 0, 1);
assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, codeOf(h, false));
}
}
@@ -0,0 +1,59 @@
package dev.relism.flash.h2.frame;
import dev.relism.flash.h2.Http2Exception;
import dev.relism.flash.transport.BufferedByteSource;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.EOFException;
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.util.Random;
import static org.junit.jupiter.api.Assertions.fail;
/**
* Phase 5's DoD: "Fuzz test green for 10 million random inputs." Throws fully random bytes at
* {@link Http2FrameReader} and asserts that only a typed, expected outcome ever results: a
* {@link Http2Exception} (a declared length exceeding {@code MAX_FRAME_SIZE_LOCAL} — the
* overwhelmingly common outcome, since a random 24-bit length is astronomically likely to
* exceed 16384), an {@link EOFException} (the random input ran out before a full frame arrived
* — the second most common outcome, since fuzz inputs are deliberately small), or a
* {@link SocketTimeoutException} (never actually expected here — no deadline is short enough to
* trip against an in-memory stream — but a legal outcome of the API's own contract). Anything
* else escaping — {@code ArrayIndexOutOfBoundsException}, {@code NegativeArraySizeException},
* {@code OutOfMemoryError}, or simply never returning — fails the test.
*/
class Http2FrameReaderFuzzTest {
private static final int TRIALS = 10_000_000;
private static final int MAX_INPUT_LEN = 64;
@Test
void fuzz_10MillionRandomInputs_onlyTypedOutcomesEscape() {
Random rnd = new Random(0x4855_3244_5F46_5A32L);
byte[] data = new byte[MAX_INPUT_LEN];
for (int trial = 0; trial < TRIALS; trial++) {
int len = rnd.nextInt(MAX_INPUT_LEN + 1);
for (int i = 0; i < len; i++) data[i] = (byte) rnd.nextInt(256);
BufferedByteSource src = new BufferedByteSource(
new ByteArrayInputStream(data, 0, len), null, 128);
Http2FrameReader reader = new Http2FrameReader(src, 128);
try {
FrameHeader header = reader.readFrame();
if (header != null) {
reader.consumeFrame();
}
} catch (Http2Exception | EOFException | SocketTimeoutException expected) {
// any of these three is a correctly-typed rejection of malformed/truncated input
} catch (IOException e) {
fail("unexpected IOException at trial " + trial + " (len=" + len + "): " + e, e);
} catch (RuntimeException e) {
fail("unexpected RuntimeException at trial " + trial + " (len=" + len + "): " + e, e);
}
}
}
}
@@ -0,0 +1,209 @@
package dev.relism.flash.h2.frame;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.h2.Http2Exception;
import dev.relism.flash.transport.BufferedByteSource;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import static org.junit.jupiter.api.Assertions.*;
class Http2FrameReaderTest {
private static BufferedByteSource sourceOf(byte[] bytes) {
return new BufferedByteSource(new ByteArrayInputStream(bytes), null);
}
private static byte[] buildFrame(FrameType type, int flags, int streamId, byte[] payload) {
FrameWriteBuffer out = new FrameWriteBuffer(new ByteWriter(32));
out.beginFrame(type, flags, streamId);
out.writer().writeBytes(payload);
out.endFrame();
byte[] result = new byte[out.writer().length()];
System.arraycopy(out.writer().array(), 0, result, 0, result.length);
return result;
}
// ── Round trip every frame type ─────────────────────────────────────────
@Test
void roundTrip_everyFrameType() throws IOException {
for (FrameType type : FrameType.values()) {
int payloadLen = switch (type) {
case PING -> 8;
case RST_STREAM, WINDOW_UPDATE -> 4;
case PRIORITY -> 5;
case GOAWAY -> 8;
default -> 10;
};
byte[] payload = new byte[payloadLen];
for (int i = 0; i < payloadLen; i++) payload[i] = (byte) (i + 1);
int streamId = type.streamIdRule() == FrameType.StreamIdRule.FORBIDDEN ? 0 : 7;
byte[] wire = buildFrame(type, 0x1, streamId, payload);
Http2FrameReader reader = new Http2FrameReader(sourceOf(wire));
FrameHeader header = reader.readFrame();
assertNotNull(header, "type=" + type);
assertEquals(type, header.type());
assertEquals(type.code(), header.typeCode());
assertEquals(payloadLen, header.length());
assertEquals(streamId, header.streamId());
assertEquals(0x1, header.flags());
for (int i = 0; i < payloadLen; i++) {
assertEquals(payload[i], header.buffer()[header.payloadOffset() + i], "byte " + i + " of type " + type);
}
reader.consumeFrame();
}
}
// ── Boundary lengths ─────────────────────────────────────────────────────
@Test
void boundaryLengths_0_1_16383_16384_16385() throws IOException {
int[] lengths = {0, 1, 16383, 16384, 16385};
for (int len : lengths) {
byte[] payload = new byte[len];
byte[] wire = buildFrame(FrameType.DATA, 0, 1, payload);
Http2FrameReader reader = new Http2FrameReader(sourceOf(wire));
if (len > dev.relism.flash.h2.Http2Limits.MAX_FRAME_SIZE_LOCAL) {
Http2Exception ex = assertThrows(Http2Exception.class, reader::readFrame);
assertEquals(dev.relism.flash.h2.Http2ErrorCode.FRAME_SIZE_ERROR, ex.errorCode());
} else {
FrameHeader header = reader.readFrame();
assertNotNull(header);
assertEquals(len, header.length());
}
}
}
// ── A frame split across multiple socket reads ─────────────────────────
private static final class DribblingInputStream extends InputStream {
private final byte[] data;
private int pos;
private final int chunkSize;
DribblingInputStream(byte[] data, int chunkSize) {
this.data = data;
this.chunkSize = chunkSize;
}
@Override
public int read() {
return pos < data.length ? (data[pos++] & 0xFF) : -1;
}
@Override
public int read(byte[] dst, int off, int len) {
if (pos >= data.length) return -1;
int n = Math.min(chunkSize, Math.min(len, data.length - pos));
System.arraycopy(data, pos, dst, off, n);
pos += n;
return n;
}
}
@Test
void frameSplitAcrossThreeSocketReads() throws IOException {
byte[] payload = new byte[300];
for (int i = 0; i < payload.length; i++) payload[i] = (byte) i;
byte[] wire = buildFrame(FrameType.DATA, 0, 3, payload);
// 9(header) + 300(payload) = 309 bytes, dribbled in chunks of 103 -> 3 reads.
int chunk = (wire.length + 2) / 3;
BufferedByteSource src = new BufferedByteSource(new DribblingInputStream(wire, chunk), null);
Http2FrameReader reader = new Http2FrameReader(src);
FrameHeader header = reader.readFrame();
assertNotNull(header);
assertEquals(300, header.length());
for (int i = 0; i < 300; i++) {
assertEquals(payload[i], header.buffer()[header.payloadOffset() + i]);
}
}
// ── A frame exactly filling the initial buffer ──────────────────────────
@Test
void frameExactlyFillingInitialBuffer() throws IOException {
int bufSize = 64;
byte[] payload = new byte[bufSize - 9]; // header + payload == bufSize exactly
byte[] wire = buildFrame(FrameType.DATA, 0, 1, payload);
assertEquals(bufSize, wire.length);
Http2FrameReader reader = new Http2FrameReader(sourceOf(wire), bufSize);
FrameHeader header = reader.readFrame();
assertNotNull(header);
assertEquals(payload.length, header.length());
}
// ── Multiple frames on one connection, sequential reads ─────────────────
@Test
void multipleFramesSequentially() throws IOException {
ByteWriter w = new ByteWriter(64);
FrameWriteBuffer out = new FrameWriteBuffer(w);
out.beginFrame(FrameType.PING, 0, 0);
out.writer().writeBytes(new byte[]{1, 2, 3, 4, 5, 6, 7, 8});
out.endFrame();
out.beginFrame(FrameType.PING, dev.relism.flash.h2.frame.FrameFlags.ACK, 0);
out.writer().writeBytes(new byte[]{8, 7, 6, 5, 4, 3, 2, 1});
out.endFrame();
byte[] wire = new byte[w.length()];
System.arraycopy(w.array(), 0, wire, 0, wire.length);
Http2FrameReader reader = new Http2FrameReader(sourceOf(wire));
FrameHeader first = reader.readFrame();
assertEquals(1, first.buffer()[first.payloadOffset()]);
assertEquals(0, first.flags());
reader.consumeFrame();
FrameHeader second = reader.readFrame();
assertEquals(8, second.buffer()[second.payloadOffset()]);
assertEquals(FrameFlags.ACK, second.flags());
reader.consumeFrame();
assertNull(reader.readFrame()); // clean EOF after both frames consumed
}
// ── EOF handling ─────────────────────────────────────────────────────────
@Test
void cleanEofBetweenFrames_returnsNull() throws IOException {
Http2FrameReader reader = new Http2FrameReader(sourceOf(new byte[0]));
assertNull(reader.readFrame());
}
@Test
void eofMidFrame_throwsEOFException() {
byte[] wire = buildFrame(FrameType.DATA, 0, 1, new byte[100]);
byte[] truncated = new byte[50]; // header + partial payload
System.arraycopy(wire, 0, truncated, 0, 50);
Http2FrameReader reader = new Http2FrameReader(sourceOf(truncated));
assertThrows(EOFException.class, reader::readFrame);
}
@Test
void eofMidHeader_throwsEOFException() {
byte[] truncated = new byte[5]; // fewer than the 9 header bytes
Http2FrameReader reader = new Http2FrameReader(sourceOf(truncated));
assertThrows(EOFException.class, reader::readFrame);
}
// ── Reserved bit masking ─────────────────────────────────────────────────
@Test
void reservedBitInStreamId_isMaskedNotRejected() throws IOException {
byte[] wire = buildFrame(FrameType.DATA, 0, 5, new byte[]{1, 2, 3});
wire[5] |= (byte) 0x80; // set the reserved high bit of the stream-id field
Http2FrameReader reader = new Http2FrameReader(sourceOf(wire));
FrameHeader header = reader.readFrame();
assertEquals(5, header.streamId(), "reserved bit must be masked, not folded into the stream id");
}
}
@@ -0,0 +1,88 @@
package dev.relism.flash.h2.frame;
import dev.relism.flash.h2.Http2ErrorCode;
import dev.relism.flash.h2.Http2Exception;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class PaddingTest {
@Test
void notPadded_returnsWholePayloadUnchanged() {
byte[] buf = {1, 2, 3, 4, 5};
long r = Padding.unpad(buf, 1, 4, false);
assertEquals(1, Padding.dataOffset(r));
assertEquals(4, Padding.dataLength(r));
}
@Test
void padded_zeroPadLength_allBytesAreData() {
// [padLength=0][data...]
byte[] buf = {0, 10, 20, 30};
long r = Padding.unpad(buf, 0, 4, true);
assertEquals(1, Padding.dataOffset(r));
assertEquals(3, Padding.dataLength(r));
assertEquals(10, buf[Padding.dataOffset(r)]);
}
@Test
void padded_someData_somePadding() {
// [padLength=2][data: 3 bytes][padding: 2 bytes] -> payload length 6
byte[] buf = {2, 7, 8, 9, 0, 0};
long r = Padding.unpad(buf, 0, 6, true);
assertEquals(1, Padding.dataOffset(r));
assertEquals(3, Padding.dataLength(r));
assertEquals(7, buf[Padding.dataOffset(r)]);
assertEquals(9, buf[Padding.dataOffset(r) + 2]);
}
@Test
void padded_allPaddingNoData() {
// [padLength=3][padding x3] -> payload length 4, dataLength 0
byte[] buf = {3, 0, 0, 0};
long r = Padding.unpad(buf, 0, 4, true);
assertEquals(0, Padding.dataLength(r));
}
@Test
void padded_atNonZeroOffset_withinLargerBuffer() {
byte[] buf = {(byte) 0xFF, (byte) 0xFF, 1, 5, 6, 0, (byte) 0xFF};
// payload starts at index 2, length 4: [padLength=1][data:5,6][padding:1]
long r = Padding.unpad(buf, 2, 4, true);
assertEquals(3, Padding.dataOffset(r));
assertEquals(2, Padding.dataLength(r));
assertEquals(5, buf[Padding.dataOffset(r)]);
assertEquals(6, buf[Padding.dataOffset(r) + 1]);
}
@Test
void padded_zeroPayloadLength_isProtocolError() {
byte[] buf = {};
Http2Exception ex = assertThrows(Http2Exception.class, () -> Padding.unpad(buf, 0, 0, true));
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, ex.errorCode());
}
@Test
void padded_padLengthEqualsPayloadLength_isProtocolError() {
// payloadLength=3, claimed padLength=3 -- leaves -1 bytes for data, invalid.
byte[] buf = {3, 0, 0};
Http2Exception ex = assertThrows(Http2Exception.class, () -> Padding.unpad(buf, 0, 3, true));
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, ex.errorCode());
}
@Test
void padded_padLengthGreaterThanPayloadLength_isProtocolError() {
byte[] buf = {(byte) 255, 0, 0};
Http2Exception ex = assertThrows(Http2Exception.class, () -> Padding.unpad(buf, 0, 3, true));
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, ex.errorCode());
}
@Test
void padded_maxValidPadLength_leavesZeroData() {
// payloadLength=5: [padLength=4][padding x4] -- valid, dataLength 0.
byte[] buf = {4, 0, 0, 0, 0};
long r = Padding.unpad(buf, 0, 5, true);
assertEquals(0, Padding.dataLength(r));
}
}
@@ -0,0 +1,164 @@
package dev.relism.flash.transport;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@code EX-37}: this class previously had zero dedicated tests — its deadline mechanism (the
* actual {@code EX-07} slowloris fix) was exercised only indirectly through real-socket,
* end-to-end tests, which never hit the {@code null}-socket path every isolated unit test in
* this codebase actually uses. Found and fixed while building {@code Http2FrameReaderTest}
* (Phase 5); this class closes the gap.
*/
class BufferedByteSourceTest {
private static BufferedByteSource sourceOf(String s) {
return new BufferedByteSource(new ByteArrayInputStream(s.getBytes(StandardCharsets.US_ASCII)), null);
}
// ── Plain InputStream passthrough ───────────────────────────────────────
@Test
void read_singleByte() throws IOException {
BufferedByteSource src = sourceOf("AB");
assertEquals('A', src.read());
assertEquals('B', src.read());
assertEquals(-1, src.read());
}
@Test
void read_intoArray() throws IOException {
BufferedByteSource src = sourceOf("hello world");
byte[] buf = new byte[5];
int n = src.read(buf, 0, 5);
assertEquals(5, n);
assertEquals("hello", new String(buf, StandardCharsets.US_ASCII));
}
@Test
void read_largerThanInternalBuffer_bypassesBufferCorrectly() throws IOException {
String big = "x".repeat(20_000);
BufferedByteSource src = new BufferedByteSource(
new ByteArrayInputStream(big.getBytes(StandardCharsets.US_ASCII)), null, 4096);
byte[] out = new byte[20_000];
int total = 0;
while (total < out.length) {
int n = src.read(out, total, out.length - total);
if (n < 0) break;
total += n;
}
assertEquals(20_000, total);
}
// ── peek / prependOnce ───────────────────────────────────────────────────
@Test
void peek_doesNotConsume() throws IOException {
BufferedByteSource src = sourceOf("abcdef");
byte[] dst = new byte[3];
int n = src.peek(dst, 0, 3);
assertEquals(3, n);
assertEquals("abc", new String(dst, StandardCharsets.US_ASCII));
// Still readable from the start — peek must not have advanced the position.
assertEquals('a', src.read());
assertEquals('b', src.read());
}
@Test
void peek_rejectsLengthAboveBufferCapacity() {
BufferedByteSource src = new BufferedByteSource(new ByteArrayInputStream(new byte[0]), null, 16);
assertThrows(IllegalArgumentException.class, () -> src.peek(new byte[20], 0, 20));
}
@Test
void prependOnce_servedBeforeUnderlyingBytes() throws IOException {
BufferedByteSource src = sourceOf("world");
byte[] prefix = "hello ".getBytes(StandardCharsets.US_ASCII);
src.prependOnce(prefix, 0, prefix.length);
byte[] out = new byte[11];
int total = 0;
while (total < out.length) {
int n = src.read(out, total, out.length - total);
if (n < 0) break;
total += n;
}
assertEquals("hello world", new String(out, 0, total, StandardCharsets.US_ASCII));
}
@Test
void prependOnce_rejectsSecondCallBeforeFirstIsConsumed() {
BufferedByteSource src = sourceOf("x");
byte[] a = "a".getBytes(StandardCharsets.US_ASCII);
src.prependOnce(a, 0, 1);
assertThrows(IllegalStateException.class, () -> src.prependOnce(a, 0, 1));
}
// ── Deadline mechanism, EX-37's actual regression coverage ──────────────
@Test
void clearDeadline_withNullSocket_doesNotThrow() throws IOException {
BufferedByteSource src = sourceOf("data");
src.setDeadline(System.nanoTime() + 1_000_000_000L);
assertDoesNotThrow(src::clearDeadline);
}
@Test
void deadlineAlreadyExpired_throwsSocketTimeoutException_evenWithNullSocket() {
BufferedByteSource src = sourceOf(""); // empty: forces fillFromUnderlying on the next read
src.setDeadline(System.nanoTime() - 1_000_000_000L); // already in the past
assertThrows(SocketTimeoutException.class, () -> src.read(new byte[1], 0, 1));
}
@Test
void deadlineNotYetExpired_readsNormally_withNullSocket() throws IOException {
BufferedByteSource src = sourceOf("z");
src.setDeadline(System.nanoTime() + 30_000_000_000L); // 30s in the future
assertEquals('z', src.read());
}
@Test
void bytesAlreadyBuffered_areServedRegardlessOfDeadline() throws IOException {
// peek() fills the internal buffer without a deadline; a since-expired deadline must not
// block already-buffered bytes from being read (only underlying-stream reads are bounded).
BufferedByteSource src = sourceOf("buffered");
src.peek(new byte[8], 0, 8);
src.setDeadline(System.nanoTime() - 1); // already expired
assertEquals('b', src.read()); // served from the buffer — no underlying read needed
}
@Test
void clearDeadline_thenRead_neverThrowsTimeoutAfterward() throws IOException {
BufferedByteSource src = sourceOf("ok");
src.setDeadline(System.nanoTime() - 1); // expired
src.clearDeadline();
assertEquals('o', src.read()); // deadline cleared — must not time out
}
// ── available / skip / close ─────────────────────────────────────────────
@Test
void skip_advancesPastBufferedAndUnderlyingBytes() throws IOException {
BufferedByteSource src = sourceOf("abcdef");
long skipped = src.skip(3);
assertEquals(3, skipped);
assertEquals('d', src.read());
}
@Test
void close_delegatesToUnderlyingStream() {
java.io.InputStream[] closed = new java.io.InputStream[1];
java.io.InputStream in = new ByteArrayInputStream(new byte[0]) {
@Override public void close() throws IOException { closed[0] = this; super.close(); }
};
BufferedByteSource src = new BufferedByteSource(in, null);
assertDoesNotThrow(src::close);
assertSame(in, closed[0]);
}
}