Description
When running Socket.IO with async_mode='threading' and simple-websocket
on the Werkzeug development server (the documented development setup for the
threading mode), every normal WebSocket disconnect makes Werkzeug log a
false HTTP 500:
ERROR:werkzeug:Error on request:
Traceback (most recent call last):
File ".../werkzeug/serving.py", line 371, in run_wsgi
execute(self.server.app)
File ".../werkzeug/serving.py", line 337, in execute
write(b"")
File ".../werkzeug/serving.py", line 262, in write
assert status_set is not None, "write() before start_response"
AssertionError: write() before start_response
Nothing is actually broken (the client disconnected normally and the server
keeps serving), but the traceback is emitted on every tab close/navigation,
which is noisy and looks like a real failure.
Versions
- python-engineio 4.13.3
- python-socketio 5.16.3
- Flask-SocketIO 5.6.1
- simple-websocket 1.1.0
- Werkzeug 3.1.8
- Python 3.12, Linux
Root cause
engineio/async_drivers/_websocket_wsgi.py hijacks the raw socket (via
simple_websocket.Server, which reads environ['werkzeug.socket']) and runs
the whole WebSocket session without ever calling start_response. When the
session ends, the wrapper only special-cases Gunicorn:
def __call__(self, environ, start_response):
self.ws = simple_websocket.Server(environ, **self.server_args)
ret = self.app(self)
if self.ws.mode == 'gunicorn':
raise StopIteration()
return ret
Under Werkzeug the returned iterable flows back into
werkzeug.serving.WSGIRequestHandler.run_wsgi(), which calls write(b"") to
flush headers that were never set, hitting the assertion and logging the
false 500.
Suggested fix
Add a werkzeug branch analogous to the existing gunicorn branch:
if self.ws.mode == 'gunicorn':
raise StopIteration()
if self.ws.mode == 'werkzeug':
raise ConnectionError()
Werkzeug's request handler explicitly treats ConnectionError as "client
dropped the connection" (connection_dropped_errors in
werkzeug/serving.py) and discards it silently, so teardown produces no log
output. flask-sock solves the same problem differently (its
WebSocketResponse drives Werkzeug's normal start_response path), so either
approach would work; the ConnectionError variant is the smaller change for
this wrapper.
Reproduction
# server.py
from flask import Flask
from flask_socketio import SocketIO
app = Flask(__name__)
socketio = SocketIO(app, async_mode='threading',
logger=True, engineio_logger=True)
socketio.run(app, port=5001, allow_unsafe_werkzeug=True)
# client.py — or simply open/close a browser tab pointed at any page
# that connects a Socket.IO client with websocket transport
import simple_websocket
ws = simple_websocket.Client.connect(
'ws://127.0.0.1:5001/socket.io/?EIO=4&transport=websocket')
ws.receive(timeout=5) # Engine.IO open packet
ws.close() # server now logs the false 500
Logs
Server output with logger=True, engineio_logger=True for one connect/close
cycle (captured with the versions above; sid and timestamps will vary):
Server initialized for threading.
sbV24lzrPScQwe6VAAAA: Sending packet OPEN data {'sid': 'sbV24lzrPScQwe6VAAAA', 'upgrades': [], 'pingTimeout': 20000, 'pingInterval': 25000, 'maxPayload': 1000000}
sbV24lzrPScQwe6VAAAA: Received request to upgrade to websocket
sbV24lzrPScQwe6VAAAA: Upgrade to websocket successful
INFO:werkzeug:127.0.0.1 - - [03/Jul/2026 03:28:14] "GET /socket.io/?EIO=4&transport=websocket HTTP/1.1" 500 -
ERROR:werkzeug:Error on request:
Traceback (most recent call last):
File ".../werkzeug/serving.py", line 371, in run_wsgi
execute(self.server.app)
File ".../werkzeug/serving.py", line 337, in execute
write(b"")
File ".../werkzeug/serving.py", line 262, in write
assert status_set is not None, "write() before start_response"
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: write() before start_response
The client sees nothing unusual: it receives the Engine.IO OPEN packet
(0{"sid":"sbV24lzrPScQwe6VAAAA","upgrades":[],...}) and closes normally.
Note the Engine.IO session itself is healthy end to end — the OPEN packet is
delivered and the upgrade succeeds; the traceback and the false 500 in the
access log appear only after the client closes the socket, when Werkzeug
tries to flush a conventional HTTP response for the hijacked connection.
Description
When running Socket.IO with
async_mode='threading'andsimple-websocketon the Werkzeug development server (the documented development setup for the
threading mode), every normal WebSocket disconnect makes Werkzeug log a
false HTTP 500:
Nothing is actually broken (the client disconnected normally and the server
keeps serving), but the traceback is emitted on every tab close/navigation,
which is noisy and looks like a real failure.
Versions
Root cause
engineio/async_drivers/_websocket_wsgi.pyhijacks the raw socket (viasimple_websocket.Server, which readsenviron['werkzeug.socket']) and runsthe whole WebSocket session without ever calling
start_response. When thesession ends, the wrapper only special-cases Gunicorn:
Under Werkzeug the returned iterable flows back into
werkzeug.serving.WSGIRequestHandler.run_wsgi(), which callswrite(b"")toflush headers that were never set, hitting the assertion and logging the
false 500.
Suggested fix
Add a
werkzeugbranch analogous to the existinggunicornbranch:Werkzeug's request handler explicitly treats
ConnectionErroras "clientdropped the connection" (
connection_dropped_errorsinwerkzeug/serving.py) and discards it silently, so teardown produces no logoutput. flask-sock solves the same problem differently (its
WebSocketResponsedrives Werkzeug's normalstart_responsepath), so eitherapproach would work; the
ConnectionErrorvariant is the smaller change forthis wrapper.
Reproduction
Logs
Server output with
logger=True, engineio_logger=Truefor one connect/closecycle (captured with the versions above; sid and timestamps will vary):
The client sees nothing unusual: it receives the Engine.IO OPEN packet
(
0{"sid":"sbV24lzrPScQwe6VAAAA","upgrades":[],...}) and closes normally.Note the Engine.IO session itself is healthy end to end — the OPEN packet is
delivered and the upgrade succeeds; the traceback and the false
500in theaccess log appear only after the client closes the socket, when Werkzeug
tries to flush a conventional HTTP response for the hijacked connection.