From c9ebab2632285fbb72f4477ebd0aa37c8ef1016a Mon Sep 17 00:00:00 2001 From: adrianrfreedman Date: Thu, 17 Sep 2026 20:48:39 +0300 Subject: [PATCH 1/9] Release the GIL while opening and closing an output container av_interleaved_write_frame() already released it, but the calls either side held it: avio_open() and avformat_write_header() in start_encoding(), av_write_trailer() and avio_closep() in close_output(). Opening a stream to an unreachable URL stopped every other Python thread. Safe with custom Python I/O, since pyio_read, pyio_write, and pyio_seek are nogil and re-acquire the GIL themselves. --- av/container/output.py | 50 ++++++++++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/av/container/output.py b/av/container/output.py index 44f4990eb..9c640c34b 100644 --- a/av/container/output.py +++ b/av/container/output.py @@ -60,13 +60,17 @@ def close_output(self: OutputContainer) -> cython.void: # We must only ever call av_write_trailer *once*, otherwise we get a # segmentation fault. Therefore no matter whether it succeeds or not # we must absolutely set enum.done. + ret: cython.int try: - self.err_check(lib.av_write_trailer(self.ptr)) + with cython.nogil: + ret = lib.av_write_trailer(self.ptr) + self.err_check(ret) finally: if self.file is None and not ( self.ptr.oformat.flags & lib.AVFMT_NOFILE ): - lib.avio_closep(cython.address(self.ptr.pb)) + with cython.nogil: + lib.avio_closep(cython.address(self.ptr.pb)) self._myflag |= 8 # enum.done = True finally: # Drop the context so a closed output reports itself as closed: @@ -591,17 +595,41 @@ def start_encoding(self): # Open the output file, if needed. name_obj: bytes = os.fsencode(self.name if self.file is None else "") name: cython.p_char = name_obj - if self.ptr.pb == cython.NULL and not self.ptr.oformat.flags & lib.AVFMT_NOFILE: - err_check( - lib.avio_open(cython.address(self.ptr.pb), name, lib.AVIO_FLAG_WRITE) - ) + ret: cython.int + all_options: Dictionary + options: Dictionary + options_ptr: cython.pointer[cython.pointer[lib.AVDictionary]] - # Copy the metadata dict. - dict_to_avdict(cython.address(self.ptr.metadata), self.metadata) + self.set_timeout(self.open_timeout) + self.start_timeout() + try: + if ( + self.ptr.pb == cython.NULL + and not self.ptr.oformat.flags & lib.AVFMT_NOFILE + ): + # avio_open() would pass the protocol a NULL interrupt + # callback, so a stalled connect could never be timed out. + with cython.nogil: + ret = lib.avio_open2( + cython.address(self.ptr.pb), + name, + lib.AVIO_FLAG_WRITE, + cython.address(self.ptr.interrupt_callback), + cython.NULL, + ) + err_check(ret) + + # Copy the metadata dict. + dict_to_avdict(cython.address(self.ptr.metadata), self.metadata) - all_options: Dictionary = Dictionary(self.options, self.container_options) - options: Dictionary = all_options.copy() - self.err_check(lib.avformat_write_header(self.ptr, cython.address(options.ptr))) + all_options = Dictionary(self.options, self.container_options) + options = all_options.copy() + options_ptr = cython.address(options.ptr) + with cython.nogil: + ret = lib.avformat_write_header(self.ptr, options_ptr) + self.err_check(ret) + finally: + self.set_timeout(None) # Track option usage... for k in all_options: From d5c1deca1548359081028ea001aea3f3eb66505b Mon Sep 17 00:00:00 2001 From: adrianrfreedman Date: Thu, 17 Sep 2026 20:48:39 +0300 Subject: [PATCH 2/9] Honour timeout when opening an output container The interrupt callback was only installed for demuxing, so av.open(url, "w", timeout=3.0) ignored the argument and a stalled connect never ended. Install it for both branches and arm it around the open and the header write, as InputContainer does around avformat_open_input(). avio_open() takes no interrupt callback, so start_encoding() now calls avio_open2() and passes the context's own callback down. The callback is disarmed as it is installed, because its deadline starts zeroed and zero reads as already expired. This covers network URLs, not a custom Python file object whose own write() blocks. The callback is only consulted inside FFmpeg. --- av/container/core.py | 18 +++--- include/avformat.pxd | 4 ++ tests/test_output_blocking.py | 108 ++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 tests/test_output_blocking.py diff --git a/av/container/core.py b/av/container/core.py index fe756a159..193b096a2 100755 --- a/av/container/core.py +++ b/av/container/core.py @@ -292,16 +292,20 @@ def __cinit__( # We need the context before we open the input AND setup Python IO. self.ptr = lib.avformat_alloc_context() - # Setup interrupt callback - if self.open_timeout is not None or self.read_timeout is not None: - self.ptr.interrupt_callback.callback = interrupt_cb - self.ptr.interrupt_callback.opaque = cython.address( - self.interrupt_callback_info - ) - if acodec is not None: self.ptr.audio_codec_id = getattr(AudioCodec, acodec) + # Setup interrupt callback. Muxing needs it as much as demuxing does, + # since writing the header to a network URL can block indefinitely. + if self.open_timeout is not None or self.read_timeout is not None: + # Start disarmed, so nothing between here and the first + # start_timeout() can be interrupted by a zeroed deadline. + self.set_timeout(None) + self.ptr.interrupt_callback.callback = interrupt_cb + self.ptr.interrupt_callback.opaque = cython.address( + self.interrupt_callback_info + ) + self.ptr.flags |= lib.AVFMT_FLAG_GENPTS self.ptr.opaque = cython.cast(cython.p_void, self) diff --git a/include/avformat.pxd b/include/avformat.pxd index 4a4181b4d..318205433 100644 --- a/include/avformat.pxd +++ b/include/avformat.pxd @@ -179,6 +179,10 @@ cdef extern from "libavformat/avformat.h" nogil: cdef int av_interleaved_write_frame(AVFormatContext *ctx, AVPacket *pkt) cdef int av_write_frame(AVFormatContext *ctx, AVPacket *pkt) cdef int avio_open(AVIOContext **s, const char *url, int flags) + cdef int avio_open2( + AVIOContext **s, const char *url, int flags, + const AVIOInterruptCB *int_cb, AVDictionary **options + ) cdef int64_t avio_size(AVIOContext *s) cdef const AVOutputFormat* av_guess_format( const char *short_name, const char *filename, const char *mime_type diff --git a/tests/test_output_blocking.py b/tests/test_output_blocking.py new file mode 100644 index 000000000..cb78bcbfb --- /dev/null +++ b/tests/test_output_blocking.py @@ -0,0 +1,108 @@ +import socket +import threading +import time + +import pytest + +import av + +from .common import TestCase + +# The main thread sleeps in 1 ms slices while another thread is stuck in the +# RTMP handshake. It wakes roughly a thousand times if the GIL is free and a +# handful of times if it is not, so this threshold sits well clear of both. +WINDOW = 1.0 +MIN_TICKS = 100 + + +class SilentServer: + """Accepts connections and then says nothing, so the handshake never ends.""" + + def __init__(self) -> None: + self.sock = socket.socket() + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.sock.bind(("127.0.0.1", 0)) + self.sock.listen(4) + self.port: int = self.sock.getsockname()[1] + self.accepted: list[socket.socket] = [] + self.thread = threading.Thread(target=self._accept, daemon=True) + self.thread.start() + + def _accept(self) -> None: + while True: + try: + conn, _ = self.sock.accept() + except OSError: + return + self.accepted.append(conn) + + def close(self) -> None: + self.sock.close() + for conn in self.accepted: + conn.close() + + +def has_rtmp() -> bool: + """Whether FFmpeg was built with the RTMP protocol. + + Port 1 on loopback refuses at once, so the probe either fails looking the + protocol up, before any connect, or fails connecting. + """ + try: + av.open("rtmp://127.0.0.1:1/x", "w", format="flv").start_encoding() + except av.error.ProtocolNotFoundError: + return False + except Exception: + pass + return True + + +@pytest.mark.skipif(not has_rtmp(), reason="FFmpeg was built without RTMP") +class TestOutputBlocking(TestCase): + def setUp(self) -> None: + self.server = SilentServer() + + def tearDown(self) -> None: + self.server.close() + + def _push(self, timeout: float) -> tuple[threading.Thread, list[BaseException]]: + raised: list[BaseException] = [] + + def run() -> None: + try: + container = av.open( + f"rtmp://127.0.0.1:{self.server.port}/live/x", + "w", + format="flv", + timeout=timeout, + ) + stream = container.add_stream("h264", rate=30) + stream.width = 320 + stream.height = 240 + stream.pix_fmt = "yuv420p" + container.start_encoding() + except BaseException as e: + raised.append(e) + + thread = threading.Thread(target=run, daemon=True) + thread.start() + return thread, raised + + def test_start_encoding_releases_the_gil(self) -> None: + thread, _ = self._push(WINDOW * 8) + + ticks = 0 + deadline = time.monotonic() + WINDOW + while time.monotonic() < deadline: + ticks += 1 + time.sleep(0.001) + + assert thread.is_alive(), "the handshake completed, so nothing was blocking" + assert ticks > MIN_TICKS, f"main thread only ran {ticks} times" + thread.join(WINDOW * 16) + + def test_start_encoding_honours_the_timeout(self) -> None: + thread, raised = self._push(WINDOW) + thread.join(WINDOW * 16) + assert not thread.is_alive(), "timeout did not interrupt the handshake" + assert raised, "the handshake returned instead of timing out" From f4f98d0cb0185123b0bc5781fd71fee38feec88b Mon Sep 17 00:00:00 2001 From: adrianrfreedman Date: Thu, 17 Sep 2026 20:48:39 +0300 Subject: [PATCH 3/9] Add the changelog entry --- CHANGELOG.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e68481872..cbd346233 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -65,6 +65,7 @@ Fixes: - Fix crashes from indexes that were turned into C pointer arithmetic without being range checked. ``MotionVectors[i]`` only checked the upper bound, so a negative index read off the front of the buffer (``mvs[-1]`` now returns the last vector, as with any sequence); ``VideoFormatComponent`` and ``AudioPlane`` accepted any index at all; and ``BitmapSubtitlePlane`` and ``VideoBlockParams`` were missing their lower bounds. - Frames returned by flushing a codec context directly (``CodecContext.decode()`` with no packet) now carry the stream's ``time_base`` instead of ``None``. - ``VideoFrame.reformat()`` (and so ``to_ndarray(format=...)``, ``to_rgb()``, ``to_image()``) now shares one ``SwsContext`` per thread instead of allocating one per frame. FFmpeg 8's swscale retains megabytes of graph state per context, which showed up as large RSS growth when many frames were alive at once. +- Writing to a network URL no longer blocks every other Python thread, and no longer ignores ``timeout``. ``avio_open()``, ``avformat_write_header()``, ``av_write_trailer()``, and ``avio_closep()`` held the GIL, so an unreachable RTMP server froze the whole process, and the interrupt callback was only installed for demuxing, so nothing could end the wait. By :gh-user:`adrianrfreedman` in (:pr:`2412`). 18.X and Below From a6d519e754075321f406810ade80a31c5f7de83d Mon Sep 17 00:00:00 2001 From: Adrian Freedman Date: Wed, 23 Sep 2026 11:56:00 +0300 Subject: [PATCH 4/9] Refuse to close an output container another thread is writing to Releasing the GIL made a cross-thread close() the obvious way to give up on a stuck connect, and it freed the context out from under the blocked thread. Two concurrent close() calls raced the same way. Flag the container while it is inside libav without the GIL, and raise from close() rather than free it. The RTMP probe also now closes the container it opens. --- av/container/core.pxd | 2 +- av/container/output.py | 11 +++++++++++ tests/test_output_blocking.py | 24 ++++++++++++++++++++++-- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/av/container/core.pxd b/av/container/core.pxd index e385163cb..e2972f3d7 100644 --- a/av/container/core.pxd +++ b/av/container/core.pxd @@ -32,7 +32,7 @@ cdef class Container: cdef timeout_info interrupt_callback_info cdef int buffer_size - cdef uint8_t _myflag # enum: writeable, input_was_opened, started, done, extradata_planned + cdef uint8_t _myflag # enum: writeable, input_was_opened, started, done, extradata_planned, blocking cdef void _assert_open(self) cdef void set_timeout(self, object) diff --git a/av/container/output.py b/av/container/output.py index 9c640c34b..ee5aa5494 100644 --- a/av/container/output.py +++ b/av/container/output.py @@ -49,6 +49,7 @@ def close_output(self: OutputContainer) -> cython.void: self._mux_one(packet) self.streams = StreamContainer() + self._myflag |= 32 # enum.blocking = True try: if self._myflag & 12 == 4: # enum.started and not enum.done # If the underlying Python IO file was already closed (e.g. during @@ -80,6 +81,7 @@ def close_output(self: OutputContainer) -> cython.void: with cython.nogil: lib.avformat_free_context(self.ptr) self.ptr = cython.NULL + self._myflag = self._myflag & ~32 # enum.blocking = False @cython.final @@ -602,6 +604,7 @@ def start_encoding(self): self.set_timeout(self.open_timeout) self.start_timeout() + self._myflag |= 32 # enum.blocking = True try: if ( self.ptr.pb == cython.NULL @@ -629,6 +632,7 @@ def start_encoding(self): ret = lib.avformat_write_header(self.ptr, options_ptr) self.err_check(ret) finally: + self._myflag = self._myflag & ~32 # enum.blocking = False self.set_timeout(None) # Track option usage... @@ -696,6 +700,13 @@ def default_subtitle_codec(self): return lib.avcodec_get_name(self.format.optr.subtitle_codec) def close(self): + if self._myflag & 32: # enum.blocking + # Another thread is inside libav without the GIL, so freeing the + # context here would be a use-after-free. Pass ``timeout`` to + # :func:`av.open` to give up on an open that never connects. + raise RuntimeError( + "Cannot close an OutputContainer while another thread is writing to it" + ) close_output(self) def mux(self, packets): diff --git a/tests/test_output_blocking.py b/tests/test_output_blocking.py index cb78bcbfb..aa3d9e759 100644 --- a/tests/test_output_blocking.py +++ b/tests/test_output_blocking.py @@ -49,7 +49,8 @@ def has_rtmp() -> bool: protocol up, before any connect, or fails connecting. """ try: - av.open("rtmp://127.0.0.1:1/x", "w", format="flv").start_encoding() + with av.open("rtmp://127.0.0.1:1/x", "w", format="flv") as container: + container.start_encoding() except av.error.ProtocolNotFoundError: return False except Exception: @@ -65,7 +66,9 @@ def setUp(self) -> None: def tearDown(self) -> None: self.server.close() - def _push(self, timeout: float) -> tuple[threading.Thread, list[BaseException]]: + def _push( + self, timeout: float, containers: list | None = None + ) -> tuple[threading.Thread, list[BaseException]]: raised: list[BaseException] = [] def run() -> None: @@ -76,6 +79,8 @@ def run() -> None: format="flv", timeout=timeout, ) + if containers is not None: + containers.append(container) stream = container.add_stream("h264", rate=30) stream.width = 320 stream.height = 240 @@ -106,3 +111,18 @@ def test_start_encoding_honours_the_timeout(self) -> None: thread.join(WINDOW * 16) assert not thread.is_alive(), "timeout did not interrupt the handshake" assert raised, "the handshake returned instead of timing out" + + def test_close_refuses_to_free_a_container_in_use(self) -> None: + containers: list[av.container.OutputContainer] = [] + thread, _ = self._push(WINDOW * 4, containers) + + # The server only accepts once the writing thread is inside the + # connect, which is where the context stops being ours to free. + deadline = time.monotonic() + WINDOW + while not self.server.accepted and time.monotonic() < deadline: + time.sleep(0.001) + assert self.server.accepted, "the writing thread never connected" + + with pytest.raises(RuntimeError, match="another thread"): + containers[0].close() + thread.join(WINDOW * 16) From aaeefc636fce228750fcfbbc69793a70722d5648 Mon Sep 17 00:00:00 2001 From: Adrian Freedman Date: Wed, 23 Sep 2026 11:56:00 +0300 Subject: [PATCH 5/9] Say what the open timeout covers when writing It covers both connecting and writing the header, and it is the only supported way to cancel a stuck output open. Muxing and closing still ignore it. --- CHANGELOG.rst | 2 +- av/container/core.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cbd346233..a0cbbd40c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -65,7 +65,7 @@ Fixes: - Fix crashes from indexes that were turned into C pointer arithmetic without being range checked. ``MotionVectors[i]`` only checked the upper bound, so a negative index read off the front of the buffer (``mvs[-1]`` now returns the last vector, as with any sequence); ``VideoFormatComponent`` and ``AudioPlane`` accepted any index at all; and ``BitmapSubtitlePlane`` and ``VideoBlockParams`` were missing their lower bounds. - Frames returned by flushing a codec context directly (``CodecContext.decode()`` with no packet) now carry the stream's ``time_base`` instead of ``None``. - ``VideoFrame.reformat()`` (and so ``to_ndarray(format=...)``, ``to_rgb()``, ``to_image()``) now shares one ``SwsContext`` per thread instead of allocating one per frame. FFmpeg 8's swscale retains megabytes of graph state per context, which showed up as large RSS growth when many frames were alive at once. -- Writing to a network URL no longer blocks every other Python thread, and no longer ignores ``timeout``. ``avio_open()``, ``avformat_write_header()``, ``av_write_trailer()``, and ``avio_closep()`` held the GIL, so an unreachable RTMP server froze the whole process, and the interrupt callback was only installed for demuxing, so nothing could end the wait. By :gh-user:`adrianrfreedman` in (:pr:`2412`). +- Writing to a network URL no longer blocks every other Python thread, and ``timeout`` now applies to opening an output container. ``avio_open()``, ``avformat_write_header()``, ``av_write_trailer()``, and ``avio_closep()`` held the GIL, so an unreachable RTMP server froze the whole process, and the interrupt callback was only installed for demuxing, so nothing could end the wait. Muxing and closing still ignore ``timeout``, and :meth:`.OutputContainer.close` now raises rather than freeing a context another thread is still writing to. By :gh-user:`adrianrfreedman` in (:pr:`2412`). 18.X and Below diff --git a/av/container/core.py b/av/container/core.py index 193b096a2..18fde4ee9 100755 --- a/av/container/core.py +++ b/av/container/core.py @@ -499,7 +499,11 @@ def open( :param int buffer_size: Size of buffer for Python input/output operations in bytes. Honored only when ``file`` is a file-like object. Defaults to 32768 (32k). :param timeout: How many seconds to wait for data before giving up, as a float, or a - ``(open timeout, read timeout)`` tuple. + ``(open timeout, read timeout)`` tuple. The open timeout covers both connecting + and reading or writing the header. Writing honours it only while opening, so it + is the supported way to give up on an output that never connects. Muxing and + closing still block indefinitely, and calling :meth:`.OutputContainer.close` + from another thread to break out of them raises instead. :param callable io_open: Custom I/O callable for opening files/streams. This option is intended for formats that need to open additional file-like objects to ``file`` using custom I/O. From 38102747ae8de9f66a1eba78cad6f92f3c601b03 Mon Sep 17 00:00:00 2001 From: Adrian Freedman Date: Wed, 23 Sep 2026 12:03:00 +0300 Subject: [PATCH 6/9] Clear the blocking flag the way input.py clears its own --- av/container/output.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/av/container/output.py b/av/container/output.py index ee5aa5494..dafa218d2 100644 --- a/av/container/output.py +++ b/av/container/output.py @@ -81,7 +81,7 @@ def close_output(self: OutputContainer) -> cython.void: with cython.nogil: lib.avformat_free_context(self.ptr) self.ptr = cython.NULL - self._myflag = self._myflag & ~32 # enum.blocking = False + self._myflag &= ~32 # enum.blocking = False @cython.final @@ -632,7 +632,7 @@ def start_encoding(self): ret = lib.avformat_write_header(self.ptr, options_ptr) self.err_check(ret) finally: - self._myflag = self._myflag & ~32 # enum.blocking = False + self._myflag &= ~32 # enum.blocking = False self.set_timeout(None) # Track option usage... From 30e073a21dd059264d442b1e468c15590a44dd2e Mon Sep 17 00:00:00 2001 From: Adrian Freedman Date: Wed, 23 Sep 2026 17:35:07 +0300 Subject: [PATCH 7/9] Cover muxing with the guard, and count instead of flagging av_interleaved_write_frame() releases the GIL too, so a close() from another thread could free the context under a writer. close_output() flushed its buffered packets through the same path before it raised the flag, so that was uncovered as well. A single bit cannot survive the nesting that allows, so it is a depth counter now. --- CHANGELOG.rst | 2 +- av/container/core.pxd | 2 +- av/container/output.pxd | 3 +++ av/container/output.py | 19 ++++++++++++------- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a0cbbd40c..1aff9c5bd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -65,7 +65,7 @@ Fixes: - Fix crashes from indexes that were turned into C pointer arithmetic without being range checked. ``MotionVectors[i]`` only checked the upper bound, so a negative index read off the front of the buffer (``mvs[-1]`` now returns the last vector, as with any sequence); ``VideoFormatComponent`` and ``AudioPlane`` accepted any index at all; and ``BitmapSubtitlePlane`` and ``VideoBlockParams`` were missing their lower bounds. - Frames returned by flushing a codec context directly (``CodecContext.decode()`` with no packet) now carry the stream's ``time_base`` instead of ``None``. - ``VideoFrame.reformat()`` (and so ``to_ndarray(format=...)``, ``to_rgb()``, ``to_image()``) now shares one ``SwsContext`` per thread instead of allocating one per frame. FFmpeg 8's swscale retains megabytes of graph state per context, which showed up as large RSS growth when many frames were alive at once. -- Writing to a network URL no longer blocks every other Python thread, and ``timeout`` now applies to opening an output container. ``avio_open()``, ``avformat_write_header()``, ``av_write_trailer()``, and ``avio_closep()`` held the GIL, so an unreachable RTMP server froze the whole process, and the interrupt callback was only installed for demuxing, so nothing could end the wait. Muxing and closing still ignore ``timeout``, and :meth:`.OutputContainer.close` now raises rather than freeing a context another thread is still writing to. By :gh-user:`adrianrfreedman` in (:pr:`2412`). +- Writing to a network URL no longer blocks every other Python thread, and ``timeout`` now applies to opening an output container. ``avio_open()``, ``avformat_write_header()``, ``av_write_trailer()``, and ``avio_closep()`` held the GIL, so an unreachable RTMP server froze the whole process, and the interrupt callback was only installed for demuxing, so nothing could end the wait. Muxing and closing still ignore ``timeout``, and Muxing and closing still ignore ``timeout``, and :meth:`.OutputContainer.close` now raises rather than freeing a context another thread is still muxing or closing. By :gh-user:`adrianrfreedman` in (:pr:`2412`). 18.X and Below diff --git a/av/container/core.pxd b/av/container/core.pxd index e2972f3d7..e385163cb 100644 --- a/av/container/core.pxd +++ b/av/container/core.pxd @@ -32,7 +32,7 @@ cdef class Container: cdef timeout_info interrupt_callback_info cdef int buffer_size - cdef uint8_t _myflag # enum: writeable, input_was_opened, started, done, extradata_planned, blocking + cdef uint8_t _myflag # enum: writeable, input_was_opened, started, done, extradata_planned cdef void _assert_open(self) cdef void set_timeout(self, object) diff --git a/av/container/output.pxd b/av/container/output.pxd index fc98c8828..41150f27b 100644 --- a/av/container/output.pxd +++ b/av/container/output.pxd @@ -7,6 +7,9 @@ from av.stream cimport Stream cdef class OutputContainer(Container): cdef lib.AVPacket *packet_ptr + # How many nogil libav calls are in flight, so close() can refuse + # to free the context while another thread is still inside one. + cdef int _blocking_depth cdef dict _extradata_bsfs cdef list[Packet] _buffered_packets cdef _buffer_for_extradata(self, Packet packet) diff --git a/av/container/output.py b/av/container/output.py index dafa218d2..d17f9301b 100644 --- a/av/container/output.py +++ b/av/container/output.py @@ -49,7 +49,7 @@ def close_output(self: OutputContainer) -> cython.void: self._mux_one(packet) self.streams = StreamContainer() - self._myflag |= 32 # enum.blocking = True + self._blocking_depth += 1 try: if self._myflag & 12 == 4: # enum.started and not enum.done # If the underlying Python IO file was already closed (e.g. during @@ -81,7 +81,7 @@ def close_output(self: OutputContainer) -> cython.void: with cython.nogil: lib.avformat_free_context(self.ptr) self.ptr = cython.NULL - self._myflag &= ~32 # enum.blocking = False + self._blocking_depth -= 1 @cython.final @@ -604,7 +604,7 @@ def start_encoding(self): self.set_timeout(self.open_timeout) self.start_timeout() - self._myflag |= 32 # enum.blocking = True + self._blocking_depth += 1 try: if ( self.ptr.pb == cython.NULL @@ -632,7 +632,7 @@ def start_encoding(self): ret = lib.avformat_write_header(self.ptr, options_ptr) self.err_check(ret) finally: - self._myflag &= ~32 # enum.blocking = False + self._blocking_depth -= 1 self.set_timeout(None) # Track option usage... @@ -700,7 +700,7 @@ def default_subtitle_codec(self): return lib.avcodec_get_name(self.format.optr.subtitle_codec) def close(self): - if self._myflag & 32: # enum.blocking + if self._blocking_depth: # Another thread is inside libav without the GIL, so freeing the # context here would be a use-after-free. Pass ``timeout`` to # :func:`av.open` to give up on an open that never connects. @@ -743,8 +743,13 @@ def _mux_one(self, packet: Packet) -> cython.void: # takes ownership of the reference. self.err_check(lib.av_packet_ref(self.packet_ptr, packet.ptr)) - with cython.nogil: - ret: cython.int = lib.av_interleaved_write_frame(self.ptr, self.packet_ptr) + ret: cython.int + self._blocking_depth += 1 + try: + with cython.nogil: + ret = lib.av_interleaved_write_frame(self.ptr, self.packet_ptr) + finally: + self._blocking_depth -= 1 self.err_check(ret) @cython.cfunc From 69689aa604fd963a41418f71dc462ba72cfdb1d9 Mon Sep 17 00:00:00 2001 From: Adrian Freedman Date: Wed, 23 Sep 2026 17:35:18 +0300 Subject: [PATCH 8/9] Close pb when the header write fails avio_open2() succeeding and avformat_write_header() failing left the connection open: started is never set, so close_output() skips avio_closep(), and avformat_free_context() does not close it either. That was always true, but a header write that times out makes it an expected path rather than a rare one. --- av/container/output.py | 13 ++++++++++++- tests/test_output_blocking.py | 26 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/av/container/output.py b/av/container/output.py index d17f9301b..44b75dac2 100644 --- a/av/container/output.py +++ b/av/container/output.py @@ -598,6 +598,7 @@ def start_encoding(self): name_obj: bytes = os.fsencode(self.name if self.file is None else "") name: cython.p_char = name_obj ret: cython.int + opened_pb: cython.bint = False all_options: Dictionary options: Dictionary options_ptr: cython.pointer[cython.pointer[lib.AVDictionary]] @@ -621,6 +622,7 @@ def start_encoding(self): cython.NULL, ) err_check(ret) + opened_pb = True # Copy the metadata dict. dict_to_avdict(cython.address(self.ptr.metadata), self.metadata) @@ -630,7 +632,16 @@ def start_encoding(self): options_ptr = cython.address(options.ptr) with cython.nogil: ret = lib.avformat_write_header(self.ptr, options_ptr) - self.err_check(ret) + try: + self.err_check(ret) + except Exception: + # started is never set, so close_output() will not close pb. + # Nothing else will either, and a stalled header write is an + # expected path now that it can time out. + if opened_pb: + with cython.nogil: + lib.avio_closep(cython.address(self.ptr.pb)) + raise finally: self._blocking_depth -= 1 self.set_timeout(None) diff --git a/tests/test_output_blocking.py b/tests/test_output_blocking.py index aa3d9e759..c0584eb63 100644 --- a/tests/test_output_blocking.py +++ b/tests/test_output_blocking.py @@ -126,3 +126,29 @@ def test_close_refuses_to_free_a_container_in_use(self) -> None: with pytest.raises(RuntimeError, match="another thread"): containers[0].close() thread.join(WINDOW * 16) + + +class TestFailedHeaderWrite(TestCase): + def test_a_failed_header_write_closes_the_connection(self) -> None: + """mp4 cannot carry PCM, so the muxer rejects it after the connect.""" + server = SilentServer() + self.addCleanup(server.close) + + container = av.open(f"tcp://127.0.0.1:{server.port}", "w", format="mp4") + container.add_stream("pcm_s16le") + with pytest.raises(av.error.ArgumentError): + container.start_encoding() + + deadline = time.monotonic() + WINDOW + while not server.accepted and time.monotonic() < deadline: + time.sleep(0.001) + assert server.accepted, "the writer never connected" + + # started was never set, so nothing downstream would close pb. + conn = server.accepted[0] + conn.settimeout(WINDOW) + try: + while conn.recv(4096): + pass # Drain whatever the muxer wrote before it gave up. + except TimeoutError: + raise AssertionError("the connection was left open") from None From 54d0a266ed7b106167425332820bab80c55c61da Mon Sep 17 00:00:00 2001 From: Adrian Freedman Date: Wed, 23 Sep 2026 17:35:18 +0300 Subject: [PATCH 9/9] Stop the blocking tests idling for thirteen seconds Each thread ran its whole timeout out before the test could end. Three seconds and two are as good as eight and four here. The RTMP probe also takes a timeout now, so a sandbox that drops the connection rather than refusing it cannot hang collection. --- tests/test_output_blocking.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_output_blocking.py b/tests/test_output_blocking.py index c0584eb63..26ac6db5e 100644 --- a/tests/test_output_blocking.py +++ b/tests/test_output_blocking.py @@ -49,7 +49,7 @@ def has_rtmp() -> bool: protocol up, before any connect, or fails connecting. """ try: - with av.open("rtmp://127.0.0.1:1/x", "w", format="flv") as container: + with av.open("rtmp://127.0.0.1:1/x", "w", format="flv", timeout=1) as container: container.start_encoding() except av.error.ProtocolNotFoundError: return False @@ -94,7 +94,7 @@ def run() -> None: return thread, raised def test_start_encoding_releases_the_gil(self) -> None: - thread, _ = self._push(WINDOW * 8) + thread, _ = self._push(WINDOW * 3) ticks = 0 deadline = time.monotonic() + WINDOW @@ -104,17 +104,17 @@ def test_start_encoding_releases_the_gil(self) -> None: assert thread.is_alive(), "the handshake completed, so nothing was blocking" assert ticks > MIN_TICKS, f"main thread only ran {ticks} times" - thread.join(WINDOW * 16) + thread.join(WINDOW * 8) def test_start_encoding_honours_the_timeout(self) -> None: thread, raised = self._push(WINDOW) - thread.join(WINDOW * 16) + thread.join(WINDOW * 8) assert not thread.is_alive(), "timeout did not interrupt the handshake" assert raised, "the handshake returned instead of timing out" def test_close_refuses_to_free_a_container_in_use(self) -> None: containers: list[av.container.OutputContainer] = [] - thread, _ = self._push(WINDOW * 4, containers) + thread, _ = self._push(WINDOW * 2, containers) # The server only accepts once the writing thread is inside the # connect, which is where the context stops being ours to free. @@ -125,7 +125,7 @@ def test_close_refuses_to_free_a_container_in_use(self) -> None: with pytest.raises(RuntimeError, match="another thread"): containers[0].close() - thread.join(WINDOW * 16) + thread.join(WINDOW * 8) class TestFailedHeaderWrite(TestCase):