1010import json
1111import uuid
1212import logging
13+ import signal
14+ import threading
15+ from http .server import BaseHTTPRequestHandler , ThreadingHTTPServer
16+ from urllib .parse import urlparse , parse_qs
1317from typing import Any , TYPE_CHECKING
1418
1519# aiortc and its dependencies have lots of internal warnings :(
1822warnings .filterwarnings ("ignore" , category = RuntimeWarning ) # TODO: remove this when google-crc32c publish a python3.12 wheel
1923
2024import capnp
21- from aiohttp import web
2225if TYPE_CHECKING :
2326 from aiortc .rtcdatachannel import RTCDataChannel
2427import aioice .ice
@@ -363,27 +366,43 @@ async def post_run_cleanup(self):
363366 await self .stream .stop ()
364367
365368
366- def schedule_teardown (app ):
367- # if nothing connects for 5 seconds, tear down livestreaming processes
368- h = app .get ('teardown' )
369- if h :
370- h .cancel ()
369+ class ServerState :
370+ def __init__ (self , debug : bool ):
371+ self .streams : dict [str , StreamSession ] = {}
372+ self .stream_lock = asyncio .Lock ()
373+ self .debug = debug
374+ self .teardown : asyncio .TimerHandle | None = None
375+
376+
377+ # if nothing connects for 5 seconds, tear down livestreaming processes
378+ def schedule_teardown (state : ServerState ):
379+ if state .teardown is not None :
380+ state .teardown .cancel ()
381+
371382 def clear ():
372- if not app ['streams' ]:
373- Params ().put_bool ("IsLiveStreaming" , False )
374- app ['teardown' ] = asyncio .get_running_loop ().call_later (5.0 , clear )
383+ if not state .streams :
384+ Params ().put_bool ("IsLiveStreaming" , False )
385+
386+ state .teardown = asyncio .get_running_loop ().call_later (5.0 , clear )
387+
388+
389+ def _json_response (obj : Any , status : int = 200 ) -> tuple [int , bytes , str ]:
390+ return (status , json .dumps (obj ).encode (), "application/json; charset=utf-8" )
375391
376392
377- async def get_stream (request : 'web.Request' ):
378- stream_dict , debug_mode = request .app ['streams' ], request .app ['debug' ]
379- raw_body = await request .json ()
380- body = StreamRequestBody (** raw_body )
393+ def _text_response (text : str , status : int = 200 ) -> tuple [int , bytes , str ]:
394+ return (status , text .encode (), "text/plain; charset=utf-8" )
381395
382- async with request .app ['stream_lock' ]:
396+
397+ async def handle_get_stream (state : ServerState , raw_body : bytes ) -> tuple [int , bytes , str ]:
398+ stream_dict , debug_mode = state .streams , state .debug
399+ body = StreamRequestBody (** json .loads (raw_body ))
400+
401+ async with state .stream_lock :
383402 # don't remove existing connection on prewarm request
384403 enabled = any (s .run_task and not s .run_task .done () and s .enabled for s in stream_dict .values ())
385404 if enabled and not body .enabled :
386- return web . json_response ({"error" : "busy" , "message" : "someone else is connected." })
405+ return _json_response ({"error" : "busy" , "message" : "someone else is connected." })
387406
388407 for sid , s in list (stream_dict .items ()):
389408 if s .run_task and not s .run_task .done ():
@@ -408,54 +427,134 @@ async def get_stream(request: 'web.Request'):
408427
409428 def remove_finished_session (_ : asyncio .Task ) -> None :
410429 stream_dict .pop (session .identifier , None )
411- schedule_teardown (request .app )
430+ schedule_teardown (state )
431+
412432 session .run_task .add_done_callback (remove_finished_session )
413433
414- return web . json_response ({"sdp" : answer .sdp , "type" : answer .type })
434+ return _json_response ({"sdp" : answer .sdp , "type" : answer .type })
415435
416436
417- async def get_schema ( request : 'web.Request' ) :
418- services = request . query . get ( "services" , "" ) .split ("," )
437+ async def handle_get_schema ( state : ServerState , services_param : str ) -> tuple [ int , bytes , str ] :
438+ services = services_param .split ("," )
419439 services = [s for s in services if s ]
420440 assert all (s in log .Event .schema .fields and not s .endswith ("DEPRECATED" ) for s in services ), "Invalid service name"
421441 schema_dict = {s : generate_field (log .Event .schema .fields [s ]) for s in services }
422- return web . json_response (schema_dict )
442+ return _json_response (schema_dict )
423443
424444
425- async def post_notify (request : 'web.Request' ):
426- try :
427- payload = await request .json ()
428- except Exception as e :
429- raise web .HTTPBadRequest (text = "Invalid JSON" ) from e
430-
431- for session in list (request .app .get ('streams' , {}).values ()):
445+ async def handle_post_notify (state : ServerState , payload : Any ) -> tuple [int , bytes , str ]:
446+ for session in list (state .streams .values ()):
432447 try :
433448 ch = session .stream .get_messaging_channel ()
434449 ch .send (json .dumps (payload ))
435450 except Exception :
436451 continue
437452
438- return web . Response ( status = 200 , text = "OK" )
453+ return _text_response ( "OK" )
439454
440455
441- async def on_shutdown (app : 'web.Application' ):
442- for session in list (app [ ' streams' ] .values ()):
456+ async def on_shutdown (state : ServerState ):
457+ for session in list (state . streams .values ()):
443458 try :
444459 ch = session .stream .get_messaging_channel ()
445460 ch .send (json .dumps ({"type" : "disconnect" , "data" : "device streaming has been stopped." }))
446461 except Exception :
447462 pass
448463 await session .stop ()
449- del app [ ' streams' ]
464+ state . streams . clear ()
450465
451466
452- @web .middleware
453- async def error_middleware (request : 'web.Request' , handler ):
454- try :
455- return await handler (request )
456- except Exception as e :
457- logging .getLogger ("webrtcd" ).exception ("Unhandled error handling %s" , request .path )
458- return web .json_response ({"error" : "exception" , "message" : f"{ type (e ).__name__ } : { e } " }, status = 500 )
467+ class WebrtcdHandler (BaseHTTPRequestHandler ):
468+ protocol_version = "HTTP/1.1"
469+
470+ # path -> allowed methods (aiohttp registered POST /stream, POST /notify, GET /schema + its auto HEAD)
471+ _routes = {
472+ "/schema" : ("GET" , "HEAD" ),
473+ "/stream" : ("POST" ,),
474+ "/notify" : ("POST" ,),
475+ }
476+
477+ def _send (self , status : int , body : bytes , content_type : str ) -> None :
478+ self .send_response (status )
479+ self .send_header ("Content-Type" , content_type )
480+ self .send_header ("Content-Length" , str (len (body )))
481+ self .end_headers ()
482+ if self .command != "HEAD" :
483+ self .wfile .write (body )
484+
485+ def _read_body (self ) -> bytes :
486+ length = int (self .headers .get ("Content-Length" , 0 ))
487+ return self .rfile .read (length ) if length else b""
488+
489+ def _run (self , coro ) -> tuple [int , bytes , str ]:
490+ return asyncio .run_coroutine_threadsafe (coro , self .server .loop ).result ()
491+
492+ def _dispatch_request (self ) -> None :
493+ parsed = urlparse (self .path )
494+ allowed = self ._routes .get (parsed .path )
495+
496+ try :
497+ if allowed is None :
498+ result = _json_response ({"error" : "not found" }, status = 404 )
499+ elif self .command not in allowed :
500+ result = _json_response ({"error" : "method not allowed" }, status = 405 )
501+ elif parsed .path == "/schema" :
502+ services = parse_qs (parsed .query ).get ("services" , ["" ])[0 ]
503+ result = self ._run (handle_get_schema (self .server .state , services ))
504+ elif parsed .path == "/stream" :
505+ result = self ._run (handle_get_stream (self .server .state , self ._read_body ()))
506+ else : # /notify
507+ try :
508+ payload = json .loads (self ._read_body ())
509+ except Exception :
510+ result = _json_response ({"error" : "bad request" }, status = 400 )
511+ else :
512+ result = self ._run (handle_post_notify (self .server .state , payload ))
513+ except Exception as e :
514+ logging .getLogger ("webrtcd" ).exception ("Unhandled error handling %s" , self .path )
515+ result = _json_response ({"error" : "exception" , "message" : f"{ type (e ).__name__ } : { e } " }, status = 500 )
516+
517+ self ._send (* result )
518+
519+ def do_GET (self ) -> None :
520+ self ._dispatch_request ()
521+
522+ def do_HEAD (self ) -> None :
523+ self ._dispatch_request ()
524+
525+ def do_POST (self ) -> None :
526+ self ._dispatch_request ()
527+
528+ def do_PUT (self ) -> None :
529+ self ._dispatch_request ()
530+
531+ def do_DELETE (self ) -> None :
532+ self ._dispatch_request ()
533+
534+ def do_PATCH (self ) -> None :
535+ self ._dispatch_request ()
536+
537+ def do_OPTIONS (self ) -> None :
538+ self ._dispatch_request ()
539+
540+ def log_message (self , fmt , * args ) -> None :
541+ # silence default access logging; errors are logged explicitly in _dispatch_request
542+ pass
543+
544+
545+ class WebrtcdHTTPServer (ThreadingHTTPServer ):
546+ daemon_threads = True
547+ allow_reuse_address = True
548+ state : ServerState
549+ loop : asyncio .AbstractEventLoop
550+
551+
552+ async def _shutdown (server : WebrtcdHTTPServer , state : ServerState , loop : asyncio .AbstractEventLoop ) -> None :
553+ # stop accepting new HTTP connections (blocks until serve_forever returns, so
554+ # run it off the loop) then tear down active stream sessions.
555+ await loop .run_in_executor (None , server .shutdown )
556+ await on_shutdown (state )
557+ loop .stop ()
459558
460559
461560def prewarm_stream_session_imports (debug_mode : bool = False ) -> None :
@@ -475,17 +574,35 @@ def webrtcd_thread(host: str, port: int, debug: bool):
475574 prewarm_end = time .monotonic ()
476575 logging .getLogger ("webrtcd" ).info (f"webrtc prewarm finished in { (prewarm_end - prewarm_start ) * 1000 } ms" )
477576
478- app = web .Application (middlewares = [error_middleware ])
577+ loop = asyncio .new_event_loop ()
578+ asyncio .set_event_loop (loop )
579+ state = ServerState (debug )
580+
581+ server = WebrtcdHTTPServer ((host , port ), WebrtcdHandler )
582+ server .state = state
583+ server .loop = loop
584+
585+ # serve HTTP on a daemon thread so the asyncio loop can own the main thread
586+ http_thread = threading .Thread (target = server .serve_forever , name = "webrtcd-http" , daemon = True )
587+ http_thread .start ()
479588
480- app ['streams' ] = dict ()
481- app ['stream_lock' ] = asyncio .Lock ()
482- app ['debug' ] = debug
483- app .on_shutdown .append (on_shutdown )
484- app .router .add_post ("/stream" , get_stream )
485- app .router .add_post ("/notify" , post_notify )
486- app .router .add_get ("/schema" , get_schema )
589+ shutting_down = False
487590
488- web .run_app (app , host = host , port = port )
591+ def request_shutdown () -> None :
592+ nonlocal shutting_down
593+ if shutting_down :
594+ return
595+ shutting_down = True
596+ loop .create_task (_shutdown (server , state , loop ))
597+
598+ for sig in (signal .SIGINT , signal .SIGTERM ):
599+ loop .add_signal_handler (sig , request_shutdown )
600+
601+ try :
602+ loop .run_forever ()
603+ finally :
604+ server .server_close ()
605+ loop .close ()
489606
490607
491608def main ():
0 commit comments