|
16 | 16 | import json |
17 | 17 | import logging |
18 | 18 | import os |
| 19 | +import signal |
19 | 20 | import time |
20 | 21 | from dataclasses import dataclass, field |
21 | 22 | from http import HTTPStatus |
|
123 | 124 | # so /json/version and /json/list match before the bare /json alias. |
124 | 125 | _DISCOVERY_SUFFIXES = ("/json/version", "/json/list", "/json") |
125 | 126 |
|
| 127 | +# Unauthenticated HTTP health target for orchestrator probes (httpGet, not |
| 128 | +# tcpSocket: a wedged event loop still accepts TCP via the listen backlog). |
| 129 | +_HEALTH_PATH = "/healthz" |
| 130 | +# After SIGTERM/SIGINT the listener closes and existing relays drain. Past this |
| 131 | +# budget remaining connections are force-closed (1001) so the process exits with |
| 132 | +# clean WS closes instead of the supervisor's SIGKILL cutting sockets mid-frame. |
| 133 | +_DEFAULT_DRAIN_TIMEOUT_SECONDS = 3600 |
| 134 | +_MAX_DRAIN_TIMEOUT_SECONDS = 24 * 3600 |
| 135 | + |
126 | 136 | # Enqueued by the drain path so the writer flushes all real frames then stops. |
127 | 137 | _DRAIN_SENTINEL = object() |
128 | 138 |
|
@@ -814,15 +824,67 @@ def __init__( |
814 | 824 | self._shared_upstreams_lock = asyncio.Lock() |
815 | 825 |
|
816 | 826 | async def serve_forever(self) -> None: |
817 | | - async with websockets.serve( |
| 827 | + loop = asyncio.get_running_loop() |
| 828 | + stop = asyncio.Event() |
| 829 | + installed: list[signal.Signals] = [] |
| 830 | + # Handlers go in BEFORE the listener opens so no signal can land in a |
| 831 | + # window where connections are being accepted but drain is not armed. |
| 832 | + for sig in (signal.SIGTERM, signal.SIGINT): |
| 833 | + try: |
| 834 | + loop.add_signal_handler(sig, stop.set) |
| 835 | + installed.append(sig) |
| 836 | + except (NotImplementedError, RuntimeError, ValueError): |
| 837 | + # Platforms/embeddings without loop signal support (Windows, |
| 838 | + # non-main threads) still serve; drain then relies on cancellation. |
| 839 | + pass |
| 840 | + server = await websockets.serve( |
818 | 841 | self._handle_client, self._host, self._port, max_size=None, process_request=self._process_request |
819 | | - ): |
820 | | - LOG.info("CDP proxy listening", host=self._host, port=self._port) |
821 | | - await asyncio.Future() |
| 842 | + ) |
| 843 | + LOG.info("CDP proxy listening", host=self._host, port=self._port) |
| 844 | + try: |
| 845 | + await stop.wait() |
| 846 | + # Drain: close the listener so no new client lands here, keep every |
| 847 | + # established relay running, and only force-close stragglers once the |
| 848 | + # drain budget is spent. The orchestrator's grace period is the outer |
| 849 | + # bound; this inner one exists so a healthy drain ends with clean |
| 850 | + # 1001 closes rather than a SIGKILL. |
| 851 | + drain_timeout = _positive_env_int( |
| 852 | + "CDP_PROXY_DRAIN_TIMEOUT_SECONDS", _DEFAULT_DRAIN_TIMEOUT_SECONDS, _MAX_DRAIN_TIMEOUT_SECONDS |
| 853 | + ) |
| 854 | + LOG.info("CDP proxy draining", drain_timeout_seconds=drain_timeout) |
| 855 | + server.close(close_connections=False) |
| 856 | + try: |
| 857 | + await asyncio.wait_for(server.wait_closed(), timeout=drain_timeout) |
| 858 | + except asyncio.TimeoutError: |
| 859 | + # Server.close() is idempotent — a second call cannot upgrade the |
| 860 | + # drain to a force-close — so stragglers are closed directly. Each |
| 861 | + # close() bounds its own handshake, so this always terminates. |
| 862 | + # `handlers` is an undocumented websockets.asyncio Server attribute |
| 863 | + # (its _close() iterates the same dict); the lifecycle tests here |
| 864 | + # exercise it, so a websockets bump that renames it fails loudly. |
| 865 | + LOG.warning("CDP proxy drain timeout expired, force-closing remaining connections") |
| 866 | + stragglers = [asyncio.create_task(conn.close(1001)) for conn in list(server.handlers)] |
| 867 | + if stragglers: |
| 868 | + await asyncio.wait(stragglers) |
| 869 | + finally: |
| 870 | + for sig in installed: |
| 871 | + loop.remove_signal_handler(sig) |
| 872 | + server.close(close_connections=True) |
| 873 | + await server.wait_closed() |
| 874 | + LOG.info("CDP proxy stopped") |
822 | 875 |
|
823 | 876 | async def _process_request(self, connection: websockets.ServerConnection, request: Request) -> Response | None: |
824 | 877 | """Serve the pre-upgrade CDP discovery GETs; return None to let a real WS |
825 | 878 | upgrade proceed. Authenticated the same way as the WS connection.""" |
| 879 | + try: |
| 880 | + is_health_probe = urlsplit(request.path).path == _HEALTH_PATH |
| 881 | + except ValueError: |
| 882 | + is_health_probe = False |
| 883 | + if is_health_probe: |
| 884 | + # Unauthenticated on purpose: probes carry no credentials, and the |
| 885 | + # response reveals nothing but liveness. Draining needs no branch — |
| 886 | + # the closed listener refuses the probe's connect outright. |
| 887 | + return connection.respond(HTTPStatus.OK, "ok") |
826 | 888 | try: |
827 | 889 | split = _split_discovery_target(request.path) |
828 | 890 | if split is None: |
|
0 commit comments