Skip to content

Commit 2f0f682

Browse files
committed
Fix the CLI on Windows.
ALso simplify the implementation. Fix #1681.
1 parent 2816d38 commit 2f0f682

3 files changed

Lines changed: 58 additions & 50 deletions

File tree

docs/project/changelog.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ New features
5959
* Added the ``--insecure`` option to the ``websockets`` CLI to disable TLS
6060
certificate validation.
6161

62+
Bug fixes
63+
.........
64+
65+
* * Restored compatibility of the ``websockets`` CLI with Windows.
66+
6267
.. _16.1:
6368

6469
16.1

src/websockets/cli.py

Lines changed: 50 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,12 @@
66
import os
77
import ssl
88
import sys
9-
from typing import Any, Generator
9+
import threading
10+
from typing import Any, Callable
1011

1112
from .asyncio.client import ClientConnection, connect
12-
from .asyncio.messages import SimpleQueue
1313
from .exceptions import ConnectionClosed
1414
from .frames import Close
15-
from .streams import StreamReader
1615
from .version import version as websockets_version
1716

1817

@@ -72,35 +71,6 @@ def print_over_input(string: str) -> None:
7271
sys.stdout.flush()
7372

7473

75-
class ReadLines(asyncio.Protocol):
76-
def __init__(self) -> None:
77-
self.reader = StreamReader()
78-
self.messages: SimpleQueue[str] = SimpleQueue()
79-
80-
def parse(self) -> Generator[None, None, None]:
81-
while True:
82-
sys.stdout.write("> ")
83-
sys.stdout.flush()
84-
line = yield from self.reader.read_line(sys.maxsize)
85-
self.messages.put(line.decode().rstrip("\r\n"))
86-
87-
def connection_made(self, transport: asyncio.BaseTransport) -> None:
88-
self.parser = self.parse()
89-
next(self.parser)
90-
91-
def data_received(self, data: bytes) -> None:
92-
self.reader.feed_data(data)
93-
next(self.parser)
94-
95-
def eof_received(self) -> None:
96-
self.reader.feed_eof()
97-
# next(self.parser) isn't useful and would raise EOFError.
98-
99-
def connection_lost(self, exc: Exception | None) -> None:
100-
self.reader.discard()
101-
self.messages.abort()
102-
103-
10474
async def print_incoming_messages(websocket: ClientConnection) -> None:
10575
async for message in websocket:
10676
if isinstance(message, str):
@@ -109,15 +79,27 @@ async def print_incoming_messages(websocket: ClientConnection) -> None:
10979
print_during_input("< (binary) " + message.hex())
11080

11181

82+
def read_outgoing_messages(
83+
queue_for_sending: Callable[[str], None],
84+
notify_end_of_file: Callable[[], None],
85+
) -> None:
86+
while True:
87+
sys.stdout.write("> ")
88+
sys.stdout.flush()
89+
line = sys.stdin.readline()
90+
if not line:
91+
notify_end_of_file()
92+
break
93+
message = line.rstrip("\r\n")
94+
queue_for_sending(message)
95+
96+
11297
async def send_outgoing_messages(
11398
websocket: ClientConnection,
114-
messages: SimpleQueue[str],
99+
messages: asyncio.Queue[str],
115100
) -> None:
116101
while True:
117-
try:
118-
message = await messages.get()
119-
except EOFError:
120-
break
102+
message = await messages.get()
121103
try:
122104
await websocket.send(message)
123105
except ConnectionClosed: # pragma: no cover
@@ -133,17 +115,39 @@ async def interactive_client(uri: str, **kwargs: Any) -> None:
133115
else:
134116
print(f"Connected to {uri}.")
135117

136-
loop = asyncio.get_running_loop()
137-
transport, protocol = await loop.connect_read_pipe(ReadLines, sys.stdin)
138-
incoming = asyncio.create_task(
139-
print_incoming_messages(websocket),
140-
)
141-
outgoing = asyncio.create_task(
142-
send_outgoing_messages(websocket, protocol.messages),
143-
)
118+
# Read messsages from stdin in a thread because Windows doesn't support
119+
# reading asynchronously (#1681), and a daemon thread to avoid blocking
120+
# Ctrl-C because signals are only delivered to the main thread.
121+
loop = asyncio.get_event_loop()
122+
messages: asyncio.Queue[str] = asyncio.Queue()
123+
# When dropping support for Python < 3.13, change notify_end_of_file() to
124+
# call messages.shutdown() and break when asyncio.QueueShutdownError is
125+
# raised in send_outgoing_messages().
126+
shutdown: asyncio.Future[None] = loop.create_future()
127+
128+
def queue_for_sending(message: str) -> None:
129+
try:
130+
loop.call_soon_threadsafe(messages.put_nowait, message)
131+
except RuntimeError: # Event loop is closed # pragma: no cover
132+
pass
133+
134+
def notify_end_of_file() -> None:
135+
try:
136+
loop.call_soon_threadsafe(shutdown.set_result, None)
137+
except RuntimeError: # Event loop is closed # pragma: no cover
138+
pass
139+
140+
threading.Thread(
141+
target=read_outgoing_messages,
142+
args=(queue_for_sending, notify_end_of_file),
143+
daemon=True,
144+
).start()
145+
146+
incoming = asyncio.create_task(print_incoming_messages(websocket))
147+
outgoing = asyncio.create_task(send_outgoing_messages(websocket, messages))
144148
try:
145149
await asyncio.wait(
146-
[incoming, outgoing],
150+
[incoming, outgoing, shutdown],
147151
# Clean up and exit when the server closes the connection
148152
# or the user enters EOT (^D), whichever happens first.
149153
return_when=asyncio.FIRST_COMPLETED,
@@ -157,7 +161,6 @@ async def interactive_client(uri: str, **kwargs: Any) -> None:
157161
finally:
158162
incoming.cancel()
159163
outgoing.cancel()
160-
transport.close()
161164

162165
await websocket.close()
163166
assert websocket.close_code is not None and websocket.close_reason is not None

tests/test_cli.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ def echo_handler(websocket):
3232
class CLITests(unittest.TestCase):
3333
def run_main(self, argv, inputs="", close_input=False, expected_exit_code=None):
3434
# Replace sys.stdin with a file-like object backed by a file descriptor
35-
# for compatibility with loop.connect_read_pipe().
35+
# so it be left open and block readline(), or be closed to simulate EOF.
3636
stdin_read_fd, stdin_write_fd = os.pipe()
37-
stdin = io.FileIO(stdin_read_fd)
37+
stdin = open(stdin_read_fd)
3838
self.addCleanup(stdin.close)
3939
os.write(stdin_write_fd, inputs.encode())
4040
if close_input:
@@ -113,7 +113,7 @@ def wait_handler(websocket):
113113

114114
with run_server(wait_handler) as server:
115115
server_uri = get_uri(server)
116-
output = self.run_main([server_uri], "", close_input=True)
116+
output = self.run_main([server_uri], close_input=True)
117117
self.assertEqual(
118118
remove_commands_and_prompts(output),
119119
add_connection_messages("", server_uri),

0 commit comments

Comments
 (0)