Skip to content

BUG: avoid possible stack overflow in arraydescr_dealloc - #32133

Merged
ngoldbaum merged 4 commits into
numpy:mainfrom
ngoldbaum:fix-descr-dealloc-overflow
Jul 30, 2026
Merged

BUG: avoid possible stack overflow in arraydescr_dealloc#32133
ngoldbaum merged 4 commits into
numpy:mainfrom
ngoldbaum:fix-descr-dealloc-overflow

Conversation

@ngoldbaum

@ngoldbaum ngoldbaum commented Jul 28, 2026

Copy link
Copy Markdown
Member

PR summary

The Py_DECREF(lself->subarray->base) we currently have can lead to situations where the DECREF causes a deallocation, which then recursively calls into arraydescr_dealloc. With a properly setup dtype, this can lead to a stack overflow on newer Python versions. See python/cpython#142253 for the upstream issue that was opened about that and #30370 on the NumPy side.

My fix is to check for cases when decrefing will deallocate and in those cases explicitly detach the base before decrefing, avoiding the recursive call.

AI Disclosure

I used an AI model to understand the problem and debug the fix and new test.

PyArray_free(lself->subarray);
while (base != NULL && Py_REFCNT(base) == 1 && PyDataType_HASSUBARRAY(base)) {
_PyArray_LegacyDescr *lbase = (_PyArray_LegacyDescr *)base;
base = lbase->subarray->base;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use Py_CLEAR instead.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that's right. I'm stealing the reference without deallocating immediately on purpose.

I could INCREF then Py_CLEAR to avoid that but that adds an unnecessary incref. Would you prefer that for clarity? I can also add a comment explaining that I'm setting the point to NULL and explicitly not deallocating yet.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CLEAR macro does exactly that, though.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would DECREF base, not lbase. I added a comment explaining the reference stealing so hopefully it's clearer.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, nvm. I thought the concern was Py_CLEAR not being careful enough, but this isn't a Py_CLEAR pattern at all.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I missed this as well. thanks for adding the comment

*/
PyArray_Descr *base = lself->subarray->base;
PyArray_free(lself->subarray);
while (base != NULL && Py_REFCNT(base) == 1 && PyDataType_HASSUBARRAY(base)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite sure, but the Py_REFCNT doesn't smell strictly thread-safe to me (if true maybe you are OK with ignoring that, but considering that this is a niche failure)?

Just enabling GC tracking feels reasonable to me (we should do this more anyway, maybe even for arrays, I even did once but that PR just withered away), but I am not sure about implications... dtypes are practically immutable (unless metadata is an actual dict as that could contain mutable objects), though, you'd want to untrack them mostly.
(We can do that now, although I think the NumPy internal singletons may still be static structs.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the Py_REFCNT==1, I looked at using PyUnstable_Object_IsUniquelyReferenced instead but that is actually strictly worse because it also has a check that the current thread is the owning thread and it's possible (if weird) for a remote thread to call the deallocator.

The Py_REFCNT==1 check is fine because this is in a deallocator, the base array is not exposed to Python, and because dtypes aren't GC-tracked and don't support weakrefs. All of those facts combine to mean that it's not possible to observe Py_REFCNT==1 here and not be genuinely the only possible reference.

We could make dtypes be GC types and add trashcan tracking here but we would also have to figure out how to manage a migration story for user dtypes and their deallocators, which would need to be updated. We would also need to figure out how to create all the statically initialized singletons like LONG_Descr. All do-able but a much bigger -- and I'd argue riskier -- change. Also not backportable like this PR.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the base array is not exposed to Python

That part isn't strictly true, though?

import numpy as np
d = np.dtype("f", metadata={"a": 3})  # just to have a unique one.
d1 = np.dtype((d, 234))
d2 = np.dtype((d1, 235))
assert d2.base is d1
assert d1.base is d

The same thing can probably also happen for fields, fields that get cleared could recurse just the same.

All do-able but a much bigger -- and I'd argue riskier -- change. Also not backportable like this PR.

Yap, agreed, should be doable, but probably tedious.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, I overstated my case. The thing that matters here is because we're in a deallocator and there's no gc tracking and no weakref support, it's not possible to observe a refcnt of 1 if there are external references, even on remote threads.

Your example is like the held variable in the test I added.

For fields, you're right that they can create recursion, but all the objects in the fields dict are types that CPython handles via the trashcan mechanism. The subarray field has to deal with the statically defined dtype types.

If we do migrate dtypes to heap types (maybe a numpy 3.0 thing along with some of the issues @prathamhole14 is identifying with static data in the C API), we'd need to update this code path to use the trashcan macros. I'll try to add comments so future grep searches find it.

@seberg seberg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should apply Kumar's suggestion to use CLEAR, CLEAR uses the right pattern.
But otherwise fine to merge I think.

I agree now that it seems safe (if confusing...). Feels all unfortunate and bit of code smell.
Seems like we should mark them as GC allocated even if we untrack them always (when there is no metadata). I don't think the static instances matter for that? Because those must be immortal anyway (you can't free them after all)!
But even then, I'll believe that is annoying to just do, so...

PyArray_free(lself->subarray);
while (base != NULL && Py_REFCNT(base) == 1 && PyDataType_HASSUBARRAY(base)) {
_PyArray_LegacyDescr *lbase = (_PyArray_LegacyDescr *)base;
base = lbase->subarray->base;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CLEAR macro does exactly that, though.

Comment thread numpy/_core/tests/test_dtype.py Outdated
t.start()
t.join()
""")
run_subprocess([sys.executable, "-c", script], timeout=180)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really see the point of the subprocess. This seems much like the previous one (but yes won't fail due to recursion).
But the previous test already segfaults without the fix? Although I guess this one may segfault on more platforms :).

@ngoldbaum
ngoldbaum force-pushed the fix-descr-dealloc-overflow branch from 366db47 to 0c06127 Compare July 29, 2026 20:22
@ngoldbaum
ngoldbaum force-pushed the fix-descr-dealloc-overflow branch from 0c06127 to fbf433f Compare July 29, 2026 20:34
@ngoldbaum
ngoldbaum requested a review from kumaraditya303 July 29, 2026 21:27
@ngoldbaum
ngoldbaum merged commit 5d011d8 into numpy:main Jul 30, 2026
91 checks passed
@charris charris removed the 09 - Backport-Candidate PRs tagged should be backported label Jul 31, 2026
charris added a commit that referenced this pull request Jul 31, 2026
BUG: avoid possible stack overflow in arraydescr_dealloc (#32133)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants