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:
co-authored by
Claude Sonnet 5
parent
a315e1df8b
commit
2bf261e4e2
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user