@@ -173,7 +173,15 @@ def feed(self, data: bytes) -> bytes:
173173
174174@dataclass
175175class _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 )
0 commit comments