Skip to content

Commit ecda547

Browse files
cursoragentjmjava
andcommitted
Fail closed when concat ffmpeg leaves a truncated output
Concat already raised ConcatError on timeout or non-zero ffmpeg, but left recordings/<target>.mp4 in place. Unlink the incomplete file so pages and validate cannot treat a hung concat as a finished demo. Co-authored-by: jmjava <jmjava@gmail.com>
1 parent 92de9f9 commit ecda547

5 files changed

Lines changed: 100 additions & 6 deletions

File tree

‎milestones/README.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ repositories that install `docgen` and maintain their own demo bundle. The
55
library no longer ships an in-repo dogfood; consumers are the integration test
66
of record.
77

8-
**Active:** **[generation-zero-values.md](generation-zero-values.md)** —
9-
`temperature: 0` / `max_whisper_segment_text_chars: 0` must not be
10-
replaced by ``or`` defaults.
8+
**Active:** **[concat-ffmpeg-timeout.md](concat-ffmpeg-timeout.md)** —
9+
concat must not leave a truncated full-demo mp4 after ffmpeg
10+
timeout or failure.
1111

1212
**Shipped:**
13+
- **[generation-zero-values.md](generation-zero-values.md)** —
14+
`temperature: 0` / `max_whisper_segment_text_chars: 0` must not be
15+
replaced by ``or`` defaults (#122).
1316
- **[compose-ffmpeg-timeout.md](compose-ffmpeg-timeout.md)** —
1417
compose must not treat a timed-out ffmpeg mux as success (#121).
1518
- **[visual-beats-numeric.md](visual-beats-numeric.md)** —
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Milestone: concat must not leave a truncated ffmpeg output
2+
3+
**Status:** Active
4+
**PR:** (pending)
5+
**Depends on:** `milestones/compose-ffmpeg-timeout.md` (PR #121),
6+
`milestones/generation-zero-values.md` (PR #122)
7+
8+
## Problem
9+
10+
Compose now raises and unlinks a partial mux on ffmpeg timeout (#121).
11+
Concat already raised ``ConcatError`` on timeout / non-zero ffmpeg, but
12+
it left ``recordings/<target>.mp4`` in place. ``ffmpeg -y`` writes as
13+
it goes, so a hung or failed concat could leave a truncated full-demo
14+
file that ``pages`` / validate treat as a finished recording.
15+
16+
## Goal
17+
18+
On concat ffmpeg timeout, ``CalledProcessError``, or missing ffmpeg,
19+
unlink the incomplete output (same contract as compose). Keep raising
20+
``ConcatError``. Empty concat maps stay a no-op.
21+
22+
## Done when
23+
24+
- [ ] Timeout / failed concat removes the incomplete target mp4.
25+
- [ ] Tests cover timeout and CalledProcessError with a pre-existing
26+
partial file.
27+
- [ ] `ruff check src/ tests/`
28+
- [ ] `pytest tests/`
29+
- [ ] `docgen benchmark` (no clock change; meets baseline)
30+
31+
## Out of scope
32+
33+
- Changing the 300s concat ffmpeg timeout.
34+
- Empty ``concat:`` maps (still a no-op).

‎milestones/generation-zero-values.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Milestone: honor explicit generation zeros (temperature / max_chars)
22

3-
**Status:** Active
3+
**Status:** Shipped
44
**PR:** [#122](https://github.com/jmjava/documentation-generator/pull/122)
55
**Depends on:** `milestones/generation-numeric-tunables.md` (PR #117),
66
`milestones/compose-ffmpeg-timeout.md` (PR #121)

‎src/docgen/concat.py‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,25 @@ def _build_one(self, out_name: str, seg_ids: list[str]) -> None:
8989
cwd=str(recordings_dir),
9090
)
9191
except FileNotFoundError as exc:
92+
_unlink_incomplete(out)
9293
raise ConcatError("[concat] ffmpeg not found in PATH") from exc
9394
except subprocess.CalledProcessError as exc:
95+
_unlink_incomplete(out)
9496
detail = (exc.stderr or exc.stdout or "")[:400]
9597
raise ConcatError(f"[concat] ffmpeg failed: {detail}") from exc
96-
except subprocess.TimeoutExpired as exc:
97-
raise ConcatError("[concat] ffmpeg timed out") from exc
98+
except subprocess.TimeoutExpired as ext:
99+
existed = out.exists()
100+
_unlink_incomplete(out)
101+
extra = f" (removed incomplete {out.name})" if existed else ""
102+
raise ConcatError(f"[concat] ffmpeg timed out{extra}") from ext
98103
finally:
99104
concat_list.unlink(missing_ok=True)
105+
106+
107+
def _unlink_incomplete(path: Path) -> None:
108+
"""Remove a truncated concat output so later stages cannot treat it as finished."""
109+
if path.exists():
110+
try:
111+
path.unlink()
112+
except OSError:
113+
pass

‎tests/test_concat.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import subprocess
56
from pathlib import Path
67

78
import pytest
@@ -71,3 +72,45 @@ def test_concat_builder_rejects_integer_segment_id() -> None:
7172
)
7273
with pytest.raises(ConcatError, match="quoted string segment id"):
7374
ConcatBuilder(cfg).build() # type: ignore[arg-type]
75+
76+
77+
def _seed_recordings(tmp_path: Path) -> None:
78+
rec = tmp_path / "recordings"
79+
rec.mkdir()
80+
(rec / "01-a.mp4").write_bytes(b"seg-a")
81+
(rec / "02-b.mp4").write_bytes(b"seg-b")
82+
83+
84+
def test_concat_ffmpeg_timeout_removes_incomplete_output(
85+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
86+
) -> None:
87+
cfg = _cfg(tmp_path, {"full": ["01", "02"]})
88+
_seed_recordings(tmp_path)
89+
out = tmp_path / "recordings" / "full.mp4"
90+
out.write_bytes(b"partial-concat")
91+
92+
def fake_run(cmd, **_kwargs):
93+
raise subprocess.TimeoutExpired(cmd=cmd, timeout=300)
94+
95+
monkeypatch.setattr(subprocess, "run", fake_run)
96+
with pytest.raises(ConcatError, match="removed incomplete full.mp4"):
97+
ConcatBuilder(cfg).build(name="full")
98+
assert not out.exists()
99+
assert not list((tmp_path / "recordings").glob(".concat-*.txt"))
100+
101+
102+
def test_concat_ffmpeg_failure_removes_incomplete_output(
103+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
104+
) -> None:
105+
cfg = _cfg(tmp_path, {"full": ["01", "02"]})
106+
_seed_recordings(tmp_path)
107+
out = tmp_path / "recordings" / "full.mp4"
108+
out.write_bytes(b"partial-concat")
109+
110+
def fake_run(cmd, **_kwargs):
111+
raise subprocess.CalledProcessError(1, cmd, stderr="mux error")
112+
113+
monkeypatch.setattr(subprocess, "run", fake_run)
114+
with pytest.raises(ConcatError, match="ffmpeg failed"):
115+
ConcatBuilder(cfg).build(name="full")
116+
assert not out.exists()

0 commit comments

Comments
 (0)