feat(platform_util,recovery_flow): Windows handle-anchored owned-spec restoration (supersedes the DW-309/DW-310 fail-closed pause) - #799
Conversation
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (1)
Included review availability: Your plan provides up to 2 included reviews per hour; 0 remain after this review. WalkthroughChangesAnchored write support
Priority: ⬇️ Low Estimated code review effort: 4 (Complex) | ~60 minutes Change: Feature Sequence Diagram(s)sequenceDiagram
participant RecoveryFlow
participant PlatformUtil
participant Win32At
participant FileSystem
RecoveryFlow->>PlatformUtil: request anchored spec restoration
PlatformUtil->>Win32At: dispatch Windows handle-relative operations
Win32At->>FileSystem: inspect, stage, and publish within directory handle
FileSystem-->>RecoveryFlow: return verified restoration result
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. A rabbit reads each line, Comment |
…open Windows CI (#799, both legs): 283 rows failed with PermissionError [WinError 5] out of _refuse_unwritable_target_at → open_at(O_WRONLY | AT_NOFOLLOW) → _refuse_link. FILE_GENERIC_WRITE carries WRITE_ATTRIBUTES only, so the write-only open succeeded and GetFileInformationByHandleEx (FileAttributeTagInfo) was then denied on that handle. CreateFileW adds FILE_READ_ATTRIBUTES to every open implicitly; do the same here. Wine granted the read regardless, which is why the harness never showed it.
Three rows inject a fault at `os.replace` (the floor every POSIX publish reaches, path-based or dir_fd-relative): the two no-tmp-residue rows in test_operatoractions and the interleaved-writer row in test_runs. With the Windows confined writer now handle-anchored, its publish is `win32_at.replace_at` (NtSetInformationFile), so that patch fired on nothing there — DID NOT RAISE / `[] == ['b']` on both Windows legs of #799. `conftest.patch_publish_rename` patches `os.replace` and, on the arm where it is live, `win32_at.replace_at` with the argument order adapted; `real_publish_rename` is the matching fall-through, bound to the originals at import. Linux behaviour is unchanged (same `os.replace` patch). Ablations: dropping either arm's patch reddens the rows on that arm (Linux; Wine).
610de2c to
559fe83
Compare
…open Windows CI (#799, both legs): 283 rows failed with PermissionError [WinError 5] out of _refuse_unwritable_target_at → open_at(O_WRONLY | AT_NOFOLLOW) → _refuse_link. FILE_GENERIC_WRITE carries WRITE_ATTRIBUTES only, so the write-only open succeeded and GetFileInformationByHandleEx (FileAttributeTagInfo) was then denied on that handle. CreateFileW adds FILE_READ_ATTRIBUTES to every open implicitly; do the same here. Wine granted the read regardless, which is why the harness never showed it.
Three rows inject a fault at `os.replace` (the floor every POSIX publish reaches, path-based or dir_fd-relative): the two no-tmp-residue rows in test_operatoractions and the interleaved-writer row in test_runs. With the Windows confined writer now handle-anchored, its publish is `win32_at.replace_at` (NtSetInformationFile), so that patch fired on nothing there — DID NOT RAISE / `[] == ['b']` on both Windows legs of #799. `conftest.patch_publish_rename` patches `os.replace` and, on the arm where it is live, `win32_at.replace_at` with the argument order adapted; `real_publish_rename` is the matching fall-through, bound to the originals at import. Linux behaviour is unchanged (same `os.replace` patch). Ablations: dropping either arm's patch reddens the rows on that arm (Linux; Wine).
…open Windows CI (#799, both legs): 283 rows failed with PermissionError [WinError 5] out of _refuse_unwritable_target_at → open_at(O_WRONLY | AT_NOFOLLOW) → _refuse_link. FILE_GENERIC_WRITE carries WRITE_ATTRIBUTES only, so the write-only open succeeded and GetFileInformationByHandleEx (FileAttributeTagInfo) was then denied on that handle. CreateFileW adds FILE_READ_ATTRIBUTES to every open implicitly; do the same here. Wine granted the read regardless, which is why the harness never showed it.
Three rows inject a fault at `os.replace` (the floor every POSIX publish reaches, path-based or dir_fd-relative): the two no-tmp-residue rows in test_operatoractions and the interleaved-writer row in test_runs. With the Windows confined writer now handle-anchored, its publish is `win32_at.replace_at` (NtSetInformationFile), so that patch fired on nothing there — DID NOT RAISE / `[] == ['b']` on both Windows legs of #799. `conftest.patch_publish_rename` patches `os.replace` and, on the arm where it is live, `win32_at.replace_at` with the argument order adapted; `real_publish_rename` is the matching fall-through, bound to the originals at import. Linux behaviour is unchanged (same `os.replace` patch). Ablations: dropping either arm's patch reddens the rows on that arm (Linux; Wine).
559fe83 to
1fb4399
Compare
…open Windows CI (#799, both legs): 283 rows failed with PermissionError [WinError 5] out of _refuse_unwritable_target_at → open_at(O_WRONLY | AT_NOFOLLOW) → _refuse_link. FILE_GENERIC_WRITE carries WRITE_ATTRIBUTES only, so the write-only open succeeded and GetFileInformationByHandleEx (FileAttributeTagInfo) was then denied on that handle. CreateFileW adds FILE_READ_ATTRIBUTES to every open implicitly; do the same here. Wine granted the read regardless, which is why the harness never showed it.
Three rows inject a fault at `os.replace` (the floor every POSIX publish reaches, path-based or dir_fd-relative): the two no-tmp-residue rows in test_operatoractions and the interleaved-writer row in test_runs. With the Windows confined writer now handle-anchored, its publish is `win32_at.replace_at` (NtSetInformationFile), so that patch fired on nothing there — DID NOT RAISE / `[] == ['b']` on both Windows legs of #799. `conftest.patch_publish_rename` patches `os.replace` and, on the arm where it is live, `win32_at.replace_at` with the argument order adapted; `real_publish_rename` is the matching fall-through, bound to the originals at import. Linux behaviour is unchanged (same `os.replace` patch). Ablations: dropping either arm's patch reddens the rows on that arm (Linux; Wine).
1fb4399 to
af1080a
Compare
…open Windows CI (#799, both legs): 283 rows failed with PermissionError [WinError 5] out of _refuse_unwritable_target_at → open_at(O_WRONLY | AT_NOFOLLOW) → _refuse_link. FILE_GENERIC_WRITE carries WRITE_ATTRIBUTES only, so the write-only open succeeded and GetFileInformationByHandleEx (FileAttributeTagInfo) was then denied on that handle. CreateFileW adds FILE_READ_ATTRIBUTES to every open implicitly; do the same here. Wine granted the read regardless, which is why the harness never showed it.
Three rows inject a fault at `os.replace` (the floor every POSIX publish reaches, path-based or dir_fd-relative): the two no-tmp-residue rows in test_operatoractions and the interleaved-writer row in test_runs. With the Windows confined writer now handle-anchored, its publish is `win32_at.replace_at` (NtSetInformationFile), so that patch fired on nothing there — DID NOT RAISE / `[] == ['b']` on both Windows legs of #799. `conftest.patch_publish_rename` patches `os.replace` and, on the arm where it is live, `win32_at.replace_at` with the argument order adapted; `real_publish_rename` is the matching fall-through, bound to the originals at import. Linux behaviour is unchanged (same `os.replace` patch). Ablations: dropping either arm's patch reddens the rows on that arm (Linux; Wine).
af1080a to
b592123
Compare
…open Windows CI (#799, both legs): 283 rows failed with PermissionError [WinError 5] out of _refuse_unwritable_target_at → open_at(O_WRONLY | AT_NOFOLLOW) → _refuse_link. FILE_GENERIC_WRITE carries WRITE_ATTRIBUTES only, so the write-only open succeeded and GetFileInformationByHandleEx (FileAttributeTagInfo) was then denied on that handle. CreateFileW adds FILE_READ_ATTRIBUTES to every open implicitly; do the same here. Wine granted the read regardless, which is why the harness never showed it.
Three rows inject a fault at `os.replace` (the floor every POSIX publish reaches, path-based or dir_fd-relative): the two no-tmp-residue rows in test_operatoractions and the interleaved-writer row in test_runs. With the Windows confined writer now handle-anchored, its publish is `win32_at.replace_at` (NtSetInformationFile), so that patch fired on nothing there — DID NOT RAISE / `[] == ['b']` on both Windows legs of #799. `conftest.patch_publish_rename` patches `os.replace` and, on the arm where it is live, `win32_at.replace_at` with the argument order adapted; `real_publish_rename` is the matching fall-through, bound to the originals at import. Linux behaviour is unchanged (same `os.replace` patch). Ablations: dropping either arm's patch reddens the rows on that arm (Linux; Wine).
b592123 to
1e139fd
Compare
…open Windows CI (#799, both legs): 283 rows failed with PermissionError [WinError 5] out of _refuse_unwritable_target_at → open_at(O_WRONLY | AT_NOFOLLOW) → _refuse_link. FILE_GENERIC_WRITE carries WRITE_ATTRIBUTES only, so the write-only open succeeded and GetFileInformationByHandleEx (FileAttributeTagInfo) was then denied on that handle. CreateFileW adds FILE_READ_ATTRIBUTES to every open implicitly; do the same here. Wine granted the read regardless, which is why the harness never showed it.
Three rows inject a fault at `os.replace` (the floor every POSIX publish reaches, path-based or dir_fd-relative): the two no-tmp-residue rows in test_operatoractions and the interleaved-writer row in test_runs. With the Windows confined writer now handle-anchored, its publish is `win32_at.replace_at` (NtSetInformationFile), so that patch fired on nothing there — DID NOT RAISE / `[] == ['b']` on both Windows legs of #799. `conftest.patch_publish_rename` patches `os.replace` and, on the arm where it is live, `win32_at.replace_at` with the argument order adapted; `real_publish_rename` is the matching fall-through, bound to the originals at import. Linux behaviour is unchanged (same `os.replace` patch). Ablations: dropping either arm's patch reddens the rows on that arm (Linux; Wine).
1e139fd to
b66fb4a
Compare
…open Windows CI (#799, both legs): 283 rows failed with PermissionError [WinError 5] out of _refuse_unwritable_target_at → open_at(O_WRONLY | AT_NOFOLLOW) → _refuse_link. FILE_GENERIC_WRITE carries WRITE_ATTRIBUTES only, so the write-only open succeeded and GetFileInformationByHandleEx (FileAttributeTagInfo) was then denied on that handle. CreateFileW adds FILE_READ_ATTRIBUTES to every open implicitly; do the same here. Wine granted the read regardless, which is why the harness never showed it.
Three rows inject a fault at `os.replace` (the floor every POSIX publish reaches, path-based or dir_fd-relative): the two no-tmp-residue rows in test_operatoractions and the interleaved-writer row in test_runs. With the Windows confined writer now handle-anchored, its publish is `win32_at.replace_at` (NtSetInformationFile), so that patch fired on nothing there — DID NOT RAISE / `[] == ['b']` on both Windows legs of #799. `conftest.patch_publish_rename` patches `os.replace` and, on the arm where it is live, `win32_at.replace_at` with the argument order adapted; `real_publish_rename` is the matching fall-through, bound to the originals at import. Linux behaviour is unchanged (same `os.replace` patch). Ablations: dropping either arm's patch reddens the rows on that arm (Linux; Wine).
f39afb5 to
4ca6bb3
Compare
…open Windows CI (#799, both legs): 283 rows failed with PermissionError [WinError 5] out of _refuse_unwritable_target_at → open_at(O_WRONLY | AT_NOFOLLOW) → _refuse_link. FILE_GENERIC_WRITE carries WRITE_ATTRIBUTES only, so the write-only open succeeded and GetFileInformationByHandleEx (FileAttributeTagInfo) was then denied on that handle. CreateFileW adds FILE_READ_ATTRIBUTES to every open implicitly; do the same here. Wine granted the read regardless, which is why the harness never showed it.
Three rows inject a fault at `os.replace` (the floor every POSIX publish reaches, path-based or dir_fd-relative): the two no-tmp-residue rows in test_operatoractions and the interleaved-writer row in test_runs. With the Windows confined writer now handle-anchored, its publish is `win32_at.replace_at` (NtSetInformationFile), so that patch fired on nothing there — DID NOT RAISE / `[] == ['b']` on both Windows legs of #799. `conftest.patch_publish_rename` patches `os.replace` and, on the arm where it is live, `win32_at.replace_at` with the argument order adapted; `real_publish_rename` is the matching fall-through, bound to the originals at import. Linux behaviour is unchanged (same `os.replace` patch). Ablations: dropping either arm's patch reddens the rows on that arm (Linux; Wine).
4ca6bb3 to
04a4391
Compare
…open Windows CI (#799, both legs): 283 rows failed with PermissionError [WinError 5] out of _refuse_unwritable_target_at → open_at(O_WRONLY | AT_NOFOLLOW) → _refuse_link. FILE_GENERIC_WRITE carries WRITE_ATTRIBUTES only, so the write-only open succeeded and GetFileInformationByHandleEx (FileAttributeTagInfo) was then denied on that handle. CreateFileW adds FILE_READ_ATTRIBUTES to every open implicitly; do the same here. Wine granted the read regardless, which is why the harness never showed it.
Three rows inject a fault at `os.replace` (the floor every POSIX publish reaches, path-based or dir_fd-relative): the two no-tmp-residue rows in test_operatoractions and the interleaved-writer row in test_runs. With the Windows confined writer now handle-anchored, its publish is `win32_at.replace_at` (NtSetInformationFile), so that patch fired on nothing there — DID NOT RAISE / `[] == ['b']` on both Windows legs of #799. `conftest.patch_publish_rename` patches `os.replace` and, on the arm where it is live, `win32_at.replace_at` with the argument order adapted; `real_publish_rename` is the matching fall-through, bound to the originals at import. Linux behaviour is unchanged (same `os.replace` patch). Ablations: dropping either arm's patch reddens the rows on that arm (Linux; Wine).
04a4391 to
a73574f
Compare
…open Windows CI (#799, both legs): 283 rows failed with PermissionError [WinError 5] out of _refuse_unwritable_target_at → open_at(O_WRONLY | AT_NOFOLLOW) → _refuse_link. FILE_GENERIC_WRITE carries WRITE_ATTRIBUTES only, so the write-only open succeeded and GetFileInformationByHandleEx (FileAttributeTagInfo) was then denied on that handle. CreateFileW adds FILE_READ_ATTRIBUTES to every open implicitly; do the same here. Wine granted the read regardless, which is why the harness never showed it.
Three rows inject a fault at `os.replace` (the floor every POSIX publish reaches, path-based or dir_fd-relative): the two no-tmp-residue rows in test_operatoractions and the interleaved-writer row in test_runs. With the Windows confined writer now handle-anchored, its publish is `win32_at.replace_at` (NtSetInformationFile), so that patch fired on nothing there — DID NOT RAISE / `[] == ['b']` on both Windows legs of #799. `conftest.patch_publish_rename` patches `os.replace` and, on the arm where it is live, `win32_at.replace_at` with the argument order adapted; `real_publish_rename` is the matching fall-through, bound to the originals at import. Linux behaviour is unchanged (same `os.replace` patch). Ablations: dropping either arm's patch reddens the rows on that arm (Linux; Wine).
a73574f to
b0eaac8
Compare
…open Windows CI (#799, both legs): 283 rows failed with PermissionError [WinError 5] out of _refuse_unwritable_target_at → open_at(O_WRONLY | AT_NOFOLLOW) → _refuse_link. FILE_GENERIC_WRITE carries WRITE_ATTRIBUTES only, so the write-only open succeeded and GetFileInformationByHandleEx (FileAttributeTagInfo) was then denied on that handle. CreateFileW adds FILE_READ_ATTRIBUTES to every open implicitly; do the same here. Wine granted the read regardless, which is why the harness never showed it.
Three rows inject a fault at `os.replace` (the floor every POSIX publish reaches, path-based or dir_fd-relative): the two no-tmp-residue rows in test_operatoractions and the interleaved-writer row in test_runs. With the Windows confined writer now handle-anchored, its publish is `win32_at.replace_at` (NtSetInformationFile), so that patch fired on nothing there — DID NOT RAISE / `[] == ['b']` on both Windows legs of #799. `conftest.patch_publish_rename` patches `os.replace` and, on the arm where it is live, `win32_at.replace_at` with the argument order adapted; `real_publish_rename` is the matching fall-through, bound to the originals at import. Linux behaviour is unchanged (same `os.replace` patch). Ablations: dropping either arm's patch reddens the rows on that arm (Linux; Wine).
b0eaac8 to
f04af84
Compare
…open Windows CI (#799, both legs): 283 rows failed with PermissionError [WinError 5] out of _refuse_unwritable_target_at → open_at(O_WRONLY | AT_NOFOLLOW) → _refuse_link. FILE_GENERIC_WRITE carries WRITE_ATTRIBUTES only, so the write-only open succeeded and GetFileInformationByHandleEx (FileAttributeTagInfo) was then denied on that handle. CreateFileW adds FILE_READ_ATTRIBUTES to every open implicitly; do the same here. Wine granted the read regardless, which is why the harness never showed it.
Three rows inject a fault at `os.replace` (the floor every POSIX publish reaches, path-based or dir_fd-relative): the two no-tmp-residue rows in test_operatoractions and the interleaved-writer row in test_runs. With the Windows confined writer now handle-anchored, its publish is `win32_at.replace_at` (NtSetInformationFile), so that patch fired on nothing there — DID NOT RAISE / `[] == ['b']` on both Windows legs of #799. `conftest.patch_publish_rename` patches `os.replace` and, on the arm where it is live, `win32_at.replace_at` with the argument order adapted; `real_publish_rename` is the matching fall-through, bound to the originals at import. Linux behaviour is unchanged (same `os.replace` patch). Ablations: dropping either arm's patch reddens the rows on that arm (Linux; Wine).
f04af84 to
2560aec
Compare
…andle
DW-309/DW-310 made a host without `dir_fd` fail closed: any rollback that had
to restore or lifecycle-normalize a bound attempt-owned spec paused on Windows
with "ACTION REQUIRED — attempt-owned spec needs manual recovery", because
CPython offers no `*at()` family there. The kernel underneath Win32 has the
same primitive, so build it:
- `win32_at.py` (new): ctypes bindings for `NtCreateFile` with a
`RootDirectory` handle (`open_directory`, `open_at`, `stat_at`) and
`NtSetInformationFile` `FileRenameInformationEx` /
`FileDispositionInformationEx` with POSIX semantics (`replace_at`,
`unlink_at`; the classic classes as the fallback where a volume refuses
the Ex ones). `AT_NOFOLLOW` opens the reparse point itself and refuses a
symlink or junction with ELOOP, as `O_NOFOLLOW` refuses a link; the flag
vocabulary IS `os.O_*` on POSIX and free CRT bits on Windows, so one
`os.O_RDONLY | AT_NOFOLLOW` serves both arms. Handles come back as CRT
descriptors (`msvcrt.open_osfhandle`) so `os.fstat`/`read`/`fsync`/
`fdopen`/`samestat` work unchanged. NTSTATUS maps through
`RtlNtStatusToDosError` into the OSError subclass Win32 would raise;
IsADirectoryError/NotADirectoryError are pinned by hand because the Win32
mapping loses them. Importable everywhere; ENOSYS off Windows.
- `platform_util`: `HANDLE_ANCHORED_WRITES = DIR_FD_ANCHORED_WRITES or
win32_at.AVAILABLE`, with `open_at`/`stat_at`/`replace_at`/`unlink_at`
wrappers whose POSIX arm is the bare `dir_fd` syscall (byte-identical to
before) and whose Windows arm goes through `win32_at` (`replace_at` inside
`_retry_on_sharing_violation`, as `atomic_replace` is). `open_dir_confined`
walks on both arms; `_open_exclusive_at`, `_atomic_write_at`,
`_refuse_unwritable_target_at`, `_atomic_write_confined` and
`create_exclusive_confined` route through the wrappers, so the confined spec
writers anchor at a handle on Windows too. The confined TEXT writer keeps
`atomic_write_text`'s translating newline default on the anchored arm
(`newline=None`), so the CRLF a ledger or decisions file lands on Windows is
unchanged. Callers that need the rest of the POSIX family (`scandir(fd)`,
`symlink` with `dir_fd`, directory fsync) stay on `DIR_FD_ANCHORED_WRITES`.
- `recovery_flow`: the two refusals gate on `HANDLE_ANCHORED_WRITES` ("without
handle-anchored writes"; the journaled `problem` reads "lacks handle-anchored
writes"), and the target stat/open/readback go through the wrappers.
Tests:
- tests/test_win32_at.py (new, skip off win32): every primitive, junction
refusal, EXCL, replace over an open share-delete target, the WinError 5/32
sharing violation the retry exists for, POSIX unlink while open, the
directory-rename refusal while a handle is open beneath, and the anchor
following a renamed directory; one ENOSYS row runs off Windows.
- test_recovery_flow.py / test_engine.py: `requires_descriptor_restoration`
and the "dir-fd anchoring is POSIX-only" gates flip to
`HANDLE_ANCHORED_WRITES` (the seven engine rows and ~30 recovery rows now
run on Windows CI); the native-Windows refusal row becomes
`test_owned_spec_restore_native_windows_anchors_at_a_handle`. Rows that
rename a directory out from under the open writer are POSIX-only by the
OS's own rule (Windows refuses that rename with ERROR_ACCESS_DENIED —
`posix_parent_swap_under_writer`, pinned from the other side in
test_win32_at.py); directory redirects are planted as junctions on Windows
(`_plant_directory_redirect`), fifo/socket/file-symlink entry types skip
there and a `junction` parameter joins the final-entry substitution row.
- test_platform_util.py: an `ANCHORED` marker runs the platform-neutral
`open_dir_confined` / `*_at` / confined-callback rows on both arms
(`_dir_fd` opens through `win32_at` on Windows); EBADF asserted by errno
("Invalid handle" on Windows); a new row pins the confined text writer's
platform line ending (ablation: `newline=""` reddens it on Windows).
- Forced-fallback rows patch `HANDLE_ANCHORED_WRITES` alongside
`DIR_FD_ANCHORED_WRITES`; `_refuse_link` joins the refusal-helper inventory.
Linux: full suite, pyright and trunk clean. The Windows arm was driven under
Wine (relative opens, renames, link refusal, error subclasses, stat_at
S_IFLNK); Wine diverges from Windows on delete-on-close, directory renames
with open handles, and a handle-relative rename after the anchored directory
moved, so those rows are settled by the Windows CI legs.
…open Windows CI (#799, both legs): 283 rows failed with PermissionError [WinError 5] out of _refuse_unwritable_target_at → open_at(O_WRONLY | AT_NOFOLLOW) → _refuse_link. FILE_GENERIC_WRITE carries WRITE_ATTRIBUTES only, so the write-only open succeeded and GetFileInformationByHandleEx (FileAttributeTagInfo) was then denied on that handle. CreateFileW adds FILE_READ_ATTRIBUTES to every open implicitly; do the same here. Wine granted the read regardless, which is why the harness never showed it.
Three rows inject a fault at `os.replace` (the floor every POSIX publish reaches, path-based or dir_fd-relative): the two no-tmp-residue rows in test_operatoractions and the interleaved-writer row in test_runs. With the Windows confined writer now handle-anchored, its publish is `win32_at.replace_at` (NtSetInformationFile), so that patch fired on nothing there — DID NOT RAISE / `[] == ['b']` on both Windows legs of #799. `conftest.patch_publish_rename` patches `os.replace` and, on the arm where it is live, `win32_at.replace_at` with the argument order adapted; `real_publish_rename` is the matching fall-through, bound to the originals at import. Linux behaviour is unchanged (same `os.replace` patch). Ablations: dropping either arm's patch reddens the rows on that arm (Linux; Wine).
2560aec to
913f3c0
Compare
|
@codex review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 913f3c021b
ℹ️ 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".
| length = len(name) * 2 | ||
| unicode = _UNICODE_STRING(length, length + 2, ctypes.cast(buf, wt.LPWSTR)) |
There was a problem hiding this comment.
Measure the NT name in UTF-16 code units
When a relative path component contains a non-BMP character (for example an emoji in an artifacts directory or spec filename), len(name) * 2 understates the byte length because that character occupies a UTF-16 surrogate pair. NtCreateFile therefore receives a truncated UNICODE_STRING, causing the new Windows confined walk/open to fail even though CreateFileW can open the path; derive both lengths from the UTF-16 encoding instead.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 1c17596: Length/MaximumLength now come from the create_unicode_buffer size (which already counts a non-BMP character as a surrogate pair) rather than len(name) * 2. Regression test test_open_at_measures_the_name_in_utf16_code_units opens, stats, exclusively creates, renames and unlinks a spec-🚀.md through the handle; ablated under Wine, the old measurement reports FileNotFoundError for the existing file.
| # A ULONG 1 in the union reads as ReplaceIfExists=TRUE for the classic | ||
| # class and as REPLACE_IF_EXISTS for the Ex one, so one buffer serves both. | ||
| encoded = name.encode("utf-16-le") | ||
| return struct.pack("<I4xQI", flags, root_handle, len(encoded)) + encoded + b"\0\0" |
There was a problem hiding this comment.
Build rename information with native pointer width
On a 32-bit Python installation on Windows, HANDLE is 4 bytes, but this format unconditionally emits the 64-bit layout (4 bytes of padding plus an 8-byte handle). The kernel then reads the padding as RootDirectory and part of the handle as FileNameLength, so every handle-relative publication through replace_at fails; construct the payload with a native-layout ctypes structure or select the layout using pointer size.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 1c17596: the FILE_RENAME_INFORMATION header format is selected by ctypes.sizeof(wt.HANDLE) — <I4xQI (name at 20) on 64-bit, <III (name at 12) on 32-bit. test_rename_information_follows_the_native_pointer_width holds the payload against a native-aligned ctypes Structure of the header rather than a copy of the arithmetic. Verified under Wine on the 3.12.10 win32 embeddable build: with the 64-bit layout forced, every replace_at fails WinError 161; with the fix, the rename rows pass.
…rd by pointer width `_nt_open` measured `UNICODE_STRING.Length` as `len(name) * 2` — code points, not the UTF-16 code units `create_unicode_buffer` already sizes the buffer in — so a non-BMP character in a component (an emoji in a spec name) handed NtCreateFile a name one WCHAR short: the read was FileNotFoundError for a file that exists, and an exclusive create planted a truncated entry beside it. Length now comes from the buffer itself. `_rename_information` packed the 64-bit FILE_RENAME_INFORMATION layout unconditionally; on a 32-bit interpreter HANDLE is 4 bytes and the kernel read the padding as RootDirectory and half the handle as FileNameLength, failing every replace_at. The header format now follows the pointer width, and the name is encoded with surrogatepass as the open buffer already tolerates. Both verified under Wine on the 3.12 amd64 and win32 embeddable builds, with each fix ablated to confirm its test fails.
|
@codex review |
|
Codex Review: Didn't find any major issues. 🚀 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
What
Thirteenth slice on top of Wave 5's stack, and the only hand-written one: it replaces the Windows fail-closed pause that S12 (#797) shipped for DW-309/DW-310 with a real handle-anchored restoration path. New module
src/bmad_loop/win32_at.py;platform_util.HANDLE_ANCHORED_WRITESjoins the POSIXdir_fdarm to it;recovery_flowrestores and normalizes attempt-owned specs on Windows instead of pausing for manual adoption.Why
S12 closed DW-308 … DW-310 by refusing attempt-owned spec restoration on any host without descriptor-relative writes — correct, and on Windows it meant every rollback that has to put a bound spec back stops with "ACTION REQUIRED — attempt-owned spec needs manual recovery" (seven
test_engine.pyrows wereskipif'd there rather than fixed). The user decided that Windows should get the same guarantee rather than a pause. CPython exposes the*at()family only where the C library has it, but the NT kernel has the same primitive: every open takes anOBJECT_ATTRIBUTES.RootDirectoryhandle the name is resolved against, and a rename or delete is aNtSetInformationFileon the file's own handle with the destination again spelled relative to a handle. The reasons the POSIX arm is race-free apply unchanged once those calls are bound.How
win32_at.py— ctypes bindings:open_directory(path, follow=True)(aCreateFileWdirectory handle,follow=Falserefuses a symlink/junction AT the path withELOOP);open_at(dir_fd, name, flags)(NtCreateFilerelative to the handle, speakingO_RDONLY/O_WRONLY/O_RDWR/O_CREAT/O_EXCLplusAT_NOFOLLOW/AT_DIRECTORY/AT_NONBLOCK;O_TRUNC/O_APPENDrefused loudly);stat_at(lstatsemantics — a reparse link reportsS_IFLNKsoS_ISREG/S_ISDIRanswer False);replace_at/unlink_at(FileRenameInformationEx/FileDispositionInformationExwith POSIX semantics, the classic classes as the fallback where a volume refuses them). "Do not follow" isFILE_OPEN_REPARSE_POINTplus an attribute check on the opened handle — only the symlink and mount-point tags count, asplatform_util._LINK_REPARSE_TAGSalready draws the line. Every handle is wrapped into a CRT descriptor (msvcrt.open_osfhandle) soos.fstat/read/fsync/fdopen/samestat/closework unchanged. NTSTATUS →RtlNtStatusToDosError→OSError(winerror), so the subclass is the one Win32 raises;IsADirectoryError/NotADirectoryErrorare pinned by hand because the Win32 mapping loses them. Importable everywhere;ENOSYSoff Windows.platform_util—HANDLE_ANCHORED_WRITES = DIR_FD_ANCHORED_WRITES or win32_at.AVAILABLE; wrappersopen_at/stat_at/replace_at/unlink_atwhose POSIX arm is the baredir_fdsyscall (byte-identical behavior) and whose Windows arm iswin32_at(replace_atinside_retry_on_sharing_violation, asatomic_replaceis).open_dir_confined,_open_exclusive_at,_atomic_write_at,_refuse_unwritable_target_at,_atomic_write_confinedandcreate_exclusive_confinedroute through them, so the confined spec writers (set_frontmatter_statusamong them — needed for DW-310's lifecycle normalization) anchor at a handle on Windows too. The confined text writer keepsatomic_write_text's translating newline default on the anchored arm, so a ledger or decisions file still lands CRLF on Windows — pinned by a new row whose ablation reddens there. Callers that need the rest of the POSIX family (scandir(fd),symlinkwithdir_fd, directory fsync: artifact publication, journal, sweep, TUI launch, verify snapshots) stay onDIR_FD_ANCHORED_WRITES, untouched.recovery_flow— both refusals gate onHANDLE_ANCHORED_WRITES("cannot be verified without handle-anchored writes"; the journaledproblemreads "lacks handle-anchored writes");target_stat_at/read_target_at/verify_published_inodeuse the wrappers. Only a host with neither arm still pauses.docs/FEATURES.mdattempt-owned-spec and confined-rewrite paragraphs describe both arms.deferred-work.mdis not hand-edited: DW-309/DW-310's recorded decisions ("fail closed without descriptors") are superseded by this slice for Windows, and the ledger is the orchestrator's to write.Tests
tests/test_win32_at.py(new, skipped off Windows): every primitive; junction refusal withELOOPand following without the flag;O_EXCL; read-open of a directory allowed / write-openEISDIR; the read-only-attributePermissionErrorthe writable probe relies on;stat_atS_IFLNKfor a junction;replace_atkeeping the writer's own handle as the published inode, across directories, over a target another share-delete handle holds open (POSIX rename semantics), and the WinError 5/32 sharing violation the retry exists for;unlink_atrefusing a directory and removing a name while the writer's handle is open (POSIX delete); the OS refusing to rename a directory with a handle open beneath it; the anchor following a renamed directory while a junction planted at the old name is never consulted. OneENOSYSrow runs on POSIX.test_recovery_flow.py/test_engine.py—requires_descriptor_restorationand the ~30 "dir-fd anchoring is POSIX-only" gates flip toHANDLE_ANCHORED_WRITES, so the seven engine rows and the recovery rows run on the Windows legs;test_owned_spec_restore_native_windows_refuses_before_path_writerbecomes…_anchors_at_a_handle(asserts the anchored write landed and the generic confined PATH writer was never reached — what the refusal existed to guarantee). Rows that rename a directory out from under the open writer are POSIX-only by the OS's own rule (Windows refuses that rename withERROR_ACCESS_DENIED;posix_parent_swap_under_writer, pinned from the other side intest_win32_at.py). Directory redirects are planted as junctions on Windows (_plant_directory_redirect),fifo/socket/file-symlinkentry types skip there and ajunctionparameter joins the final-entry substitution row.test_platform_util.py— anANCHOREDmarker runs the platform-neutralopen_dir_confined/*_at/ confined-callback rows on both arms (_dir_fdopens throughwin32_aton Windows);EBADFasserted by errno ("Invalid handle" on Windows); the confined text writer's platform line ending pinned. Forced-fallback rows everywhere patchHANDLE_ANCHORED_WRITESbesideDIR_FD_ANCHORED_WRITES;_refuse_linkjoins the portability guard's refusal-helper inventory.stat_at); Wine diverges from Windows on delete-on-close, directory renames with open handles and a handle-relative rename after the anchored directory moved, so those rows are settled by the Windows CI legs.Testing
At this tip:
uv run pytest -q -n 4(10,245 passed, 86 skipped),uv run pyright(0 errors, 0 warnings),trunk check --all --no-fix(263 files, no issues),uv run --no-project python scripts/release.py checkanduv buildall clean. Every one of the 13 tips of the rebased stack was re-verified the same way (round 6). Negative assertions were ablated before being trusted.Changelog
One
Addedentry under## [Unreleased](trailingdocs(changelog): wave5 S13 entriescommit). The S12 entries for DW-308 … DW-310 stand: the no-descriptor refusal still exists for a host with neither arm.Notes
gh stack mergeis bottom-up, so S12 lands first and this immediately after — do not stop between them.win32_at.py+541 andtest_win32_at.py+425.Stack (merge bottom-up): S01 → … → S12 → S13 (this). Base is S12 (
pbean/wave5-12-migration-ledger-byte-binding), notmain. Stacked on #797.Summary by CodeRabbit
New Features
Bug Fixes