Skip to content

fix(l7): resynchronize HTTP/2 parsing after any lost header block (#364 review) - #365

Merged
mayankpande88 merged 6 commits into
mainfrom
fix/http2-review-followups
Oct 6, 2026
Merged

mayankpande88 merged 6 commits into
mainfrom
fix/http2-review-followups

Conversation

@mayankpande88

@mayankpande88 mayankpande88 commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The review fixes for #364. #364 was merged before they were pushed, so they are here instead. Details are in the review reply on #364.

  • Lost header blocks no longer decode to wrong values.
    • Ring-buffer drops: the kernel records a dropped event on its connection (connection.l7_lost), and the next delivered event carries it (l7_event.lost_before). The parser resets that direction, counted as a new events_lost stage.
    • Truncation: the table is also reset when frames after the cut were lost, when the cut frame's header wasn't captured, and when a frame ends inside a later read's missing tail.
    • Discarded blocks: the table is reset when a pending header block is replaced by a new HEADERS frame, and when a malformed HEADERS or oversized partial frame is discarded.
  • Eviction at the 100-stream limit prefers requests still waiting for response headers, so a live long-lived stream (a watch) isn't evicted first.
  • Trailers on an untracked stream no longer create an empty request.
  • HPACK table: it is a ring, so eviction is O(1); a 64 KB block of minimal insertions takes ~0.3 ms instead of ~34 ms. Size updates above 64 KiB are clamped instead of failing the block.
  • Write probe fallback: when the Go TLS write probe falls back to the function entry, that is now logged once per binary.
  • Untracked connections (from the review of this PR): a TLS write on a connection the kernel wasn't tracking inserted the connection into active_connections but kept using a stack copy. The protocol detected on that first write was discarded, so a following write that started mid-frame could never be detected. It now continues on the map entry.
Engineering detail
  • Regression tests are built from the review's repros. The truncation and pending-block tests fail on the parent commit, decoding /a for /b.
  • l7_lost uses what was tail padding in struct connection, which stays 32 bytes. lost_before takes the event header's former padding byte.
  • The read-modify-write isn't atomic, because atomic OR needs 5.12. Two CPUs racing on one connection can rarely lose a flag.

Local e2e: built the agent from this branch (the first three commits are identical to the head tested before #364 merged) and ran it in a local Docker VM (kernel 6.10). A Go net/http HTTP/2 client making ~1.5M requests per 60s had every request decoded on a fresh connection, with 0 decode errors. The short-lived-process suite captured 389 of 391. A test-only build with a 64 KB ring buffer forced 939 drops; the lost flag reached the parser and caused 2 resynchronizations, with 0 decode errors and every request outside the dropped events decoded. With the untracked-connection fix added: the same Go client decoded every request with 0 errors, and the scenario suite, including its OpenSSL clients, matched the earlier runs. Unit tests, vet and golangci-lint pass, and the eBPF programs load on 5.10 and 6.1.

…ates

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.
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.
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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces robust handling for lost L7 events in the HTTP/2 parser and optimizes the HPACK decoder. Specifically, it tracks lost events via a new l7_lost field in the connection state, allowing the Go-side parser to reset its decoder state and avoid decoding incorrect paths when gaps occur. Additionally, the HPACK decoder is refactored to use a ring buffer for O(1) evictions, and the HTTP/2 stream eviction logic is improved to prioritize streams without response headers. A critical issue was identified in the eBPF code where modifications to conn->l7_lost are not persisted back to the active_connections map when TCP connection tracking misses, as conn still points to a temporary stack variable.

Comment thread ebpftracer/ebpf/l7/l7.c
…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.
@blue4209211

Copy link
Copy Markdown
Contributor

Thanks, this covers everything from the #364 review. The lost-event flag, the wider truncation resets, the pending-block drop, eviction order and the HPACK ring all look right. The server-side preface explanation makes sense. go test ./ebpftracer/l7/ passes locally. LGTM, merging.

Three small things for a later pass, none blocking:

  1. TLS mark on the untracked-connection switch (trace_enter_write, ~l7.c:577). mark_tls(conn) runs on conn_on_stack before conn switches to the map entry. If another CPU's insert won the BPF_NOEXIST race (for example the TCP connect path, which inserts with tls=0), the tracked entry never gets the mark, and that connection's socket-level ciphertext isn't skipped. Calling mark_tls(conn) again after the switch closes it.
  2. The l7_lost race. As you noted, the non-atomic read/clear can lose a flag when CPUs race. To remove it without 5.12 atomics, use a u32 lost counter per direction, incremented with __sync_fetch_and_add (XADD works on older kernels) and carried on each event. Userspace would detect a gap by comparing with the last value it saw, so nothing needs clearing. It needs more than the padding byte, so struct connection would grow past 32 bytes. Worth it only if events_lost shows the race matters.
  3. Copy failures aren't flagged. When COPY_PAYLOAD_RINGBUF fails (the bpf_probe_read of the user buffer), the event is dropped before send_event, so l7_lost isn't set. It's rare because those buffers are normally resident, but it's the one loss path the parser still can't see. Setting the same bit there, or counting it, would close it.

@mayankpande88
mayankpande88 merged commit fa510d4 into main Oct 6, 2026
7 checks passed
@mayankpande88
mayankpande88 deleted the fix/http2-review-followups branch October 6, 2026 08:41
@mayankpande88

Copy link
Copy Markdown
Contributor Author

Points 1 and 3 are in #367: the TLS mark is applied again after switching to the tracked entry, and failed copies set l7_lost. Point 2, the counter-based l7_lost, waits for events_lost numbers from dev to show the race matters. The CPU follow-up from #353 is #366.

blue4209211 pushed a commit that referenced this pull request Oct 6, 2026
…cked connection (#367)

* fix(ebpf): flag events lost to a failed copy, and mark TLS on the tracked connection

Two gaps left after the lost-event flag (from review of #365):

- An event dropped because bpf_probe_read of the user buffer failed was
  dropped before send_event, so l7_lost was not set and the parser could
  not see the gap. COPY_PAYLOAD_RINGBUF now records the loss on the
  connection, on the written or read side by where it runs.
- On a TLS write to an untracked connection, mark_tls ran on the stack
  copy before the code switched to the map entry. If another CPU had
  inserted that entry first (the TCP connect path does, with tls 0), it
  never got the mark and its socket-level ciphertext was not skipped. The
  mark is now applied again after the switch.

* refactor(ebpf): drop COPY_PAYLOAD_RINGBUF's unused event parameter

The programs are unchanged (same instruction counts); ebpf.go differs
only in the BTF line info that carries the edited source lines.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants