Repository navigation
[1.6.3] - 2026-09-20
Fixed
-
muxm appeared to hang right after
Loaded config:on large Matroska sources (and paused silently after every Matroska encode) — the console went quiet afterℹ️ Loaded config: ~/.muxmrcand stayed that way until Ctrl-C; the run log ended at→ Caching stream metadata...and the failure-artifacts directory held only an emptyprobe_meta.err. The config import was never the blocker: the silent step isinit_src_fps, which for a Matroska source called_fps_measure_avg_decimal(added in 1.6.2 to catch VFR sources whoser_frame_rateis a container timing base) — and that runsffprobe -count_packets, which reads the entire file. Its comment claimed the pass "stays fast even on large files: ~0.1s measured on a 473MB capture"; that number came from a file already in the page cache and does not extrapolate — the count is I/O-bound and scales with file size. On a 29.5GB mkvmerge remux (Marty Supreme (2025).mkv, external APFS volume) it was still running after 6+ minutes, before the encode had even started, and nothing is logged until a step finishes, so it read as a deadlock. Confirmed by timing the exactffprobecalls against the file: the metadata probe is 0.10s, the packet count is minutes. The same pass also ran on the output side of every Matroska encode's verify step (ffmpeg's muxer writes a per-streamDURATIONtag, which is all the guard needs to proceed), a multi-GB full read before "Verifying output…" completes.Two changes, neither of which weakens the VFR detection where it matters. (1) Free tag check first — a Matroska video stream written by mkvmerge already carries
NUMBER_OF_FRAMESandDURATION;init_src_fpscomputes the average from them (new_fps_tag_avg_decimal, arithmetic only, no I/O), and when it agrees with the nominal rate there is nothing to correct and the file is never read. That is every ordinary CFR movie (the reported file: 215328 ÷ 8980.972s = 23.976 = its24000/1001). The tags are deliberately trusted only to prove agreement, never to override: a plainffmpeg -c copyremux carries a staleNUMBER_OF_FRAMESover verbatim while rewritingDURATION(verified: a forced999999survived two remuxes), so a trimmed copy of a long film would otherwise "average" thousands of fps, be adopted by_fps_prefers_avg, and fail a good encode at the integrity guard (die 45). A disagreement, or absent tags, falls through to a real count. (2) Size gate —_fps_measure_avg_decimalnow declines files over 2 GiB (returns nothing; callers keep the nominalr_frame_rate, exactly the pre-1.6.2 behavior), logging why to the run log. Large real-world media is essentially always CFR, which the nominal rate already gets right; the false positive this measurement exists for is a screen/game-capture recording, which is small._fps_dur_to_secs(shared duration parsing) and_file_size_bytes(BSD/GNUstatprobed separately, per the GNUstat -fhazard documented at_muxm_tier_is_trusted) were extracted to support this. Verified against the reported file with--dry-runand the same~/.muxmrc/--hw-accel videotoolbox:Loaded config→Disk OKin the same second (previously 6+ minutes and counting).Note: the
REPLACE_SOURCEprompt hang fixed below is a genuine but separate bug, found while investigating an earlier form of this report; it did not explain this one — the failing run had no source/output collision and never reached that prompt. -
A
.muxmrc-setREPLACE_SOURCE=1could hang muxm indefinitely at the source-replacement confirmation prompt —REPLACE_SOURCEis a config-settable variable (CONFIG_TRACKED_VARS), but the--replace-sourceflag's "stdin must be a TTY" guard lives only in the CLI parsing arm, so a value arriving via~/.muxmrc,/etc/.muxmrc, or./.muxmrcreached theread -r _confirmprompt with no TTY guarantee at all. When the output path collided with the source and stdin was open but idle (a backgrounded run, or stdin piped from something that never writes or closes), thatreadblocked forever. Reproduced against the pre-fix code (an open, idle stdin pipe; killed only by an outertimeout, exit 124) and confirmed thatmuxm --helpunder the same.muxmrcreturns immediately, so config sourcing itself was never the blocker. The prompt's read is now bounded —read -t 30 -r _confirm || _confirm=""— so an unanswered prompt resolves as a decline (die 11, "source replacement declined by user") after at most 30 seconds instead of hanging. The immediate-EOF case (stdin redirected from/dev/null) still declines instantly, exactly as the earlier M6 fix intended; the--replace-sourceflag's own parse-time TTY rejection is unchanged.
Tests
- New
cli-replace-source-timeout-bound(cli suite) — the existingcli-replace-source-eoftest uses</dev/null, which hits EOF near-instantly and so passes identically with or without any timeout; nothing exercised the open-but-idle stdin shape that actually hung. The new test reproduces that shape (a process-substitution pipe nothing writes to) against a copy of muxm with the bound shortened from-t 30to-t 1(the same sed-copy idiomtools/perturb_check.shuses), so it takes ~2s instead of 30s while still running the realread -t Nline. It carries its own "exactly one line changed" anchor-drift guard and runs the target in the background under a 15s poll ceiling with a force-kill, so a regression fails the test instead of hanging the suite. Written for the harness'sset -euo pipefail: thewaitanddiff | grep -ccalls are||-guarded (a barewait; rc=$?would have silently aborted the whole run on the expected nonzero exit). MUT-M6-EOFmutation anchor refreshed intools/perturb_check.shto the newread -t 30 -r _confirm || _confirm=""line; verified it still turnscli-replace-source-eofred (ERR-trap crash, exit 1) while the clean source passes.containerssuite: the AVI→MKV passthrough-fallback fixture was too short (1s) and failed deterministically —passthrough fallback: expected a derived .mkv output for the .avi source, none found. For a Matroska output muxm's fps-integrity guard measures the true average as packet count ÷ the video stream's tagged duration (ffprobe never populatesavg_frame_ratefor Matroska); over a 1s/25-frame clip a small fixed duration-tag offset (encoder priming) is a large enough fraction to skew that to ~23.05 fps against a true 24, tripping the guard's 1% tolerance — a false-positive "desync" that aborted the build (exit 45) after the fallback notice was already logged, so no output file was ever shipped. Reproduces in isolation, so not a parallel-run flake. Measured empirically: 1s (23.05 fps) and 2s (23.52 fps) still trip it; 3s and 5s do not. The fixture is now 3s. Test-only — the guard itself is unchanged, and it remains correct for real-world clip lengths.unit-fps-no-full-read(unit suite, 8 assertions) — a PATH-shimmedffproberecords any invocation, so the test asserts the cause (was the file read?) instead of a wall clock: agreeing tags on a Matroska source never trigger a count (run on a small file on purpose, so the size gate cannot mask a regression of the tag check); a tag-less or stale-tag source over the gate is not counted (using a sparse 3 GiB file, so no space or I/O); the same metadata on a small file is still counted (VFR detection preserved); a staleNUMBER_OF_FRAMESis never adopted; plus direct coverage of_fps_tag_avg_decimal. Unit suite 691 → 699.- Two new
tools/perturb_check.shanchors —MUT-FPS-TAGSKIP(tag check made always-true → every Matroska source is counted again) andMUT-FPS-SIZEGATE(ceiling raised out of reach). Each verified red-under-mutation and green-when-reverted by the realenforcegate. - Running the test suite no longer leaves an empty
log.txtin the working directory —unit-dv-ffmpeg-wrap(_test_unit_dv_ffmpeg_wrap_mp4) passed the relative namelog.txtas theLOGargument of_dv_ffmpeg_wrap_mp4, which redirects its (stubbed) ffmpeg's output there with>"$_log", so the shell created a zero-bytelog.txtin whatever directory the harness was launched from (the repo root, in practice — where thelog.txtline in.gitignorekept it out ofgit statuswithout stopping it being created). The test asserts on the recorded ffmpeg argv and never reads the log, so the argument is now/dev/null. Test-only:muxmitself is unchanged, and the real call sites always pass$WORKDIR/…paths. Verified by deleting the file and re-running: the unit suite alone recreated it before the change; a fulltests/run_parallel.shafter it (2646 pass / 0 fail / 1 skip, the baseline) leaves the repo root byte-for-byte identical to its pre-run listing.
Changed
- Version bumped to 1.6.3.
docs/muxm.1regenerated viatools/gen-docs.sh(.THversion and release date);completions/muxm-completion.bashis unchanged.RELEASE_DATEset to 2026-09-20.