Skip to content

Commit 6969833

Browse files
committed
fix(mutation): harden prepare reaper and retarget for Windows
Three cross-platform defects in the lock-free prepare path (ee22914), each with a POSIX-simulated regression test (CI is Linux-only): - reaper stalled the KB on a locked loose file: the file branch's unlink(missing_ok=True) only swallows FileNotFoundError, so a PermissionError (AV/indexer on Windows) escaped kb_lock and crashed every exclusive-lock command. Now wrapped in try/except OSError. - read-only staging never self-healed: copy2 preserves a read-only source's bit, and rmtree(ignore_errors=True) can't delete read-only entries on Windows, so the orphan re-logged 'Could not fully reap' on every lock acquisition. rmtree now passes an onerror that clears the read-only bit and retries. - retarget rewrote line endings: write_text without newline= translated \n to \r\n on Windows, so a collision-renamed source became CRLF while all others stayed LF. Switched to atomic_write_text (LF-preserving).
1 parent ee22914 commit 6969833

4 files changed

Lines changed: 165 additions & 3 deletions

File tree

openkb/cli.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,10 @@ def _retarget_prepared_document_artifacts(prepared, doc_name: str) -> None:
535535
if old_source.exists():
536536
text = old_source.read_text(encoding="utf-8")
537537
text = text.replace(f"sources/images/{old_doc_name}/", f"sources/images/{doc_name}/")
538-
old_source.write_text(text, encoding="utf-8")
538+
# LF-preserving rewrite: write_text without newline= would translate \n
539+
# to \r\n on Windows, leaving a collision-renamed source CRLF while every
540+
# other source (written via atomic_write_text in convert) stays LF.
541+
atomic_write_text(old_source, text)
539542
old_source.rename(new_source)
540543
result.source_path = new_source
541544
# Defensive: if prepare ever writes source_path off the <doc_name>.md

openkb/locks.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import logging
1414
import os
1515
import shutil
16+
import stat
1617
import tempfile
1718
import threading
1819
from pathlib import Path
@@ -127,6 +128,32 @@ def _drain_pending_journals(openkb_dir: Path) -> None:
127128
_reap_prepare_staging(openkb_dir)
128129

129130

131+
def _reap_prepare_staging_onerror(func, path, exc_info) -> None:
132+
"""``shutil.rmtree`` onerror: clear a read-only bit so the reap self-heals.
133+
134+
``shutil.copy2`` preserves a read-only source's attribute into staging; on
135+
Windows ``os.unlink``/``os.rmdir`` deny a read-only entry, so rmtree would
136+
leave the orphan behind on every reap ("Could not fully reap …" forever,
137+
re-logged on each exclusive-lock acquisition). Adding the owner-write bit
138+
clears ``FILE_ATTRIBUTE_READONLY`` and the retry succeeds (POSIX is
139+
unaffected — a read-only bit never blocks unlink there). Any other error is
140+
swallowed to preserve the best-effort reap this branch has always had; a
141+
still-stuck orphan is reported via the ``orphan.exists()`` check below.
142+
``path`` is the absolute path shutil reports (``_rmtree_safe_fd`` →
143+
``onexc`` → here), so ``chmod`` and the retry resolve correctly.
144+
"""
145+
exc = exc_info[1] if exc_info else None
146+
if isinstance(exc, PermissionError) and func in (os.unlink, os.rmdir):
147+
try:
148+
os.chmod(path, os.stat(path).st_mode | stat.S_IWUSR)
149+
except OSError:
150+
return
151+
try:
152+
func(path)
153+
except OSError:
154+
return
155+
156+
130157
def _reap_prepare_staging(openkb_dir: Path) -> None:
131158
"""Remove orphaned prepare-staging dirs left by interrupted prepares.
132159
@@ -152,9 +179,16 @@ def _reap_prepare_staging(openkb_dir: Path) -> None:
152179
log.warning("Skipping symlink in prepare staging (not followed): %s", orphan)
153180
continue
154181
if orphan.is_dir():
155-
shutil.rmtree(orphan, ignore_errors=True)
182+
shutil.rmtree(orphan, onerror=_reap_prepare_staging_onerror)
156183
else:
157-
orphan.unlink(missing_ok=True)
184+
# Best-effort: a loose file an AV/indexer holds open on Windows
185+
# raises PermissionError (missing_ok only swallows FileNotFoundError).
186+
# Swallow OSError so one unreachable orphan can't escape the reap,
187+
# run up through kb_lock, and stall every exclusive-lock command.
188+
try:
189+
orphan.unlink(missing_ok=True)
190+
except OSError as exc:
191+
log.debug("Could not unlink orphaned prepare staging %s: %s", orphan, exc)
158192
if orphan.exists():
159193
log.warning("Could not fully reap orphaned prepare staging: %s", orphan)
160194
else:

tests/test_add_prepare.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,54 @@ def test_commit_prepared_document_resolves_final_name_under_serial_owner(tmp_pat
115115
assert (kb_dir / "wiki" / "sources" / f"{renamed[0]['doc_name']}.md").exists()
116116

117117

118+
def test_retarget_preserves_lf_line_endings(tmp_path, monkeypatch):
119+
"""Renaming a prepared source on collision must keep it LF.
120+
121+
The convert phase writes via atomic_write_text (binary, LF preserved), but
122+
retarget used Path.write_text without newline=, which on Windows translates
123+
\\n to \\r\\n — so a collision-renamed source ends up CRLF while every other
124+
source stays LF (inconsistent KB, noisy git diffs, stray \\r in \\n-split
125+
parsers). POSIX write_text already leaves LF, so we force the Windows
126+
translation to prove the contract holds regardless of platform.
127+
"""
128+
from openkb.add_prepare import PreparedDocument
129+
from openkb.cli import _retarget_prepared_document_artifacts
130+
from openkb.converter import ConvertResult
131+
132+
staging = tmp_path / "staging"
133+
(staging / "raw").mkdir(parents=True)
134+
sources = staging / "wiki" / "sources"
135+
sources.mkdir(parents=True)
136+
137+
old_name = "orig"
138+
new_name = "orig-deadbeef"
139+
old_source = sources / f"{old_name}.md"
140+
old_source.write_text("# Title\n\nsources/images/orig/x.png\n", encoding="utf-8")
141+
old_raw = staging / "raw" / f"{old_name}.md"
142+
old_raw.write_text("raw\n", encoding="utf-8")
143+
144+
prepared = PreparedDocument(
145+
input_index=0,
146+
source_path=Path(f"{old_name}.md"),
147+
staging_dir=staging,
148+
result=ConvertResult(doc_name=old_name, raw_path=old_raw, source_path=old_source),
149+
)
150+
151+
real_write_text = Path.write_text
152+
153+
def windows_translate(self, data, *args, **kwargs):
154+
# Mimic Windows default text-mode write (newline=None): \n -> \r\n.
155+
return real_write_text(self, data.replace("\n", "\r\n"), *args, **kwargs)
156+
157+
monkeypatch.setattr(Path, "write_text", windows_translate)
158+
159+
_retarget_prepared_document_artifacts(prepared, new_name)
160+
161+
new_source = sources / f"{new_name}.md"
162+
assert new_source.exists()
163+
assert b"\r\n" not in new_source.read_bytes()
164+
165+
118166
def test_commit_prepared_document_requires_serial_owner_lock(tmp_path):
119167
import pytest
120168

tests/test_locks.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
from __future__ import annotations
44

55
import json
6+
import os
67
import stat
78
import threading
9+
from pathlib import Path
810

911
import pytest
1012

@@ -209,3 +211,78 @@ def test_reaper_does_not_follow_symlink_in_prepare_staging(tmp_path):
209211
pass
210212

211213
assert (target / "keep.txt").exists()
214+
215+
216+
def test_reaper_survives_denied_unlink_of_loose_file_in_prepare_staging(tmp_path, monkeypatch):
217+
"""A loose file (not a dir) in staging/prepare/ whose unlink is denied must
218+
not escape kb_lock and stall the whole KB.
219+
220+
On Windows an AV/indexer holding such a file makes os.unlink raise
221+
PermissionError; ``missing_ok=True`` only swallows FileNotFoundError, so the
222+
error currently propagates out of every exclusive-lock acquisition
223+
(add/remove/recompile/chat). The directory branch uses rmtree and is safe;
224+
this guards the asymmetric file branch.
225+
"""
226+
openkb_dir = tmp_path / ".openkb"
227+
prepare_root = openkb_dir / "staging" / "prepare"
228+
prepare_root.mkdir(parents=True)
229+
loose = prepare_root / "stray.dat"
230+
loose.write_text("x", encoding="utf-8")
231+
232+
real_unlink = Path.unlink
233+
234+
def deny_unlink(self, *args, **kwargs):
235+
if Path(self) == loose:
236+
raise PermissionError(13, "Access is denied", str(self))
237+
return real_unlink(self, *args, **kwargs)
238+
239+
monkeypatch.setattr(Path, "unlink", deny_unlink)
240+
241+
# Must not raise: the reap is best-effort and must not stall the lock holder.
242+
with kb_ingest_lock(openkb_dir):
243+
pass
244+
245+
246+
def test_reaper_self_heals_readonly_dir_under_prepare_staging(tmp_path, monkeypatch):
247+
"""A read-only file inside an orphaned prepare dir must be reaped, not left
248+
behind forever.
249+
250+
shutil.copy2 preserves a read-only source's attribute into staging; on
251+
Windows os.unlink denies a read-only file and rmtree(ignore_errors=True)
252+
leaves the tree behind, so the orphan resurfaces as "Could not fully reap"
253+
on every lock acquisition and never self-heals. POSIX deletes read-only
254+
files fine, so we simulate the Windows denial: deny once, then let the
255+
retry (after the handler clears the read-only bit) succeed.
256+
"""
257+
openkb_dir = tmp_path / ".openkb"
258+
prepare_root = openkb_dir / "staging" / "prepare"
259+
prepare_root.mkdir(parents=True)
260+
orphan = prepare_root / "000005-doc-11223344"
261+
orphan.mkdir()
262+
readonly = orphan / "readonly.md"
263+
readonly.write_text("locked", encoding="utf-8")
264+
265+
real_unlink = os.unlink
266+
attempts = {"n": 0}
267+
268+
def deny_once_then_succeed(*args, **kwargs):
269+
# shutil's POSIX fast path calls os.unlink(entry_name, dir_fd=topfd) — a
270+
# relative name — so identify the read-only file by basename. Deny the
271+
# first attempt (Windows Access-denied on a read-only file), then let the
272+
# handler's retry (after it clears the read-only bit) succeed.
273+
name = args[0] if args else kwargs.get("path")
274+
if Path(str(name)).name == readonly.name:
275+
attempts["n"] += 1
276+
if attempts["n"] == 1:
277+
raise PermissionError(13, "Access is denied", str(name))
278+
return real_unlink(*args, **kwargs)
279+
280+
# shutil.rmtree calls os.unlink internally; patching the shared os module
281+
# makes its first attempt on the read-only file raise (Windows behaviour).
282+
monkeypatch.setattr(os, "unlink", deny_once_then_succeed)
283+
284+
with kb_ingest_lock(openkb_dir):
285+
pass
286+
287+
assert not orphan.exists() # fully reaped, no residue to warn about forever
288+
assert attempts["n"] == 2 # denied once, recovered on the retry

0 commit comments

Comments
 (0)