Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

http_request_producer_impl leaks spsc_buffer when consumer cancels #2437

Copy link
Copy link

Description

@riemass
Issue body actions

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.

Reactions are currently unavailable

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

    Morty Proxy This is a proxified and sanitized view of the page, visit original site.