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/client/main.py b/client/main.py index ae1ba571..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 @@ -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/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_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_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_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/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('{}') 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) 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); }); 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" }