Repository navigation
Fix concurrent native stderr capture wedging video source opens - #3014
Conversation
capture_process_stderr redirected the process-wide fd 2 into a pipe around each cv2.VideoCapture open with no synchronisation. When two sources opened concurrently the second saved a duplicate of the first's pipe as "the real stderr", so the first blocked forever reading a pipe that never reached EOF and fd 2 was left pointing at that pipe. The source created first stayed in INITIALISING with no error until the process restarted. The redirection is now a single reference-counted capture shared by every concurrent caller: the first entrant installs it, the last leaver restores fd 2, and the state lock is held only for the fd manipulation, never across an open. A background thread drains the pipe continuously, which also fixes opens blocking once a backend wrote more than the pipe capacity. Windows are delimited with FIONREAD instead of writing into the pipe, readiness uses poll() so descriptors above FD_SETSIZE work, descriptor ownership is released before closing, startup cleans up on BaseException, a closed stderr degrades to no capture, and forked children drop the inherited capture without taking per-instance locks. DISABLE_NATIVE_STDERR_CAPTURE turns the capture off, and capture_process_stderr.handles_concurrent_opens marks a build on which concurrent entry is safe.
Cover the interleaved-open deadlock, pipe-capacity writes, descriptors above FD_SETSIZE, interrupts during bookkeeping and thread start, descriptor-number reuse, a closed stderr, forks taken mid-capture and mid-teardown, per-participant windows, the opt-out and the capability marker. Every test runs on a bounded daemon thread or in a timed subprocess so a regression fails instead of hanging the suite.
|
👋 Thanks for the pull request! Here is how automated Claude review works here, so you spend credits (and reviewer time) wisely. 🚦 This PR is marked Ready for review, so automated Claude review will run — and every pass spends real credits. Warning 💸 The Claude reviewer bills in credits, not vibesAutomated review spins up a real agent that reads real code and spends real credits on every pass. It is glad to help — but it is not a rubber duck, a linter you poke in a loop, or a substitute for reading the contributing guide. Treat it like an expensive senior reviewer whose time you booked, and show up prepared. Draft when unsure, Ready when you mean it:
However you get there, arrive prepared:
Reviews are not free. A draft costs nothing to review; a Ready PR is a promise that it is worth reviewing.
|
|
🤖 Claude review started at commit New commits are not auto-reviewed. Add the |
|
Review summary Skills: review-topic-concurrency-and-resource-safety, review-core-infra, review-topic-test-hygiene, review-topic-backward-compat-and-versioning. No dedicated surface skill covers Scope: reference-counted, lock-guarded rewrite of the process-wide fd-2 stderr capture around native Traced under zero-trust:
No blocking or medium+ findings. The new env var mirrors existing undocumented streaming flags; env-var docs live in the external Reviewed at HEAD: b7c82d9 |
|
😎 PR passes the vibe-check and trust-me-bro verification. |
`_open_fd_count()` called `pytest.skip()` when `/proc/self/fd` was missing, but it runs on a worker thread in `test_repeated_sequential_captures_do_not_leak_file_descriptors`. `Skipped` raised there only kills the thread, so the test failed on `len(counts) == 2` instead of skipping. The subprocess script in `test_fork_closes_the_read_end_of_a_capture_that_is_still_winding_down` listed `/proc/self/fd` with no guard and exited 1. On macOS the file gave 2 failed, 15 passed, 1 skipped. Decide the platform once, at collection time, with a `requires_procfs` skipif marker on the three tests that read `/proc/self/fd`. Also skip the select-limit test when RLIMIT_NOFILE cannot reach a descriptor above 1100, instead of dying with EMFILE in the subprocess. macOS now: 15 passed, 3 skipped. Linux behaviour is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
What this fixes
capture_process_stderrredirects the process-wide fd 2 into a pipe around eachcv2.VideoCaptureopen, so FFmpeg's error text can be classified into aStreamErrorCode. It did that without any synchronisation, and everyInferencePipelineopens its source on its own thread. When two sources starttogether the redirects interleave:
A's read never sees EOF, because
savedBis still a write end ofpipeA, so Ablocks there forever. B then restores
savedB, leaving fd 2 permanently pointingat
pipeA's write end: the process loses its real stderr, and once that orphaned64 KiB pipe fills, any native stderr writer blocks too.
The observable symptom is that the source created first sits in
INITIALISINGat 0 fps with no error until the process is restarted, whichrepeats the race. Two RTSP sources started concurrently reproduce it every time.
What changed
Because fd 2 is process-wide, the redirection is now process-wide too: a single
reference-counted capture that the first entrant installs and the last leaver
removes. The state lock is held only for the fd manipulation itself and never
across an open, so a slow or unreachable camera cannot starve any other source's
connect or reconnect. fd 2 is restored under that lock — which is what stops the
next entrant from duplicating a pipe instead of the real stderr — and on the
exception path as well.
The pipe is drained continuously by a background thread instead of being read
once after the open. That also fixes a second hang, independent of the race:
anything writing more than the 64 KiB pipe capacity during a single open blocked
inside the open, because nothing was reading the pipe yet.
Each participant gets the text written during its own window, delimited without
writing anything into the pipe: the pipe is asked how much it still holds
(
FIONREAD) and the drain thread reports when it is idle, under a short boundedtimeout, so a window is not cut short by output the drain thread has not copied
yet. Overlapping captures can still see each other's lines. That is harmless and
documented: the captured text is only a hint for classification and is consulted
solely when
isOpened()is False, so another source's output can never turn asuccessful open into an error.
Every wait is bounded. Reference release is unconditional, so an interruption
anywhere in the bookkeeping still restores fd 2, and startup cleans up on
BaseException— including an interrupt insideThread.start()that may alreadyhave spawned the drain. Descriptors are closed through a helper that gives up
ownership of the number before closing it, so no cleanup path can shut a file the
process has since been handed that number for. Startup duplicates the real stderr
before allocating the pipe, so a process whose stderr is closed degrades to no
capture rather than having
os.pipe()handed descriptor 2.A forked child gets particular care, since it inherits descriptors but none of
the threads. The at-fork handler drops the inherited redirection without touching
any per-instance lock — another thread may have held one when
fork()ran — anda context manager unwinding in the child leaves the inherited object alone. It
also closes the read end of any capture that had been released but whose drain
thread had not yet finished with it; that thread does not exist in the child, so
nothing else would.
Readiness uses
poll()rather thanselect(), which raisesValueErroronce adescriptor reaches
FD_SETSIZE— reachable on a server holding many sources andsockets, and fatal for a thread whose job is to drain a pipe. On Windows, which
has neither
poll()nor pipe-capableselect(), the drain thread falls back tothe blocking read / EOF-on-teardown mechanism the previous code used.
New
DISABLE_NATIVE_STDERR_CAPTURE(defaultFalse, i.e. capture enabled) turnsthe capture off entirely, at the cost of the underlying error text in
SourceConnectionErrormessages.capture_process_stderr.handles_concurrent_opensand
NATIVE_STDERR_CAPTURE_HANDLES_CONCURRENT_OPENSare exported so anythingthat worked around the old behaviour by serialising native opens can detect a
build that no longer needs it and disable itself.
Deliberately unchanged
Two behaviours are left exactly as they were, and are now recorded in the
docstring. While a capture is active fd 2 belongs to it, so stderr written by the
rest of the process is captured rather than logged; and a subprocess spawned
during a capture inherits the pipe and sees EPIPE on stderr writes made after the
capture ends. Both predate this change. Neither can be addressed without work
that does not belong in a concurrency fix — in particular, mirroring captured
bytes back to the log naively would publish the credential-bearing source
references that some backend error messages contain, so doing it safely needs
line-boundary reassembly, redaction before forwarding, and a forwarding path that
cannot stall behind a slow log sink. I would rather do that separately.
Not included
cv2.VideoCaptureRTSP opens are already bounded: OpenCV's FFmpeg wrapperenforces a 30 s open timeout through its interrupt callback (measured —
Stream timeout triggered after 30034.86 ms). Note that FFmpeg'sstimeoutinOPENCV_FFMPEG_CAPTURE_OPTIONSdoes not shorten it, whileCAP_PROP_OPEN_TIMEOUT_MSECpassed as a capture parameter does (measured at4.00 s). No timeout machinery was added.
Testing
New
test_stream_error_classifier_concurrency.py: eighteen tests, each on adaemon thread joined with a bound or isolated in a subprocess with a hard
timeout, so a regression fails the suite instead of hanging it. They cover the
exact interleaving above; more than one pipe capacity written during a single
capture; a read descriptor forced above
FD_SETSIZE; interrupts injected intothe entry bookkeeping, the exit bookkeeping and
Thread.start(); every cleanuppath run again over a spent capture whose descriptor numbers have been reused; a
process whose stderr is closed; a
fork()taken while another thread holds thecapture's condition; a
fork()taken while a released capture's drain is stillwinding down; fd 2 still referring to the original stderr (
st_dev/st_ino)after N interleaved captures, including when the bodies raise; per-participant
windows; the opt-out; the capability marker; and the invariant that the module
never writes into the capture pipe at all.
Fourteen of the eighteen fail on
main. Repeat runs show no flakiness, and 10ksequential plus 10k concurrent capture cycles return descriptor and thread counts
to their baseline. The camera directory passes (292), and the full unit-test
invocation matches
mainexactly — the same four pre-existing, order-dependenttest_builder.pysymlink failures on both.black,isortandflake8areclean over
inference inference_sdk.