Skip to content

Handle rejected handshakes cleanly in the threading server#1726

Open
apoorvdarshan wants to merge 1 commit into
python-websockets:mainfrom
apoorvdarshan:fix-sync-server-rejected-handshake
Open

Handle rejected handshakes cleanly in the threading server#1726
apoorvdarshan wants to merge 1 commit into
python-websockets:mainfrom
apoorvdarshan:fix-sync-server-rejected-handshake

Conversation

@apoorvdarshan

Copy link
Copy Markdown

Summary

In the :mod:threading server, when process_request or process_response rejects the opening handshake by returning an HTTP response, handshake() returns normally and handshake_exc stays None (it's only set for failures, not deliberate rejections). conn_handler then reached:

assert connection.protocol.state is OPEN

After a rejection the state is CONNECTING or CLOSED, never OPEN, so the assertion failed. The resulting AssertionError was swallowed by the surrounding except Exception: block, which closed the raw socket without going through close_socket() or joining recv_events_thread. Nothing was logged, so the failure was silent.

With python -O, the assertion was stripped instead, so the connection handler ran on the already-closed connection: start_keepalive() and handler(connection) executed against a CLOSED connection and every rejection logged "connection handler failed".

This is the exact case the asyncio server already handles explicitly (asyncio/server.py), so the two implementations had diverged.

Fix

Replace the assertion with an explicit branch that takes the rejection/close path cleanly, mirroring the asyncio server and using the threading server's own cleanup idiom:

if connection.protocol.state is not OPEN:
    # process_request or process_response rejected the handshake.
    # The connection is already closing or closed.
    connection.close_socket()
    connection.recv_events_thread.join()
    return

This also makes the # pragma: no cover on the outer except Exception: accurate again: that branch no longer runs on every rejection.

Tests / Verification

  • Guarded tests/sync/test_server.py::test_process_request_returns_response with self.assertNoLogs("websockets", logging.ERROR), matching its asyncio counterpart. On the unpatched server this fails under python -O (the handler runs on the rejected connection and logs an error); with the fix it passes under both python and python -O.
  • python -m unittest tests.sync.test_server passes (50 tests) under both python and python -O.
  • tests.sync.test_client, tests.sync.test_router, and tests.asyncio.test_server remain green.
  • Coverage of src/websockets/sync/server.py stays at 100% (the new branch is exercised by the rejection and success paths).
  • ruff format --check, ruff check, and mypy --strict are clean.
  • Added a changelog entry under 16.2.

Disclosure: prepared with AI assistance; reviewed and verified locally.

When process_request or process_response rejects the opening handshake by
returning an HTTP response, handshake() returns normally and handshake_exc
stays None, so conn_handler reached `assert connection.protocol.state is
OPEN`. Since the state is CONNECTING or CLOSED after a rejection, the
assertion failed and the AssertionError was swallowed by the surrounding
`except Exception`, which closed the raw socket without going through
close_socket() or joining recv_events_thread. With `python -O`, the
assertion was stripped instead and the connection handler ran on the
already-closed connection, logging "connection handler failed" on every
rejection.

Replace the assertion with an explicit branch that takes the rejection path
cleanly, mirroring the asyncio server. Guard the corresponding test with
assertNoLogs, matching its asyncio counterpart.

Fixes python-websockets#1722.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant