Skip to content

Commit 4bfd0ff

Browse files
authored
bugfix connection burst sometimes loses ECHO mode (jquast#135)
* bugfix connection burst sometimes loses ECHO mode * asyncio.get_event_loop() -> asyncio.get_running_loop()
1 parent 5168e8f commit 4bfd0ff

12 files changed

Lines changed: 152 additions & 66 deletions

docs/history.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
History
22
=======
3+
4.0.1
4+
* bugfix: ``telnetlib3-client`` could begin a shell in wrong ECHO mode, depending on order of
5+
options in a "connection burst".
6+
37
4.0.0
48
* removed: ``telnetlib3.color_filter``. ``ColorFilter``, ``ColorConfig``, ``PALETTES``,
59
``PetsciiColorFilter``, and ``AtasciiControlFilter`` have all been moved to the downstream

telnetlib3/client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -623,7 +623,7 @@ def connection_factory() -> client_base.BaseClient:
623623

624624
try:
625625
_, protocol = await asyncio.wait_for(
626-
asyncio.get_event_loop().create_connection(
626+
asyncio.get_running_loop().create_connection(
627627
connection_factory, host or "localhost", port, **conn_kwargs
628628
),
629629
timeout=connect_timeout,
@@ -1267,7 +1267,7 @@ def patched_send_env(keys: Sequence[str]) -> Dict[str, Any]:
12671267
else:
12681268
fp_ssl = ssl_module.create_default_context()
12691269

1270-
waiter_closed: asyncio.Future[None] = asyncio.get_event_loop().create_future()
1270+
waiter_closed: asyncio.Future[None] = asyncio.get_running_loop().create_future()
12711271

12721272
fp_conn_kwargs: Dict[str, Any] = {
12731273
"host": args.host,

telnetlib3/client_shell.py

Lines changed: 111 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,15 @@ def feed(self, data: bytes) -> bytes:
173173

174174
@dataclass
175175
class _RawLoopState:
176-
"""Mutable state bundle for :func:`_raw_event_loop`."""
176+
"""
177+
Mutable state bundle for :func:`_raw_event_loop`.
178+
179+
Initialised by :func:`telnet_client_shell` before the loop starts and mutated
180+
in-place as mid-session negotiation arrives (e.g. server WILL ECHO toggling
181+
after login, LINEMODE EDIT confirmed by server). On loop exit,
182+
``switched_to_raw`` and ``reactivate_repl`` reflect final state so the caller
183+
can decide whether to restart a REPL.
184+
"""
177185

178186
switched_to_raw: bool
179187
last_will_echo: bool
@@ -529,7 +537,7 @@ async def make_stdout(self) -> asyncio.StreamWriter:
529537
write_fobj = sys.stdout
530538
if self._istty:
531539
write_fobj = sys.stdin
532-
loop = asyncio.get_event_loop()
540+
loop = asyncio.get_running_loop()
533541
writer_transport, writer_protocol = await loop.connect_write_pipe(
534542
asyncio.streams.FlowControlMixin, write_fobj
535543
)
@@ -544,7 +552,7 @@ async def connect_stdin(self) -> asyncio.StreamReader:
544552
"""
545553
reader = asyncio.StreamReader()
546554
reader_protocol = asyncio.StreamReaderProtocol(reader)
547-
transport, _ = await asyncio.get_event_loop().connect_read_pipe(
555+
transport, _ = await asyncio.get_running_loop().connect_read_pipe(
548556
lambda: reader_protocol, sys.stdin
549557
)
550558
self._stdin_transport = transport
@@ -628,17 +636,37 @@ def _send_stdin(
628636
return new_timer, pending
629637

630638
def _get_raw_mode(writer: Union[TelnetWriter, TelnetWriterUnicode]) -> "bool | None":
631-
"""Return the writer's ``ctx.raw_mode`` (``None``, ``True``, or ``False``)."""
639+
"""
640+
Return the raw-mode override from the writer's session context.
641+
642+
``None`` = auto-detect from server negotiation (default),
643+
``True`` = force raw / character-at-a-time,
644+
``False`` = force line mode.
645+
"""
632646
return writer.ctx.raw_mode
633647

634648
def _ensure_autoreply_engine(
635649
telnet_writer: Union[TelnetWriter, TelnetWriterUnicode],
636650
) -> "Optional[Any]":
637-
"""Return the autoreply engine from the writer's context, if set."""
651+
"""
652+
Return the autoreply engine from the writer's session context, or ``None``.
653+
654+
The autoreply engine is optional application-level machinery (e.g. a macro
655+
engine in a MUD client) that watches server output and sends pre-configured
656+
replies. It is absent in standalone telnetlib3 and supplied by the host
657+
application via ``writer.ctx.autoreply_engine``.
658+
"""
638659
return telnet_writer.ctx.autoreply_engine
639660

640661
def _get_linemode_buffer(writer: Union[TelnetWriter, TelnetWriterUnicode]) -> "LinemodeBuffer":
641-
"""Return (or lazily create) the LinemodeBuffer attached to *writer*."""
662+
"""
663+
Return (or lazily create) the :class:`LinemodeBuffer` attached to *writer*.
664+
665+
The buffer is stored as ``writer._linemode_buf`` so it persists across loop
666+
iterations and accumulates characters between :meth:`LinemodeBuffer.feed`
667+
calls. Created on first use because LINEMODE negotiation may complete after
668+
the shell has already started.
669+
"""
642670
buf: Optional[LinemodeBuffer] = getattr(writer, "_linemode_buf", None)
643671
if buf is None:
644672
buf = LinemodeBuffer(
@@ -749,7 +777,7 @@ async def _raw_event_loop(
749777
ar_engine = _ensure_autoreply_engine(telnet_writer)
750778
if ar_engine is not None:
751779
ar_engine.feed(out)
752-
if raw_mode is None:
780+
if raw_mode is None or (raw_mode is True and state.switched_to_raw):
753781
mode_result = tty_shell.check_auto_mode(
754782
state.switched_to_raw, state.last_will_echo
755783
)
@@ -763,7 +791,7 @@ async def _raw_event_loop(
763791
# becomes \r\n for correct display.
764792
if state.switched_to_raw and not in_raw:
765793
out = out.replace("\n", "\r\n")
766-
if want_repl():
794+
if raw_mode is None and want_repl():
767795
state.reactivate_repl = True
768796
stdout.write(out.encode())
769797
_ts_file = telnet_writer.ctx.typescript_file
@@ -807,49 +835,102 @@ async def telnet_client_shell(
807835
stdout = await tty_shell.make_stdout()
808836
tty_shell.setup_winch()
809837

810-
# EOR/GA-based command pacing for raw-mode autoreplies.
811-
prompt_ready_raw = asyncio.Event()
812-
prompt_ready_raw.set()
813-
ga_detected_raw = False
814-
815-
_sh_ctx: TelnetSessionContext = telnet_writer.ctx
816-
817-
def _on_prompt_signal_raw(_cmd: bytes) -> None:
818-
nonlocal ga_detected_raw
819-
ga_detected_raw = True
820-
prompt_ready_raw.set()
821-
ar = _sh_ctx.autoreply_engine
838+
# Prompt-pacing via IAC GA / IAC EOR.
839+
#
840+
# MUD servers emit IAC GA (Go-Ahead, RFC 854) or IAC EOR (End-of-Record, RFC 885) after
841+
# each prompt to signal "output is complete, awaiting your input." The autoreply engine
842+
# uses this to pace its replies. It calls ctx.autoreply_wait_fn() before sending each
843+
# reply, preventing races where a reply arrives before the server has finished rendering
844+
# the prompt.
845+
#
846+
# 'server_uses_ga' becomes True on the first GA/EOR received. _wait_for_prompt is does
847+
# nothing until 'server_uses_ga', so servers that never send GA/EOR (Most everything but
848+
# MUDs these days) are silently unaffected.
849+
#
850+
# prompt_event starts SET so the first autoreply fires immediately — there is no prior
851+
# GA to wait for. _on_ga_or_eor re-sets it on each prompt signal; _wait_for_prompt
852+
# clears it after consuming the signal so the next autoreply waits for the following
853+
# prompt.
854+
prompt_event = asyncio.Event()
855+
prompt_event.set()
856+
server_uses_ga = False
857+
858+
# The session context is the decoupling point between this shell and the
859+
# autoreply engine (which may live in a separate module). Storing
860+
# _wait_for_prompt on it lets the engine call back into our local event state
861+
# without a direct import or reference to this closure.
862+
ctx: TelnetSessionContext = telnet_writer.ctx
863+
864+
def _on_ga_or_eor(_cmd: bytes) -> None:
865+
nonlocal server_uses_ga
866+
server_uses_ga = True
867+
prompt_event.set()
868+
ar = ctx.autoreply_engine
822869
if ar is not None:
823870
ar.on_prompt()
824871

825872
from .telopt import GA, CMD_EOR
826873

827-
telnet_writer.set_iac_callback(GA, _on_prompt_signal_raw)
828-
telnet_writer.set_iac_callback(CMD_EOR, _on_prompt_signal_raw)
874+
telnet_writer.set_iac_callback(GA, _on_ga_or_eor)
875+
telnet_writer.set_iac_callback(CMD_EOR, _on_ga_or_eor)
876+
877+
async def _wait_for_prompt() -> None:
878+
"""
879+
Wait for the next prompt signal before the autoreply engine sends a reply.
829880
830-
async def _wait_for_prompt_raw() -> None:
831-
if not ga_detected_raw:
881+
No-op until the first GA/EOR confirms this server uses prompt signalling.
882+
After that, blocks until :func:`_on_ga_or_eor` fires the event, then clears
883+
it to arm the wait for the following prompt. A 2-second safety timeout
884+
prevents stalling if the server stops sending GA mid-session.
885+
"""
886+
if not server_uses_ga:
832887
return
833888
try:
834-
await asyncio.wait_for(prompt_ready_raw.wait(), timeout=2.0)
889+
await asyncio.wait_for(prompt_event.wait(), timeout=2.0)
835890
except asyncio.TimeoutError:
836891
pass
837-
prompt_ready_raw.clear()
892+
prompt_event.clear()
838893

839-
_sh_ctx.autoreply_wait_fn = _wait_for_prompt_raw
894+
ctx.autoreply_wait_fn = _wait_for_prompt
840895

841896
escape_name = accessories.name_unicode(keyboard_escape)
842897
banner_sep = "\r\n" if tty_shell._istty else linesep
843898
stdout.write(f"Escape character is '{escape_name}'.{banner_sep}".encode())
844899

845900
def _handle_close(msg: str) -> None:
901+
# \033[m resets all SGR attributes so server-set colours do not
902+
# bleed into the terminal after disconnect.
846903
stdout.write(f"\033[m{linesep}{msg}{linesep}".encode())
847904
tty_shell.cleanup_winch()
848905

849-
def _want_repl() -> bool:
906+
def _should_reactivate_repl() -> bool:
907+
# Extension point for callers that embed a REPL (e.g. a MUD client).
908+
# Return True to break _raw_event_loop and return to the REPL when
909+
# the server puts the terminal back into local mode. The base shell
910+
# has no REPL, so this always returns False.
850911
return False
851912

852-
# Standard event loop (byte-at-a-time).
913+
# Wait up to 50 ms for subsequent WILL ECHO / WILL SGA packets to arrive before
914+
# committing to a terminal mode.
915+
#
916+
# check_negotiation() declares the handshake complete as soon as TTYPE and NEW_ENVIRON /
917+
# CHARSET are settled, without waiting for ECHO / SGA. Those options typically travel
918+
# in the same "initial negotiation burst" but may not have not yet have "arrived" at
919+
# this point in our TCP read until a few milliseconds later. Servers that never send
920+
# WILL ECHO (rlogin, basically) simply time out and proceed correctly.
921+
raw_mode = _get_raw_mode(telnet_writer)
922+
if raw_mode is not False and tty_shell._istty:
923+
try:
924+
await asyncio.wait_for(
925+
telnet_writer.wait_for_condition(lambda w: w.mode != "local"), timeout=0.05
926+
)
927+
except (asyncio.TimeoutError, asyncio.CancelledError):
928+
pass
929+
930+
# Commit the terminal to raw mode now that will_echo is stable. suppress_echo=True
931+
# disables the kernel's local ECHO because the server will echo (or we handle it in
932+
# software). local_echo is set to True only when the server will NOT echo, so we
933+
# reproduce keystrokes ourselves.
853934
if not switched_to_raw and tty_shell._istty and tty_shell._save_mode is not None:
854935
tty_shell.set_mode(tty_shell._make_raw(tty_shell._save_mode, suppress_echo=True))
855936
switched_to_raw = True
@@ -871,6 +952,6 @@ def _want_repl() -> bool:
871952
keyboard_escape,
872953
state,
873954
_handle_close,
874-
_want_repl,
955+
_should_reactivate_repl,
875956
)
876957
tty_shell.disconnect_stdin(stdin)

telnetlib3/fingerprinting.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,8 +358,9 @@ async def probe_client_capabilities(
358358

359359
await writer.drain()
360360

361-
deadline = asyncio.get_event_loop().time() + timeout
362-
while asyncio.get_event_loop().time() < deadline:
361+
loop = asyncio.get_running_loop()
362+
deadline = loop.time() + timeout
363+
while loop.time() < deadline:
363364
all_responded = all(
364365
writer.remote_option.get(opt) is not None
365366
for opt, name, desc in to_probe

telnetlib3/server.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -780,7 +780,7 @@ async def _upgrade_to_tls(self) -> None:
780780
(rewritten in 3.11). See
781781
https://github.com/python/cpython/issues/79156
782782
"""
783-
loop = asyncio.get_event_loop()
783+
loop = asyncio.get_running_loop()
784784
assert self._transport is not None
785785
protocol = self._real_factory()
786786
try:
@@ -1138,13 +1138,13 @@ def _make_telnet_protocol() -> asyncio.Protocol:
11381138
def factory() -> asyncio.Protocol:
11391139
return _TLSAutoDetectProtocol(ssl, _make_telnet_protocol)
11401140

1141-
telnet_server._server = await asyncio.get_event_loop().create_server(factory, host, port)
1141+
telnet_server._server = await asyncio.get_running_loop().create_server(factory, host, port)
11421142
else:
11431143

11441144
def factory() -> asyncio.Protocol:
11451145
return _make_telnet_protocol()
11461146

1147-
telnet_server._server = await asyncio.get_event_loop().create_server(
1147+
telnet_server._server = await asyncio.get_running_loop().create_server(
11481148
factory, host, port, ssl=ssl
11491149
)
11501150

@@ -1392,7 +1392,7 @@ async def guarded_shell(
13921392
_cfg_mapping = ", ".join((f"{field}={{{field}}}" for field in CONFIG._fields)).format(**_locals)
13931393
logger.debug("Server configuration: %s", _cfg_mapping)
13941394

1395-
loop = asyncio.get_event_loop()
1395+
loop = asyncio.get_running_loop()
13961396

13971397
# bind
13981398
server = await create_server(

telnetlib3/server_fingerprinting.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -990,7 +990,7 @@ async def _read_banner_until_quiet(
990990
stripped_accum = bytearray()
991991
esc_responded = False
992992
menu_responded = False
993-
loop = asyncio.get_event_loop()
993+
loop = asyncio.get_running_loop()
994994
deadline = loop.time() + max_wait
995995
while loop.time() < deadline:
996996
remaining = min(quiet_time, deadline - loop.time())

telnetlib3/server_pty_shell.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ async def run(self) -> None:
305305
"""Bridge loop between telnet and PTY."""
306306
import errno
307307

308-
loop = asyncio.get_event_loop()
308+
loop = asyncio.get_running_loop()
309309
pty_read_event = asyncio.Event()
310310
pty_data_queue: asyncio.Queue[bytes] = asyncio.Queue()
311311

@@ -583,7 +583,7 @@ async def _wait_for_terminal_info(
583583
:param writer: TelnetWriter instance.
584584
:param timeout: Maximum time to wait in seconds.
585585
"""
586-
loop = asyncio.get_event_loop()
586+
loop = asyncio.get_running_loop()
587587
start = loop.time()
588588

589589
while loop.time() - start < timeout:

telnetlib3/tests/accessories.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ async def asyncio_server(protocol_factory, host, port):
145145
class TrackingProtocol(_TrackingProtocol, protocol_factory):
146146
_transports = transports
147147

148-
server = await asyncio.get_event_loop().create_server(TrackingProtocol, host, port)
148+
server = await asyncio.get_running_loop().create_server(TrackingProtocol, host, port)
149149
try:
150150
yield server
151151
finally:

telnetlib3/tests/test_client_unit.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ async def test_begin_shell_cancelled_future():
389389
client = BaseClient.__new__(BaseClient)
390390
client.log = types.SimpleNamespace(debug=lambda *a, **kw: None, isEnabledFor=lambda _: False)
391391
client.shell = lambda r, w: None
392-
fut = asyncio.get_event_loop().create_future()
392+
fut = asyncio.get_running_loop().create_future()
393393
fut.cancel()
394394
client.begin_shell(fut)
395395

telnetlib3/tests/test_pty_shell.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,10 @@ def begin_shell(self, result):
112112
await writer.drain()
113113

114114
result = ""
115-
deadline = asyncio.get_event_loop().time() + 2.0
115+
loop = asyncio.get_running_loop()
116+
deadline = loop.time() + 2.0
116117
while "hello world" not in result:
117-
remaining = deadline - asyncio.get_event_loop().time()
118+
remaining = deadline - loop.time()
118119
if remaining <= 0:
119120
break
120121
chunk = await asyncio.wait_for(reader.read(50), remaining)
@@ -892,7 +893,7 @@ async def noop_bridge(*a):
892893

893894
with (
894895
patch("os.waitpid", return_value=(0, 0)),
895-
patch("asyncio.get_event_loop", return_value=mock_loop),
896+
patch("asyncio.get_running_loop", return_value=mock_loop),
896897
patch.object(session, "_bridge_loop", side_effect=noop_bridge),
897898
):
898899
await session.run()

0 commit comments

Comments
 (0)