Skip to content

feat(platform_util,recovery_flow): Windows handle-anchored owned-spec restoration (supersedes the DW-309/DW-310 fail-closed pause) - #799

Merged
pbean merged 5 commits into
pbean/wave5-12-migration-ledger-byte-bindingfrom
pbean/wave5-13-windows-handle-anchored-restoration
Sep 20, 2026
Merged

pbean merged 5 commits into
pbean/wave5-12-migration-ledger-byte-bindingfrom
pbean/wave5-13-windows-handle-anchored-restoration

Conversation

@pbean

@pbean pbean commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

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_WRITES joins the POSIX dir_fd arm to it; recovery_flow restores 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.py rows were skipif'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 an OBJECT_ATTRIBUTES.RootDirectory handle the name is resolved against, and a rename or delete is a NtSetInformationFile on 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) (a CreateFileW directory handle, follow=False refuses a symlink/junction AT the path with ELOOP); open_at(dir_fd, name, flags) (NtCreateFile relative to the handle, speaking O_RDONLY/O_WRONLY/O_RDWR/O_CREAT/O_EXCL plus AT_NOFOLLOW/AT_DIRECTORY/AT_NONBLOCK; O_TRUNC/O_APPEND refused loudly); stat_at (lstat semantics — a reparse link reports S_IFLNK so S_ISREG/S_ISDIR answer False); replace_at / unlink_at (FileRenameInformationEx / FileDispositionInformationEx with POSIX semantics, the classic classes as the fallback where a volume refuses them). "Do not follow" is FILE_OPEN_REPARSE_POINT plus an attribute check on the opened handle — only the symlink and mount-point tags count, as platform_util._LINK_REPARSE_TAGS already draws the line. Every handle is wrapped into a CRT descriptor (msvcrt.open_osfhandle) so os.fstat/read/fsync/fdopen/samestat/close work unchanged. NTSTATUS → RtlNtStatusToDosErrorOSError(winerror), so the subclass is the one Win32 raises; IsADirectoryError/NotADirectoryError are pinned by hand because the Win32 mapping loses them. Importable everywhere; ENOSYS off Windows.
  • platform_utilHANDLE_ANCHORED_WRITES = DIR_FD_ANCHORED_WRITES or win32_at.AVAILABLE; wrappers open_at/stat_at/replace_at/unlink_at whose POSIX arm is the bare dir_fd syscall (byte-identical behavior) and whose Windows arm is win32_at (replace_at inside _retry_on_sharing_violation, as atomic_replace is). open_dir_confined, _open_exclusive_at, _atomic_write_at, _refuse_unwritable_target_at, _atomic_write_confined and create_exclusive_confined route through them, so the confined spec writers (set_frontmatter_status among them — needed for DW-310's lifecycle normalization) anchor at a handle on Windows too. The confined text writer keeps atomic_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), symlink with dir_fd, directory fsync: artifact publication, journal, sweep, TUI launch, verify snapshots) stay on DIR_FD_ANCHORED_WRITES, untouched.
  • recovery_flow — both refusals gate on HANDLE_ANCHORED_WRITES ("cannot be verified without handle-anchored writes"; the journaled problem reads "lacks handle-anchored writes"); target_stat_at/read_target_at/verify_published_inode use the wrappers. Only a host with neither arm still pauses.
  • Docs — the docs/FEATURES.md attempt-owned-spec and confined-rewrite paragraphs describe both arms. deferred-work.md is 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 with ELOOP and following without the flag; O_EXCL; read-open of a directory allowed / write-open EISDIR; the read-only-attribute PermissionError the writable probe relies on; stat_at S_IFLNK for a junction; replace_at keeping 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_at refusing 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. One ENOSYS row runs on POSIX.
  • test_recovery_flow.py / test_engine.pyrequires_descriptor_restoration and the ~30 "dir-fd anchoring is POSIX-only" gates flip to HANDLE_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_writer becomes …_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 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); the confined text writer's platform line ending pinned. Forced-fallback rows everywhere patch HANDLE_ANCHORED_WRITES beside DIR_FD_ANCHORED_WRITES; _refuse_link joins the portability guard's refusal-helper inventory.
  • The Windows arm was driven under Wine before CI (relative opens, renames, link refusal, error subclasses, 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 check and uv build all 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 Added entry under ## [Unreleased] (trailing docs(changelog): wave5 S13 entries commit). The S12 entries for DW-308 … DW-310 stand: the no-descriptor refusal still exists for a host with neither arm.

Notes

  • Merge with S12. S12 alone ships the fail-closed Windows pause; this slice is what the user asked for instead. gh stack merge is bottom-up, so S12 lands first and this immediately after — do not stop between them.
  • Two commits, both hand-written: the feature and its changelog entry. Net: 10 files, +1,293/−141, of which win32_at.py +541 and test_win32_at.py +425.

Stack (merge bottom-up): S01 → … → S12 → S13 (this). Base is S12 (pbean/wave5-12-migration-ledger-byte-binding), not main. Stacked on #797.

Summary by CodeRabbit

  • New Features

    • Added Windows support for safely restoring and normalizing attempt-owned specifications.
    • Extended safe, handle-anchored file operations to policy and related artifact updates.
    • Prevented writes through symlinks and junctions on supported platforms.
  • Bug Fixes

    • Recovery now proceeds on supported Windows and POSIX systems.
    • Unsupported platforms pause before staging or normalization and report that handle-anchored writes are unavailable.
    • Preserved platform-appropriate line endings during confined text updates.

@pbean
pbean added this pull request to stack #798 September 17, 2026 00:50
@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown

Review Change StackReview 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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 7e7c2be1-ae5e-4bd0-94bb-36734c8509cd

📥 Commits

Reviewing files that changed from the base of the PR and between b592123 and 1e139fd.

📒 Files selected for processing (1)
  • tests/test_portability_guard.py

Included review availability: Your plan provides up to 2 included reviews per hour; 0 remain after this review.


Walkthrough

Changes

Anchored write support

Layer / File(s) Summary
Windows NT filesystem operations
src/bmad_loop/win32_at.py, tests/test_win32_at.py, tests/test_portability_guard.py
Adds Windows handle-relative open, stat, replace, and unlink operations. The implementation refuses symlinks and junctions when requested and provides non-Windows ENOSYS behavior.
Cross-platform anchored write dispatch
src/bmad_loop/platform_util.py, tests/conftest.py, tests/test_platform_util.py, tests/test_artifact_publication.py
Adds HANDLE_ANCHORED_WRITES and dispatches confined walks, atomic writes, replacement, cleanup, and exclusive creation through POSIX or Windows primitives.
Attempt-owned spec recovery
src/bmad_loop/recovery_flow.py, tests/test_recovery_flow.py, docs/FEATURES.md, CHANGELOG.md
Recovery uses handle-anchored capability checks and platform shims for inspection, verification, and restoration. Hosts without either anchored-write arm pause before the write.
Cross-platform validation seams
tests/test_engine.py, tests/test_operatoractions.py, tests/test_runs.py
Tests use shared publication-rename hooks, updated capability markers, forced fallback paths, and concurrent publication coverage.

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
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.03% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 156 functions across 11 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main change: Windows handle-anchored restoration for attempt-owned specifications, replacing the previous fail-closed pause.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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.

❤️ Share

A rabbit reads each line,
The patch grows clear beneath the moon,
Small changes hop in place,
Tests guard the garden path,
Reviews bloom before the dawn.

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

@pbean pbean changed the title pbean/wave5 13 windows handle anchored restoration feat(platform_util,recovery_flow): Windows handle-anchored owned-spec restoration (supersedes the DW-309/DW-310 fail-closed pause) Sep 17, 2026
pbean pushed a commit that referenced this pull request Sep 17, 2026
…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.
pbean pushed a commit that referenced this pull request Sep 17, 2026
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).
@pbean
pbean force-pushed the pbean/wave5-13-windows-handle-anchored-restoration branch from 610de2c to 559fe83 Compare September 19, 2026 17:18
pbean pushed a commit that referenced this pull request Sep 19, 2026
…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.
pbean pushed a commit that referenced this pull request Sep 19, 2026
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).
pbean pushed a commit that referenced this pull request Sep 19, 2026
…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.
pbean pushed a commit that referenced this pull request Sep 19, 2026
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).
@pbean
pbean force-pushed the pbean/wave5-13-windows-handle-anchored-restoration branch from 559fe83 to 1fb4399 Compare September 19, 2026 21:52
pbean pushed a commit that referenced this pull request Sep 19, 2026
…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.
pbean pushed a commit that referenced this pull request Sep 19, 2026
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).
@pbean
pbean force-pushed the pbean/wave5-13-windows-handle-anchored-restoration branch from 1fb4399 to af1080a Compare September 19, 2026 23:13
pbean pushed a commit that referenced this pull request Sep 19, 2026
…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.
pbean pushed a commit that referenced this pull request Sep 19, 2026
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).
@pbean
pbean force-pushed the pbean/wave5-13-windows-handle-anchored-restoration branch from af1080a to b592123 Compare September 19, 2026 23:56
pbean pushed a commit that referenced this pull request Sep 20, 2026
…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.
pbean pushed a commit that referenced this pull request Sep 20, 2026
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).
@pbean
pbean force-pushed the pbean/wave5-13-windows-handle-anchored-restoration branch from b592123 to 1e139fd Compare September 20, 2026 00:21
pbean pushed a commit that referenced this pull request Sep 20, 2026
…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.
pbean pushed a commit that referenced this pull request Sep 20, 2026
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).
@pbean
pbean force-pushed the pbean/wave5-13-windows-handle-anchored-restoration branch from 1e139fd to b66fb4a Compare September 20, 2026 00:48
pbean pushed a commit that referenced this pull request Sep 20, 2026
…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.
pbean pushed a commit that referenced this pull request Sep 20, 2026
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).
@pbean
pbean force-pushed the pbean/wave5-13-windows-handle-anchored-restoration branch from f39afb5 to 4ca6bb3 Compare September 20, 2026 05:12
pbean pushed a commit that referenced this pull request Sep 20, 2026
…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.
pbean pushed a commit that referenced this pull request Sep 20, 2026
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).
@pbean
pbean force-pushed the pbean/wave5-13-windows-handle-anchored-restoration branch from 4ca6bb3 to 04a4391 Compare September 20, 2026 05:50
pbean pushed a commit that referenced this pull request Sep 20, 2026
…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.
pbean pushed a commit that referenced this pull request Sep 20, 2026
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).
@pbean
pbean force-pushed the pbean/wave5-13-windows-handle-anchored-restoration branch from 04a4391 to a73574f Compare September 20, 2026 06:24
pbean pushed a commit that referenced this pull request Sep 20, 2026
…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.
pbean pushed a commit that referenced this pull request Sep 20, 2026
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).
@pbean
pbean force-pushed the pbean/wave5-13-windows-handle-anchored-restoration branch from a73574f to b0eaac8 Compare September 20, 2026 06:29
pbean pushed a commit that referenced this pull request Sep 20, 2026
…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.
pbean pushed a commit that referenced this pull request Sep 20, 2026
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).
@pbean
pbean force-pushed the pbean/wave5-13-windows-handle-anchored-restoration branch from b0eaac8 to f04af84 Compare September 20, 2026 06:55
pbean pushed a commit that referenced this pull request Sep 20, 2026
…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.
pbean pushed a commit that referenced this pull request Sep 20, 2026
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).
@pbean
pbean force-pushed the pbean/wave5-13-windows-handle-anchored-restoration branch from f04af84 to 2560aec Compare September 20, 2026 07:10
t added 4 commits September 20, 2026 10:23
…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).
@pbean
pbean force-pushed the pbean/wave5-13-windows-handle-anchored-restoration branch from 2560aec to 913f3c0 Compare September 20, 2026 17:23
@pbean

pbean commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 20, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-20T18:02:16.553701Z 1c17596 Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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: 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".

Comment thread src/bmad_loop/win32_at.py Outdated
Comment on lines +270 to +271
length = len(name) * 2
unicode = _UNICODE_STRING(length, length + 2, ctypes.cast(buf, wt.LPWSTR))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/bmad_loop/win32_at.py Outdated
# 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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@pbean

pbean commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: 1c17596949

ℹ️ 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".

@pbean
pbean merged commit f121a56 into main Sep 20, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant