Skip to content

Commit b691820

Browse files
authored
Production entrypoint, health/drain, and cloud image for the CDP proxy (SKY-12533) (#7546)
1 parent 0504175 commit b691820

3 files changed

Lines changed: 198 additions & 4 deletions

File tree

skyvern/proxy/adapters/websocket_server.py

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import json
1717
import logging
1818
import os
19+
import signal
1920
import time
2021
from dataclasses import dataclass, field
2122
from http import HTTPStatus
@@ -123,6 +124,15 @@
123124
# so /json/version and /json/list match before the bare /json alias.
124125
_DISCOVERY_SUFFIXES = ("/json/version", "/json/list", "/json")
125126

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+
126136
# Enqueued by the drain path so the writer flushes all real frames then stops.
127137
_DRAIN_SENTINEL = object()
128138

@@ -814,15 +824,67 @@ def __init__(
814824
self._shared_upstreams_lock = asyncio.Lock()
815825

816826
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(
818841
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")
822875

823876
async def _process_request(self, connection: websockets.ServerConnection, request: Request) -> Response | None:
824877
"""Serve the pre-upgrade CDP discovery GETs; return None to let a real WS
825878
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")
826888
try:
827889
split = _split_discovery_target(request.path)
828890
if split is None:

tests/unit/proxy/test_discovery.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,29 @@ async def test_discovery_never_resolves_or_leaks_the_upstream() -> None:
235235
assert sessions.resolve_calls == 0
236236
text = bytes(response.body).decode()
237237
assert SECRET not in text and "vendor.internal" not in text
238+
239+
240+
# ---- health endpoint --------------------------------------------------------
241+
242+
243+
@pytest.mark.asyncio
244+
async def test_healthz_answers_without_credentials() -> None:
245+
server = _server(StaticKeyAuth({VALID_KEY: Principal(principal_id="p")}))
246+
247+
response = await _discover(server, "/healthz")
248+
249+
assert response is not None
250+
assert response.status_code == 200
251+
assert response.body == b"ok"
252+
253+
254+
@pytest.mark.asyncio
255+
async def test_healthz_never_reaches_discovery_or_ws_upgrade() -> None:
256+
# A probe target is answered directly: not None (which would hand the GET to
257+
# the WS handshake) and not gated on the discovery auth path.
258+
server = _server(StaticKeyAuth({VALID_KEY: Principal(principal_id="p")}))
259+
260+
response = await _discover(server, "/healthz?probe=1")
261+
262+
assert response is not None
263+
assert response.status_code == 200

tests/unit/proxy/test_websocket_server.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
from __future__ import annotations
22

3+
import asyncio
4+
import contextlib
5+
import os
6+
import signal
7+
import socket
38
from types import SimpleNamespace
49
from typing import Mapping
510

611
import pytest
12+
from websockets import exceptions as websockets_exceptions
13+
from websockets.asyncio import client as websockets_client
714
from websockets.datastructures import Headers
815

916
from skyvern.proxy.adapters.memory import (
@@ -365,3 +372,102 @@ async def test_session_is_resolved_once_per_connection_not_per_message() -> None
365372
await make_server(upstream, counting)._handle_client(ws) # type: ignore[arg-type]
366373

367374
assert counting.resolve_calls == 1
375+
376+
377+
# ---- serve_forever lifecycle: health endpoint + signal drain ----------------
378+
379+
380+
def _free_port() -> int:
381+
with socket.socket() as sock:
382+
sock.bind(("127.0.0.1", 0))
383+
return int(sock.getsockname()[1])
384+
385+
386+
async def _wait_for_listener(port: int, *, up: bool, attempts: int = 500) -> None:
387+
for _ in range(attempts):
388+
try:
389+
_, writer = await asyncio.open_connection("127.0.0.1", port)
390+
except OSError:
391+
if not up:
392+
return
393+
else:
394+
writer.close()
395+
if up:
396+
return
397+
await asyncio.sleep(0.01)
398+
raise AssertionError(f"listener on port {port} never became {'reachable' if up else 'refused'}")
399+
400+
401+
async def _http_get(port: int, path: str) -> tuple[int, bytes]:
402+
reader, writer = await asyncio.open_connection("127.0.0.1", port)
403+
writer.write(f"GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n".encode())
404+
await writer.drain()
405+
raw = await reader.read(-1)
406+
writer.close()
407+
head, _, body = raw.partition(b"\r\n\r\n")
408+
return int(head.split(b" ", 2)[1]), body
409+
410+
411+
def _lifecycle_server(port: int) -> CdpProxyServer:
412+
sessions = InMemorySessionRegistry()
413+
sessions.put(make_resolved_session())
414+
return CdpProxyServer(
415+
upstream=RecordingUpstreamBrowser(),
416+
sessions=sessions,
417+
auth=AllowAllAuth(),
418+
metrics=NoOpMetrics(),
419+
event_policy=ForwardAllEventPolicy(),
420+
host="127.0.0.1",
421+
port=port,
422+
)
423+
424+
425+
@pytest.mark.asyncio
426+
async def test_serve_forever_healthz_and_sigterm_drain() -> None:
427+
port = _free_port()
428+
task = asyncio.create_task(_lifecycle_server(port).serve_forever())
429+
try:
430+
await _wait_for_listener(port, up=True)
431+
status, body = await _http_get(port, "/healthz")
432+
assert (status, body) == (200, b"ok")
433+
434+
async with websockets_client.connect(f"ws://127.0.0.1:{port}/s1") as client:
435+
await client.ping()
436+
os.kill(os.getpid(), signal.SIGTERM)
437+
# Drain: the listener refuses new work while the live relay survives.
438+
await _wait_for_listener(port, up=False)
439+
await client.ping()
440+
await asyncio.wait_for(task, timeout=10)
441+
finally:
442+
if not task.done():
443+
task.cancel()
444+
with contextlib.suppress(asyncio.CancelledError):
445+
await task
446+
447+
448+
@pytest.mark.asyncio
449+
async def test_serve_forever_force_closes_stragglers_after_drain_timeout(
450+
monkeypatch: pytest.MonkeyPatch,
451+
) -> None:
452+
monkeypatch.setenv("CDP_PROXY_DRAIN_TIMEOUT_SECONDS", "1")
453+
port = _free_port()
454+
task = asyncio.create_task(_lifecycle_server(port).serve_forever())
455+
client = None
456+
try:
457+
await _wait_for_listener(port, up=True)
458+
client = await websockets_client.connect(f"ws://127.0.0.1:{port}/s1")
459+
os.kill(os.getpid(), signal.SIGTERM)
460+
461+
# The straggler never closes; the drain budget expires and the server
462+
# exits anyway, closing the client with a clean 1001 instead of a RST.
463+
await asyncio.wait_for(task, timeout=10)
464+
with pytest.raises(websockets_exceptions.ConnectionClosed):
465+
await asyncio.wait_for(client.recv(), timeout=5)
466+
assert client.protocol.close_code == 1001
467+
finally:
468+
if client is not None:
469+
await client.close()
470+
if not task.done():
471+
task.cancel()
472+
with contextlib.suppress(asyncio.CancelledError):
473+
await task

0 commit comments

Comments
 (0)