Skip to content

Commit

Permalink
websockets 14.0 support (#1769)
Browse files Browse the repository at this point in the history
Co-authored-by: Garrick Aden-Buie <[email protected]>
  • Loading branch information
jcheng5 and gadenbuie authored Nov 13, 2024
1 parent 8d0f72e commit b3725f7
Show file tree
Hide file tree
Showing 3 changed files with 41 additions and 19 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
56 changes: 38 additions & 18 deletions shiny/_autoreload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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")
Expand Down Expand Up @@ -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.
Expand All @@ -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()

Expand All @@ -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
Expand All @@ -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
Expand Down

0 comments on commit b3725f7

Please sign in to comment.