Repository navigation
fix(server): relay client websocket closes with a legal code - #1731
Merged
Pangjiping merged 3 commits intoSep 7, 2026
Merged
Pangjiping merged 3 commits into
Pangjiping merged 3 commits into
Conversation
_relay_client_messages() in server/opensandbox_server/api/proxy.py passed the ASGI websocket.disconnect code straight to backend.close(). ASGI reports 1005 when the client's Close frame carried no status and 1006 when the connection dropped without a Close frame, and RFC 6455 7.4.1 reserves both for local use, so serializing them into a Close frame raises websockets ProtocolError. A browser calling ws.close() with no code produces exactly that 1005, so an ordinary client disconnect raised out of the relay task, tore down the proxy task group, and was logged as "Unexpected websocket proxy failure" with a traceback. The backend was never told why its peer went away. Both close paths now go through _backend_websocket_close_code(), mirroring the existing _client_websocket_close_code() used on the backend-to-client side: transmittable and application-defined codes pass through unchanged, 1005 maps to 1000 because the client did close cleanly, and every other reserved or out-of-range value maps to 1001.
MaxFreedomPollard
requested review from
Generalwin,
Pangjiping,
hittyt,
jwx0925 and
ninan-nn
as code owners
September 6, 2026 17:01
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
websocket.disconnectcode into a transmittable WebSocket close code before relaying it to the sandbox backend.Problem
When a browser calls
ws.close()with no status code, the Close frame it sends carries an empty payload. Uvicorn reports that to the app as{"type": "websocket.disconnect", "code": 1005}, and it reports a dropped connection as1006. RFC 6455 7.4.1 reserves both codes for local use and forbids them on the wire._relay_client_messages()inserver/opensandbox_server/api/proxy.pypassed that code straight intobackend.close(). Serializing it fails, sowebsocketsraisesProtocolError: invalid status code. The exception escaped the relay task, tore down the proxy task group, and came out the other side as anUnexpected websocket proxy failuretraceback plus a1011close to a client that had already gone. The backend never learned why its peer disappeared, and the most common way a browser closes a WebSocket logged an error every time.Root cause
Two call sites relayed the raw code: the
websocket.disconnectbranch (proxy.py:448,code=message.get("code", ...)) and theWebSocketDisconnecthandler (proxy.py:454,code=exc.code).The mirrored direction was already fixed.
_relay_backend_messages()runs every backend code through_client_websocket_close_code()before closing the client. The client-to-backend direction had no equivalent.Change
Add
_backend_websocket_close_code()next to the existing helper and route both close paths through it.EXTERNAL_CLOSE_CODESand application codes3000-4999pass through unchanged.1005maps to1000: the client did send a Close frame, it just carried no status.1006and1015, maps to1001, because from the backend's point of view the peer went away without a clean close.1000, as before.Testing
_RecordingBackendWebSocket.close()in the new tests callsClose(code, reason).serialize(), the same trick the existing backend-to-client test uses, so an illegal relayed code fails deterministically instead of silently passing.uv run pytest tests/test_routes_proxy.py -qgives115 passed. Onmainthe two new relay cases fail withwebsockets.exceptions.ProtocolError: invalid status code.uv run pytest -q --cov=opensandbox_server --cov-report=term --cov-fail-under=80gives1670 passed, 16 skipped, total coverage82.57%.uv run ruff checkgivesAll checks passed!.uv run pyright opensandbox_server/api/proxy.py tests/test_routes_proxy.pygives0 errors, 0 warnings, 0 informations../scripts/verify-license.shgivesLicense headers verified., andgit diff --checkis clean._relay_client_messages()against a livewebsocketsserver and fed it each disconnect code. Before the change,1005,1006,1015and999all raisedProtocolErrorand the server saw no Close frame. After it, the server observes1000,1001,1001and1001, while1000,1001and4001arrive unchanged.uv run ruff format --checkreports drift on both files, but it reports the same drift onmain; the reformatting it wants is in unrelated lines, so this change leaves that baseline alone. CI runsruff check, notruff format.Breaking Changes
No HTTP API, OpenAPI, SDK, config or Kubernetes change. Close codes that were already legal on the wire are relayed exactly as before; only the codes that could never be sent change.
Checklist