This document covers the why behind the what. For exact signatures, see
API.md; for measured throughput, see BENCHMARK.md.
- Throughput first. A parser is on the hot path of every proxy, cache,
and log processor. Parsing a 1 KB request head at 10 Gbps is ~100 ns per
request; at that scale, allocator overhead and branch mispredictions
dominate. So: no
malloc, nomemcpyof header bytes, no per-byte callbacks on the one-shot path. - Memory-safe by construction. The parser is fed untrusted bytes from a
socket. It must never read past the end of the caller's buffer, never
dereference a NULL, and never produce a
(ptr, len)pair that escapes the buffer. ASan/UBSan clean is a release gate, not an aspiration. - Streaming-friendly. A socket
read()can return any number of bytes at any boundary. The streaming parser must make progress on whatever it gets, without forcing the caller to buffer whole messages. - Portable. One source tree, one binary, runs on anything with a C99 compiler. SIMD is a runtime fast-path, not a build-time requirement.
Non-goals: HTTP/2 (separate HPACK problem), TLS, connection management, routing. lightning parses bytes; the caller decides what to do with them.
┌────────────────────────────────────────────┐
caller ─────▶ │ include/lightning/lightning.h │ ◀── public API
│ one-shot │ streaming │ url │ chunked │
└────────────┼───────────────────────────────┘
│
┌───────────▼────────────────────────────────┐
│ src/lightning.c │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ one-shot parser │ │ streaming FSM │ │
│ └────────┬─────────┘ └──────────┬──────┘ │
│ │ SIMD byte search │ │
│ └──────────┬─────────────┘ │
│ runtime dispatch: avx2 / sse42 / │
│ scalar │
└──────────────────────────────────────────────┘
src/chunked.c src/url.c
(standalone) (RFC 3986)
Three translation units, ~2k LoC total:
lightning.c— one-shot parser, streaming state machine, header helpers, SIMD dispatch, CPU detection. The big one.chunked.c— incremental chunked decoder, usable without the rest.url.c— RFC 3986 URL splitter, percent-decoder, query parser.
internal.h is private. It declares the streaming state enum, the method
table, and the inline helpers (lht_find_byte, lht_ascii_ieq,
lht_hexval) that the .c files share. Public consumers must not include it.
HTTP line scanning is, in the limit, "find the next \n in this buffer."
That is memchr, and memchr is one of the most optimized routines in
libc. So why bother with hand-written SIMD?
Because the one-shot parser spends most of its time not in memchr, but in
header line scanning — finding the : that separates field name from
value, then the \n that ends the line. Both scans happen over the same
run of bytes. Doing them as one pass over a 32-byte SIMD register avoids
two trips through L1.
The dispatch function:
static const char *find_any(const char *p, size_t len, char c1, char c2)
{
#if defined(LHT_AVX2_COMPILETIME)
if (lht_has_avx2() && len >= 32)
return simd_find_any_avx2(p, len, c1, c2);
#endif
#if defined(LHT_SSE42_COMPILETIME)
if (lht_has_sse42() && len >= 16)
return simd_find_any_sse42(p, len, c1, c2);
#endif
return scalar_find_any(p, len, c1, c2);
}Three things to notice:
- Runtime dispatch. The binary is compiled with
-mavx2(or-msse4.2) so the SIMD code is present, but it is gated bylht_has_avx2()/lht_has_sse42()which read CPUID on first call and cache the result. A binary built on a Zen 4 box runs scalar on a Haswell-era Xeon without crashing. - Length gate. For
len < 32the SIMD path is a net loss (setup cost exceeds the saved work). The scalar fallback handles short buffers. - Unaligned head. The AVX2 path walks byte-by-byte until aligned,
then switches to
__m256iloads. Unaligned_mm256_loadu_si256is free on modern CPUs, but keeping the alignment dance makes the worst case predictable.
memchr is still used where the search is one-byte and the libc
implementation is excellent: request-line whitespace, response status
digits, the leading \n for find_nl. The hand-rolled SIMD only kicks in
for the two-byte (c1, c2) header scans, where it actually beats libc.
ARM NEON is currently scalar-only. The dispatch is in place; the intrinsics are not. This is the highest-value contribution open.
Every parsed field is returned as (ptr, len), where ptr points into the
caller's input buffer. There is no internal staging buffer on the one-shot
path; headers are returned as lht_header_t entries with name and value
pointing directly into buf.
Consequences:
- The caller owns memory. lightning never
mallocs. - The returned pointers are valid only while
bufis alive and unchanged. The caller is responsible for not freeing or mutatingbufmid-parse. - For header values, optional whitespace (OWS) is trimmed in-place by
adjusting
value/value_len— never by copying.
The streaming parser has one small staging area: cur_field[128] and
cur_value[256] on lht_stream_t. These accumulate a single field name or
value across byte-boundary splits (a Content-Length header split at
Content-Len would otherwise break the hop-by-hop detection logic). They
are fixed-size, owned by the caller (because the caller owns
lht_stream_t), and bounded by max_header_size. They are not used for
the user-facing callbacks — on_header_field and on_header_value still
receive pointers into the input buffer.
The is_te_cl_conn flag on the stream marks the current field as one of
Transfer-Encoding, Content-Length, Connection — the three headers
that drive body framing. When set, the parser copies the value into
cur_value to consult it after the line ends. This is the only place
lightning copies bytes during streaming parse, and the buffer is
stack-allocated inside the stream struct.
The streaming parser is a flat switch over s->state. No nested function
calls, no recursion, no per-state allocation. Each state consumes some
bytes, transitions, and returns to the dispatcher.
States (see internal.h for the full enum):
s_start
├─ LHT_MSG_REQUEST ─▶ s_req_method ─▶ s_req_path ─▶ s_req_http_h ─▶ … ─▶ s_req_line_lf
└─ LHT_MSG_RESPONSE ─▶ s_res_http_h ─▶ … ─▶ s_res_status ─▶ s_res_reason ─▶ s_res_line_lf
│
▼
s_header_field_start ─▶ s_header_field ─▶ s_header_value_discard_ws
▲ │
│ ▼
│ s_header_value ─▶ s_header_line_cr
│ │
▼ ▼
s_header_line_lf ◀────────────────────────────────── s_header_line_lf
│
▼
s_headers_done ─┬─▶ s_message_done (no body)
├─▶ s_chunk_size_start (chunked)
├─▶ s_body (Content-Length / read-until-EOF)
└─▶ s_message_done (HEAD method, 1xx/204/304)
Two invariants the FSM relies on:
-
Every state that does multi-byte scanning can resume mid-token. The
s_header_fieldcase shows this: it scans bytes until it sees:,\r, or\n. If it hitsendfirst, it accumulates what it has intocur_field, setsstate = s_header_field(nots_header_field_start), and returns. The next call re-enters the same case and continues the scan. -
Error transitions are terminal.
stream_error(s)setss->state = (uint32_t)-1and the dispatcher hits thedefaultcase forever after.lht_stream_error()reports this. The only recovery islht_stream_reset().
The body decision happens in s_headers_done:
- Requests without
Content-Lengthand withoutTransfer-Encoding: chunkedhave no body. →s_message_done. - Responses with
1xx,204,304have no body. →s_message_done. HEADmethod requests have no body regardless. →s_message_done.- Otherwise: chunked → chunk state machine;
Content-Length: N→s_bodyforNbytes; nothing →s_body(read until EOF, valid for responses only).
lht_stream_finish is the EOF signal. For s_body with no
Content-Length, EOF is success. For any other non-s_message_done
state, EOF is LHT_EPARSE (truncation).
The rules, enforced by the code and verified by ASan/UBSan in CI:
-
No OOB reads. Every scan checks
p < endbefore dereferencing*p. SIMD loads checkp + 32 <= end(AVX2) orp + 16 <= end(SSE4.2) before the load. The unaligned-head loop walks byte-by-byte until aligned, never touching bytes pastend. -
No OOB writes.
cur_fieldandcur_valueaccumulation is bounded bysizeof(s->cur_field) - s->cur_field_len > n. If a header field is longer than 128 bytes, the field is silently truncated for the hop-by-hop detection (the user callback still gets the full pointer). -
No use-after-free. Pointers returned to the caller point into the caller's buffer. The contract is documented: keep
bufalive while you use the returned(ptr, len)pairs. lightning itself never holds a pointer across calls. -
No integer overflow.
lht_chunked_executecheckssz > (1ull << 63)before shifting.Content-Lengthparsing reads digits into auint64_twith the same overflow check. Header counts aresize_tand bounded bymax_headers(default 1024). -
No UB. Strict aliasing is respected:
__m256iloads go through__m256i*casts ofconst char*, which is the documented pattern. Signed overflow is absent (we useunsigned charfor byte classification). The fuzz test (tests/test_fuzz.c) feeds 5000 random inputs and 3000 mutated valid inputs through both parsers; ASan/UBSan stays clean.
Target: RFC 7230 (HTTP/1.1 message syntax) with selective tolerance for real-world deviations.
Strict (default, LHT_CONF_NORMAL):
- Lines must end with
CRLF. BareLFis a parse error. - No leading or trailing OWS allowed in field values (we trim it anyway, matching common practice).
- Request-target is opaque; we don't validate characters.
- Status code is exactly three digits.
Content-Lengthis parsed as an unsigned integer; multipleContent-Lengthheaders with different values would be a security bug (we don't currently detect this — see "Known limitations" below).
Tolerant (LHT_CONF_TOLERANT):
- Accepts bare
LFas a line terminator. - Accepts surrounding OWS in field names (rare in practice).
The streaming parser always accepts bare LF after a value, because too
many real servers send it. Strict CRLF enforcement happens in the
s_header_line_cr state when not in tolerant mode.
Body framing rules (RFC 7230 §3.3):
- If
Transfer-Encoding: chunkedis present, the body is chunked.Content-Lengthis ignored (the spec says remove it; we just defer to chunked). - Otherwise,
Content-Lengthgives the body length. - Otherwise, for responses, the body runs to EOF. For requests, no body.
This is the order the spec mandates. We do not currently reject messages
with both Transfer-Encoding: chunked and Content-Length — we treat
them as chunked. That matches every browser and server in the wild.
Known limitations:
- No multi-
Content-Lengthconflict detection (security-relevant for proxies; tracked). - No
Trailerheader parsing beyond consuming the trailer block. - Headers with obs-fold (
\r\nfollowed by whitespace as line continuation) are not merged; they are treated as separate fields, which the spec actually forbids but most parsers tolerate. Add later. - ARM NEON SIMD path is unwritten; ARM falls through to scalar.