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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
10 changes: 10 additions & 0 deletions src/gen_worker/discovery/heavy_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions src/gen_worker/video_encode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion tests/test_discovery_heavy_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions tests/test_video_encode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 -------------------------------------------------------


Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading