Description
TRootSnifferFull::ProduceExe (the exe.json handler) executes a registered object's method through the interpreter and serializes the returned value, but it never deletes the object the method returns. For a method that returns a freshly allocated, owned object — e.g. TH2::ProjectionX/ProjectionY, which returns a new TH1D — every exe.json request leaks that result.
It is masked when callers reuse the same target name (ProjectionY resets the existing same-named histogram), but with distinct names (or any method that returns a new owned object on each call) the leak is unbounded.
Reproducer
Short, self-contained (THttpServer + a raw-socket HTTP GET, no extra deps):
// ROOT exe.json memory leak: TRootSnifferFull::ProduceExe never frees the
// object returned by the executed method (here TH2::ProjectionY -> a new TH1D).
// Each exe.json call with a fresh target name leaks the result (~3 allocations).
//
// Build: g++ repro.cc $(root-config --cflags --libs) -lRHTTP -lNet -pthread \
// -fsanitize=address -g -o repro
// Run: ./repro 200 # LeakSanitizer reports ~3*N leaked allocations
#include "TH2D.h"
#include "THttpServer.h"
#include "TSystem.h"
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <atomic>
#include <string>
#include <thread>
static void http_get(int port, const std::string& path) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in a{}; a.sin_family = AF_INET; a.sin_port = htons(port);
inet_pton(AF_INET, "127.0.0.1", &a.sin_addr);
if (connect(fd, (sockaddr*)&a, sizeof(a)) == 0) {
std::string r = "GET " + path + " HTTP/1.0\r\n\r\n";
(void)!write(fd, r.data(), r.size());
char b[4096]; while (read(fd, b, sizeof b) > 0) {}
}
close(fd);
}
int main(int argc, char** argv) {
const int N = argc > 1 ? atoi(argv[1]) : 200;
const int port = 9410;
auto* h = new TH2D("h2", "h2", 128, 0, 128, 1024, 0, 1024);
for (int i = 0; i < 5000; ++i) h->Fill(i % 128, (i * 7) % 1024);
THttpServer serv(Form("http:%d", port));
serv.SetReadOnly(kFALSE); // allow exe.json method calls
serv.Register("/", h);
std::atomic<bool> stop{false};
std::thread pump([&]{ while (!stop) { serv.ProcessRequests(); gSystem->Sleep(5); } });
gSystem->Sleep(300);
for (int k = 0; k < N; ++k) // distinct target names -> unbounded leak
http_get(port, Form("/h2/exe.json?method=ProjectionY&name=_s%d&firstxbin=1&lastxbin=1", k));
stop = true; pump.join();
return 0; // AddressSanitizer reports the leak at exit
}
Build & run:
g++ repro.cc $(root-config --cflags --libs) -lRHTTP -lNet -pthread -fsanitize=address -g -o repro
./repro 200
Result (ROOT 6.38.04, Linux x86_64, gcc 15, AddressSanitizer)
Distinct target names _s0 .. _sN → 3·N leaked allocations (one TH1D + its two internal arrays per call), i.e. it scales linearly with the number of requests:
| requests |
leaked |
./repro 20 |
60 allocations, ~186 KB |
./repro 200 |
600 allocations, ~1.86 MB |
With a fixed name (name=_s for every call) it is instead bounded at 3 allocations, because ProjectionY finds and resets the existing same-named histogram.
Leak stack (ASan):
operator new
TStorage::ObjectAlloc
TH2::DoProjection
TClingCallFunc::exec / TMethodCall::Execute (interpreted method call)
TRootSnifferFull::ProduceExe
THttpServer::ProcessRequest
THttpServer::ProcessRequests
Expected
ProduceExe should manage the lifetime of the object returned by the executed method (e.g. delete it after serialization when it owns it and does not keep it reachable in a directory), so repeated exe.json method calls do not leak.
Setup
- ROOT 6.38.04 (system package), Linux x86_64, gcc 15, AddressSanitizer/LeakSanitizer.
Description
TRootSnifferFull::ProduceExe(theexe.jsonhandler) executes a registered object's method through the interpreter and serializes the returned value, but it never deletes the object the method returns. For a method that returns a freshly allocated, owned object — e.g.TH2::ProjectionX/ProjectionY, which returns anew TH1D— everyexe.jsonrequest leaks that result.It is masked when callers reuse the same target name (
ProjectionYresets the existing same-named histogram), but with distinct names (or any method that returns a new owned object on each call) the leak is unbounded.Reproducer
Short, self-contained (THttpServer + a raw-socket HTTP GET, no extra deps):
Build & run:
g++ repro.cc $(root-config --cflags --libs) -lRHTTP -lNet -pthread -fsanitize=address -g -o repro ./repro 200Result (ROOT 6.38.04, Linux x86_64, gcc 15, AddressSanitizer)
Distinct target names
_s0 .. _sN→ 3·N leaked allocations (oneTH1D+ its two internal arrays per call), i.e. it scales linearly with the number of requests:./repro 20./repro 200With a fixed name (
name=_sfor every call) it is instead bounded at 3 allocations, becauseProjectionYfinds and resets the existing same-named histogram.Leak stack (ASan):
Expected
ProduceExeshould manage the lifetime of the object returned by the executed method (e.g. delete it after serialization when it owns it and does not keep it reachable in a directory), so repeatedexe.jsonmethod calls do not leak.Setup