diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eca908a3..d26427fd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Branded theming via `ui.Theme.from_brand()` now correctly applies monospace inline and block font family choices. (#1762) +* Compatibility with `websockets>=14.0`, which has changed its public APIs. Shiny now requires websockets 13 or later (#1769). + ## [1.2.0] - 2024-10-29 diff --git a/pyproject.toml b/pyproject.toml index e9778149b..291849ea2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "typing-extensions>=4.10.0", "uvicorn>=0.16.0;platform_system!='Emscripten'", "starlette", - "websockets>=10.0", + "websockets>=13.0", "python-multipart", "htmltools>=0.6.0", "click>=8.1.4;platform_system!='Emscripten'", diff --git a/shiny/_autoreload.py b/shiny/_autoreload.py index b77fec6fe..634554608 100644 --- a/shiny/_autoreload.py +++ b/shiny/_autoreload.py @@ -59,6 +59,7 @@ def reload_begin(): # Called from child process when new application instance starts up def reload_end(): import websockets + import websockets.asyncio.client # os.kill(os.getppid(), signal.SIGUSR1) @@ -70,12 +71,12 @@ def reload_end(): async def _() -> None: options = { - "extra_headers": { + "additional_headers": { "Shiny-Autoreload-Secret": os.getenv("SHINY_AUTORELOAD_SECRET", ""), } } try: - async with websockets.connect( + async with websockets.asyncio.client.connect( url, **options # pyright: ignore[reportArgumentType] ) as websocket: await websocket.send("reload_end") @@ -169,6 +170,17 @@ def start_server(port: int, app_port: int, launch_browser: bool): os.environ["SHINY_AUTORELOAD_PORT"] = str(port) os.environ["SHINY_AUTORELOAD_SECRET"] = secret + # websockets 14.0 (and presumably later) log an error if a connection is opened and + # closed before any data is sent. Our VS Code extension does exactly this--opens a + # connection to check if the server is running, then closes it. It's better that it + # does this and doesn't actually perform an HTTP request because we can't guarantee + # that the HTTP request will be cheap (we do the same ping on both the autoreload + # socket and the main uvicorn socket). So better to just suppress all errors until + # we think we have a problem. You can unsuppress by setting the environment variable + # to DEBUG. + loglevel = os.getenv("SHINY_AUTORELOAD_LOG_LEVEL", "CRITICAL") + logging.getLogger("websockets").setLevel(loglevel) + app_url = get_proxy_url(f"http://127.0.0.1:{app_port}/") # Run on a background thread so our event loop doesn't interfere with uvicorn. @@ -186,6 +198,8 @@ async def _coro_main( port: int, app_url: str, secret: str, launch_browser: bool ) -> None: import websockets + import websockets.asyncio.server + from websockets.http11 import Request, Response reload_now: asyncio.Event = asyncio.Event() @@ -198,18 +212,22 @@ def nudge(): reload_now.set() reload_now.clear() - async def reload_server(conn: websockets.server.WebSocketServerProtocol): + async def reload_server(conn: websockets.asyncio.server.ServerConnection): try: - if conn.path == "/autoreload": + if conn.request is None: + raise RuntimeError( + "Autoreload server received a connection with no request" + ) + elif conn.request.path == "/autoreload": # The client wants to be notified when the app has reloaded. The client # in this case is the web browser, specifically shiny-autoreload.js. while True: await reload_now.wait() await conn.send("autoreload") - elif conn.path == "/notify": + elif conn.request.path == "/notify": # The client is notifying us that the app has reloaded. The client in # this case is the uvicorn worker process (see reload_end(), above). - req_secret = conn.request_headers.get("Shiny-Autoreload-Secret", "") + req_secret = conn.request.headers.get("Shiny-Autoreload-Secret", "") if req_secret != secret: # The client couldn't prove that they were from a child process return @@ -225,18 +243,20 @@ async def reload_server(conn: websockets.server.WebSocketServerProtocol): # VSCode extension used in RSW sniffs out ports that are being listened on, which # leads to confusion if all you get is an error. async def process_request( - path: str, request_headers: websockets.datastructures.Headers - ) -> Optional[tuple[http.HTTPStatus, websockets.datastructures.HeadersLike, bytes]]: - # If there's no Upgrade header, it's not a WebSocket request. - if request_headers.get("Upgrade") is None: - # For some unknown reason, this fixes a tendency on GitHub Codespaces to - # correctly proxy through this request, but give a 404 when the redirect is - # followed and app_url is requested. With the sleep, both requests tend to - # succeed reliably. - await asyncio.sleep(1) - return (http.HTTPStatus.MOVED_PERMANENTLY, [("Location", app_url)], b"") - - async with websockets.serve( + connection: websockets.asyncio.server.ServerConnection, + request: Request, + ) -> Response | None: + if request.headers.get("Upgrade") is None: + return Response( + status_code=http.HTTPStatus.MOVED_PERMANENTLY, + reason_phrase="Moved Permanently", + headers=websockets.Headers(Location=app_url), + body=None, + ) + else: + return None + + async with websockets.asyncio.server.serve( reload_server, "127.0.0.1", port, process_request=process_request ): await asyncio.Future() # wait forever