Skip to content

test: make parallel input guardrail overlap deterministic - #4158

Closed
LeSingh1 wants to merge 1 commit into
openai:mainfrom
LeSingh1:fix/deterministic-parallel-guardrail-tests
Closed

test: make parallel input guardrail overlap deterministic#4158
LeSingh1 wants to merge 1 commit into
openai:mainfrom
LeSingh1:fix/deterministic-parallel-guardrail-tests

Conversation

@LeSingh1

@LeSingh1 LeSingh1 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Summary

tests/test_guardrails.py::test_mixed_blocking_and_parallel_guardrails and its streaming twin fail intermittently under make tests-parallel, while passing reliably in isolation.

Both tests proved that a run_in_parallel=True input guardrail overlaps the model call by sampling time.time() around a fixed asyncio.sleep(MEDIUM_DELAY) in each guardrail, then asserting timestamps["model_called"] <= timestamps["parallel_end"].

That assertion has a zero-width margin: it only holds while the runner reaches the model within the ~30ms the parallel guardrail spends sleeping. Under pytest-xdist the workers saturate the CPU, the process gets descheduled, the guardrail's timer expires first, and the guardrail resumes before the model call is issued — so the test fails even though the runner behaved correctly and did schedule the model concurrently with the guardrail:

assert timestamps["model_called"] <= timestamps["parallel_end"]
AssertionError: Model called while parallel guardrail still running
assert 1785804845.201256 <= 1785804845.149035

This is test timing-sensitivity, not a runtime bug. run.py creates the model task and the parallel guardrail task before gathering both, and run_internal/run_loop.py starts the parallel guardrail task before awaiting run_single_turn_streamed; the overlap itself is correct.

This PR drives the overlap with explicit asyncio.Event synchronization instead. The parallel guardrail stays in flight until the model call sets model_called, and the model call records whether the blocking guardrail had already finished and whether the parallel guardrail was still running. Ordering is now enforced by happens-before edges rather than wall-clock margins, so scheduling latency cannot flip the result. This matches the asyncio.Event rendezvous idiom already used elsewhere in this file, for example test_parallel_guardrail_trip_before_tool_execution_stops_streaming_turn.

The tests keep their teeth. Forcing run_in_parallel=True guardrails to run blocking (patching run.py and run_internal/run_loop.py so every input guardrail lands in sequential_guardrails) makes both tests fail, because the guardrail's wait for the model call is never satisfied and wait_for raises. The blocking-before-parallel and blocking-before-model checks are unchanged in intent and still fail if a blocking guardrail stops completing first.

Test plan

  • Reproduced the original flake with uv run pytest tests/test_guardrails.py -q -n 8 -k mixed_blocking while saturating the machine with 24 CPU-bound processes: 2 failures across 21 iterations on main, hitting both the streaming and non-streaming variant.
  • Same load, same command, with this change: 0 failures across 15 iterations.
  • Regression check: temporarily forced parallel input guardrails to run blocking; both tests fail with TimeoutError from the guardrail rendezvous. Reverted afterwards.
  • No runtime cost on the passing path: with deterministic ordering (-p no:randomly) the streaming test takes 0.11s before and after, so the 1s timeout is never approached.
  • bash .agents/skills/code-change-verification/scripts/run.sh — all commands passed (make format, make lint, make typecheck, make tests).

Only tests/test_guardrails.py is touched; no runtime code changes.

Issue number

N/A — flake found while running the suite; no existing issue.

Checks

  • I've added new tests, if relevant
  • I've run .agents/skills/code-change-verification/scripts/run.sh
  • I've confirmed all verification steps pass
  • If using Codex, I've run /review before submitting this PR

`test_mixed_blocking_and_parallel_guardrails` and its streaming twin proved
that a `run_in_parallel=True` input guardrail overlaps the model call by
comparing `time.time()` samples taken around a fixed `asyncio.sleep(0.03)` in
each guardrail, then asserting `model_called <= parallel_end`.

That assertion has a zero-width margin: it only holds while the runner reaches
the model within the 30ms the parallel guardrail spends sleeping. Under
`make tests-parallel` the pytest-xdist workers saturate the CPU, the process
gets descheduled, the guardrail's timer expires first, and the guardrail
resumes before the model call is issued. The tests then fail even though the
runner behaved correctly and still scheduled the model concurrently with the
guardrail. Both variants have been observed failing this way.

Drive the overlap with `asyncio.Event` rendezvous instead. The parallel
guardrail now stays suspended until the model call sets `model_called`, and the
model call records whether the blocking guardrail had finished and whether the
parallel guardrail was still in flight. The ordering is now enforced by
happens-before edges rather than by wall-clock margins, so scheduling latency
cannot flip the result.

The tests keep their teeth: if parallel guardrails stopped overlapping the
model call, the guardrail's wait would never be satisfied and the test would
fail on the timeout, and the blocking-before-model and blocking-before-parallel
checks still fail if a blocking guardrail stopped completing first.
@seratch seratch added the project label Aug 4, 2026

@seratch seratch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flaky wall-clock assertion is a real problem, and replacing it with event synchronization is the right direction. However, both updated tests can still pass if the model runs before the parallel guardrail starts: parallel_finished.is_set() is also false before the guardrail has begun, and the later guardrail sees model_called already set.

Please add a parallel_started event in both variants, set it at the start of the parallel guardrail, and have the tracked model await it before setting model_called and invoking the original model method. This creates a two-way rendezvous and proves that the guardrail is genuinely in flight without relying on timing. After that focused change and green checks, this should be ready for another review.

@seratch

seratch commented Aug 5, 2026

Copy link
Copy Markdown
Member

Your contribution here is included in #4187; thanks again for suggesting the change!

@seratch seratch closed this Aug 5, 2026
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.

2 participants