Skip to content

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
facebook:mainfrom
JamBalaya56562:fix-rust-oserror-subclass
Open

cpython-ext: raise the concrete OSError subclass for I/O errors (fixes Windows clone/pull/rebase)#1413
JamBalaya56562 wants to merge 1 commit into
facebook:mainfrom
JamBalaya56562:fix-rust-oserror-subclass

Conversation

@JamBalaya56562

Copy link
Copy Markdown
Contributor

Problem

On Windows, every command that opens a locked transaction fails:

$ sl pull
abort: The system cannot find the file specified.: journal

clone, pull and rebase all abort this way. commit and goto use
lock-free transactions and keep working, which is why only some commands are
affected.

With --traceback (sl 0.2.20260811-150444+8fb02b32, Windows 11):

File "static:sapling.git", line 628, in pull
    with repo.lock(), repo.transaction("pull"):
File "static:sapling.localrepo", line 1781, in transaction
    self.svfs.stat("journal")
File "static:sapling.vfs", line 551, in stat
    return self.lstat(path)
File "static:sapling.vfs", line 501, in lstat
    return self._rustvfs.metadata(self._rustpath(path))
FileNotFoundError: [Errno 2] The system cannot find the file specified.: 'journal'

localrepo.transaction() treats a missing journal as the normal case:

if not lockfree:
    try:
        self.svfs.stat("journal")
    except FileNotFoundError:
        # No existing transaction - this is the normal case.
        pass
    else:
        self.recover()

The except clause 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 with

cpython::PyErr::new::<exc::OSError, _>(py, (errno, strerror, path))

PyErr::new::<T> stores T as ptype and the argument tuple as pvalue.
CPython builds the actual exception instance when the error is normalized, and
_PyErr_NormalizeException() does not replace the type when it instantiates
from an argument tuple - so the type stays OSError even though
OSError.__new__ returned a FileNotFoundError.

CPython 3.10 and older push the exception type for except matching
(JUMP_IF_NOT_EXC_MATCH compares PyErr_GivenExceptionMatches(OSError, FileNotFoundError), which is false), while the bound object is the
FileNotFoundError instance. That produces the confusing state where

except FileNotFoundError:                 # does not catch
except OSError:                           # catches, errno == 2
type(e) is builtins.FileNotFoundError     # True
isinstance(e, FileNotFoundError)          # True

CPython 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:

  • The Windows release binaries embed CPython 3.10.11
    (sl debugshell -c "import sys; print(sys.version)"), so the bug reproduces.
  • The Homebrew formula depends on python@3.13, so macOS and Linux are fine
    and 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; only
errors crossing the Rust boundary are.

Fix

Instantiate OSError and build the PyErr from the instance, so ptype is
the concrete subclass that OSError.__new__ picked. This is correct on every
supported Python version and fixes every except FileNotFoundError /
PermissionError / FileExistsError / ... over a Rust-raised I/O error at
once, 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 a
Win32 error code. ERROR_FILE_NOT_FOUND (2) happens to equal ENOENT, which
is why the journal probe still carried errno == 2, but the others do not
line up. Verified against the released Windows build:

ERROR_ACCESS_DENIED (5)   -> errno 5 (EIO)   -> plain OSError, not PermissionError
ERROR_PATH_NOT_FOUND (3)  -> errno 3 (ESRCH) -> would map to ProcessLookupError

Before this change a wrong errno was only a wrong e.errno; once errno selects
the exception type, it becomes a wrong exception type. So the Win32 code is now
passed as OSError's winerror argument and CPython derives the errno from it,
exactly as it does for its own Windows I/O errors in
PyErr_SetExcFromWindowsErrWithFilenameObjects. OSError.strerror and
OSError.filename are unchanged, so the abort: messages built by
scmutil.callcatch() are unchanged.

Tests

eden/scm/lib/cpython-ext/src/io_error.rs asserts on the exception type
directly, so those tests fail without the fix on any Python version:

  • test_missing_file_is_a_file_not_found_error
  • test_errno_selects_the_exception_type (unix)
  • test_win32_error_code_selects_the_exception_type (windows)

eden/scm/tests/test-rust-io-errors.py covers the Python side through vfs
(stat, lstat, read, listdir, unlink, and the lexists() fallback).
It uses except, so it only reproduces the original bug on CPython 3.10 and
older - 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:

sl clone https://github.com/facebook/sapling
cd sapling && sl pull      # abort: The system cannot find the file specified.: journal

Or without a repo:

sl debugshell -c "
import tempfile
from sapling import vfs
v = vfs.vfs(tempfile.mkdtemp(), audit=False)
try:
    v.stat('missing')
except FileNotFoundError:
    print('caught')
except OSError as e:
    print('NOT caught by except FileNotFoundError:', type(e).__name__, e.errno)
"

Verification

  • cargo test -p sapling-cpython-ext --lib io_error passes with the change and
    fails on both tests without it (Windows 11, x86_64-msvc).
  • cargo clippy -p sapling-cpython-ext --all-targets and rustfmt --check are
    clean.
  • Each assertion in test-rust-io-errors.py was confirmed to reproduce the bug
    against the released Windows binary via sl debugshell. The test file itself
    was not run under run-tests.py - I could not produce a full local build of
    sl on Windows.
  • The pre-existing cpython_ext serde tests crash in my local environment
    because the only Python I could link against is 3.14; that is unrelated to
    this change and reproduces on a clean tree.

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.
@meta-cla meta-cla Bot added the CLA Signed label Aug 13, 2026
@meta-codesync

meta-codesync Bot commented Aug 13, 2026

Copy link
Copy Markdown

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant