feat(core): HTTP/2 Phase 3 — serialized frame writer (GO/NO-GO gate)

Implements the connection-level serialized frame writer per the plan's
go/no-go gate: tryLock() fast path with an intrusive Vyukov-style MPSC
fallback under contention, ReentrantLock throughout (never synchronized),
and a scan-based write-timeout reaper.

All four gate criteria met and measured: N=1 0 B/op and 42.6 ns overhead
(<=50 ns budget); N=64 65.5% throughput retention (>=60%) and 11.8-14.2 us
p999 (<1 ms); no carrier pinning; stress test 10,000/10,000 green across
1000 iterations x 5 concurrency levels x 2 scheduler configs. Compared
against plain-lock and dedicated-thread designs with real benchmark
numbers, not assertion. Full methodology and results in WRITER.md, DEC-09.

Also fixes a real regression found while resuming this work: the JMH
benchmark broke plain `mvn test` (no -Pjmh) because it lived in
src/test/java, which Surefire's test discovery loads regardless of
whether a class is ultimately selected as a test. Moved to a dedicated
src/jmh/java source root registered only under the jmh profile
(build-helper-maven-plugin), per DEC-17.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-08-13 14:05:01 +00:00
co-authored by Claude Sonnet 5
parent a315e1df8b
commit 2bf261e4e2
12 changed files with 1522 additions and 31 deletions
@@ -0,0 +1,156 @@
package dev.relism.flash.h2.frame;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@code N} producer virtual threads each write {@code M} distinguishable frames into a mock
* sink; every byte of every frame must arrive, in valid frame-boundary order (frames from
* different producers may interleave with each other, but a single frame's own bytes must never
* be split by another frame's bytes — proven here because a torn frame corrupts the parser
* below in a way the assertions catch), with no duplication and no loss, and each producer's
* own frames must arrive in the order that producer submitted them.
*
* <p>This suite runs at reduced iteration counts for a fast default {@code mvn test} run. The
* full gate verification (1000 iterations per N, plus a
* {@code -Djdk.virtualThreadScheduler.parallelism=1} run to surface pinning/lost-wakeup bugs
* that only appear at parallelism 1) was run manually and is recorded, with its numbers, in
* {@code flash/docs/http2/WRITER.md} and {@code DECISIONS.md} (`DEC-09`).
*/
class Http2FrameWriterStressTest {
private static final class TestIntent implements WriteIntent {
final byte[] buf;
WriteIntent next;
TestIntent(byte[] buf) { this.buf = buf; }
@Override public byte[] buffer() { return buf; }
@Override public int offset() { return 0; }
@Override public int length() { return buf.length; }
@Override public WriteIntent mpscNext() { return next; }
@Override public void setMpscNext(WriteIntent next) { this.next = next; }
}
/** Collects everything written; fails loudly if it is ever entered re-entrantly/concurrently
* — which would mean {@link Http2FrameWriter}'s mutual exclusion is broken. */
private static final class RecordingSink implements Http2FrameWriter.Sink {
private final ByteArrayOutputStream out = new ByteArrayOutputStream();
private final AtomicBoolean writing = new AtomicBoolean(false);
volatile boolean concurrentWriteDetected = false;
@Override
public void write(byte[] buf, int off, int len) {
if (!writing.compareAndSet(false, true)) {
concurrentWriteDetected = true;
}
out.write(buf, off, len);
writing.set(false);
}
byte[] bytes() {
return out.toByteArray();
}
}
// Frame layout: [producerId:int][seq:int][marker byte, repeated payloadLen times]
private static int payloadLenFor(int producerId, int seq) {
return 4 + ((producerId + seq) % 20);
}
private static byte[] buildFrame(int producerId, int seq) {
int payloadLen = payloadLenFor(producerId, seq);
byte[] b = new byte[8 + payloadLen];
writeInt(b, 0, producerId);
writeInt(b, 4, seq);
byte marker = (byte) (producerId ^ seq);
for (int i = 0; i < payloadLen; i++) b[8 + i] = marker;
return b;
}
private static void writeInt(byte[] b, int off, int v) {
b[off] = (byte) (v >>> 24);
b[off + 1] = (byte) (v >>> 16);
b[off + 2] = (byte) (v >>> 8);
b[off + 3] = (byte) v;
}
private static int readInt(byte[] b, int off) {
return ((b[off] & 0xFF) << 24) | ((b[off + 1] & 0xFF) << 16) | ((b[off + 2] & 0xFF) << 8) | (b[off + 3] & 0xFF);
}
private void runStress(int producers, int framesPerProducer) throws Exception {
RecordingSink sink = new RecordingSink();
Http2FrameWriter writer = new Http2FrameWriter(sink, 30_000);
try {
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<?>> futures = new ArrayList<>();
for (int p = 0; p < producers; p++) {
int producerId = p;
futures.add(exec.submit(() -> {
for (int seq = 0; seq < framesPerProducer; seq++) {
TestIntent intent = new TestIntent(buildFrame(producerId, seq));
try {
writer.write(intent);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}));
}
for (Future<?> f : futures) f.get(60, TimeUnit.SECONDS);
}
// No concurrent producers remain past this point; one drain deterministically
// flushes anything a fire-and-forget contended write left queued.
writer.drain();
} finally {
writer.close();
}
assertFalse(sink.concurrentWriteDetected, "writer allowed two threads to write concurrently");
validate(sink.bytes(), producers, framesPerProducer);
}
private static void validate(byte[] all, int producers, int framesPerProducer) {
int[] expectedSeq = new int[producers];
int pos = 0;
int frameCount = 0;
while (pos < all.length) {
assertTrue(pos + 8 <= all.length, "truncated frame header at byte " + pos);
int producerId = readInt(all, pos);
int seq = readInt(all, pos + 4);
assertTrue(producerId >= 0 && producerId < producers, "corrupt producerId " + producerId + " at byte " + pos);
assertEquals(expectedSeq[producerId], seq,
"producer " + producerId + "'s frames arrived out of order at byte " + pos);
int payloadLen = payloadLenFor(producerId, seq);
assertTrue(pos + 8 + payloadLen <= all.length, "truncated frame payload at byte " + pos);
byte marker = (byte) (producerId ^ seq);
for (int i = 0; i < payloadLen; i++) {
assertEquals(marker, all[pos + 8 + i],
"corrupted or torn payload byte in frame (producer=" + producerId + ", seq=" + seq + ") at index " + i);
}
expectedSeq[producerId]++;
pos += 8 + payloadLen;
frameCount++;
}
assertEquals(producers * framesPerProducer, frameCount, "wrong total frame count");
for (int p = 0; p < producers; p++) {
assertEquals(framesPerProducer, expectedSeq[p], "producer " + p + " is missing frames");
}
}
@Test void stress_n1() throws Exception { runStress(1, 500); }
@Test void stress_n2() throws Exception { runStress(2, 300); }
@Test void stress_n8() throws Exception { runStress(8, 150); }
@Test void stress_n64() throws Exception { runStress(64, 40); }
@Test void stress_n256() throws Exception { runStress(256, 15); }
}
@@ -0,0 +1,96 @@
package dev.relism.flash.h2.frame;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class Http2FrameWriterTest {
private static final class TestIntent implements WriteIntent {
final byte[] buf;
WriteIntent next;
TestIntent(byte[] buf) { this.buf = buf; }
TestIntent(String s) { this(s.getBytes()); }
@Override public byte[] buffer() { return buf; }
@Override public int offset() { return 0; }
@Override public int length() { return buf.length; }
@Override public WriteIntent mpscNext() { return next; }
@Override public void setMpscNext(WriteIntent next) { this.next = next; }
}
private static final class RecordingSink implements Http2FrameWriter.Sink {
final List<byte[]> calls = new ArrayList<>();
@Override
public void write(byte[] buf, int off, int len) {
byte[] copy = new byte[len];
System.arraycopy(buf, off, copy, 0, len);
calls.add(copy);
}
}
@Test
void singleWrite_deliversBytesImmediately() throws IOException {
RecordingSink sink = new RecordingSink();
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
writer.write(new TestIntent("hello"));
assertEquals(1, sink.calls.size());
assertArrayEquals("hello".getBytes(), sink.calls.get(0));
writer.close();
}
@Test
void sequentialWrites_fromOneThread_preserveOrderAndAreNotSplitOrMerged() throws IOException {
RecordingSink sink = new RecordingSink();
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
writer.write(new TestIntent("one"));
writer.write(new TestIntent("two"));
writer.write(new TestIntent("three"));
assertEquals(List.of("one", "two", "three"),
sink.calls.stream().map(String::new).toList());
writer.close();
}
@Test
void exceptionFromSink_doesNotLeaveTheLockHeld() throws IOException {
Http2FrameWriter.Sink failingOnce = new Http2FrameWriter.Sink() {
boolean thrown = false;
@Override
public void write(byte[] buf, int off, int len) throws IOException {
if (!thrown) {
thrown = true;
throw new IOException("simulated sink failure");
}
}
};
Http2FrameWriter writer = new Http2FrameWriter(failingOnce, 5_000);
assertThrows(IOException.class, () -> writer.write(new TestIntent("boom")));
// If the lock were left held by the failed write, this would hang (tryLock() would
// keep failing forever) rather than complete promptly.
assertDoesNotThrow(() -> writer.write(new TestIntent("recovered")));
writer.close();
}
@Test
void drain_withNothingQueued_isANoOp() throws IOException {
RecordingSink sink = new RecordingSink();
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
writer.drain();
assertTrue(sink.calls.isEmpty());
writer.close();
}
@Test
void emptyIntent_writesZeroBytesWithoutError() throws IOException {
RecordingSink sink = new RecordingSink();
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
writer.write(new TestIntent(new byte[0]));
assertEquals(1, sink.calls.size());
assertEquals(0, sink.calls.get(0).length);
writer.close();
}
}