Skip to content

Preserve the OS errno across the PyO3 boundary (#265) - #268

Merged
leynos merged 7 commits into
mainfrom
fix-pyo3-errno
Aug 3, 2026
Merged

Preserve the OS errno across the PyO3 boundary (#265)#268
leynos merged 7 commits into
mainfrom
fix-pyo3-errno

Conversation

@leynos

@leynos leynos commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Summary

OSErrors raised by the extension carried errno = None, even when the failure was a real OS error whose number appeared in the message. Callers could not branch on the kind of failure; the only route left was matching message text, which is not a stable interface.

Closes #265.

Cause

PyO3's From<io::Error> for PyErr picks the exception type from io::ErrorKind, then constructs it with a single argument — the error's Display string:

_ => exceptions::PyOSError::new_err(err),   // PyErrArguments -> self.to_string()

Python populates errno and strerror only for OSError(errno, strerror), two arguments or more:

OSError('Bad file descriptor (os error 9)',)  -> OSError            errno=None
OSError(9, 'Bad file descriptor')             -> OSError            errno=9
OSError(32, 'Broken pipe')                    -> BrokenPipeError    errno=32

I confirmed the number survives intact as far as PumpError::Io (raw_os_error = Some(9)), so the loss is entirely at the final conversion.

Fix

Construct the exception at our boundary with (code, strerror). This fixes both halves at once, because CPython maps the errno to the matching subclass itself — the same subclass selection PyO3 reached for through ErrorKind, taken from the authoritative source rather than a parallel table. Reading a directory now raises IsADirectoryError, as it should.

Rust's " (os error N)" suffix is stripped, which would otherwise render as "[Errno 9] Bad file descriptor (os error 9)". An io::Error with no raw_os_error has no number to preserve, so PyO3's mapping stays in use for it.

Tests

The existing assertion in test_rust_pump_stream_propagates_io_errors is unchanged and now passes. It reaches the conversion through the pump, which treats a broken pipe as non-fatal and drains — so most interesting errnos never propagate that way. cuprum/unittests/test_rust_errno.py isolates the conversion through the exported entry points and pins the subclass selection and message shape as well as the number. strip_os_error_suffix has Rust-side unit tests, including a mismatched-code case so a suffix belonging to a different error is never trimmed.

Mutation-verified: restoring PyO3's single-argument conversion fails five tests; leaving the Rust suffix in place fails one.

Validation

make check-fmt, make lint, make typecheck, make test all pass; cs delta reports no issues.

Note

Stacked on #241. Review that first.

@coderabbitai

coderabbitai Bot commented Jul 31, 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

Summary

Preserve OS error details across the PyO3 boundary.

  • Construct platform-specific Python exceptions with the raw OS error code.
  • Preserve .errno, .winerror, .strerror, and the correct Python OSError subclass.
  • Remove Rust’s duplicated "(os error N)" suffix.
  • Retain PyO3’s existing conversion for errors without a raw OS code.
  • Add Rust property tests and Python regression tests for issue #265.
  • Update the maturin build snapshot and user and developer documentation.

Walkthrough

Preserve OS error codes, Python exception subclasses, and normalized messages during PumpError conversion. Add Rust and Python regression tests for POSIX and Windows behaviour. Document the error contract and include the Python test in the maturin wheel snapshot.

Changes

Errno preservation

Layer / File(s) Summary
Convert I/O errors with native codes
rust/cuprum-rust/src/errors.rs
Construct platform-specific Python OSError values from raw OS codes. Remove matching (os error N) suffixes. Retain PyO3 conversion for errors without raw codes. Add parameterised and property-based suffix tests.
Verify platform error behaviour
cuprum/unittests/test_rust_errno.py, cuprum/unittests/__snapshots__/test_maturin_build.ambr
Test POSIX and Windows error codes, strerror, exception subclasses, duplicate suffix removal, resource cleanup, and wheel packaging.
Document error propagation
docs/developers-guide.md, docs/users-guide.md
Document Unix and Windows error mapping, errno and winerror handling, message normalisation, fallback behaviour, and native-extension coverage status.

Sequence Diagram(s)

sequenceDiagram
  participant RustStream
  participant PumpError
  participant PythonRuntime
  RustStream->>PumpError: report OS I/O failure
  PumpError->>PythonRuntime: construct platform-specific OSError
  PythonRuntime-->>RustStream: expose errno, winerror, subclass, and message
Loading

Possibly related issues

  • leynos/cuprum#277: Adds Windows-specific coverage for os_error_to_py_err and documents verified winerror behaviour.
  • leynos/cuprum#276: Covers Rust-to-Python I/O error conversion, including preservation of OSError.errno and platform-specific subclasses.

Possibly related PRs

  • leynos/cuprum#163: Modifies PumpError Rust-to-Python error conversion in the same module.
  • leynos/cuprum#227: Covers related Rust I/O error handling, although it uses a different write-loop path.

Suggested labels: Issue

Suggested reviewers: codescene-access

Poem

Preserve the errno.
Build the right Python error.
Strip duplicated text.
Test pipes and directories.
Let failures speak clearly.


Caution

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

  • Ignore

❌ Failed checks (1 error)

Check name Status Explanation Resolution
Testing (Overall) ❌ Error POSIX tests exercise the boundary, but the Windows test derives expected values from the raised winerror, so an incorrect or constant native code could pass; CI also skips these extension tests. Assert the native error code and strerror from an independent operation oracle, add coverage for the no-raw-code fallback, and run the extension tests in POSIX and Windows CI jobs.
✅ Passed checks (19 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes errno preservation across the PyO3 boundary and includes the linked issue reference (#265).
Description check ✅ Passed The description clearly explains the defect, fix, tests, validation, and linked issue.
Linked Issues check ✅ Passed The changes satisfy issue #265 by preserving OS error codes, Python subclasses, error text, and regression coverage.
Out of Scope Changes check ✅ Passed The code, tests, and documentation changes directly support the linked issue objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
User-Facing Documentation ✅ Passed The users guide adds a clear Rust stream error-handling section covering errno, strerror, subclasses, Windows winerror, suffix removal, and fallback errors, matching the implementation.
Developer Documentation ✅ Passed Accept: docs/developers-guide.md documents the PyO3 boundary, Unix/Windows error construction, suffix stripping, fallback, and test coverage; the existing design, roadmap, and complete execplan rem...
Module-Level Documentation ✅ Passed Accept the module documentation: errors.rs has //! purpose and integration details, and test_rust_errno.py has a clear module docstring covering purpose, utility and PyO3 relationships.
Testing (Unit And Behavioural) ✅ Passed Accept the test coverage: Rust rstest/proptest cover suffix edge cases and invariants; Python tests call exported pump/consume APIs and assert errno, subclasses, messages, winerror, platform scope,...
Testing (Property / Proof) ✅ Passed errors.rs adds three substantive proptest properties over arbitrary i32 codes and generated messages, covering matching, mismatched, and unrecognised suffixes.
Testing (Compile-Time / Ui) ✅ Passed Accept this check: the change is runtime-only; focused Rust/Python assertions cover suffix and OSError fields, and the wheel snapshot records a stable file-list contract.
Unit Architecture ✅ Passed Keep this design: io_error_to_py_err is a pure boundary adapter, while rust_pump_stream and rust_consume_stream expose explicit PyResult fallibility; tests clean up resources.
Domain Architecture ✅ Passed Keep this change. PumpError is crate-private stream infrastructure, and PyO3 conversion is centralised at exported entry points; _streams_rs remains a thin adapter.
Observability ✅ Passed Accept this check: the change exposes bounded errno/winerror/strerror fields, while existing failure-boundary tracing supplies platform and operation context; tests and guides document the contract.
Security And Privacy ✅ Passed The PR only adds typed OS-error conversion, tests, docs, and dependency pin updates; scans found no secrets, auth changes, injection sinks, permissions, or sensitive data exposure.
Performance And Resource Use ✅ Passed The change adds no production loops, I/O, retries, caches, or unbounded collections; it performs bounded message formatting only once on an error path.
Concurrency And State ✅ Passed Treat this as non-concurrent: the PR adds pure error conversion and local test fixtures, with no shared mutable state, locks, tasks, cancellation, ordering, or spawned work.
Architectural Complexity And Maintainability ✅ Passed Accept the change: keep the private conversion helpers in errors.rs; cfg-scoped Unix/Windows construction isolates a real platform seam, with no new dependencies or architectural layers.
Rust Compiler Lint Integrity ✅ Passed Keep the lint surface intact: the Rust patch adds no broad suppressions, artificial anchors, or clone calls; all new helpers and imports have real conversion or test uses.
📋 Issue Planner

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

View plan used: #265

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-pyo3-errno

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

@sourcery-ai

sourcery-ai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Implements a custom conversion from Rust io::Error/PumpError into Python OSError that preserves errno and lets CPython select the correct exception subclass, while cleaning up message formatting and adding tests to pin the behaviour at both the Rust and Python boundaries.

File-Level Changes

Change Details Files
Route PumpError::Io through a custom io::Error→PyErr converter that preserves errno and defers subclass selection to CPython.
  • Replace PumpError::Io conversion from direct io_err.into() to a call to io_error_to_py_err(io_err).
  • Keep other PumpError variants mapping to PyOSError with existing messages unchanged.
rust/cuprum-rust/src/errors.rs
Introduce helpers to strip Rust’s "(os error N)" suffix and construct OSError(code, strerror) when a raw OS errno is available.
  • Add strip_os_error_suffix(message, code) to remove the exact " (os error {code})" suffix when present.
  • Add io_error_to_py_err(err) that inspects raw_os_error(), builds PyOSError(code, stripped_strerror) on Some(code), and falls back to PyO3’s default conversion on None.
  • Document behaviour and rationale in Rust doc comments so future changes understand the errno/subclass guarantees.
rust/cuprum-rust/src/errors.rs
Add Rust unit tests to verify suffix-stripping behaviour is code-anchored and non-destructive.
  • Introduce rstest-based parameterized test strip_os_error_suffix_removes_only_its_own_code covering matching, non-matching, and no-suffix cases.
  • Ensure mismatched errno codes do not cause unintended truncation of the error message.
rust/cuprum-rust/src/errors.rs
Add Python tests that exercise exported Rust entry points and assert errno, strerror, subclass selection, and message formatting across the PyO3 boundary.
  • Create test_rust_errno.py to drive rust_pump_stream and rust_consume_stream with intentionally bad file descriptors and directory descriptors.
  • Pin that pump and consume errors expose errno (EBADF/EISDIR), have populated strerror, and raise the CPython-selected subclasses (OSError/IsADirectoryError).
  • Assert that rendered messages state the error number once via Python’s [Errno N] prefix and no longer include Rust’s "(os error N)" suffix.
cuprum/unittests/test_rust_errno.py
cuprum/unittests/__snapshots__/test_maturin_build.ambr

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.

@leynos
leynos marked this pull request as ready for review August 1, 2026 02:07

@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

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6434c99046

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rust/cuprum-rust/src/errors.rs Outdated
@buzzybee-df12

Copy link
Copy Markdown
Collaborator

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 1, 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 Aug 1, 2026
@lodyai
lodyai Bot force-pushed the fix-pyo3-errno branch from 6434c99 to 3a0ac56 Compare August 1, 2026 12:12
codescene-access[bot]

This comment was marked as outdated.

@pandalump

Copy link
Copy Markdown
Collaborator

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 2, 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.

Base automatically changed from rust-consume-stream-snapshots to main August 2, 2026 11:14
codescene-access[bot]

This comment was marked as outdated.

codescene-access[bot]

This comment was marked as outdated.

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 Aug 3, 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 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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/users-guide.md`:
- Around line 1264-1268: Update the Python error-handling example around
rust_consume_stream to import rust_consume_stream before it is called, ensuring
the example reaches the documented OSError branch without changing the existing
exception flow.
🪄 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: db9aa884-4d66-42bb-b189-7000e77553df

📥 Commits

Reviewing files that changed from the base of the PR and between 3a0ac56 and 85bc50c.

📒 Files selected for processing (5)
  • cuprum/unittests/__snapshots__/test_maturin_build.ambr
  • cuprum/unittests/test_rust_errno.py
  • docs/developers-guide.md
  • docs/users-guide.md
  • rust/cuprum-rust/src/errors.rs
🔗 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 docs/users-guide.md
codescene-access[bot]

This comment was marked as outdated.

@pandalump

Copy link
Copy Markdown
Collaborator

Re: pre-merge check "Testing (Overall)" — ERROR

Head is now 4045613. The core criticism was correct, and I have fixed it rather than argued with it.

The tautology was real

The Windows case I added in 85bc50c derived its expectations from the very exception it was asserting on:

raised = excinfo.value
winerror = _winerror_of(raised)
assert winerror, ...
derived = OSError(0, raised.strerror, None, winerror, None)
assert type(raised) is type(derived)
assert raised.errno == derived.errno

Both the winerror and the strerror fed into derived came off raised. So the test pinned CPython's winerror → errno → subclass derivation — which was never in doubt, it is CPython's own behaviour — and said nothing whatsoever about the code the extension supplied. assert winerror only required truthiness. A Rust side that hard-coded 6, or reported a code taken from some entirely different failure, would have passed every assertion. That is not a test of the boundary; it is a test of CPython.

1. Independent oracle

cuprum/unittests/test_rust_errno.py now obtains the expectation from outside the boundary, in two stages:

  • The native code comes from issuing the same failing read through ctypes and reading GetLastError back (_win32_readfile_error). A Windows stream handle in this crate is a std::fs::File (io_utils/mod.rs:65), whose read is ReadFile — so the probe is the same system call on the same handle, but routed through CPython instead of through the extension. Nothing it returns has passed through the code under test.
  • errno, strerror and the subclass then come from ctypes.WinError(code) (_win32_oracle), which looks the message up with FormatMessage and lets CPython derive the rest from the independently obtained code.

I chose the live probe over a hard-coded constant such as ERROR_INVALID_HANDLE = 6 deliberately. A constant would be independent but also brittle and, worse, wrong for this fixture — a GENERIC_WRITE handle fails ReadFile with ERROR_ACCESS_DENIED, not an invalid-handle code — so it would have to be kept in step with the fixture by hand. The probe cannot drift, and it still fails loudly if the premise breaks: it asserts the probe read actually failed, since a probe that succeeds proves nothing.

The pytest.raises guard is also tightened from r"^\[WinError \d+\] " to re.escape(f"[WinError {expected}]"), so it names the code instead of accepting any digits.

Two implementation details worth flagging for review: use_last_error=True plus ctypes.get_last_error() is used so the interpreter cannot overwrite GetLastError between the failed call and the read; and explicit argtypes are set so a pointer-sized HANDLE is not truncated to a C int on 64-bit Windows.

strerror on the POSIX arm was also weak — it was only asserted truthy. It is now compared against os.strerror(errno.EBADF), which CPython looks up independently of the exception.

2. No-raw-code fallback

Confirmed: nothing exercised it. That arm is live in production, not hypothetical — the write paths raise ErrorKind::WriteZero for a write that makes no progress (io_utils/mod.rs:255 and :281), and a synthesized io::Error has no raw_os_error() to preserve.

It could not be covered from Python: no Python-reachable input makes a real write() return 0 for a non-empty buffer on demand. It could not be covered by inspecting the built PyErr either, because that needs a live interpreter and the crate is compiled with pyo3/extension-module unconditionally. I checked this rather than assuming it — a probe test calling Python::attach fails at link time:

rust-lld: error: undefined symbol: PyUnicode_Type
rust-lld: error: undefined symbol: PyExc_InterruptedError
...
error: could not compile `cuprum-rust` (lib test)

Adding a Cargo feature to make the extension-module link optional would fix that, but it changes the build for two PRs stacked on this branch, so it is not the right move here.

Instead the decision is split out of io_error_to_py_err into a pure helper, raw_os_error_parts, and tested directly for both io::Error representations — one carrying a custom payload, one built from a bare kind — including the WriteZero shape the write paths actually produce.

Mutation-verified, not assumed: changing err.raw_os_error()? to err.raw_os_error().unwrap_or(0) fails all three synthesized cases.

3. CI execution — deliberately not done here

This is the one part of the resolution I am not implementing in this PR, and I want to be explicit about why rather than let it look like an oversight.

#268 does not touch .github/workflows/ci.yml at all, and both #269 and #278 are stacked directly on this branch. Adding a CI job here would conflict with #269 on rebase and duplicate work already scoped elsewhere. The in-code comment in test_rust_errno.py now states this position explicitly and cites both, so the gap is recorded where someone will actually read it rather than only in a review thread.

What I verified versus assumed

  • Verified locally with the extension built (maturin develop): the four POSIX cases pass, including the new os.strerror oracle. The Windows case skips.
  • Verified: the fallback mutation kills the new Rust tests; the GIL link failure above; that test_maturin_build.py in the cs delta output is pre-existing (d5f4c88 landed on main after the last check, and cs delta compares two-dot — the file is not in origin/main...HEAD at all). cs review scores both changed files 10.0.
  • Not verified, and cannot be from Linux: the Windows arm itself. It remains an unexecuted statement of the contract until Run the Python suite natively on Windows to cover the winerror conversion #277 lands. In particular the strerror equality assumes Rust's FormatMessageW lookup and CPython's agree; they use the same flags but different language IDs (SUBLANG_SYS_DEFAULT versus user default), so a system whose UI language differs from the user's could in principle disagree. Flagging it here so whoever enables Run the Python suite natively on Windows to cover the winerror conversion #277 knows where to look first.

Gates

check-fmt, lint (ruff, interrogate, pylint 10.00/10, clippy, whitaker), typecheck, test (79/79 Rust via nextest; 773 passed / 52 skipped Python), markdownlint, nixie, and mbake validate Makefile are all green on 4045613. clippy::expect_used caught an .expect() in the first draft of the new Rust test; it now uses the let ... else { panic!(...) } form the neighbouring tests use.

codescene-access[bot]

This comment was marked as outdated.

leynos and others added 7 commits August 3, 2026 22:59
`OSError`s raised by the extension carried `errno = None`, even when the
failure was a real OS error whose number appeared in the message. Callers
could not branch on the kind of failure; the only route left was matching
the message text, which is not a stable interface.

The cause is in PyO3's `From<io::Error> for PyErr`: it picks the exception
type from `io::ErrorKind`, then constructs it with a single argument, the
error's `Display` string. Python populates `errno` and `strerror` only for
`OSError(errno, strerror)` — two arguments or more — so the number
survived in the text and nowhere a caller could reach it.

Construct the exception here instead, passing `(code, strerror)`. That
fixes both halves at once, because CPython maps the errno to the matching
subclass itself: `OSError(32, ...)` *is* a `BrokenPipeError`. It is the
same subclass selection PyO3 reached for through `ErrorKind`, taken from
the authoritative source rather than a parallel table — reading a
directory now raises `IsADirectoryError`, as it should.

Strip Rust's `" (os error N)"` suffix from the message, which would
otherwise render as `"[Errno 9] Bad file descriptor (os error 9)"`. An
`io::Error` with no `raw_os_error` has no number to preserve, so PyO3's
mapping stays in use for it.

The existing assertion in `test_rust_pump_stream_propagates_io_errors` is
unchanged and now passes. It reaches the conversion through the pump,
which treats a broken pipe as non-fatal and drains, so most interesting
errnos never propagate that way; `test_rust_errno.py` isolates the
conversion through the exported entry points instead, and pins the
subclass selection and message shape as well as the number.

Verified by mutation: restoring PyO3's single-argument conversion fails
five tests, and leaving the Rust suffix in place fails one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The two-argument `OSError(code, strerror)` form is correct only where
`raw_os_error` is an errno. On Windows it is a `GetLastError` code, so
the number landed in `errno`, meaning something else entirely —
`ERROR_INVALID_HANDLE` is 6, which as an errno is `ENXIO` — left
`winerror` unset, and selected the subclass from the wrong number. The
field this change set out to make branchable would have been actively
misleading there, and Windows wheels are built and shipped.

Move the construction behind a cfg-selected `os_error_to_py_err`. The
Windows arm uses the five-argument form, which is the one that carries a
native code: given a `winerror`, CPython ignores the errno argument,
derives errno from the Win32 code, and selects the subclass from the
derived value, so all three fields agree.

Verified by cross-compiling (`cargo check --target
x86_64-pc-windows-msvc`); no job runs the Python suite on Windows, so
the errno tests now skip there rather than encode both taxonomies.

Add three proptests for `strip_os_error_suffix`, whose four rstest cases
could not show that stripping stays anchored to this error's own code
over arbitrary messages and codes. The generator deliberately includes
messages that merely end the way a suffix does: an unconstrained `.*`
almost never produces one, and so cannot catch an implementation that
trims punctuation off messages it does not recognise.

Mutation-verified — never stripping fails property 1, stripping without
the code anchor fails property 2, and rewriting unrecognised messages
fails properties 2 and 3.

Document the conversion in both guides, including the errno/strerror
contract, subclass selection, and the removed suffix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pytest.raises(OSError)` needs a `match` to satisfy ruff PT011, and the
one these tests carried was `"Bad file descriptor"` — the C library's
`strerror`, which glibc translates under `LC_MESSAGES`. Under a locale
with the catalogue installed the guard would fail before the test
reached the errno and exception-type assertions it exists for.

Anchor on the `[Errno 9]` prefix CPython formats itself instead. That
keeps PT011 satisfied, drops the dependency on English, and asserts
something the test actually cares about — the number — rather than
prose the platform owns.

Verified with the extension built: all four pass. I could not reproduce
the original failure here, because this host has no glibc message
catalogues installed, so `strerror` stays English even under `fr_FR`;
the change rests on the documented `LC_MESSAGES` behaviour rather than
on a local reproduction.

Also correct the users' guide, which said an I/O failure "inside either
helper raises". `rust_pump_stream` treats a broken pipe or connection
reset as a downstream stage exiting early: it drains and returns
successfully, so that sentence described a failure the helper
deliberately suppresses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`rendered` is not read after the comparison, so cloning it only to hand
`prop_assert_eq!` an owned value allocates a second copy of every
generated message for nothing. The macro binds its operands to locals
before comparing, so the borrow taken for the left-hand call has already
ended and the value can simply be moved.

Raised by CodeRabbit as a Rust compiler lint integrity error.
The module skipped itself wholesale on Windows, so the Windows arm of
`os_error_to_py_err` had no executable statement of its contract at all —
only prose in the module docstring saying why it was absent.

Skip per platform instead. The POSIX cases keep the assertions they
always had; a new Windows case pins the part that differs, namely that
the native Win32 code arrives in `winerror` rather than being passed off
as an `errno`, and that `errno`, the exception subclass, and the message
prefix are all derived from it.

That case does not hard-code an expected code. It reads the expectation
back from an `OSError` built from the observed `winerror`, so it pins the
derivation itself and cannot go stale on whichever Win32 code the failure
happens to raise. `OSError.winerror` is invisible to a type check run off
Windows, so the one suppression that needs lives in a single accessor.

Nothing runs this case yet: no job runs the Python suite on Windows, and
`#277` tracks that. Update the developers' guide to say plainly what is
executed today versus merely written down, since the previous wording
credited a cross-compilation check that does not exist.
The `OSError` example under "Rust stream error handling" imports `errno`
and then calls `rust_consume_stream`, which it never imports. A reader
copying the block gets a `NameError` on the first line of the `try`, so
they never reach the `IsADirectoryError` and `errno.EBADF` branches the
section exists to demonstrate.

Add the import, leaving the exception flow untouched. `fd` stays
unbound: the prose immediately above establishes it as the descriptor
under discussion, which is how the guide's other examples use that name.

Checked every other Python block in the guide for the same omission;
this is the only one. No other example references `rust_consume_stream`,
`rust_pump_stream`, or anything else from `cuprum._streams_rs`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Windows case added in 85bc50c derived its expectations from the very
exception it was testing: it read `winerror` off the raised `OSError`,
built a second `OSError` from that number and the raised `strerror`, and
compared the two. That pins CPython's own derivation, which was never in
doubt, and says nothing about the value the extension supplied. A Rust
side that hard-coded a plausible Win32 code, or reported one taken from
some other failure, would have satisfied every assertion. The pre-merge
review was right to call it circular.

Obtain the expected code from outside the boundary instead. The test now
issues the same failing `ReadFile` through `ctypes` — a Windows stream
handle is a `std::fs::File`, whose `read` is `ReadFile`, so this is the
same system call on the same handle — and reads the code back from
`GetLastError`. `ctypes.WinError` then turns that code into the `OSError`
CPython itself would raise, supplying the expected `errno`, `strerror`,
and subclass. Nothing the extension returns feeds an expectation any
more, and the `[WinError N]` guard names the code rather than accepting
any digits. `use_last_error` keeps the interpreter from overwriting
`GetLastError` before it is read, and explicit `argtypes` stop a
pointer-sized `HANDLE` being truncated to a C int.

Give the POSIX arm an oracle too. `strerror` was only asserted truthy;
it is now compared against `os.strerror(EBADF)`, which CPython looks up
independently of the exception.

Cover the fallback arm, which nothing exercised. `io_error_to_py_err`
hands an `io::Error` carrying no `raw_os_error` to PyO3 unchanged, and
that arm is live in production: the write paths in `io_utils` raise
`ErrorKind::WriteZero` for a write that makes no progress, and such an
error has no number to preserve. Asserting on the built `PyErr` would
need a live interpreter, and the crate is compiled with
`pyo3/extension-module`, so no `cargo test` binary can link one — a probe
fails at link time on undefined `PyExc_*` symbols. Split the decision out
into `raw_os_error_parts`, which is pure, and test that directly for both
`io::Error` representations.

Mutation-verified: making the helper report code 0 instead of no code
fails all three synthesized cases.

Verified the POSIX assertions against a built extension (`maturin
develop`): four pass, the Windows case skips. The Windows arm stays
unexecuted, as no job runs the Python suite on Windows.

No CI job is added here. POSIX extension-gated execution is #269's
`extension-tests` job, and native Windows runtime coverage is
#277; this branch does not touch `ci.yml`, and adding a job
would conflict with #269 on rebase.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lodyai
lodyai Bot force-pushed the fix-pyo3-errno branch from 4045613 to c81ad46 Compare August 3, 2026 20:59
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.

@leynos

leynos commented Aug 3, 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 o11y, code safety, documentation and validation coverage as in scope).

❌ Failed checks (1 error)

Check name Status Explanation Resolution
Testing (Overall) ❌ Error POSIX tests exercise the boundary, but the Windows test derives expected values from the raised winerror, so an incorrect or constant native code could pass; CI also skips these extension tests. Assert the native error code and strerror from an independent operation oracle, add coverage for the no-raw-code fallback, and run the extension tests in POSIX and Windows CI jobs.

@coderabbitai

coderabbitai Bot commented Aug 3, 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

echo '== PR head =='
gh pr view 268 --repo leynos/cuprum --json headRefOid,url,state --jq '.'

echo '== Relevant source structure =='
ast-grep outline rust/cuprum-rust/src/errors.rs --items all --match 'raw_os_error_parts|io_error_to_py_err|PumpError' || true
ast-grep outline cuprum/unittests/test_rust_errno.py --items all || true

echo '== Relevant source excerpts =='
rg -n -C 5 'raw_os_error_parts|io_error_to_py_err|raw_os_error|WinError|_win32_oracle|_win32_readfile_error|os\.strerror|extension-tests|`#269`|`#277`|`#278`' \
  rust/cuprum-rust/src/errors.rs cuprum/unittests/test_rust_errno.py \
  .github/workflows 2>/dev/null || true

echo '== Workflow files =='
fd -t f . .github/workflows 2>/dev/null | sort
echo '== Workflow references =='
rg -n -C 3 'extension-tests|maturin|test_rust_errno|pytest|windows' .github/workflows 2>/dev/null || true

echo '== Follow-up issue and PR state =='
for n in 269 277 278; do
  echo "--- #$n ---"
  gh issue view "$n" --repo leynos/cuprum --json number,title,state,url,body,closedAt \
    --jq '{number,title,state,url,closedAt,body}' || true
done

Length of output: 36411


@leynos: Keep Testing (Overall) unresolved for this PR stack.

PR #268 resolves these requirements:

  1. It uses an independent POSIX oracle: os.strerror(errno.EBADF).
  2. It uses an independent Windows oracle: ReadFile through ctypes, GetLastError, and ctypes.WinError.
  3. It tests the no-raw-code path through raw_os_error_parts, including io::ErrorKind::WriteZero.

PR #268 does not resolve CI execution:

  • It contains no extension-tests workflow job.
  • PR #269 implements the POSIX extension-test job and is still open.
  • Issue #277 tracks the required native Windows runtime job and is still open.
  • PR #278 does not address the Windows CI gap.

Do not open a new issue. #269 and #277 already provide the required tracking.

Use this AI agent prompt for the remaining Windows work in #277:

Implement leynos/cuprum#277 after the POSIX extension-test work from PR `#269` is available.

Add a native Windows CI job on `windows-2022`. Build the Rust extension with the repository-supported maturin workflow. Run the extension-gated test target with `CUPRUM_REQUIRE_RUST_EXTENSION` enabled. Ensure that the job executes `cuprum/unittests/test_rust_errno.py` on Windows.

Keep the POSIX and Windows assertions platform-scoped:
- On POSIX, assert the expected `errno`, `strerror`, OSError subclass, and message format.
- On Windows, assert `winerror` against the independent `ReadFile` plus `GetLastError` oracle, then assert the derived `errno`, OSError subclass, `strerror`, and message format against `ctypes.WinError`.

Do not treat skipped extension tests as a passing result. Make a missing Rust extension fail the CI job with an actionable error.

Update `docs/developers-guide.md` to state that native Windows runtime coverage now executes in CI. Run formatting, lint, type checking, the normal test suite, and the Windows extension-test job. Report executed test counts and any platform-specific failures.

✏️ Learnings added
Learnt from: leynos
URL: https://github.com/leynos/cuprum/pull/268

Timestamp: 2026-08-03T23:20:18.994Z
Learning: In `leynos/cuprum`, Rust-extension boundary tests require a dedicated CI environment that builds the extension and sets `CUPRUM_REQUIRE_RUST_EXTENSION`; otherwise extension-gated modules can skip without exercising the Python/Rust boundary. PR `#269` tracks POSIX execution, and GitHub issue `#277` tracks native Windows runtime execution for the `cfg(windows)` `os_error_to_py_err` path.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@leynos
leynos merged commit 1a2e712 into main Aug 3, 2026
20 checks passed
@leynos
leynos deleted the fix-pyo3-errno branch August 3, 2026 23:25
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.

OSError raised across the PyO3 boundary loses errno (always None)

3 participants