Drain subprocess stream consumers once and harden test synchronisation - #226
Drain subprocess stream consumers once and harden test synchronisation#226leynos wants to merge 5 commits into
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🔗 Linked repositories identifiedCodeRabbit considers these linked repositories for cross-repo context during reviews:
Summary
Testing
WalkthroughRefactor subprocess cleanup so process termination, stream draining, and timeout translation occur in separate stages. Update timeout, cancellation, blocked-stdin, and early-stdin-close tests with deterministic coordination. ChangesSubprocess cleanup refactor
Sequence Diagram(s)sequenceDiagram
participant SubprocessRunner
participant ProcessWaiter
participant StreamConsumers
participant TimeoutHandler
SubprocessRunner->>ProcessWaiter: wait for process exit
ProcessWaiter->>ProcessWaiter: terminate process on timeout or cancellation
SubprocessRunner->>StreamConsumers: cancel and drain stdout/stderr
StreamConsumers-->>SubprocessRunner: return decoded output
SubprocessRunner->>TimeoutHandler: translate timeout with output
Possibly related PRs
Suggested labels: Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 20✅ Passed checks (20 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
@coderabbitai Please suggest a fix for this issue and supply a prompt for an AI coding agent to enable it to apply the fix. Include the file and symbol names indicated in the issue at the head of your response. Ensure that this is validated against the current version of the codegraph. If further refinement to address this finding would be deleterious, please supply a clear explanatory one to two paragraph markdown message I can paste into the CodeScene web ui's diagnostic suppression function so this diagnostic can be silenced. cuprum/unittests/test_safe_cmd_stdin.py Comment on lines +275 to +277 # Probe the real pipe capacity so the writer is guaranteed to stall in
# drain() rather than assuming a ~64 KiB buffer.
payload = b"x" * drain_blocking_payload_size()❌ New issue: Code Duplication |
This comment was marked as resolved.
This comment was marked as resolved.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 12a9296a89
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
cuprum/_subprocess_execution.py (1)
229-239: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReuse
_drain_stream_consumersin the stdin-failure path.The extraction leaves this branch as the only place that still hand-rolls cancel-then-gather. Route it through the new helper so all three failure paths share one drain.
♻️ Proposed de-duplication
# An unexpected stdin-writer failure (or a cancellation landing on # this await) must still reconcile the stdout/stderr consumers, # mirroring the timeout and cancellation paths above, so those tasks # are cancelled and drained before the error propagates. - _cancel_pending_consumers(consumers) - await asyncio.gather(*consumers, return_exceptions=True) + await _drain_stream_consumers(consumers) raise🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cuprum/_subprocess_execution.py` around lines 229 - 239, Replace the manual _cancel_pending_consumers(consumers) and asyncio.gather sequence in the stdin_task exception handler with the existing _drain_stream_consumers helper. Preserve the current BaseException handling and re-raise behavior so stdin failures and cancellations still propagate after consumers are drained.cuprum/unittests/test_subprocess_timeout.py (1)
97-124: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winThe generated delays buy nothing once every consumer is pre-gathered.
Line 106 drives all consumers to completion before the drain, so
stdout_delay/stderr_delaycannot influence any asserted outcome and thetask.done()check at lines 110-112 is vacuous. The property currently reduces to "two independent outcomes decode independently" and would still pass against an implementation that never cancels.Use the delays to leave a consumer genuinely pending — for example, gather with a zero timeout or skip the pre-gather for a generated subset — so the drain's cancel-then-decode path is actually explored. Otherwise drop the delay fields and shrink the example budget.
♻️ Sketch: let a generated consumer stay pending
- # Let every consumer reach its outcome before draining, so the decode - # path (text preserved, failure -> None) is exercised rather than the - # cancellation of a reader that never got to run. - await asyncio.gather(*consumers, return_exceptions=True) + # Yield a bounded number of times so consumers with a smaller delay + # finish while longer-delayed ones stay pending, exercising the decode + # path and the cancellation path in the same property. + for _ in range(case.settle_steps): + await asyncio.sleep(0) stdout_text, stderr_text = await _drain_stream_consumers(consumers)Expected values then become
Nonewhenever the consumer's delay exceeded
settle_steps, which is exactly the invariant worth pinning down.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cuprum/unittests/test_subprocess_timeout.py` around lines 97 - 124, Update run_case and its generated test setup so stdout_delay and stderr_delay can leave consumers pending when _drain_stream_consumers runs, instead of always awaiting every consumer to completion first. Preserve completed consumer outcomes, but expect None for consumers whose delay exceeds the settling window, and keep assertions that verify pending tasks are cancelled and all tasks are done after draining; remove delay fields only if the test no longer exercises timing.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@cuprum/unittests/test_observe.py`:
- Around line 317-327: Update the _STDIN_EARLY_CLOSE_CHILD script so its wait
for the go marker has a finite deadline; when the deadline expires, exit with a
non-zero status instead of continuing to sleep. Preserve the existing marker,
stdin-close, and completion behavior when the marker appears.
In `@tests/helpers/stream_pipes.py`:
- Around line 50-58: Update the pipe-capacity calculation around
fcntl.F_GETPIPE_SZ to check whether the constant is available before calling
fcntl.fcntl. On hosts without F_GETPIPE_SZ, return a conservative default
capacity while preserving the existing Linux calculation and descriptor cleanup
behavior.
---
Outside diff comments:
In `@cuprum/_subprocess_execution.py`:
- Around line 229-239: Replace the manual _cancel_pending_consumers(consumers)
and asyncio.gather sequence in the stdin_task exception handler with the
existing _drain_stream_consumers helper. Preserve the current BaseException
handling and re-raise behavior so stdin failures and cancellations still
propagate after consumers are drained.
In `@cuprum/unittests/test_subprocess_timeout.py`:
- Around line 97-124: Update run_case and its generated test setup so
stdout_delay and stderr_delay can leave consumers pending when
_drain_stream_consumers runs, instead of always awaiting every consumer to
completion first. Preserve completed consumer outcomes, but expect None for
consumers whose delay exceeds the settling window, and keep assertions that
verify pending tasks are cancelled and all tasks are done after draining; remove
delay fields only if the test no longer exercises timing.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 0161eabe-d10e-45fd-85ba-f94dd274eaac
📒 Files selected for processing (8)
cuprum/_subprocess_execution.pycuprum/_subprocess_timeout.pycuprum/unittests/test_observe.pycuprum/unittests/test_safe_cmd_context.pycuprum/unittests/test_safe_cmd_stdin.pycuprum/unittests/test_subprocess_timeout.pydocs/debugging/debugging-plan-2026-07-18-hypothesis-too-slow.mdtests/helpers/stream_pipes.py
🔗 Linked repositories identified
CodeRabbit considers these linked repositories for cross-repo context during reviews:
leynos/shared-actions(auto-detected)leynos/pylint-pypy-shim(auto-detected)leynos/whitaker(auto-detected)
|
@coderabbitai resume |
✅ Action performedReviews resumed. |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@cuprum/unittests/test_observe_stdin_early_close.py`:
- Around line 142-143: Update the test around _orchestrate to accept pytest’s
tmp_path fixture and construct the go and closed marker paths from it, replacing
tempfile.TemporaryDirectory. Remove the now-unused tempfile import, while
retaining the Path import if it is still needed at runtime.
In `@cuprum/unittests/test_safe_cmd_stdin.py`:
- Around line 285-304: Preserve the execution-strategy label from
execution_strategy in the test setup instead of discarding it, and use that
label in the timeout assertion message. Keep mode available for the capture
variant, while ensuring failures identify whether run() or run_sync() hung.
In `@docs/developers-guide.md`:
- Around line 1571-1572: Update the non-streaming `_execute_subprocess`
documentation step to include that `_cancel_stdin_writer` cancels and drains the
writer when `_wait_for_exit_code` raises `TimeoutError` or
`asyncio.CancelledError`, while retaining the existing happy-path description.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: b7a42a7a-ac7d-40aa-bd60-d4fd70b127a2
📒 Files selected for processing (11)
cuprum/_subprocess_execution.pycuprum/_subprocess_timeout.pycuprum/unittests/__snapshots__/test_maturin_build.ambrcuprum/unittests/test_observe.pycuprum/unittests/test_observe_stdin_early_close.pycuprum/unittests/test_safe_cmd_context.pycuprum/unittests/test_safe_cmd_stdin.pycuprum/unittests/test_subprocess_timeout.pydocs/debugging/debugging-plan-2026-07-18-hypothesis-too-slow.mddocs/developers-guide.mdtests/helpers/stream_pipes.py
🔗 Linked repositories identified
CodeRabbit considers these linked repositories for cross-repo context during reviews:
leynos/shared-actions(auto-detected)leynos/pylint-pypy-shim(auto-detected)leynos/whitaker(auto-detected)
…ning Verify each outstanding code-review finding against current code and fix the still-valid ones with minimal changes. - Move the pipe-capacity probe into tests/helpers/stream_pipes.py as drain_blocking_payload_size, importing fcntl lazily so collection succeeds on platforms without it; reuse it in the blocked-stdin tests in place of the hardcoded 1 MiB payloads. - Replace the payload-size-timed early-close stdin test with a deterministic go/closed marker handshake: the child closes stdin only once the writer is confirmed wedged in drain(), then signals back that it has closed. - Replace the fixed asyncio.sleep readiness guess in the cancellation test with an sh.observe start-phase hook and an asyncio.Event. - Drain stream consumers exactly once on timeout. _wait_for_exit_code now waits only for the exit code (still terminating the process); the timeout branch of _run_subprocess_with_streams drains the consumers via the new _drain_stream_consumers helper and hands the decoded output to a synchronous _handle_stream_timeout, preserving captured stdout/stderr on TimeoutExpired. - Add a capture-mode blocked-stdin timeout regression test covering the stream path. - Insert a clause-separating comma in the hypothesis-too-slow debugging note. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…case Replace the near-identical direct- and capture-mode blocked-stdin timeout tests with a single test_timeout_with_blocked_stdin_writer_does_not_hang parametrized over capture/mode, keeping the explicit direct and capture ids and the #117 regression coverage across both output paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tests - stream_pipes: probe pipe capacity via a guarded _probe_pipe_capacity that falls back to a conservative default when fcntl or F_GETPIPE_SZ is absent, so drain_blocking_payload_size callers need no platform guard. - _subprocess_execution: reuse _drain_stream_consumers in the stdin-writer failure handler instead of a manual cancel+gather, matching the timeout and cancellation branches. - test_subprocess_timeout: give the drain hypothesis test a fixed settling window so slow consumers stay pending and exercise cancellation, expecting None past the window. - test_observe: extract the early-close coordination cluster into test_observe_stdin_early_close.py, bringing test_observe.py under the 400-line cap; the child now waits for the go marker with a finite deadline and exits non-zero rather than spinning forever. - developers-guide: correct the streaming-timeout flow description and document the shared drain_blocking_payload_size helper and its reuse policy. - Regenerate the maturin wheel-manifest snapshot for the new test module. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- test_observe_stdin_early_close: use pytest's tmp_path fixture for the go/ closed marker paths instead of tempfile.TemporaryDirectory, dropping the tempfile import and moving the now annotation-only Path import into the TYPE_CHECKING block. - test_safe_cmd_stdin: keep the execution-strategy label from execution_ strategy and name it in the non-hang assertion, so a failure identifies whether run() or run_sync() hung. - developers-guide: document that the non-streaming _execute_subprocess path cancels and drains the stdin writer via _cancel_stdin_writer on timeout or cancellation. - users-guide: note that TimeoutExpired preserves any captured stdout/stderr (exc.output / exc.stderr), or None when capture is disabled. Skipped the optional ADR-007/cuprum-design update: the stdin cancel/drain is an implementation detail consistent with ADR-007's retained-behaviour goal (its non-goals explicitly exclude cancellation semantics) and is now covered in the developers-guide. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8afb825 to
f906b35
Compare
|
@coderabbitai resume |
✅ Action performedReviews resumed. |
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@cuprum/unittests/test_observe_stdin_early_close.py`:
- Around line 36-47: Update the embedded _STDIN_EARLY_CLOSE_CHILD script to
import pathlib.Path, convert the CUPRUM_STDIN_CLOSE_GO and CUPRUM_STDIN_CLOSED
values to Path objects, and replace os.path.exists() with Path.exists() and
open()/write with Path.write_text().
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: e2df1dcb-3d45-48b9-897d-cf6a3bb0ac6d
📒 Files selected for processing (12)
cuprum/_subprocess_execution.pycuprum/_subprocess_timeout.pycuprum/unittests/__snapshots__/test_maturin_build.ambrcuprum/unittests/test_observe.pycuprum/unittests/test_observe_stdin_early_close.pycuprum/unittests/test_safe_cmd_context.pycuprum/unittests/test_safe_cmd_stdin.pycuprum/unittests/test_subprocess_timeout.pydocs/debugging/debugging-plan-2026-07-18-hypothesis-too-slow.mddocs/developers-guide.mddocs/users-guide.mdtests/helpers/stream_pipes.py
🔗 Linked repositories identified
CodeRabbit considers these linked repositories for cross-repo context during reviews:
leynos/shared-actions(auto-detected)leynos/pylint-pypy-shim(auto-detected)leynos/whitaker(auto-detected)
The embedded child script escapes ruff's PTH gate because it is a string literal, so it still used os.path.exists and open()/write. Build the marker paths as Path objects and use Path.exists()/Path.write_text(), matching the pathlib convention applied to the rest of the codebase. os stays imported for os.environ. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
This branch actions a batch of code-review findings on the subprocess
execution machinery and its tests. Each finding was first verified against the
current code; all six proved still valid, and every one has been fixed with
minimal changes. The substantive change is that stream-consumer draining on
timeout now happens exactly once rather than twice, with captured output
preserved on
TimeoutExpired; the remainder replace timing-based testcoordination with deterministic synchronisation seams and move a shared helper
into the test-helper package.
No issue or roadmap task governs this branch, and there is no execplan; the
work originates from inline and out-of-diff review comments.
Review walkthrough
_wait_for_exit_codenow waits only for the exit code (still terminating the process on timeout or cancellation), and a new_drain_stream_consumershelper cancels, drains, and decodes the stdout/stderr consumers exactly once in the timeout and cancellation branches of_run_subprocess_with_streams._handle_stream_timeoutis now synchronous and simply raises_SubprocessTimeoutErrorfrom the caller-supplied, already-decoded stdout/stderr, so captured output survives onto the publicTimeoutExpired.drain_blocking_payload_sizeprobes the real pipe capacity and importsfcntllazily so off-POSIX collection still succeeds.drain(), then signals back.asyncio.sleepreadiness guess with ansh.observestart-phase hook and anasyncio.Event.drain_blocking_payload_sizehelper instead of hardcoded 1 MiB payloads, and a capture-mode blocked-stdin timeout regression test exercises the stream path._drain_stream_consumersdecoding, the synchronous_handle_stream_timeout, and_wait_for_exit_code's process teardown.Validation
uv run ruff format --check: pass (all files formatted)make lint: pass (ruff clean, typos gate clean)make typecheck: pass (ty check, all checks passed)make markdownlint: pass (exit 0)make nixie: pass (all diagrams validated)uv run pytest -n0 test_observe.py test_safe_cmd_context.py test_safe_cmd_stdin.py test_subprocess_timeout.py: 57 passedNotes
drained the consumers twice (discarding the first pass, keeping the second),
so collapsing to a single drain does not change observable output. A consumer
still blocked on
read()at drain time is cancelled and decodes toNone, asbefore.
make testsuite, including the Rustnextestportion, was not runto completion locally; the changes are Python-only and do not touch the Rust
crate. The known trybuild snapshot drift against the CI-pinned toolchain is
unrelated to this branch.