@@ -222,6 +222,7 @@ def __init__(
222222 # Internal state
223223 self ._loop : Optional [asyncio .AbstractEventLoop ] = None
224224 self ._loop_thread : Optional [threading .Thread ] = None
225+ self ._startup_task : Optional ["asyncio.Task[None]" ] = None
225226 self ._pc : Optional ["RTCPeerConnection" ] = None
226227 # In model mode queue items carry an extra raw-predictions-dict element:
227228 # (frame, data, metadata). In default mode: (frame, metadata).
@@ -265,53 +266,117 @@ def __init__(
265266 def _init_connection (self ) -> None :
266267 """Initialize event loop, thread, and WebRTC connection."""
267268 # Start event loop in background thread
268- self ._loop = asyncio .new_event_loop ()
269+ loop = asyncio .new_event_loop ()
270+ self ._loop = loop
269271
270272 def _run (loop : asyncio .AbstractEventLoop ) -> None :
271273 asyncio .set_event_loop (loop )
272- loop .run_forever ()
274+ try :
275+ loop .run_forever ()
276+ finally :
277+ try :
278+ try :
279+ pending_tasks = asyncio .all_tasks (loop )
280+ for task in pending_tasks :
281+ task .cancel ()
282+ if pending_tasks :
283+ loop .run_until_complete (
284+ asyncio .gather (* pending_tasks , return_exceptions = True )
285+ )
286+ except Exception :
287+ logger .exception ("Failed to drain WebRTC event-loop tasks" )
273288
274- self ._loop_thread = threading .Thread (
275- target = _run , args = (self ._loop ,), daemon = True
276- )
277- self ._loop_thread .start ()
289+ try :
290+ loop .run_until_complete (loop .shutdown_asyncgens ())
291+ except Exception :
292+ logger .exception ("Failed to shut down WebRTC async generators" )
293+ finally :
294+ try :
295+ loop .close ()
296+ finally :
297+ asyncio .set_event_loop (None )
298+
299+ loop_thread = threading .Thread (target = _run , args = (loop ,), daemon = True )
300+ self ._loop_thread = loop_thread
301+ try :
302+ loop_thread .start ()
303+ except BaseException :
304+ if not loop_thread .is_alive ():
305+ loop .close ()
306+ self ._loop = None
307+ self ._loop_thread = None
308+ raise
278309
279310 # Initialize WebRTC connection
280- fut = asyncio .run_coroutine_threadsafe (self ._init (), self ._loop )
311+ init_coro = self ._run_startup ()
312+ try :
313+ fut = asyncio .run_coroutine_threadsafe (init_coro , loop )
314+ except BaseException :
315+ init_coro .close ()
316+ raise
317+
281318 try :
282319 fut .result ()
283- except requests .exceptions .HTTPError as e :
284- if e .response .status_code == 404 :
285- raise RuntimeError (
286- f"WebRTC endpoint not found at { self ._api_url } /initialise_webrtc_worker.\n "
287- f"This API URL may not support WebRTC streaming.\n "
288- f"Troubleshooting:\n "
289- f" - For self-hosted inference, ensure the server is started with WebRTC enabled\n "
290- f" - For Roboflow Cloud, use a dedicated inference server URL (not serverless.roboflow.com)\n "
291- f" - Verify the --api-url parameter points to the correct server\n "
292- f"Response: { e .response .text } "
293- ) from e
294- else :
320+ except BaseException as error :
321+ if not fut .done ():
322+ fut .cancel ()
323+
324+ if isinstance (error , requests .exceptions .HTTPError ):
325+ if error .response .status_code == 404 :
326+ raise RuntimeError (
327+ f"WebRTC endpoint not found at { self ._api_url } /initialise_webrtc_worker.\n "
328+ f"This API URL may not support WebRTC streaming.\n "
329+ f"Troubleshooting:\n "
330+ f" - For self-hosted inference, ensure the server is started with WebRTC enabled\n "
331+ f" - For Roboflow Cloud, use a dedicated inference server URL (not serverless.roboflow.com)\n "
332+ f" - Verify the --api-url parameter points to the correct server\n "
333+ f"Response: { error .response .text } "
334+ ) from error
295335 raise RuntimeError (
296- f"Failed to initialize WebRTC session (HTTP { e .response .status_code } ).\n "
336+ f"Failed to initialize WebRTC session (HTTP { error .response .status_code } ).\n "
297337 f"API URL: { self ._api_url } \n "
298- f"Error: { e } \n "
299- f"Response: { e .response .text } "
300- ) from e
301- except Exception as e :
338+ f"Error: { error } \n "
339+ f"Response: { error .response .text } "
340+ ) from error
341+
342+ if not isinstance (error , Exception ):
343+ raise
344+
302345 raise RuntimeError (
303- f"Failed to initialize WebRTC session: { e .__class__ .__name__ } : { e } \n "
346+ f"Failed to initialize WebRTC session: "
347+ f"{ error .__class__ .__name__ } : { error } \n "
304348 f"API URL: { self ._api_url } "
305- ) from e
349+ ) from error
350+
351+ async def _run_startup (self ) -> None :
352+ """Track the loop-owned startup task until teardown observes it."""
353+ self ._startup_task = asyncio .current_task ()
354+ await self ._init ()
306355
307356 def _ensure_started (self ) -> None :
308357 """Ensure connection is started (thread-safe, idempotent)."""
309- with self ._state_lock :
310- if self ._state == SessionState .NOT_STARTED :
311- self ._state = SessionState .STARTED
312- self ._init_connection ()
313- elif self ._state == SessionState .CLOSED :
314- raise RuntimeError ("Cannot use closed WebRTCSession" )
358+ startup_failed = False
359+ try :
360+ with self ._state_lock :
361+ if self ._state == SessionState .NOT_STARTED :
362+ self ._state = SessionState .STARTED
363+ try :
364+ self ._init_connection ()
365+ except BaseException :
366+ self ._state = SessionState .CLOSED
367+ startup_failed = True
368+ raise
369+ elif self ._state == SessionState .CLOSED :
370+ raise RuntimeError ("Cannot use closed WebRTCSession" )
371+ except BaseException :
372+ if startup_failed :
373+ try :
374+ self .close ()
375+ except (Exception , asyncio .CancelledError ):
376+ logger .exception (
377+ "Failed to clean up WebRTC session after startup failure"
378+ )
379+ raise
315380
316381 def _parse_video_metadata (
317382 self ,
@@ -401,40 +466,85 @@ def _close_in_background(self) -> None:
401466 except Exception :
402467 logger .exception ("Failed to close WebRTC session" )
403468
469+ async def _cleanup_async_resources (self ) -> None :
470+ """Wait for startup to stop before reading and closing its resources."""
471+ startup_task = self ._startup_task
472+ try :
473+ if startup_task is not None and startup_task is not asyncio .current_task ():
474+ if not startup_task .done ():
475+ startup_task .cancel ()
476+ try :
477+ await startup_task
478+ except asyncio .CancelledError :
479+ pass
480+ except Exception :
481+ # The startup caller owns reporting its original exception.
482+ pass
483+ finally :
484+ self ._startup_task = None
485+ try :
486+ if self ._pc is not None :
487+ await self ._pc .close ()
488+ finally :
489+ if self ._source is not None :
490+ await self ._source .cleanup ()
491+
404492 def _close_resources (self ) -> None :
405493 """Close session resources once and signal waiting callers."""
406494 try :
407495 with self ._state_lock :
408496 self ._state = SessionState .CLOSED
497+ loop = self ._loop
498+ loop_thread = self ._loop_thread
409499
410500 # Signal video iterator to stop by putting None sentinel
411501 try :
412502 self ._video_queue .put_nowait (None )
413503 except Exception :
414504 pass # Queue might be full, but that's okay
415505
416- # Cleanup resources (nested finally ensures all cleanup steps execute)
506+ # Cleanup resources before stopping their owner event loop.
417507 try :
418- # Close peer connection
419- if self ._loop and self ._pc :
420- asyncio .run_coroutine_threadsafe (
421- self ._pc .close (), self ._loop
422- ).result ()
508+ if (
509+ loop is not None
510+ and not loop .is_closed ()
511+ and loop_thread is not None
512+ and loop_thread .is_alive ()
513+ and (
514+ self ._startup_task is not None
515+ or self ._pc is not None
516+ or self ._source is not None
517+ )
518+ ):
519+ cleanup_coro = self ._cleanup_async_resources ()
520+ try :
521+ cleanup_future = asyncio .run_coroutine_threadsafe (
522+ cleanup_coro , loop
523+ )
524+ except BaseException :
525+ cleanup_coro .close ()
526+ raise
527+ cleanup_future .result ()
423528 finally :
529+ # Stop event loop and join thread
424530 try :
425- # Cleanup source (webcam, video file, etc.)
426- if self ._loop and self ._source :
427- asyncio .run_coroutine_threadsafe (
428- self ._source .cleanup (), self ._loop
429- ).result ()
531+ if loop is not None and not loop .is_closed ():
532+ try :
533+ loop .call_soon_threadsafe (loop .stop )
534+ except RuntimeError :
535+ if not loop .is_closed ():
536+ raise
430537 finally :
431- # Stop event loop and join thread
432- if self ._loop :
433- self ._loop .call_soon_threadsafe (self ._loop .stop )
434- if self ._loop_thread :
435- self ._loop_thread .join (
436- timeout = WEBRTC_EVENT_LOOP_SHUTDOWN_TIMEOUT
437- )
538+ if (
539+ loop_thread is not None
540+ and loop_thread is not threading .current_thread ()
541+ ):
542+ loop_thread .join (timeout = WEBRTC_EVENT_LOOP_SHUTDOWN_TIMEOUT )
543+ if loop_thread .is_alive ():
544+ logger .warning (
545+ "WebRTC event loop thread did not stop "
546+ f"within { WEBRTC_EVENT_LOOP_SHUTDOWN_TIMEOUT } s"
547+ )
438548 finally :
439549 self ._close_done .set ()
440550
@@ -969,6 +1079,7 @@ async def _init(self) -> None:
9691079 turn_config = await self ._get_turn_config ()
9701080
9711081 pc = RTCPeerConnection (configuration = turn_config )
1082+ self ._pc = pc
9721083 relay = MediaRelay ()
9731084
9741085 # Monitor ICE connection state for failures
@@ -1275,5 +1386,3 @@ def _on_data_message(message: Any) -> None: # noqa: ANN401
12751386 # Start video file upload if applicable
12761387 if isinstance (self ._source , VideoFileSource ):
12771388 asyncio .ensure_future (self ._source .start_upload ())
1278-
1279- self ._pc = pc
0 commit comments