Repository navigation
Description
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
romanlutz commentedon Nov 2, 2026
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.