Skip to content

Commit cddb610

Browse files
webrtc: replace aiohttp with stdlib http server (#38226)
* webrtc: replace aiohttp with stdlib http server * lil more * simplify * remove aiohttp error response helper --------- Co-authored-by: stefpi <19478336+stefpi@users.noreply.github.com>
1 parent 1474d54 commit cddb610

3 files changed

Lines changed: 164 additions & 215 deletions

File tree

openpilot/system/webrtc/webrtcd.py

Lines changed: 164 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
import json
1111
import uuid
1212
import logging
13+
import signal
14+
import threading
15+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
16+
from urllib.parse import urlparse, parse_qs
1317
from typing import Any, TYPE_CHECKING
1418

1519
# aiortc and its dependencies have lots of internal warnings :(
@@ -18,7 +22,6 @@
1822
warnings.filterwarnings("ignore", category=RuntimeWarning) # TODO: remove this when google-crc32c publish a python3.12 wheel
1923

2024
import capnp
21-
from aiohttp import web
2225
if TYPE_CHECKING:
2326
from aiortc.rtcdatachannel import RTCDataChannel
2427
import 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

461560
def 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

491608
def main():

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ dependencies = [
4646

4747
# body / webrtcd
4848
"av",
49-
"aiohttp",
5049
"aiortc",
5150

5251
# panda

0 commit comments

Comments
 (0)