diff --git a/README.md b/README.md index 062c897..20d471d 100644 --- a/README.md +++ b/README.md @@ -260,8 +260,12 @@ captures **no images, screenshots, or video**. Use `--decoders no` to isolate server auto-transcoding from the Mac decoder, or select another decoder for a separate decoder experiment. `--expected-cells 4` or `--expected-cells 8` blocks a phase if the final stats contain a different number of cells. A -source-health failure is reported separately from a client/decoder failure. -The current checkout still requires one manual SetupWizard acceptance per +source-health failure is reported separately from a client/decoder failure. To +run a controlled known-good or malformed-resource decoder phase, pass the exact +Emby item ID with `--item-id `. The selector takes precedence over the +broad pool filter, requires exactly one matching item, records the ID in the +private phase manifest, and fails closed on a missing or duplicate match. The +current checkout still requires one manual SetupWizard acceptance per live phase; the runner prints this notice rather than automating GUI clicks. The default 10-minute phases are a pilot. Run the full soak only after the diff --git a/docs/performance-roadmap.md b/docs/performance-roadmap.md index a90b6cd..edc3c6e 100644 --- a/docs/performance-roadmap.md +++ b/docs/performance-roadmap.md @@ -25,6 +25,9 @@ another 30- or 60-minute soak. unchanged, and Windows/Linux ignore this variable. - Decoder telemetry records the requested and active decoder, hardware attempts and activations, software fallbacks, exhausted recovery, and quarantine. +- `scripts/run-soak-diagnostics.py --item-id ` selects one exact Emby item + for a controlled decoder phase. It fails closed when the source response has + zero or multiple matches and records the selector in the private manifest. - `scripts/profile-macos-render.py` parses native captures. Its `--matrix` mode accepts normalized profile JSON or `analyze_run()` reports and selects the highest passing 4/6/8-cell mode. Missing evidence blocks selection. diff --git a/hyperwall/soak_filter.py b/hyperwall/soak_filter.py index ee1bfd7..207d849 100644 --- a/hyperwall/soak_filter.py +++ b/hyperwall/soak_filter.py @@ -7,12 +7,25 @@ def apply_initial_filter( items: list[dict[str, Any]], mode: str | None, + *, + item_id: str | None = None, ) -> tuple[list[dict[str, Any]], str]: - """Return the initial playback pool and its normalized filter mode. + """Return the initial soak pool and its normalized selection mode. - The soak-only caller uses ``favorites`` to select a stable corpus before - any cell starts. Normal launches pass an empty mode and retain all items. + ``item_id`` is an opt-in exact-resource selector for controlled native + decoder experiments. It takes precedence over the broad pool filter and + fails closed when the source response does not contain exactly one match. + Normal launches pass no selector and retain the existing all/favorites + behavior. """ + if item_id not in (None, ""): + matches = [item for item in items if item.get("Id") == item_id] + if not matches: + return [], "item-not-found" + if len(matches) != 1: + return [], "item-ambiguous" + return list(matches), "item" + normalized = str(mode or "").strip().lower() if normalized != "favorites": return list(items), "all" diff --git a/hyperwall/wall.py b/hyperwall/wall.py index 0a3bc24..769ca88 100644 --- a/hyperwall/wall.py +++ b/hyperwall/wall.py @@ -690,6 +690,13 @@ def _start_async_load(self) -> None: self.loader.start() def _on_items_loaded(self, items: list[dict[str, Any]]) -> None: + soak_active = os.environ.get("HYPERWALL_SOAK_ACTIVE") == "1" + initial_filter = ( + os.environ.get("HYPERWALL_SOAK_FILTER", "") if soak_active else "" + ) + initial_item_id = ( + os.environ.get("HYPERWALL_SOAK_ITEM_ID") if soak_active else None + ) source_items = select_playback_candidates( list(items), direct_only=getattr(self, "_stable_direct_only", False), @@ -709,14 +716,12 @@ def _on_items_loaded(self, items: list[dict[str, Any]]) -> None: "auto-transcode enabled for heavy or unmeasured sources.", len(source_items), ) - self.all_items = source_items - initial_filter = ( - os.environ.get("HYPERWALL_SOAK_FILTER", "") - if os.environ.get("HYPERWALL_SOAK_ACTIVE") == "1" - else "" + selection_items = ( + list(items) if initial_item_id not in (None, "") else source_items ) + self.all_items = selection_items self.filtered, self.filter_mode = apply_initial_filter( - source_items, initial_filter + selection_items, initial_filter, item_id=initial_item_id ) self.playlists.set_source(self.filtered, DEFAULT_GROUP) if self.filter_mode != "all": diff --git a/launch.sh b/launch.sh index b234490..4fe6bf0 100755 --- a/launch.sh +++ b/launch.sh @@ -12,6 +12,7 @@ if [ "${HYPERWALL_SOAK_ACTIVE:-0}" != "1" ]; then unset HYPERWALL_SOAK_ACTIONS unset HYPERWALL_SOAK_PROFILE unset HYPERWALL_SOAK_FILTER + unset HYPERWALL_SOAK_ITEM_ID unset HYPERWALL_SOAK_REPORT_DIR unset HYPERWALL_SOAK_REPORT_ROOT unset HYPERWALL_NO_RELAUNCH diff --git a/scripts/run-soak-diagnostics.py b/scripts/run-soak-diagnostics.py index 50d540c..edfd7d8 100755 --- a/scripts/run-soak-diagnostics.py +++ b/scripts/run-soak-diagnostics.py @@ -253,7 +253,8 @@ def _validate_endpoint(url: str | None) -> None: def _safe_env_manifest(env: object) -> dict[str, object]: keys = ( "HYPERWALL_STATS", "HYPERWALL_PERFTRACE", "HYPERWALL_SOAK_MINUTES", - "HYPERWALL_SOAK_DWELL_S", "HYPERWALL_SOAK_PROFILE", "HYPERWALL_HWDEC", + "HYPERWALL_SOAK_DWELL_S", "HYPERWALL_SOAK_PROFILE", "HYPERWALL_SOAK_FILTER", + "HYPERWALL_SOAK_ITEM_ID", "HYPERWALL_HWDEC", "HYPERWALL_CACHE_BUDGET_MB", "HYPERWALL_DEMUXER_PER_CELL_MB", "HYPERWALL_AUTO_TRANSCODE", "HYPERWALL_STABLE_DIRECT_ONLY", "HYPERWALL_STABLE_MAX_FPS", "HYPERWALL_STABLE_MAX_BITRATE_MBPS", @@ -269,13 +270,20 @@ def _force_private_permissions(path: Path) -> None: force_private_permissions(path, mode) -def _base_env(report_dir: Path, minutes: int, dwell: int) -> dict[str, str]: +def _base_env( + report_dir: Path, + minutes: int, + dwell: int, + *, + item_id: str | None = None, +) -> dict[str, str]: env = os.environ.copy() for key in ( "HYPERWALL_HWDEC", "HYPERWALL_VO", "HYPERWALL_GPU_API", "HYPERWALL_PROFILE", + "HYPERWALL_SOAK_ITEM_ID", ): env.pop(key, None) env.update({ @@ -292,6 +300,8 @@ def _base_env(report_dir: Path, minutes: int, dwell: int) -> dict[str, str]: "HYPERWALL_NO_LOG_SETUP": "1", "LC_NUMERIC": "C", }) + if item_id is not None: + env["HYPERWALL_SOAK_ITEM_ID"] = item_id return env @@ -330,10 +340,11 @@ def _run_live_phase( dwell: int, watchdog: int, expected_cells: int | None = None, + item_id: str | None = None, ) -> int: phase_dir.mkdir(parents=True, exist_ok=True) _force_private_permissions(phase_dir) - env = _base_env(phase_dir, minutes, dwell) + env = _base_env(phase_dir, minutes, dwell, item_id=item_id) env["HYPERWALL_HWDEC"] = decoder _write_run_metadata( phase_dir / "runner.json", @@ -549,6 +560,10 @@ def main(argv: list[str] | None = None) -> int: "--expected-cells", type=int, default=None, help="Require this many final stats cells; mismatch blocks the phase.", ) + parser.add_argument( + "--item-id", default=None, + help="Select exactly one Emby item ID for controlled decoder phases.", + ) parser.add_argument( "--decoders", default=",".join(DEFAULT_DECODERS), help="Comma-separated decoder A/B phases; use one value to run one phase.", @@ -640,6 +655,7 @@ def main(argv: list[str] | None = None) -> int: dwell=args.dwell, watchdog=args.watchdog_grace, expected_cells=args.expected_cells, + item_id=args.item_id, ) result = _analyze_phase( phase_dir, diff --git a/soak_wall.sh b/soak_wall.sh index 04b2c42..f1aab97 100755 --- a/soak_wall.sh +++ b/soak_wall.sh @@ -36,7 +36,7 @@ PIDS=() printf 'started_at=%s\n' "$(python3 -c 'from datetime import datetime, timezone; print(datetime.now(timezone.utc).isoformat())')" printf 'host='; sw_vers printf 'hardware='; system_profiler SPHardwareDataType 2>/dev/null || true - printf 'env=HYPERWALL_SOAK_MINUTES=%s HYPERWALL_SOAK_DWELL_S=%s HYPERWALL_SOAK_PROFILE=%s HYPERWALL_SOAK_FILTER=%s HYPERWALL_HWDEC=%s HYPERWALL_CACHE_BUDGET_MB=%s HYPERWALL_DEMUXER_PER_CELL_MB=%s\n' "$HYPERWALL_SOAK_MINUTES" "$HYPERWALL_SOAK_DWELL_S" "$HYPERWALL_SOAK_PROFILE" "${HYPERWALL_SOAK_FILTER:-}" "${HYPERWALL_HWDEC:-}" "${HYPERWALL_CACHE_BUDGET_MB:-}" "${HYPERWALL_DEMUXER_PER_CELL_MB:-}" + printf 'env=HYPERWALL_SOAK_MINUTES=%s HYPERWALL_SOAK_DWELL_S=%s HYPERWALL_SOAK_PROFILE=%s HYPERWALL_SOAK_FILTER=%s HYPERWALL_SOAK_ITEM_ID=%s HYPERWALL_HWDEC=%s HYPERWALL_CACHE_BUDGET_MB=%s HYPERWALL_DEMUXER_PER_CELL_MB=%s\n' "$HYPERWALL_SOAK_MINUTES" "$HYPERWALL_SOAK_DWELL_S" "$HYPERWALL_SOAK_PROFILE" "${HYPERWALL_SOAK_FILTER:-}" "${HYPERWALL_SOAK_ITEM_ID:-}" "${HYPERWALL_HWDEC:-}" "${HYPERWALL_CACHE_BUDGET_MB:-}" "${HYPERWALL_DEMUXER_PER_CELL_MB:-}" display_probe="$(system_profiler SPDisplaysDataType 2>/dev/null || true)" if printf '%s\n' "$display_probe" | python3 -c ' import sys diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index d59f48e..b4aa8a7 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -11,6 +11,7 @@ import sys import tempfile from pathlib import Path, PurePosixPath +from unittest import mock sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -63,6 +64,23 @@ def test_runner_records_only_safe_environment_fields(): assert "os.environ" in source +def test_runner_propagates_exact_item_id_into_safe_manifest(): + runner = _load_runner_module() + with tempfile.TemporaryDirectory() as tmp: + env = runner._base_env(Path(tmp), 1, 0, item_id="known-good-id") + assert env["HYPERWALL_SOAK_ITEM_ID"] == "known-good-id" + assert runner._safe_env_manifest(env)["HYPERWALL_SOAK_ITEM_ID"] == "known-good-id" + + +def test_runner_clears_ambient_item_id_without_explicit_selector(): + runner = _load_runner_module() + with tempfile.TemporaryDirectory() as tmp, mock.patch.dict( + os.environ, {"HYPERWALL_SOAK_ITEM_ID": "stale-id"}, clear=False + ): + env = runner._base_env(Path(tmp), 1, 0) + assert "HYPERWALL_SOAK_ITEM_ID" not in env + + def test_runner_has_safe_preflight_and_manual_wizard_notice(): source = open( os.path.join( diff --git a/tests/test_soak_filter.py b/tests/test_soak_filter.py index 34fdf9a..59e60b4 100644 --- a/tests/test_soak_filter.py +++ b/tests/test_soak_filter.py @@ -28,10 +28,46 @@ def test_initial_filter_is_all_by_default_and_for_unknown_modes(): assert filtered is not items +def test_exact_item_selection_overrides_pool_filter(): + items = [ + {"Id": "good", "UserData": {"IsFavorite": False}}, + {"Id": "other", "UserData": {"IsFavorite": True}}, + ] + + filtered, mode = apply_initial_filter( + items, "favorites", item_id="good" + ) + + assert mode == "item" + assert filtered == [items[0]] + + +def test_exact_item_selection_fails_closed_when_missing_or_ambiguous(): + items = [ + {"Id": "good"}, + {"Id": "duplicate"}, + {"Id": "duplicate"}, + ] + + missing, missing_mode = apply_initial_filter( + items, "favorites", item_id="absent" + ) + ambiguous, ambiguous_mode = apply_initial_filter( + items, "favorites", item_id="duplicate" + ) + + assert missing == [] + assert missing_mode == "item-not-found" + assert ambiguous == [] + assert ambiguous_mode == "item-ambiguous" + + def run_all() -> int: tests = [ test_initial_favorites_filter_selects_only_favorites, test_initial_filter_is_all_by_default_and_for_unknown_modes, + test_exact_item_selection_overrides_pool_filter, + test_exact_item_selection_fails_closed_when_missing_or_ambiguous, ] failures = 0 for test in tests: diff --git a/tests/test_soak_telemetry.py b/tests/test_soak_telemetry.py index 228d595..2598aef 100644 --- a/tests/test_soak_telemetry.py +++ b/tests/test_soak_telemetry.py @@ -214,6 +214,7 @@ def test_macos_soak_launcher_collects_system_telemetry(): "HYPERWALL_SOAK_PROFILE:-audio", "HYPERWALL_SOAK_REPORT_DIR", "HYPERWALL_SOAK_FILTER", + "HYPERWALL_SOAK_ITEM_ID", "HYPERWALL_STATS=1", "HYPERWALL_PERFTRACE=1", "powermetrics", @@ -241,6 +242,7 @@ def test_normal_launcher_clears_stale_soak_environment(): assert 'unset HYPERWALL_SOAK_MINUTES' in source assert 'unset HYPERWALL_SOAK_REPORT_DIR' in source assert 'unset HYPERWALL_SOAK_FILTER' in source + assert 'unset HYPERWALL_SOAK_ITEM_ID' in source assert 'unset HYPERWALL_NO_LOG_SETUP' in source