Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this repository uses date-stamped
release notes until a stricter semver/tagging policy is formalized.

## [1.3.0-rc.4] - 2026-09-22

Windows-focused repair with automatic recovery. A suite-cache extraction folder
that the normal user cannot read or replace (for example one left by an
administrator-privileged run) no longer requires manual deletion: after full
archive/manifest/clip verification the client installs a verified copy at a
deterministic writable location beside the blocked folder, announces the
recovery in the preparation event log, and reuses it byte-for-byte on later
runs. The blocked folder is never deleted, taken over or trusted. If neither
location is writable the failure names the actual permission error and the
cache path. The macOS and Linux binaries are byte-identical to the accepted
rc.2 builds. Client 0.3.3; protocol 7.1, admission minimum, suite bytes,
frozen fingerprints and scientific settings unchanged.

## [1.3.0-rc.3] - 2026-09-22

Windows-only repair. The packaged GUI now names every pre-encode failure's
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ V7 artifact authorization uses `ARTIFACT_UPLOAD_SECRET` only on the server to si

## Version identities

- Published project release: `1.3.0-rc.3` / `2026-09-22` in `release.json`; client/0.3.2, protocol `7.1`.
- Published project release: `1.3.0-rc.4` / `2026-09-22` in `release.json`; client/0.3.3, protocol `7.1`.
- Historical project release: `1.2.0` / `client/0.2.0`, protocol `7.0` (historical timing).
- Candidate client implementation/minimum version: `client/0.3.0`.
- Candidate benchmark protocol version: `7.1`, timer boundary `ffmpeg-process-v1`.
Expand Down
5 changes: 4 additions & 1 deletion client/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@
print_info, print_success, print_warning, print_error, print_batch_summary,
)

CLIENT_VERSION = "client/0.3.2"
CLIENT_VERSION = "client/0.3.3"
# UI/package patches do not change the server's frozen protocol 7.1 contract.
PROTOCOL_MINIMUM_CLIENT_VERSION = "client/0.3.0"
PUBLICATION_CONSENT_VERSION = 1
Expand Down Expand Up @@ -290,6 +290,9 @@ def wrapped(*args, **kwargs):
def progress(stage, **details):
_emit_event(sink, "preparation_progress", scope="preparation", stage=stage, **details)
if sink is None:
if stage == "recovery" and details.get("message"):
print_info(f"Cache recovery: {details['message']}")
return
label = details.get("clipId") or os.path.basename(str(details.get("path") or ""))
done, total = details.get("completedBytes"), details.get("totalBytes")
amount = f" ({done}/{total} bytes)" if done is not None and total else ""
Expand Down
109 changes: 73 additions & 36 deletions client/suite.py
Original file line number Diff line number Diff line change
Expand Up @@ -969,9 +969,9 @@ def _copy_preparation_file(source, destination):
def _suite_pack_target_access_error(target_root: str) -> Optional[str]:
"""Explain why an existing extracted root can be neither reused nor replaced.

A cache created by a different account or an administrator-privileged run is
invisible to os.path.exists and undeletable by the normal user; returning a
cause here prevents a multi-gigabyte re-extraction that cannot land anyway.
A folder write-protected against the current account is invisible to
os.path.exists and undeletable or unreadable once found. The caller recovers
into a writable alternate location; this only names the observed cause.
"""
if not os.path.isdir(target_root):
return None
Expand All @@ -980,42 +980,62 @@ def _suite_pack_target_access_error(target_root: str) -> Optional[str]:
for _ in entries:
break
except PermissionError as exc:
return (
f"suite cache at {target_root} exists but is not accessible to the current user ({exc}); "
"it was created by a different account or an administrator-privileged run - delete that "
"cache folder, then start the run again"
)
return f"the existing cache folder {target_root} is not readable by the current user ({exc})"
except OSError as exc:
return (
f"suite cache at {target_root} cannot be inspected ({exc}); delete that cache folder, "
"then start the run again"
)
return f"the existing cache folder {target_root} cannot be inspected ({exc})"
try:
with open(os.path.join(target_root, "manifest.json"), "rb"):
return None
except FileNotFoundError:
return None
except OSError as exc:
return (
f"suite cache at {target_root} cannot be read ({exc}); it was likely created by a "
"different account or an administrator-privileged run - delete that cache folder, then "
"start the run again"
)
return f"the existing cache folder {target_root} cannot be read ({exc})"


def _suite_pack_recovered_root(pack_metadata: Mapping[str, Any], cache_root: Optional[str] = None) -> str:
"""Deterministic user-writable home for verified content when the primary
extraction location is blocked by ACLs or locks. It lives beside, never inside,
the blocked ".suite-pack" subtree, under the same suite cache root."""
suite_fingerprint = str(pack_metadata.get("suiteFingerprint") or "").strip()
if not suite_fingerprint:
raise RuntimeError("suite pack metadata is missing suiteFingerprint")
return os.path.join(cache_root or _suite_cache_root(), ".suite-pack-recovered", suite_fingerprint)


def _extract_suite_pack(pack_path: str, metadata: Mapping[str, Any], cache_root: Optional[str] = None) -> str:
target_root = _suite_pack_extract_root(metadata, cache_root)
canonical_root = os.path.join(target_root, "canonical")
recovered_root = _suite_pack_recovered_root(metadata, cache_root)
primary_error = _suite_pack_target_access_error(target_root)
if primary_error is None:
try:
_verify_extracted_suite_pack(target_root, metadata, verify_media=False)
return canonical_root
except Exception:
pass
else:
# A previous run may already have installed the verified recovered copy; reuse
# it byte-for-byte without re-extracting.
try:
_verify_extracted_suite_pack(recovered_root, metadata, verify_media=False)
except Exception:
pass
else:
preparation_progress(
"recovery", path=recovered_root,
message=f"reusing the verified recovered suite cache copy at {recovered_root}",
)
return os.path.join(recovered_root, "canonical")
staging_parent = os.path.dirname(recovered_root) if primary_error else os.path.dirname(target_root)
try:
_verify_extracted_suite_pack(target_root, metadata, verify_media=False)
return canonical_root
except Exception:
access_error = _suite_pack_target_access_error(target_root)
if access_error:
raise RuntimeError(access_error) from None
parent_dir = os.path.dirname(target_root)
os.makedirs(parent_dir, exist_ok=True)
staging_root = tempfile.mkdtemp(prefix="suite-pack-", dir=parent_dir)
os.makedirs(staging_parent, exist_ok=True)
except OSError as exc:
raise RuntimeError(
f"the configured suite cache under {cache_root or _suite_cache_root()} is not writable by "
f"the current user ({exc}); point ENCODINGDB_SUITE_CACHE_DIR at a writable folder and "
"start the run again"
) from exc
staging_root = tempfile.mkdtemp(prefix="suite-pack-", dir=staging_parent)
try:
with tarfile.open(pack_path, "r:gz") as archive:
for member in archive:
Expand All @@ -1028,21 +1048,38 @@ def _extract_suite_pack(pack_path: str, metadata: Mapping[str, Any], cache_root:
with archive.extractfile(member) as source, open(destination, "xb") as target:
_copy_preparation_stream(source, target, path=member.name, total=member.size)
_verify_extracted_suite_pack(staging_root, metadata)
if primary_error is None:
try:
if os.path.isdir(target_root):
shutil.rmtree(target_root)
os.replace(staging_root, target_root)
return canonical_root
except OSError as swap_exc:
primary_error = (
f"the existing cache folder {target_root} could not be replaced ({swap_exc}); "
"it is held open or write-protected for this account"
)
try:
if os.path.isdir(target_root):
shutil.rmtree(target_root)
os.replace(staging_root, target_root)
except OSError as swap_exc:
os.makedirs(os.path.dirname(recovered_root), exist_ok=True)
if os.path.isdir(recovered_root):
shutil.rmtree(recovered_root)
os.replace(staging_root, recovered_root)
except OSError as recovered_exc:
raise RuntimeError(
f"verified suite content could not be installed into {target_root} because the "
f"existing cache folder could not be replaced ({swap_exc}); close any program "
"holding that folder (for example an Explorer window), delete it if asked, and "
"start the run again"
) from swap_exc
"verified suite content could not be installed into the primary cache "
f"(after re-extraction: {primary_error}) and could not be written to the recovered "
f"cache location {recovered_root} ({recovered_exc}) either; configure a writable "
"ENCODINGDB_SUITE_CACHE_DIR and start the run again"
) from recovered_exc
preparation_progress(
"recovery", path=recovered_root,
message=f"the primary suite cache could not be used ({primary_error}); installed a fully "
f"verified copy at {recovered_root} and will reuse it on future runs",
)
return os.path.join(recovered_root, "canonical")
except BaseException:
shutil.rmtree(staging_root, ignore_errors=True)
raise
return os.path.join(target_root, "canonical")


def _load_requests():
Expand Down
2 changes: 1 addition & 1 deletion client/tests/test_encoding_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def setUp(self) -> None:
def test_corrected_metrics_use_distinguishable_client_version(self) -> None:
from client import main as client_main

self.assertEqual(client_main.CLIENT_VERSION, "client/0.3.2")
self.assertEqual(client_main.CLIENT_VERSION, "client/0.3.3")
self.assertEqual(client_main.PROTOCOL_MINIMUM_CLIENT_VERSION, "client/0.3.0")

def test_vmaf_passes_distorted_input_before_reference(self) -> None:
Expand Down
2 changes: 1 addition & 1 deletion client/tests/test_release_preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def test_release_json_declares_coherent_frozen_release(self) -> None:
payload = json.loads((release_manifest_lib.ROOT_DIR / "release.json").read_text(encoding="utf-8"))

self.assertEqual(payload["suiteVersion"], "encodingdb-test-suite-v1")
self.assertEqual(payload["projectVersion"], "1.3.0-rc.3")
self.assertEqual(payload["projectVersion"], "1.3.0-rc.4")
self.assertEqual(payload["releaseDate"], "2026-09-22")
for tree in ("client", "server"):
root = release_manifest_lib.ROOT_DIR / tree / "resources/test_suite_v1"
Expand Down
74 changes: 59 additions & 15 deletions client/tests/test_suite_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,42 +292,86 @@ def _fixture_pack(self, directory: str):
suite.build_suite_pack_archive(str(root), str(archive))
return archive, metadata

def test_unreachable_stale_cache_reports_ownership_without_reextracting(self):
# A cache subtree created by an administrator-privileged run is invisible to
# os.path.exists and undeletable by the normal user; the client must name that
# cause instead of re-extracting gigabytes that can never be installed.
def test_unreachable_stale_cache_recovers_into_writable_alternate(self):
# A primary extraction protected against this account must not stop the run:
# verified bytes install into the writable recovered location, the blocked
# folder stays byte-identical, and the next start reuses the recovered copy.
import os
real_scandir = os.scandir
with tempfile.TemporaryDirectory() as directory:
archive, metadata = self._fixture_pack(directory)
target = Path(suite._suite_pack_extract_root(metadata, directory))
recovered = Path(suite._suite_pack_recovered_root(metadata, directory))
target.mkdir(parents=True)
(target / "manifest.json").write_text("stale bytes")
with mock.patch.object(suite.os, "scandir", side_effect=PermissionError(13, "Access is denied")), \
mock.patch.object(tarfile, "open", side_effect=AssertionError("must not re-extract an unreachable cache")):
with self.assertRaisesRegex(RuntimeError, "administrator-privileged") as raised:
suite._extract_suite_pack(str(archive), metadata, directory)
self.assertIn(str(target), str(raised.exception))
def deny_primary(path, *args, **kwargs):
if Path(path) == target:
raise PermissionError(13, "Access is denied")
return real_scandir(path, *args, **kwargs)
events = []
with mock.patch.object(suite.os, "scandir", side_effect=deny_primary), \
mock.patch.object(suite, "verify_suite_clip", return_value=suite.ClipVerificationResult(True, "fixture media verification", {})), \
mock.patch.object(suite, "preparation_progress", side_effect=lambda stage, **d: events.append((stage, d))):
canonical = Path(suite._extract_suite_pack(str(archive), metadata, directory))
self.assertEqual(canonical, recovered / "canonical")
self.assertEqual((canonical.parent / "manifest.json").read_text(),
(Path(directory) / "source" / "manifest.json").read_text())
with mock.patch.object(tarfile, "open", side_effect=AssertionError("recovered copy must be reused without re-extracting")):
again = Path(suite._extract_suite_pack(str(archive), metadata, directory))
self.assertEqual(again, canonical)
self.assertEqual((target / "manifest.json").read_text(), "stale bytes")
self.assertTrue(any(stage == "recovery" and "recovered" in str(details.get("message")) for stage, details in events))
self.assertEqual(list(recovered.parent.glob("suite-pack-*")), [])

def test_locked_target_swap_reports_actionable_error_and_cleans_staging(self):
def test_locked_target_swap_recovers_into_writable_alternate(self):
import shutil
real_rmtree = shutil.rmtree
with tempfile.TemporaryDirectory() as directory:
archive, metadata = self._fixture_pack(directory)
target = Path(suite._suite_pack_extract_root(metadata, directory))
recovered = Path(suite._suite_pack_recovered_root(metadata, directory))
with mock.patch.object(suite, "verify_suite_clip", return_value=suite.ClipVerificationResult(True, "fixture media verification", {})):
canonical = Path(suite._extract_suite_pack(str(archive), metadata, directory))
(canonical.parent / "manifest.json").write_text("corrupt") # force fast-path miss
def deny_target(path, *args, **kwargs):
if Path(path) == target:
raise PermissionError(13, "Access is denied")
return real_rmtree(path, *args, **kwargs)

with mock.patch.object(suite.shutil, "rmtree", side_effect=deny_target):
with self.assertRaisesRegex(RuntimeError, "could not be replaced") as raised:
suite._extract_suite_pack(str(archive), metadata, directory)
self.assertIn("Explorer", str(raised.exception))
recovered_canonical = Path(suite._extract_suite_pack(str(archive), metadata, directory))
self.assertEqual(recovered_canonical, recovered / "canonical")
self.assertEqual((recovered_canonical.parent / "manifest.json").read_text(),
(Path(directory) / "source" / "manifest.json").read_text())
self.assertEqual(list(target.parent.glob("suite-pack-*")), [])
self.assertTrue((target / "manifest.json").exists())
self.assertEqual(list(recovered.parent.glob("suite-pack-*")), [])
self.assertEqual((target / "manifest.json").read_text(), "corrupt")

def test_unwritable_cache_reports_actual_permission_cause(self):
# No primary, no recovered location: fail clearly naming the cache folder and
# the permission error instead of claiming a different extraction would work.
import os
real_scandir = os.scandir
real_makedirs = os.makedirs
with tempfile.TemporaryDirectory() as directory:
archive, metadata = self._fixture_pack(directory)
target = Path(suite._suite_pack_extract_root(metadata, directory))
recovered_parent = Path(suite._suite_pack_recovered_root(metadata, directory)).parent
target.mkdir(parents=True)
(target / "manifest.json").write_text("stale bytes")
def deny_primary(path, *args, **kwargs):
if Path(path) == target:
raise PermissionError(13, "Access is denied")
return real_scandir(path, *args, **kwargs)
def deny_recovered(path, *args, **kwargs):
if Path(path) == recovered_parent:
raise PermissionError(13, "Access is denied")
return real_makedirs(path, *args, **kwargs)
with mock.patch.object(suite.os, "scandir", side_effect=deny_primary), \
mock.patch.object(suite.os, "makedirs", side_effect=deny_recovered):
with self.assertRaisesRegex(RuntimeError, "not writable") as raised:
suite._extract_suite_pack(str(archive), metadata, directory)
self.assertIn(directory, str(raised.exception))
self.assertIn("Access is denied", str(raised.exception))

def test_cached_pack_checks_all_bytes_without_reprobing_and_repairs_corruption(self):
with small_media_fixture() as (root, manifest):
Expand Down
4 changes: 4 additions & 0 deletions client/tests/test_windows_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ def test_initialized_controls_and_keyboard_handlers_use_real_running_guards(self
app._handle_event({"type": "preparation_progress", "stage": "probe", "path": "test.mkv"})
self.assertEqual(app.stage_var.get(), "Preparing: probe")
self.assertIn("Stop is available", app.summary_var.get())
app._handle_event({"type": "preparation_progress", "stage": "recovery",
"path": "recovered", "message": "installed a fully verified copy"})
self.assertIn("Suite cache recovery active", app.summary_var.get())
self.assertNotEqual(app.stage_var.get(), "Preparing: probe")
self.assertIn("Alt+B", app.start_btn.options["text"])
self.assertNotIn("<Alt-r>", bindings)
self.assertIn("Alt+S", app.stop_btn.options["text"])
Expand Down
Loading
Loading