Research-2131: who frees the vmaf CLI's option dictionaries¶
- Status: Active
- Workstream:
T-CLI-PRE-REGISTRATION-OPTS-DICT-LEAK-2026-09-30(bug fix, no ADR) - Last updated: 2026-09-30
Question¶
vmaf --feature cambi=full_ref=true on an input that cannot be opened exits with a LeakSanitizer report of 158 bytes. Which exit paths leak the option dictionaries the CLI builds, and where can the CLI release them without freeing one that libvmaf already took?
Sources¶
core/tools/cli_parse.cpp:parse_feature_config()andapply_model_opt()build the dictionaries withvmaf_feature_dictionary_set();cli_free().core/tools/vmaf.cpp:run_cli(),register_cli_feature(),load_one_model_entry(),overload_model_collection_features(),cleanup_cli_run_state().core/include/libvmaf/libvmaf.h(vmaf_use_feature()) andcore/include/libvmaf/model.h(vmaf_model_feature_overload(),vmaf_model_collection_feature_overload()): the ownership contract (Netflix/vmaf#1242).core/src/libvmaf.cvmaf_use_feature(),core/src/feature/feature_extractor.cppvmaf_feature_extractor_context_create(),core/src/opt.cppvmaf_option_set().- PR #1642 review findings (the reproducer and the pre-registration scope).
Findings¶
- The three libvmaf calls take the dictionary on every path except their argument guards.
cli_free()freed onlyfeature_cfg[i].bufandmodel_config[i].buf, so a dictionary stayed allocated whenever the run did not reach its call:open_cli_inputs()failures (missing file, odd height with 4:2:0),load_cli_models()failures (a model feature that does not fit the frame, before the overloads are applied), and inregister_cli_features()every feature after the first failure, and the ADR-0498 refusal of a feature pinned to a backend the run did not start (--backend cpu --feature psnr_cuda=...), which returns before the hand-off. - Measured on master
10f27efe2, clang 22 ASan build, 64x64 inputs: missing input with--feature psnr=enable_chroma=trueand a--model path=...:vif.vif_enhn_gain_limit=1.0overload 329 bytes / 8 allocations; odd height 329;vmaf_v1.0.16_3d0hon 64x64 329; failing--feature cambibefore--feature psnr=enable_chroma=true163; unknown extractor with options 158;--backend cpu --feature psnr_cuda=enable_chroma=false164, where LeakSanitizer also turns the intended exit 100 into 1.--feature psnr=no_such_option=1and a complete run are clean. - Exit codes do not change. Without a sanitizer, master and the fix exit alike on all eight paths the regression test drives (release builds, no LTO): 255 for a missing input, an odd height, an unknown extractor and an unknown option, 234 (
-EINVAL) for a model or feature that does not fit, 100 for the refused pinned backend, 0 for a complete run. - The overload dictionary's leak is invisible to the default LeakSanitizer scan: a stale pointer in the exit-time stack frames keeps it reachable.
LSAN_OPTIONS=use_stacks=0:use_registers=0reports it; the regression test sets that, which is safe on these paths because no worker thread is alive at exit. vmaf_use_feature()returns-EINVALin two cases the caller cannot tell apart by the code: an unknown extractor name (argument guard, dictionary handed back) and an option the extractor rejects (vmaf_fex_ctx_parse_options()inside context creation, dictionary already freed). With a NULL dictionary every option setter returns 0, sovmaf_use_feature(vmaf, name, NULL)fails with-EINVALonly for an unknown name. The CLI runs that second call only after the first failed with-EINVAL; for a known name it registers one more extractor, with default options, in a context the run is about to close, whichvmaf_close()releases. The probe stays sound only while a registration failure ends the run. Avmaf_feature_backend_twin()pre-check cannot replace it: that call returns-EINVALboth for an unknown name and for a registered GPU extractor such aspsnr_cuda.
Alternatives explored¶
| Option | Result | Why not chosen |
|---|---|---|
Free the dictionaries on each run_cli() error return | Needs a free on every early return and knowledge of which ones libvmaf already took | Ownership spread over many returns; the next early return would leak again |
cli_free() frees what is left, hand-offs clear the pointer (chosen) | One release point; a hand-off that forgets to clear double-frees in every normal run, so tests and ASan catch it at once | — |
| Leave the unknown-name case alone | Simpler register_cli_feature() | One dictionary still leaks on a typo in --feature name=... |
| Add a public "is this extractor registered" query | Exact without a second call | New public API and exported symbol for a CLI error path |
Open questions¶
cli_parse()'s ownusage()exits (a bad option string in the middle of a--modelvalue, more than 32 overloads) still leave the half-built dictionaries to the process exit. The shipped CLI exits with the stack live, so LeakSanitizer does not report them; only the fuzz harness, which longjmps out ofexit(), can lose them, and it runs with leak detection off for that reason.
Related¶
docs/state.mdT-CLI-PRE-REGISTRATION-OPTS-DICT-LEAK-2026-09-30,T-CLI-PARSE-ERROR-PATH-LEAK-2026-09-16(the parse-time paths, #1425).core/tools/test/test_vmaf_option_dict_ownership.sh,core/test/test_cli_parse.crelease_parsed().