Skip to content

Commit dbb7c18

Browse files
committed
fix(webrtc): clean up failed session startup
Ensure startup failures enter a terminal state and reuse the existing single-flight teardown. Publish partial peer connections before later initialization, cancel unfinished startup work, and close the event loop in its owner thread.\n\nCover pre- and post-peer failures, HTTP errors, concurrent callers, external interrupts, scheduling failures, pending tasks, and teardown failures.
1 parent a6caf5e commit dbb7c18

2 files changed

Lines changed: 590 additions & 50 deletions

File tree

inference_sdk/webrtc/session.py

Lines changed: 144 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -265,53 +265,112 @@ def __init__(
265265
def _init_connection(self) -> None:
266266
"""Initialize event loop, thread, and WebRTC connection."""
267267
# Start event loop in background thread
268-
self._loop = asyncio.new_event_loop()
268+
loop = asyncio.new_event_loop()
269+
self._loop = loop
269270

270271
def _run(loop: asyncio.AbstractEventLoop) -> None:
271272
asyncio.set_event_loop(loop)
272-
loop.run_forever()
273+
try:
274+
loop.run_forever()
275+
finally:
276+
try:
277+
try:
278+
pending_tasks = asyncio.all_tasks(loop)
279+
for task in pending_tasks:
280+
task.cancel()
281+
if pending_tasks:
282+
loop.run_until_complete(
283+
asyncio.gather(*pending_tasks, return_exceptions=True)
284+
)
285+
except Exception:
286+
logger.exception("Failed to drain WebRTC event-loop tasks")
273287

274-
self._loop_thread = threading.Thread(
275-
target=_run, args=(self._loop,), daemon=True
276-
)
277-
self._loop_thread.start()
288+
try:
289+
loop.run_until_complete(loop.shutdown_asyncgens())
290+
except Exception:
291+
logger.exception("Failed to shut down WebRTC async generators")
292+
finally:
293+
try:
294+
loop.close()
295+
finally:
296+
asyncio.set_event_loop(None)
297+
298+
loop_thread = threading.Thread(target=_run, args=(loop,), daemon=True)
299+
self._loop_thread = loop_thread
300+
try:
301+
loop_thread.start()
302+
except BaseException:
303+
if not loop_thread.is_alive():
304+
loop.close()
305+
self._loop = None
306+
self._loop_thread = None
307+
raise
278308

279309
# Initialize WebRTC connection
280-
fut = asyncio.run_coroutine_threadsafe(self._init(), self._loop)
310+
init_coro = self._init()
311+
try:
312+
fut = asyncio.run_coroutine_threadsafe(init_coro, loop)
313+
except BaseException:
314+
init_coro.close()
315+
raise
316+
281317
try:
282318
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:
319+
except BaseException as error:
320+
if not fut.done():
321+
fut.cancel()
322+
323+
if isinstance(error, requests.exceptions.HTTPError):
324+
if error.response.status_code == 404:
325+
raise RuntimeError(
326+
f"WebRTC endpoint not found at {self._api_url}/initialise_webrtc_worker.\n"
327+
f"This API URL may not support WebRTC streaming.\n"
328+
f"Troubleshooting:\n"
329+
f" - For self-hosted inference, ensure the server is started with WebRTC enabled\n"
330+
f" - For Roboflow Cloud, use a dedicated inference server URL (not serverless.roboflow.com)\n"
331+
f" - Verify the --api-url parameter points to the correct server\n"
332+
f"Response: {error.response.text}"
333+
) from error
295334
raise RuntimeError(
296-
f"Failed to initialize WebRTC session (HTTP {e.response.status_code}).\n"
335+
f"Failed to initialize WebRTC session (HTTP {error.response.status_code}).\n"
297336
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:
337+
f"Error: {error}\n"
338+
f"Response: {error.response.text}"
339+
) from error
340+
341+
if not isinstance(error, Exception):
342+
raise
343+
302344
raise RuntimeError(
303-
f"Failed to initialize WebRTC session: {e.__class__.__name__}: {e}\n"
345+
f"Failed to initialize WebRTC session: "
346+
f"{error.__class__.__name__}: {error}\n"
304347
f"API URL: {self._api_url}"
305-
) from e
348+
) from error
306349

307350
def _ensure_started(self) -> None:
308351
"""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")
352+
startup_failed = False
353+
try:
354+
with self._state_lock:
355+
if self._state == SessionState.NOT_STARTED:
356+
self._state = SessionState.STARTED
357+
try:
358+
self._init_connection()
359+
except BaseException:
360+
self._state = SessionState.CLOSED
361+
startup_failed = True
362+
raise
363+
elif self._state == SessionState.CLOSED:
364+
raise RuntimeError("Cannot use closed WebRTCSession")
365+
except BaseException:
366+
if startup_failed:
367+
try:
368+
self.close()
369+
except (Exception, asyncio.CancelledError):
370+
logger.exception(
371+
"Failed to clean up WebRTC session after startup failure"
372+
)
373+
raise
315374

316375
def _parse_video_metadata(
317376
self,
@@ -406,6 +465,8 @@ def _close_resources(self) -> None:
406465
try:
407466
with self._state_lock:
408467
self._state = SessionState.CLOSED
468+
loop = self._loop
469+
loop_thread = self._loop_thread
409470

410471
# Signal video iterator to stop by putting None sentinel
411472
try:
@@ -416,25 +477,61 @@ def _close_resources(self) -> None:
416477
# Cleanup resources (nested finally ensures all cleanup steps execute)
417478
try:
418479
# Close peer connection
419-
if self._loop and self._pc:
420-
asyncio.run_coroutine_threadsafe(
421-
self._pc.close(), self._loop
422-
).result()
480+
if (
481+
loop is not None
482+
and not loop.is_closed()
483+
and loop_thread is not None
484+
and loop_thread.is_alive()
485+
and self._pc is not None
486+
):
487+
peer_close_coro = self._pc.close()
488+
try:
489+
peer_close_future = asyncio.run_coroutine_threadsafe(
490+
peer_close_coro, loop
491+
)
492+
except BaseException:
493+
peer_close_coro.close()
494+
raise
495+
peer_close_future.result()
423496
finally:
424497
try:
425498
# 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()
499+
if (
500+
loop is not None
501+
and not loop.is_closed()
502+
and loop_thread is not None
503+
and loop_thread.is_alive()
504+
and self._source is not None
505+
):
506+
source_cleanup_coro = self._source.cleanup()
507+
try:
508+
source_cleanup_future = asyncio.run_coroutine_threadsafe(
509+
source_cleanup_coro, loop
510+
)
511+
except BaseException:
512+
source_cleanup_coro.close()
513+
raise
514+
source_cleanup_future.result()
430515
finally:
431516
# 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-
)
517+
try:
518+
if loop is not None and not loop.is_closed():
519+
try:
520+
loop.call_soon_threadsafe(loop.stop)
521+
except RuntimeError:
522+
if not loop.is_closed():
523+
raise
524+
finally:
525+
if (
526+
loop_thread is not None
527+
and loop_thread is not threading.current_thread()
528+
):
529+
loop_thread.join(timeout=WEBRTC_EVENT_LOOP_SHUTDOWN_TIMEOUT)
530+
if loop_thread.is_alive():
531+
logger.warning(
532+
"WebRTC event loop thread did not stop "
533+
f"within {WEBRTC_EVENT_LOOP_SHUTDOWN_TIMEOUT}s"
534+
)
438535
finally:
439536
self._close_done.set()
440537

@@ -969,6 +1066,7 @@ async def _init(self) -> None:
9691066
turn_config = await self._get_turn_config()
9701067

9711068
pc = RTCPeerConnection(configuration=turn_config)
1069+
self._pc = pc
9721070
relay = MediaRelay()
9731071

9741072
# Monitor ICE connection state for failures
@@ -1275,5 +1373,3 @@ def _on_data_message(message: Any) -> None: # noqa: ANN401
12751373
# Start video file upload if applicable
12761374
if isinstance(self._source, VideoFileSource):
12771375
asyncio.ensure_future(self._source.start_upload())
1278-
1279-
self._pc = pc

0 commit comments

Comments
 (0)