From a0e50622c1bbd1c3e85605e16caad4649f5c769a Mon Sep 17 00:00:00 2001 From: ofhd Date: Tue, 22 Sep 2026 00:18:16 -0700 Subject: [PATCH 1/4] Make every pre-encode failure name its cause in the GUI event log The packaged windowed client has no visible stderr: the suite-preparation, preflight, and batch-gate returns printed only there, so a first-run preparation failure surfaced as nothing but "Run failed (exit code 3)", and the worker's done handler overwrote even a reported cause with that bare exit code. Emit run_error events with the full causal message at every gate the GUI can reach (sweep planner and suite preparation, compatibility/runtime-integrity preflight, v7 clip preparation and argument gates, batch identity/budget checks), retain the last cause in the app, and compose "Run failed (exit code N): " into both the status line and the event log when a run ends failed. Adds causal-event regressions for the three exit-3 sites and cause retention through the GUI done handler. --- client/main.py | 74 ++++++++++++++++++++++---------- client/tests/test_preparation.py | 40 +++++++++++++++++ client/tests/test_windows_gui.py | 30 +++++++++++++ client/windows_gui.py | 14 +++++- 4 files changed, 135 insertions(+), 23 deletions(-) diff --git a/client/main.py b/client/main.py index ae1ba571..dcc3e9be 100644 --- a/client/main.py +++ b/client/main.py @@ -305,19 +305,21 @@ def progress(stage, **details): return wrapped -def _preparation_preflight(args, *, base_url=None): +def _preparation_preflight(args, *, base_url=None, event_sink=None): check_preparation_cancelled() if not getattr(args, "no_submit", False): preparation_progress("compatibility") try: check_compatibility(base_url or args.base_url, CLIENT_VERSION) except Exception as exc: - print(f"Compatibility check failed before preparation: {exc}. Use --no-submit for local collection.", file=sys.stderr) + message = f"Compatibility check failed before preparation: {exc}. Use --no-submit for local collection." + print(message, file=sys.stderr) + _emit_event(event_sink, "run_error", scope="preparation", code=5, message=message) return 5 - return _preparation_runtime_integrity() + return _preparation_runtime_integrity(event_sink=event_sink) -def _preparation_runtime_integrity(): +def _preparation_runtime_integrity(event_sink=None): check_preparation_cancelled() if bool(getattr(sys, "frozen", False)) or os.environ.get("ENCODINGDB_RUNTIME_LOCK_PATH"): from .runtime_lock import verify_runtime_lock @@ -325,7 +327,9 @@ def _preparation_runtime_integrity(): try: verify_runtime_lock(ffmpeg_path=config.ffmpeg_exe(), ffprobe_path=config.ffprobe_exe()) except Exception as exc: - print(f"Runtime integrity check failed before preparation: {exc}", file=sys.stderr) + message = f"Runtime integrity check failed before preparation: {exc}" + print(message, file=sys.stderr) + _emit_event(event_sink, "run_error", scope="preparation", code=2, message=message) return 2 return 0 @@ -1260,13 +1264,15 @@ def run_sweep_mode( print(f"Unsupported sweep mode: {mode}", file=sys.stderr) return 4 base_args = _apply_submission_policy(base_args, interactive=interactive) - preflight_rc = _preparation_preflight(base_args) + preflight_rc = _preparation_preflight(base_args, event_sink=event_sink) if preflight_rc: return preflight_rc presets_cfg = presets_cfg if presets_cfg is not None else load_presets_config(PRESETS_CONFIG_PATH) candidates = list_all_available_encoders() if not candidates: - print("No available encoders found in this ffmpeg build.", file=sys.stderr) + message = "No available encoders found in this ffmpeg build." + print(message, file=sys.stderr) + _emit_event(event_sink, "run_error", scope="preparation", code=4, message=message) return 4 plan = sweep_plan.plan_sweep( mode, @@ -1275,14 +1281,18 @@ def run_sweep_mode( is_usable=_probe_encoder_usable_with_cancel, ) if plan.is_empty(): - print("No usable encoder on this machine supports a sweep.", file=sys.stderr) + message = "No usable encoder on this machine supports a sweep." + print(message, file=sys.stderr) + _emit_event(event_sink, "run_error", scope="preparation", code=4, message=message) return 4 for name, reason in plan.skipped: print_info(f"Skipped {sweep_plan_label(name)}: {reason}") try: suite_clips = _prepare_sweep_clips(plan.clip_policy) except Exception as exc: - print(f"EncodingDB Test Suite v1 is unavailable: {exc}", file=sys.stderr) + message = f"EncodingDB Test Suite v1 is unavailable: {exc}" + print(message, file=sys.stderr) + _emit_event(event_sink, "run_error", scope="preparation", code=3, message=message) return 3 tasks: List[Dict[str, Any]] = [] for step in plan.steps: @@ -1414,14 +1424,18 @@ def run_benchmark_batch( ) -> int: duration_minutes = float(getattr(args, "max_duration_minutes", 60)) if not math.isfinite(duration_minutes) or not math.isfinite(duration_minutes * 60) or duration_minutes <= 0: - print("--max-duration-minutes must be positive and finite", file=sys.stderr) + message = "--max-duration-minutes must be positive and finite" + print(message, file=sys.stderr) + _emit_event(event_sink, "run_error", scope="batch", code=4, message=message) return 4 - preflight_rc = _preparation_preflight(args, base_url=base_url) + preflight_rc = _preparation_preflight(args, base_url=base_url, event_sink=event_sink) if preflight_rc: return preflight_rc ok, ffmpeg_version = ensure_ffmpeg_and_ffprobe() if not ok: - print("ffmpeg/ffprobe not found in PATH. Please install ffmpeg.", file=sys.stderr) + message = "ffmpeg/ffprobe not found in PATH. Please install ffmpeg." + print(message, file=sys.stderr) + _emit_event(event_sink, "run_error", scope="batch", code=2, message=message) return 2 if getattr(args, "local_metrics", False): quality_ok, quality_rc = _ensure_local_quality_stack(event_sink=event_sink, scope="batch") @@ -1429,14 +1443,18 @@ def run_benchmark_batch( return quality_rc suite_clip = tasks[0].get("suiteClip") if tasks else None if not isinstance(suite_clip, PreparedSuiteClip): - print("Batch benchmark requires EncodingDB Test Suite v1 clip identities.", file=sys.stderr) + message = "Batch benchmark requires EncodingDB Test Suite v1 clip identities." + print(message, file=sys.stderr) + _emit_event(event_sink, "run_error", scope="batch", code=3, message=message) return 3 input_path = suite_clip.path default_input_hash = suite_clip.input_hash protocol_config = _build_protocol_config() planned_attempts = len(tasks) * (protocol_config.warmup_runs + protocol_config.minimum_measured_runs + protocol_config.max_adaptive_repeats) if planned_attempts > int(getattr(args, "max_attempts", 100)): - print(f"Campaign can require {planned_attempts} encodes, exceeding --max-attempts. Select fewer recipes or set an explicit budget.", file=sys.stderr) + message = f"Campaign can require {planned_attempts} encodes, exceeding --max-attempts. Select fewer recipes or set an explicit budget." + print(message, file=sys.stderr) + _emit_event(event_sink, "run_error", scope="batch", code=4, message=message) return 4 campaign_seed = getattr(args, "campaign_seed", None) if campaign_seed is None: @@ -2261,20 +2279,26 @@ def run_v7_suite_clip_mode( interactive: bool = False, ) -> int: base_args = _apply_submission_policy(base_args, interactive=interactive) - preflight_rc = _preparation_preflight(base_args) + preflight_rc = _preparation_preflight(base_args, event_sink=event_sink) if preflight_rc: return preflight_rc clip_id = str(getattr(base_args, "v7_suite_clip", "") or "").strip() + campaign_scope = str(getattr(base_args, "campaign", "quick") or "quick") try: - suite_clips = (_prepare_full_suite() if getattr(base_args, "campaign", "quick") == "full" + suite_clips = (_prepare_full_suite() if campaign_scope == "full" else [_prepare_named_suite_clip(clip_id) if clip_id else _prepare_quick_suite_clip()]) except Exception as exc: - print(f"Unable to prepare suite clip {clip_id}: {exc}", file=sys.stderr) + label = clip_id or ("all seven frozen clips" if campaign_scope == "full" else "the default quick clip") + message = f"Unable to prepare suite clip {label}: {exc}" + print(message, file=sys.stderr) + _emit_event(event_sink, "run_error", scope="preparation", code=3, message=message) return 3 requested_codec = str(getattr(base_args, "codec", "") or "").strip() if not requested_codec: - print("--codec is required for noninteractive v7 suite clip mode.", file=sys.stderr) + message = "--codec is required for noninteractive v7 suite clip mode." + print(message, file=sys.stderr) + _emit_event(event_sink, "run_error", scope="preparation", code=4, message=message) return 4 if has_encoder(requested_codec): resolved_encoder = requested_codec @@ -2284,10 +2308,14 @@ def run_v7_suite_clip_mode( else: resolved_encoder = None if not resolved_encoder or not has_encoder(resolved_encoder): - print(f"Requested encoder '{requested_codec}' is not available.", file=sys.stderr) + message = f"Requested encoder '{requested_codec}' is not available." + print(message, file=sys.stderr) + _emit_event(event_sink, "run_error", scope="preparation", code=4, message=message) return 4 if is_hardware_encoder_name(resolved_encoder) and not is_hardware_encoder_usable(resolved_encoder): - print(f"Selected hardware encoder '{resolved_encoder}' is not usable on this machine.", file=sys.stderr) + message = f"Selected hardware encoder '{resolved_encoder}' is not usable on this machine." + print(message, file=sys.stderr) + _emit_event(event_sink, "run_error", scope="preparation", code=4, message=message) return 4 preset_list = [value.strip() for value in str(getattr(base_args, "presets", "") or "").split(",") if value.strip()] @@ -2297,7 +2325,9 @@ def run_v7_suite_clip_mode( target_bitrate_kbps = getattr(base_args, "target_bitrate_kbps", None) if target_bitrate_kbps is not None or resolved_encoder.lower().endswith("_videotoolbox"): if target_bitrate_kbps is None or target_bitrate_kbps <= 0: - print("VideoToolbox v7 runs require --target-bitrate-kbps.", file=sys.stderr) + message = "VideoToolbox v7 runs require --target-bitrate-kbps." + print(message, file=sys.stderr) + _emit_event(event_sink, "run_error", scope="preparation", code=4, message=message) return 4 task_rate_control = {"mode": "vbr", "targetBitrateKbps": int(target_bitrate_kbps)} else: @@ -2340,7 +2370,7 @@ def run_v7_suite_clip_mode( @_preparation_operation def _resume_campaign(args, *, event_sink=None, cancel_event=None, interactive=False): args = _apply_submission_policy(args, interactive=interactive) - preflight_rc = _preparation_preflight(args) + preflight_rc = _preparation_preflight(args, event_sink=event_sink) if preflight_rc: return preflight_rc try: diff --git a/client/tests/test_preparation.py b/client/tests/test_preparation.py index 597f220e..822fc084 100644 --- a/client/tests/test_preparation.py +++ b/client/tests/test_preparation.py @@ -204,6 +204,46 @@ def test_progress_is_throttled_and_does_not_start_budget(self): self.assertIsNone(campaign._MEASUREMENT_BUDGET.get()) progress.assert_called_once() + def test_sweep_suite_unavailable_surfaces_causal_run_error(self): + events = [] + presets = main.load_presets_config(main.PRESETS_CONFIG_PATH) + with mock.patch.object(main, 'list_all_available_encoders', return_value=['libx264']), \ + mock.patch.object(main, '_probe_encoder_usable_with_cancel', return_value=True), \ + mock.patch.object(main, '_prepare_sweep_clips', + side_effect=RuntimeError('suite pack could not be acquired: offline')): + rc = main.run_sweep_mode(mode='small', base_args=self.args(no_submit=True), + event_sink=events.append, presets_cfg=presets) + self.assertEqual(rc, 3) + errors = [event for event in events if event.get('type') == 'run_error'] + self.assertEqual([event['code'] for event in errors], [3]) + self.assertEqual(errors[0]['scope'], 'preparation') + self.assertIn('offline', errors[0]['message']) + + def test_v7_quick_prep_failure_surfaces_causal_run_error(self): + events = [] + with mock.patch.object(main, '_prepare_quick_suite_clip', + side_effect=RuntimeError('pack download failed: HTTP 403')): + rc = main.run_v7_suite_clip_mode(base_args=self.args(no_submit=True), event_sink=events.append) + self.assertEqual(rc, 3) + errors = [event for event in events if event.get('type') == 'run_error'] + self.assertEqual([event['code'] for event in errors], [3]) + self.assertIn('the default quick clip', errors[0]['message']) + self.assertIn('HTTP 403', errors[0]['message']) + + def test_batch_missing_clip_identity_surfaces_causal_run_error(self): + events = [] + hardware = main.HardwareInfo('Test CPU', 'none', 16, 'TestOS') + with mock.patch.object(main, 'ensure_ffmpeg_and_ffprobe', return_value=(True, 'ffmpeg test')): + rc = main.run_benchmark_batch( + hardware=hardware, base_url='https://invalid.example', + args=self.args(no_submit=True, max_attempts=100, max_duration_minutes=60), + tasks=[{'encoder': 'libx264', 'preset': 'fast', 'crf': 24, 'rateControl': None}], + event_sink=events.append) + self.assertEqual(rc, 3) + errors = [event for event in events if event.get('type') == 'run_error'] + self.assertEqual([event['code'] for event in errors], [3]) + self.assertIn('clip identities', errors[0]['message']) + if __name__ == '__main__': unittest.main() diff --git a/client/tests/test_windows_gui.py b/client/tests/test_windows_gui.py index 40174cf5..e6b5e9d8 100644 --- a/client/tests/test_windows_gui.py +++ b/client/tests/test_windows_gui.py @@ -399,6 +399,36 @@ def test_explicit_allowance_reported_when_set(self): bindings[""](None) self.assertIn("Explicit measurement allowance: 15 minutes", " ".join(log_lines)) + def test_run_error_cause_survives_done_and_persists_in_log(self): + app, _root, _bindings, _tk = self.build() + lines = [] + with mock.patch.object(app, "_append_log", lines.append): + app._handle_event({"type": "run_error", "scope": "preparation", "code": 3, + "message": "EncodingDB Test Suite v1 is unavailable: download blocked"}) + app.event_queue.put(("done", 3)) + app._poll_events() + self.assertIn("Run failed (exit code 3)", app.summary_var.get()) + self.assertIn("download blocked", app.summary_var.get()) + self.assertIn("Run failed (exit code 3): EncodingDB Test Suite v1 is unavailable: download blocked", + " ".join(lines)) + + def test_generic_failure_done_appends_actionable_log_line(self): + app, _root, _bindings, _tk = self.build() + lines = [] + with mock.patch.object(app, "_append_log", lines.append): + app.event_queue.put(("done", 6)) + app._poll_events() + self.assertIn("exit code 6", app.summary_var.get()) + self.assertIn("event log", app.summary_var.get()) + self.assertTrue(any("exit code 6" in line for line in lines)) + + def test_start_clears_previous_failure_cause(self): + app, _root, bindings, _tk = self.build() + app.last_failure = "stale cause from a previous run" + with mock.patch.object(gui.threading, "Thread", FakeThread): + bindings[""](None) + self.assertIsNone(app.last_failure) + if __name__ == "__main__": unittest.main() diff --git a/client/windows_gui.py b/client/windows_gui.py index 6ca837c3..d5a37085 100644 --- a/client/windows_gui.py +++ b/client/windows_gui.py @@ -140,6 +140,9 @@ def __init__(self) -> None: self.running = False self._browse_shown = False self._close_deadline = 0.0 + # Causal message from the most recent run_error/unhandled failure in this + # run; the done handler must not replace it with a bare exit code. + self.last_failure: Optional[str] = None self.mode_var = tk.StringVar(value="Small") self.no_submit_var = tk.BooleanVar(value=bool(getattr(base_args, "no_submit", False))) @@ -374,6 +377,7 @@ def _start_run(self) -> None: messagebox.showerror("Unsupported configuration", "Select an available encoder and supported preset before starting.") return self.cancel_event.clear() + self.last_failure = None self._set_running(True) self.summary_var.set("Run started...") self.stage_var.set("Starting") @@ -627,6 +631,7 @@ def _handle_event(self, event: Dict[str, Any]) -> None: if event_type == "run_error": self.stage_var.set("Error") + self.last_failure = str(event.get("message") or "").strip() or None self.summary_var.set(str(event.get("message") or "Run failed")) self._append_log(self.summary_var.get()) @@ -638,6 +643,8 @@ def _poll_events(self) -> None: self._handle_event(payload) elif kind == "error": self.stage_var.set("Error") + first_line = str(payload).strip().splitlines()[0] if str(payload).strip() else "" + self.last_failure = first_line or None self.summary_var.set("Run failed. See event log.") self._append_log(payload) elif kind == "done": @@ -660,7 +667,12 @@ def _poll_events(self) -> None: elif rc == 130: self.summary_var.set("Run cancelled") else: - self.summary_var.set(f"Run failed (exit code {rc})") + if self.last_failure: + failure = f"Run failed (exit code {rc}): {self.last_failure}" + else: + failure = f"Run failed (exit code {rc}); see event log for details" + self.summary_var.set(failure) + self._append_log(failure) self._update_single_fields_state(preview=False) elif kind == "upload_status": self._append_log(payload) From d04bddc2a5fefb01f8b0206afafbf481cba4e063 Mon Sep 17 00:00:00 2001 From: ofhd Date: Tue, 22 Sep 2026 03:30:02 -0700 Subject: [PATCH 2/4] Name the owning account when the suite cache blocks its own replacement An extraction root created by an administrator-privileged or foreign-account run is invisible to os.path.exists and undeletable by the normal user, so every normal run re-downloaded, re-verified, and then lost the swap with WinError 5 behind a generic 'suite unavailable' and exit 3. Classify the unreadable/foreign-ACL cache before burning a re-extraction, report the target path plus the actionable delete instruction when the swap itself is refused, and stop swallowing the swap failure behind ignore_errors. --- client/suite.py | 54 +++++++++++++++++++++++++++++++-- client/tests/test_suite_v1.py | 57 +++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/client/suite.py b/client/suite.py index eb14422a..db16f590 100644 --- a/client/suite.py +++ b/client/suite.py @@ -966,6 +966,43 @@ def _copy_preparation_file(source, destination): _copy_preparation_stream(reader, writer, path=source, total=os.path.getsize(source)) +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. + """ + if not os.path.isdir(target_root): + return None + try: + with os.scandir(target_root) as entries: + 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" + ) + except OSError as exc: + return ( + f"suite cache at {target_root} cannot be inspected ({exc}); delete that cache folder, " + "then start the run again" + ) + 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" + ) + + 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") @@ -973,7 +1010,9 @@ def _extract_suite_pack(pack_path: str, metadata: Mapping[str, Any], cache_root: _verify_extracted_suite_pack(target_root, metadata, verify_media=False) return canonical_root except Exception: - pass + 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) @@ -989,8 +1028,17 @@ 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) - shutil.rmtree(target_root, ignore_errors=True) - os.replace(staging_root, target_root) + try: + if os.path.isdir(target_root): + shutil.rmtree(target_root) + os.replace(staging_root, target_root) + except OSError as swap_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 except BaseException: shutil.rmtree(staging_root, ignore_errors=True) raise diff --git a/client/tests/test_suite_v1.py b/client/tests/test_suite_v1.py index 205e5cc4..9d899e22 100644 --- a/client/tests/test_suite_v1.py +++ b/client/tests/test_suite_v1.py @@ -272,6 +272,63 @@ def test_notice_tampering_rejected_and_valid_extraction_reused(self): suite._extract_suite_pack(str(archive), metadata, directory) self.assertEqual(notice.read_text(), "Fixture license notice") + def _fixture_pack(self, directory: str): + import hashlib + root = Path(directory) / "source" + root.mkdir() + payload = json.loads(Path(suite.get_manifest_path()).read_text()) + for clip in payload["clips"]: + clip["sha256"] = hashlib.sha256(b"fixture").hexdigest() + clip["byteSize"] = len(b"fixture") + (root / "manifest.json").write_text(json.dumps(payload)) + (root / "finalization-status.json").write_text(json.dumps({"isFrozen": True})) + (root / "notices").mkdir() + (root / "canonical").mkdir() + for clip in payload["clips"]: + (root / "notices" / f"{clip['id']}.txt").write_text("Fixture license notice") + (root / "canonical" / clip["fileName"]).write_bytes(b"fixture") + metadata = suite.build_suite_pack_metadata(str(root)) + archive = Path(directory) / "suite.tar.gz" + 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. + with tempfile.TemporaryDirectory() as directory: + archive, metadata = self._fixture_pack(directory) + target = Path(suite._suite_pack_extract_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)) + self.assertEqual((target / "manifest.json").read_text(), "stale bytes") + + def test_locked_target_swap_reports_actionable_error_and_cleans_staging(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)) + 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)) + self.assertEqual(list(target.parent.glob("suite-pack-*")), []) + self.assertTrue((target / "manifest.json").exists()) + def test_cached_pack_checks_all_bytes_without_reprobing_and_repairs_corruption(self): with small_media_fixture() as (root, manifest): (root / "suite-lock.json").write_text('{}') From d1b4584f68b7f7a670f409217b3e34371707cf7f Mon Sep 17 00:00:00 2001 From: ofhd Date: Tue, 22 Sep 2026 03:30:34 -0700 Subject: [PATCH 3/4] Freeze the repaired client at 1.3.0-rc.3 and client/0.3.2 Protocol 7.1 and the minimum-client gate are unchanged; the bump only makes the cache-cause fix distinguishable in submission metadata and download provenance. --- client/main.py | 2 +- client/tests/test_encoding_regressions.py | 2 +- client/tests/test_release_preflight.py | 4 ++-- release.json | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/client/main.py b/client/main.py index dcc3e9be..d0df2b36 100644 --- a/client/main.py +++ b/client/main.py @@ -107,7 +107,7 @@ print_info, print_success, print_warning, print_error, print_batch_summary, ) -CLIENT_VERSION = "client/0.3.1" +CLIENT_VERSION = "client/0.3.2" # 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 diff --git a/client/tests/test_encoding_regressions.py b/client/tests/test_encoding_regressions.py index 3ba00225..9b6e32d6 100644 --- a/client/tests/test_encoding_regressions.py +++ b/client/tests/test_encoding_regressions.py @@ -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.1") + self.assertEqual(client_main.CLIENT_VERSION, "client/0.3.2") self.assertEqual(client_main.PROTOCOL_MINIMUM_CLIENT_VERSION, "client/0.3.0") def test_vmaf_passes_distorted_input_before_reference(self) -> None: diff --git a/client/tests/test_release_preflight.py b/client/tests/test_release_preflight.py index b76d6a4c..df96b67f 100644 --- a/client/tests/test_release_preflight.py +++ b/client/tests/test_release_preflight.py @@ -58,8 +58,8 @@ 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.2") - self.assertEqual(payload["releaseDate"], "2026-09-21") + self.assertEqual(payload["projectVersion"], "1.3.0-rc.3") + self.assertEqual(payload["releaseDate"], "2026-09-22") for tree in ("client", "server"): root = release_manifest_lib.ROOT_DIR / tree / "resources/test_suite_v1" status = json.loads((root / "finalization-status.json").read_text()) diff --git a/release.json b/release.json index 030177a6..a0c457c5 100644 --- a/release.json +++ b/release.json @@ -1,9 +1,9 @@ { "schemaVersion": 1, - "projectVersion": "1.3.0-rc.2", - "releaseDate": "2026-09-21", + "projectVersion": "1.3.0-rc.3", + "releaseDate": "2026-09-22", "benchmarkProtocolVersion": "7.1", "plFormulaVersion": "7.0", "suiteVersion": "encodingdb-test-suite-v1", - "clientImplementationVersion": "client/0.3.1" + "clientImplementationVersion": "client/0.3.2" } From aa2c913cbfb5364cd5c164aea41080b05a08d9fd Mon Sep 17 00:00:00 2001 From: ofhd Date: Tue, 22 Sep 2026 03:58:42 -0700 Subject: [PATCH 4/4] Point the run page at the 1.3.0-rc.3 Windows repair Stamp the rebuilt Windows GUI digest, move rc.2 to the superseded packaged section with its published digests, keep the rc.1 plain CLI builds reachable under their own tag, and update the README current-release statements. macOS/Linux primary digests are unchanged: byte-identical republish. --- README.md | 2 +- frontend/app/run/page.test.tsx | 16 +++--- frontend/app/run/page.tsx | 17 +++++- frontend/app/run/releaseAssets.ts | 78 +++++++++++++------------- frontend/app/run/releaseConfig.test.ts | 4 +- 5 files changed, 66 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index cd7be169..428571c4 100644 --- a/README.md +++ b/README.md @@ -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.2` / `2026-09-21` in `release.json`; client/0.3.1, protocol `7.1`. +- Published project release: `1.3.0-rc.3` / `2026-09-22` in `release.json`; client/0.3.2, 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`. diff --git a/frontend/app/run/page.test.tsx b/frontend/app/run/page.test.tsx index ff9419ec..defc3d9d 100644 --- a/frontend/app/run/page.test.tsx +++ b/frontend/app/run/page.test.tsx @@ -17,10 +17,10 @@ describe("downloadModel", () => { } }); - it("never resolves staged rc.2 names onto the deployed rc.1 base", () => { + it("never resolves staged current-tag names onto the deployed superseded base", () => { // The live deployment still points COLLECTION_DOWNLOAD_BASE at the - // published rc.1 path; concatenating rc.2 file names onto it would 404 - // or, worse, serve superseded bytes under new names. + // published predecessor path; concatenating current file names onto it + // would 404 or, worse, serve superseded bytes under new names. const model = downloadModel({ [downloadBaseEnvVar]: `${repoReleases}/download/${supersededTag}` }); expect(model.published).toBe(false); for (const item of model.items) expect(item.href).toBeNull(); @@ -37,7 +37,7 @@ describe("downloadModel", () => { }); describe("RunPage", () => { - it("stages the packaged rc.2 builds with verified digests but no download links until publication", () => { + it("stages the packaged builds with verified digests but no download links until publication", () => { vi.stubEnv("COLLECTION_DOWNLOAD_BASE", ""); render(); for (const asset of primaryAssets) { @@ -64,7 +64,7 @@ describe("RunPage", () => { // Source/CLI exists only as the optional advanced path. }); - it("does not activate rc.2 downloads when the environment still names the rc.1 base", () => { + it("does not activate current downloads when the environment still names the superseded base", () => { vi.stubEnv("COLLECTION_DOWNLOAD_BASE", `${repoReleases}/download/${supersededTag}`); render(); expect(screen.getByText(/is being prepared/)).toBeInTheDocument(); @@ -86,10 +86,12 @@ describe("RunPage", () => { it("documents superseded and historical builds without presenting them as recommended", () => { render(); - // rc.1 keeps its verified digests and links under its own published tag. + // The superseded release keeps its verified digests and links under its + // own published tag. macOS/Linux digests intentionally equal the primary + // ones (byte-identical republish), so match with getAllByText. for (const asset of supersededAssets) { expect(asset.sha256).toMatch(/^[0-9a-f]{64}$/); - expect(screen.getByText(new RegExp(String(asset.sha256).slice(0, 16)))).toBeInTheDocument(); + expect(screen.getAllByText(new RegExp(String(asset.sha256).slice(0, 16))).length).toBeGreaterThan(0); expect(document.querySelector(`a[href='${repoReleases}/download/${supersededTag}/${asset.file}']`)).not.toBeNull(); } expect(screen.getByText(/bare extensionless executable/)).toBeInTheDocument(); diff --git a/frontend/app/run/page.tsx b/frontend/app/run/page.tsx index 37ca2d17..e962424d 100644 --- a/frontend/app/run/page.tsx +++ b/frontend/app/run/page.tsx @@ -1,5 +1,5 @@ import styles from "./page.module.css"; -import { downloadModel, historicalTag, projectTag, repoReleases, supersededAssets, supersededTag } from "./releaseAssets"; +import { cliTag, downloadModel, historicalTag, projectTag, repoReleases, supersededAssets, supersededTag } from "./releaseAssets"; export const dynamic = "force-dynamic"; @@ -13,6 +13,7 @@ export default function RunPage() { const downloads = downloadModel(process.env); const historical = `${repoReleases}/download/${historicalTag}`; const superseded = `${repoReleases}/download/${supersededTag}`; + const cliBase = `${repoReleases}/download/${cliTag}`; return

Contribute results

@@ -65,8 +66,8 @@ export default function RunPage() {

macOS: the app is ad-hoc signed and not notarized, so the first open may be blocked. If you trust this source, open System Settings → Privacy & Security and choose “Open Anyway” — see Apple’s instructions for opening a blocked app. Nothing here asks you to disable Gatekeeper or clear the quarantine flag.

Windows: SmartScreen warns because the executable is unsigned. Verify the SHA-256 above first, and continue only if you trust the source; the page gives no bypass tool or automation.

-

Superseded command-line builds ({supersededTag})

-

Protocol-compatible plain executables that predate the packaged apps. The macOS file is a bare extensionless executable — prefer the disk image. Advanced users with a terminal may keep using these until {projectTag} ships.

+

Superseded packaged builds ({supersededTag})

+

The {supersededTag} Windows build reported preparation failures as a bare exit code and could loop against a cache folder owned by another account; it is superseded by {projectTag}. Its macOS and Linux binaries are byte-identical to the current ones.

    {supersededAssets.map((asset) =>
  • {asset.label}{" "} @@ -74,6 +75,16 @@ export default function RunPage() {
  • )}
+

Plain command-line builds ({cliTag})

+

Protocol-compatible executables that predate the packaged apps; the macOS file is a bare extensionless executable.

+ +

Historical 1.2.0 (cannot submit)

The 1.2.0 downloads (client/0.2.0, protocol 7.0) remain reachable for reference, but they cannot submit to the server version shipped with this page. Treat them as archived, not as recommended downloads.

    diff --git a/frontend/app/run/releaseAssets.ts b/frontend/app/run/releaseAssets.ts index b235536c..00c21e58 100644 --- a/frontend/app/run/releaseAssets.ts +++ b/frontend/app/run/releaseAssets.ts @@ -1,25 +1,26 @@ -// Release-aware download model for the collection client (target 1.3.0-rc.2). +// Release-aware download model for the collection client (target 1.3.0-rc.3). // -// The packaged rc.2 builds - macOS DMG with a double-clickable app, Windows -// GUI executable, Linux archive with a launcher - are being produced on the -// client lane. They become downloadable only when the operator publishes them -// as GitHub release assets under tag `1.3.0-rc.2` and stamps the verified -// SHA-256 digests into `primaryAssets` during integration. +// rc.3 is a Windows-only repair of rc.2: pre-encode failures now name their +// cause in the GUI event log instead of a bare "Run failed (exit code 3)", and +// a suite cache owned by another account (administrator-privileged creation) +// reports its path and recovery instead of looping. The macOS and Linux +// binaries are byte-identical to the accepted rc.2 builds, republished under +// the new tag with their original source identity; the Windows GUI and console +// are rebuilt from the repair commit. // // COLLECTION_DOWNLOAD_BASE must be the full download base for the CURRENT tag, -// e.g. `https://github.com///releases/download/1.3.0-rc.2`. -// The model fails closed on any other value: the currently deployed base -// still points at the published 1.3.0-rc.1 assets, and rc.2 file names must -// never be concatenated onto that path. Publication is therefore keyed to the -// exact tag segment, not merely to a non-empty variable. +// e.g. `https://github.com///releases/download/1.3.0-rc.3`. +// The model fails closed on any other value: publication is keyed to the +// exact tag segment, so asset names can never resolve under another tag. // -// 1.3.0-rc.1 stays published as plain command-line builds (the macOS asset is -// a bare extensionless executable); it is listed as superseded, never as the -// recommended download. 1.2.0 (client/0.2.0, protocol 7.0) is historical and -// cannot submit to the protocol 7.1 server. +// 1.3.0-rc.2 stays published but superseded (its Windows build has the defect +// above). 1.3.0-rc.1 stays published as plain command-line builds (`cliTag`), +// never as the recommended download. 1.2.0 (client/0.2.0, protocol 7.0) is +// historical and cannot submit to the protocol 7.1 server. -export const projectTag = "1.3.0-rc.2"; -export const supersededTag = "1.3.0-rc.1"; +export const projectTag = "1.3.0-rc.3"; +export const supersededTag = "1.3.0-rc.2"; +export const cliTag = "1.3.0-rc.1"; export const historicalTag = "1.2.0"; export const repoReleases = "https://github.com/oliverdougherC/Encoding_Database/releases"; @@ -49,7 +50,7 @@ export const primaryAssets: ReleaseAsset[] = [ { file: "encodingdb-client-windows.exe", label: "Windows (GUI)", - sha256: "242881ee5095703c67c134590aa96fc5545d8d865321ace84cb451955194b612", + sha256: "a79e188706dd64fc9669b4df346808998079cfc1eb8df8705b5407af61fb4457", support: "No Authenticode signature, so SmartScreen may warn at first launch. The window exposes the same Small/Medium/Large/Full sweeps as the guided interface.", }, { @@ -60,35 +61,36 @@ export const primaryAssets: ReleaseAsset[] = [ }, ]; -// Published but superseded command-line builds (client/0.3.0, protocol 7.1). -// Checksums are the accepted release-executable identities recorded in -// docs/operations/evidence/integration-recovery-20260920/package-acceptance.json -// and each build's SHA256SUMS receipt (all four re-hashed on-disk 2026-09-20). -// Keep these as documentation; they must not be presented as recommended. +// Published but superseded packaged builds (1.3.0-rc.2, client/0.3.1, protocol 7.1). +// Digests are the published rc.2 release-asset digests (GitHub asset digests, +// re-read 2026-09-22). The Windows pair carries the bare-exit-code defect +// repaired in rc.3; the macOS/Linux entries are byte-identical to the primary +// downloads above and are listed only for checksum continuity. Keep these as +// documentation; they must not be presented as recommended. export const supersededAssets: ReleaseAsset[] = [ { file: "encodingdb-client-windows.exe", - label: "Windows GUI (rc.1)", - sha256: "9af36d251e94f7c261f163775db286eaf4c5988ea0d3a30f0346ba9719670cd0", - support: "No Authenticode signature; measured on one physical Windows 11 / RTX 5090 host (GUI acceptance r9).", + label: "Windows GUI (rc.2)", + sha256: "242881ee5095703c67c134590aa96fc5545d8d865321ace84cb451955194b612", + support: "No Authenticode signature; reports preparation failures as a bare exit code - replaced by rc.3.", }, { file: "encodingdb-client-windows-console.exe", - label: "Windows console (rc.1)", - sha256: "0637ad12e31fd20adfeb4d26ea1b377d8e21c26fde1db341ead6fca0a7b4ee97", - support: "No Authenticode signature; same physical host and build session as the GUI executable.", + label: "Windows console (rc.2)", + sha256: "a3fd56fcfc5776d8c34dd82a45ec875a6dfc5b1a232231c2ba7180570721c424", + support: "No Authenticode signature; same defect as the rc.2 GUI executable.", }, { - file: "encodingdb-client-linux", - label: "Linux x86-64 (rc.1)", - sha256: "e8927096799fc4c7592a375b0318bb2313f36fe00f950cfeb555ac93c29d5a9e", - support: "Unsigned; built and replay-accepted on Ubuntu 24.04 (NVIDIA NVENC host).", + file: "EncodingDB-macOS-arm64.dmg", + label: "macOS DMG (rc.2)", + sha256: "2ec29a38cf36920d8eb030de97113cd37c56373a277ddbd79e46d1b0336c36ef", + support: "Byte-identical to the current macOS download; same file republished under the rc.3 tag.", }, { - file: "encodingdb-client-macos", - label: "macOS Apple Silicon (rc.1)", - sha256: "93339fda368d9285e8ba7d6c79d40de1a1069ca638839c126e8d5cb88f1e9a89", - support: "Bare extensionless executable (no app bundle) - superseded by the DMG. Ad-hoc signed (not Developer ID, not notarized); native arm64 with an embedded runtime requiring macOS 27 or later (tested on macOS 27.0 build 26A428); older macOS and Intel Macs unverified. No Rosetta.", + file: "encodingdb-client-linux.tar.gz", + label: "Linux archive (rc.2)", + sha256: "b1a68a039ce78a6bc9718adbae99409865326ea8a8b3fc29e47aaa090ef0f4d8", + support: "Byte-identical to the current Linux download; same file republished under the rc.3 tag.", }, ]; @@ -100,7 +102,7 @@ export interface DownloadModel { // A missing/mismatched base means "assets staged, not yet published": hrefs // stay null so the page can stage the plan without claiming an unpublished -// URL is live - and without ever resolving rc.2 names under another tag. +// URL is live - and without ever resolving current asset names under another tag. export function downloadModel(env: Record): DownloadModel { const base = (env[downloadBaseEnvVar] ?? "").trim().replace(/\/+$/, ""); const published = base.endsWith(`/download/${projectTag}`); diff --git a/frontend/app/run/releaseConfig.test.ts b/frontend/app/run/releaseConfig.test.ts index 7f55e428..9ecc9aff 100644 --- a/frontend/app/run/releaseConfig.test.ts +++ b/frontend/app/run/releaseConfig.test.ts @@ -45,8 +45,8 @@ describe("release configuration consistency", () => { }); it("fails closed when the configured base names a different tag than the project", () => { - // Guards the live-deployment cutover: a stale rc.1 base must never yield - // rc.2 asset links. + // Guards the live-deployment cutover: a stale predecessor base must never + // yield current asset links. expect(downloadModel({ [downloadBaseEnvVar]: `${repoReleases}/download/1.3.0-rc.1` }).published).toBe(false); expect(downloadModel({ [downloadBaseEnvVar]: `${repoReleases}/download/${historicalTag}` }).published).toBe(false); });