Preserve partial capture when a timed-out run's readers have not reached EOF (#292) - #293
Preserve partial capture when a timed-out run's readers have not reached EOF (#292)#293leynos wants to merge 9 commits into
Conversation
A capturing run reports its streams as text, and `TimeoutExpired` is no exception. `_drain_stream_consumers` only honoured that by accident: it cancelled every reader that was not already `done()` and mapped a cancelled one to `None`, which was harmless solely because the dead process's pipes had already delivered EOF by the time the drain looked. Nothing enforced that ordering. Python 3.15.0rc1 observes the process exit before the pipes' pending end-of-file events, so both readers are still parked in `read()` when the drain runs, and a capturing timeout reports `None` for both streams. That is a lost capture on every timeout path, not just the non-positive one the suite happens to assert. Make the drain robust instead. It now takes `capture`, and a capturing drain first gives the readers a bounded window to reach the EOF that is already imminent, then reports a reader with no text as the empty string rather than `None`. The cancellation and stdin-failure paths pass `capture=False`, since they discard the text and must not wait. Closes #292.
|
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:
SummaryFix timed-out capturing subprocess runs that return
WalkthroughPreserve captured output during timeout cleanup. Allow a bounded EOF grace period. Report empty strings for captured streams without text. Discard output on non-capturing cleanup paths. ChangesTimeout capture handling
Sequence Diagram(s)sequenceDiagram
participant SubprocessExecution
participant StreamConsumers
participant TimeoutExpired
SubprocessExecution->>StreamConsumers: Drain streams after timeout with capture enabled
StreamConsumers->>StreamConsumers: Wait for bounded EOF grace period
StreamConsumers-->>SubprocessExecution: Return settled stdout and stderr
SubprocessExecution->>TimeoutExpired: Set output and stderr
Possibly related PRs
Suggested labels: Suggested reviewers: Poem
Caution Pre-merge checks failedPlease resolve all errors before merging. Addressing warnings is optional.
❌ Failed checks (1 error, 2 warnings, 1 inconclusive)
✅ Passed checks (16 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Reviewer's GuideEnsure timed-out capturing subprocess runs reliably surface partial stdout/stderr as text instead of None, by making the drain helper capture-aware and adding regression tests and documentation for the behavior. Sequence diagram for capturing drain on subprocess timeoutsequenceDiagram
participant Runner as _run_subprocess_with_streams
participant Subprocess
participant StdoutConsumer as stdout_consumer_task
participant StderrConsumer as stderr_consumer_task
participant Drain as _drain_stream_consumers
Runner->>Subprocess: start subprocess
Subprocess->>StdoutConsumer: produce stdout
Subprocess->>StderrConsumer: produce stderr
Runner->>Runner: _wait_for_exit_code_within_timeout
alt timeout
Runner->>Runner: _cancel_stdin_writer
Runner->>Drain: _drain_stream_consumers(consumers, capture=execution.capture)
opt capture is True
Drain->>Drain: asyncio.wait(consumers, timeout=_CAPTURE_EOF_GRACE_S)
end
Drain->>StdoutConsumer: _cancel_pending_consumers
Drain->>StderrConsumer: _cancel_pending_consumers
StdoutConsumer-->>Drain: stdout_result
StderrConsumer-->>Drain: stderr_result
Drain->>Drain: _decode_consumer_result(result, capture=True)
Drain-->>Runner: stdout_text ("" if no text), stderr_text ("" if no text)
Runner->>Runner: _handle_stream_timeout(stdout_text, stderr_text)
else cancellation or stdin failure
Runner->>Runner: _cancel_stdin_writer
Runner->>Drain: _drain_stream_consumers(consumers, capture=False)
Drain->>StdoutConsumer: _cancel_pending_consumers
Drain->>StderrConsumer: _cancel_pending_consumers
StdoutConsumer-->>Drain: stdout_result
StderrConsumer-->>Drain: stderr_result
Drain->>Drain: _decode_consumer_result(result, capture=False)
Drain-->>Runner: stdout_text=None, stderr_text=None
Runner-->>Runner: propagate error without captured output
end
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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/_subprocess_execution.py`:
- Around line 120-125: Make _drain_stream_consumers cancellation-safe by
ensuring both consumer tasks are explicitly cancelled and awaited when the
capture EOF-grace wait is interrupted, before re-propagating CancelledError from
the enclosing run. Preserve normal result handling when the grace period
completes, and add a regression covering cancellation of a timed-out capturing
run during that period that verifies both readers are settled.
In `@cuprum/unittests/test_timeout_capture_contract.py`:
- Around line 143-151: Update consume_forever to explicitly consume the required
on_line callback parameter before awaiting, while keeping its name and signature
unchanged for _spawn_stream_consumers compatibility. Use del on_line or a
narrowly scoped suppression so Ruff no longer reports ARG001.
🪄 Autofix
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: c4e7b5cd-1cee-4717-aa2d-f0fc2da15bf1
📒 Files selected for processing (7)
CHANGELOG.mdcuprum/_subprocess_execution.pycuprum/unittests/__snapshots__/test_maturin_build.ambrcuprum/unittests/test_subprocess_timeout.pycuprum/unittests/test_subprocess_timeout_properties.pycuprum/unittests/test_timeout_capture_contract.pydocs/developers-guide.md
🔗 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 EOF grace window this branch added to `_drain_stream_consumers` is the one place a timed-out capturing run suspends while it still owns two reader tasks, and it had no protection. `asyncio.wait` does not cancel what it waits on, so a `CancelledError` arriving during the window propagated straight out of the drain, past the cancel call and the gather that follow it. Both readers were left running and unawaited, outliving the cancelled `run()` that spawned them — the owned-lifetime rule the rest of the teardown code obeys. Reconcile them on that path before letting the cancellation continue. The cancel-and-drain pair is extracted into `_settle_consumers` so the grace-interrupted path and the normal path settle the readers identically rather than by two similar-looking copies. Suppressing a second cancellation during that settle is sufficient here, unlike the shielded teardowns in `_process_lifecycle`. Those guard a multi-step process teardown — SIGTERM, grace, SIGKILL, reap — that only completes while something keeps awaiting it, so abandoning it strands an OS process. Here the work is `Task.cancel()`, which has already been requested synchronously by the time the gather begins; a cancelled task settles at its next scheduling turn whether or not anyone awaits it, and the await exists only to retrieve the results. Nothing outside the event loop can be stranded, so the shield-and-loop discipline would buy nothing. The regression test cancels a capturing drain mid-window and asserts both that the `CancelledError` still propagates and that neither reader is left running. Mutation-verified: with the unguarded `await asyncio.wait(...)` restored, it fails with both consumers still pending. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@coderabbitai review |
✅ Action performedReview finished.
|
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/_subprocess_execution.py`:
- Around line 92-105: Update _decode_consumer_result to use structural pattern
matching with match/case for dispatching result values, replacing the current
isinstance and None checks. Preserve the existing behavior: BaseException or
None returns an empty string when capture is true and None otherwise, while a
string result is returned unchanged.
- Around line 144-147: Update _settle_consumers and _decode_consumer_result so
cancellation of a reader that already buffered output preserves and returns that
partial data instead of becoming an empty string. Add a regression test covering
output written before an inherited pipe delays EOF, and verify the captured
output remains available after consumer cancellation.
In `@cuprum/unittests/test_timeout_capture_contract.py`:
- Around line 84-86: Complete the sentence in the timeout-capture contract
description by changing “the pipe's EOF events are:” to “the pipe's EOF events
are processed:”.
🪄 Autofix
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: 83f1d1fa-3ee1-47e1-a44c-b71ed93e25a1
📒 Files selected for processing (7)
CHANGELOG.mdcuprum/_subprocess_execution.pycuprum/unittests/__snapshots__/test_maturin_build.ambrcuprum/unittests/test_subprocess_timeout.pycuprum/unittests/test_subprocess_timeout_properties.pycuprum/unittests/test_timeout_capture_contract.pydocs/developers-guide.md
🔗 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)
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
`make spelling` regenerates `typos.toml` from the shared base plus `typos.local.toml`, but the inline-code-span exemption lived only in the generated file — hand-edited in, so every regeneration deleted it and three backtick-quoted identifiers (`artifact`, `color`) failed the gate on a clean checkout. The pattern now lives in the overlay's `[patterns] ignore`, which the generator merges and preserves. Verified non-vacuous: a seeded violation outside a code span still fails the gate. Closes #294. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both files that own a stream drain sat against the 400-line module ceiling: `_streams.py` at 399 lines, and `_subprocess_execution.py` at 415 behind a `too-many-lines` suppression. Neither could take another line, and the capture fixes that follow need several. Split each along a seam that already existed. The synchronous decoding, echo-sink, and line-splitting helpers move to `cuprum/_stream_text.py`, leaving `_streams.py` holding the asyncio read loops that drive them. The consumer settlement helpers — cancel, drain once, decode — move to `cuprum/_subprocess_drain.py`, the single point every teardown path in `_subprocess_execution.py` already went through. Pure moves: no behaviour changes. `_subprocess_execution.py` is back under the ceiling, so its suppression is gone; the issue #30 TODO stays, because the stdin/runner split it asks for has not happened.
A capturing reader parked in `read()` past the EOF grace window is cancelled by teardown, and `_drain` discarded its buffer along with it: the `CancelledError` propagated, `_decode_consumer_result` mapped it to `""`, and a run that had genuinely captured output reported none. Output written before the deadline is output the run captured; losing it to the cancellation that guarantees teardown leaves no reader behind is a poor trade. A capturing `_drain` now catches the cancellation at its read and returns what it buffered. Nothing is awaited after the catch, so the task still settles on the turn the cancellation asked for. The exemption stops there: a non-capturing drain has nothing to salvage and still propagates, as do the pump and relay paths, where swallowing a cancellation would strand a stage. A capturing reader therefore completes normally rather than reporting `task.cancelled()`, leaving `_decode_consumer_result`'s exception branch to genuine failures — restated there, and now written with `match`/`case` as the Python path instructions ask.
`test_subprocess_timeout_properties` had grown past the project's 400-line ceiling while holding two unrelated concerns: the timeout waiter's deadline arithmetic and the consumer drain's settlement invariants. Its own docstring already cited that ceiling as the reason it split from `test_subprocess_timeout`. Move the drain properties to `test_subprocess_drain_properties`, along the seam the section banner already marked. Pure move; both modules are back under the ceiling, and the drain now has somewhere to gain the capturing cases it still lacks.
Both drain properties hard-coded `capture=False`, so the contract that actually broke under 3.15.0rc1 — a capturing run reports its streams as text — had only example-based coverage. Add a capturing property over the same consumer states, asserting each stream decodes to a string rather than `None` and to the text that state should have left behind. It samples one further state: `partial`, a reader cancelled while holding a buffer it never got to finish. That kind runs the production drain rather than standing in for it, because the text such a reader keeps is exactly what the production code decides, and Hypothesis generates the text it keeps.
The drain runs while a failure is already propagating, so it can neither raise what it finds nor report it: a reader that broke decodes to the same empty string as one that simply had nothing to say. Nothing recorded the difference, and `gather(..., return_exceptions=True)` has swallowed reader exceptions unexamined since #22 — long before this branch narrowed the gap by mapping a capturing failure to `""` rather than `None`. Record two things at DEBUG, in the shape `_log_suppressed_stream_close_error` established: `stream_consumer_failed` for a settled reader whose result is an exception other than a plain `CancelledError`, and `capture_eof_grace_expired` counting the readers still parked when the window closed. Cancellation stays unrecorded — every teardown cancels something, so recording it would make the record routine enough to ignore. Neither is a metric or a trace event; that belongs with the `ExecEvent` contract in #285 and #286, not with a teardown helper. The grace window moves into `_await_eof_grace` so the wait and its diagnosis sit together, and `_drain` sheds its per-chunk echo and callback branches to `_tee_chunk`, keeping it inside the complexity limit now that it handles cancellation too.
The users' guide promised partial output on `TimeoutExpired` but said nothing about what a capturing run reports when a reader had nothing, nor that Cuprum waits a bounded window for end-of-file before cancelling readers. Both are user-visible: `exc.output` is now always a string under `capture=True`, and a reader cancelled at the window's close still yields what it had read. Also read the drain's structured log fields through `record.__dict__.get`, as `test_backend` already does, rather than as attributes `ty` cannot know a `LogRecord` carries.
|
@coderabbitai Responses to the pre-merge checks, evaluated against head Unit Architecture (Error) — split response(a) Injecting the EOF-grace policy: declined, as scope. The precedent for injection exists and I want to be honest that it does —
If a concrete case turns up — a workload where the readers reliably need longer, or a test that must drive the window — that is a good argument for the field, and I will add it. Please point at one if you have it. (b) BaseException collapsed to I checked whether the swallowing is ours before deciding what to do about it. It predates this PR. What did arrive with this PR, in Rather than redesign the settlement path while a failure is propagating through it, the drain now says what it discards. Testing (Property / Proof) (Warning) — addedCorrect, and cheap to fix. Observability (Warning) — minimal DEBUG added, metrics declinedThere was no logging in this module at all, which was a fair hit. Two DEBUG records now, both on the
Metrics and trace events are declined here deliberately. Emitting them belongs with the User-Facing Documentation (Inconclusive) — resolved by documenting itYour read was right that the guide was silent on the new behaviour. Gates on One structural note worth flagging: |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '== HEAD =='
git rev-parse HEAD
git status --short
printf '%s\n' '== Changed files against merge base =='
base="$(git merge-base origin/main HEAD)"
git diff --name-status "$base"...HEAD
printf '%s\n' '== Relevant module outlines =='
fd -a '(_subprocess_drain|_subprocess_execution|_stream_text)\.py$' cuprum -x ast-grep outline {} --items all --type function --view compact
printf '%s\n' '== Drain implementation and logging references =='
rg -n -C 5 \
'(_drain_stream_consumers|_settle_consumers|_decode_consumer_result|stream_consumer_failed|capture_eof_grace_expired|_log_suppressed_stream_close_error)' \
cuprum
printf '%s\n' '== Tests and user documentation references =='
rg -n -C 3 \
'(test_capturing_drain_reports_every_consumer_as_text|test_subprocess_drain_logging|output.*stderr|EOF|end-of-file|capture_eof_grace_expired)' \
cuprum/unittests docs/users-guide.md
printf '%s\n' '== Public call sites of the drain =='
rg -n -C 3 '\b_drain_stream_consumers\s*\(' cuprumLength of output: 1458 🏁 Script executed: #!/bin/bash
set -u
printf '%s\n' '== Relevant files =='
fd -a '(_subprocess_drain|_subprocess_execution|_stream_text)\.py$' cuprum
fd -a '(test_subprocess_drain_logging|test_timeout_capture_contract|test_subprocess_timeout|test_subprocess_timeout_properties)\.py$' cuprum/unittests
printf '%s\n' '== Implementation =='
rg -n -C 8 \
'(_drain_stream_consumers|_settle_consumers|_decode_consumer_result|stream_consumer_failed|capture_eof_grace_expired|_log_suppressed_stream_close_error)' \
cuprum/_subprocess_drain.py cuprum/_subprocess_execution.py 2>&1 || true
printf '%s\n' '== Drain call sites =='
rg -n -C 4 '\b_drain_stream_consumers\s*\(' cuprum 2>&1 || true
printf '%s\n' '== Regression and logging tests =='
rg -n -C 6 \
'(test_capturing_drain_reports_every_consumer_as_text|test_subprocess_drain_logging|stream_consumer_failed|capture_eof_grace_expired|partial)' \
cuprum/unittests 2>&1 || true
printf '%s\n' '== User documentation =='
rg -n -C 5 \
'(TimeoutExpired|exc\.output|exc\.stderr|end-of-file|EOF|capture=True|capture)' \
docs/users-guide.md 2>&1 || true
printf '%s\n' '== Recent commits and changed-file names =='
git log --oneline -12 2>&1 || true
git diff --name-status HEAD~12..HEAD 2>&1 || trueLength of output: 75550
The fixed internal EOF grace remains appropriate. Do not add Keep the Keep the capture-aware drain split. The timeout path waits for EOF. The cancellation and stdin-writer-failure paths retain prompt teardown. The property test covers completed, pending, failing, and partial consumers. The logging tests cover failure records, cancellation exclusion, and EOF-grace expiry. The user guide now states the I verified these changes at ✏️ Learnings added
You are interacting with an AI system. |
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 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_subprocess_drain_logging.py`:
- Around line 45-48: Remove the explicit `return None` from
`_never_reaches_eof`; leave the function ending after the awaited event so its
`None` result is implicit and complies with R501.
In `@cuprum/unittests/test_subprocess_drain_properties.py`:
- Around line 59-71: Replace the conditional dispatch in the consumer-kind
helper with a match statement on kind, preserving the existing completed,
pending, failing, and partial behavior and the unsupported-kind ValueError
fallback.
- Around line 200-208: Update the run_case test around _drain_while_raising to
yield control to the event loop after creating the consumer tasks and before
invoking the drain, allowing the failing consumer to reach its exception first.
Preserve the primary TimeoutError assertion and ensure the test still verifies
cleanup without letting the scenario remain cancellation-only.
In `@cuprum/unittests/test_timeout_capture_contract.py`:
- Around line 208-218: Replace the four asyncio.sleep(0) polling turns in both
capturing and non-capturing cancellation tests with a blocked-read barrier.
Update the reader stub to expose an asyncio.Event when its post-buffer read
begins waiting, then await that event before calling drain.cancel() so
cancellation is tested only after _drain has consumed the buffer and blocked on
EOF.
🪄 Autofix
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: 2fe3d3b7-9b34-4977-9233-de1287ea813b
📒 Files selected for processing (14)
cuprum/_stream_text.pycuprum/_streams.pycuprum/_subprocess_drain.pycuprum/_subprocess_execution.pycuprum/_testing.pycuprum/unittests/__snapshots__/test_maturin_build.ambrcuprum/unittests/test_subprocess_drain_logging.pycuprum/unittests/test_subprocess_drain_properties.pycuprum/unittests/test_subprocess_timeout.pycuprum/unittests/test_subprocess_timeout_properties.pycuprum/unittests/test_timeout_capture_contract.pydocs/developers-guide.mddocs/users-guide.mdtypos.local.toml
🔗 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)
| async def _never_reaches_eof() -> str | None: | ||
| """Block as a reader does on a pipe whose EOF never arrives.""" | ||
| await asyncio.Event().wait() | ||
| return None |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Remove the explicit None return.
Delete Line 48. _never_reaches_eof has no non-None return path, so the
explicit return None violates R501.
Proposed fix
async def _never_reaches_eof() -> str | None:
"""Block as a reader does on a pipe whose EOF never arrives."""
await asyncio.Event().wait()
- return NoneBased on learnings and coding guidelines, functions that only return None
must use implicit None or a bare return, not return None.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async def _never_reaches_eof() -> str | None: | |
| """Block as a reader does on a pipe whose EOF never arrives.""" | |
| await asyncio.Event().wait() | |
| return None | |
| async def _never_reaches_eof() -> str | None: | |
| """Block as a reader does on a pipe whose EOF never arrives.""" | |
| await asyncio.Event().wait() |
🤖 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_drain_logging.py` around lines 45 - 48,
Remove the explicit `return None` from `_never_reaches_eof`; leave the function
ending after the awaited event so its `None` result is implicit and complies
with R501.
Sources: Coding guidelines, Learnings
| if kind == "completed": | ||
| await asyncio.sleep(0) | ||
| return text | ||
| if kind == "pending": | ||
| await asyncio.Event().wait() | ||
| return text | ||
| if kind == "failing": | ||
| await asyncio.sleep(0) | ||
| raise _ConsumerFailureError | ||
| if kind == "partial": | ||
| return await _partial_capture(text) | ||
| msg = f"unsupported consumer kind: {kind!r}" | ||
| raise ValueError(msg) |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Replace the multi-branch dispatch with match.
Replace the four imperative branches with match kind cases. This helper
dispatches a fixed set of consumer states.
Proposed refactor
- if kind == "completed":
- await asyncio.sleep(0)
- return text
- if kind == "pending":
- await asyncio.Event().wait()
- return text
- if kind == "failing":
- await asyncio.sleep(0)
- raise _ConsumerFailureError
- if kind == "partial":
- return await _partial_capture(text)
- msg = f"unsupported consumer kind: {kind!r}"
- raise ValueError(msg)
+ match kind:
+ case "completed":
+ await asyncio.sleep(0)
+ return text
+ case "pending":
+ await asyncio.Event().wait()
+ return text
+ case "failing":
+ await asyncio.sleep(0)
+ raise _ConsumerFailureError
+ case "partial":
+ return await _partial_capture(text)
+ case _:
+ msg = f"unsupported consumer kind: {kind!r}"
+ raise ValueError(msg)As per path instructions, “Prefer structural pattern matching over
isinstance() or imperative decomposition.”
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if kind == "completed": | |
| await asyncio.sleep(0) | |
| return text | |
| if kind == "pending": | |
| await asyncio.Event().wait() | |
| return text | |
| if kind == "failing": | |
| await asyncio.sleep(0) | |
| raise _ConsumerFailureError | |
| if kind == "partial": | |
| return await _partial_capture(text) | |
| msg = f"unsupported consumer kind: {kind!r}" | |
| raise ValueError(msg) | |
| match kind: | |
| case "completed": | |
| await asyncio.sleep(0) | |
| return text | |
| case "pending": | |
| await asyncio.Event().wait() | |
| return text | |
| case "failing": | |
| await asyncio.sleep(0) | |
| raise _ConsumerFailureError | |
| case "partial": | |
| return await _partial_capture(text) | |
| case _: | |
| msg = f"unsupported consumer kind: {kind!r}" | |
| raise ValueError(msg) |
🤖 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_drain_properties.py` around lines 59 - 71,
Replace the conditional dispatch in the consumer-kind helper with a match
statement on kind, preserving the existing completed, pending, failing, and
partial behavior and the unsupported-kind ValueError fallback.
Source: Path instructions
| async def run_case() -> None: | ||
| """Drain failing consumers while a TimeoutError is propagating.""" | ||
| consumers = ( | ||
| asyncio.create_task(_make_consumer(stdout_kind, "out")), | ||
| asyncio.create_task(_make_consumer(stderr_kind, "err")), | ||
| ) | ||
| primary = TimeoutError("primary") | ||
| with pytest.raises(TimeoutError) as exc_info: | ||
| await _drain_while_raising(primary, consumers) |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Exercise failed consumers before cleanup.
Yield the event loop before raising primary. At Line 208, the finally block
drains newly created tasks before a "failing" consumer reaches its exception.
The test therefore verifies cancellation only and does not prove that a
completed reader failure cannot replace the primary error.
Proposed fix
consumers = (
asyncio.create_task(_make_consumer(stdout_kind, "out")),
asyncio.create_task(_make_consumer(stderr_kind, "err")),
)
+ for _ in range(4):
+ await asyncio.sleep(0)
primary = TimeoutError("primary")
with pytest.raises(TimeoutError) as exc_info:
await _drain_while_raising(primary, consumers)As per coding guidelines, new behavioural coverage must be substantive and
non-vacuous.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async def run_case() -> None: | |
| """Drain failing consumers while a TimeoutError is propagating.""" | |
| consumers = ( | |
| asyncio.create_task(_make_consumer(stdout_kind, "out")), | |
| asyncio.create_task(_make_consumer(stderr_kind, "err")), | |
| ) | |
| primary = TimeoutError("primary") | |
| with pytest.raises(TimeoutError) as exc_info: | |
| await _drain_while_raising(primary, consumers) | |
| async def run_case() -> None: | |
| """Drain failing consumers while a TimeoutError is propagating.""" | |
| consumers = ( | |
| asyncio.create_task(_make_consumer(stdout_kind, "out")), | |
| asyncio.create_task(_make_consumer(stderr_kind, "err")), | |
| ) | |
| for _ in range(4): | |
| await asyncio.sleep(0) | |
| primary = TimeoutError("primary") | |
| with pytest.raises(TimeoutError) as exc_info: | |
| await _drain_while_raising(primary, consumers) |
🤖 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_drain_properties.py` around lines 200 - 208,
Update the run_case test around _drain_while_raising to yield control to the
event loop after creating the consumer tasks and before invoking the drain,
allowing the failing consumer to reach its exception first. Preserve the primary
TimeoutError assertion and ensure the test still verifies cleanup without
letting the scenario remain cancellation-only.
Source: Coding guidelines
| drain = asyncio.create_task(_drain(reader, config)) | ||
| # Let the drain consume the buffered bytes and park on the next read. | ||
| for _ in range(4): | ||
| await asyncio.sleep(0) | ||
| assert not drain.done(), "the drain must still be waiting for EOF" | ||
|
|
||
| drain.cancel() | ||
|
|
||
| captured = await drain | ||
| assert captured == _PARTIAL_TEXT, ( | ||
| f"a cancelled capturing read must keep its buffer, got {captured!r}" |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Replace turn-count polling with a blocked-read barrier.
Do not use four asyncio.sleep(0) turns as proof that _drain consumed
_PARTIAL_TEXT and reached its next read(). A scheduler can leave the task
unstarted. The capturing test can then raise CancelledError, and the
non-capturing test can pass without exercising cancellation during a blocked
read.
Expose an asyncio.Event from the reader stub when its post-buffer read() is
waiting. Await that event before calling drain.cancel() in both tests.
Based on the PR objective, avoid relying on event-loop scheduling.
Also applies to: 235-243
🤖 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_timeout_capture_contract.py` around lines 208 - 218,
Replace the four asyncio.sleep(0) polling turns in both capturing and
non-capturing cancellation tests with a blocked-read barrier. Update the reader
stub to expose an asyncio.Event when its post-buffer read begins waiting, then
await that event before calling drain.cancel() so cancellation is tested only
after _drain has consumed the buffer and blocked on EOF.
Why
The
Typecheck and test (Python 3.15a)CI job moved from Python 3.15.0b2 to3.15.0rc1 and now fails on
mainitself(run 31058537773),
and therefore on every pull request. Four parametrizations of
test_non_positive_timeout_at_public_boundaryfail — thecapture=Truecasesfor both
run()andrun_sync(), at timeouts0and-1.0:This is the blocker for CI on #243 and #244.
The mechanism
_run_subprocess_with_streamsspawns a stdout and a stderr consumer task, waitsfor the exit code, and on expiry terminates the process and calls
_drain_stream_consumers. That helper cancelled every reader not alreadydone()and mapped a cancelled reader toNone.That mapping was only ever harmless because of a scheduling coincidence. The
process is dead by then, so its pipes are closed, and on 3.12 through 3.14 the
readers observe end-of-file and complete before the drain inspects them.
Nothing in the code enforced that ordering. Under 3.15.0rc1 the process exit is
observed before the pending end-of-file events on the stdout and stderr pipes
have been processed, so both readers are still parked in
read()when the drainruns. It cancels them and reports
Nonefor both streams — exactly thetwo-
Nonesignature in the failure.Note the scope: this is not confined to non-positive timeouts. The ordinary
positive-timeout path drains through the same helper, so on rc1 a
run(timeout=5)against a command that produced output and then hung wouldraise
TimeoutExpiredwith that output discarded. The non-positive cases aremerely where the suite happens to look.
The fix
_drain_stream_consumersnow takes acaptureflag, and a capturing drain doestwo things a non-capturing one does not.
_CAPTURE_EOF_GRACE_S(0.25 s) for the readers to reachend-of-file before cancelling them. The process is already dead, so
end-of-file is imminent rather than hypothetical, and a reader cancelled one
scheduling turn short of it loses the capture it was about to deliver. The
window is bounded because a grandchild that inherited the pipe can wedge a
reader indefinitely, and teardown must never wait on that.
None, so the documented "a capturing run reports its streams as text"contract holds by construction rather than by scheduling luck.
The cancellation and stdin-failure paths pass
capture=False: they discard thedrained text, so they pay neither the window nor the contract and teardown stays
as prompt as before.
Both halves are version-neutral. Nothing here is conditioned on the interpreter
version, and 3.12 to 3.14 behaviour is unchanged — on those versions the readers
were already finished, so the window returns immediately and the fallback never
fires.
test_safe_cmd_run.py's assertions are untouched; they state the intendedcontract and the fix is what now satisfies it.
Regression tests
New file
cuprum/unittests/test_timeout_capture_contract.py. Rather thansimulating rc1's scheduling, it withholds end-of-file outright, which reproduces
the worst case on every interpreter and so fails on the old code unconditionally:
end-of-file;
their capture;
None;never observe end-of-file, a capturing
run_sync(timeout=0)still reports bothstreams as text — this covers the wiring that tells the drain a run is
capturing.
Mutation proof: reverting both halves of the fix in
cuprum/_subprocess_execution.py(immediate cancel,Noneon absence) failsthree of the four, and the public-boundary one fails with
got output=None stderr=None— byte-for-byte the CI symptom, on Python 3.14.Validation
rc1 cannot be run locally:
uv python install 3.15.0rc1reportsNo download found for request: cpython-3.15rc1-linux-x86_64-gnu, aspython-build-standalone has not published it. So the rc1 job cannot be confirmed
green before merge; CI is the oracle. Instead:
CI failure's exact two-
Nonesignature;withholding end-of-file;
check-fmt,lint,typecheck,test,markdownlint,nixie, andcs delta origin/main HEAD("No issues found!").mbake format --check Makefilefails identically onorigin/main, whoseMakefilethis branch does not touch.References
main:run 31058537773
Summary by Sourcery
Ensure capturing subprocess runs always report captured stdout and stderr as text on timeout instead of dropping partial output based on event-loop scheduling.
Bug Fixes:
None, fixing failures on newer Python versions.Enhancements:
Documentation:
Tests:
Chores: