-
Notifications
You must be signed in to change notification settings - Fork 2
cli: make burn terminal bidirectional #131
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| """Bidirectional raw terminal bridge for the burn command.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import importlib | ||
| import os | ||
| import signal | ||
| from collections.abc import Iterator | ||
| from contextlib import contextmanager | ||
| from typing import BinaryIO | ||
|
|
||
| from defib.transport.base import Transport, TransportError, TransportTimeout | ||
|
|
||
|
|
||
| @contextmanager | ||
| def _raw_terminal(stdin: BinaryIO) -> Iterator[None]: | ||
| """Disable canonical input, translations, and local echo.""" | ||
| if os.name != "posix" or not stdin.isatty(): | ||
| yield | ||
| return | ||
|
|
||
| import termios | ||
| import tty | ||
|
|
||
| fd = stdin.fileno() | ||
| previous = termios.tcgetattr(fd) | ||
| tty.setraw(fd) | ||
| try: | ||
| yield | ||
| finally: | ||
| termios.tcsetattr(fd, termios.TCSADRAIN, previous) | ||
|
|
||
|
|
||
| async def _pump_posix_stdin( | ||
| transport: Transport, | ||
| fd: int, | ||
| stop: asyncio.Event, | ||
| ) -> None: | ||
| loop = asyncio.get_running_loop() | ||
| queue: asyncio.Queue[bytes] = asyncio.Queue() | ||
|
|
||
| def on_readable() -> None: | ||
| try: | ||
| data = os.read(fd, 1024) | ||
| except OSError: | ||
| data = b"" | ||
| if not data: | ||
| loop.remove_reader(fd) | ||
| queue.put_nowait(data) | ||
|
|
||
| loop.add_reader(fd, on_readable) | ||
| try: | ||
| while not stop.is_set(): | ||
| try: | ||
| data = await asyncio.wait_for(queue.get(), timeout=0.1) | ||
| except TimeoutError: | ||
| continue | ||
| if not data: | ||
| stop.set() | ||
| return | ||
| if not await _forward_input(transport, data, stop): | ||
| return | ||
| finally: | ||
| loop.remove_reader(fd) | ||
|
|
||
|
|
||
| async def _forward_input( | ||
| transport: Transport, | ||
| data: bytes, | ||
| stop: asyncio.Event, | ||
| ) -> bool: | ||
| """Forward input bytes, treating Ctrl-C as a local terminal command.""" | ||
| before_sigint, separator, _ = data.partition(b"\x03") | ||
| if before_sigint: | ||
| await transport.write(before_sigint) | ||
| if separator: | ||
| stop.set() | ||
| return False | ||
| return True | ||
|
|
||
|
|
||
| async def _pump_stream_stdin( | ||
| transport: Transport, | ||
| stdin: BinaryIO, | ||
| stop: asyncio.Event, | ||
| ) -> None: | ||
| while not stop.is_set(): | ||
| data = await asyncio.to_thread(stdin.read, 1024) | ||
| if not data: | ||
| stop.set() | ||
| return | ||
| if not await _forward_input(transport, data, stop): | ||
| return | ||
|
|
||
|
|
||
| async def _pump_windows_console( | ||
| transport: Transport, | ||
| stop: asyncio.Event, | ||
| ) -> None: | ||
| msvcrt = importlib.import_module("msvcrt") | ||
|
|
||
| while not stop.is_set(): | ||
| if not msvcrt.kbhit(): | ||
| await asyncio.sleep(0.01) | ||
| continue | ||
| char = msvcrt.getch() | ||
| if char in (b"\x00", b"\xe0"): | ||
| msvcrt.getch() | ||
| continue | ||
| if not await _forward_input(transport, char, stop): | ||
| return | ||
|
|
||
|
|
||
| async def _pump_transport( | ||
| transport: Transport, | ||
| stdout: BinaryIO, | ||
| stop: asyncio.Event, | ||
| ) -> None: | ||
| while not stop.is_set(): | ||
| try: | ||
| data = await transport.read(256, timeout=0.1) | ||
| except TransportTimeout: | ||
| continue | ||
| if not data: | ||
| stop.set() | ||
| return | ||
| stdout.write(data) | ||
| stdout.flush() | ||
|
|
||
|
|
||
| async def _bridge_terminal( | ||
| transport: Transport, | ||
| stdin: BinaryIO, | ||
| stdout: BinaryIO, | ||
| stop: asyncio.Event, | ||
| ) -> None: | ||
| if os.name == "nt" and stdin.isatty(): | ||
| stdin_task = asyncio.create_task(_pump_windows_console(transport, stop)) | ||
| elif stdin.isatty(): | ||
| stdin_task = asyncio.create_task(_pump_posix_stdin(transport, stdin.fileno(), stop)) | ||
| else: | ||
| stdin_task = asyncio.create_task(_pump_stream_stdin(transport, stdin, stop)) | ||
| output_task = asyncio.create_task(_pump_transport(transport, stdout, stop)) | ||
| stop_task = asyncio.create_task(stop.wait()) | ||
| tasks = {stdin_task, output_task, stop_task} | ||
|
|
||
| done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) | ||
| stop.set() | ||
| for task in pending: | ||
| task.cancel() | ||
| await asyncio.gather(*pending, return_exceptions=True) | ||
|
|
||
| for task in done: | ||
| if task is not stop_task: | ||
| task.result() | ||
|
|
||
|
|
||
| async def run_raw_terminal( | ||
| transport: Transport, | ||
| stdin: BinaryIO, | ||
| stdout: BinaryIO, | ||
| ) -> None: | ||
| """Bridge stdin and transport until EOF, disconnect, or Ctrl-C.""" | ||
| stop = asyncio.Event() | ||
| previous_handler = signal.getsignal(signal.SIGINT) | ||
|
|
||
| def on_sigint(*_: object) -> None: | ||
| stop.set() | ||
|
|
||
| signal.signal(signal.SIGINT, on_sigint) | ||
| try: | ||
| with _raw_terminal(stdin): | ||
| try: | ||
| await _bridge_terminal(transport, stdin, stdout, stop) | ||
| except TransportError: | ||
| pass | ||
| finally: | ||
| signal.signal(signal.SIGINT, previous_handler) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| """Tests for the interactive raw terminal bridge.""" | ||
|
|
||
| import asyncio | ||
| import io | ||
| import os | ||
| import signal | ||
|
|
||
| import pytest | ||
|
|
||
| from defib.cli.terminal import run_raw_terminal | ||
| from defib.transport.base import TransportError | ||
| from defib.transport.mock import MockTransport | ||
|
|
||
|
|
||
| requires_pty = pytest.mark.skipif(os.name != "posix", reason="PTY tests require POSIX") | ||
|
|
||
|
|
||
| async def _wait_until(predicate, timeout: float = 1.0) -> None: | ||
| loop = asyncio.get_running_loop() | ||
| deadline = loop.time() + timeout | ||
| while loop.time() < deadline: | ||
| if predicate(): | ||
| return | ||
| await asyncio.sleep(0.01) | ||
| raise AssertionError("condition not met before timeout") | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| @requires_pty | ||
| async def test_pty_input_is_forwarded_and_transport_output_is_printed() -> None: | ||
| import pty | ||
| import termios | ||
|
|
||
| master_fd, slave_fd = pty.openpty() | ||
| stdin = os.fdopen(slave_fd, "rb", buffering=0) | ||
| stdout = io.BytesIO() | ||
| transport = MockTransport() | ||
| transport.enqueue_rx(b"hisilicon # ") | ||
|
|
||
| task = asyncio.create_task(run_raw_terminal(transport, stdin, stdout)) | ||
| try: | ||
| await _wait_until(lambda: not termios.tcgetattr(stdin.fileno())[3] & termios.ECHO) | ||
| os.write(master_fd, b"help\r") | ||
| await _wait_until(lambda: b"help\r" in transport.all_tx_data) | ||
| await _wait_until(lambda: b"hisilicon # " in stdout.getvalue()) | ||
| os.write(master_fd, b"\x03") | ||
| await asyncio.wait_for(task, timeout=1.0) | ||
| finally: | ||
| if not task.done(): | ||
| task.cancel() | ||
| await asyncio.gather(task, return_exceptions=True) | ||
| os.close(master_fd) | ||
| stdin.close() | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| @requires_pty | ||
| async def test_sigint_stops_bridge_and_restores_pty() -> None: | ||
| import pty | ||
| import termios | ||
|
|
||
| master_fd, slave_fd = pty.openpty() | ||
| stdin = os.fdopen(slave_fd, "rb", buffering=0) | ||
| stdout = io.BytesIO() | ||
| transport = MockTransport() | ||
| original_attrs = termios.tcgetattr(stdin.fileno()) | ||
| original_handler = signal.getsignal(signal.SIGINT) | ||
|
|
||
| task = asyncio.create_task(run_raw_terminal(transport, stdin, stdout)) | ||
| try: | ||
| await _wait_until(lambda: not termios.tcgetattr(stdin.fileno())[3] & termios.ECHO) | ||
|
|
||
| signal.raise_signal(signal.SIGINT) | ||
| await asyncio.wait_for(task, timeout=1.0) | ||
|
|
||
| assert termios.tcgetattr(stdin.fileno()) == original_attrs | ||
| assert signal.getsignal(signal.SIGINT) == original_handler | ||
| finally: | ||
| if not task.done(): | ||
| task.cancel() | ||
| await asyncio.gather(task, return_exceptions=True) | ||
| os.close(master_fd) | ||
| stdin.close() | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_redirected_stdin_is_forwarded_until_eof() -> None: | ||
| stdin = io.BytesIO(b"help\r") | ||
| stdout = io.BytesIO() | ||
| transport = MockTransport() | ||
|
|
||
| await run_raw_terminal(transport, stdin, stdout) | ||
|
|
||
| assert transport.all_tx_data == b"help\r" | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| @requires_pty | ||
| async def test_transport_disconnect_ends_terminal_cleanly() -> None: | ||
| import pty | ||
|
|
||
| class DisconnectingTransport(MockTransport): | ||
| async def read(self, size: int, timeout: float | None = None) -> bytes: | ||
| raise TransportError("remote disconnected") | ||
|
|
||
| master_fd, slave_fd = pty.openpty() | ||
| stdin = os.fdopen(slave_fd, "rb", buffering=0) | ||
| task = asyncio.create_task( | ||
| run_raw_terminal(DisconnectingTransport(), stdin, io.BytesIO()) | ||
| ) | ||
| try: | ||
| await asyncio.wait_for(task, timeout=1.0) | ||
| finally: | ||
| if not task.done(): | ||
| task.cancel() | ||
| await asyncio.gather(task, return_exceptions=True) | ||
| os.close(master_fd) | ||
| stdin.close() |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.