Skip to content

Commit 0efa16a

Browse files
authored
Fix python 3.12 compatibility and improve ssh graceful teardown (PR #43)
1 parent 2b83c0d commit 0efa16a

2 files changed

Lines changed: 74 additions & 18 deletions

File tree

gambaterm/ssh.py

Lines changed: 67 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,20 @@
99
from pathlib import Path
1010
from dataclasses import dataclass
1111
from contextlib import asynccontextmanager
12-
from typing import Callable, TypeAlias, ContextManager, AsyncIterator
12+
from typing import AnyStr, Callable, TypeAlias, ContextManager, AsyncIterator
1313
from enum import Enum, auto
1414
from concurrent.futures import ThreadPoolExecutor, CancelledError
1515

1616
import asyncssh
17-
from asyncssh import SSHServerProcess, SSHAcceptor
17+
from asyncssh import (
18+
SFTPServerFactory,
19+
SSHServerConnection,
20+
SSHServerProcess,
21+
SSHAcceptor,
22+
SSHServer,
23+
SSHServerProcessFactory,
24+
)
25+
from asyncssh.channel import SSHChannel
1826
from blessed import Terminal
1927

2028
from .run import run
@@ -176,7 +184,7 @@ def ssh_terminal_handler(
176184
terminal_type: str,
177185
executor: ThreadPoolExecutor,
178186
) -> int:
179-
# Now is a good time to instanciate the console
187+
# Now is a good time to instantiate the console
180188
# (it might fail if the ROM does not exist for instance)
181189
console = console_callback()
182190

@@ -285,7 +293,28 @@ class NoAuthentication:
285293
)
286294

287295

288-
class SSHServer(asyncssh.SSHServer):
296+
class GambatermSSHServerProcess(SSHServerProcess[str]):
297+
def __init__(
298+
self,
299+
process_factory: SSHServerProcessFactory[str],
300+
sftp_factory: SFTPServerFactory | None,
301+
sftp_version: int,
302+
allow_scp: bool,
303+
active_sessions: set[GambatermSSHServerProcess],
304+
):
305+
super().__init__(process_factory, sftp_factory, sftp_version, allow_scp)
306+
self._gambaterm_active_sessions = active_sessions
307+
308+
def connection_made(self, chan: SSHChannel[AnyStr]) -> None:
309+
self._gambaterm_active_sessions.add(self)
310+
return super().connection_made(chan)
311+
312+
def connection_lost(self, exc: Exception | None) -> None:
313+
self._gambaterm_active_sessions.discard(self)
314+
return super().connection_lost(exc)
315+
316+
317+
class GambatermSSHServer(SSHServer):
289318
def __init__(
290319
self,
291320
authentication: AuthenticationMethod,
@@ -294,27 +323,38 @@ def __init__(
294323
command_parser: CommandParser,
295324
users_directory: Path,
296325
executor: ThreadPoolExecutor,
326+
active_connections: dict[GambatermSSHServer, SSHServerConnection],
297327
):
298328
self._gambaterm_console_cls = console_cls
299329
self._gambaterm_namespace = namespace
300330
self._gambaterm_command_parser = command_parser
301331
self._gambaterm_users_directory = users_directory
302332
self._gambaterm_executor = executor
303333
self._gambaterm_authentication = authentication
334+
self._gambaterm_active_connections = active_connections
335+
self._gambaterm_active_sessions: set[GambatermSSHServerProcess] = set()
304336

305-
def connection_made(self, conn: asyncssh.SSHServerConnection) -> None:
337+
def connection_made(self, conn: SSHServerConnection) -> None:
306338
conn.set_extra_info(console_cls=self._gambaterm_console_cls)
307339
conn.set_extra_info(executor=self._gambaterm_executor)
308340
conn.set_extra_info(namespace=self._gambaterm_namespace)
309341
conn.set_extra_info(command_parser=self._gambaterm_command_parser)
310342
conn.set_extra_info(users_directory=self._gambaterm_users_directory)
343+
self._gambaterm_active_connections[self] = conn
344+
345+
def connection_lost(self, exc: Exception | None) -> None:
346+
self._gambaterm_active_connections.pop(self)
311347

312348
def begin_auth(self, username: str) -> bool:
313349
return not isinstance(self._gambaterm_authentication, NoAuthentication)
314350

315351
def session_requested(self) -> SSHServerProcess[str]:
316-
return asyncssh.SSHServerProcess(
317-
safe_ssh_process_handler, sftp_factory=None, sftp_version=3, allow_scp=False
352+
return GambatermSSHServerProcess(
353+
safe_ssh_process_handler,
354+
sftp_factory=None,
355+
sftp_version=3,
356+
allow_scp=False,
357+
active_sessions=self._gambaterm_active_sessions,
318358
)
319359

320360
def password_auth_supported(self) -> bool:
@@ -391,14 +431,16 @@ async def run_ssh_server(
391431
"aes128-ctr",
392432
]
393433

434+
active_connections: dict[GambatermSSHServer, SSHServerConnection] = {}
394435
server = await asyncssh.create_server(
395-
lambda: SSHServer(
436+
lambda: GambatermSSHServer(
396437
authentication,
397438
console_cls,
398439
namespace,
399440
command_parser,
400441
users_directory,
401442
executor,
443+
active_connections,
402444
),
403445
bind,
404446
port,
@@ -428,13 +470,23 @@ async def run_ssh_server(
428470
try:
429471
yield server
430472
finally:
431-
# Stop listening
473+
# Stop listening for new connections
432474
server.close()
433475

434-
# server.close_clients()
435-
for transport in server._clients:
436-
for channel in transport._protocol._channels.values():
437-
channel._session._writers[None].write_eof()
476+
# Freeze active connections
477+
for ssh_server, connection in list(active_connections.items()):
478+
# Freeze active sessions
479+
for session in list(ssh_server._gambaterm_active_sessions):
480+
# Graceful teardown
481+
# This is important to make sure the client receives the cleanup data
482+
session.eof_received()
483+
await session.wait_closed()
484+
485+
# Close the connection
486+
# This is important for clients stuck in authentication phase for instance
487+
connection.close()
488+
489+
# Now nothing should keep the server from closing
438490
await server.wait_closed()
439491

440492

@@ -452,7 +504,7 @@ def main(
452504
"-b",
453505
type=str,
454506
default="127.0.0.1",
455-
help="Bind adress of the SSH server, "
507+
help="Bind address of the SSH server, "
456508
"use `0.0.0.0` for all interfaces (default is localhost)",
457509
)
458510
parser.add_argument(
@@ -467,7 +519,7 @@ def main(
467519
"--pw",
468520
type=str,
469521
default=None,
470-
help="Enable password authentification with the given global password",
522+
help="Enable password authentication with the given global password",
471523
)
472524
parser.add_argument(
473525
"--no-auth",

tests/test_gambaterm.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import signal
23
import sys
34
import asyncio
45
import pytest
@@ -110,12 +111,13 @@ async def ssh_client() -> str:
110111
assert "| test_rom.gb |" in client_stdout
111112
assert "▀ ▄▄ ▀" in client_stdout
112113
finally:
113-
server.terminate()
114+
server.send_signal(signal.SIGINT)
114115
server.wait()
115116
print(server.stdout.read(), end="", file=sys.stdout)
116117
print(server.stderr.read(), end="", file=sys.stderr)
117118
server.stdout.close()
118119
server.stderr.close()
120+
assert server.returncode == 0
119121

120122

121123
@pytest.mark.parametrize("color_arg", COLOR_ARG_VARIANTS)
@@ -168,12 +170,13 @@ async def telnet_client() -> str:
168170
assert "| test_rom.gb |" in result
169171
assert "\u2580" in result or "\u2584" in result
170172
finally:
171-
server.terminate()
173+
server.send_signal(signal.SIGINT)
172174
server.wait()
173175
print(server.stdout.read(), end="", file=sys.stdout)
174176
print(server.stderr.read(), end="", file=sys.stderr)
175177
server.stdout.close()
176178
server.stderr.close()
179+
assert server.returncode == 0
177180

178181

179182
def test_gambaterm_telnet_unknown_term() -> None:
@@ -231,9 +234,10 @@ async def telnet_client() -> str:
231234
assert "| test_rom.gb |" in result
232235
assert "\u2580" in result or "\u2584" in result
233236
finally:
234-
server.terminate()
237+
server.send_signal(signal.SIGINT)
235238
server.wait()
236239
print(server.stdout.read(), end="", file=sys.stdout)
237240
print(server.stderr.read(), end="", file=sys.stderr)
238241
server.stdout.close()
239242
server.stderr.close()
243+
assert server.returncode == 0

0 commit comments

Comments
 (0)