Skip to content

Commit 304308a

Browse files
committed
fix(webrtc): clean up failed session startup
Ensure failed startup attempts enter a terminal state and reuse the existing single-flight teardown path. Track the loop-owned startup task so cleanup waits for it to stop before reading partial resources. Publish peer connections as soon as they are created, close the event loop in its owner thread, and preserve the original startup error. Cover pre- and post-peer failures, interruptions, concurrent callers, scheduler failures, pending tasks, and teardown failures.
1 parent a6caf5e commit 304308a

2 files changed

Lines changed: 672 additions & 54 deletions

File tree

inference_sdk/webrtc/session.py

Lines changed: 161 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)