Skip to content

Release Module native caches on device close to fix nightly LSan leak - #1131

Draft
jkiviluoto-nv wants to merge 1 commit into
shader-slang:mainfrom
jkiviluoto-nv:fix-lsan-leak-1130
Draft

Release Module native caches on device close to fix nightly LSan leak#1131
jkiviluoto-nv wants to merge 1 commit into
shader-slang:mainfrom
jkiviluoto-nv:fix-lsan-leak-1130

Conversation

@jkiviluoto-nv

Copy link
Copy Markdown
Collaborator

Summary

Fixes the deterministic LeakSanitizer finding reported in #1130: every scheduled sanitizers.yml run on the Linux asan-ubsan leg has failed since the workflow was introduced, on a NativeBoundCallRuntime::args setter allocation (std::vector<sgl::ref<NativeBoundVariableRuntime>>).

Root cause

NativeCallDataCache, NativeCallData, NativeBoundCallRuntime, and NativeBoundVariableRuntime all derive from sgl::Object, which is bound to Python via nanobind's nb::intrusive_ptr mechanism (src/slangpy_ext/core/object.cpp). That mechanism uses plain Py_INCREF/Py_DECREF once an instance is owned by Python — there is no tp_traverse/tp_clear, so these objects are invisible to Python's cyclic GC.

slangpy.core.module.Module (pure Python) owns call_data_cache (a CallDataCache/NativeCallDataCache subclass) and a strong _attr_cache: dict[str, Function]. Calling a Function populates call_data_cache with a NativeCallData, which holds runtime() — a NativeBoundCallRuntime whose m_args/m_kwargs are exactly the leaked NativeBoundVariableRuntime trees.

Nothing currently ties a Module's lifetime, or its call_data_cache, to Device.close(). Device::close() is documented to "remove all cyclic references that might prevent the device from being destroyed," and is invoked for every open device via the atexit-registered Device::close_all_devices() (src/slangpy_ext/slangpy_ext.cpp). But that path is pure C++ and has no visibility into the Python-level Module wrapper or its caches. If any Module stays alive until interpreter shutdown (e.g. referenced from the module-level LOADED_MODULES weak dict combined with some other live reference, or a retained pytest traceback), its whole call_data_cache tree stays reachable-but-unreleased at process exit — which LSan reports as a leak even though it's just an unreleased cache, not a genuine leak.

Fix

Module already has a proven pattern for releasing these caches: on_hot_reload() replaces call_data_cache with a fresh CallDataCache() and clears _attr_cache, dispatch_data_cache, pipeline_cache, and shader_table_cache. This PR extracts that into Module._release_native_caches() and also calls it from a new device.register_device_close_callback hook, registered alongside the existing hot-reload hook in _register_hot_reload_hook. Device-close callbacks run "at start of device close," before the device tears down, so replacing these caches is safe (nothing is dereferenced, only re-pointed at empty containers).

This means every path that closes a device — explicit device.close() and the atexit-driven close_all_devices() alike — now also releases every live Module's native cache tree, matching Device::close()'s documented purpose.

Verification

I was unable to get a working ASan+UBSan LeakSanitizer repro locally (clang-14's LD_PRELOAD combination for ASan+UBSan is broken via a sigaction interceptor bug, clang-18's apt.llvm.org build has an internal UBSan ABI mismatch, and clang-19 built and linked cleanly but import slangpy spun in a nanothread/ASan interceptor-related sched_yield loop that looked unrelated to this leak). This fix is therefore based on static analysis of the reference-counting/ownership chain, not a live before/after LSan comparison — the sanitizers.yml nightly run is the actual verification.

NativeCallDataCache/NativeBoundCallRuntime derive from sgl::Object,
which uses plain Py_INCREF/Py_DECREF once owned by Python with no
tp_traverse/tp_clear. A Module that stays alive past a test (e.g. via
LOADED_MODULES or a retained traceback) keeps its whole
call_data_cache tree reachable until process exit, which LeakSanitizer
reports as a direct leak in NativeBoundCallRuntime::set_args even
though it is just an unreleased cache, not a true leak.

Device::close() is documented to remove cyclic references that would
otherwise prevent cleanup, and is invoked for every open device by the
atexit-registered close_all_devices() call - but it has no visibility
into the Python-level Module wrapper or its caches. Reuse the same
device-close-callback mechanism already used for hot reload to release
each Module's call_data_cache and related caches when its device
closes, mirroring the reset already done in on_hot_reload.

Fixes shader-slang#1130
@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@jkiviluoto-nv

Copy link
Copy Markdown
Collaborator Author

The Windows Release build failure (build (windows, x86_64, msvc, Release, 3.10)) is a pre-existing flake unrelated to this change: sgl_tests reports 266 | 266 passed | 0 failed | 5 skipped and Status: SUCCESS!, but the process still exits non-zero. The same job failed identically on main at commit e59f6d3a (2026-08-24), before this PR existed. This PR only touches slangpy/core/module.py (Python), which has no effect on the sgl_tests C++ binary's exit code.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants