Skip to content

bug: run-bundle retention is inoperative on native Windows #378

Description

@codeforester

Problem

Run-bundle retention has no native-Windows implementation. RetentionPolicy — including
RetentionPolicy.safe_defaults(), which every default App gets — is silently inoperative
on a platform the support matrix lists as Supported, so run bundles and their temp contents
grow without bound and every invocation past the cap logs a warning per removable bundle.

The only destructive path is descriptor-relative POSIX:

  • _remove_run_bundle() calls os.stat(candidate, dir_fd=root_fd, ...) and _remove_tree_at()
    (lib/python/base_cli/_runtime.py:765-795);
  • _remove_tree_at() calls _directory_open_flags(), which dereferences os.O_DIRECTORY
    and os.O_NOFOLLOW unconditionally (lib/python/base_cli/_runtime.py:297).

There is a guard asymmetry: _open_directory_nofollow() degrades gracefully with
if not hasattr(os, "O_DIRECTORY") or not hasattr(os, "O_NOFOLLOW")
(lib/python/base_cli/_runtime.py:801), but _directory_open_flags() and _remove_tree_at()
have no such guard. _cleanup.py handles the same platform question correctly via
_supports_fd_relative_cleanup() (lib/python/base_cli/_cleanup.py:110-119), and
docs/platform-support.md documents that temp-content erasure falls back on Windows. Retention
has no equivalent fallback and no documentation of the gap.

Verified evidence

Reviewed 2026-09-30 at a58ec109349fa3f3d03eae5b0de078b39ea361a2 (macOS, Python 3.14.6, Click 8.4.2).

Faithful native-Windows model — os.O_DIRECTORY/os.O_NOFOLLOW absent,
os.supports_dir_fd empty, and os.open() on a directory raising PermissionError (the
Windows CRT behaviour). Five finished status: "ok" bundles, RetentionPolicy(max_bundles=1):

Could not prune run bundle '.../runs/bundle0': [Errno 13] Permission denied
Could not prune run bundle '.../runs/bundle1': [Errno 13] Permission denied
Could not prune run bundle '.../runs/bundle2': [Errno 13] Permission denied
Could not prune run bundle '.../runs/bundle3': [Errno 13] Permission denied
Could not prune run bundle '.../runs/bundle4': [Errno 13] Permission denied
  prune returned normally (no exception raised to the caller)
  bundles remaining: 5 -> ['bundle0'..'bundle4']   (policy wanted 1)

prune_run_bundles() does not fail the command — except (OSError, RuntimeError) and
remove()'s except OSError absorb it — so the failure is silent apart from warning spam,
bounded only by _RETENTION_REMOVAL_BUDGET = 256 warnings per invocation.

Secondary, harder failure. On a platform where a directory os.open() succeeds but
O_DIRECTORY is absent, _remove_tree_at() raises AttributeError, which is not caught by
prune_run_bundles()'s except (OSError, RuntimeError). It escapes _create_context() and
fails the command. Reproduced by deleting only those two attributes on macOS with
RetentionPolicy(max_bundles=3):

--- POSIX (real platform) ---           run 1..5: exit=0
--- no O_DIRECTORY/O_NOFOLLOW ---       run 1..3: exit=1   ("Error: Unexpected internal error.")

Native-Windows confirmation on a real runner is still needed to establish which manifestation
occurs there; both follow from the same missing fallback.

Why CI does not catch it. windows-latest is in the matrix, but the unit suite uses a fresh
temp home per test and never exceeds max_bundles, and no test mocks a dir_fd-less platform
for this path (tests/test_platform_edge_paths.py mocks os.name == "nt" only for
_private_files). The Windows benchmark profile runs 31 persistence iterations against one home,
which does cross the default cap — worth checking whether that job is currently reporting warnings.

Proposal

  1. Add a _supports_fd_relative_bundle_removal() guard mirroring _cleanup.py, and a
    pathname-based Windows removal fallback that keeps the existing symlink/mount/identity
    refusals (validate each component, refuse reparse points, stay on one volume).
  2. If no safe removal is possible, fail closed once per pass with a single actionable
    warning naming the platform limitation — not one warning per bundle.
  3. Guard _directory_open_flags() (or its callers) so an unsupported platform raises a typed
    error prune_run_bundles() already catches, never AttributeError.
  4. Document the retention boundary in docs/platform-support.md next to the existing
    temp-erasure note.

Acceptance criteria

  • On a platform without descriptor-relative directory operations, retention either removes
    bundles through the audited fallback or fails closed with exactly one warning per pass.
  • No code path in _runtime.py dereferences os.O_DIRECTORY/os.O_NOFOLLOW without a guard.
  • A regression test simulates a dir_fd-less platform (absent flags and empty
    os.supports_dir_fd) and asserts the invocation still exits 0 and the warning count is bounded.
  • A native-Windows test asserts max_bundles is actually enforced.
  • docs/platform-support.md states the retention boundary explicitly.

Non-goals

  • Do not weaken the POSIX identity/mount/symlink guarantees to share one code path.
  • Do not perform race-prone pathname recursion on POSIX where descriptor operations exist.

Activity

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

Metadata

Metadata

Assignees

Labels

area: runtimeRuntime, lifecycle, execution, or process-boundary ownership.bugSomething is not working

Type

No type

Projects

  • Status
    Backlog

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions