Skip to content

[v640] Fixes and unit test for Python exit-time crash issue #23728 - #23851

Open
guitargeek wants to merge 4 commits into
root-project:v6-40-00-patchesfrom
guitargeek:v6-40-fix-23728
Open

guitargeek wants to merge 4 commits into
root-project:v6-40-00-patchesfrom
guitargeek:v6-40-fix-23728

Conversation

@guitargeek

Copy link
Copy Markdown
Contributor

This is backporting targeted changes from master and adding a regression test.

A test run without the fixes is launched in #23850.

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Test Results

    23 files      23 suites   3d 12h 7m 10s ⏱️
 3 878 tests  3 878 ✅ 0 💤 0 ❌
81 165 runs  81 165 ✅ 0 💤 0 ❌

Results for commit 07f41e6.

♻️ This comment has been updated with latest results.

The JIT-compiled call and destructor wrappers were cached in function-local
static maps in make_wrapper() and make_dtor_wrapper(). The destructor of such
a static is registered with the runtime at its first use, and C++ runs these
destructors in reverse order of registration. When the first use happened
after ROOT registered its atexit handlers, the cache was already destroyed by
the time TROOT::EndOfProcessCleanups() ran, and deleting an object of a class
without compiled dictionary from there (e.g. the object held by a TBranchElement
of a dictionary-less collection) called find()/insert() on a dead std::map:

  std::map<...>::insert (this=<make_dtor_wrapper::gDtorWrapperStore>)
  CppImpl::(anonymous namespace)::make_dtor_wrapper
  CppImpl::Destruct
  TClingCallFunc::ExecDestructor
  TClingClassInfo::Delete
  TBranchElement::ReleaseObject
  ...
  TROOT::EndOfProcessCleanups

In PyROOT this is what happens as soon as the script constructs and drops
any object of a class without compiled dictionary (for example a new template
instantiation such as std::vector<TNamed>): its destructor is the first use
of the cache, well after `import ROOT`.

Store the caches in InterpreterInfo instead. The interpreter registry is an
llvm::ManagedStatic, so the caches now live as long as the interpreter they
belong to, and are also freed together with it.

This is a backport of upstream CppInterOp commit b8ae9f666c ("Move wrapper
caches from function-local statics to per-interpreter storage"), which ROOT
master received with the CppInterOp upgrade in c8626da.

Fixes root-project#23728

Assisted-by: claude-code:claude-fable-5-1
Cpp::Destruct takes (object, scope, withFree, count), but
TClingCallFunc::ExecDestructor passed (nary, withFree). Through the implicit
conversions, TClingClassInfo::Delete (nary=0, withFree=true) ran the
destructor but never freed the memory, so every object of a class without
compiled dictionary deleted through TClass::Destructor leaked, and
TClingClassInfo::DeleteArray (nary=1, withFree=true) used `delete` instead of
`delete[]`.

Measured with 180 interpreted objects of 1 MiB created with TClass::New and
deleted with TClass::Destructor: RSS grew by 179 MiB before, 0 MiB after.

Master has the right order since 17c57aa.

Assisted-by: claude-code:claude-fable-5-1
…oject#23728

Reads a tree with a branch of a dictionary-less collection in a fresh Python
process, lets TROOT::EndOfProcessCleanups delete it at exit, and constructs
another dictionary-less object before exiting. This crashed in the
JIT-compiled destructor lookup before the CppInterOp upgrade.

The child process runs with MALLOC_PERTURB_ set, because a use of destroyed
objects only crashes reliably when freed memory is poisoned, and with
ROOT_OBJECT_AUTO_REGISTRATION=1 plus explicit checks that the file is
registered with gROOT and owns the tree, so the test keeps exercising the
scenario when object auto-registration is disabled.

Assisted-by: claude-code:claude-fable-5-1
(cherry picked from commit a41307c)
…vely

The test left the destructor of the branch class uncompiled until exit,
so that cling had to JIT-compile it from TROOT::EndOfProcessCleanups().
On Windows, that cleanup runs from libCore's DLL_PROCESS_DETACH, after
libCling's statics have already been torn down, so compiling anything
there crashes with or without the fix (the no-fix run in root-project#23850 fails
identically). Pre-compile the exact destructor instead: the exit-time
call is then a pure cache lookup, which is the regular exit path on
Windows. The bug was in that lookup on the destroyed function-local
static, so the test still fails without the fix.

Write the branch through the overload taking the class name and the
address. The templated Branch() that the pythonization on master calls
refuses collections without a compiled CollectionProxy, which silently
left the tree without the branch and the test without its purpose.

Also give the child process a timeout: without the fix, ROOT's crash
handler can hang instead of terminating the process.

Assisted-by: claude-code:claude-fable-5-1

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant