Class Http2FrameWriter

java.lang.Object
dev.relism.flash.http2.frame.Http2FrameWriter

public final class Http2FrameWriter extends Object
The one component every HTTP/2 write in this codebase passes through — connection frames and stream frames alike (both are just WriteIntents). 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.

The design, three layers

Layer 1 — serialize outside the lock. By the time write(dev.relism.flash.http2.frame.WriteIntent) is called, the caller has already built its complete frame into a buffer it owns (see WriteIntent). This writer never serializes anything; it only ever issues one bulk 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."

Layer 2 — ReentrantLock, never synchronized. On Java 21, a virtual thread blocking inside synchronized pins its carrier platform thread; blocking on a ReentrantLock unmounts it instead (JEP 491, which removes the synchronized ReentrantLock is also load-bearing here for a second reason synchronized cannot offer: ReentrantLock.tryLock().

Layer 3 — tryLock() fast path, intrusive MPSC fallback. The overwhelmingly common case, even on a genuinely multiplexed connection, is exactly one stream wanting to write at a given instant. 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 tryLock() fails (genuine contention) does the intent get published through IntrusiveMpscQueue (one more CAS, still zero allocation — the intent itself is the queue node) for the current lock holder to drain.

Lost-wakeup avoidance

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 AtomicReference and the lock's own acquire/release ordering (recorded in full in WRITER.md, since it is exactly the kind of reasoning a future reader must be able to re-derive, not just trust):
 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()
 
A frame's bytes are never interleaved with another frame's bytes: every write of one intent is a single sink.write call issued while holding the lock, and the lock is not released between a WriteIntent's bytes.

Write timeout

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 Http2Limits.WRITE_TIMEOUT_MS, enforced by a shared background reaper (Http2FrameWriter.WriteTimeoutReaper) that interrupts the blocked thread past the deadline — Socket#setSoTimeout bounds reads, not writes, so it cannot be used here. connection setup), so arming/disarming the deadline for each individual write is two volatile field writes, not an allocation.
  • Constructor Details

  • Method Details

    • write

      public void write(WriteIntent intent) throws IOException
      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.

      Why the fast path is gated on !queue.hasWork(), not just tryLock() Writing intent immediately, before anything already queued, is only safe when nothing is already queued. Without the hasWork() check, this sequence is possible — and violates same-producer ordering, which the stress test asserts: a producer's write(a) then 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, write(c), finds the lock free (the holder released it between the producer's calls) and would otherwise write c directly — landing on the wire before a and b, which are still sitting in the queue. Checking 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 WRITER.md for the full argument.

      Throws:
      IOException
    • writePriority

      public void writePriority(WriteIntent intent) throws IOException
      Writes a connection-control frame ahead of queued stream data. An already executing socket write is never interrupted, but once it completes the priority queue is drained before the ordinary queue. This is used for PING acknowledgements, SETTINGS acknowledgements, GOAWAY and RST_STREAM.
      Throws:
      IOException
    • drain

      public void drain() throws IOException
      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.
      Throws:
      IOException
    • close

      public void close()
      Deregisters this writer from the write-timeout reaper. Call once, when the connection closes.