From 011c39af6f9adc26ef2881d97b2df7bc77f3ab7d Mon Sep 17 00:00:00 2001 From: Fish Wang Date: Wed, 28 Oct 2026 01:55:59 +0000 Subject: [PATCH] Fix report serialization with pytest 9.2. pytest 9.2 turned TestReport.nodeid into a property backed by a NodeId object. It is not marshalable, so pytest-forked breaks. - Now serialize_report emits a plain nodeid string. - Crash reports use the call phase; pytest 9.2 prints the phase in the short summary, which exposed the ??? placeholder. - The forked child writes its traceback to the captured stderr file before exiting with "status 3," so the next similar failure is easier to spot. --- a/src/pytest_forked/__init__.py +++ b/src/pytest_forked/__init__.py @@ -13,6 +13,10 @@ # copied from xdist remote def serialize_report(rep): d = rep.__dict__.copy() + # pytest >= 9.2 stores a NodeId object in ``_id`` and exposes ``nodeid`` + # as a property. convert it to a plain string. + if "_id" in d: + d["nodeid"] = str(d.pop("_id")) if hasattr(rep.longrepr, "toterminal"): d["longrepr"] = str(rep.longrepr) else: @@ -97,6 +101,11 @@ def _worker(runforked_fn, stdout_path, stderr_path, retval_path): code = e.code if e.code is not None else 0 os._exit(int(code)) except Exception: + import traceback + + # pytest's capture has restored fd 2 by now; write to the file directly + with open(stderr_path, "a") as f: + traceback.print_exc(file=f) os._exit(EXITSTATUS_EXCEPTION) finally: try: @@ -221,12 +230,10 @@ def report_process_crash(item, result): from _pytest import runner - # pytest >= 4.1 - has_from_call = getattr(runner.CallInfo, "from_call", None) is not None - if has_from_call: - call = runner.CallInfo.from_call(lambda: 0 / 0, "???") - else: - call = runner.CallInfo(lambda: 0 / 0, "???") + # The child died while running the test body, so report it as the + # "call" phase. + # pytest >= 9.2 prints the phase in the short summary; handle it here. + call = runner.CallInfo.from_call(lambda: 0 / 0, "call") call.excinfo = info rep = runner.pytest_runtest_makereport(item, call) if result.out: --- a/testing/test_boxed.py +++ b/testing/test_boxed.py @@ -1,6 +1,10 @@ +import marshal import os import pytest +from _pytest.reports import TestReport + +from pytest_forked import serialize_report needsfork = pytest.mark.skipif(not hasattr(os, "fork"), reason="os.fork required") @@ -119,3 +123,42 @@ def test_other_module(): def test_is_not_boxed_by_default(testdir): config = testdir.parseconfig(testdir.tmpdir) assert not config.option.forked + + +def test_serialize_report_roundtrips_through_marshal(): + rep = TestReport( + "test_x.py::test_y", + ("test_x.py", 1, "test_y"), + {}, + "passed", + None, + "call", + [], + 0.1, + 0.0, + 0.1, + [], + ) + d = marshal.loads(marshal.dumps(serialize_report(rep))) + assert "_id" not in d + rebuilt = TestReport(**d) + assert rebuilt.nodeid == rep.nodeid + assert rebuilt.outcome == "passed" + + +@needsfork +def test_child_exception_traceback_is_reported(testdir): + p1 = testdir.makepyfile( + """ + def test_function(record_property): + record_property("obj", object()) + """ + ) + result = testdir.runpytest(p1, "--forked") + result.stdout.fnmatch_lines( + [ + "*EXITED with status 3*", + "*ValueError: unmarshallable object*", + "*1 failed*", + ] + )