From e3e121b1f7b382963f4900973aa2745ed7d02526 Mon Sep 17 00:00:00 2001 From: Amit Agarwal Date: Tue, 27 Oct 2026 15:48:29 +0530 Subject: [PATCH] Release messageRouter persistent reference in ProducerConfig destructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProducerConfig stores the user's messageRouter JS callback in a Napi::FunctionReference created with Napi::Persistent. Persistent handles are GC roots, so the callback — and everything its closure captures — can never be collected. Producer deliberately extends ProducerConfig's lifetime (shared_ptr held by the Producer wrapper), but neither Producer::Close nor ~Producer nor ~ProducerConfig ever calls Reset() on the reference. The result: any application that closes producers and creates new ones (e.g. periodic recycling of producers for config/credential rotation or partition-count refresh) accumulates unreachable-but-pinned callback graphs forever. Each pinned graph also keeps the JS Producer/Client ObjectWrap alive, so their finalizers never run and the underlying C++ producer/client allocations are never freed either — the leak shows up as steady RSS growth while the JS heap stays flat. Reset the reference in ~ProducerConfig. The destructor runs only after the last shared_ptr is released, i.e. after the Producer wrapper is gone and its C producer freed, so no routing callback can be invoked afterwards. Reset() is a no-op for a never-assigned reference (no messageRouter configured). Verified with heap snapshots taken before and after producer create/close cycles: closed producers' router closures were retained via (GC roots) -> (Global handles) -> messageRouter -> [context] -> this and grew linearly with churn; with this change they are collected and the retained set stays at the live producer count. Co-Authored-By: Claude Opus 4.6 --- src/ProducerConfig.cc | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ProducerConfig.cc b/src/ProducerConfig.cc index eca62a7f..bae93a40 100644 --- a/src/ProducerConfig.cc +++ b/src/ProducerConfig.cc @@ -292,7 +292,14 @@ ProducerConfig::ProducerConfig(const Napi::Object& producerConfig) : topic("") { } } -ProducerConfig::~ProducerConfig() {} +ProducerConfig::~ProducerConfig() { + // Release the persistent reference to the JS messageRouter callback. Without + // this, every producer created with a custom messageRouter pins the callback + // (and everything its closure captures) as a GC root for the lifetime of the + // process, long after the producer and its client are closed, leaking memory + // per closed producer. Reset() is a no-op when no router was configured. + messageRouter.Reset(); +} std::shared_ptr ProducerConfig::GetCProducerConfig() { return this->cProducerConfig;