Skip to content

Property tests for subprocess timeout & fail-fast reducers (#75) - #245

Merged
leynos merged 7 commits into
mainfrom
python-subprocess-timeout-tests
Jul 30, 2026
Merged

Property tests for subprocess timeout & fail-fast reducers (#75)#245
leynos merged 7 commits into
mainfrom
python-subprocess-timeout-tests

Conversation

@leynos

@leynos leynos commented Jul 28, 2026

Copy link
Copy Markdown
Owner

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:

  • a _SubprocessTimeoutError carries a payload captured on the stream-timeout path, used verbatim;
  • a bare TimeoutError is resolved from a _TimeoutFallback (configured timeout, captured stdout/stderr, injected exit-time clock reading);
  • either branch yields a concrete timeout, so downstream TimeoutExpired reporting is consistent.

_handle_subprocess_timeout now 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_stages drives it (keeping the strict-length zip for scheduling).

Tests

cuprum/unittests/test_subprocess_timeout_reducers.py (Hypothesis) proves the three #75 goals:

  • Timeout payload consistency — the carried payload wins and never consults the fallback; a bare timeout resolves exactly from the fallback (timeout/clock/stdout/stderr); a missing configured timeout is an internal invariant error.
  • No double-termination scheduling — the selection is exactly the running, non-failed stages, unique and ordered, never the failure index.
  • Idempotent cleanup — once the selected stages settle, a second pass selects nothing.

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:

  • Introduce a _TimeoutFallback data structure and _resolve_timeout_payload reducer to unify subprocess timeout payload resolution.
  • Introduce a _stages_to_terminate reducer to select pipeline stages for termination after fail-fast.

Enhancements:

  • Refactor _handle_subprocess_timeout to delegate payload resolution to a pure reducer for consistent timeout reporting.
  • Refactor _terminate_pipeline_remaining_stages to use a precomputed termination target set, avoiding double-scheduling and ensuring idempotent cleanup.

Tests:

  • Add Hypothesis-based property tests for subprocess timeout payload resolution and fail-fast termination selection reducers.

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>

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry @leynos, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 59fe2771-9ddb-4604-b406-4f96be733eee

📥 Commits

Reviewing files that changed from the base of the PR and between fbd355d and 340cf62.

📒 Files selected for processing (1)
  • docs/cuprum-design.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)

Summary

  • Extracted two pure reducers: _resolve_timeout_payload (normalises timeout details from either a carried _SubprocessTimeoutError payload or a _TimeoutFallback) and _stages_to_terminate (deterministically selects unfinished, non-failed pipeline stage indices to terminate after fail-fast).
  • Refactored existing timeout handling and fail-fast cleanup to delegate to the reducers while preserving observable behaviour (event emission, exception raising, and strict scheduling semantics), including preventing duplicate termination.
  • Added Hypothesis property tests for timeout payload consistency (carried vs fallback, and invariant error when no configured timeout exists) and for fail-fast stage selection ordering/idempotency.
  • Added bounded CrossHair verification (five PEP 316 contracts) for both reducers using check_states with MessageType.CONFIRMED, plus import-time CrossHair availability handling via cuprum/unittests/_crosshair_support.py.
  • Updated documentation and diagrams in docs/cuprum-design.md and expanded docs/developers-guide.md with the verification approach (Hypothesis + CrossHair), invariants, bounds, and compatibility/skip conditions.
  • Updated the wheel-manifest snapshot to include the new CrossHair and reducer test modules.

Walkthrough

Refactor 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.

Changes

Timeout and fail-fast lifecycle reducers

Layer / File(s) Summary
Normalize timeout payloads
cuprum/_subprocess_timeout.py
Introduce _TimeoutFallback and _resolve_timeout_payload, route timeout handling through resolved details, and export the new symbols.
Select remaining pipeline stages
cuprum/_process_lifecycle.py
Extract stage termination selection and use it to build fail-fast termination tasks for running, non-failed stages.
Validate reducer behaviour
cuprum/unittests/test_subprocess_timeout_reducers.py, cuprum/unittests/__snapshots__/test_maturin_build.ambr
Add Hypothesis properties for timeout resolution, invariant enforcement, stage selection, and idempotent cleanup; record the new test modules in the wheel snapshot.
Verify bounded symbolic contracts
cuprum/unittests/_crosshair_support.py, cuprum/unittests/test_subprocess_timeout_reducers_crosshair.py
Add shared CrossHair availability handling and symbolic checks for timeout payload resolution and fail-fast stage selection.
Document flows and verification
docs/cuprum-design.md, docs/developers-guide.md
Add accessible lifecycle diagrams and document bounded Hypothesis and CrossHair verification commands and skip behaviour.

### 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
Loading

Possibly related PRs

  • leynos/cuprum#158: Refactors the same timeout-translation path in cuprum/_subprocess_timeout.py.

Suggested labels: Issue

Suggested reviewers: codescene-delta-analysis, codescene-access

Poem

Gather timeout fields in line,
Let failed stages mark the sign.
Test each path both broad and bright,
CrossHair checks the states at night—
Clean pipelines hum by design.

🚥 Pre-merge checks | ✅ 20
✅ Passed checks (20 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the PR focus on property tests for the timeout and fail-fast reducers and includes the linked issue.
Description check ✅ Passed The description directly explains the reducer extraction, property tests, and CrossHair verification in scope.
Linked Issues check ✅ Passed The changes satisfy #75 by proving timeout payload consistency, idempotent cleanup, and no double-termination scheduling.
Out of Scope Changes check ✅ Passed The documentation, snapshot, and CrossHair support changes all support the reducer verification work and stay in scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Testing (Overall) ✅ Passed PASS: The new Hypothesis and CrossHair tests directly exercise both reducers and cover carried payloads, fallback resolution, invariant errors, uniqueness, ordering and idempotence.
User-Facing Documentation ✅ Passed PASS: The PR only extracts private reducers and adds tests/docs; docs/users-guide.md already covers the observable timeout and fail-fast behaviour.
Developer Documentation ✅ Passed Document the new reducers and CrossHair helper in the developer guide and design doc; no roadmap or execplan item required for this work.
Module-Level Documentation ✅ Passed Approve it: every touched Python module has a top-level docstring stating its purpose and, where needed, its relation to the timeout/fail-fast reducers.
Testing (Unit And Behavioural) ✅ Passed PASS: the new unit/property tests cover reducer edge cases and invariants, while existing public run() and pipeline tests still exercise the observable boundaries.
Testing (Property / Proof) ✅ Passed Hypothesis tests and bounded CrossHair contracts cover the new timeout and fail-fast invariants, and the docs now recommend both for these reducers.
Testing (Compile-Time / Ui) ✅ Passed No Rust/TypeScript compile-time path changed, and the wheel snapshot test is focused, normalised, and backed by new Hypothesis/CrossHair reducer tests.
Unit Architecture ✅ Passed PASS: the PR extracts pure reducers for timeout selection and fail-fast termination; side-effects stay in command handlers, with explicit fallback/ctx inputs and property tests.
Domain Architecture ✅ Passed PASS: the new reducers stay in internal subprocess/pipeline modules, translate timeout and stage state only, and add no domain-to-infrastructure boundary leak.
Observability ✅ Passed Refactor only extracted pure reducers; existing start/exit observation emits remain intact and no new logging, metrics, tracing, or alerts were introduced.
Security And Privacy ✅ Passed Only docs diagram text changed; no secrets, auth, permission, injection, or privacy-sensitive data was added.
Performance And Resource Use ✅ Passed Keep the new reducers linear and bounded; the only extra collections are small, fail-fast-path sets/lists, and the tests use finite symbolic domains.
Concurrency And State ✅ Passed Reducers are pure/frozen, no new shared mutable state was added, and termination tasks are created then awaited with gather; tests cover ordering and idempotence.
Architectural Complexity And Maintainability ✅ Passed The reducers are narrow, immediately used, and remove branchy logic without adding hidden layers or speculative indirection.
Rust Compiler Lint Integrity ✅ Passed PASS: the PR’s symmetric diff touches only Python/docs files; no Rust source, lint suppressions, or clone changes were introduced.
📋 Issue Planner

Built with CodeRabbit's Coding Plans for faster development and fewer bugs.

View plan used: #75

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch python-subprocess-timeout-tests

Comment @coderabbitai help to get the list of available commands.

@sourcery-ai

sourcery-ai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Extracts 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 handling

sequenceDiagram
    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)
Loading

Sequence diagram for updated fail-fast pipeline termination selection

sequenceDiagram
    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
Loading

File-Level Changes

Change Details Files
Introduce a pure resolver for subprocess timeout payloads and refactor timeout handling to use it.
  • Add the frozen dataclass _TimeoutFallback to hold configured timeout, captured stdout/stderr, and an injected exit-time clock value used for bare TimeoutError resolution.
  • Implement _resolve_timeout_payload to map either a _SubprocessTimeoutError (using its carried payload) or a bare TimeoutError (using _TimeoutFallback plus _require_timeout) into a _SubprocessTimeoutDetails instance.
  • Refactor _handle_subprocess_timeout to delegate payload computation to _resolve_timeout_payload, using a _TimeoutFallback built from the execution context and time.perf_counter(), then emit the exit event and raise TimeoutExpired using the resolved payload fields.
  • Export _TimeoutFallback and _resolve_timeout_payload in the module’s all list.
cuprum/_subprocess_timeout.py
Introduce a pure fail-fast stage selection helper and adjust pipeline termination logic to use it.
  • Add _stages_to_terminate, which returns indices of stages to terminate after fail-fast by filtering out the failed stage and any stages already marked done, ensuring each selected index appears at most once.
  • Refactor _terminate_pipeline_remaining_stages to compute a target index set from _stages_to_terminate and then create termination tasks only for those indices while preserving the strict zip over processes and wait_tasks.
  • Preserve idempotent cleanup semantics by basing selection solely on the done flags snapshot and never rescheduling already-finished stages.
cuprum/_process_lifecycle.py
Add property-based tests for the new reducers and update snapshot metadata.
  • Create cuprum/unittests/test_subprocess_timeout_reducers.py with Hypothesis tests that prove: carried _SubprocessTimeoutError payloads are used verbatim and ignore fallbacks, bare TimeoutError payloads are built solely from _TimeoutFallback and require a configured timeout, and fail-fast selection via _stages_to_terminate returns exactly the running, non-failed stages, uniquely and in order, and is idempotent over settled stages.
  • Regenerate or adjust the maturin build snapshot file to account for the new tests or distribution metadata changes.
cuprum/unittests/test_subprocess_timeout_reducers.py
cuprum/unittests/__snapshots__/test_maturin_build.ambr

Assessment against linked issues

Issue Objective Addressed Explanation
#75 Introduce a testable seam for subprocess timeout handling (around _handle_subprocess_timeout) and add property-based (Hypothesis) tests to verify timeout payload consistency.
#75 Introduce a testable seam for pipeline fail-fast termination (around _terminate_pipeline_remaining_stages) and add property-based (Hypothesis) tests to verify idempotent cleanup and no double-termination scheduling.
#75 Apply CrossHair verification to the extracted transition data / reducers involved in timeout and fail-fast lifecycle handling. The PR adds pure reducers (_resolve_timeout_payload, _stages_to_terminate) and Hypothesis property tests for them, but there is no use of CrossHair or any static/semantic verification tooling in the changes.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

codescene-access[bot]

This comment was marked as outdated.

codescene-access[bot]

This comment was marked as outdated.

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>
codescene-access[bot]

This comment was marked as outdated.

codescene-access[bot]

This comment was marked as outdated.

@buzzybee-df12

Copy link
Copy Markdown
Collaborator

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot added the Issue label Jul 29, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 302858c and 2d30f10.

📒 Files selected for processing (4)
  • cuprum/_process_lifecycle.py
  • cuprum/_subprocess_timeout.py
  • cuprum/unittests/__snapshots__/test_maturin_build.ambr
  • cuprum/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)

Comment thread cuprum/_subprocess_timeout.py Outdated
Comment thread cuprum/unittests/test_subprocess_timeout_reducers.py Outdated
Comment thread cuprum/unittests/test_subprocess_timeout_reducers.py Outdated
@leynos

leynos commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

@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 (_resolve_timeout_payload, _stages_to_terminate) and Hypothesis property tests for them, but there is no use of CrossHair or any static/semantic verification tooling in the changes.

@coderabbitai

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>
codescene-access[bot]

This comment was marked as outdated.

lodyai Bot pushed a commit that referenced this pull request Jul 29, 2026
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>
codescene-access[bot]

This comment was marked as outdated.

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>
@pandalump

Copy link
Copy Markdown
Collaborator

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

cuprum/unittests/test_subprocess_timeout_reducers_crosshair.py adds five PEP 316 contracts, executed through check_states and required to return MessageType.CONFIRMED:

_resolve_timeout_payload

Contract Invariant
carried_payload a carried _SubprocessTimeoutError returns its own timeout, stdout, stderr, and exit time verbatim
fallback_payload a bare TimeoutError with a configured timeout returns the fallback's four fields exactly
missing_timeout_raises a bare TimeoutError with configured_timeout is None raises _SubprocessInvariantError

The independence requirement is enforced structurally rather than asserted: the fallback in the carried contract holds different values and a None configured timeout, so a resolver that consulted it would either return a wrong field or raise.

_stages_to_terminate

Contract Invariant
selection_well_formed every index in range, unique, ordered; none equals failure_index; every one has done[idx] is False; and the result equals exactly the unfinished non-failed set
selection_idempotent after marking the selected stages done, a second invocation returns []

The checks actually verify

I mutation-checked rather than trusting five green ticks. Both mutants yield POST_FAIL instead of CONFIRMED:

  • making the carried branch read fallback.stdoutcarried_payload fails;
  • dropping the failed-stage exclusion from the selection → selection_well_formed fails.

Deliberate bounds

Kept small and finite so CrossHair exhausts the space rather than returning CANNOT_CONFIRM:

  • at most three stages, with failure_index constrained to a valid index by precondition;
  • the per-stage done flags encoded as a single bounded integer bitmask rather than a symbolic list of symbolic booleans — this is the change that makes the space enumerable;
  • three-value enumerations for times and for text (including None) instead of unrestricted floats and strings. The reducers only ever copy these, so representative values suffice; what matters is that carried and fallback values stay distinguishable, which the enumerations preserve.

Automated, not just documented

The module matches the cuprum/unittests/test_*.py glob in the Makefile's PYTEST_TARGETS, so make test collects and runs it — I verified the module appears in the make test output rather than assuming it. check_states demands CONFIRMED, so an available-but-unconfirmed result (CANNOT_CONFIRM) or a refuted postcondition fails the run; neither is downgraded to a skip or warning. Availability uses the shared _crosshair_support.py probe, which degrades to a skip only for a missing dependency (ImportError) or an interpreter the tracer cannot handle (TraceException), matching the existing 3.15 policy. On this interpreter all five report PASSED, not SKIPPED.

Note: _crosshair_support.py here is byte-identical to the copy on #243's branch, so an add/add merge of the two PRs resolves cleanly.

Validation

Command Outcome
uv run pytest -q cuprum/unittests/test_subprocess_timeout_reducers.py 5 passed (Hypothesis, unchanged)
uv run pytest -q cuprum/unittests/test_subprocess_timeout_reducers_crosshair.py 5 passed, all CONFIRMED
uv run crosshair check ... --analysis_kind=PEP316 exit 0, no counterexamples
make check-fmt pass
make lint pass (ruff, interrogate 100%, pylint 10.00/10, clippy)
make typecheck pass (ty clean)
make test pass — 765 passed / 47 skipped, Rust nextest 57/57, CrossHair module executed
make markdownlint / make nixie pass

The wheel-manifest snapshot was regenerated for the two new files under cuprum/. Documentation of the verified invariants, both commands, and the bounds is in docs/developers-guide.md.

Leaving #75 open until this is reviewed and merged, per the instruction to close it only once the requirement is demonstrably satisfied.

codescene-access[bot]

This comment was marked as outdated.

codescene-access[bot]

This comment was marked as outdated.

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>
@pandalump

Copy link
Copy Markdown
Collaborator

All three findings verified against current code and fixed in c773b57. None were stale.

1. Structural match/case in _resolve_timeout_payload

Valid, and worth noting why: the code this reducer was extracted from — _handle_subprocess_timeout before this branch — already used a structural match/case for exactly this dispatch. My extraction quietly replaced it with isinstance, so this restores the established idiom rather than introducing a new one.

match exc:
    case _SubprocessTimeoutError(
        timeout=timeout,
        stdout=stdout,
        stderr=stderr,
        exited_at=exited_at,
    ):
        return _SubprocessTimeoutDetails(...)
    case _:
        return _SubprocessTimeoutDetails(
            timeout=_require_timeout(fallback.configured_timeout, exc),
            ...
        )

The _SubprocessTimeoutError case comes first and binds its captured fields by pattern, as asked.

One deliberate deviation: I used case _ rather than case TimeoutError() for the second arm. Because each arm returns, a non-matching input would fall off the end and implicitly return None, violating the declared -> _SubprocessTimeoutDetails. The catch-all keeps the function total. (The original could use case TimeoutError() safely because it assigned locals and fell through to shared code rather than returning.)

Behaviour is unchanged, and I confirmed that rather than assuming it: the CrossHair contract still refutes a mutant that makes the carried branch read fallback.stdout, reporting POST_FAIL instead of CONFIRMED.

2. Strategy naming

Valid. _finite_floats and _optional_text were lowercase against the convention every other property module in cuprum/unittests follows — _LINES, _SEGMENTS, _ABS_PATHS, _COMPRESSIONS, _OUTPUT_OPTIONS, _CONSUMER_OUTCOME. Renamed to _FINITE_FLOATS and _OPTIONAL_TEXT, with all references updated.

(The strategies in the adjacent CrossHair module, _TIMES and _TEXTS, already matched the convention.)

3. Assertion messages

Valid — the module had ten bare assertions. Every one now names the invariant it verifies, so a Hypothesis counterexample identifies the violated property rather than only the values that broke it. For example:

assert payload == details, (
    "a carried _SubprocessTimeoutError payload must be returned verbatim, "
    "with no field taken from the fallback"
)
assert failure_index not in result, (
    "the failed stage owns its own exit and must never be terminated"
)

That covers the carried-payload comparison, the four fallback field assertions, the four selection assertions, and the idempotence assertion. grep for bare asserts in the file now returns zero.

Validation

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

codescene-access[bot]

This comment was marked as outdated.

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>
codescene-access[bot]

This comment was marked as outdated.

@pandalump

Copy link
Copy Markdown
Collaborator

@coderabbitai I have self-validated the three Inconclusive pre-merge rows, since each was marked inconclusive for want of evidence rather than for a suspected defect. Evidence below.

Module-Level Documentation. Every module the PR adds or changes opens with a docstring, and the repository's interrogate gate enforces this at 100%:

cuprum/_subprocess_timeout.py                                   module docstring: yes
cuprum/_process_lifecycle.py                                    module docstring: yes
cuprum/unittests/test_subprocess_timeout_reducers.py            module docstring: yes
cuprum/unittests/test_subprocess_timeout_reducers_crosshair.py  module docstring: yes
cuprum/unittests/_crosshair_support.py                          module docstring: yes

RESULT: PASSED (minimum: 100.0%, actual: 100.0%)

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 (AGENTS.md L25-27):

261  cuprum/_subprocess_timeout.py
283  cuprum/_process_lifecycle.py
330  cuprum/unittests/test_subprocess_timeout_reducers_crosshair.py
182  cuprum/unittests/test_subprocess_timeout_reducers.py

Performance And Resource Use. Both reducers are pure, allocation-light and called once per event, not per byte or per poll — _stages_to_terminate once per pipeline failure (_process_lifecycle.py:268) and _resolve_timeout_payload once per timeout (_subprocess_timeout.py:191). Measured:

_stages_to_terminate(stages=  3):   0.28 us/call   O(n) single pass, one list
_stages_to_terminate(stages= 10):   0.52 us/call
_stages_to_terminate(stages=100):   3.43 us/call
_resolve_timeout_payload:           1.59 us/call   one frozen dataclass, no I/O

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.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

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.py

Length of output: 40391


@pandalump: Clear all three previously inconclusive rows.

  • Module-level documentation: Verify this as satisfied. Each listed changed module begins with a module docstring.
  • Unit architecture: Verify this as satisfied. The reducers remain in their behaviour-owning modules, and the inspected files range from 83 to 330 lines—each below the stated 400-line threshold.
  • Performance and resource use: Verify the design conclusion. _resolve_timeout_payload only selects/copies fields and constructs one payload; _stages_to_terminate is a single O(n) list-comprehension pass. Their only production call sites run once per timeout and once per pipeline fail-fast cleanup respectively, rather than inside a polling or byte-processing loop.

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.

codescene-access[bot]

This comment was marked as outdated.

codescene-access[bot]

This comment was marked as outdated.

@pandalump

Copy link
Copy Markdown
Collaborator

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

coderabbitai[bot]

This comment was marked as resolved.

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>
codescene-access[bot]

This comment was marked as outdated.

@codescene-access codescene-access Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No quality gates enabled for this code.

@pandalump

Copy link
Copy Markdown
Collaborator

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@leynos
leynos merged commit 75387d7 into main Jul 30, 2026
20 checks passed
@leynos
leynos deleted the python-subprocess-timeout-tests branch July 30, 2026 22:05
lodyai Bot pushed a commit that referenced this pull request Jul 30, 2026
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>
lodyai Bot pushed a commit that referenced this pull request Aug 3, 2026
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>
leynos added a commit that referenced this pull request Aug 3, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Property-based tests for _handle_subprocess_timeout and _terminate_pipeline_remaining_stages

3 participants