Skip to content

fix: fix issues for rare cases across deadlocking, caching, serialization, GC, and Python frontend - #290

Open
XuehaiPan wants to merge 4 commits into
metaopt:mainfrom
XuehaiPan:fix/correctness-audit
Open

fix: fix issues for rare cases across deadlocking, caching, serialization, GC, and Python frontend#290
XuehaiPan wants to merge 4 commits into
metaopt:mainfrom
XuehaiPan:fix/correctness-audit

Conversation

@XuehaiPan

@XuehaiPan XuehaiPan commented Jul 27, 2026

Copy link
Copy Markdown
Member

Description

A correctness audit of optree, landed as five self-contained commits. They are being pushed one at
a time
so that CI validates each in isolation: much of what they fix is only observable under
free-threading, subinterpreters, debug assertions, or on Windows, so a green run per commit is worth
more than one green run at the end. Each commit compiles standalone with OPTREE_CXX_WERROR=ON, so
the series stays bisectable. See the individual commit messages for details.

Commit Area
fix(registry): correct GIL and registry lock ordering deadlocks
refactor(pytypes): extract WeakKeyCache and make the type caches interpreter-safe caching
fix(treespec): map struct sequence fields by offset and validate pickled state serialization
fix(treespec): correct namespace merging, GC reporting and walker recursion GC, semantics
fix(dataclasses,attrs,accessors,ops): correct field selection and broadcasting Python frontend

Motivation and Context

These are latent correctness bugs rather than reported regressions: thread-safety and memory-safety
issues that surface only under free-threading, subinterpreters, debug builds or crafted input, plus
a few semantic bugs in namespace handling and field selection. Most are invisible on a release build
of a single interpreter, which is why they had not been caught.

Not linked to an existing issue; the changes came out of a systematic audit rather than a bug report.

  • I have raised an issue to propose this change (required for new features and bug fixes)

Types of changes

What types of changes does your code introduce? Put an x in all the boxes that apply:

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds core functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation (update in the documentation)

Implemented Tasks

  • Fix the GIL to registry lock ordering, and read the registry size under a single lock
  • Extract WeakKeyCache and key the type caches by (interpreter_id, address) with per-interpreter cleanup
  • Map struct sequence fields by offset, render unnamed slots as <unnamed@N>, and validate pickled treespec state
  • Reject namespace merges that rebind custom nodes, fix the PyTreeIter GC hooks, and bound walker recursion
  • Compute accessor children from the tree-child fields, guard registration failures, and stop applying is_leaf to internal sentinels

Checklist

Go over all the following points, and put an x in all the boxes that apply.
If you are unsure about any of these, don't hesitate to ask. We are here to help!

  • I have read the CONTRIBUTION guide. (required)
  • My change requires a change to the documentation.
  • I have updated the tests accordingly. (required for a bug fix or a new feature)
  • I have updated the documentation accordingly.
  • I have reformatted the code using make format. (required)
  • I have checked the code using make lint. (required)
  • I have ensured make test pass. (required)
Local verification of the full series
  • pytest tests/: 93860 passed, 5 skipped with all five commits applied.
  • Each of the five commits builds standalone with OPTREE_CXX_WERROR=ON.
  • ruff, pylint (10.00/10), mypy (no issues in 19 source files), clang-format and
    addlicense clean.
  • make doctest cannot complete locally because torch and jax are not installed; the same
    doctests fail identically on main (for example optree/functools.py, untouched here), so CI
    covers them.
  • Platform-gated behaviour in this series (free-threading, subinterpreters, debug assertions,
    Windows stack limits) is not observable on a macOS release build, which is why each commit gets
    its own CI run.

@XuehaiPan XuehaiPan self-assigned this Jul 27, 2026
@XuehaiPan XuehaiPan added bug Something isn't working enhancement New feature or request cxx Something related to the CXX source code python Something related to the Python source code thread-safety Multi-threading issues labels Jul 27, 2026
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (e94c440) to head (2905055).

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #290   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           15        15           
  Lines         1585      1591    +6     
  Branches       212       214    +2     
=========================================
+ Hits          1585      1591    +6     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)
unittests-cp310-Linux 100.00% <100.00%> (ø)
unittests-cp310-Windows 100.00% <100.00%> (ø)
unittests-cp310-macOS 100.00% <100.00%> (ø)
unittests-cp311-Linux 100.00% <100.00%> (ø)
unittests-cp311-Windows 100.00% <100.00%> (ø)
unittests-cp311-macOS 100.00% <100.00%> (ø)
unittests-cp312-Linux 100.00% <100.00%> (ø)
unittests-cp312-Windows 100.00% <100.00%> (ø)
unittests-cp312-macOS 100.00% <100.00%> (ø)
unittests-cp313-Linux 100.00% <100.00%> (ø)
unittests-cp313-Windows 100.00% <100.00%> (ø)
unittests-cp313-macOS 100.00% <100.00%> (ø)
unittests-cp313t-Linux 100.00% <100.00%> (ø)
unittests-cp313t-Windows 100.00% <100.00%> (ø)
unittests-cp313t-macOS 100.00% <100.00%> (ø)
unittests-cp314-Linux 100.00% <100.00%> (ø)
unittests-cp314-Windows 100.00% <100.00%> (ø)
unittests-cp314-macOS 100.00% <100.00%> (ø)
unittests-cp314t-Linux 100.00% <100.00%> (ø)
unittests-cp314t-Windows 100.00% <100.00%> (ø)
unittests-cp314t-macOS 100.00% <100.00%> (ø)
unittests-cp39-Linux 100.00% <100.00%> (ø)
unittests-cp39-Windows 100.00% <100.00%> (ø)
unittests-cp39-macOS 100.00% <100.00%> (ø)
unittests-pp311-Linux 99.53% <45.45%> (-0.47%) ⬇️
unittests-pp311-Windows ?
unittests-pp311-macOS 99.53% <45.45%> (-0.47%) ⬇️
unittests-pydebug 100.00% <100.00%> (ø)
unittests-pydebug-cp310d-Linux ?
unittests-pydebug-cp310d-Windows ?
unittests-pydebug-cp310d-macOS 100.00% <100.00%> (ø)
unittests-pydebug-cp311d-Linux ?
unittests-pydebug-cp311d-Windows ?
unittests-pydebug-cp311d-macOS ?
unittests-pydebug-cp312d-Linux ?
unittests-pydebug-cp312d-Windows ?
unittests-pydebug-cp312d-macOS ?
unittests-pydebug-cp313d-Linux ?
unittests-pydebug-cp313d-Windows ?
unittests-pydebug-cp313d-macOS ?
unittests-pydebug-cp313td-Linux ?
unittests-pydebug-cp313td-Windows ?
unittests-pydebug-cp313td-macOS ?
unittests-pydebug-cp314d-Linux 100.00% <100.00%> (ø)
unittests-pydebug-cp314d-Windows ?
unittests-pydebug-cp314d-macOS 100.00% <100.00%> (ø)
unittests-pydebug-cp314td-Linux ?
unittests-pydebug-cp314td-Windows ?
unittests-pydebug-cp314td-macOS 100.00% <100.00%> (ø)
unittests-pydebug-cp39d-Linux 100.00% <100.00%> (ø)
unittests-pydebug-cp39d-Windows ?
unittests-pydebug-cp39d-macOS ?

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@XuehaiPan
XuehaiPan force-pushed the fix/correctness-audit branch 2 times, most recently from 01e1568 to df646e0 Compare July 27, 2026 17:43
Several registry entry points released the GIL while holding `sm_mutex`, which
inverts the GIL to `sm_mutex` order against a concurrent flatten that holds the
GIL and waits on the read lock, wedging the interpreter.

- `Register` and `Unregister` ran the namedtuple / PyStructSequence classification
  and `PyErr_WarnEx` under the write lock. Both run Python and can release the
  GIL. Classify and warn before taking the lock; emitting the override warning
  before any insert also keeps registration atomic under warnings-as-errors.
- `Lookup`, `Register`, `Unregister` and `GetRegistrySize` called `GetSingleton()`
  under the lock. Under `per_interpreter_gil` that releases the GIL once any
  subinterpreter has imported the module. Acquire the singletons first.
- `FlattenInto` read-locked `sm_dict_order_mutex` and then called
  `IsDictInsertionOrdered`, which locks it again. `std::shared_mutex` is not
  recursive, so the nested shared acquisition is undefined behaviour and deadlocks
  under writer preference. Add `GetDictInsertionOrderedFlags`, which returns both
  flags from one lock, and make `IsDictInsertionOrdered` delegate to it.
- `GetRegistrySize` took `Size()` twice, dropping the lock between the two reads,
  so a concurrent registration could slip in and trip the consistency check. Split
  out `SizeImpl` and read both registries under a single lock.

`RegisterImpl` and `UnregisterImpl` no longer need to be templated on `NoneIsLeaf`
now that the callers hold both singletons, so they become plain member functions.
…rpreter-safe

The namedtuple classification, PyStructSequence classification and struct sequence
field-name caches each hand-rolled the same cache, weakref and locking dance.
Extract one `WeakKeyCache` class and route all three through it, then fix the
subinterpreter hazards in one place.

Entries are keyed by `(interpreter_id, object address)` rather than the address
alone. The address can be shared across interpreters, since a type like `int` is
immortal and lives at the same address in each, while a computed reference value
belongs to the interpreter that produced it. An address-only key would hand that
value to another interpreter, which could use it after the owner freed it on
finalization.

A per-entry weakref evicts an entry when its key is collected, so a later key that
reuses the freed address cannot read a stale value. A per-interpreter `atexit`
callback clears that interpreter's entries, covering what the weakref cannot: an
immortal key is never collected, and interpreter ids restart from 0 after a
`Py_Finalize` and `Py_Initialize` cycle, so a fresh interpreter must not inherit a
finalized one's entries.

The lookup also releases the read lock and re-acquires the GIL in that order
before touching the borrowed value, so the GIL is never re-acquired while the lock
is held, which would invert the order against the weakref eviction callback.

`hashing.h` gains generic `std::equal_to` and `std::not_equal_to` for
`std::pair<T, U>`, mirroring the `std::hash` specialization already there, so the
pair key works without a third copy of the element-wise comparison.
…led state

Both areas run through `serialization.cpp` and the struct sequence helpers.

`tp_members` lists only the named fields, so indexing it by position mislabelled
every slot after the first unnamed one: `os.stat_result` slots 7, 8 and 9 were
reported as `st_atime`, `st_mtime` and `st_ctime`. Map by the byte offset each
member carries instead, and fill the remaining slots with the unnamed marker.
`structseq_fields` is fixed the same way, the marker is exported as
`PyStructSequence_UnnamedField`, the repr renders an unnamed slot as
`<unnamed@N>`, and `StructSequenceEntry.codify` emits index access for a slot
whose name is not an identifier. `PyStructSequenceUnnamedField()` works around the
symbol being unexported before 3.11.0a2, where referencing it broke the import.

`FromPicklable` trusted its input, so a crafted pickle could build a spec that
later read out of bounds or aborted in repr. Validate the kind before the
narrowing cast, the arity against the children the traversal provides, node data
on childless kinds, dict key count and distinctness, defaultdict shape, deque
`maxlen`, namedtuple and struct sequence arity, and the whole traversal rather
than only its last node. `ToPicklable` copies the node's mutable containers so the
state cannot alias the spec, and `__reduce__` makes protocols 0 and 1 reconstruct
through `cls.__new__(cls)` instead of aborting.
…ursion

Namespace merging. `compose`, `broadcast_to_common_suffix`, `transform` and
`treespec_from_collection` let an empty namespace adopt the other side's
namespace. If a custom node resolved globally but resolves to a *different*
registration under the adopted namespace, the result claimed the namespace while
carrying the wrong registration. Add `FindReregisteredCustomType` and reject those
merges; a type registered only globally still resolves by fallback and is allowed.
The dict constructors also dropped the namespace, losing insertion-ordered key
order, and `MakeFromCollection` ignored the return value of `PyErr_WarnEx`, so an
escalated warning surfaced as a confusing `SystemError`.

GC. `PyTreeIter` never visited or cleared its leaf predicate, so a cycle through
that callback leaked. `PyTreeSpec` reports the registration's members only when
its node is their sole owner: the registration holds one reference to each member
however many nodes point at it, so reporting while shared decrements the same
object once per node and underflows the collector's shadow refcount, which is
fatal on debug builds.

Recursion. `PathsImpl`, `AccessorsImpl` and `BroadcastToCommonSuffixImpl` recurse
once per tree level, so a deeply nested spec overflowed the native stack and
crashed instead of raising. Thread a depth through them and raise `RecursionError`
at `MAX_RECURSION_DEPTH`, which is lowered further for debug builds on Windows,
where the frames are large and the throw still has to unwind them.

Also fixes `IsPrefix` snapshotting a dict node's subtree from the pristine
traversal rather than the working copy (a processed ancestor may have relocated
it), `broadcast_to_common_suffix` sorting the argument spec's live key list in
place while building an error message, custom node entries being dropped when
broadcasting, and `Transform` reading pending counts after `pop_back`.
@XuehaiPan
XuehaiPan force-pushed the fix/correctness-audit branch from 0a46312 to 2905055 Compare July 28, 2026 09:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working cxx Something related to the CXX source code enhancement New feature or request python Something related to the Python source code thread-safety Multi-threading issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant