You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
ifnotlockfree:
try:
self.svfs.stat("journal")
exceptFileNotFoundError:
# No existing transaction - this is the normal case.passelse:
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
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:
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.
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
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.