Skip to content

Replace dill with validated JSON for pytest error reports - #427

Merged
markdorison merged 1 commit into
mainfrom
pytest-json-error-reports
Sep 18, 2026
Merged

markdorison merged 1 commit into
mainfrom
pytest-json-error-reports

Conversation

@markdorison

Copy link
Copy Markdown
Contributor

What

  • ciqueue.pytest workers now store each failing or skipped phase's final TestReport as compressed JSON, via pytest's public pytest_report_to_serializable hook (the same path pytest-xdist uses).
  • ciqueue.pytest_report validates every record (identity, phase, outcome, shape) and reconstructs it with pytest_report_from_serializable. Anything else, including legacy dill blobs, fails the report job with exit 4 rather than being skipped or treated as a pass.
  • dill and tblib are removed, ciqueue/_pytest/outcomes.py is deleted, and the pytest floor is now >=6.2.5.

Why

The reporter called dill.loads on every value in the build:<id>:error-reports Redis hash. A client with write access to the build namespace could store a pickle whose __reduce__ ran code in the reporting job (CWE-502). The pickle was load-bearing: workers shipped live ExceptionInfo and traceback objects so the reporter could re-render failures. So the fix is a wire-format change, not a check in front of the sink.

Behaviour changes

  • Wire format break. Workers and the reporter must run the same ciqueue version within a build; use a fresh build ID after upgrading. Mixed versions fail loudly at collection.
  • Traceback style (--tb, --showlocals) is now decided by the worker's flags. The reporter no longer needs the source checkout to render frames.
  • Strict-xfail XPASS was previously lost on the wire and is now reported as failed. xfail-failed tests no longer consume a requeue.
  • record_property values are stringified on the wire, matching what JUnit already emits.

Testing

Tests cover: rejection of an executable pickle record with no side effect and a non-zero exit; malformed and invalid envelopes; records that would fail open (mismatched nodeid, wasxfail on a failed outcome, contradictory setup plus call); xfail, strict XPASS and dynamic xfail across worker to reporter including JUnit; retried tests whose teardown calls pytest.xfail; record_property with a non-JSON value through both plugins. Existing integration counts, skip locations, WILL_RETRY and JUnit assertions are unchanged.

The suite (35 tests) passes on Python 3.10 through 3.14 with current pytest, and on pytest 6.2.5, 7.4.4 and 8.4.2.

Out of scope

The Ruby runner has the same class of sink (Marshal.load in ci/queue/redis/build_record.rb, ErrorReport.coder = Marshal in minitest and rspec). Tracked separately.

The report plugin loaded every value in the build:<id>:error-reports
Redis hash with dill.loads. Any client with write access to the build
namespace could store a pickle whose __reduce__ ran arbitrary code in
the reporting job (CWE-502). zlib.decompress was the only step between
Redis and the deserializer.

Pickling was load-bearing: workers stored CallInfo.__dict__ with a live
ExceptionInfo and traceback so the reporter could re-render the failure
via pytest's default makereport. That is also why tblib and the whole
_pytest/outcomes.py swap dance existed.

Ship the rendered report instead. Workers now serialize the final
TestReport for each non-passing phase through pytest's public
pytest_report_to_serializable hook (the same path pytest-xdist uses)
into compressed JSON. The reporter validates shape, identity and
outcome before reconstructing via pytest_report_from_serializable and
replays it from the outermost makereport wrapper.

- Reject anything that is not a well-formed TestReport for the expected
  nodeid/phase with pytest.UsageError (exit 4). Malformed or legacy dill
  records fail the report job; they never become a pass.
- Reject records that would fail-open: wasxfail on a failed outcome, or
  a skipped setup paired with a call report.
- Serialize before acknowledge so an encoding error cannot lose a
  failure; late failures after another worker's ack are still discarded
  as TIMED OUT.
- Clear wasxfail when synthesizing WILL_RETRY/TIMED OUT skips; carry
  user_properties and captured sections into the reporter's teardown so
  JUnit output is unchanged. user_properties are stringified on the
  wire.
- Remove dill and tblib; delete ciqueue/_pytest/outcomes.py; raise the
  pytest floor to >=6.2.5 (hooks exist since 5.1; verified 6.2.5,
  7.4.4, 8.4.2, 9.1.1 on Python 3.10-3.14).

Wire format change: workers and the reporter must run the same ciqueue
version within a build, and upgraded workers need a fresh build ID.
Traceback style (--tb, --showlocals) is now decided by the worker's
options. Strict-xfail XPASS, previously dropped on the wire, is now
reported as failed. xfail-failed tests no longer consume a requeue.

Assisted-By: devx/6b388d1b-b275-4664-9297-6db9839a7769
@markdorison markdorison self-assigned this Sep 18, 2026
@markdorison
markdorison marked this pull request as ready for review September 18, 2026 20:17
@markdorison
markdorison merged commit cc1f1d0 into main Sep 18, 2026
34 checks passed
@markdorison
markdorison deleted the pytest-json-error-reports branch September 18, 2026 20:51
@mdwn mdwn mentioned this pull request Sep 30, 2026

This branch was successfully deployed

1 active deployment
rubygems — 77546b31 Deployed Sep 18, 2026 by shopify-shipit[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants