Stop output containers blocking the process on a stalled network URL - #2412
Conversation
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.
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.
192e36b to
f4f98d0
Compare
|
Thanks, this is a clear write-up and the core change looks right. Using One thing I'd like addressed before merging: Calling
Two concurrent Smaller things:
|
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.
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.
|
Ok, all four are addressed in aaeefc6.
The changelog now says "
I will do muxing and closing in a separate PR. |
|
Thanks, the guard is what I had in mind. I built it locally: the new tests pass and so does the full suite. Two more things before merging: The guard doesn't cover muxing, but the changelog says it does. A header write that fails or times out leaks Nits:
|
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.
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.
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.
|
Ok, all four are in. The guard now covers muxing. Setting it in a third place made the nesting real, so it is a depth counter rather than a bit. Nothing nests today, but an inner call clearing the bit would have told
The tests are down from about 13s to 6.2s. The two threads run for three seconds and two rather than eight and four. Muxing and closing honouring |
Fixes #2400.
Writing to a network URL wedges the whole process.
av.open(url, "w")holds the GIL across the connect and the handshake, so every other Python thread stops, and thetimeoutargument is ignored for output containers, so there is nothing to end the wait. On an unreachable RTMP server the interpreter never comes back.Two separate causes.
The GIL is held across the blocking calls
av_interleaved_write_framealready releases it, so muxing was fine, but the calls either side of it did not:avio_openstart_encoding, connects to the URLavformat_write_headerstart_encoding, writes the header over that connectionav_write_trailerclose_outputavio_closepclose_outputAll four are now wrapped in
with cython.nogil. This is safe for custom Python I/O as well, becausepyio_read,pyio_write, andpyio_seekare alreadynogiland re-acquire the GIL themselves. That is the same reasonav_interleaved_write_framecould be wrapped.timeoutnever reached an output containerThe interrupt callback was only installed on the demuxing branch of
Container.__cinit__, soav.open(url, "w", timeout=3.0)accepted the argument and did nothing with it. It is now installed for both, andstart_encodingarms it around the open and the header write the same wayInputContainerdoes aroundavformat_open_input.Installing it is not enough on its own.
avio_opentakes no interrupt callback, so the protocol got a NULL one and a stalled connect could not be interrupted.start_encodingnow callsavio_open2and passes the format context's callback down, which is what libavformat's ownio_open_defaultdoes.The callback is disarmed at install time. Its deadline lives in a zeroed struct, and a zeroed deadline reads as already expired, so anything blocking between the install and the first
start_timeout()would have aborted immediately.Reproducing
A socket that accepts the connection and then says nothing, so the RTMP handshake never completes. One thread opens the stream, the main thread counts 1 ms sleeps for a second:
timeout=1.0honouredBoth cases are in
tests/test_output_blocking.py. On main the first one does not fail, it hangs, which is the bug.What this does not cover
write()blocks. The interrupt callback is only consulted inside FFmpeg, so it cannot preempt Python code. This fixes network URLs, where FFmpeg owns the socket.timeout.av_interleaved_write_frameon a dead connection blocks the calling thread until TCP gives up, which is the other half of what the reporter saw. Arming the timer per packet is a behaviour change for every existing muxing user, so I have left it out. Happy to do it separately if you want it.avio_open2's options argument is NULL here, asavio_openhad no way to take one either. Passingoptions.ptrthrough it and letting the protocol consume what it recognises, the wayavformat_open_inputdoes, would be a sensible follow-up: protocol options for output URLs currently have no route in and end up in the unused-options warning.