diff --git a/CHANGELOG.md b/CHANGELOG.md index 32913d7..5d5f076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.26.1 (2026-07-14) + +- **NVENC per-request fallback recreates the PyAV output container.** A + hardware stream that failed during codec open remained attached to the + original container, so mux startup retried that orphan and failed even + after adding libx264. The fallback now starts with a clean container. +- **Discovery stubs no longer poison later optional-dependency probes.** A + missing heavy module remains usable through its returned stub reference, + but is removed from `sys.modules` immediately so `find_spec()` stays honest. + ## 0.26.0 (2026-07-14) - **Model residency is declarative.** Protocol v3 replaces ordinary diff --git a/pyproject.toml b/pyproject.toml index 51d15e8..a6ea333 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gen-worker" -version = "0.26.0" +version = "0.26.1" description = "A library used to build custom functions in Cozy Creator's serverless function platform." readme = "README.md" license = "MIT" diff --git a/src/gen_worker/discovery/heavy_deps.py b/src/gen_worker/discovery/heavy_deps.py index 7e06432..58e0034 100644 --- a/src/gen_worker/discovery/heavy_deps.py +++ b/src/gen_worker/discovery/heavy_deps.py @@ -165,6 +165,16 @@ def _import( sys.meta_path.remove(finder) except ValueError: pass + # Keep later optional-dependency probes honest. A retained stub + # makes find_spec(root) report installed even though package + # metadata correctly has no matching distribution. + for module_name in [ + n + for n, module in sys.modules.items() + if isinstance(module, _HeavyDepStub) + and n.split(".", 1)[0] == root + ]: + del sys.modules[module_name] builtins.__import__ = _import try: diff --git a/src/gen_worker/video_encode.py b/src/gen_worker/video_encode.py index 260ac6e..c429b31 100644 --- a/src/gen_worker/video_encode.py +++ b/src/gen_worker/video_encode.py @@ -257,6 +257,14 @@ def _open(self, height: int, width: int) -> None: "hardware encoder %s failed to open (%s: %s); " "falling back to libx264 for this encode", self._encoder.codec, type(exc).__name__, exc) + # add_stream() leaves the failed hardware stream attached. A + # second stream in that container makes PyAV retry the orphan + # when muxing starts, so the advertised fallback fails too. + try: + self._container.close() + except Exception: + logger.debug("failed hardware encoder container close", exc_info=True) + self._container = av.open(self._path, mode="w") self._encoder = _x264() self._stream = self._add_video_stream(self._encoder, width, height) if self._sample_rate: diff --git a/tests/test_discovery_heavy_deps.py b/tests/test_discovery_heavy_deps.py index a092e17..3fe81b3 100644 --- a/tests/test_discovery_heavy_deps.py +++ b/tests/test_discovery_heavy_deps.py @@ -114,6 +114,9 @@ def test_find_spec_probes_stay_honest() -> None: # is find_spec-only) must NOT see a missing dep as installed — a fooled # probe unlocks module-scope use of the dep in library code. with stub_missing_heavy_deps(extra=(FAKE,)): + stub = _stmt_import(f"import {FAKE}")[FAKE] + assert isinstance(stub, _HeavyDepStub) + assert FAKE not in sys.modules assert importlib.util.find_spec(FAKE) is None with pytest.raises(ModuleNotFoundError): importlib.import_module(FAKE) # programmatic probe, not stubbed @@ -147,7 +150,8 @@ def test_stubs_removed_on_exit() -> None: before_import = builtins.__import__ with stub_missing_heavy_deps(extra=(FAKE,)): _stmt_import(f"import {FAKE}.nn") - assert FAKE in sys.modules + assert FAKE not in sys.modules + assert f"{FAKE}.nn" not in sys.modules assert builtins.__import__ is before_import assert FAKE not in sys.modules assert f"{FAKE}.nn" not in sys.modules diff --git a/tests/test_video_encode.py b/tests/test_video_encode.py index 64caccd..e1bb74d 100644 --- a/tests/test_video_encode.py +++ b/tests/test_video_encode.py @@ -89,6 +89,33 @@ def test_hardware_open_failure_falls_back_per_encode(tmp_path, monkeypatch) -> N assert _decode_frame_count(str(out)) == 4 +def test_hardware_stream_open_failure_recreates_container(tmp_path, monkeypatch) -> None: + """A stream can be added before NVENC refuses to open. The orphan must + not ride into the software fallback container and fail again at mux.""" + out = tmp_path / "fallback-after-stream.mp4" + enc = ve.StreamingVideoEncoder( + out, + fps=24, + encoder=ve.EncoderChoice("h264_nvenc", {}, hardware=True), + ) + containers = [] + real_add = enc._add_video_stream + + def fail_hardware_then_add_software(choice, width, height): + containers.append(enc._container) + if choice.hardware: + raise RuntimeError("NVENC session exhausted after add_stream") + return real_add(choice, width, height) + + monkeypatch.setattr(enc, "_add_video_stream", fail_hardware_then_add_software) + enc.add(_frames(count=4)) + enc.finish() + + assert containers[0] is not containers[1] + assert enc.encoder.codec == "libx264" + assert _decode_frame_count(str(out)) == 4 + + # ---- streaming encoder ------------------------------------------------------- diff --git a/uv.lock b/uv.lock index 335f3b7..6fb0f32 100644 --- a/uv.lock +++ b/uv.lock @@ -583,7 +583,7 @@ wheels = [ [[package]] name = "gen-worker" -version = "0.26.0" +version = "0.26.1" source = { editable = "." } dependencies = [ { name = "blake3" },