feat: add HTTP/2 session management and events - #928
niteshpurohit wants to merge 7 commits into
Conversation
- Implemented HTTP/2 session management in `http2.cpp` to handle client-server communication. - Added support for HTTP/2 events such as headers, data frames, and stream management. - Introduced new dependency operations for HTTP/2 in `dependency.cpp` and `contract.hpp`. - Created tests for HTTP/2 functionality in `http2.cpp` and `http2_contract.cpp` to ensure reliability and correctness. - Enhanced memory management for HTTP/2 sessions using a bounded arena to prevent memory exhaustion. closes: #60
There was a problem hiding this comment.
🟡 Changes recommended
Add a resume path for paused DATA delivery and expose SETTINGS ACK information in the contract.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds an nghttp2-backed HTTP/2 adapter with session management, event handling, bounded memory allocation, dependency integration, and tests.
Changes:
- Adds HTTP/2 session, stream, header, settings, and event contracts.
- Implements nghttp2 callbacks, frame operations, and bounded-arena allocation.
- Integrates build configuration, dependencies, CI coverage, and tests.
File summaries
| File | Summary |
|---|---|
tests/adapters/http2.cpp |
Functional HTTP/2 adapter tests |
tests/adapters/http2_contract.cpp |
HTTP/2 contract assertions |
src/core/contract/laghu/core/contract.hpp |
HTTP/2 dependency operations |
src/adapters/http2.cpp |
nghttp2 HTTP/2 adapter implementation |
src/adapters/dependency.cpp |
HTTP/2 dependency operation mappings |
src/adapters/contract/laghu/adapters/http2.hpp |
Public HTTP/2 contract |
CMakeLists.txt |
HTTP/2 build and test targets |
cmake/LaghuDependencies.cmake |
Dependency build configuration |
cmake/LaghuBuildIdentity.cmake |
HTTP/2 build identity inputs |
.github/workflows/toolchain.yml |
HTTP/2 CI coverage |
Review details
Suppressed comments (1)
src/adapters/http2.cpp:227
- The GOAWAY event only forwards
error_code;last_stream_idand the opaque debug data are dropped, whileevent.streamis derived from the frame header's connection stream ID (0). Consumers therefore cannot determine which peer streams were processed or access the peer diagnostic; add fields toHttp2Eventand populate them here.
case NGHTTP2_GOAWAY:
event.kind = Http2EventKind::goaway;
event.code = frame->goaway.error_code;
break;
- Files reviewed: 10/10 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Added `last_stream_id` and `debug_data` to `Http2Event` for better tracking of stream states. - Updated `on_frame_received` to handle settings acknowledgment and goaway frame metadata. - Enhanced `Events` struct in tests to track settings acknowledgments and goaway metadata. - Introduced new test cases for goaway handling and pause resumption scenarios.
There was a problem hiding this comment.
🔵 Needs a closer look
Moderate issues remain in DATA-event handling, memory reclamation, and explicit flow-control behavior.
Review details
Suppressed comments (5)
Previously missed (1) — in code that hasn't changed since the last review.
src/adapters/http2.cpp:82
memory_freeis a no-op, andmemory_reallocallocates a replacement without reclaiming the old block. nghttp2 regularly frees and resizes session/HPACK allocations as streams and the dynamic table churn; because this monotonic arena is only reset after the whole session ends, a long-lived connection will eventually exhaust its bound even when its steady-state native memory is bounded. Use a reclaimable bounded pool/allocator for the nghttp2 memory hooks, or otherwise recycle storage during the session.
src/adapters/http2.cpp:196
- When this callback returns
NGHTTP2_ERR_PAUSEfor an incoming DATA frame, nghttp2 retains the frame and requiresnghttp2_session_resume_data(session, stream_id)before processing can continue.Http2Sessionexposes no resume operation, so a sink that pauses on a data event cannot resume the stream by merely passing the unconsumed suffix back toreceive()as the public contract suggests. Add a stream-resume operation to the contract/implementation (and call it before retrying input), or do not advertise pausing DATA callbacks.
return callback_result(state, event, true, false);
src/adapters/http2.cpp:196
- The new
on_dataevent path has no test coverage: the HTTP/2 tests exercise headers, SETTINGS, reset, GOAWAY, window updates, limits, and pause-on-header, but never deliver a DATA frame or verify itsdata/end_streamfields. Add a DATA-frame exchange (including a zero-length END_STREAM case and, if supported, pause/rejection) so regressions in this advertised callback cannot pass the adapter suite.
int on_data(nghttp2_session*, std::uint8_t flags, std::int32_t native_stream_id,
const std::uint8_t* data, std::size_t length, void* user_data) noexcept {
auto& state = *static_cast<NativeState*>(user_data);
const auto data_view = core::ByteView::from(
{reinterpret_cast<const std::byte*>(data), length});
if (!data_view.has_value()) {
return NGHTTP2_ERR_CALLBACK_FAILURE;
}
Http2Event event{};
event.kind = Http2EventKind::data;
event.stream = stream_id(native_stream_id);
event.data = *data_view;
event.end_stream = (flags & NGHTTP2_FLAG_END_STREAM) != 0U;
return callback_result(state, event, true, false);
src/adapters/http2.cpp:196
- When the event sink returns
reject_streamfor a DATA event,rejectableis false, socallback_resultmaps it toNGHTTP2_ERR_CALLBACK_FAILUREand tears down the whole HTTP/2 session instead of producing the stream-scoped temporal callback failure used for header events. This prevents callers from rejecting a stream after inspecting its body and makes the publicreject_streamaction unsafe for data callbacks; allow the data callback to use the stream-rejection result.
return callback_result(state, event, true, false);
src/adapters/http2.cpp:316
- Constructing the session with
nullptroptions leaves nghttp2's automatic receive-window updates enabled. That means consuming DATA inreceive()can cause WINDOW_UPDATE frames without Laghu explicitly granting credit, while this contract exposessubmit_connection_window_update/submit_stream_window_updateand assigns backpressure ownership to Laghu. Configure the native session withnghttp2_option_set_no_auto_window_updateand provide/use an explicit credit path so a paused or unconsumed stream cannot advertise more capacity automatically.
result = nghttp2_session_client_new3(&native_session, callbacks, state, nullptr, &memory);
} else {
result = nghttp2_session_server_new3(&native_session, callbacks, state, nullptr, &memory);
- Files reviewed: 10/10 changed files
- Comments generated: 0 new
- Review effort level: Lite
- Added support for data frame events in the HTTP/2 session. - Implemented allocation header reuse for memory management. - Enhanced event structure to track data events and bytes. - Introduced tests for data frame reception and flow control. - Improved memory allocation and deallocation logic for efficiency.
There was a problem hiding this comment.
🟡 Changes recommended
Two unresolved moderate issues affect rejected DATA-stream resets and DATA-event pause/resume behavior.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
src/adapters/http2.cpp:232
on_dataacceptsHttp2CallbackAction::pauseand maps it toNGHTTP2_ERR_PAUSE, but nghttp2 pauses DATA delivery untilnghttp2_session_resume_data()is called; replaying the unconsumed input suffix, asreceive()promises, does not resume that stream. A consumer that pauses on a data event therefore cannot reliably continue processing. Expose an explicit data-resume operation/state, or disallowpausefor data events and document the supported pause points.
int on_data(nghttp2_session*, std::uint8_t flags, std::int32_t native_stream_id,
const std::uint8_t* data, std::size_t length, void* user_data) noexcept {
auto& state = *static_cast<NativeState*>(user_data);
const auto data_view = core::ByteView::from(
{reinterpret_cast<const std::byte*>(data), length});
if (!data_view.has_value()) {
return NGHTTP2_ERR_CALLBACK_FAILURE;
}
Http2Event event{};
event.kind = Http2EventKind::data;
event.stream = stream_id(native_stream_id);
event.data = *data_view;
event.end_stream = (flags & NGHTTP2_FLAG_END_STREAM) != 0U;
return callback_result(state, event, true, false);
tests/adapters/http2.cpp:372
- This test creates both sessions without event sinks and only checks that control-frame submission succeeds. Consequently, regressions in the new
resetandwindow_updateevent paths would still pass; attach sinks to the sessions and assert both event counters after receiving the generated frames.
auto client = Http2Session::create(*worker, Http2Role::client, client_arena, limits(),
Http2EventSink{&client_events, Events::write});
auto server = Http2Session::create(*worker, Http2Role::server, server_arena, limits(),
Http2EventSink{&server_events, Events::write});
- Files reviewed: 10/10 changed files
- Comments generated: 1
- Review effort level: Lite
- Added a new function `append_data_frame` to encapsulate data frame construction logic, improving code readability and maintainability. - Replaced direct data frame construction in `receive_data_frame` with a call to `append_data_frame`. - Enhanced the handling of pending resets by using an array to track multiple reset streams, preventing overflow and improving error handling. - Updated logic to clear pending resets after processing, ensuring the state is reset correctly.
There was a problem hiding this comment.
🔵 Needs a closer look
Unresolved moderate HTTP/2 behavior and contract issues must be addressed before approval.
Review details
Suppressed comments (7)
Previously missed (1) — in code that hasn't changed since the last review.
src/adapters/http2.cpp:169
reject_streamis advertised as a per-stream callback action, but this branch returns a negative nghttp2 callback code andreceive()has no special handling for it, so the intentional rejection is reported as a generic dependency/corrupt-data error. That is indistinguishable from a receive/protocol failure to API consumers and can cause them to tear down the connection, unlike data rejection, which queues RST and returns consumed bytes. Normalize header rejection consistently as a stream reset or expose a distinct, resumable result contract.
src/adapters/contract/laghu/adapters/http2.hpp:75
Http2Eventexposes four borrowedByteViewfields, but the contract only documents ownership of the sink context and says nothing about when these views expire. A sink can copy the trivially-copyable event and later dereferencename,value,data, ordebug_dataafter nghttp2 has invalidated the storage. Document these fields as callback-scoped (or provide owned copies) so consumers cannot mistake the event for persistent data.
struct Http2Event final {
Http2EventKind kind{Http2EventKind::frame_received};
Http2StreamId stream{};
core::ByteView name{};
src/adapters/contract/laghu/adapters/http2.hpp:124
- The session stores a non-owning arena pointer and
require_valid()/release()dereference it, but this contract does not state the required lifetime ordering. As withsrc/adapters/contract/laghu/adapters/structured_data.hpp:74-76, the caller must destroy the session before resetting or destroying the arena; otherwise a reset can release the arena reservation and the later session destructor can dereference a dangling pointer.
[[nodiscard]] static core::Result<Http2Session> create(
core::WorkerId worker, Http2Role role, core::BoundedArena& arena,
Http2Limits limits, Http2EventSink event_sink = {},
DependencyLogSink log_sink = {}) noexcept;
src/adapters/contract/laghu/adapters/http2.hpp:148
- This contract exposes inbound DATA events and flow-control updates, but no operation for submitting DATA frames or a data provider. After
submit_headers(..., false), callers have no way to send a request or response body through the adapter; the tests have to hand-encode DATA frames instead. Add a bounded outbound DATA/provider API, or explicitly scope this milestone to inbound DATA and adjust the stated client-server/framing coverage.
[[nodiscard]] core::Result<Http2StreamId> submit_headers(
std::span<const Http2Header> headers, bool end_stream) noexcept;
[[nodiscard]] core::Result<void> submit_headers(
Http2StreamId stream, std::span<const Http2Header> headers,
bool end_stream) noexcept;
[[nodiscard]] core::Result<void> submit_settings(
std::span<const Http2Setting> settings) noexcept;
[[nodiscard]] core::Result<void> submit_reset(Http2StreamId stream,
std::uint32_t error_code) noexcept;
[[nodiscard]] core::Result<void> submit_goaway(std::int32_t last_stream_id,
std::uint32_t error_code,
core::ByteView debug_data) noexcept;
[[nodiscard]] core::Result<void> submit_connection_window_update(
std::int32_t increment) noexcept;
[[nodiscard]] core::Result<void> submit_stream_window_update(
Http2StreamId stream, std::int32_t increment) noexcept;
src/adapters/http2.cpp:176
- After the sink returns
reject_streamfor a DATA event, this path only records the stream and returns 0. If the samereceive()buffer contains another DATA frame/chunk for that stream,on_datainvokes the sink again before this duplicate check, so data is delivered after rejection and the RST is delayed until all input is parsed. Track rejected streams before invoking the sink or stop parsing at the first rejection.
if (action == Http2CallbackAction::reject_stream && event.kind == Http2EventKind::data &&
event.stream.valid()) {
const std::int32_t rejected_stream = event.stream.wire_value();
for (std::size_t index = 0; index < state.pending_reset_count; ++index) {
if (state.pending_reset_streams[index] == rejected_stream) {
return 0;
}
src/adapters/http2.cpp:376
Http2Roleis a public enum, but every value other thanclientis silently treated asserver. A caller that passes an invalid enum value (for example after deserialization or a cast) therefore creates a session with the wrong wire role instead of receiving an input error. Validate both enum values before selecting the native constructor, consistent with the enum validation used bysrc/core/mapped_regions.cpp:23-29.
if (role == Http2Role::client) {
result = nghttp2_session_client_new3(&native_session, callbacks, state, options, &memory);
} else {
result = nghttp2_session_server_new3(&native_session, callbacks, state, options, &memory);
src/adapters/http2.cpp:166
- The public contract says that a callback may pause parsing, but this path only returns
NGHTTP2_ERR_PAUSEwhenpausableis true. The adapter passesfalseforheaders_begin, SETTINGS, reset, GOAWAY, window-update, and stream-closed events, so returningpausefor any of those events becomesNGHTTP2_ERR_CALLBACK_FAILUREand surfaces as a fatal receive error. Either support pause consistently where nghttp2 permits it or document the event-specific restriction in the contract.
if (action == Http2CallbackAction::pause && pausable) {
return NGHTTP2_ERR_PAUSE;
}
- Files reviewed: 10/10 changed files
- Comments generated: 0 new
- Review effort level: Lite
- Added Http2DataProvider and Http2DataReadResult structures to manage data reading. - Implemented submit_data and resume_data methods in Http2Session for improved data submission handling. - Enhanced error handling for data provider failures in native state management. - Updated tests to validate new data submission and resumption functionality.
There was a problem hiding this comment.
🔵 Needs a closer look
Three unresolved moderate findings remain in the HTTP/2 implementation and arena test fixture.
Review details
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
tests/adapters/http2.cpp:38
storageis the backing block returned toBoundedArena, which placesNativeStateandAllocationHeaderobjects there withalignof(std::max_align_t).std::array<std::byte, ...>only has byte alignment, so this fixture can return misaligned storage and make the placement-new/custom allocator accesses undefined on targets where the stack address is not sufficiently aligned. Align this buffer as the bounded-arena tests do.
src/adapters/http2.cpp:273
- The per-header event leaves
Http2Event::end_streamat its defaultfalse, even when the containing HEADERS frame carriesEND_STREAM. Consumers handlingheaderevents therefore cannot rely on the event metadata to detect end-of-stream (and the field is already populated forheaders_beginandframe_received). Copy the frame flag into this event as well.
event.sensitive = (flags & NGHTTP2_NV_FLAG_NO_INDEX) != 0U;
src/adapters/http2.cpp:171
reject_streamis documented as supported forheaders_beginandheader, but this branch turns those callbacks intoNGHTTP2_ERR_TEMPORAL_CALLBACK_FAILUREbefore the pending-reset path below can run. A policy rejection during header processing therefore abortsreceive()without queuing/sending anRST_STREAM, unlike a DATA rejection; implement the same deferred stream-reset behavior for header events or change the public contract.
if (action == Http2CallbackAction::reject_stream && rejectable) {
return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE;
- Files reviewed: 10/10 changed files
- Comments generated: 0 new
- Review effort level: Lite
- Added end_stream flag to header events for better tracking of stream termination. - Updated event handling to reflect the end of headers in processing logic. - Improved test structure to validate header ending conditions in outbound data submissions.
There was a problem hiding this comment.
🟡 Changes recommended
A critical memory-reallocation issue and two moderate HTTP/2 behavior issues remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (5)
src/adapters/http2.cpp:171
- The contract documents
reject_streamas supported for header-begin and header events, but this branch returnsNGHTTP2_ERR_TEMPORAL_CALLBACK_FAILUREdirectly from the nghttp2 receive call.receive()then treats the negative result as a dependency/protocol error, unlike the DATA path below which queues a reset and returns the consumed input. A normal header rejection is therefore surfaced as a failed session operation; normalize this action consistently (queue/reset and preserve progress, or explicitly handle the expected temporal result) instead of returning it ashttp2_receivecorruption.
if (action == Http2CallbackAction::reject_stream && rejectable) {
return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE;
src/adapters/http2.cpp:220
- A provider can return the default
morestate with zero bytes, and this check accepts it. Becausedeferredis the explicit no-data state,next_output()can make no progress (or repeatedly emit empty DATA frames) instead of surfacing invalid provider output. Rejectmore/0here so a provider cannot stall the session.
if (produced->bytes > length ||
(produced->state == Http2DataReadState::deferred && produced->bytes != 0U)) {
state.data_provider_error = core_error(core::ErrorCode::invalid_range,
"HTTP/2 data provider returned an invalid size");
state.data_provider_failed = true;
tests/adapters/http2.cpp:353
ended_frameswas already incremented by the earlier"abc"DATA frame, which also hasEND_STREAM, so this condition is already true before the empty DATA frame is received. Capture the counter before this exchange and assert that it increases to actually cover the empty end-stream frame.
const auto empty_stream = client->submit_headers(headers, false);
if (!empty_stream.has_value() || !transfer(*client, *server) ||
!receive_data_frame(*server, empty_stream->wire_value(), {}, true) ||
server_events.ended_frames == 0U) {
tests/adapters/http2.cpp:346
server_events.data_endedwas set by the earlier inbound"abc"DATA frame and is never cleared, so this assertion can pass even if the deferred outbound provider does not emitEND_STREAM. Reset a per-exchange flag or track the ending event separately before testing the outbound transfer.
server_events.data_events <= data_events_before_resume || !server_events.data_ended) {
tests/adapters/http2.cpp:483
- This only checks that
submit_connection_window_updateaccepts the request; its output is never drained or passed to the peer, so the connection-level WINDOW_UPDATE path is not covered by the control-frame test. Transfer the pending output to the client (and preferably assert the corresponding event) here.
return server->submit_connection_window_update(1024).has_value();
- Files reviewed: 10/10 changed files
- Comments generated: 1
- Review effort level: Lite
- Changed `size` to `capacity` and added `used_size` in `AllocationHeader` for better memory tracking. - Updated allocation logic to utilize `capacity` instead of `size` for memory checks. - Ensured `used_size` is updated during memory reallocation to reflect the actual used memory.
http2.cppto handle client-server communication.dependency.cppandcontract.hpp.http2.cppandhttp2_contract.cppto ensure reliability and correctness.closes: #60