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
@@ -145,4 +145,15 @@ public final class Http2Limits {
* {@code FlashConfiguration.idleKeepAliveTimeoutMs}.
*/
public static final long STREAM_IDLE_TIMEOUT_MS = 60_000;
/**
* Maximum time, in milliseconds, {@code Http2FrameWriter} may spend blocked inside a single
* socket write. A blocking write is unavoidable when the kernel send buffer is full and the
* peer is not reading (that peer holds the connection's single writer lock for the duration
* — see {@code WRITER.md}), but it must not be unbounded: a peer that simply stops reading
* would otherwise let a single stalled connection wedge the writer forever. Enforced via a
* background reaper interrupting the blocked thread past the deadline, not
* {@code Socket#setSoTimeout} — that option bounds reads, not writes.
*/
public static final long WRITE_TIMEOUT_MS = 30_000;
}
@@ -0,0 +1,258 @@
package dev.relism.flash.h2.frame;
import dev.relism.flash.h2.Http2Limits;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
/**
* The one component every HTTP/2 write in this codebase passes through — connection frames and
* stream frames alike (both are just {@link WriteIntent}s). Its entire job is serializing
* concurrent access to one connection's socket write side as cheaply as physically possible,
* because under multiplexing every stream on a connection shares that one socket.
*
* <h2>The design, three layers</h2>
*
* <p><b>Layer 1 — serialize outside the lock.</b> By the time {@link #write} is called, the
* caller has already built its complete frame into a buffer it owns (see {@link WriteIntent}).
* This writer never serializes anything; it only ever issues one bulk
* {@code sink.write(buffer, offset, length)} call while holding the lock — never many small
* writes, which would turn "hold the lock" into "hold the lock across a serialization pass."
*
* <p><b>Layer 2 — {@link ReentrantLock}, never {@code synchronized}.</b> On Java 21, a virtual
* thread blocking inside {@code synchronized} pins its carrier platform thread; blocking on a
* {@link ReentrantLock} unmounts it instead (JEP 491, which removes the {@code synchronized}
* pinning behaviour, only lands in JDK 24+ — see {@code EX-01}, {@code DEC-03}).
* {@code ReentrantLock} is also load-bearing here for a second reason {@code synchronized}
* cannot offer: {@link ReentrantLock#tryLock()}.
*
* <p><b>Layer 3 — {@code tryLock()} fast path, intrusive MPSC fallback.</b> The overwhelmingly
* common case, even on a genuinely multiplexed connection, is exactly one stream wanting to
* write at a given instant. {@code tryLock()} on an uncontended lock is one successful CAS; the
* calling thread writes inline and releases — no handoff, no queue touched, no allocation, no
* context switch. Only when {@code tryLock()} fails (genuine contention) does the intent get
* published through {@link IntrusiveMpscQueue} (one more CAS, still zero allocation — the
* intent itself is the queue node) for the current lock holder to drain.
*
* <h3>Lost-wakeup avoidance</h3>
* The classic hazard: a producer offers its intent to the queue at the exact moment the current
* holder has just found the queue empty and is about to unlock — the item would be stranded
* with nobody left to drain it. This is closed by two cooperating checks, and the correctness
* argument for why together they are sufficient is a happens-before chain through the queue's
* {@code AtomicReference} and the lock's own acquire/release ordering (recorded in full in
* {@code WRITER.md}, since it is exactly the kind of reasoning a future reader must be able to
* re-derive, not just trust):
* <pre>
* write(intent):
* if tryLock() succeeds: // 1 CAS, the fast path
* drive(intent) // write intent directly, then drain the queue, then unlock
* else:
* queue.offer(intent) // 1 CAS, zero allocation
* if tryLock() succeeds: // the producer's own second chance
* drive(null) // drain whatever is queued, including our own intent
*
* drive(firstIntentOrNull):
* write firstIntentOrNull if present, then poll-and-write until the queue is empty
* unlock()
* while queue.hasWork(): // the re-check-after-unlock that closes the race
* if !tryLock(): break // someone else is now responsible; their own recheck covers us
* poll-and-write until empty
* unlock()
* </pre>
* A frame's bytes are never interleaved with another frame's bytes: every write of one intent
* is a single {@code sink.write} call issued while holding the lock, and the lock is not
* released between a {@code WriteIntent}'s bytes.
*
* <h3>Write timeout</h3>
* A blocking write is unavoidable when the kernel send buffer is full and the peer is not
* reading — whoever holds the lock is blocked in the syscall, holding up every other stream on
* the connection. This is bounded by {@link Http2Limits#WRITE_TIMEOUT_MS}, enforced by a shared
* background reaper ({@link WriteTimeoutReaper}) that interrupts the blocked thread past the
* deadline — {@code Socket#setSoTimeout} bounds reads, not writes, so it cannot be used here.
* Registration happens once per writer (connection-setup cost, not per write — R2 exempts
* connection setup), so arming/disarming the deadline for each individual write is two
* {@code volatile} field writes, not an allocation.
*/
public final class Http2FrameWriter {
/** What a frame's serialized bytes are ultimately written to. Kept minimal and separate
* from {@code java.io.OutputStream} so this class is testable without a real socket. */
public interface Sink {
void write(byte[] buf, int off, int len) throws IOException;
}
private final Sink sink;
private final long writeTimeoutMs;
private final ReentrantLock lock = new ReentrantLock();
private final IntrusiveMpscQueue queue = new IntrusiveMpscQueue();
// Set only for the duration of an in-flight sink.write() call; see WriteTimeoutReaper. A
// single volatile write to arm, one to disarm — no timestamp is recorded here (see the
// reaper's own Javadoc for why: a per-write System.nanoTime() call measurably missed the
// N=1 gate's 50 ns overhead budget when this was first benchmarked, recorded in WRITER.md).
private volatile Thread writingThread;
public Http2FrameWriter(Sink sink) {
this(sink, Http2Limits.WRITE_TIMEOUT_MS);
}
public Http2FrameWriter(Sink sink, long writeTimeoutMs) {
this.sink = sink;
this.writeTimeoutMs = writeTimeoutMs;
WriteTimeoutReaper.register(this);
}
/**
* Serializes and writes one frame. Returns when the bytes are in the socket buffer or
* safely queued behind another writer. Never blocks on another stream's I/O while holding
* the lock for longer than that stream's own single bulk write.
*
* <p><b>Why the fast path is gated on {@code !queue.hasWork()}, not just {@code tryLock()}</b>
* (found by this phase's own stress test, at N=64/256 — exactly the kind of bug R10 exists
* to catch): writing {@code intent} immediately, before anything already queued, is only
* safe when nothing is already queued. Without the {@code hasWork()} check, this sequence
* is possible — and violates same-producer ordering, which the stress test asserts: a
* producer's {@code write(a)} then {@code write(b)} contends and both get queued
* (fire-and-forget); the current holder is about to drain them but has not yet; that
* producer's very next call, {@code write(c)}, finds the lock free (the holder released it
* between the producer's calls) and would otherwise write {@code c} directly — landing on
* the wire before {@code a} and {@code b}, which are still sitting in the queue. Checking
* {@code hasWork()} first means "bypass the queue" only happens when the queue is observed
* genuinely empty, i.e. everything previously offered — by any producer — has already been
* written; see {@code WRITER.md} for the full argument.
*/
public void write(WriteIntent intent) throws IOException {
if (!queue.hasWork() && lock.tryLock()) {
drive(intent);
} else {
queue.offer(intent);
if (lock.tryLock()) {
drive(null);
}
}
}
/** Flushes any queued intents. Called by the demux loop when it has nothing left to read —
* a no-op on the (overwhelmingly common) fast path where nothing is queued. */
public void drain() throws IOException {
if (!queue.hasWork()) return;
if (lock.tryLock()) {
drive(null);
}
}
/** Deregisters this writer from the write-timeout reaper. Call once, when the connection
* closes. */
public void close() {
WriteTimeoutReaper.unregister(this);
}
private void drive(WriteIntent firstIntentOrNull) throws IOException {
try {
if (firstIntentOrNull != null) writeDirect(firstIntentOrNull);
WriteIntent next;
while ((next = queue.poll()) != null) {
writeDirect(next);
}
} finally {
lock.unlock();
}
// Lost-wakeup fix: re-check after unlocking, looping because this cycle itself can race
// the same way — see the class Javadoc for the correctness argument.
while (queue.hasWork()) {
if (!lock.tryLock()) break;
try {
WriteIntent next;
while ((next = queue.poll()) != null) {
writeDirect(next);
}
} finally {
lock.unlock();
}
}
}
private void writeDirect(WriteIntent intent) throws IOException {
writingThread = Thread.currentThread();
try {
sink.write(intent.buffer(), intent.offset(), intent.length());
} catch (IOException e) {
if (Thread.interrupted()) {
InterruptedIOException timeout = new InterruptedIOException(
"HTTP/2 write timed out after ~" + writeTimeoutMs + " ms");
timeout.initCause(e);
throw timeout;
}
throw e;
} finally {
writingThread = null;
Thread.interrupted(); // clear a stray interrupt flag defensively before returning control
}
}
/**
* A single shared daemon thread scanning every registered {@link Http2FrameWriter} for a
* blocking write that has overrun {@link Http2Limits#WRITE_TIMEOUT_MS}. One thread for the
* whole process (like {@code DateHeader}'s refresher), not one per connection — registration
* is the only per-connection cost, and it is a connection-setup-time cost (R2-exempt), not a
* per-write one.
*
* <p>Deliberately does <em>not</em> ask each write to record a {@code System.nanoTime()}
* deadline — an earlier version did, and Phase 3's own benchmark measured that single
* {@code nanoTime()} call (plus the extra volatile field it required) costing enough to miss
* the N=1 gate's 50 ns-over-baseline budget (recorded in {@code WRITER.md}). Instead, the
* reaper counts <em>consecutive scans</em> a given writer has been observed still blocked
* ({@link #writingThread} non-null); a writer blocked for more than
* {@code WRITE_TIMEOUT_MS / SCAN_INTERVAL_MS} consecutive scans is interrupted. This trades
* a little precision (up to one scan interval of slop — already inherent to any
* background-reaper design) for removing all per-write timing cost.
*/
static final class WriteTimeoutReaper {
private static final long SCAN_INTERVAL_MS = 50;
private static final Set<Http2FrameWriter> ACTIVE = ConcurrentHashMap.newKeySet();
// Touched only by the single reaper thread -- no synchronization needed.
private static final java.util.Map<Http2FrameWriter, Integer> BLOCKED_SCAN_COUNTS = new java.util.IdentityHashMap<>();
static {
Thread reaper = new Thread(() -> {
while (true) {
try {
Thread.sleep(SCAN_INTERVAL_MS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
for (Http2FrameWriter writer : ACTIVE) {
Thread t = writer.writingThread;
if (t == null) {
BLOCKED_SCAN_COUNTS.remove(writer);
continue;
}
int scans = BLOCKED_SCAN_COUNTS.merge(writer, 1, Integer::sum);
long thresholdScans = Math.max(1, writer.writeTimeoutMs / SCAN_INTERVAL_MS);
if (scans >= thresholdScans) {
t.interrupt();
BLOCKED_SCAN_COUNTS.remove(writer);
}
}
}
}, "flash-h2-write-timeout-reaper");
reaper.setDaemon(true);
reaper.start();
}
private WriteTimeoutReaper() {
}
static void register(Http2FrameWriter writer) {
ACTIVE.add(writer);
}
static void unregister(Http2FrameWriter writer) {
ACTIVE.remove(writer);
}
}
}
@@ -0,0 +1,101 @@
package dev.relism.flash.h2.frame;
import java.util.concurrent.atomic.AtomicReference;
/**
* A Vyukov-style intrusive multi-producer, single-consumer queue of {@link WriteIntent}s.
* "Intrusive" means the queued object <em>is</em> the node — {@link WriteIntent#mpscNext()} /
* {@link WriteIntent#setMpscNext} supply the linkage — so {@link #offer} allocates nothing: one
* {@link AtomicReference#getAndSet} CAS and that is the entire cost.
*
* <h3>Only {@link Http2FrameWriter} calls {@link #poll()}</h3>
* This queue is safe for any number of concurrent {@link #offer} callers, but {@link #poll()}
* must only ever be called by the single thread currently holding the writer's lock — exactly
* the invariant {@code Http2FrameWriter} maintains (it never calls {@code poll()} without
* holding the lock). Calling {@code poll()} from two threads concurrently is undefined.
*
* <h3>The stub node and the "inconsistent" result</h3>
* The queue always contains at least one node — a private, singleton {@code stub} — which lets
* {@link #offer} and {@link #poll} both proceed without ever observing a literal {@code null}
* head. A subtlety of this algorithm (documented here because it surprises readers unfamiliar
* with it, and it is the reason {@code Http2FrameWriter}'s drain loop is itself a loop, not a
* single pass): {@link #poll()} can return {@code null} even when {@link #offer} has completed
* and is "logically" enqueued, if that producer's {@code getAndSet} (which publishes the new
* tail pointer) has completed but its following {@code setMpscNext} (which links the *previous*
* tail to it) has not yet landed. This is a momentary, self-correcting race — the next
* {@code poll()} call (even from the same thread, immediately after) will see it — never a
* permanent loss. {@code Http2FrameWriter}'s lost-wakeup-avoidance protocol (see its Javadoc)
* already retries in exactly the way this requires.
*/
final class IntrusiveMpscQueue {
/**
* Sentinel node that is never returned by {@link #poll()} and never appears anywhere except
* internally. Its own {@code mpscNext} field is the only piece of mutable state on it.
*/
private static final class Stub implements WriteIntent {
private volatile WriteIntent next;
@Override public byte[] buffer() { throw new UnsupportedOperationException("stub node"); }
@Override public int offset() { throw new UnsupportedOperationException("stub node"); }
@Override public int length() { throw new UnsupportedOperationException("stub node"); }
@Override public WriteIntent mpscNext() { return next; }
@Override public void setMpscNext(WriteIntent next) { this.next = next; }
}
private final Stub stub = new Stub();
private final AtomicReference<WriteIntent> head = new AtomicReference<>(stub);
private WriteIntent tail = stub; // consumer-only; never touched by offer()
/** Enqueues {@code node}. Safe from any number of concurrent threads. Zero allocation. */
void offer(WriteIntent node) {
node.setMpscNext(null);
WriteIntent prev = head.getAndSet(node);
prev.setMpscNext(node);
}
/**
* Dequeues the next intent, or {@code null} if the queue is empty <em>or</em> a producer is
* momentarily mid-{@link #offer} — see the class Javadoc. Single-consumer only.
*/
WriteIntent poll() {
WriteIntent t = tail;
WriteIntent next = t.mpscNext();
if (t == stub) {
if (next == null) {
return null; // genuinely empty
}
tail = next;
t = next;
next = t.mpscNext();
}
if (next != null) {
tail = next;
return t;
}
WriteIntent h = head.get();
if (t != h) {
return null; // producer mid-offer; momentary, retry later
}
// t is the last real node and head hasn't moved past it: park the stub here so the
// next poll() (once a future offer() lands) has somewhere to advance from, then check
// whether t already gained a follower while we were doing this.
offer(stub);
next = t.mpscNext();
if (next != null) {
tail = next;
return t;
}
return null;
}
/** Cheap, conservative "might there be work" check — never a false negative, may be a false
* positive (harmless: the caller just attempts a {@code tryLock()} that finds nothing). */
boolean hasWork() {
return head.get() != tail;
}
}
@@ -0,0 +1,40 @@
package dev.relism.flash.h2.frame;
/**
* "Serialize yourself, then hand me the finished bytes." The interface a stream (and,
* eventually, connection-level singletons — the precompiled SETTINGS ACK, PING ACK, GOAWAY,
* WINDOW_UPDATE frames) implements to write through {@link Http2FrameWriter}.
*
* <h3>Layer 1 — serialize outside the lock</h3>
* By the time {@link Http2FrameWriter#write} is called, the implementation has already built
* its complete output (frame header + HPACK block + payload, or whatever the frame needs) into
* a buffer it owns — a per-stream scratch buffer, reused across writes, never allocated per
* call. {@link #buffer()}/{@link #offset()}/{@link #length()} just describe where that
* already-finished output lives. {@code Http2FrameWriter} never serializes anything itself; it
* only ever issues one bulk {@code write(buffer, offset, length)} while holding the connection's
* write lock — see {@code WRITER.md} for why that distinction is the entire point of this
* design (the lock must never be held across serialization work, only across the syscall).
*
* <h3>Intrusive queue linkage</h3>
* {@link #mpscNext()}/{@link #setMpscNext} are not part of the writer's public contract — they
* exist so a {@code WriteIntent} can double as an {@link IntrusiveMpscQueue} node with zero
* extra allocation when the writer is contended. Implementations provide simple field storage;
* nothing about the field is meaningful outside {@link IntrusiveMpscQueue}.
*/
public interface WriteIntent {
/** The buffer holding this intent's already-serialized bytes. */
byte[] buffer();
/** Offset of the first byte to write, within {@link #buffer()}. */
int offset();
/** Number of bytes to write, starting at {@link #offset()}. */
int length();
/** Intrusive MPSC queue linkage — see {@link IntrusiveMpscQueue}. Not for external use. */
WriteIntent mpscNext();
/** Intrusive MPSC queue linkage — see {@link IntrusiveMpscQueue}. Not for external use. */
void setMpscNext(WriteIntent next);
}