From 480bf805346cd10ee0af304b202b914dd741b78b Mon Sep 17 00:00:00 2001 From: Holden Date: Mon, 15 Jun 2026 00:58:22 +0000 Subject: [PATCH] fix: reconcile < target severity and rmtree symlink guard (v0.5.10) - reconcile.py: re-escalate the < target branch from INFO to WARNING and add 'permanently unmapped' label. Both post-loop branches produce identical permanent mapping loss; v0.5.9 incorrectly treated the timeout case as recoverable. - executor.py: guard shutil.rmtree with 'not os.path.islink(person_dir)' so a race-replaced symlink-to-directory is skipped rather than raising an unhandled OSError that aborts all remaining jobs. Correct comment: rmtree raises OSError, not NotADirectoryError. --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- winnow/executor.py | 7 ++++--- winnow/reconcile.py | 5 +++-- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff7b7cd..aa792cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.10] - 2026-06-15 + +### Fixed + +- **Reconcile `< target` branch re-escalated to WARNING**: when fewer Frigate files appear than expected after the full backoff window, the affected files are permanently unmapped — identical in consequence to the `> target` (external upload race) case fixed in v0.5.9. The v0.5.9 demotion to `INFO` was incorrect; both post-loop branches now log at `WARNING` and include the "permanently unmapped" label. + +- **`execute_jobs` symlink guard added before `shutil.rmtree`**: `os.path.isdir` follows symlinks and returns `True` for a symlink pointing at a directory. If a race condition replaces `person_dir` with such a symlink, the old guard would pass and `shutil.rmtree` would raise an unhandled `OSError`, aborting all remaining jobs in the batch. The guard is now `os.path.isdir(person_dir) and not os.path.islink(person_dir)`, so a symlink-to-directory is silently skipped. The comment is also corrected: `shutil.rmtree` raises `OSError` (not `NotADirectoryError`) on a top-level symlink. + ## [0.5.9] - 2026-06-15 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 35e851b..2db5d42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "winnow" -version = "0.5.9" +version = "0.5.10" description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition." license = "AGPL-3.0-or-later" requires-python = ">=3.13" diff --git a/winnow/executor.py b/winnow/executor.py index 8c09595..0954045 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -107,9 +107,10 @@ def execute_jobs(jobs: list[dict]) -> None: logger.error(str(e)) continue # Face crops are transient (uploaded then discarded); wipe before each run. - # shutil.rmtree raises NotADirectoryError on a top-level symlink (POSIX), - # so a race-replaced symlink cannot cause deletion outside output_dir. - if os.path.isdir(person_dir): + # Exclude symlinks explicitly: os.path.isdir follows them and returns True + # for a symlink-to-directory, but shutil.rmtree raises OSError on a + # top-level symlink rather than deleting through it. + if os.path.isdir(person_dir) and not os.path.islink(person_dir): shutil.rmtree(person_dir) os.makedirs(person_dir, exist_ok=True) diff --git a/winnow/reconcile.py b/winnow/reconcile.py index 53bb43e..a4ffcc2 100644 --- a/winnow/reconcile.py +++ b/winnow/reconcile.py @@ -85,9 +85,10 @@ def reconcile_frigate_mappings( target, ) else: - logger.info( + logger.warning( "%s: only %s of %s expected Frigate files" - " appeared after reconciliation — mapping skipped", + " appeared after reconciliation — mapping skipped;" + " these files are permanently unmapped", person_name, len(new_files), target,