Repository navigation
fix: decode HTTP/2 headers reliably (HPACK, preface detection, duplicate Go TLS writes) - #364
Conversation
Every L7 event was reserved in the ring buffer at the full struct size: a header plus two MAX_PAYLOAD_SIZE buffers, about 8 KB, whatever it carried. HTTP/2 frames of a streaming response are mostly a few hundred bytes with no response part. A burst of them filled the 32 MB buffer with mostly empty slots, and later events were lost. On a test cluster the loss was 8-12% of L7 events on the two nodes running a streaming LLM service. Events are now built in a per-CPU scratch buffer and copied into the ring with bpf_ringbuf_output at their actual length. That length is the header plus the payload, or the header, the payload buffer and the response when there is one. Payload and response keep their fixed offsets, so userspace decodes a short record as before. Reads that build an event and then drop it no longer take ring space either. node_agent_l7_ringbuf_drops_total now counts only events that were sent and lost.
Payload copies into event and request buffers stopped at MAX_PAYLOAD_SIZE-1 bytes, while payload_size and the decoder allow MAX_PAYLOAD_SIZE. A read or write of 4096 bytes or more was decoded with a last byte the event never wrote: before, a byte left in the ring buffer by an older record; since the scratch buffer, one left by the previous event on that CPU. Go's HTTP/2 reads and flushes through 4 KB buffers, so 4096-byte events are common, and the frame that crosses the end of one is reassembled with that byte. Inside a header block it produces a wrong header value or an HPACK decode error, and the decoder reset after an error loses the dynamic table for the rest of the connection. The clamp is inline asm: written in C, clang compared a copy of the size and the verifier lost the bound for the response buffer.
… of sync The parser decoded header blocks with hpack.Decoder and replaced it with a fresh one after any error. When the agent joins a connection after it opened, or loses a header block, the peer's encoder keeps referencing dynamic table entries the decoder never saw. hpack.Decoder fails at the first such reference, so the block's later insertions are never applied and the next block fails the same way. Resetting changes nothing: on a connection whose encoder references an old entry in every request, every block failed for good. Whatever the table supplies was lost with it: :path and :authority of requests, and grpc-status and any :status outside the static table (201, 401, 503, ...) of responses. Header blocks are now decoded by a decoder that skips references to entries it does not hold and applies every insertion. Indices count back from the newest entry, so everything inserted since it lost track sits at the index the encoder uses, and it converges on the encoder's table as old entries are evicted. In a test that joins a connection after 20 requests, static-named headers (:method, :path, :authority, content-type) decode in full from request 63; the reset-on-error decoder failed all 380 blocks. Missing a block's insertions would shift older indices, so the table is now reset where a header block is known to be lost: a HEADERS or CONTINUATION frame cut by truncation, a pending header block dropped, a CONTINUATION without its HEADERS. A block that decodes to pseudo-headers impossible for its direction (:method in a response, :status in a request) also resets it. References the decoder cannot resolve are counted as the hpack_partial stage, not as decode errors. Larger dynamic table size updates, which hpack.Decoder(4096) rejected, are accepted.
Evicting, resetting or emptying the dynamic table resliced it, so the backing array kept the dropped entries' strings alive until overwritten. Clear them. Index with int after the bounds checks.
Go's HTTP/2 client sends one a few requests into every connection, once it has the server's SETTINGS. hpack.Decoder, fed with Write and never Close as the parser must, rejected it as not at the beginning of a header block, which put every Go client connection into the reset-on-error cascade.
…ing one The parser tracks at most 100 requests per connection that are waiting for their response. A request whose response it never sees (the read was cut short by truncation, or the event was lost) waits until the stream GC, two minutes or more. Once 100 of those piled up, new streams were refused, silently: every request on the connection was dropped until the GC ran. At the limit the request that has waited longest is now dropped instead, and counted as the stream_evicted stage.
The crypto/tls.(*Conn).Write probe sat on the function's entry. A Go function's entry runs again when its goroutine's stack has to grow there: the stack check branches to morestack, which copies the stack and restarts the function from its first instruction. The probe fired twice and the write was sent twice. A duplicated write splices a copy of its bytes into the connection's stream. The HTTP/2 parser loses frame alignment for the rest of the connection when the write ends inside a frame, as Go's 4 KB flushes do, and a repeated header block inserts its HPACK entries twice. Stacks grow again after the GC shrinks them, so this recurs for the life of a process: in a local run, 10 of 4,751 writes on one connection arrived twice. The write probe is now attached to the first instruction past the prologue's stack check, which runs once per call with the argument registers untouched (the check uses only scratch registers). A function whose prologue has no recognizable check keeps the entry. Emitting from return probes instead was tried and dropped: it needs a uprobe on every return instruction (17 per Go binary instead of 9), and the slower per-process attach missed short-lived processes (233 of 391 captured locally, against 390).
A stripped Go binary's symbols come only from .gopclntab, and openGoFuncTable read it by reopening the path, a /proc/<pid>/exe link. When the process exited between the ELF open and that reopen, its crypto/tls functions were "not found". LookupSymbols caches that per binary and AttachGoTlsUprobes caches no_symbols, so every later process of the binary went unprobed until the entries were evicted. Short-lived processes of a stripped binary, the first of which is often gone before the lookup finishes, hit this. In a local run where attaching was slower, 450 attaches of the short-lived test client returned no_symbols. ELFFile now keeps the opened file and the table is mapped from it. A symbol is also no longer recorded as missing when the table failed to load for another reason.
The write path checked for HTTP/2 first only on ports 443 and 8443. On
any other port the protocol detectors ran in order, and is_redis_query
accepts anything that starts with an uppercase letter, so the client
connection preface ("PRI * HTTP/2.0...") was taken for a Redis command
and the connection was cached as Redis. Every write until the first
server frame was read, which flips the connection to HTTP/2, was lost.
Go and gRPC clients write their first request's headers before reading
the server's SETTINGS, so those headers were lost on every new HTTP/2
connection to a port other than 443/8443, internal gRPC included. They
carry most of the HPACK dynamic table's insertions, so the decoder was
missing them for the life of the connection: every later request that
referenced them failed to decode.
The preface is unambiguous, so it is now checked before the other
detectors on every port.
There was a problem hiding this comment.
Code Review
This pull request introduces several optimizations and robustness improvements to the L7 HTTP/2 parser and eBPF tracer. Key changes include implementing a custom, tolerant HPACK decoder that can recover from mid-stream joins, optimizing ring buffer usage by building events in a per-CPU scratch map (l7_event_heap) before outputting them at their actual size, and probing Go TLS functions past their stack checks to avoid duplicate events when the stack grows. Additionally, the ELF reader is updated to keep the file descriptor open, preventing failures when processes exit. The reviewer identified a high-severity security vulnerability in the eBPF code where the scratch event buffer is not fully cleared, potentially leaking stale payload data from previous requests on the same CPU, and provided a code suggestion to zero the buffer up to the response field.
|
Nice work. The stack-growth diagnosis for duplicated Go TLS writes, preface-before-Redis detection, ring-buffer records sized to their content, the full 4096-byte copy and the stripped-binary fix all look right. Userspace already bounds-checks every slice, so a shorter record can't panic. On the decoder: integer, Huffman, string and static-table handling match RFC 7541. Client and server tables are separate. A ~25 s fuzz of The "never decodes a wrong value after a mid-stream join" claim holds as long as no later block is lost: the decoder's table is a suffix of the encoder's, and it never evicts later than the encoder does. It does not hold when a block is lost without the parser knowing. Then it emits wrong values, not unknown ones. 1. Loss the parser isn't told about gives wrong header values ( These came from throwaway tests against Go's
Suggested fixes:
2. Question: does the server side still miss the preface? The preface is checked only on writes. 3. Eviction prefers long-lived streams ( The oldest Minor
|
|
Thanks for the careful review. All of it is addressed in three new commits (add8572, c3cc8df, a44c7d1). 1. Undetected loss. You're right: wrong values, not unknown ones. Each of your repros is now a test that fails on the previous head with
2. Server-side preface. Intended, and nothing depends on it: L7 is recorded from the client side.
3. Eviction. A request still waiting for response headers now goes first, oldest first. One whose response was lost never gets them, and a live watch has them, so it stays. Minor:
Local verification (Docker VM, kernel 6.10):
|
… review) (#365) * fix(l7): keep the HPACK table in a ring, and clamp oversized size updates Eviction shifted the whole slice of entries. With a size update to 64 KiB a peer can fill the table with ~2,000 minimal entries, and a block of minimal insertions then evicts on every insertion: one 64 KB block took ~34 ms. The table is now a ring, so eviction is O(1); the same block decodes in ~0.3 ms. A size update above the 64 KiB the decoder will hold failed the block, losing everything after it. It is now clamped: the decoder only mirrors the encoder's table, and evicting entries the encoder still has makes them read as unknown, not wrong. * fix(tls): log when the Go TLS write probe falls back to the entry When no stack check is recognized in Write's prologue, the probe goes on the function entry, where a write made while the goroutine's stack grows is captured twice. That fallback was silent; it is now logged once per binary. * fix(l7): resynchronize HTTP/2 parsing after any lost header block The tolerant decoder never decodes a wrong value as long as it sees every insertion. A header block lost without the parser knowing breaks that: later references then resolve to the wrong entries. Review found four ways that happened: - Ring-buffer drops. The parser never learned of them. The kernel now records a dropped event on its connection (connection.l7_lost, one bit per direction, in what was tail padding) and the next delivered event carries it (l7_event.lost_before, the former padding byte). The parser then drops that direction's partial frame and pending block and resets its table (stage events_lost). - Truncation that hides a following HEADERS frame. The table was reset only when the cut frame itself was HEADERS or CONTINUATION. It is now reset unless the missing bytes are known to be the tail of one other frame: also when frames after the cut were lost (one write often holds a response's DATA and then its trailers), when the cut frame's header was never captured, and when a frame ends inside a later read's own missing tail. - A HEADERS frame arriving while a header block waits for CONTINUATION frames replaced it silently; the pending block is now dropped with a reset. - A malformed HEADERS frame and an oversized partial frame were discarded without a reset. Also from review: - Eviction at the stream limit now prefers a request still waiting for response headers, oldest first. A request whose response was lost never gets them, while the oldest stream overall is often a live long-lived one (a watch) that has its headers and awaits its end. - A client block with no pseudo-headers on a stream the parser does not track (trailers of an evicted stream) no longer creates a request. * fix(ebpf): keep using the tracked connection after inserting it on a TLS write On a TLS write to a connection the kernel was not tracking (an OpenSSL socket whose connect it missed), trace_enter_write inserted its stack copy into active_connections but went on using the copy. The protocol detected on that first write, and a loss recorded by send_event, were written to the copy and discarded, so the next write had to detect the protocol afresh, which a write starting mid-frame cannot. It now continues on the map entry, or on another CPU's if that one won the race. LLM capture, which skips the stack copy, now also sees that first write, binding a mark set from a ClientHello there rather than on the second write. --------- Co-authored-by: Shiv <3078106+blue4209211@users.noreply.github.com>
Summary
HTTP/2 requests were decoded badly: on a test cluster about 2.5 HPACK decode errors per stream, and every error leaves headers undecoded. Five separate causes, each of which corrupts or desyncs the decoder's dynamic table, plus two capture gaps found while tracing them. This folds #356, #357, #359, #360, #361 and #362, which were tested together; each commit matches one of them and can be reviewed on its own.
What was wrong:
is_redis_queryaccepts anything starting with an uppercase letter,PRI * HTTP/2.0included. Every write until the first server frame was lost, including the first request's headers, which carry most of the HPACK insertions. That affected every new HTTP/2 connection to such a port, internal gRPC included.hpack.Decoder. That doesn't help once the peer references entries the decoder never saw: on a connection that does so every request, every block failed for good.hpack.DecoderwithWriteand neverClose, so it rejected the table size update Go clients send a few requests in. That put every Go client connection into the cascade.Also:
/proc/<pid>/exe. A process that exited mid-lookup got the binary cached as having no TLS functions, so every later process of it went unprobed.Metrics:
node_agent_hpack_decode_errors_totalnow counts only real decode failures.node_agent_http2_stage_totalgets three new stages:hpack_partial(unresolvable references),stream_evicted(evictions) andevents_lost(resynchronizations after a ring-buffer drop).Engineering detail
Each original PR has the full analysis and its own review thread: #356, #357, #359, #360, #361, #362.
TestProgramsLoad,TestGoTLSFdWalk). The 4096-byte clamp is inline asm, because written in C the verifier lost the bound.hpack.Decoderdoes. After a mid-stream join it never decodes a wrong value unless a later header block is lost without the parser knowing. Every loss path the parser can see now resets the table, and ring-buffer drops are flagged by the kernel. Reference names copied from entries that existed before the join (custom names likex-request-id) stay unknown; static-table names recover.TestStackCheckEndcovers amd64 and arm64 prologues, for small and large frames.:authorityfirst means no static-index byte for the kernel heuristic).Local e2e: ran agent images built from this change and from its parent in a local Docker VM (kernel 6.10):
go vetand golangci-lint pass, and the eBPF programs load on 5.10 and 6.1.