Skip to content

tests: fix monitor harness races, tox py313 only, agent guidance - #152

Merged
joshschmelzle merged 3 commits into
devfrom
test-cleanup
Jul 19, 2026
Merged

tests: fix monitor harness races, tox py313 only, agent guidance#152
joshschmelzle merged 3 commits into
devfrom
test-cleanup

Conversation

@joshschmelzle

@joshschmelzle joshschmelzle commented Jul 19, 2026

Copy link
Copy Markdown
Member

Fixes the delayed-connect flake that failed CI on dev, plus cleanup.

Root cause of the flake

Single-thread, no zombie required:

  1. patch("...monitor.time.sleep") patches sleep on the global stdlib time module (monitor.py does plain import time), so the test's own time.sleep(0.01) poll loop was a no-op. Both the monitor thread and the test busy-spun on the GIL; on a loaded runner a ~2s stall could expire the old wall-clock deadline before the monitor reached the dhcp step.

  2. monitor_loop pops itself from the registry before running the dhcp/route/app phase, and stop_all_connection_monitors holds _monitor_lock across its join(2.0) while the thread's own cleanup wants that lock: a guaranteed-timeout lock convoy that releases both threads simultaneously into patch teardown. The old _wait_for_monitors_idle watched only the registry and returned instantly.

  3. The nested with patch blocks unwind innermost first: start_app_in_namespace is unpatched before restart_dhcp_with_timeout. The thread's dhcp call landed on the still-patched mock (1 call); its start_app call landed after restore (mock: 0 calls). Exactly the CI evidence: dhcp called once, start_app called 0 times.

Changes

  • _wait_for_monitors_idle requires the registry empty and no live ConnectionMonitor-* threads (they are already named), and raises on timeout instead of returning silently. This also closes the pop-before-actions blind spot.
  • The three real-thread scenarios synchronize on a threading.Event set by the mock side_effect. Event.wait blocks in C and yields the GIL, so it is immune to the global sleep mock. Cleanup runs inside the patch stack and before assertions, so a failing run cannot tear down mocks under a live thread or leak one.
  • The wpa status sequence is a locked index instead of a shared generator.
  • The three intentionally-invalid validation scenarios scope-suppress their expected pydantic serializer warnings.
  • tox envlist = py313 only (py39/py311 were permanent SKIP noise; CI and the shipped target are 3.13).
  • AGENTS.md with branch/PR and testing rules (including the global time.sleep aliasing gotcha), plus a CLAUDE.md pointer.

Follow-ups (separate PRs)

  • monitor.py: move registry cleanup to a finally after the action phase; stop holding _monitor_lock across join().
  • pyproject.toml: stale requires-python = ">=3.9" and 3.9/3.11 classifiers.

- _wait_for_monitors_idle now also detects still-alive ConnectionMonitor-*
  threads and raises on timeout instead of returning silently; a leaked
  thread consumes the next scenario's mocks (the delayed-connect flake)
- replace wall-clock mock polling with Event-synchronized side_effects in
  the three real-thread scenarios; thread-safe wpa status sequence
- scope-suppress expected pydantic serializer warnings in the three
  intentionally-invalid validation scenarios
- tox: envlist py313 only (py39/py311 were skip noise)
- add AGENTS.md testing/PR rules and CLAUDE.md pointer for coding agents
@joshschmelzle joshschmelzle self-assigned this Jul 19, 2026
@joshschmelzle
joshschmelzle merged commit a45e962 into dev Jul 19, 2026
10 checks passed
@joshschmelzle
joshschmelzle deleted the test-cleanup branch July 19, 2026 13:06
joshschmelzle added a commit that referenced this pull request Jul 19, 2026
…153)

Two lifecycle defects found during adversarial review of the delayed-connect
test flake (see #152):

- monitor_loop removed its registry entries BEFORE running the dhcp/route/
  autostart phase (and early-return paths skipped cleanup entirely), so a
  thread could be invisible to stop helpers while side effects were still
  pending. Cleanup now runs in a finally after the body, on every path.
- stop_connection_monitor and stop_all_connection_monitors held _monitor_lock
  across join(), but the monitor thread's own deregistration needs that lock,
  guaranteeing the join times out in exactly the window it matters. Joins now
  happen outside the lock.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant