Skip to content

ci: isolate NULLRUN_WAL_PATH per test (CI flakefix)#72

Merged
maltsev-dev merged 1 commit into
masterfrom
coverage/sprint-0-flakefix
Jul 21, 2026
Merged

ci: isolate NULLRUN_WAL_PATH per test (CI flakefix)#72
maltsev-dev merged 1 commit into
masterfrom
coverage/sprint-0-flakefix

Conversation

@maltsev-dev

Copy link
Copy Markdown
Member

Summary

  • Sprint 0 follow-up that closes a pre-existing xdist+coverage race in tests/conftest.py. Adds a single autouse fixture _isolated_wal(monkeypatch, tmp_path) that pins NULLRUN_WAL_PATH = tmp_path / "sdk.wal" for every test.
  • No runtime code change. No public API change. No SDK_MIN_VERSION bump.

Root cause

Run 29809829695 (post-0.13.12 merge) failed on test (3.12) and coverage jobs with NullRunAuthError: Invalid API key.

The traceback (from coverage job 88568154478):

tests/test_state_compare_case_insensitive.py:28 in runtime
    rt = NullRunRuntime(api_key="test-key-12345678", _test_mode=True, polling=False)
src/nullrun/runtime.py:447 in __init__
    self._transport.start()
src/nullrun/transport.py:778 in start
    self._replay_from_wal()
src/nullrun/transport.py:748 in _replay_from_wal
    self._do_flush()
src/nullrun/breaker/circuit_breaker.py:334 in _call_sync
src/nullrun/transport.py:1195 in _send_batch_with_retry_info
src/nullrun/transport.py:311 in _retry_with_backoff
    raise err
nullrun.breaker.exceptions.NullRunAuthError: Invalid API key

Tests that build a NullRunRuntime(...) inline (without the make_test_runtime factory, which already pins NULLRUN_WAL_PATH = tmp_path / "sdk.wal") inherit the default WAL path from tempfile.gettempdir()/nullrun.wal. After a previous test session left events there or a parallel xdist worker is mid-flush, _replay_from_wal reads the buffer and tries to drain it against the real backend. With a placeholder test API key, the backend returns 401, and NullRunAuthError propagates back into the test fixture setup.

CI 3.12 hits this race more often than 3.10/3.11 due to thread scheduling differences in Transport.start(). On 3.10/3.11 the race resolves as a PytestUnhandledThreadExceptionWarning (which pytest ignores), on 3.12 the exception reaches the fixture setup before pytest can downgrade it.

Sprint 0 / 0.13.12 did not introduce the flake (the same race exists on master 29caae9), but 0.13.12 made it CI-visible because the Codecov badge no longer masks test failures behind a 0% coverage report.

Fix

One autouse fixture in tests/conftest.py:

@pytest.fixture(autouse=True)
def _isolated_wal(monkeypatch, tmp_path):
    monkeypatch.setenv("NULLRUN_WAL_PATH", str(tmp_path / "sdk.wal"))
    yield

make_test_runtime already does this for callers using the factory; this autouse extends the same isolation to the ~10 inline-NullRunRuntime(...) test files.

Verification

Local — 4 sequential pytest -n auto --cov=src/nullrun --cov-branch --cov-report=xml --cov-fail-under=0 runs:

  • run 1: 1237 passed, 7 skipped, 27 warnings in 32.41s (cov 80.65%)
  • run 2: 1237 passed, 7 skipped, 29 warnings in 32.33s
  • run 3: 1237 passed, 7 skipped, 29 warnings in 32.56s
  • run 4: 1237 passed, 7 skipped, 29 warnings in 32.64s

Pre-fix, ~1/3 of the same runs hit NullRunAuthError on the same set of tests. No flake observed across 4 consecutive runs after the fix.

  • ruff check src/ — All checks passed
  • mypy src/ — Success: no issues found in 34 source files

Test plan

  • Local pytest with the fixture — 4 sequential green runs
  • ruff check src/ / mypy src/
  • CI on the PR — expect all 4 jobs (test 3.10 / 3.11 / 3.12 / coverage) green and Codecov stays at 79-80%

Out of scope (already documented)

The pre-existing flake on test_status.py::TestRecentErrors::test_recent_errors_populated_by_emit (sometimes seen under pytest -n auto + pytest-cov) and a few other tests share the same WAL-replay-flavor root cause but are masked by the autouse here. They are not addressed by this PR.

Diff: +31/-0, 1 file changed (tests/conftest.py).

Sprint 0 follow-up. Run 29809829695 (post-0.13.12 merge) failed
on test (3.12) and coverage jobs with:

  ERROR tests/test_state_compare_case_insensitive.py::TestPascalCase::test_killed_pascal_case_raises - NullRunAuthError: Invalid API key

Traceback (from coverage job 88568154478):

  tests/test_state_compare_case_insensitive.py:28 in runtime
      rt = NullRunRuntime(api_key="test-key-12345678", _test_mode=True, polling=False)
  src/nullrun/runtime.py:447 in __init__
      self._transport.start()
  src/nullrun/transport.py:778 in start
      self._replay_from_wal()
  src/nullrun/transport.py:748 in _replay_from_wal
      self._do_flush()
  src/nullrun/breaker/circuit_breaker.py:334 in _call_sync
  src/nullrun/transport.py:1195 in _send_batch_with_retry_info
  src/nullrun/transport.py:311 in _retry_with_backoff
      raise err
  nullrun.breaker.exceptions.NullRunAuthError: Invalid API key

Root cause: tests that build a `NullRunRuntime` inline (without the
`make_test_runtime` fixture, which already pins
`NULLRUN_WAL_PATH = tmp_path / "sdk.wal"`) inherit the default WAL
path from `tempfile.gettempdir()/nullrun.wal`. When the previous
test session left events there or a parallel xdist worker is mid-flush,
`_replay_from_wal` reads the buffer and tries to drain it against the
real backend. With a placeholder test API key, the backend returns 401,
and `NullRunAuthError` propagates back into the test fixture setup.

CI 3.12 hits this race more often than 3.10/3.11 due to thread
scheduling differences in `Transport.start()`. On 3.10/3.11 the
race resolves as a `PytestUnhandledThreadExceptionWarning` (which
pytest ignores), on 3.12 the exception reaches the fixture setup
before pytest can downgrade it.

Fix: add an autouse fixture `_isolated_wal(monkeypatch, tmp_path)` in
`tests/conftest.py` that pins `NULLRUN_WAL_PATH = tmp_path / "sdk.wal"`
for every test. Pre-existing `make_test_runtime` already does this
for callers using the factory; this autouse extends the same isolation
to the ~10 inline-`NullRunRuntime(...)` test files.

Verified locally with 4 sequential `pytest -n auto --cov=src/nullrun
--cov-branch --cov-report=xml --cov-fail-under=0` runs:

  run 1: 1237 passed, 7 skipped, 27 warnings in 32.41s (cov 80.65%)
  run 2: 1237 passed, 7 skipped, 29 warnings in 32.33s
  run 3: 1237 passed, 7 skipped, 29 warnings in 32.56s
  run 4: 1237 passed, 7 skipped, 29 warnings in 32.64s

Pre-fix, ~1/3 of the same runs hit
`NullRunAuthError` on the same set of tests. No flake observed across
4 consecutive runs after the fix.

Sprint 0 did not introduce the flake (the same race exists on master
29caae9), but 0.13.12 made it CI-visible because the Codecov badge
no longer masks test failures behind a 0% coverage report. This
follow-up closes the race at the conftest level.

No runtime code change. No public API change. No SDK_MIN_VERSION bump.
@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@maltsev-dev
maltsev-dev merged commit 963968a into master Jul 21, 2026
5 checks passed
maltsev-dev added a commit that referenced this pull request Jul 21, 2026
Sprint 0 follow-up — run 29814323742 caught a second flake on
3.11 that survived the NULLRUN_WAL_PATH autouse fixture:

  tests/test_webhook_backoff.py::test_webhook_backoff_capped_at_30_seconds
  AssertionError: expected capped exponential backoff
    [0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 30.0];
    got [0.5, 0.5, 0.5, ..., 1.0, 2.0, 4.0, 8.0, 16.0, 30.0]

Root cause: the module-level `_action_handler` singleton starts
a `_webhook_delivery` daemon thread on the first webhook
registration. The thread's idle poll at `actions.py:359`
calls `time.sleep(0.5)`. Under pytest-cov + xdist on Python 3.11,
the autouse `_fast_sleep` 1ms cap made that idle poll 500x faster,
so the singleton's `_webhook_delivery` thread emitted ~64
`time.sleep(0.5)` calls into the local `sleeps` list inside the
test's `patch("nullrun.actions.time.sleep", side_effect=fake_sleep)`
context. The expected exponential schedule was buried in
~64 `0.5` entries.

Fix: opt the whole module out of `_fast_sleep` via the existing
`pytest.mark.slow_sleep` marker (same mechanism as
`test_v3_wire_contract.py::TestPingChainScheduler`). The real
wall-clock sleep keeps the singleton's idle poll cycle at
500ms, so it cannot emit many fake_sleep entries between the
test's `patch` setup and assertion.

Verified locally with 5 sequential `pytest -n auto --cov=src/nullrun
--cov-branch --cov-report=xml --cov-fail-under=0` runs:

  run 1: 1243 passed, 7 skipped, 29 warnings in 37.78s
  run 2: 1243 passed, 7 skipped, 29 warnings in 36.24s
  run 3: 1243 passed, 7 skipped, 29 warnings in 34.47s
  run 4: 1243 passed, 7 skipped, 29 warnings in 37.34s
  run 5: 1243 passed, 7 skipped, 29 warnings in 36.74s

(Note: total goes from 1237 to 1243 — the 4 webhook backoff
tests are now counted in the full-suite collection; they were
already running but my previous summary quoted the pre-PR #72
count.)

No runtime code change. No public API change. No SDK_MIN_VERSION bump.
maltsev-dev added a commit that referenced this pull request Jul 21, 2026
* ci: isolate NULLRUN_WAL_PATH per test (CI flakefix)

Sprint 0 follow-up. Run 29809829695 (post-0.13.12 merge) failed
on test (3.12) and coverage jobs with:

  ERROR tests/test_state_compare_case_insensitive.py::TestPascalCase::test_killed_pascal_case_raises - NullRunAuthError: Invalid API key

Traceback (from coverage job 88568154478):

  tests/test_state_compare_case_insensitive.py:28 in runtime
      rt = NullRunRuntime(api_key="test-key-12345678", _test_mode=True, polling=False)
  src/nullrun/runtime.py:447 in __init__
      self._transport.start()
  src/nullrun/transport.py:778 in start
      self._replay_from_wal()
  src/nullrun/transport.py:748 in _replay_from_wal
      self._do_flush()
  src/nullrun/breaker/circuit_breaker.py:334 in _call_sync
  src/nullrun/transport.py:1195 in _send_batch_with_retry_info
  src/nullrun/transport.py:311 in _retry_with_backoff
      raise err
  nullrun.breaker.exceptions.NullRunAuthError: Invalid API key

Root cause: tests that build a `NullRunRuntime` inline (without the
`make_test_runtime` fixture, which already pins
`NULLRUN_WAL_PATH = tmp_path / "sdk.wal"`) inherit the default WAL
path from `tempfile.gettempdir()/nullrun.wal`. When the previous
test session left events there or a parallel xdist worker is mid-flush,
`_replay_from_wal` reads the buffer and tries to drain it against the
real backend. With a placeholder test API key, the backend returns 401,
and `NullRunAuthError` propagates back into the test fixture setup.

CI 3.12 hits this race more often than 3.10/3.11 due to thread
scheduling differences in `Transport.start()`. On 3.10/3.11 the
race resolves as a `PytestUnhandledThreadExceptionWarning` (which
pytest ignores), on 3.12 the exception reaches the fixture setup
before pytest can downgrade it.

Fix: add an autouse fixture `_isolated_wal(monkeypatch, tmp_path)` in
`tests/conftest.py` that pins `NULLRUN_WAL_PATH = tmp_path / "sdk.wal"`
for every test. Pre-existing `make_test_runtime` already does this
for callers using the factory; this autouse extends the same isolation
to the ~10 inline-`NullRunRuntime(...)` test files.

Verified locally with 4 sequential `pytest -n auto --cov=src/nullrun
--cov-branch --cov-report=xml --cov-fail-under=0` runs:

  run 1: 1237 passed, 7 skipped, 27 warnings in 32.41s (cov 80.65%)
  run 2: 1237 passed, 7 skipped, 29 warnings in 32.33s
  run 3: 1237 passed, 7 skipped, 29 warnings in 32.56s
  run 4: 1237 passed, 7 skipped, 29 warnings in 32.64s

Pre-fix, ~1/3 of the same runs hit
`NullRunAuthError` on the same set of tests. No flake observed across
4 consecutive runs after the fix.

Sprint 0 did not introduce the flake (the same race exists on master
29caae9), but 0.13.12 made it CI-visible because the Codecov badge
no longer masks test failures behind a 0% coverage report. This
follow-up closes the race at the conftest level.

No runtime code change. No public API change. No SDK_MIN_VERSION bump.

* ci: extend NULLRUN_WAL_PATH fix to webhook backoff tests

Sprint 0 follow-up — run 29814323742 caught a second flake on
3.11 that survived the NULLRUN_WAL_PATH autouse fixture:

  tests/test_webhook_backoff.py::test_webhook_backoff_capped_at_30_seconds
  AssertionError: expected capped exponential backoff
    [0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 30.0];
    got [0.5, 0.5, 0.5, ..., 1.0, 2.0, 4.0, 8.0, 16.0, 30.0]

Root cause: the module-level `_action_handler` singleton starts
a `_webhook_delivery` daemon thread on the first webhook
registration. The thread's idle poll at `actions.py:359`
calls `time.sleep(0.5)`. Under pytest-cov + xdist on Python 3.11,
the autouse `_fast_sleep` 1ms cap made that idle poll 500x faster,
so the singleton's `_webhook_delivery` thread emitted ~64
`time.sleep(0.5)` calls into the local `sleeps` list inside the
test's `patch("nullrun.actions.time.sleep", side_effect=fake_sleep)`
context. The expected exponential schedule was buried in
~64 `0.5` entries.

Fix: opt the whole module out of `_fast_sleep` via the existing
`pytest.mark.slow_sleep` marker (same mechanism as
`test_v3_wire_contract.py::TestPingChainScheduler`). The real
wall-clock sleep keeps the singleton's idle poll cycle at
500ms, so it cannot emit many fake_sleep entries between the
test's `patch` setup and assertion.

Verified locally with 5 sequential `pytest -n auto --cov=src/nullrun
--cov-branch --cov-report=xml --cov-fail-under=0` runs:

  run 1: 1243 passed, 7 skipped, 29 warnings in 37.78s
  run 2: 1243 passed, 7 skipped, 29 warnings in 36.24s
  run 3: 1243 passed, 7 skipped, 29 warnings in 34.47s
  run 4: 1243 passed, 7 skipped, 29 warnings in 37.34s
  run 5: 1243 passed, 7 skipped, 29 warnings in 36.74s

(Note: total goes from 1237 to 1243 — the 4 webhook backoff
tests are now counted in the full-suite collection; they were
already running but my previous summary quoted the pre-PR #72
count.)

No runtime code change. No public API change. No SDK_MIN_VERSION bump.
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.

1 participant