Summary
http_request_producer_impl (an internal class in libcaf_net/caf/net/http/with.cpp) has a reference cycle that is never broken when the consumer side of the request buffer calls cancel(). This causes the spsc_buffer<http::request> and everything it keeps alive — including the multiplexer's watched_ list entries — to leak.
Root cause
When net::http::with(sys).serve(fd).start(callback) is used, the library creates a make_spsc_buffer_resource<request>() buffer pair internally. The producer_resource end is opened by make_http_request_producer, which creates an http_request_producer_impl and registers it as the buffer's producer:
// with.cpp
auto ptr = make_counted<http_request_producer_impl>(std::move(ecp), buf);
buf->set_producer(ptr); // buffer holds a ref to the producer
This creates a reference cycle:
spsc_buffer::producer_ ──→ http_request_producer_impl
↑ │
└─────────── buf_ ─────────────────┘
The cycle is normally broken by the producer calling buf_->close() (setting producer_ = nullptr). However, when the consumer calls cancel() — as any well-behaved consumer such as blocking_consumer does on destruction — spsc_buffer::cancel() calls producer_->on_consumer_cancel(), which is implemented as a no-op:
// with.cpp — http_request_producer_impl
void on_consumer_cancel() override {
// nop
}
The cycle is never broken, so both objects keep each other alive indefinitely. This also keeps alive any disposable entries that were registered in the multiplexer's watched_ list via router_impl::lift() → future::bind_to(mpx).then(...).
Why tests currently pass
The existing test that exercises this code path ("server responds with service unavailable when request queue is overloaded") accidentally avoids the leak by calling request_buffer->close() from the consumer side — i.e. it calls the producer's close()/abort() method on the buffer directly, which sets producer_ = nullptr and breaks the cycle. A properly-written consumer (e.g. blocking_consumer) would instead call cancel(), triggering the bug.
Expected behaviour
http_request_producer_impl::on_consumer_cancel() should break the reference cycle, consistent with every other producer in CAF:
| Producer |
on_consumer_cancel() action |
producer_adapter |
Schedules a cancel action on the execution context |
blocking_producer |
Changes internal state + notifies waiting threads |
| Flow-based producers |
Notifies the owner actor |
http_request_producer_impl |
no-op ← bug |
The ecp_ member (an execution_context_ptr to the multiplexer) stored in http_request_producer_impl is never used; it was presumably added in anticipation of exactly this deferred-cleanup pattern.
Fix sketch
on_consumer_cancel() must release buf_ to break the cycle. It cannot do so inline because spsc_buffer::cancel() holds the buffer's mutex when it calls on_consumer_cancel(), so releasing buf_ there would destroy the buffer under its own lock. The release must be deferred via the execution context — the same pattern used by producer_adapter:
void on_consumer_cancel() override {
// Break the cycle: release buf_ outside of the buffer lock.
ecp_->schedule(make_action([buf = std::move(buf_)]() mutable noexcept {
buf = nullptr;
}));
}
Proposed regression test
Add a test to libcaf_net/caf/net/http/with.test.cpp that exercises the code path using blocking_consumer to consume and respond to requests, then verifies the test passes cleanly under ASAN/LSan (which the CI already runs via --dev-mode). See the test body below — it is structurally identical to the existing 503-overflow test but replaces the manual buffer pull loop with blocking_consumer:
TEST("blocking_consumer does not leak the request buffer") {
// Regression test for http_request_producer_impl::on_consumer_cancel()
// being a no-op, which left a producer/buffer reference cycle unbroken
// whenever the consumer cancelled rather than let the producer close.
auto [server_fd, client_fd] = unbox(net::make_stream_socket_pair());
net::socket_guard client_guard{client_fd};
std::optional<async::consumer_resource<net::http::request>> requests;
auto hdl = net::http::with(sys)
.serve(server_fd)
.start([&requests](auto pull) { requests.emplace(std::move(pull)); });
require_has_value(hdl);
detail::scope_guard hdl_guard{[hdl]() mutable noexcept { hdl->dispose(); }};
// Send one request.
auto req = "GET /ping HTTP/1.1\r\nHost: localhost\r\n\r\n"sv;
require_eq(net::write(client_fd, as_bytes(std::span{req})),
static_cast<ptrdiff_t>(req.size()));
// Consume the request via blocking_consumer and respond, then let the
// blocking_consumer destruct (calling cancel()). Prior to the fix this
// left the spsc_buffer<request> and its associated future-callbacks
// in the multiplexer watch list alive until process exit.
{
auto consumer = unbox(async::make_blocking_consumer(unbox(std::move(requests))));
net::http::request item;
require_eq(consumer.pull(async::delay_errors, item), async::read_result::ok);
item.respond(net::http::status::ok, "text/plain", "ok");
} // blocking_consumer destructs here, calling cancel()
auto response = read_http_response(client_fd);
check_eq(response.header.status(), uint16_t{200});
check_eq(response.body, "ok");
}
This test passes without leaks once on_consumer_cancel() is fixed, and fails (ASAN/LSan reports leaked memory) with the current no-op implementation.
Summary
http_request_producer_impl(an internal class inlibcaf_net/caf/net/http/with.cpp) has a reference cycle that is never broken when the consumer side of the request buffer callscancel(). This causes thespsc_buffer<http::request>and everything it keeps alive — including the multiplexer'swatched_list entries — to leak.Root cause
When
net::http::with(sys).serve(fd).start(callback)is used, the library creates amake_spsc_buffer_resource<request>()buffer pair internally. Theproducer_resourceend is opened bymake_http_request_producer, which creates anhttp_request_producer_impland registers it as the buffer's producer:This creates a reference cycle:
The cycle is normally broken by the producer calling
buf_->close()(settingproducer_ = nullptr). However, when the consumer callscancel()— as any well-behaved consumer such asblocking_consumerdoes on destruction —spsc_buffer::cancel()callsproducer_->on_consumer_cancel(), which is implemented as a no-op:The cycle is never broken, so both objects keep each other alive indefinitely. This also keeps alive any
disposableentries that were registered in the multiplexer'swatched_list viarouter_impl::lift()→future::bind_to(mpx).then(...).Why tests currently pass
The existing test that exercises this code path (
"server responds with service unavailable when request queue is overloaded") accidentally avoids the leak by callingrequest_buffer->close()from the consumer side — i.e. it calls the producer'sclose()/abort()method on the buffer directly, which setsproducer_ = nullptrand breaks the cycle. A properly-written consumer (e.g.blocking_consumer) would instead callcancel(), triggering the bug.Expected behaviour
http_request_producer_impl::on_consumer_cancel()should break the reference cycle, consistent with every other producer in CAF:on_consumer_cancel()actionproducer_adapterblocking_producerhttp_request_producer_implThe
ecp_member (anexecution_context_ptrto the multiplexer) stored inhttp_request_producer_implis never used; it was presumably added in anticipation of exactly this deferred-cleanup pattern.Fix sketch
on_consumer_cancel()must releasebuf_to break the cycle. It cannot do so inline becausespsc_buffer::cancel()holds the buffer's mutex when it callson_consumer_cancel(), so releasingbuf_there would destroy the buffer under its own lock. The release must be deferred via the execution context — the same pattern used byproducer_adapter:Proposed regression test
Add a test to
libcaf_net/caf/net/http/with.test.cppthat exercises the code path usingblocking_consumerto consume and respond to requests, then verifies the test passes cleanly under ASAN/LSan (which the CI already runs via--dev-mode). See the test body below — it is structurally identical to the existing 503-overflow test but replaces the manual buffer pull loop withblocking_consumer:This test passes without leaks once
on_consumer_cancel()is fixed, and fails (ASAN/LSan reports leaked memory) with the current no-op implementation.