Skip to content

Commit 568c990

Browse files
committed
Remove telnet_input module
1 parent ec93e24 commit 568c990

4 files changed

Lines changed: 96 additions & 472 deletions

File tree

gambaterm/telnet.py

Lines changed: 45 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
from __future__ import annotations
22

3-
import os
4-
import re
53
import time
64
import hashlib
75
import asyncio
86
import argparse
97
import traceback
10-
from contextlib import contextmanager
118
from pathlib import Path
12-
from typing import TYPE_CHECKING, Any, Callable, Coroutine, Iterator
9+
from typing import TYPE_CHECKING, Any, Callable, Coroutine, ContextManager
1310
from concurrent.futures import ThreadPoolExecutor
1411

1512
if TYPE_CHECKING:
@@ -20,36 +17,20 @@
2017
from .colors import ColorMode
2118
from .file_input import console_input_from_file_context
2219
from .main import add_base_arguments, add_optional_arguments, AppConfig
23-
from .console import Console, InputGetter, GameboyColor
24-
from .keyboard_input import MESSAGE_SUGGESTING_KITTY_SUPPORT
25-
from .telnet_input import TelnetInputState, read_telnet_input
20+
from .console import Console, GameboyColor
21+
from .input_getter import BaseInputGetter
22+
from .keyboard_input import (
23+
MESSAGE_SUGGESTING_KITTY_SUPPORT,
24+
console_input_from_keyboard_protocol_context,
25+
is_kitty_keyboard_protocol_supported,
26+
)
2627
from .remote_terminal import RemoteTerminal
2728
from .telnet_app_session import (
2829
set_tcp_nodelay,
2930
telnet_to_terminal,
3031
)
3132

3233

33-
@contextmanager
34-
def no_input_context(console: Console) -> Iterator[InputGetter]:
35-
"""Provide a no-op input getter that returns no button presses."""
36-
yield lambda: set()
37-
38-
39-
@contextmanager
40-
def telnet_input_context(
41-
console: Console, state: TelnetInputState
42-
) -> Iterator[InputGetter]:
43-
"""Provide input from telnet keyboard state."""
44-
45-
def get_input() -> set[Console.Input]:
46-
for event in state.pop_events():
47-
console.handle_event(event)
48-
return state.get_input()
49-
50-
yield get_input
51-
52-
5334
def _save_dir_name(username: str | None) -> str:
5435
"""Hash the username into a safe directory name.
5536
@@ -62,90 +43,60 @@ def _save_dir_name(username: str | None) -> str:
6243

6344

6445
def thread_target(
65-
term: RemoteTerminal,
46+
terminal: RemoteTerminal,
6647
console_callback: Callable[[], Console],
6748
app_config: AppConfig,
6849
color_mode: ColorMode,
69-
input_state: TelnetInputState | None = None,
50+
username: str | None,
7051
) -> int:
7152
"""Run the emulator in a thread with the given RemoteTerminal."""
7253
console: Console = console_callback()
7354

55+
console_input_context: ContextManager[BaseInputGetter]
7456
if app_config.input_file is not None:
7557
console_input_context = console_input_from_file_context(
76-
console, app_config.input_file, app_config.skip_inputs
58+
console, terminal, app_config.input_file, app_config.skip_inputs
59+
)
60+
elif is_kitty_keyboard_protocol_supported(terminal, timeout=3):
61+
console_input_context = console_input_from_keyboard_protocol_context(
62+
console,
63+
terminal,
7764
)
78-
elif input_state is not None:
79-
console_input_context = telnet_input_context(console, input_state)
8065
else:
81-
console_input_context = no_input_context(console)
82-
83-
with console_input_context as get_console_input:
84-
try:
85-
term.stream.write(term.enter_fullscreen + term.clear + term.hide_cursor)
86-
term.stream.flush()
66+
message = MESSAGE_SUGGESTING_KITTY_SUPPORT
67+
terminal.stream.write(message)
68+
terminal.stream.flush()
69+
print(f"< User `{username}` did not support keyboard protocol")
70+
return 1
8771

72+
try:
73+
terminal.stream.write(
74+
terminal.enter_fullscreen + terminal.clear + terminal.hide_cursor
75+
)
76+
terminal.stream.flush()
77+
with console_input_context as get_console_input:
8878
run(
8979
console,
90-
get_input=get_console_input,
91-
term=term,
80+
input_getter=get_console_input,
81+
term=terminal,
9282
frame_advance=app_config.frame_advance,
9383
color_mode=color_mode,
9484
break_after=app_config.break_after,
95-
speed_factor=app_config.speed_factor,
85+
speed=app_config.speed,
9686
)
97-
except (KeyboardInterrupt, OSError):
98-
return 0
99-
else:
100-
return 0
101-
finally:
102-
time.sleep(0.1)
103-
term.stream.write(term.clear + term.exit_fullscreen + term.normal_cursor)
104-
try:
105-
term.stream.flush()
106-
except BrokenPipeError:
107-
pass
108-
109-
110-
_KITTY_RESPONSE_RE = re.compile(rb"\x1b\[\?([0-9]*)u")
111-
112-
113-
async def _detect_kitty_keyboard(
114-
reader: TelnetReader, writer: TelnetWriter, timeout: float = 3.0
115-
) -> bool:
116-
"""Check if the telnet client supports the kitty keyboard protocol.
117-
118-
Must be called before the input reading task starts.
119-
120-
:param reader: telnetlib3 reader
121-
:param writer: telnetlib3 writer
122-
:param timeout: seconds to wait for response
123-
:returns: ``True`` if the terminal responds to the kitty keyboard query
124-
"""
125-
writer.write(b"\x1b[?u")
126-
await writer.drain()
127-
128-
buf = b""
129-
loop = asyncio.get_event_loop()
130-
deadline = loop.time() + timeout
131-
while True:
132-
remaining = deadline - loop.time()
133-
if remaining <= 0:
134-
break
87+
except (KeyboardInterrupt, OSError):
88+
return 0
89+
else:
90+
return 0
91+
finally:
92+
time.sleep(0.1)
93+
terminal.stream.write(
94+
terminal.clear + terminal.exit_fullscreen + terminal.normal_cursor
95+
)
13596
try:
136-
chunk = await asyncio.wait_for(
137-
reader.read(256),
138-
timeout=remaining,
139-
)
140-
if not chunk:
141-
break
142-
buf += chunk if isinstance(chunk, bytes) else chunk.encode("latin-1")
143-
if _KITTY_RESPONSE_RE.search(buf):
144-
return True
145-
except asyncio.TimeoutError:
146-
break
147-
148-
return False
97+
terminal.stream.flush()
98+
except BrokenPipeError:
99+
pass
149100

150101

151102
ShellCallback = Callable[["TelnetReader", "TelnetWriter"], Coroutine[Any, Any, None]]
@@ -298,25 +249,6 @@ async def _telnet_shell(
298249
# Set TCP_NODELAY to disable Nagle's algorithm for paced output
299250
set_tcp_nodelay(writer)
300251

301-
# Require kitty keyboard protocol (must be checked before input task starts)
302-
if getattr(app_config, "input_file", None) is None:
303-
if not await _detect_kitty_keyboard(reader, writer):
304-
print(
305-
f"< Telnet client {peer_host} does not support "
306-
f"kitty keyboard protocol"
307-
)
308-
msg = MESSAGE_SUGGESTING_KITTY_SUPPORT.replace("\n", "\r\n")
309-
writer.write(msg.encode("utf-8"))
310-
await writer.drain()
311-
return 1
312-
313-
# Create input pipe for Ctrl+C/D forwarding
314-
input_read_fd, input_write_fd = os.pipe()
315-
316-
state = TelnetInputState()
317-
input_task = asyncio.create_task(
318-
read_telnet_input(reader, writer, state, input_write_fd)
319-
)
320252
stats_task = asyncio.create_task(
321253
_log_connection_stats(writer, peer_host, peer_port)
322254
)
@@ -346,33 +278,20 @@ async def _telnet_shell(
346278
config = AppConfig(**vars(namespace))
347279

348280
def target(term: RemoteTerminal) -> int:
349-
return thread_target(term, console_callback, config, color_mode, state)
281+
return thread_target(term, console_callback, config, color_mode, username)
350282

351283
return await telnet_to_terminal(
284+
reader,
352285
writer,
353286
executor,
354287
target,
355-
input_read_fd,
356288
)
357289
finally:
358-
input_task.cancel()
359290
stats_task.cancel()
360-
try:
361-
await input_task
362-
except asyncio.CancelledError:
363-
pass
364291
try:
365292
await stats_task
366293
except asyncio.CancelledError:
367294
pass
368-
try:
369-
os.close(input_write_fd)
370-
except OSError:
371-
pass
372-
try:
373-
os.close(input_read_fd)
374-
except OSError:
375-
pass
376295

377296

378297
async def run_server(

gambaterm/telnet_app_session.py

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
if TYPE_CHECKING:
1414
from telnetlib3.stream_writer import TelnetWriter
15+
from telnetlib3.stream_reader import TelnetReader
1516

1617
from .remote_terminal import RemoteTerminal
1718

@@ -106,11 +107,30 @@ def on_naws(rows: int, cols: int) -> None:
106107
writer.set_ext_callback(NAWS, original_on_naws)
107108

108109

110+
async def async_reader_to_sync_pipe(reader: TelnetReader, write_fd: int) -> None:
111+
loop = asyncio.get_running_loop()
112+
pipe_file = os.fdopen(write_fd, "wb", buffering=0)
113+
transport, protocol = await loop.connect_write_pipe(
114+
lambda: asyncio.streams.FlowControlMixin(), pipe_file
115+
)
116+
writer = asyncio.StreamWriter(transport, protocol, None, loop)
117+
118+
try:
119+
while True:
120+
data = await reader.read(4096)
121+
if not data:
122+
break
123+
writer.write(data)
124+
await writer.drain()
125+
finally:
126+
writer.close()
127+
128+
109129
async def telnet_to_terminal(
130+
reader: TelnetReader,
110131
writer: TelnetWriter,
111132
executor: ThreadPoolExecutor,
112133
target: Callable[[RemoteTerminal], T],
113-
input_read_fd: int,
114134
) -> T:
115135
"""Create a RemoteTerminal and run *target* in a thread executor.
116136
@@ -125,29 +145,40 @@ async def telnet_to_terminal(
125145
cols = writer.get_extra_info("cols") or 80
126146
rows = writer.get_extra_info("rows") or 24
127147

128-
read_fd, write_fd = os.pipe()
148+
forward_read_fd, forward_write_fd = os.pipe()
129149
forward_task: asyncio.Task[None] | None = None
130150

151+
input_read_fd, input_write_fd = os.pipe()
152+
input_task: asyncio.Task[None] | None = None
153+
131154
def _target() -> T:
132-
with open(write_fd, "w", newline="\r\n") as stream:
133-
telnet_term = RemoteTerminal(
134-
stream=stream,
135-
keyboard_fd=input_read_fd,
136-
rows=rows,
137-
columns=cols,
138-
)
139-
with bind_resize_telnet(writer, telnet_term):
140-
return target(telnet_term)
155+
try:
156+
with open(forward_write_fd, "w", newline="\r\n") as stream:
157+
telnet_term = RemoteTerminal(
158+
stream=stream,
159+
keyboard_fd=input_read_fd,
160+
rows=rows,
161+
columns=cols,
162+
)
163+
with bind_resize_telnet(writer, telnet_term):
164+
return target(telnet_term)
165+
finally:
166+
os.close(input_read_fd)
141167

142168
loop = asyncio.get_running_loop()
143-
forward_task = asyncio.create_task(paced_forward_output(read_fd, writer))
169+
forward_task = asyncio.create_task(paced_forward_output(forward_read_fd, writer))
170+
input_task = asyncio.create_task(async_reader_to_sync_pipe(reader, input_write_fd))
144171
try:
145172
return await loop.run_in_executor(executor, _target)
146173
finally:
147174
# write_fd is closed by open(write_fd, "w").__exit__ in _target,
148175
# so forward_task will see EOF. Just wait for it to finish.
149-
if forward_task is not None:
150-
try:
151-
await asyncio.wait_for(forward_task, timeout=2.0)
152-
except asyncio.TimeoutError:
153-
forward_task.cancel()
176+
try:
177+
await asyncio.wait_for(forward_task, timeout=2.0)
178+
except asyncio.TimeoutError:
179+
forward_task.cancel()
180+
try:
181+
input_task.cancel()
182+
await asyncio.wait_for(input_task, timeout=2.0)
183+
except (asyncio.TimeoutError, asyncio.CancelledError):
184+
pass

0 commit comments

Comments
 (0)