fix(sessions): retire-capture integrity - backups skip capture dirs whole; short reads fail the capture

- hermes backup excluded *.db-wal by suffix but not the retired-wal capture dirs, so it would ship the capture's main-image copy while dropping the captured -wal that is the artifact's point; exclude <name>.retired-wal-* dirs whole (they must move as manifest+image+wal unit).
- _copy_range treated a short read as success, yielding a truncated copy with a valid manifest while the unlinked inode still dies at exit; raise RetiredGenerationCaptureError and clean the .part file.
- _quarantine_reason docstring no longer claims close() checks replaced before generation loss (close evaluates loss first and skips quarantine when lost).
This commit is contained in:
kshitijk4poor
2026-09-11 10:38:20 +05:30
committed by kshitij
parent 5cb9f5c347
commit 64fe13a647
3 changed files with 37 additions and 14 deletions
+24 -11
View File
@@ -250,20 +250,33 @@ def _own_descriptor_for_identity(identity) -> "Optional[int]":
def _copy_range(read: Callable[[int, int], Optional[bytes]], dest: Path, *, size: int) -> Dict[str, Any]:
"""Stream ``size`` bytes via ``read(offset, length)`` into ``dest`` (temp file, fsync, rename)."""
"""Stream ``size`` bytes via ``read(offset, length)`` into ``dest`` (temp file, fsync, rename).
A short read raises ``RetiredGenerationCaptureError``: a truncated copy with a valid-looking
manifest would let the caller settle the handle while the unlinked inode still dies at exit."""
digest = hashlib.sha256()
part = dest.with_name(dest.name + ".part")
offset = 0
with open(part, "wb") as out:
while offset < size:
chunk = read(offset, min(_CAPTURE_CHUNK_BYTES, size - offset))
if not chunk:
break
out.write(chunk)
digest.update(chunk)
offset += len(chunk)
out.flush()
os.fsync(out.fileno())
try:
with open(part, "wb") as out:
while offset < size:
chunk = read(offset, min(_CAPTURE_CHUNK_BYTES, size - offset))
if not chunk:
break
out.write(chunk)
digest.update(chunk)
offset += len(chunk)
out.flush()
os.fsync(out.fileno())
except Exception:
part.unlink(missing_ok=True)
raise
if offset != size:
part.unlink(missing_ok=True)
raise RetiredGenerationCaptureError(
f"short read copying {dest.name}: {offset} of {size} bytes — the retired generation "
"is NOT fully captured; the handle stays open for a retry"
)
os.replace(part, dest)
return {"file": dest.name, "bytes": offset, "sha256": digest.hexdigest()}