mirror of
https://github.com/lowobservable/oec.git
synced 2026-01-11 23:53:04 +00:00
Refactor run loop to improve perceived terminal responsiveness
This commit is contained in:
parent
122e55bf5c
commit
49ac1a9100
@ -5,6 +5,7 @@ oec.controller
|
||||
|
||||
import time
|
||||
import logging
|
||||
from select import select
|
||||
from coax import poll, poll_ack, load_control_register, get_features, PollAction, \
|
||||
KeystrokePollResponse, TerminalType, ReceiveTimeout, \
|
||||
ReceiveError, ProtocolError
|
||||
@ -56,11 +57,17 @@ class Controller:
|
||||
self.running = False
|
||||
|
||||
def _run_loop(self):
|
||||
if self.session:
|
||||
try:
|
||||
self.session.handle_host()
|
||||
except SessionDisconnectedError:
|
||||
self._handle_session_disconnected()
|
||||
poll_delay = self._calculate_poll_delay(time.perf_counter())
|
||||
|
||||
# If POLLing is delayed, handle the host output, otherwise just sleep.
|
||||
if poll_delay > 0:
|
||||
if self.session:
|
||||
try:
|
||||
self._update_session(poll_delay)
|
||||
except SessionDisconnectedError:
|
||||
self._handle_session_disconnected()
|
||||
else:
|
||||
time.sleep(poll_delay)
|
||||
|
||||
try:
|
||||
poll_response = self._poll()
|
||||
@ -150,6 +157,17 @@ class Controller:
|
||||
|
||||
self.session = None
|
||||
|
||||
def _update_session(self, duration):
|
||||
while duration > 0:
|
||||
start_time = time.perf_counter()
|
||||
|
||||
if self.session not in select([self.session], [], [], duration)[0]:
|
||||
break
|
||||
|
||||
self.session.handle_host()
|
||||
|
||||
duration -= (time.perf_counter() - start_time)
|
||||
|
||||
def _handle_poll_response(self, poll_response):
|
||||
if isinstance(poll_response, KeystrokePollResponse):
|
||||
self._handle_keystroke_poll_response(poll_response)
|
||||
@ -184,11 +202,6 @@ class Controller:
|
||||
self.session.handle_key(key, modifiers, scan_code)
|
||||
|
||||
def _poll(self):
|
||||
delay = self._calculate_poll_delay(time.perf_counter())
|
||||
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
|
||||
self.last_poll_time = time.perf_counter()
|
||||
|
||||
poll_action = self.terminal.get_poll_action() if self.terminal else PollAction.NONE
|
||||
@ -219,7 +232,7 @@ class Controller:
|
||||
else:
|
||||
period = self.disconnected_poll_period
|
||||
|
||||
return (self.last_poll_time + period) - current_time
|
||||
return max((self.last_poll_time + period) - current_time, 0)
|
||||
|
||||
def _load_control_register(self):
|
||||
load_control_register(self.interface, self.terminal.get_control_register())
|
||||
|
||||
@ -5,6 +5,9 @@ class Session:
|
||||
def terminate(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def fileno(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def handle_host(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@ -81,6 +81,9 @@ class TN3270Session(Session):
|
||||
|
||||
self.emulator = None
|
||||
|
||||
def fileno(self):
|
||||
return self.emulator.stream.socket.fileno()
|
||||
|
||||
def handle_host(self):
|
||||
try:
|
||||
if not self.emulator.update(timeout=0):
|
||||
|
||||
21
oec/vt100.py
21
oec/vt100.py
@ -4,7 +4,6 @@ oec.vt100
|
||||
"""
|
||||
|
||||
import os
|
||||
from select import select
|
||||
import logging
|
||||
from ptyprocess import PtyProcess
|
||||
import pyte
|
||||
@ -112,20 +111,23 @@ class VT100Session(Session):
|
||||
if self.host_process:
|
||||
self._terminate_host_process()
|
||||
|
||||
def fileno(self):
|
||||
return self.host_process.fileno()
|
||||
|
||||
def handle_host(self):
|
||||
data = None
|
||||
|
||||
try:
|
||||
if self.host_process not in select([self.host_process], [], [], 0)[0]:
|
||||
return False
|
||||
|
||||
data = self.host_process.read()
|
||||
except EOFError:
|
||||
self.host_process = None
|
||||
|
||||
raise SessionDisconnectedError
|
||||
|
||||
self._handle_host_output(data)
|
||||
self.vt100_stream.feed(data)
|
||||
|
||||
self._apply()
|
||||
self._flush()
|
||||
|
||||
return True
|
||||
|
||||
@ -182,15 +184,6 @@ class VT100Session(Session):
|
||||
|
||||
self.host_process = None
|
||||
|
||||
def _handle_host_output(self, data):
|
||||
if self.logger.isEnabledFor(logging.DEBUG):
|
||||
self.logger.debug(f'Host process output: {data}')
|
||||
|
||||
self.vt100_stream.feed(data)
|
||||
|
||||
self._apply()
|
||||
self._flush()
|
||||
|
||||
def _apply(self):
|
||||
for row in self.vt100_screen.dirty:
|
||||
row_buffer = self.vt100_screen.buffer[row]
|
||||
|
||||
@ -26,6 +26,8 @@ class RunLoopTestCase(unittest.TestCase):
|
||||
|
||||
self.controller.connected_poll_period = 1
|
||||
|
||||
self.controller._update_session = Mock()
|
||||
|
||||
patcher = patch('oec.controller.poll')
|
||||
|
||||
self.poll_mock = patcher.start()
|
||||
@ -73,34 +75,35 @@ class RunLoopTestCase(unittest.TestCase):
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def test_no_terminal(self):
|
||||
self._assert_run_loop(0, ReceiveTimeout, 0, False)
|
||||
self._assert_run_loop(1, ReceiveTimeout, 4, False)
|
||||
self._assert_run_loop(0, ReceiveTimeout, False, 0, False)
|
||||
self._assert_run_loop(1, ReceiveTimeout, False, 4, False)
|
||||
|
||||
self.assertIsNone(self.controller.terminal)
|
||||
self.assertIsNone(self.controller.session)
|
||||
|
||||
def test_terminal_attached(self):
|
||||
self._assert_run_loop(0, PowerOnResetCompletePollResponse(0xa), 0, True)
|
||||
self._assert_run_loop(0, None, 0, False)
|
||||
self._assert_run_loop(0.5, None, 0.5, False)
|
||||
self._assert_run_loop(0, PowerOnResetCompletePollResponse(0xa), False, 0, True)
|
||||
self._assert_run_loop(0, None, False, 0, False)
|
||||
self._assert_run_loop(0.5, None, True, 0.5, False)
|
||||
|
||||
self.assertIsNotNone(self.controller.terminal)
|
||||
self.assertIsNotNone(self.controller.session)
|
||||
|
||||
self.controller.session.handle_host.assert_called()
|
||||
self.controller._update_session.assert_called()
|
||||
|
||||
def test_unsupported_terminal_attached(self):
|
||||
self.read_terminal_ids_mock.return_value = DFT_TERMINAL_IDS
|
||||
|
||||
self._assert_run_loop(0, PowerOnResetCompletePollResponse(0xa), 0, True)
|
||||
self._assert_run_loop(0, PowerOnResetCompletePollResponse(0xa), False, 0, True)
|
||||
|
||||
self.assertIsNone(self.controller.terminal)
|
||||
self.assertIsNone(self.controller.session)
|
||||
|
||||
def test_keystroke(self):
|
||||
self._assert_run_loop(0, PowerOnResetCompletePollResponse(0xa), 0, True)
|
||||
self._assert_run_loop(0, KeystrokePollResponse(0b0110000010), 0, True)
|
||||
self._assert_run_loop(0, None, 0, False)
|
||||
self._assert_run_loop(0, PowerOnResetCompletePollResponse(0xa), False, 0, True)
|
||||
self._assert_run_loop(0, KeystrokePollResponse(0b0110000010), False, 0, True)
|
||||
self._assert_run_loop(0, None, False, 0, False)
|
||||
self._assert_run_loop(0.5, None, True, 0.5, False)
|
||||
|
||||
self.assertIsNotNone(self.controller.terminal)
|
||||
self.assertIsNotNone(self.controller.session)
|
||||
@ -108,9 +111,9 @@ class RunLoopTestCase(unittest.TestCase):
|
||||
self.controller.session.handle_key.assert_called_with(Key.LOWER_A, KeyboardModifiers.NONE, 96)
|
||||
|
||||
def test_terminal_detached(self):
|
||||
self._assert_run_loop(0, PowerOnResetCompletePollResponse(0xa), 0, True)
|
||||
self._assert_run_loop(0, None, 0, False)
|
||||
self._assert_run_loop(0.5, ReceiveTimeout, 0.5, False)
|
||||
self._assert_run_loop(0, PowerOnResetCompletePollResponse(0xa), False, 0, True)
|
||||
self._assert_run_loop(0, None, False, 0, False)
|
||||
self._assert_run_loop(0.5, ReceiveTimeout, True, 0.5, False)
|
||||
|
||||
self.assertIsNone(self.controller.terminal)
|
||||
self.assertIsNone(self.controller.session)
|
||||
@ -118,12 +121,12 @@ class RunLoopTestCase(unittest.TestCase):
|
||||
self.session_mock.terminate.assert_called()
|
||||
|
||||
def test_session_disconnected(self):
|
||||
self.session_mock.handle_host.side_effect = [None, SessionDisconnectedError, None]
|
||||
self.controller._update_session.side_effect = [None, SessionDisconnectedError, None]
|
||||
|
||||
self._assert_run_loop(0, PowerOnResetCompletePollResponse(0xa), 0, True)
|
||||
self._assert_run_loop(0, None, 0, False)
|
||||
self._assert_run_loop(0.5, None, 0.5, False)
|
||||
self._assert_run_loop(1.5, None, 0.5, False)
|
||||
self._assert_run_loop(0, PowerOnResetCompletePollResponse(0xa), False, 0, True)
|
||||
self._assert_run_loop(0, None, False, 0, False)
|
||||
self._assert_run_loop(0.5, None, True, 0.5, False)
|
||||
self._assert_run_loop(1.5, None, True, 0.5, False)
|
||||
|
||||
self.assertIsNotNone(self.controller.terminal)
|
||||
self.assertIsNotNone(self.controller.session)
|
||||
@ -132,8 +135,8 @@ class RunLoopTestCase(unittest.TestCase):
|
||||
|
||||
def test_alarm(self):
|
||||
# Arrange
|
||||
self._assert_run_loop(0, PowerOnResetCompletePollResponse(0xa), 0, True)
|
||||
self._assert_run_loop(0, None, 0, False)
|
||||
self._assert_run_loop(0, PowerOnResetCompletePollResponse(0xa), False, 0, True)
|
||||
self._assert_run_loop(0, None, False, 0, False)
|
||||
|
||||
self.assertIsNotNone(self.controller.terminal)
|
||||
|
||||
@ -141,18 +144,18 @@ class RunLoopTestCase(unittest.TestCase):
|
||||
self.controller.terminal.sound_alarm()
|
||||
|
||||
# Assert
|
||||
self._assert_run_loop(0.5, None, 0.5, False)
|
||||
self._assert_run_loop(0.5, None, True, 0.5, False)
|
||||
|
||||
self.assertEqual(self.poll_mock.call_args[0][1], PollAction.ALARM)
|
||||
|
||||
self.assertFalse(self.controller.terminal.alarm)
|
||||
|
||||
def test_toggle_cursor_blink(self):
|
||||
self._assert_run_loop(0, PowerOnResetCompletePollResponse(0xa), 0, True)
|
||||
self._assert_run_loop(0, PowerOnResetCompletePollResponse(0xa), False, 0, True)
|
||||
|
||||
self.assertFalse(self.controller.terminal.display.cursor_blink)
|
||||
|
||||
self._assert_run_loop(0, KeystrokePollResponse(0b0101010010), 0, True)
|
||||
self._assert_run_loop(0, KeystrokePollResponse(0b0101010010), False, 0, True)
|
||||
|
||||
self.assertTrue(self.controller.terminal.display.cursor_blink)
|
||||
|
||||
@ -162,7 +165,7 @@ class RunLoopTestCase(unittest.TestCase):
|
||||
|
||||
self.load_control_register_mock.reset_mock()
|
||||
|
||||
self._assert_run_loop(0, KeystrokePollResponse(0b0101010010), 0, True)
|
||||
self._assert_run_loop(0, KeystrokePollResponse(0b0101010010), False, 0, True)
|
||||
|
||||
self.assertFalse(self.controller.terminal.display.cursor_blink)
|
||||
|
||||
@ -171,13 +174,13 @@ class RunLoopTestCase(unittest.TestCase):
|
||||
self.assertFalse(self.load_control_register_mock.call_args[0][1].cursor_blink)
|
||||
|
||||
def test_toggle_cursor_reverse(self):
|
||||
self._assert_run_loop(0, PowerOnResetCompletePollResponse(0xa), 0, True)
|
||||
self._assert_run_loop(0, PowerOnResetCompletePollResponse(0xa), False, 0, True)
|
||||
|
||||
self.assertFalse(self.controller.terminal.display.cursor_reverse)
|
||||
|
||||
self._assert_run_loop(0, KeystrokePollResponse(0b0100111110), 0, True)
|
||||
self._assert_run_loop(0, KeystrokePollResponse(0b0101010010), 0, True)
|
||||
self._assert_run_loop(0, KeystrokePollResponse(0b0100111110), 0, True)
|
||||
self._assert_run_loop(0, KeystrokePollResponse(0b0100111110), False, 0, True)
|
||||
self._assert_run_loop(0, KeystrokePollResponse(0b0101010010), False, 0, True)
|
||||
self._assert_run_loop(0, KeystrokePollResponse(0b0100111110), False, 0, True)
|
||||
|
||||
self.assertTrue(self.controller.terminal.display.cursor_reverse)
|
||||
|
||||
@ -187,9 +190,9 @@ class RunLoopTestCase(unittest.TestCase):
|
||||
|
||||
self.load_control_register_mock.reset_mock()
|
||||
|
||||
self._assert_run_loop(0, KeystrokePollResponse(0b0100111110), 0, True)
|
||||
self._assert_run_loop(0, KeystrokePollResponse(0b0101010010), 0, True)
|
||||
self._assert_run_loop(0, KeystrokePollResponse(0b0100111110), 0, True)
|
||||
self._assert_run_loop(0, KeystrokePollResponse(0b0100111110), False, 0, True)
|
||||
self._assert_run_loop(0, KeystrokePollResponse(0b0101010010), False, 0, True)
|
||||
self._assert_run_loop(0, KeystrokePollResponse(0b0100111110), False, 0, True)
|
||||
|
||||
self.assertFalse(self.controller.terminal.display.cursor_reverse)
|
||||
|
||||
@ -198,31 +201,33 @@ class RunLoopTestCase(unittest.TestCase):
|
||||
self.assertFalse(self.load_control_register_mock.call_args[0][1].cursor_reverse)
|
||||
|
||||
def test_toggle_clicker(self):
|
||||
self._assert_run_loop(0, PowerOnResetCompletePollResponse(0xa), 0, True)
|
||||
self._assert_run_loop(0, PowerOnResetCompletePollResponse(0xa), False, 0, True)
|
||||
|
||||
self.assertFalse(self.controller.terminal.keyboard.clicker)
|
||||
|
||||
self._assert_run_loop(0, KeystrokePollResponse(0b0101011110), 0, True)
|
||||
self._assert_run_loop(0, None, 0, False)
|
||||
self._assert_run_loop(0, KeystrokePollResponse(0b0101011110), False, 0, True)
|
||||
self._assert_run_loop(0, None, False, 0, False)
|
||||
|
||||
self.assertTrue(self.controller.terminal.keyboard.clicker)
|
||||
|
||||
self.assertEqual(self.poll_mock.call_args[0][1], PollAction.ENABLE_KEYBOARD_CLICKER)
|
||||
|
||||
self._assert_run_loop(0.5, KeystrokePollResponse(0b0101011110), 0.5, True)
|
||||
self._assert_run_loop(1, None, 0, False)
|
||||
self._assert_run_loop(0.5, KeystrokePollResponse(0b0101011110), True, 0.5, True)
|
||||
self._assert_run_loop(1, None, False, 0, False)
|
||||
|
||||
self.assertFalse(self.controller.terminal.keyboard.clicker)
|
||||
|
||||
self.assertEqual(self.poll_mock.call_args[0][1], PollAction.DISABLE_KEYBOARD_CLICKER)
|
||||
|
||||
def _assert_run_loop(self, poll_time, poll_response, expected_delay, expected_poll_ack):
|
||||
def _assert_run_loop(self, poll_time, poll_response, expected_update_session, expected_poll_delay, expected_poll_ack):
|
||||
# Arrange
|
||||
self.controller._update_session.reset_mock()
|
||||
|
||||
self.poll_mock.side_effect = [poll_response]
|
||||
|
||||
self.poll_ack_mock.reset_mock()
|
||||
|
||||
self.perf_counter_mock.side_effect = [poll_time, poll_time + expected_delay]
|
||||
self.perf_counter_mock.side_effect = [poll_time, poll_time + expected_poll_delay]
|
||||
|
||||
self.sleep_mock.reset_mock()
|
||||
|
||||
@ -230,12 +235,70 @@ class RunLoopTestCase(unittest.TestCase):
|
||||
self.controller._run_loop()
|
||||
|
||||
# Assert
|
||||
if expected_delay > 0:
|
||||
self.sleep_mock.assert_called_once_with(expected_delay)
|
||||
else:
|
||||
if expected_update_session:
|
||||
self.controller._update_session.assert_called_once_with(expected_poll_delay)
|
||||
self.sleep_mock.assert_not_called()
|
||||
else:
|
||||
self.controller._update_session.assert_not_called()
|
||||
|
||||
if expected_poll_delay > 0:
|
||||
self.sleep_mock.assert_called_once_with(expected_poll_delay)
|
||||
else:
|
||||
self.sleep_mock.assert_not_called()
|
||||
|
||||
if expected_poll_ack:
|
||||
self.poll_ack_mock.assert_called_once()
|
||||
else:
|
||||
self.poll_ack_mock.assert_not_called()
|
||||
|
||||
class UpdateSessionTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.controller = Controller(None, None, None)
|
||||
|
||||
self.controller.session = Mock()
|
||||
|
||||
patcher = patch('oec.controller.time.perf_counter')
|
||||
|
||||
self.perf_counter_mock = patcher.start()
|
||||
|
||||
patcher = patch('oec.controller.select')
|
||||
|
||||
self.select_mock = patcher.start()
|
||||
|
||||
def test_zero_duration(self):
|
||||
# Act
|
||||
self.controller._update_session(0)
|
||||
|
||||
# Assert
|
||||
self.controller.session.handle_host.assert_not_called()
|
||||
|
||||
self.select_mock.assert_not_called()
|
||||
|
||||
def test_select_timeout(self):
|
||||
# Arrange
|
||||
self.select_mock.return_value = ([], [], [])
|
||||
|
||||
# Act
|
||||
self.controller._update_session(1)
|
||||
|
||||
# Assert
|
||||
self.controller.session.handle_host.assert_not_called()
|
||||
|
||||
self.select_mock.assert_called_once()
|
||||
|
||||
def test_select_available(self):
|
||||
# Arrange
|
||||
self.perf_counter_mock.side_effect = [0, 0.75, 0.75]
|
||||
|
||||
self.select_mock.side_effect = [([self.controller.session], [], []), ([], [], [])]
|
||||
|
||||
# Act
|
||||
self.controller._update_session(1)
|
||||
|
||||
# Assert
|
||||
self.controller.session.handle_host.assert_called_once()
|
||||
|
||||
self.assertEqual(self.select_mock.call_count, 2)
|
||||
|
||||
self.assertEqual(self.select_mock.call_args_list[0][0][3], 1)
|
||||
self.assertEqual(self.select_mock.call_args_list[1][0][3], 0.25)
|
||||
|
||||
@ -5,7 +5,7 @@ import context
|
||||
|
||||
from oec.display import Dimensions
|
||||
from oec.keyboard import Key, KeyboardModifiers
|
||||
from oec.vt100 import VT100Session, select
|
||||
from oec.vt100 import VT100Session
|
||||
|
||||
class SessionHandleHostTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@ -17,12 +17,6 @@ class SessionHandleHostTestCase(unittest.TestCase):
|
||||
|
||||
self.session.host_process = Mock()
|
||||
|
||||
patcher = patch('oec.vt100.select')
|
||||
|
||||
select_mock = patcher.start()
|
||||
|
||||
select_mock.return_value = [[self.session.host_process]]
|
||||
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def test(self):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user