Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

websockets 14.0 support #1769

Merged
merged 6 commits into from
Nov 13, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 (#1769).
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved


## [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
Loading