Repository navigation
Keep the Python error when it was set without a value - #159
Conversation
An error set with a type but no value, as PyErr_SetNone does (CPython's own next() among others, before 3.12), made FetchCurrentOrNull throw a NullReferenceException while converting it, losing the Python error. The value now comes from normalizing the error when it is missing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Approving. The fix is minimal and correct, and the test reproduces the bug.
What I checked
- The fix.
NewReference(BorrowedReference, canBeNull: true)increfs throughXIncref, which accepts NULL, so the copy is safe when the value is NULL.PyErr_NormalizeExceptionaccepts a NULL value and creates the instance.FromPyErrtakes its own reference tovalRef(new PyObject(valRef)), so passingnormalizedValue.Borrow()there is safe. When the fetched value is set, the code does exactly what it did before.TryDecodePyErr, which runs earlier with the raw value, already handled NULL (ToPyErrArgsusesFromNullableReference). - The test, both ways, on Python 3.11. On master,
TestErrorSetWithoutValuefails withNullReferenceExceptionatNewReference.Borrow←PythonException.FetchCurrentOrNull. With this PR it passes. The rest ofTestPythonExceptionpasses too. - The reported scenario. I ran a Python function that does
next(iter([]))and called it from .NET. On master that raisesNullReferenceException: Object reference not set to an instance of an object.With this PR it raises aPythonExceptionof typeStopIteration. CPython 3.12+ normalizes the error eagerly, so only 3.8–3.11 hit the bug, and CI runs the test on all of them. - Other callers of
PyErr_Fetch.Converter.SetConversionError,Finalizer,PyObject(dispose andTryConvert),ClassBaseandFetchCurrentOrNullRawall handle a NULL value already. Only this path had the bug. - CI. All checks pass: Build and Test on 3.8–3.14,
lean-python-unit-testsandlean-python-regression. - Confidentiality. Nothing private in the diff, the commit, the branch name or the description.
Not caused by this PR: normalizedValue is never disposed, so every conversion leaks one reference to the normalized exception. That happens on master as well, for errors set with a value. I filed it as #160, and it doesn't block this change.
Other repositories: Lean needs no code change. It picks this fix up when it moves to the next pythonnet package version.
Risk: I set risk:low. The change is three lines and easy to revert. It only changes a path that used to throw NullReferenceException, and every path that works today behaves as before. The evidence: a test that fails without the fix, green CI including Lean's Python suites, and the end-to-end run in the description.
There was a problem hiding this comment.
Approving fb1a4ad55d3d6945fa3d671472659a55ab210ffb. No blocking issues introduced by this change.
The NULL-safe copy lets CPython normalize a missing value, and the fallback passes the resulting exception instance to FromPyErr. That method acquires its own reference, so disposing the fetched references does not invalidate the returned exception. The non-NULL path remains unchanged; the initial error decoder already accepts a missing value.
Independent verification (Python 3.11):
- The new
TestErrorSetWithoutValuefails against master withNullReferenceExceptionatNewReference.Borrow/FetchCurrentOrNull, and passes with this patch. - All 14
TestPythonExceptiontests pass. The full embedding run,dotnet test --runtime any-x64 src/embed_tests/ --filter "FullyQualifiedName!~SetPythonPath", passes 1,039 tests, skips 8, and fails none. The isolatedSetPythonPathrun passes its one test. - Calling Python's
next(iter([]))from .NET producesNullReferenceExceptionon master and a normalizedPythonExceptionof typeStopIterationwith this patch; the Python error indicator is clear afterward. - Tested end to end: an algorithm calling
next(iter([]))inon_datareports the Python source location (main.py: line 11) rather than the internal null-reference error. - All nine CI checks pass, including Python 3.8–3.14 and both Lean Python suites. No private information found in the change or its public metadata/discussion.
Existing defects, not blockers: I reproduced the normalized-value reference leak on both master and this head (refcount 3 → 103 after 100 conversions); it is already tracked in #160. I also reproduced the same replacement-type/original-value mismatch on both revisions when an exception constructor fails, and filed #161 separately.
Consumers: Lean needs no API or source adaptation. Its pinned pythonnet package references need advancing after a release containing this fix.
Risk: I retained risk:low. This is a small, reversible fix confined to a previously failing NULL-value path, with a regression test that fails without it, green CI, and independently verified end-to-end evidence.
What changed and why
CPython lets an error be set with a type but no value:
PyErr_SetNonedoes that, and before 3.12 the value stays NULL until the error is normalized. C extensions use it, and so does CPython itself, e.g. the builtinnext()raisingStopIterationon Python 3.11.PythonException.FetchCurrentOrNullonly checked the type for null and then calledvalue.Borrow(), which throwsNullReferenceExceptionon a NULL value (NewReference.cs,Borrow). So converting such an error into a CLR exception crashed, and the original Python error was lost: callers saw onlyObject reference not set to an instance of an objectfromNewReference.cs.The fix, in
src/runtime/PythonException.cs:PyErr_NormalizeExceptionis made fromvalue.BorrowNullable()(PyErr_NormalizeExceptionaccepts a NULL value and creates the instance);valReftoFromPyErr, which requires one. When the fetched value is set, everything works as before.Upstream pythonnet doesn't have this problem: it normalizes
valuein place beforeFromPyErr.Tests
TestPythonException.TestErrorSetWithoutValue: setsValueErrorwith no value (whatPyErr_SetNonedoes), then checks thatThrowLastAsClrExceptionthrows aPythonExceptionof typeValueErrorand clears the error indicator. Before the fix it fails with the sameNullReferenceExceptionatNewReference.Borrow←PythonException.FetchCurrentOrNull; with the fix it passes.dotnet test --runtime any-x64 src/embed_tests/ --filter "FullyQualifiedName!~SetPythonPath": Passed 1039, Skipped 8, Failed 0dotnet test --runtime any-x64 src/embed_tests/ --filter "FullyQualifiedName~SetPythonPath": Passed 1pytest --runtime netcore tests: 485 passed, 23 skippeddotnet test --runtime any-x64 src/python_tests_runner/: Passed 2next(iter([]))inon_data. Before the fix the backtest's runtime error was onlyObject reference not set to an instance of an object. in NewReference.cs:line 170. With the fix it reports the user's own error:at on_data next(iter([])) in main.py: line 11.The Lean Python unit and regression test workflows run in this PR's CI.
🤖 Generated with Claude Code