From 011c39af6f9adc26ef2881d97b2df7bc77f3ab7d Mon Sep 17 00:00:00 2001
From: Fish Wang <fishw@asu.edu>
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*",
+        ]
+    )
