Skip to content

fix(server): relay client websocket closes with a legal code - #1731

Merged
Pangjiping merged 3 commits into
opensandbox-group:mainfrom
MaxFreedomPollard:fix/proxy-client-disconnect-close-code
Sep 7, 2026
Merged

Pangjiping merged 3 commits into
opensandbox-group:mainfrom
MaxFreedomPollard:fix/proxy-client-disconnect-close-code

Conversation

@MaxFreedomPollard

Copy link
Copy Markdown
Contributor

Summary

  • Translate the ASGI websocket.disconnect code into a transmittable WebSocket close code before relaying it to the sandbox backend.
  • Keep normal, application-defined and other externally valid codes exactly as the client sent them.
  • Add regression tests for both the new boundary helper and the client relay loop.

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 as 1006. RFC 6455 7.4.1 reserves both codes for local use and forbids them on the wire.

_relay_client_messages() in server/opensandbox_server/api/proxy.py passed that code straight into backend.close(). Serializing it fails, so websockets raises ProtocolError: invalid status code. The exception escaped the relay task, tore down the proxy task group, and came out the other side as an Unexpected websocket proxy failure traceback plus a 1011 close 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.disconnect branch (proxy.py:448, code=message.get("code", ...)) and the WebSocketDisconnect handler (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.

  • Codes in EXTERNAL_CLOSE_CODES and application codes 3000-4999 pass through unchanged.
  • 1005 maps to 1000: the client did send a Close frame, it just carried no status.
  • Everything else reserved or out of range, including 1006 and 1015, maps to 1001, because from the backend's point of view the peer went away without a clean close.
  • A missing code still maps to 1000, as before.

Testing

_RecordingBackendWebSocket.close() in the new tests calls Close(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 -q gives 115 passed. On main the two new relay cases fail with websockets.exceptions.ProtocolError: invalid status code.
  • uv run pytest -q --cov=opensandbox_server --cov-report=term --cov-fail-under=80 gives 1670 passed, 16 skipped, total coverage 82.57%.
  • uv run ruff check gives All checks passed!.
  • uv run pyright opensandbox_server/api/proxy.py tests/test_routes_proxy.py gives 0 errors, 0 warnings, 0 informations.
  • ./scripts/verify-license.sh gives License headers verified., and git diff --check is clean.
  • Manual check against a real peer: I ran _relay_client_messages() against a live websockets server and fed it each disconnect code. Before the change, 1005, 1006, 1015 and 999 all raised ProtocolError and the server saw no Close frame. After it, the server observes 1000, 1001, 1001 and 1001, while 1000, 1001 and 4001 arrive unchanged.

uv run ruff format --check reports drift on both files, but it reports the same drift on main; the reformatting it wants is in unrelated lines, so this change leaves that baseline alone. CI runs ruff check, not ruff format.

  • Not run (explain why)
  • Unit tests
  • Integration tests
  • e2e / manual verification

Breaking Changes

  • None
  • Yes (describe impact and migration path)

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

  • Linked Issue or clearly described motivation
  • Added/updated docs (not needed; protocol-correctness fix with no user-visible API or config change)
  • Added/updated tests
  • Security impact considered: no new inputs, no new headers, no change to authentication or to what is forwarded
  • Backward compatibility considered

_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.
@github-actions github-actions Bot added component/server size/S Denotes a PR that changes 10-29 lines, ignoring generated files. labels Sep 6, 2026

@Pangjiping Pangjiping left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@Pangjiping
Pangjiping merged commit 5043b35 into opensandbox-group:main Sep 7, 2026
54 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component/server size/S Denotes a PR that changes 10-29 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants