Skip to content

Keep the Python error when it was set without a value - #159

Merged
quantconnect-kronos[bot] merged 1 commit into
masterfrom
kronos/bug-exception-without-value
Nov 11, 2026
Merged

quantconnect-kronos[bot] merged 1 commit into
masterfrom
kronos/bug-exception-without-value

Conversation

@quantconnect-kronos

Copy link
Copy Markdown

What changed and why

CPython lets an error be set with a type but no value: PyErr_SetNone does 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 builtin next() raising StopIteration on Python 3.11.

PythonException.FetchCurrentOrNull only checked the type for null and then called value.Borrow(), which throws NullReferenceException on 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 only Object reference not set to an instance of an object from NewReference.cs.

The fix, in src/runtime/PythonException.cs:

  • the copy passed to PyErr_NormalizeException is made from value.BorrowNullable() (PyErr_NormalizeException accepts a NULL value and creates the instance);
  • when the fetched value is NULL, the normalized value is passed as valRef to FromPyErr, which requires one. When the fetched value is set, everything works as before.

Upstream pythonnet doesn't have this problem: it normalizes value in place before FromPyErr.

Tests

  • New TestPythonException.TestErrorSetWithoutValue: sets ValueError with no value (what PyErr_SetNone does), then checks that ThrowLastAsClrException throws a PythonException of type ValueError and clears the error indicator. Before the fix it fails with the same NullReferenceException at NewReference.Borrow ← PythonException.FetchCurrentOrNull; with the fix it passes.
  • Ran CI's suites on Python 3.11:
    • dotnet test --runtime any-x64 src/embed_tests/ --filter "FullyQualifiedName!~SetPythonPath": Passed 1039, Skipped 8, Failed 0
    • dotnet test --runtime any-x64 src/embed_tests/ --filter "FullyQualifiedName~SetPythonPath": Passed 1
    • pytest --runtime netcore tests: 485 passed, 23 skipped
    • dotnet test --runtime any-x64 src/python_tests_runner/: Passed 2
  • Tested end to end with a Lean Python algorithm that calls next(iter([])) in on_data. Before the fix the backtest's runtime error was only Object 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

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>
@quantconnect-kronos quantconnect-kronos Bot added kronos:develop:working develop: in progress kronos:review Request review labels Nov 11, 2026
@quantconnect-kronos-reviewer quantconnect-kronos-reviewer Bot added kronos:review:queued review: queued kronos:review:working review: in progress and removed kronos:review Request review kronos:review:queued review: queued labels Nov 11, 2026
@quantconnect-kronos-reviewer
quantconnect-kronos-reviewer Bot marked this pull request as ready for review November 11, 2026 14:12

@quantconnect-kronos-reviewer quantconnect-kronos-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approving. The fix is minimal and correct, and the test reproduces the bug.

What I checked

  • The fix. NewReference(BorrowedReference, canBeNull: true) increfs through XIncref, which accepts NULL, so the copy is safe when the value is NULL. PyErr_NormalizeException accepts a NULL value and creates the instance. FromPyErr takes its own reference to valRef (new PyObject(valRef)), so passing normalizedValue.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 (ToPyErrArgs uses FromNullableReference).
  • The test, both ways, on Python 3.11. On master, TestErrorSetWithoutValue fails with NullReferenceException at NewReference.Borrow ← PythonException.FetchCurrentOrNull. With this PR it passes. The rest of TestPythonException passes too.
  • The reported scenario. I ran a Python function that does next(iter([])) and called it from .NET. On master that raises NullReferenceException: Object reference not set to an instance of an object. With this PR it raises a PythonException of type StopIteration. 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 and TryConvert), ClassBase and FetchCurrentOrNullRaw all 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-tests and lean-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.

@quantconnect-kronos-reviewer quantconnect-kronos-reviewer Bot added risk:low Low risk and removed kronos:review:working review: in progress labels Nov 11, 2026
@quantconnect-kronos quantconnect-kronos Bot added the kronos:review Request review label Nov 11, 2026
@quantconnect-kronos-reviewer quantconnect-kronos-reviewer Bot added kronos:review:queued review: queued and removed kronos:review Request review labels Nov 11, 2026
@quantconnect-kronos-reviewer-2 quantconnect-kronos-reviewer-2 Bot added kronos:review:working review: in progress and removed kronos:review:queued review: queued labels Nov 11, 2026

@quantconnect-kronos-reviewer-2 quantconnect-kronos-reviewer-2 Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 TestErrorSetWithoutValue fails against master with NullReferenceException at NewReference.Borrow / FetchCurrentOrNull, and passes with this patch.
  • All 14 TestPythonException tests 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 isolated SetPythonPath run passes its one test.
  • Calling Python's next(iter([])) from .NET produces NullReferenceException on master and a normalized PythonException of type StopIteration with this patch; the Python error indicator is clear afterward.
  • Tested end to end: an algorithm calling next(iter([])) in on_data reports 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.

@quantconnect-kronos-reviewer-2 quantconnect-kronos-reviewer-2 Bot removed the kronos:review:working review: in progress label Nov 11, 2026
@quantconnect-kronos
quantconnect-kronos Bot merged commit ad5410a into master Nov 11, 2026
9 checks passed
@quantconnect-kronos
quantconnect-kronos Bot deleted the kronos/bug-exception-without-value branch November 11, 2026 14:29
@quantconnect-kronos quantconnect-kronos Bot removed the kronos:develop:working develop: in progress label Nov 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:low Low risk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants