Property tests for subprocess timeout & fail-fast reducers (#75) - #245
Conversation
The subprocess timeout and pipeline fail-fast paths are temporal and branch-heavy, with no isolated seam for their decisions. Extract two pure reducers so they can be property-tested without process state or a clock. _subprocess_timeout.py: _resolve_timeout_payload(exc, _TimeoutFallback) is the timeout-payload seam. A _SubprocessTimeoutError carries a payload that is used verbatim; a bare TimeoutError is resolved from a _TimeoutFallback (configured timeout, captured stdout/stderr, injected exit-time clock). Either branch yields a concrete timeout, so downstream TimeoutExpired reporting is consistent. _handle_subprocess_timeout now calls it and keeps the event-emit and raise side effects. _process_lifecycle.py: _stages_to_terminate(failure_index, done) selects which stages get a termination task — every stage except the failed one and any already-finished stage, each at most once. _terminate_pipeline_remaining_stages drives it (keeping the strict-length zip for scheduling). Add cuprum/unittests/test_subprocess_timeout_reducers.py with Hypothesis property tests proving: timeout-payload consistency (carried payload wins and ignores the fallback; bare timeouts resolve from the fallback; a missing configured timeout is an invariant error), the fail-fast selection is exactly the running non-failed stages with no double scheduling, and cleanup is idempotent (a second pass over settled stages selects nothing). Regenerate the maturin wheel-manifest snapshot for the new test file. Closes #75 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
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
WalkthroughRefactor timeout payload normalisation and fail-fast stage selection into dedicated helpers. Route timeout handling through resolved details, select only running pipeline stages for termination, and add Hypothesis and CrossHair verification with documentation and wheel snapshot updates. ChangesTimeout and fail-fast lifecycle reducers
### Sequence Diagram(s) sequenceDiagram
participant TimeoutHandler
participant TimeoutResolver
participant PipelineCleanup
participant StageSelector
TimeoutHandler->>TimeoutResolver: resolve timeout details
TimeoutResolver-->>TimeoutHandler: concrete payload
PipelineCleanup->>StageSelector: select unfinished stages
StageSelector-->>PipelineCleanup: termination targets
Possibly related PRs
Suggested labels: Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 20✅ Passed checks (20 passed)
📋 Issue PlannerBuilt with CodeRabbit's Coding Plans for faster development and fewer bugs. View plan used: ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Reviewer's GuideExtracts two pure reducer helpers for subprocess timeout payload resolution and pipeline fail-fast termination selection, updates the timeout handler and termination logic to use them, and adds Hypothesis-based property tests to validate timeout payload consistency, no double-termination, and idempotent cleanup. Sequence diagram for updated subprocess timeout handlingsequenceDiagram
participant Caller
participant _handle_subprocess_timeout
participant _resolve_timeout_payload
participant _emit_exit_event
participant _raise_timeout_expired
participant _get_exit_code
Caller->>_handle_subprocess_timeout: exc, ctx
_handle_subprocess_timeout->>_resolve_timeout_payload: exc, _TimeoutFallback
_resolve_timeout_payload-->>_handle_subprocess_timeout: _SubprocessTimeoutDetails payload
_handle_subprocess_timeout->>_get_exit_code: ctx.process
_get_exit_code-->>_handle_subprocess_timeout: exit_code
_handle_subprocess_timeout->>_emit_exit_event: _SubprocessExitEvent
_handle_subprocess_timeout->>_raise_timeout_expired: _TimeoutContext, exc
_raise_timeout_expired-->>Caller: TimeoutExpired (exception)
Sequence diagram for updated fail-fast pipeline termination selectionsequenceDiagram
participant Caller
participant _terminate_pipeline_remaining_stages
participant _stages_to_terminate
participant asyncio_create_task as asyncio.create_task
participant _terminate_process_via_wait_task
participant asyncio_gather as asyncio.gather
Caller->>_terminate_pipeline_remaining_stages: processes, wait_tasks, failure_index, cancel_grace
_terminate_pipeline_remaining_stages->>_stages_to_terminate: failure_index, [wait_task.done()]
_stages_to_terminate-->>_terminate_pipeline_remaining_stages: list[int] targets
loop for each idx in targets
_terminate_pipeline_remaining_stages->>asyncio_create_task: _terminate_process_via_wait_task(process, wait_task, cancel_grace)
asyncio_create_task->>_terminate_process_via_wait_task: process, wait_task, cancel_grace
end
_terminate_pipeline_remaining_stages->>asyncio_gather: termination_tasks
asyncio_gather-->>Caller: termination completed
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
test_resolve_uses_carried_payload_for_subprocess_timeout_error took 7 @given arguments (threshold 4). Draw the captured details from a _timeout_details composite strategy (one argument) and use a fixed, distinctive fallback: configured_timeout=None would raise if the carried branch ever consulted it, so no raise plus == details proves the fallback is never touched. Code health 10.0. Co-Authored-By: Claude Opus 4.8 (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_timeout.py`:
- Around line 149-176: Update _resolve_timeout_payload to use structural
match/case for the timeout variants instead of isinstance(). Keep the
_SubprocessTimeoutError case first, preserving its captured payload fields, then
handle the bare TimeoutError using fallback and _require_timeout.
In `@cuprum/unittests/test_subprocess_timeout_reducers.py`:
- Around line 34-35: Rename the module-level Hypothesis strategies
_finite_floats and _optional_text to private UPPER_SNAKE_CASE names, and update
every reference to use the new names consistently.
- Line 67: Update the assertions in the affected tests, including the
payload/details comparison and the ranges at the referenced locations, to
include concise descriptive messages using the existing invariant each assertion
verifies. Preserve the assertion conditions while replacing every bare assert
with an assert that supplies a failure message, so Hypothesis counterexamples
identify the violated invariant.
🪄 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: f3e2f866-1342-4433-8d31-270c6185e12d
📒 Files selected for processing (4)
cuprum/_process_lifecycle.pycuprum/_subprocess_timeout.pycuprum/unittests/__snapshots__/test_maturin_build.ambrcuprum/unittests/test_subprocess_timeout_reducers.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 Have the following failed checks now been resolved? If further work is required, please provide an AI agent prompt for the remaining work to be done to address these failures. Do not treat warnings as optional or aspirational. Where a change is out of scope for this PR, propose a GitHub issue unless one exists already. (Treat documentation and validation coverage as in scope). #75 Apply CrossHair verification to the extracted transition data / reducers involved in timeout and fail-fast lifecycle handling. ❌ The PR adds pure reducers ( |
This comment was marked as resolved.
This comment was marked as resolved.
Document the two seams this branch extracts with sequence diagrams, each carrying a screen-reader caption describing the flow in prose alongside the numbered figure caption used elsewhere in section 8. - Figure 4, in 8.1.4 Timeouts: subprocess timeout handling, from _resolve_timeout_payload through _get_exit_code and _emit_exit_event to _raise_timeout_expired. Following prose notes that both timeout variants converge on one payload, so TimeoutExpired always reports a concrete timeout. - Figure 5, at the end of 8.2: fail-fast termination selection via the pure _stages_to_terminate reducer, then task creation and gather. Following prose records why the selection is testable without processes and why cleanup is idempotent. Renumbered the two later figures (concurrent execution flow and fail-fast cancellation) from 4 and 5 to 6 and 7 so captions stay in reading order. No prose cross-references figure numbers, so nothing else needed updating. Corrected one label against the implementation: _emit_exit_event takes _ExitEventDetails; there is no _SubprocessExitEvent type in the codebase. The create_task message elides its arguments to stay inside the 120-column Markdown limit; the following message already spells them out. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record the seam this branch extracts in design-doc 8.4, where the telemetry adapter decisions already live. Adds an "Event-to-operation reduction" subsection stating why the split exists — the pure _metric_operations reducer decides what to record and _apply is the only step that reaches the collector, so the mapping is property-testable without one — plus the two consequences worth pinning: labels are projected only when the reducer yields an operation, so a plan event never touches them, and an unrecognized phase raises rather than being silently dropped. The sequence diagram carries a screen-reader caption describing the whole flow in prose, including the empty-tuple early return and which collector call each operation variant becomes. The caption is deliberately unnumbered rather than continuing the Figure N sequence used elsewhere in section 8. PR #245 renumbers the later figures in that section, so any number chosen here would be wrong under one merge order; no prose cross-references figure numbers, and section 13 already uses unnumbered screen-reader captions. Worth a tidying pass once both land. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes issue #75's CrossHair criterion. The Hypothesis module is unchanged; this adds bounded symbolic verification alongside it. Five PEP 316 contracts, executed through check_states and required to return MessageType.CONFIRMED: _resolve_timeout_payload — a carried _SubprocessTimeoutError returns its own timeout, stdout, stderr, and exit time verbatim (the fallback in that contract holds different values and a None configured timeout, so a resolver consulting it would return a wrong field or raise); a bare TimeoutError with a configured timeout returns the fallback's four fields exactly; a bare TimeoutError without one raises _SubprocessInvariantError. _stages_to_terminate — the selection is in range, unique, ordered, excludes the failure index, contains only stages whose done flag is False, and equals exactly the unfinished non-failed set; and cleanup is idempotent, since marking the selected stages done makes a second pass select nothing. The domains are bounded so CrossHair exhausts them rather than returning CANNOT_CONFIRM: at most three stages with failure_index constrained by precondition, done flags encoded as one bounded integer bitmask rather than a symbolic list of symbolic booleans, and three-value enumerations for times and text (including None) since the reducers only copy those values while keeping carried and fallback distinguishable. Verified genuine rather than assumed: making the carried branch read the fallback's stdout, and dropping the failed-stage exclusion from the selection, each yield POST_FAIL instead of CONFIRMED. The module matches PYTEST_TARGETS' cuprum/unittests/test_*.py glob, so make test collects and runs it — the checks are automated, not just documented. Availability uses the shared _crosshair_support.py probe (byte-identical to the copy on the #243 branch, so the two merge cleanly), which degrades to a skip only for a missing dependency or an untraceable interpreter; anything else, including CANNOT_CONFIRM, fails. Documents the verified invariants, both commands, and the deliberate bounds in the developers guide, and regenerates the wheel manifest for the two new files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Completed the CrossHair verification requirement for #75 in 25a4509. The existing Hypothesis module is untouched — this adds symbolic verification alongside it, not instead of it. Contracts
The independence requirement is enforced structurally rather than asserted: the fallback in the carried contract holds different values and a
The checks actually verifyI mutation-checked rather than trusting five green ticks. Both mutants yield
Deliberate boundsKept small and finite so CrossHair exhausts the space rather than returning
Automated, not just documentedThe module matches the Note: Validation
The wheel-manifest snapshot was regenerated for the two new files under Leaving #75 open until this is reviewed and merged, per the instruction to close it only once the requirement is demonstrably satisfied. |
Three review findings, all valid against current code. _resolve_timeout_payload dispatched on the timeout variant with isinstance, but the code it was extracted from — _handle_subprocess_timeout before this branch — used a structural match/case for exactly this dispatch. Restore that idiom: the _SubprocessTimeoutError case comes first and binds its captured payload fields by pattern, and the remaining case resolves from the fallback through _require_timeout. The catch-all keeps the function total, so every path returns a payload rather than falling through. Behaviour is unchanged, and the CrossHair contracts still refute a mutant that reads the fallback's stdout in the carried branch. The module-level Hypothesis strategies were lowercase, against the _UPPER_SNAKE_CASE convention every other property module in cuprum/unittests uses (_LINES, _SEGMENTS, _COMPRESSIONS, _OUTPUT_OPTIONS, _CONSUMER_OUTCOME, and others). Renamed to _FINITE_FLOATS and _OPTIONAL_TEXT with all references updated. Every bare assertion in the module now carries a message naming the invariant it verifies, so a Hypothesis counterexample reports which property failed rather than only the values that broke it. That covers the carried-payload comparison, the four fallback field assertions, the four selection assertions, and the idempotence assertion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
All three findings verified against current code and fixed in c773b57. None were stale. 1. Structural
|
| Command | Outcome |
|---|---|
uv run pytest cuprum/unittests/test_subprocess_timeout_reducers.py |
5 passed |
uv run pytest cuprum/unittests/test_subprocess_timeout_reducers_crosshair.py |
5 passed, all CONFIRMED |
uv run pytest cuprum/unittests/test_subprocess_timeout.py |
4 passed (existing timeout suite intact) |
make check-fmt |
pass |
make lint |
pass (ruff, interrogate 100%, pylint 10.00/10, clippy) |
make typecheck |
pass (ty clean — this is what the total-match point above protects) |
make test |
pass — Rust nextest 57/57, full Python suite green |
make markdownlint / make nixie |
pass |
Three CodeScene findings on the CrossHair contract module. _selection_is_well_formed bundled six predicates into one function, reaching a cyclomatic complexity of 10 against a threshold of 9. Split it along the natural seam: _selection_shape_is_valid covers in-range, no-repeats, and ordering, while _selection_membership_is_exact covers the failed-stage exclusion, the unfinished-only rule, and exact membership. The contract still calls one predicate. _carried_payload_wins and _carried_payload_contract each took five scalars. The four that index the carried payload's fields now travel as a single tuple in field order, leaving two arguments each. Bounding every tuple element in the preconditions preserves exactly the previous symbolic domain. Verified the refactors did not weaken the contracts rather than assuming it: making the carried branch read the fallback's stdout, and dropping the failed-stage exclusion from the selection, each still yield POST_FAIL instead of CONFIRMED. Module health returns to 10.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@coderabbitai I have self-validated the three Module-Level Documentation. Every module the PR adds or changes opens with a docstring, and the repository's Unit Architecture. The extracted reducers are pure functions in the modules that own the behaviour, not a new layer, and every touched module stays well inside the repository's 400-line cap ( Performance And Resource Use. Both reducers are pure, allocation-light and called once per event, not per byte or per poll — Against a subprocess spawn or a timeout wait these are far below the noise floor, and the extraction replaced inline branches of the same complexity, so there is no regression to measure against. |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '== Repository module-size guidance =='
rg -n -C 3 '400.{0,20}(line|Line)|module.{0,40}400' AGENTS.md || true
printf '%s\n' '== File line counts and first non-empty lines =='
for f in \
cuprum/_subprocess_timeout.py \
cuprum/_process_lifecycle.py \
cuprum/unittests/test_subprocess_timeout_reducers.py \
cuprum/unittests/test_subprocess_timeout_reducers_crosshair.py \
cuprum/unittests/_crosshair_support.py
do
printf '\n%s\n' "-- $f"
wc -l "$f"
sed -n '1,12p' "$f"
done
printf '%s\n' '== Structural map of changed implementation and test modules =='
ast-grep outline \
cuprum/_subprocess_timeout.py \
cuprum/_process_lifecycle.py \
cuprum/unittests/test_subprocess_timeout_reducers.py \
cuprum/unittests/test_subprocess_timeout_reducers_crosshair.py \
cuprum/unittests/_crosshair_support.py \
--items all
printf '%s\n' '== Reducer declarations and all in-repository call contexts =='
rg -n -C 8 \
'def (_resolve_timeout_payload|_stages_to_terminate)\b|_resolve_timeout_payload\(|_stages_to_terminate\(' \
cuprum
printf '%s\n' '== Reducer implementations and handler contexts =='
rg -n -C 20 \
'def _resolve_timeout_payload\b|def _handle_subprocess_timeout\b|def _stages_to_terminate\b|def _terminate_pipeline_remaining_stages\b' \
cuprum/_subprocess_timeout.py cuprum/_process_lifecycle.pyLength of output: 40391
Treat the reported microbenchmark figures as self-reported runtime measurements; static inspection independently confirms the allocation-light, non-I/O, linear/event-level characteristics underlying them. |
|
@coderabbitai review |
✅ Action performedReview finished.
|
Two diagram inaccuracies against the code they document. Figure 4 showed the caller passing `exc, ctx` to `_handle_subprocess_timeout`, which takes `ctx, exc`. A sequence diagram that inverts a signature is worse than no diagram, because a reader trusts it over the source. Figure 5 showed `asyncio.gather` unconditionally, but `_terminate_pipeline_remaining_stages` only gathers when `termination_tasks` is non-empty. The screen-reader description already documented that guard, so the diagram contradicted the prose beside it. Add the alternate branch so the empty-target case — every other stage already settled, which is the idempotent second pass — is visible. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@coderabbitai review |
✅ Action performedReview finished.
|
Record the seam this branch extracts in design-doc 8.4, where the telemetry adapter decisions already live. Adds an "Event-to-operation reduction" subsection stating why the split exists — the pure _metric_operations reducer decides what to record and _apply is the only step that reaches the collector, so the mapping is property-testable without one — plus the two consequences worth pinning: labels are projected only when the reducer yields an operation, so a plan event never touches them, and an unrecognized phase raises rather than being silently dropped. The sequence diagram carries a screen-reader caption describing the whole flow in prose, including the empty-tuple early return and which collector call each operation variant becomes. The caption is deliberately unnumbered rather than continuing the Figure N sequence used elsewhere in section 8. PR #245 renumbers the later figures in that section, so any number chosen here would be wrong under one merge order; no prose cross-references figure numbers, and section 13 already uses unnumbered screen-reader captions. Worth a tidying pass once both land. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record the seam this branch extracts in design-doc 8.4, where the telemetry adapter decisions already live. Adds an "Event-to-operation reduction" subsection stating why the split exists — the pure _metric_operations reducer decides what to record and _apply is the only step that reaches the collector, so the mapping is property-testable without one — plus the two consequences worth pinning: labels are projected only when the reducer yields an operation, so a plan event never touches them, and an unrecognized phase raises rather than being silently dropped. The sequence diagram carries a screen-reader caption describing the whole flow in prose, including the empty-tuple early return and which collector call each operation variant becomes. The caption is deliberately unnumbered rather than continuing the Figure N sequence used elsewhere in section 8. PR #245 renumbers the later figures in that section, so any number chosen here would be wrong under one merge order; no prose cross-references figure numbers, and section 13 already uses unnumbered screen-reader captions. Worth a tidying pass once both land. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Extract metrics event-to-operation reducer; stateful-test it (#78) MetricsHook.__call__ was a phase-dispatch that reached straight into the collector, with no seam to verify which counters and histograms each event yields across varied phase/order/pid combinations. Extract the pure event-to-operation reducer _metric_operations(event) -> tuple[_MetricOp, ...] (with _CounterOp/_HistogramOp records and a _PHASE_COUNTERS lookup for the unit-counter phases). __call__ now applies the reducer's operations, resolving labels only when there is at least one operation, so plan and unknown phases still compute no labels. The former _increment/_record_stdin_bytes/_record_exit helpers are removed; behaviour is unchanged and the existing test_metrics_adapter.py suite still passes. Add cuprum/unittests/test_metrics_adapter_stateful.py: - property tests pinning the operations produced per phase (unit counters, plan no-op, stdin bytes only when counted, exit failure/ duration only when present, unknown phase raises); - a Hypothesis RuleBasedStateMachine that streams random events through a real MetricsHook/InMemoryMetrics and checks the accumulated counters and histograms against an independent phase-count oracle — proving counters and observations are created exactly when intended. Regenerate the maturin wheel-manifest snapshot for the new test file. Closes #78 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Document the metrics event-to-operation reduction Record the seam this branch extracts in design-doc 8.4, where the telemetry adapter decisions already live. Adds an "Event-to-operation reduction" subsection stating why the split exists — the pure _metric_operations reducer decides what to record and _apply is the only step that reaches the collector, so the mapping is property-testable without one — plus the two consequences worth pinning: labels are projected only when the reducer yields an operation, so a plan event never touches them, and an unrecognized phase raises rather than being silently dropped. The sequence diagram carries a screen-reader caption describing the whole flow in prose, including the empty-tuple early return and which collector call each operation variant becomes. The caption is deliberately unnumbered rather than continuing the Figure N sequence used elsewhere in section 8. PR #245 renumbers the later figures in that section, so any number chosen here would be wrong under one merge order; no prose cross-references figure numbers, and section 13 already uses unnumbered screen-reader captions. Worth a tidying pass once both land. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Use a PEP 695 alias and structural phase dispatch Two review findings, both valid against current code. _MetricOp used the legacy assignment form for its union. The project targets Python 3.12 and already declares aliases with the PEP 695 type statement in cuprum/sh.py and cuprum/events.py, so this now matches. The alias is used only in annotations, and the module has postponed evaluation, so the lazy TypeAliasType introduces no runtime concern. _metric_operations dispatched through chained top-level ifs. Restore a match/case on the phase, which is the idiom used elsewhere in this module (_apply) and in _subprocess_timeout. The mapped unit-counter phases stay keyed by _PHASE_COUNTERS behind a guard clause rather than being repeated as a literal alternation in the pattern, so the metric names keep exactly one definition and cannot drift from the table. plan, the nested stdin byte-count check, exit via _exit_operations, and the unknown-phase _UnhandledMetricsPhaseError all behave as before. An earlier revision of this function was converted away from match to satisfy the complexity and return-count lints; keeping the table lookup as a guard clause rather than expanding it into separate cases holds the structure under both limits, and ruff is clean. Behaviour is unchanged, verified by mutation rather than assumed: dropping the stdin byte-count check and silently returning for an unknown phase each fail the existing property and stateful tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Document the metrics event-to-operation reducer in the guide The pre-merge Developer Documentation check noted that no developer-guide entry covers the new _metric_operations seam; only the design document records it. Extend the observability section, beside the existing note that MetricsHook consumes ExecEvent values, with the split this branch introduces: the pure reducer decides what to record, _apply is the only step that reaches the collector, and the stateful test drives random event streams through it against an independent phase-count oracle. Records the two consequences a future change must preserve — labels are projected only when an operation is yielded, and an unrecognized phase raises rather than being dropped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Make the phase-counter mapping read-only `_PHASE_COUNTERS` is documented as the single definition of these metric names, but a plain dict leaves that claim unenforced: any importing module could rewrite an entry and silently redirect a counter. Wrap it in `types.MappingProxyType` so the mapping matches its stated contract, per the project's preference for immutable module-level data. The annotation widens to `cabc.Mapping` accordingly; only `.get` is used at the call site, so nothing else changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add property coverage for the structured logging hook (#78) Closes the last gap in #78's title. Tracing already had stateful coverage before this PR and metrics gained it here, leaving the logging hook as the only adapter with no randomised event coverage. Use `@given` properties rather than a fourth state machine, because `structured_logging_hook` holds no state: it maps a phase to a level, builds an `extra` mapping, and emits one record per event. A state machine would generate interleavings that cannot distinguish any two implementations, since nothing carries between events. Its real risks are per-event and shape-dependent, and that is what the properties pin: one record per event, each phase at its configured level, every attached field `cuprum_`-prefixed so it cannot shadow a reserved `LogRecord` attribute, a total message formatter, and a JSON round trip. Two of the five properties were vacuous when first written, which mutation testing caught rather than review. The JSON property generated only string tag values, so removing both `_json_serializable` and `default=str` still passed; `ExecEvent.tags` is typed `Mapping[str, object]`, so the generator now produces values that are not JSON-native, which is what those two guards exist for. The level property accepted any configured level, so dropping a phase from the map and silently falling back to DEBUG also passed; it now derives the expected level independently per phase. All four mutants fail the corrected properties: an unprefixed extra key, an empty message for an unknown phase, no JSON coercion, and a phase dropped from the level map. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Give every metrics assertion a failure message A bare assert on a shrunk Hypothesis example reports only that two values differed, which is the least useful moment to lose the phase, the byte count, or the expected operations. Attach a message to each, carrying the inputs that produced the failure and the values on both sides. Verified with an AST walk rather than a grep: no `ast.Assert` in the module is left without a `msg`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Define what a partly-applied metrics event leaves behind An exit event yields a failure counter and a duration observation as two independent collector calls, so a collector that raises on the second records a failure without its duration. That was true but undocumented and untested, which left it looking like an oversight rather than a decision. State the contract: the calls are independent and ordered, no atomicity is attempted, and what already landed stays. Atomicity is not achievable here — the collector wraps an arbitrary backend, and buffering to apply together would only move the problem while delaying when metrics appear. Note where the exception goes: `_emit_exec_event` catches it, logs `observe_hook_failed`, and lets the command continue, because a broken metrics backend must not fail the user's command. Pin it with a collector whose histogram writes fail, asserting the counter remains, the observation does not, and the error reaches the caller. Verified by mutation: reversing the operation order fails it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * State the metrics dispatch contract where implementers will find it Three review findings on the metrics adapter tests. `emit` retyped the four phase-to-counter pairs a third time, after the production `_PHASE_COUNTERS` and the module's own `_UNIT_COUNTER_PHASES`. Key the existing list once and look up through it, so the stateful oracle and the parametrized cases cannot drift. It stays a test-local restatement rather than an import of the adapter's table: an oracle reading the production mapping would agree with it by construction and could not catch a wrong metric name. Dispatch the phases with `match`/`case`, matching the reducer's own style, and give the fall-through an explicit arm — `plan` and an uncounted `stdin` both leave the oracle unchanged, which was previously only implied by the absence of a branch. Caption the metrics-dispatch diagram, which was the only one in the file without one. The number is provisional; `#251` tracks renumbering. Document the non-atomic application contract outside the docstring. An `exit` event applies two independent collector calls in a fixed order, so a collector that raises on the second leaves the first applied — which is something a collector implementer needs before writing one, not something to discover from a source docstring. Add the screen-reader description the figure was also missing, and a pointer from the developers' guide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Withdraw the idempotency claim the adapter cannot support The dispatch contract said a collector should treat each call as "independent and idempotent-safe". The second half is unsupported: `inc_counter` and `observe_histogram` receive no event or operation identifier, so a collector has nothing to deduplicate on and a repeated call increments again. Say what is actually true instead — calls are independent and ordered — and state the absent guarantee explicitly rather than leaving it inferred. The adapter never retries a failed call either, which is why a partial application stays partial; a collector wanting exactly-once has to get the identity from somewhere else. Corrected in all three places the contract is stated: the design document, the `MetricsHook` docstring, and the developers' guide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Name the phases metrics handles, and who dies on failure Two documentation claims about the metrics reducer were incomplete or wrong, and one test asserted the weaker of two available contracts. **The phase contract was under-stated.** `ExecPhase` has seven members, and `_metric_operations` has an arm for every one of them, but the shared event contract listed only five — omitting `stdin` and `stdin_error`. Both documents then leant on "the documented phase contract" to describe a reducer that is in fact total over the whole of `ExecPhase`. Name the seven phases where the event contract is introduced, and use one wording in both the design document and the developers' guide. The users' guide's `cuprum_phase` value list had the same five-phase gap; the structured logging adapter is fail-open, so it really does emit those records. **The escalation path was documented backwards.** Three places claimed `_emit_exec_event` "lets the command continue", so that a broken metrics backend cannot fail a user's command. It does not. It logs `observe_hook_failed`, wraps the error in `_ExecEventEmissionError` to carry already-scheduled observe tasks through cleanup, and `_StageObservation.emit` unwraps that and re-raises the collector's original exception — the command dies with it. State that instead, and say what follows: because the reducer's phase match is fail-closed, adding an `ExecPhase` value without an arm would raise for every caller that has already registered `MetricsHook`. **The failure-path test now asserts that.** The simulated backend failure was a bare `RuntimeError`, which `pytest.raises` cannot distinguish from an incidental one. Give it a named `_MetricsBackendError`, following the test-local exception convention in `test_cqrs_helpers.py`, and add a behavioural case driving a real command through a failing collector: the backend's own exception type reaches the caller of `run_sync` unchanged. Removing the re-raise in `_pipeline_types.py` turns that case red, so it pins the contract rather than restating the hook's internals. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: leynos <leynos@rohga> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
The subprocess timeout handler and the pipeline fail-fast cleanup are temporal and branch-heavy, with a risk of inconsistent timeout payloads, non-idempotent cleanup, and double-termination scheduling — and no isolated seam for their decisions (#75). This extracts two pure reducers and property-tests them without process state or a clock.
Seams
_resolve_timeout_payload(exc, _TimeoutFallback)(cuprum._subprocess_timeout) — the timeout-payload seam:_SubprocessTimeoutErrorcarries a payload captured on the stream-timeout path, used verbatim;TimeoutErroris resolved from a_TimeoutFallback(configured timeout, captured stdout/stderr, injected exit-time clock reading);timeout, so downstreamTimeoutExpiredreporting is consistent._handle_subprocess_timeoutnow calls it and keeps the exit-event emit and the raise._stages_to_terminate(failure_index, done)(cuprum._process_lifecycle) — the fail-fast selection: every stage except the failed one and any already-finished stage, each at most once._terminate_pipeline_remaining_stagesdrives it (keeping the strict-lengthzipfor scheduling).Tests
cuprum/unittests/test_subprocess_timeout_reducers.py(Hypothesis) proves the three #75 goals:Validation
Full gates green:
make check-fmt,make lint(ruff, interrogate 100%, pylint 10.00/10),make test(825 passed / 58 skipped incl. existing timeout & pipeline behaviour suites; Rust nextest 57/57). Wheel-manifest snapshot regenerated.Closes #75
🤖 Generated with Claude Code
Summary by Sourcery
Extract pure reducers for subprocess timeout payload resolution and fail-fast pipeline termination, and property-test their behaviour for consistency and idempotence.
New Features:
_TimeoutFallbackdata structure and_resolve_timeout_payloadreducer to unify subprocess timeout payload resolution._stages_to_terminatereducer to select pipeline stages for termination after fail-fast.Enhancements:
_handle_subprocess_timeoutto delegate payload resolution to a pure reducer for consistent timeout reporting._terminate_pipeline_remaining_stagesto use a precomputed termination target set, avoiding double-scheduling and ensuring idempotent cleanup.Tests: