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
14 changes: 14 additions & 0 deletions ai/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,20 @@ its own training surface):
remove it and do not substitute a `PYTHONPATH=ai/scripts` prefix in CI
step definitions — the pyproject config is the single source of truth.

- **`importlib.util` callers must pre-register the module in `sys.modules`.**
Python 3.14 introduced a regression in `dataclasses._is_type()`
(CPython gh-129861): it calls `sys.modules.get(cls.__module__).__dict__`
which raises `AttributeError: 'NoneType' …` when the module is not yet
in `sys.modules` at `exec_module()` time. Any caller that loads an
`ai/scripts/` module via `importlib.util.spec_from_file_location` +
`module_from_spec()` + `exec_module()` **must** insert
`sys.modules[spec.name] = module` between `module_from_spec()` and
`exec_module()`. This invariant applies to all such loaders in the test
suite and any automation harness. `_script_bootstrap.py` itself avoids
the crash by not using `from __future__ import annotations` (which delays
annotation evaluation and can trigger the bug in dataclass field
resolution).

- The `iter_pairs` filename regex is fork-specific. If upstream adds a
loader with a different ladder convention, do NOT merge them — keep
ours under `ai/data/` and theirs under whatever path they pick.
Expand Down
1 change: 1 addition & 0 deletions ai/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ gpu = []
viz = ["matplotlib>=3.10.9", "seaborn>=0.13.2"]
dev = [
"pytest>=9.0.3",
"pytest-timeout>=0.5",
"ruff>=0.15.15",
"mypy>=2.1.0",
"types-PyYAML>=6.0.12.20260518",
Expand Down
2 changes: 0 additions & 2 deletions ai/scripts/_script_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
# SPDX-License-Identifier: BSD-3-Clause-Plus-Patent
"""Shared direct-invocation bootstrap for ``ai/scripts`` modules."""

from __future__ import annotations

import sys
from dataclasses import dataclass
from pathlib import Path
Expand Down
6 changes: 4 additions & 2 deletions ai/tests/test_e2e_frame_to_score.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@
REPO_ROOT = Path(__file__).resolve().parents[2]
# Honour VMAF_BIN so any worktree / CI run can point at a freshly-built binary.
# Default follows the post-ADR-0700 rename: libvmaf/ → core/.
VMAF_BIN = Path(os.environ.get("VMAF_BIN", "")) or (
REPO_ROOT / "core" / "build-cpu" / "tools" / "vmaf"
# Use None as sentinel: Path('') == Path('.'), which would execute CWD as binary.
_vmaf_bin_env = os.environ.get("VMAF_BIN") # None when unset; VMAF_BIN='' means unset
VMAF_BIN = (
Path(_vmaf_bin_env) if _vmaf_bin_env else (REPO_ROOT / "core" / "build-cpu" / "tools" / "vmaf")
)
# Honour VMAF_YUVDIR for worktrees where python/test/resource/ isn't checked out.
YUV_DIR = Path(
Expand Down
2 changes: 2 additions & 0 deletions core/src/feature/feature_collector.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,8 @@ int vmaf_feature_collector_init(VmafFeatureCollector **const feature_collector)
free(static_cast<void *>(fc->feature_vector));
free_fc:
free(fc);
*feature_collector =
nullptr; /* prevent dangling pointer — mirrors feature_collector.c:265 pattern */
fail:
/* NULL the caller's handle so it cannot be dereferenced after a failed
* init. ASan/LeakSan: avoids dangling-pointer UAF. CERT MEM30-C. */
Expand Down
34 changes: 29 additions & 5 deletions core/src/feature/feature_extractor.c
Original file line number Diff line number Diff line change
Expand Up @@ -874,8 +874,20 @@ static struct fex_list_entry *get_fex_list_entry(VmafFeatureExtractorContextPool
return NULL;
}

/* ctx_pool_ensure_slot_ctx — allocate and initialise the per-thread context
* for pool slot [i] if not already present.
*
* framesync is passed explicitly (extracted from fex->framesync by the caller
* while the pool lock is held) rather than being read inside this function.
* Reading fex->framesync here would be a data race: fex is the shared
* registered VmafFeatureExtractor and its framesync field can be written
* concurrently by set_fex_framesync() on the main thread. By snapshotting
* the pointer once under the pool lock and passing it in, we guarantee a
* single unsynchronised read at most, which is safe because the assignment
* in set_fex_framesync() happens-before any vmaf_read_pictures() call (i.e.
* before the pool is acquired for the first time). */
static int ctx_pool_ensure_slot_ctx(struct fex_list_entry *entry, int i, VmafFeatureExtractor *fex,
VmafDictionary *opts_dict)
VmafDictionary *opts_dict, VmafFrameSyncContext *framesync)
{
if (entry->ctx_list[i].fex_ctx)
return 0;
Expand All @@ -895,17 +907,21 @@ static int ctx_pool_ensure_slot_ctx(struct fex_list_entry *entry, int i, VmafFea
return err;
}
entry->ctx_list[i].fex_ctx = f;
/* Propagate framesync to the per-slot deep copy. framesync was captured
* from fex->framesync by the caller under the pool lock, avoiding any
* data race on the shared fex struct (iter9-tsan-race-deep finding #1). */
if (f->fex->flags & VMAF_FEATURE_FRAME_SYNC) {
f->fex->framesync = (fex->framesync);
f->fex->framesync = framesync;
}
return 0;
}

static int ctx_pool_claim_slot(struct fex_list_entry *entry, VmafFeatureExtractor *fex,
VmafDictionary *opts_dict, VmafFeatureExtractorContext **fex_ctx)
VmafDictionary *opts_dict, VmafFeatureExtractorContext **fex_ctx,
VmafFrameSyncContext *framesync)
{
for (int i = 0; i < atomic_load(&entry->capacity); i++) {
int err = ctx_pool_ensure_slot_ctx(entry, i, fex, opts_dict);
int err = ctx_pool_ensure_slot_ctx(entry, i, fex, opts_dict, framesync);
if (err)
return err;
if (!entry->ctx_list[i].in_use) {
Expand Down Expand Up @@ -940,7 +956,15 @@ int vmaf_fex_ctx_pool_aquire(VmafFeatureExtractorContextPool *pool, VmafFeatureE
while (atomic_load(&entry->capacity) == atomic_load(&entry->in_use))
pthread_cond_wait(&(entry->full), &(pool->lock));

err = ctx_pool_claim_slot(entry, fex, opts_dict, fex_ctx);
/* Snapshot fex->framesync once under the lock and pass it to
* ctx_pool_claim_slot / ctx_pool_ensure_slot_ctx so neither function
* needs to read the shared fex struct directly. This removes the
* data race identified by iter9-tsan-race-deep finding #1 where a
* concurrent set_fex_framesync() write on the main thread could race
* with the memcpy inside vmaf_feature_extractor_context_create(). */
VmafFrameSyncContext *framesync =
(fex->flags & VMAF_FEATURE_FRAME_SYNC) ? fex->framesync : NULL;
err = ctx_pool_claim_slot(entry, fex, opts_dict, fex_ctx, framesync);

unlock:
pthread_mutex_unlock(&(pool->lock));
Expand Down
36 changes: 31 additions & 5 deletions core/src/feature/feature_extractor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,7 @@ int vmaf_fex_ctx_pool_create(VmafFeatureExtractorContextPool **pool, unsigned n_
free(p->fex_list);
free_p:
free(p);
*pool = NULL; /* prevent dangling pointer — mirrors feature_extractor.c:797 pattern */
fail:
/* NULL the caller's handle so it cannot be dereferenced after a failed
* pool create. ASan/LeakSan: avoids dangling-pointer UAF. CERT MEM30-C. */
Expand Down Expand Up @@ -869,8 +870,19 @@ static struct fex_list_entry *get_fex_list_entry(VmafFeatureExtractorContextPool
return &pool->fex_list[pool->cnt++];
}

/* ctx_pool_ensure_slot_ctx — allocate and initialise the per-thread context
* for pool slot [i] if not already present.
*
* framesync is passed explicitly (extracted from fex->framesync by the caller
* while the pool lock is held) rather than being read inside this function.
* Reading fex->framesync here would be a data race: fex is the shared
* registered VmafFeatureExtractor and its framesync field can be written
* concurrently by set_fex_framesync() on the main thread. By snapshotting
* the pointer once under the pool lock and passing it in, we guarantee a
* single read at a point where a happens-before relationship to the
* registration write exists. */
static int ctx_pool_ensure_slot_ctx(struct fex_list_entry *entry, int i, VmafFeatureExtractor *fex,
VmafDictionary *opts_dict)
VmafDictionary *opts_dict, VmafFrameSyncContext *framesync)
{
if (entry->ctx_list[i].fex_ctx)
return 0;
Expand All @@ -890,17 +902,21 @@ static int ctx_pool_ensure_slot_ctx(struct fex_list_entry *entry, int i, VmafFea
return err;
}
entry->ctx_list[i].fex_ctx = f;
/* Propagate framesync to the per-slot deep copy. framesync was captured
* from fex->framesync by the caller under the pool lock, avoiding any
* data race on the shared fex struct (iter9-tsan-race-deep finding #1). */
if (f->fex->flags & VMAF_FEATURE_FRAME_SYNC) {
f->fex->framesync = (fex->framesync);
f->fex->framesync = framesync;
}
return 0;
}

static int ctx_pool_claim_slot(struct fex_list_entry *entry, VmafFeatureExtractor *fex,
VmafDictionary *opts_dict, VmafFeatureExtractorContext **fex_ctx)
VmafDictionary *opts_dict, VmafFeatureExtractorContext **fex_ctx,
VmafFrameSyncContext *framesync)
{
for (int i = 0; i < entry->capacity.load(); i++) {
int err = ctx_pool_ensure_slot_ctx(entry, i, fex, opts_dict);
int err = ctx_pool_ensure_slot_ctx(entry, i, fex, opts_dict, framesync);
if (err)
return err;
if (!entry->ctx_list[i].in_use) {
Expand Down Expand Up @@ -935,7 +951,17 @@ int vmaf_fex_ctx_pool_aquire(VmafFeatureExtractorContextPool *pool, VmafFeatureE
while (entry->capacity.load() == entry->in_use.load())
pthread_cond_wait(&(entry->full), &(pool->lock));

err = ctx_pool_claim_slot(entry, fex, opts_dict, fex_ctx);
/* Snapshot fex->framesync once under the lock and pass it to
* ctx_pool_claim_slot / ctx_pool_ensure_slot_ctx so neither function
* needs to read the shared fex struct directly. This removes the
* data race identified by iter9-tsan-race-deep finding #1 where a
* concurrent set_fex_framesync() write on the main thread could race
* with the memcpy inside vmaf_feature_extractor_context_create(). */
{
VmafFrameSyncContext *framesync =
(fex->flags & VMAF_FEATURE_FRAME_SYNC) ? fex->framesync : NULL;
err = ctx_pool_claim_slot(entry, fex, opts_dict, fex_ctx, framesync);
}

unlock:
pthread_mutex_unlock(&(pool->lock));
Expand Down
13 changes: 8 additions & 5 deletions core/src/feature/hip/float_psnr/float_psnr_score.hip
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@
* written to a contiguous float buffer; host accumulates in `double`
* and applies the CPU log10 formula.
*
* HIP warp size is 64 on GCN/RDNA (vs CUDA's 32). The `__shfl_down`
* intrinsic is available under HIP's `<hip/hip_runtime.h>` with the
* same signature as CUDA's `__shfl_down_sync` (HIP masks are 64-bit on
* RDNA). The shared memory array for warp partial sums is sized for the
* HIP warp size (64).
* HIP warp size varies: 64 on GCN/RDNA1 (wave64), 32 on RDNA2+ (wave32).
* The `__shfl_down` intrinsic is available under HIP's `<hip/hip_runtime.h>`
* with the same signature as CUDA's `__shfl_down_sync` (HIP masks are
* 64-bit on GCN/RDNA1, 32-bit on RDNA2+). The shared memory array for warp
* partial sums is sized for the minimum warp size (32 = FPSNR_MIN_WARP_SIZE)
* so it is large enough on wave32 hardware (8 slots) and wave64 uses only
* the first 4 of those slots. Runtime `warpSize` is used in all reduction
* loops and lane detection so the same binary runs correctly on both.
*
* Compilation: `hipcc --offload-arch=gfx90a --emit-llvm -S` (or
* equivalent `--genco` for HSACO fat binary). The meson.build
Expand Down
27 changes: 19 additions & 8 deletions core/src/libvmaf.c
Original file line number Diff line number Diff line change
Expand Up @@ -1608,26 +1608,37 @@ struct ThreadDataBatch {
VmafFeatureCollector *feature_collector;
RegisteredFeatureExtractors *registered_fex;
unsigned n_subsample;
int err;
/* _Atomic int err: the worker thread writes this field multiple times as
* it iterates over extractors; the thread pool runner reads it once (as
* the function return value) to accumulate into pool->last_error. Making
* the field atomic prevents a TSan data-race report if a future code path
* reads f->err without going through the function return value, and
* documents that the field is written from a worker context
* (iter9-tsan-race-deep finding #2). */
_Atomic int err;
};

static int threaded_extract_batch_func(void *e, void **thread_data)
{
struct ThreadDataBatch *f = e;
f->err = 0;
/* f->err is _Atomic int; use atomic_store/atomic_load throughout so that
* TSan sees proper sequenced-before edges and does not report a race if a
* future caller reads f->err outside the function return value path
* (iter9-tsan-race-deep finding #2). */
atomic_store(&f->err, 0);

BatchThreadData *td = *thread_data;
if (!td) {
td = malloc(sizeof(*td));
if (!td) {
f->err = -ENOMEM;
atomic_store(&f->err, -ENOMEM);
goto unref;
}
td->cnt = f->registered_fex->cnt;
td->fex_ctx = calloc(td->cnt, sizeof(*td->fex_ctx));
if (!td->fex_ctx) {
free(td);
f->err = -ENOMEM;
atomic_store(&f->err, -ENOMEM);
goto unref;
}
*thread_data = td;
Expand Down Expand Up @@ -1667,7 +1678,7 @@ static int threaded_extract_batch_func(void *e, void **thread_data)
if (opts_dict) {
int err = vmaf_dictionary_copy(&opts_dict, &d);
if (err) {
f->err = err;
atomic_store(&f->err, err);
break;
}
}
Expand All @@ -1678,7 +1689,7 @@ static int threaded_extract_batch_func(void *e, void **thread_data)
* thread-private (ADR-0795). */
int err = vmaf_feature_extractor_context_create(&td->fex_ctx[i], shared_fex, d);
if (err) {
f->err = err;
atomic_store(&f->err, err);
break;
}
}
Expand Down Expand Up @@ -1723,7 +1734,7 @@ static int threaded_extract_batch_func(void *e, void **thread_data)
}

if (err) {
f->err = err;
atomic_store(&f->err, err);
break;
}
}
Expand All @@ -1733,7 +1744,7 @@ static int threaded_extract_batch_func(void *e, void **thread_data)
vmaf_picture_unref(&f->prev_ref);
vmaf_picture_unref(&f->ref);
vmaf_picture_unref(&f->dist);
return f->err;
return atomic_load(&f->err);
}

static int threaded_read_pictures_batch(VmafContext *vmaf, VmafPicture *ref, VmafPicture *dist,
Expand Down
10 changes: 8 additions & 2 deletions core/src/svm.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2967,7 +2967,10 @@ class SVMModelParserBufferSource
* `SAN-MODEL-MALLOC-OOB` (alloc-too-big + null `memcpy` on a crafted
* model file).
*/
#define VMAF_SVM_MAX_AXIS_COUNT (1 << 24)
/* Tightened to floor(sqrt(INT_MAX)) = 46340 so that the
* nr_class*(nr_class-1)/2 permutation product cannot overflow a signed 32-bit
* int even before any size_t cast is applied (UBSan finding, iter9-fuzz-extended). */
#define VMAF_SVM_MAX_AXIS_COUNT 46340

template <typename TSource> class SVMModelParser
{
Expand Down Expand Up @@ -3039,7 +3042,10 @@ template <typename TSource> class SVMModelParser
exceptAssert(model_source.get(model->nr_class), "Failed to read nr_class.");
exceptAssert(model->nr_class > 0 && model->nr_class <= VMAF_SVM_MAX_AXIS_COUNT,
"nr_class out of range");
nr_class_permutations = model->nr_class * (model->nr_class - 1) / 2;
/* Cast before multiply to keep arithmetic in size_t and avoid
* signed 32-bit overflow (UBSan finding, iter9-fuzz-extended). */
nr_class_permutations =
(size_t)model->nr_class * (size_t)(model->nr_class - 1) / 2u;
} else if (buffer == "total_sv") {
exceptAssert(model_source.get(model->l), "Failed to read total_sv.");
exceptAssert(model->l > 0 && model->l <= VMAF_SVM_MAX_AXIS_COUNT,
Expand Down
2 changes: 1 addition & 1 deletion dev/Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -978,7 +978,7 @@ RUN python3.14 -m venv /opt/vmaf-venv \
&& /opt/vmaf-venv/bin/pip install --no-cache-dir --upgrade pip \
&& /opt/vmaf-venv/bin/pip install --no-cache-dir matplotlib \
&& /opt/vmaf-venv/bin/pip install --no-cache-dir -e /build/vmaf/mcp-server/vmaf-mcp \
&& /opt/vmaf-venv/bin/pip install --no-cache-dir -e /build/vmaf/ai \
&& /opt/vmaf-venv/bin/pip install --no-cache-dir -e '/build/vmaf/ai[dev]' \
# Pin torchvision to the ABI-compatible wheel for torch 2.12.x.
# Without this pin, pip may resolve torchvision 0.26.0 (built against
# torch 2.11) which raises ``RuntimeError: operator torchvision::nms does
Expand Down
4 changes: 4 additions & 0 deletions dev/scripts/dev-mcp-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,11 @@ mkdir -p /tmp && chmod 1777 /tmp
# bind-mount). /probes itself is created by the docker-compose bind
# and may not exist yet on a fresh host if the compose file never ran.
# Guard with || true so the entrypoint does not abort on read-only hosts.
# The chown ensures the vmaf user owns the directory even when the host
# bind-mount source is owned by root:root (mode 755), which would otherwise
# cause PermissionError for every bisect worker writing into the workdir.
mkdir -p "${VMAFTUNE_WORKDIR:-/probes/vmaftune-work}" 2>/dev/null || true
chown vmaf:vmaf "${VMAFTUNE_WORKDIR:-/probes/vmaftune-work}" 2>/dev/null || true

LOG_FILE="${VMAF_MCP_LOG:-/tmp/vmaf-mcp.log}"
MODEL_PATH="${VMAF_MODEL_PATH:-/workspace/model}"
Expand Down
7 changes: 5 additions & 2 deletions mcp-server/vmaf-mcp/src/vmaf_mcp/http_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ async def _handle_score(request: Any, metrics: dict[str, Any]) -> Any:
inside ``request.json()``).
"""
aiohttp = _require_aiohttp()
from vmaf_mcp.server import ScoreRequest, _run_vmaf_score, _validate_path
from vmaf_mcp.server import ScoreRequest, _dumps_strict, _run_vmaf_score, _validate_path

request_id = str(uuid.uuid4())[:8]
t0 = time.monotonic()
Expand Down Expand Up @@ -554,10 +554,13 @@ async def _handle_score(request: Any, metrics: dict[str, Any]) -> Any:
_log_with_rid(logging.INFO, f"POST /v1/score done in {elapsed:.0f}ms", request_id)
metrics["scoring_requests_total"].labels(endpoint="/v1/score", status="200").inc()
result["request_id"] = request_id
# Use _dumps_strict (NaN/Infinity → null) to produce RFC 8259-compliant
# JSON; bare json.dumps() with allow_nan=True emits bare NaN/Infinity
# tokens which are not valid JSON per RFC 8259.
return aiohttp.web.Response(
status=200,
content_type="application/json",
text=json.dumps(result),
text=_dumps_strict(result),
)


Expand Down
9 changes: 9 additions & 0 deletions mcp-server/vmaf-mcp/src/vmaf_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,15 @@ def _describe_model(name_or_path: str) -> dict[str, Any]:
candidate = repo / candidate
candidate = candidate.resolve()
if candidate.is_file() and candidate.suffix.lower() in _MODEL_EXTENSIONS:
# Explicit allowlist guard — mirrors _validate_path() to make the
# security invariant unconditional rather than relying on the model/
# directory being under an allowlisted root by construction.
allowed = _allowed_roots()
if not any(candidate.is_relative_to(r) for r in allowed):
raise ValueError(
f"model path {candidate} not under an allowlisted root; "
"set VMAF_MCP_ALLOW to extend."
)
return _describe_model_file(candidate, repo)

# --- Step 2: search by filename match (full name, no extension) ---
Expand Down
Loading
Loading