Skip to content

BUG: Cancelling video frame extraction leaves unreferenced temporary images #3700

Closed

Description

@romanlutz

Describe the bug

Cancelling video scoring while frame extraction runs leaves temporary frame files that are never scored or registered in memory.

VideoHelper._score_frames_async awaits asyncio.to_thread(self._extract_frames, video_path) at pyrit/score/video_scorer.py:172. Cancellation ends that await, but the thread keeps running. _extract_frames creates files with delete=False at lines 248-258. The cancelled caller never receives the returned paths and does not clean them up. Both VideoTrueFalseScorer and VideoFloatScaleScorer use this helper.

Reproduced on freshly fetched main at d3446e045960b5ebee4641aa70fdeb03af186587 on October 31, 2026. Focused searches found no dedicated issue or open correction. The same helper's audio extraction already handles cancellation cleanup at lines 263-285. #3359 introduced the threaded frame extraction; #3358 concerned the original event-loop blocking issue.

Steps/Code to Reproduce

This exercises the real frame-extraction and async-helper code with synthetic OpenCV to control cancellation. No OpenCV installation, external video, provider or database is needed. The writer pauses after the first file; cancellation happens before the second file is written. All outputs stay in a disposable directory.

import asyncio
import functools
import sys
import tempfile
import threading
import types
from pathlib import Path
from unittest.mock import MagicMock, patch

from pyrit.models import MessagePiece, ScoringExpectation
from pyrit.score.scorer import Scorer
from pyrit.score.video_scorer import VideoHelper

async def main_async():
    loop = asyncio.get_running_loop()
    started, finished = asyncio.Event(), asyncio.Event()
    proceed = threading.Event()
    written = []
    cv2 = types.ModuleType("cv2")
    cv2.CAP_PROP_FRAME_COUNT = 1
    cv2.CAP_PROP_POS_FRAMES = 2

    class Capture:
        def __init__(self, path):
            pass
        def get(self, property_id):
            return 2
        def set(self, property_id, value):
            pass
        def read(self):
            return True, object()
        def release(self):
            loop.call_soon_threadsafe(finished.set)

    def write(path, frame):
        Path(path).write_bytes(b"synthetic-frame")
        written.append(Path(path))
        if len(written) == 1:
            loop.call_soon_threadsafe(started.set)
            if not proceed.wait(30):
                raise TimeoutError("Worker rendezvous timed out")
        return True

    cv2.VideoCapture = Capture
    cv2.imencode = lambda extension, frame: (True, b"synthetic")
    cv2.imwrite = write
    scorer = MagicMock(spec=Scorer)
    scorer._select_expectation.return_value = ScoringExpectation()
    helper = VideoHelper(image_capable_scorer=scorer, num_sampled_frames=2)
    original_tempfile = tempfile.NamedTemporaryFile
    with tempfile.TemporaryDirectory() as directory:
        root = Path(directory)
        video = root / "synthetic.mp4"
        video.touch()
        piece = MessagePiece(
            role="assistant", original_value=str(video), original_value_data_type="video_path",
        )
        with patch.dict(sys.modules, {"cv2": cv2}), patch(
            "pyrit.score.video_scorer.tempfile.NamedTemporaryFile",
            functools.partial(original_tempfile, dir=root),
        ):
            task = asyncio.create_task(helper._score_frames_async(
                message_piece=piece, expectation=None,
            ))
            try:
                await asyncio.wait_for(started.wait(), 30)
                task.cancel()
                try:
                    await task
                except asyncio.CancelledError:
                    print("cancelled_before_worker_finished:", not finished.is_set())
                print("files_at_cancellation:", len(list(root.glob("*_frame_*.png"))))
            finally:
                proceed.set()
                await asyncio.wait_for(finished.wait(), 30)
                await loop.shutdown_default_executor()
            outputs = list(root.glob("*_frame_*.png"))
            print("files_after_worker_finished:", len(outputs))
            print("frame_scorer_calls:", scorer._score_batch_nested_async.call_count)

asyncio.run(main_async())

Expected Results

After the extraction worker finishes handling a cancelled request, there should be no unreferenced frame files. Cancellation should retain ownership of worker outputs long enough to clean them up. Frames retained for successfully persisted scoring are outside this issue's scope.

Actual Results

cancelled_before_worker_finished: True
files_at_cancellation: 1
files_after_worker_finished: 2
frame_scorer_calls: 0

The enclosing test directory removes these outputs when the snippet exits. Without that test cleanup, the cancelled scoring call leaves them behind.

Suggested fix: keep track of the extraction worker through cancellation, reclaim its unpublished outputs and test cancellation before extraction completes, including repeated cancellation. Cover both scorer wrappers without deleting successfully persisted evidence. Real-codec timing and resource-exhaustion severity were not measured.

Screenshots

N/A.

Versions

Windows 11; Python 3.14.4; PyRIT 1.2.0.dev0, editable main at the SHA above. Browser: N/A. OpenCV is replaced by the synthetic module in this reproduction; real codec coverage was not run.

pyrit.show_versions() dependency output (local executable path omitted):

pyrit: 1.2.0.dev0
Cython: None
numpy: 2.4.6
openai: 2.54.0
packaging: 26.3
pip: None
scipy: 1.17.1
setuptools: 84.2.1
sqlite3: None
torch: None
transformers: 5.20.2

Activity

  1. romanlutz commented on Nov 2, 2026

    @romanlutz
    ContributorAuthor

    Ownership record: alexcastgonz (@pcbeingused333), whose display name is alexcastgonz, opened #3756 first and was the selected contributor for this issue. GitHub would not let me persist the assignment because they had not commented on the issue. #3756 has now merged and closed the issue.

    I compared and closed the later duplicate #3806. Its one useful extra assertion was noted on #3756 as possible follow-up coverage.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions