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
2 changes: 1 addition & 1 deletion .standards-baseline.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": 1,
"generated_at": "2026-10-02T02:40:30Z",
"generated_at": "2026-10-02T05:12:30Z",
"repository": "VMAFx/vmafx",
"commit_sha": "6e5896e0ba36dd97dab36ec390c13336f863679e",
"total_infractions": 270,
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1089,6 +1089,15 @@
the new `feature_backends` array says where each extractor ran.


- **The `vmaf` CLI's read-ahead checks its invariants with assertions again.**
A lint cleanup had replaced the seven `assert()`s of the frame reader by
early returns, so a broken invariant would have dropped a frame or ended a
stream without a message instead of stopping a debug build. No release
carried the change. The clang-tidy finding that prompted it is a false
positive of clang-tidy 22 on glibc 2.44 hosts; the CI image does not report
it.


- **`vmaf` no longer leaks its option dictionaries when a run stops early.**
The dictionaries built from `--feature name=opt=val` and from a `--model`
feature overload (`--model version=...:vif.vif_enhn_gain_limit=1.0`) were
Expand Down
7 changes: 7 additions & 0 deletions changelog.d/fixed/cli-frame-reader-asserts-restored.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- **The `vmaf` CLI's read-ahead checks its invariants with assertions again.**
A lint cleanup had replaced the seven `assert()`s of the frame reader by
early returns, so a broken invariant would have dropped a frame or ended a
stream without a message instead of stopping a debug build. No release
carried the change. The clang-tidy finding that prompted it is a false
positive of clang-tidy 22 on glibc 2.44 hosts; the CI image does not report
it.
9 changes: 9 additions & 0 deletions core/test/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,15 @@ test('test_float_adm_divides_contract',
timeout : 30,
)

# The CLI read-ahead's invariants are assert()s, not early returns: a lint
# cleanup once replaced all seven after a host-toolchain false positive.
test('test_cli_frame_reader_asserts_contract',
import('python').find_installation(),
args : files('test_cli_frame_reader_asserts_contract.py'),
suite : ['fast'],
timeout : 30,
)

# A default scale=0 is only executable by the GPU float-SSIM twins while the
# CPU auto-scale resolves to 1. Pin the model-only, first-frame fallback seam
# and all four backend declarations without requiring an accelerator.
Expand Down
103 changes: 103 additions & 0 deletions core/test/test_cli_frame_reader_asserts_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
# Copyright 2026 Lusoris
# SPDX-License-Identifier: EUPL-1.2
"""Pin the invariants of the CLI's read-ahead (`FrameReader`) as assertions.

`core/tools/vmaf.cpp` reads each input on its own thread, a bounded number of
frames ahead of the scoring loop. The ring's invariants (the slot count never
exceeds the depth, a reader is started once, a picture pointer is never null)
are `assert()`s: a violated one stops a debug build at the line that broke it.

On 2026-10-02 a lint cleanup replaced all seven by early returns, because
clang-tidy 22 on a glibc 2.44 host reports `misc-static-assert` /
`cert-dcl03-c` for every runtime `assert()` in a C++ translation unit (glibc's
macro expands to `expr ? void (1 ? 1 : bool (expr)) : __assert_fail(...)`, and
the check takes the constant arm for a constant condition). The early returns
turned a broken invariant into a silently dropped frame or a silently ended
stream. This contract fails when an invariant stops being asserted.

Device-free: reads the source only.
"""

from __future__ import annotations

import re
import unittest
from pathlib import Path

SOURCE = Path(__file__).resolve().parents[1] / "tools" / "vmaf.cpp"

# function name -> the conditions it must assert
INVARIANTS = {
"release_fetched_picture": ("pic != nullptr",),
"FrameReader::start": ("!threaded_", "!thread_.joinable()"),
"FrameReader::wait_for_free_slot": ("count_ <= kReadaheadDepth",),
"FrameReader::publish": ("count_ < kReadaheadDepth",),
"FrameReader::next": ("pic != nullptr",),
"FrameReader::request_stop": ("count_ <= kReadaheadDepth",),
}


def function_body(text: str, name: str) -> str:
"""Return the body of the definition of ``name`` (brace-matched)."""
match = re.search(rf"^[\w:<>\s\*&]*\b{re.escape(name)}\([^;{{]*\)\s*\n?{{", text, re.M)
if match is None:
raise AssertionError(f"definition of {name}() not found in {SOURCE.name}")
depth, start = 0, match.end() - 1
for index in range(start, len(text)):
if text[index] == "{":
depth += 1
elif text[index] == "}":
depth -= 1
if depth == 0:
return text[start : index + 1]
raise AssertionError(f"unbalanced braces after {name}()")


def missing_assertions(text: str) -> list[str]:
"""List ``function: condition`` for every invariant that is not asserted."""
missing = []
for name, conditions in INVARIANTS.items():
body = function_body(text, name)
for condition in conditions:
if f"assert({condition});" not in body:
missing.append(f"{name}: assert({condition})")
return missing


class FrameReaderAssertsContract(unittest.TestCase):
def test_every_invariant_is_asserted(self) -> None:
text = SOURCE.read_text(encoding="utf-8")
self.assertEqual(missing_assertions(text), [])
self.assertIn("#include <cassert>", text)

def test_an_early_return_in_place_of_an_assertion_is_reported(self) -> None:
text = SOURCE.read_text(encoding="utf-8")
planted = text.replace(
" assert(!threaded_);\n assert(!thread_.joinable());\n",
" if (threaded_ || thread_.joinable())\n return;\n",
)
self.assertNotEqual(planted, text)
self.assertEqual(
missing_assertions(planted),
[
"FrameReader::start: assert(!threaded_)",
"FrameReader::start: assert(!thread_.joinable())",
],
)

def test_a_folded_condition_in_place_of_an_assertion_is_reported(self) -> None:
text = SOURCE.read_text(encoding="utf-8")
planted = text.replace(
" if (!stop_) {\n assert(count_ < kReadaheadDepth);\n",
" if (!stop_ && count_ < kReadaheadDepth) {\n",
)
self.assertNotEqual(planted, text)
self.assertEqual(
missing_assertions(planted),
["FrameReader::publish: assert(count_ < kReadaheadDepth)"],
)


if __name__ == "__main__":
unittest.main()
8 changes: 8 additions & 0 deletions core/tools/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,14 @@ step on main thread. Rebase-sensitive rules:
`preallocate_cli_pictures()` adds exactly `2 * kReadaheadDepth` when
`state->readahead`. Fetching first and queueing later makes pool use
unbounded and lets readers starve libvmaf's pictures.
- `FrameReader` invariants = `assert()` (7: `release_fetched_picture`,
`start` x2, `wait_for_free_slot`, `publish`, `next`, `request_stop`). Never
swap for early return / folded condition: broken invariant then = silently
dropped frame or ended stream. `misc-static-assert` / `cert-dcl03-c` on them
= false positive of clang-tidy 22 on glibc 2.44 hosts
(`T-TIDY-GLIBC-244-STATIC-ASSERT-FALSE-POSITIVE-2026-10-02`); CI image
(glibc 2.39) reports none. Guard:
`core/test/test_cli_frame_reader_asserts_contract.py`.
- Shutdown calls `request_stop()` on BOTH readers before `join()` on either.
`request_stop()` drains ring back to pool, which is what wakes
reader blocked in `vmaf_fetch_preallocated_picture()`; joining one reader
Expand Down
20 changes: 10 additions & 10 deletions core/tools/vmaf.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
* thread, a bounded number of frames ahead of the scoring loop. */

#include <array>
#include <cassert>
#include <climits>
#include <condition_variable>
#include <cstdint>
Expand Down Expand Up @@ -1484,8 +1485,7 @@ struct FetchedFrame {

void release_fetched_picture(VmafPicture *pic)
{
if (!pic)
return;
assert(pic != nullptr);
if (vmaf_picture_unref(pic))
(void)fprintf(stderr, "\nproblem during vmaf_picture_unref (read-ahead)\n");
}
Expand Down Expand Up @@ -1550,8 +1550,8 @@ namespace

void FrameReader::start()
{
if (threaded_ || thread_.joinable())
return;
assert(!threaded_);
assert(!thread_.joinable());
try {
thread_ = std::thread([this] { produce(); });
} catch (const std::exception &) {
Expand Down Expand Up @@ -1581,8 +1581,7 @@ bool FrameReader::wait_for_free_slot()
{
std::unique_lock<std::mutex> lock(lock_);
not_full_.wait(lock, [this] { return stop_ || count_ < kReadaheadDepth; });
if (count_ > kReadaheadDepth)
return false;
assert(count_ <= kReadaheadDepth);
return !stop_;
}

Expand All @@ -1598,7 +1597,8 @@ bool FrameReader::publish(const FetchedFrame &frame)
bool published = false;
{
const std::scoped_lock<std::mutex> lock(lock_);
if (!stop_ && count_ < kReadaheadDepth) {
if (!stop_) {
assert(count_ < kReadaheadDepth);
slots_[(head_ + count_) % kReadaheadDepth] = frame;
count_++;
published = true;
Expand Down Expand Up @@ -1629,8 +1629,7 @@ void FrameReader::finish()
* or stopped) reports end of stream. */
int FrameReader::next(VmafPicture *pic)
{
if (!pic)
return -EINVAL;
assert(pic != nullptr);
if (!threaded_)
return fetch_picture(vmaf_, vid_, pic, depth_);
FetchedFrame frame = {.pic = {}, .ret = 1};
Expand Down Expand Up @@ -1666,8 +1665,9 @@ void FrameReader::request_stop()
unsigned n_drained = 0;
{
const std::scoped_lock<std::mutex> lock(lock_);
assert(count_ <= kReadaheadDepth);
stop_ = true;
for (; count_ > 0 && n_drained < kReadaheadDepth; count_--) {
for (; count_ > 0; count_--) {
drained[n_drained] = slots_[head_];
n_drained++;
head_ = (head_ + 1) % kReadaheadDepth;
Expand Down
12 changes: 12 additions & 0 deletions docs/rebase-notes.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
<!-- markdownlint-disable MD001 MD003 MD004 MD007 MD013 MD018 MD022 MD024 MD025 MD026 MD028 MD029 MD031 MD032 MD033 MD036 MD037 MD038 MD040 MD041 MD046 MD049 MD050 MD051 MD052 MD053 MD055 MD056 MD058 MD059 -->
# Rebase notes

## The CLI read-ahead asserts its invariants (2026-10-02)

`fix/cli-restore-frame-reader-asserts`, closes `T-CLI-FRAME-READER-ASSERTS-REPLACED-2026-10-02`.

- `core/tools/vmaf.cpp`: `release_fetched_picture()` and `FrameReader`
(`start`, `wait_for_free_slot`, `publish`, `next`, `request_stop`) hold seven
`assert()`s. A sync or a lint pass must not turn them into early returns;
`core/test/test_cli_frame_reader_asserts_contract.py` fails if one goes.
- A local clang-tidy run on a glibc 2.44 host reports `misc-static-assert` on
them (`T-TIDY-GLIBC-244-STATIC-ASSERT-FALSE-POSITIVE-2026-10-02`). Upstream
Netflix/vmaf has no `FrameReader`; nothing to keep in step there.

## The CPU clang-tidy lane brought back to baseline (2026-10-02)

`fix/cpu-tidy-regressions`, closes `T-TIDY-CPU-LANE-ABOVE-BASELINE-2026-10-02`.
Expand Down
Loading
Loading