cpython-ext: raise the concrete OSError subclass for I/O errors (fixes Windows clone/pull/rebase) - #1413
Open
JamBalaya56562 wants to merge 1 commit into
Open
Conversation
translate_io_error() built errors with
`PyErr::new::<exc::OSError, _>(py, args)`, which stores `OSError` as the
exception type and the constructor arguments as the exception value.
CPython turns that pair into an instance when the error is normalized,
but normalization does not replace the type, so the type stays `OSError`
even though `OSError.__new__` selects a subclass from errno.
CPython 3.10 and older match `except` clauses against the exception type
rather than the instance, so `except FileNotFoundError` never caught an
ENOENT error coming from Rust. localrepo.transaction() probes for a
leftover journal with exactly that pattern:
try:
self.svfs.stat("journal")
except FileNotFoundError:
pass
else:
self.recover()
The Windows builds embed CPython 3.10, so every locked transaction
aborted there with "The system cannot find the file specified.: journal"
- clone, pull and rebase all failed, while lock-free paths such as commit
and goto kept working. The macOS and Linux builds use CPython 3.12+,
which normalizes exceptions when they are raised, and were unaffected.
vfs.lexists() relies on the same pattern.
Instantiate `OSError` so the exception type is the concrete subclass on
every version.
Also report the Win32 error code as `OSError.winerror` on Windows.
`raw_os_error()` is a Win32 error code there, not an errno, and now that
errno selects the exception type a wrong errno means a wrong type:
ERROR_ACCESS_DENIED (5) was reported as EIO and stayed a plain `OSError`
instead of becoming `PermissionError`, and ERROR_PATH_NOT_FOUND (3) would
be reported as ESRCH. CPython derives the matching errno from winerror,
the same way it reports its own Windows I/O errors.
The Rust tests assert on the exception type, so they fail without the fix
regardless of the Python version. The Python test uses `except`, so it
only reproduces the original bug on CPython 3.10 and older.
|
This pull request has been imported. If you are a Meta employee, you can view this in D115831359. (Because this pull request was imported automatically, there will not be any future comments.) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On Windows, every command that opens a locked transaction fails:
clone,pullandrebaseall abort this way.commitandgotouselock-free transactions and keep working, which is why only some commands are
affected.
With
--traceback(sl 0.2.20260811-150444+8fb02b32, Windows 11):localrepo.transaction()treats a missing journal as the normal case:The
exceptclause does not match, so the normal case aborts the command.vfs.lexists()uses the same pattern.Root cause
cpython_ext::error::translate_io_error()built the error withPyErr::new::<T>storesTasptypeand the argument tuple aspvalue.CPython builds the actual exception instance when the error is normalized, and
_PyErr_NormalizeException()does not replace the type when it instantiatesfrom an argument tuple - so the type stays
OSErroreven thoughOSError.__new__returned aFileNotFoundError.CPython 3.10 and older push the exception type for
exceptmatching(
JUMP_IF_NOT_EXC_MATCHcomparesPyErr_GivenExceptionMatches(OSError, FileNotFoundError), which is false), while the bound object is theFileNotFoundErrorinstance. That produces the confusing state whereCPython 3.11 matches against the instance and 3.12 normalizes at raise time,
so neither is affected.
Why this is Windows-only
It depends on the version of the embedded interpreter, not on the OS:
(
sl debugshell -c "import sys; print(sys.version)"), so the bug reproduces.python@3.13, so macOS and Linux are fineand CI stays green.
This is not a recent regression - the released 0.2.20260522 Windows binary
behaves the same way.
Errors raised by Python itself (
open(),os.stat()) are unaffected; onlyerrors crossing the Rust boundary are.
Fix
Instantiate
OSErrorand build thePyErrfrom the instance, soptypeisthe concrete subclass that
OSError.__new__picked. This is correct on everysupported Python version and fixes every
except FileNotFoundError/PermissionError/FileExistsError/ ... over a Rust-raised I/O error atonce, including the two sites above.
Windows error codes
Included in the same change because it is now load-bearing:
io_error_errno()returned
io::Error::raw_os_error()as the errno, but on Windows that is aWin32 error code.
ERROR_FILE_NOT_FOUND(2) happens to equalENOENT, whichis why the journal probe still carried
errno == 2, but the others do notline up. Verified against the released Windows build:
Before this change a wrong errno was only a wrong
e.errno; once errno selectsthe exception type, it becomes a wrong exception type. So the Win32 code is now
passed as
OSError'swinerrorargument and CPython derives the errno from it,exactly as it does for its own Windows I/O errors in
PyErr_SetExcFromWindowsErrWithFilenameObjects.OSError.strerrorandOSError.filenameare unchanged, so theabort:messages built byscmutil.callcatch()are unchanged.Tests
eden/scm/lib/cpython-ext/src/io_error.rsasserts on the exception typedirectly, so those tests fail without the fix on any Python version:
test_missing_file_is_a_file_not_found_errortest_errno_selects_the_exception_type(unix)test_win32_error_code_selects_the_exception_type(windows)eden/scm/tests/test-rust-io-errors.pycovers the Python side throughvfs(
stat,lstat,read,listdir,unlink, and thelexists()fallback).It uses
except, so it only reproduces the original bug on CPython 3.10 andolder - the module docstring says so explicitly, since a 3.12+ test runner
passes it either way.
Reproducing
On a Windows build with an embedded CPython 3.10:
Or without a repo:
Verification
cargo test -p sapling-cpython-ext --lib io_errorpasses with the change andfails on both tests without it (Windows 11, x86_64-msvc).
cargo clippy -p sapling-cpython-ext --all-targetsandrustfmt --checkareclean.
test-rust-io-errors.pywas confirmed to reproduce the bugagainst the released Windows binary via
sl debugshell. The test file itselfwas not run under
run-tests.py- I could not produce a full local build ofslon Windows.cpython_extserde tests crash in my local environmentbecause the only Python I could link against is 3.14; that is unrelated to
this change and reproduces on a clean tree.