Skip to content

Commit b3725f7

Browse files
jcheng5gadenbuie
andauthored
websockets 14.0 support (#1769)
Co-authored-by: Garrick Aden-Buie <[email protected]>
1 parent 8d0f72e commit b3725f7

File tree

3 files changed

+41
-19
lines changed

3 files changed

+41
-19
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
* Branded theming via `ui.Theme.from_brand()` now correctly applies monospace inline and block font family choices. (#1762)
1313

14+
* Compatibility with `websockets>=14.0`, which has changed its public APIs. Shiny now requires websockets 13 or later (#1769).
15+
1416

1517
## [1.2.0] - 2024-10-29
1618

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ dependencies = [
3131
"typing-extensions>=4.10.0",
3232
"uvicorn>=0.16.0;platform_system!='Emscripten'",
3333
"starlette",
34-
"websockets>=10.0",
34+
"websockets>=13.0",
3535
"python-multipart",
3636
"htmltools>=0.6.0",
3737
"click>=8.1.4;platform_system!='Emscripten'",

shiny/_autoreload.py

+38-18
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def reload_begin():
5959
# Called from child process when new application instance starts up
6060
def reload_end():
6161
import websockets
62+
import websockets.asyncio.client
6263

6364
# os.kill(os.getppid(), signal.SIGUSR1)
6465

@@ -70,12 +71,12 @@ def reload_end():
7071

7172
async def _() -> None:
7273
options = {
73-
"extra_headers": {
74+
"additional_headers": {
7475
"Shiny-Autoreload-Secret": os.getenv("SHINY_AUTORELOAD_SECRET", ""),
7576
}
7677
}
7778
try:
78-
async with websockets.connect(
79+
async with websockets.asyncio.client.connect(
7980
url, **options # pyright: ignore[reportArgumentType]
8081
) as websocket:
8182
await websocket.send("reload_end")
@@ -169,6 +170,17 @@ def start_server(port: int, app_port: int, launch_browser: bool):
169170
os.environ["SHINY_AUTORELOAD_PORT"] = str(port)
170171
os.environ["SHINY_AUTORELOAD_SECRET"] = secret
171172

173+
# websockets 14.0 (and presumably later) log an error if a connection is opened and
174+
# closed before any data is sent. Our VS Code extension does exactly this--opens a
175+
# connection to check if the server is running, then closes it. It's better that it
176+
# does this and doesn't actually perform an HTTP request because we can't guarantee
177+
# that the HTTP request will be cheap (we do the same ping on both the autoreload
178+
# socket and the main uvicorn socket). So better to just suppress all errors until
179+
# we think we have a problem. You can unsuppress by setting the environment variable
180+
# to DEBUG.
181+
loglevel = os.getenv("SHINY_AUTORELOAD_LOG_LEVEL", "CRITICAL")
182+
logging.getLogger("websockets").setLevel(loglevel)
183+
172184
app_url = get_proxy_url(f"http://127.0.0.1:{app_port}/")
173185

174186
# Run on a background thread so our event loop doesn't interfere with uvicorn.
@@ -186,6 +198,8 @@ async def _coro_main(
186198
port: int, app_url: str, secret: str, launch_browser: bool
187199
) -> None:
188200
import websockets
201+
import websockets.asyncio.server
202+
from websockets.http11 import Request, Response
189203

190204
reload_now: asyncio.Event = asyncio.Event()
191205

@@ -198,18 +212,22 @@ def nudge():
198212
reload_now.set()
199213
reload_now.clear()
200214

201-
async def reload_server(conn: websockets.server.WebSocketServerProtocol):
215+
async def reload_server(conn: websockets.asyncio.server.ServerConnection):
202216
try:
203-
if conn.path == "/autoreload":
217+
if conn.request is None:
218+
raise RuntimeError(
219+
"Autoreload server received a connection with no request"
220+
)
221+
elif conn.request.path == "/autoreload":
204222
# The client wants to be notified when the app has reloaded. The client
205223
# in this case is the web browser, specifically shiny-autoreload.js.
206224
while True:
207225
await reload_now.wait()
208226
await conn.send("autoreload")
209-
elif conn.path == "/notify":
227+
elif conn.request.path == "/notify":
210228
# The client is notifying us that the app has reloaded. The client in
211229
# this case is the uvicorn worker process (see reload_end(), above).
212-
req_secret = conn.request_headers.get("Shiny-Autoreload-Secret", "")
230+
req_secret = conn.request.headers.get("Shiny-Autoreload-Secret", "")
213231
if req_secret != secret:
214232
# The client couldn't prove that they were from a child process
215233
return
@@ -225,18 +243,20 @@ async def reload_server(conn: websockets.server.WebSocketServerProtocol):
225243
# VSCode extension used in RSW sniffs out ports that are being listened on, which
226244
# leads to confusion if all you get is an error.
227245
async def process_request(
228-
path: str, request_headers: websockets.datastructures.Headers
229-
) -> Optional[tuple[http.HTTPStatus, websockets.datastructures.HeadersLike, bytes]]:
230-
# If there's no Upgrade header, it's not a WebSocket request.
231-
if request_headers.get("Upgrade") is None:
232-
# For some unknown reason, this fixes a tendency on GitHub Codespaces to
233-
# correctly proxy through this request, but give a 404 when the redirect is
234-
# followed and app_url is requested. With the sleep, both requests tend to
235-
# succeed reliably.
236-
await asyncio.sleep(1)
237-
return (http.HTTPStatus.MOVED_PERMANENTLY, [("Location", app_url)], b"")
238-
239-
async with websockets.serve(
246+
connection: websockets.asyncio.server.ServerConnection,
247+
request: Request,
248+
) -> Response | None:
249+
if request.headers.get("Upgrade") is None:
250+
return Response(
251+
status_code=http.HTTPStatus.MOVED_PERMANENTLY,
252+
reason_phrase="Moved Permanently",
253+
headers=websockets.Headers(Location=app_url),
254+
body=None,
255+
)
256+
else:
257+
return None
258+
259+
async with websockets.asyncio.server.serve(
240260
reload_server, "127.0.0.1", port, process_request=process_request
241261
):
242262
await asyncio.Future() # wait forever

0 commit comments

Comments
 (0)