bastd.ui.settings.nettesting

Provides ui for network related testing.

  1# Released under the MIT License. See LICENSE for details.
  2#
  3"""Provides ui for network related testing."""
  4
  5from __future__ import annotations
  6
  7import time
  8import copy
  9import weakref
 10from threading import Thread
 11from typing import TYPE_CHECKING
 12
 13from efro.error import CleanError
 14import ba
 15import ba.internal
 16from bastd.ui.settings.testing import TestingWindow
 17
 18if TYPE_CHECKING:
 19    from typing import Callable, Any
 20
 21# We generally want all net tests to timeout on their own, but we add
 22# sort of sane max in case they don't.
 23MAX_TEST_SECONDS = 60 * 2
 24
 25
 26class NetTestingWindow(ba.Window):
 27    """Window that runs a networking test suite to help diagnose issues."""
 28
 29    def __init__(self, transition: str = 'in_right'):
 30        self._width = 820
 31        self._height = 500
 32        self._printed_lines: list[str] = []
 33        uiscale = ba.app.ui.uiscale
 34        super().__init__(
 35            root_widget=ba.containerwidget(
 36                size=(self._width, self._height),
 37                scale=(
 38                    1.56
 39                    if uiscale is ba.UIScale.SMALL
 40                    else 1.2
 41                    if uiscale is ba.UIScale.MEDIUM
 42                    else 0.8
 43                ),
 44                stack_offset=(0.0, -7 if uiscale is ba.UIScale.SMALL else 0.0),
 45                transition=transition,
 46            )
 47        )
 48        self._done_button = ba.buttonwidget(
 49            parent=self._root_widget,
 50            position=(40, self._height - 77),
 51            size=(120, 60),
 52            scale=0.8,
 53            autoselect=True,
 54            label=ba.Lstr(resource='doneText'),
 55            on_activate_call=self._done,
 56        )
 57
 58        self._copy_button = ba.buttonwidget(
 59            parent=self._root_widget,
 60            position=(self._width - 200, self._height - 77),
 61            size=(100, 60),
 62            scale=0.8,
 63            autoselect=True,
 64            label=ba.Lstr(resource='copyText'),
 65            on_activate_call=self._copy,
 66        )
 67
 68        self._settings_button = ba.buttonwidget(
 69            parent=self._root_widget,
 70            position=(self._width - 100, self._height - 77),
 71            size=(60, 60),
 72            scale=0.8,
 73            autoselect=True,
 74            label=ba.Lstr(value='...'),
 75            on_activate_call=self._show_val_testing,
 76        )
 77
 78        twidth = self._width - 450
 79        ba.textwidget(
 80            parent=self._root_widget,
 81            position=(self._width * 0.5, self._height - 55),
 82            size=(0, 0),
 83            text=ba.Lstr(resource='settingsWindowAdvanced.netTestingText'),
 84            color=(0.8, 0.8, 0.8, 1.0),
 85            h_align='center',
 86            v_align='center',
 87            maxwidth=twidth,
 88        )
 89
 90        self._scroll = ba.scrollwidget(
 91            parent=self._root_widget,
 92            position=(50, 50),
 93            size=(self._width - 100, self._height - 140),
 94            capture_arrows=True,
 95            autoselect=True,
 96        )
 97        self._rows = ba.columnwidget(parent=self._scroll)
 98
 99        ba.containerwidget(
100            edit=self._root_widget, cancel_button=self._done_button
101        )
102
103        # Now kick off the tests.
104        # Pass a weak-ref to this window so we don't keep it alive
105        # if we back out before it completes. Also set is as daemon
106        # so it doesn't keep the app running if the user is trying to quit.
107        Thread(
108            daemon=True,
109            target=ba.Call(_run_diagnostics, weakref.ref(self)),
110        ).start()
111
112    def print(self, text: str, color: tuple[float, float, float]) -> None:
113        """Print text to our console thingie."""
114        for line in text.splitlines():
115            txt = ba.textwidget(
116                parent=self._rows,
117                color=color,
118                text=line,
119                scale=0.75,
120                flatness=1.0,
121                shadow=0.0,
122                size=(0, 20),
123            )
124            ba.containerwidget(edit=self._rows, visible_child=txt)
125            self._printed_lines.append(line)
126
127    def _copy(self) -> None:
128        if not ba.clipboard_is_supported():
129            ba.screenmessage(
130                'Clipboard not supported on this platform.', color=(1, 0, 0)
131            )
132            return
133        ba.clipboard_set_text('\n'.join(self._printed_lines))
134        ba.screenmessage(f'{len(self._printed_lines)} lines copied.')
135
136    def _show_val_testing(self) -> None:
137        ba.app.ui.set_main_menu_window(NetValTestingWindow().get_root_widget())
138        ba.containerwidget(edit=self._root_widget, transition='out_left')
139
140    def _done(self) -> None:
141        # pylint: disable=cyclic-import
142        from bastd.ui.settings.advanced import AdvancedSettingsWindow
143
144        ba.app.ui.set_main_menu_window(
145            AdvancedSettingsWindow(transition='in_left').get_root_widget()
146        )
147        ba.containerwidget(edit=self._root_widget, transition='out_right')
148
149
150def _run_diagnostics(weakwin: weakref.ref[NetTestingWindow]) -> None:
151    # pylint: disable=too-many-statements
152
153    from efro.util import utc_now
154
155    have_error = [False]
156
157    # We're running in a background thread but UI stuff needs to run
158    # in the logic thread; give ourself a way to pass stuff to it.
159    def _print(
160        text: str, color: tuple[float, float, float] | None = None
161    ) -> None:
162        def _print_in_logic_thread() -> None:
163            win = weakwin()
164            if win is not None:
165                win.print(text, (1.0, 1.0, 1.0) if color is None else color)
166
167        ba.pushcall(_print_in_logic_thread, from_other_thread=True)
168
169    def _print_test_results(call: Callable[[], Any]) -> bool:
170        """Run the provided call, print result, & return success."""
171        starttime = time.monotonic()
172        try:
173            call()
174            duration = time.monotonic() - starttime
175            _print(f'Succeeded in {duration:.2f}s.', color=(0, 1, 0))
176            return True
177        except Exception as exc:
178            import traceback
179
180            duration = time.monotonic() - starttime
181            msg = (
182                str(exc)
183                if isinstance(exc, CleanError)
184                else traceback.format_exc()
185            )
186            _print(msg, color=(1.0, 1.0, 0.3))
187            _print(f'Failed in {duration:.2f}s.', color=(1, 0, 0))
188            have_error[0] = True
189            return False
190
191    try:
192        _print(
193            f'Running network diagnostics...\n'
194            f'ua: {ba.app.user_agent_string}\n'
195            f'time: {utc_now()}.'
196        )
197
198        if bool(False):
199            _print('\nRunning dummy success test...')
200            _print_test_results(_dummy_success)
201
202            _print('\nRunning dummy fail test...')
203            _print_test_results(_dummy_fail)
204
205        # V1 ping
206        baseaddr = ba.internal.get_master_server_address(source=0, version=1)
207        _print(f'\nContacting V1 master-server src0 ({baseaddr})...')
208        v1worked = _print_test_results(lambda: _test_fetch(baseaddr))
209
210        # V1 alternate ping (only if primary fails since this often fails).
211        if v1worked:
212            _print('\nSkipping V1 master-server src1 test since src0 worked.')
213        else:
214            baseaddr = ba.internal.get_master_server_address(
215                source=1, version=1
216            )
217            _print(f'\nContacting V1 master-server src1 ({baseaddr})...')
218            _print_test_results(lambda: _test_fetch(baseaddr))
219
220        if 'none succeeded' in ba.app.net.v1_test_log:
221            _print(
222                f'\nV1-test-log failed: {ba.app.net.v1_test_log}',
223                color=(1, 0, 0),
224            )
225            have_error[0] = True
226        else:
227            _print(f'\nV1-test-log ok: {ba.app.net.v1_test_log}')
228
229        for srcid, result in sorted(ba.app.net.v1_ctest_results.items()):
230            _print(f'\nV1 src{srcid} result: {result}')
231
232        curv1addr = ba.internal.get_master_server_address(version=1)
233        _print(f'\nUsing V1 address: {curv1addr}')
234
235        _print('\nRunning V1 transaction...')
236        _print_test_results(_test_v1_transaction)
237
238        # V2 ping
239        baseaddr = ba.internal.get_master_server_address(version=2)
240        _print(f'\nContacting V2 master-server ({baseaddr})...')
241        _print_test_results(lambda: _test_fetch(baseaddr))
242
243        _print('\nComparing local time to V2 server...')
244        _print_test_results(_test_v2_time)
245
246        # Get V2 nearby zone
247        with ba.app.net.zone_pings_lock:
248            zone_pings = copy.deepcopy(ba.app.net.zone_pings)
249        nearest_zone = (
250            None
251            if not zone_pings
252            else sorted(zone_pings.items(), key=lambda i: i[1])[0]
253        )
254
255        if nearest_zone is not None:
256            nearstr = f'{nearest_zone[0]}: {nearest_zone[1]:.0f}ms'
257        else:
258            nearstr = '-'
259        _print(f'\nChecking nearest V2 zone ping ({nearstr})...')
260        _print_test_results(lambda: _test_nearby_zone_ping(nearest_zone))
261
262        _print('\nSending V2 cloud message...')
263        _print_test_results(_test_v2_cloud_message)
264
265        if have_error[0]:
266            _print(
267                '\nDiagnostics complete. Some diagnostics failed.',
268                color=(10, 0, 0),
269            )
270        else:
271            _print(
272                '\nDiagnostics complete. Everything looks good!',
273                color=(0, 1, 0),
274            )
275    except Exception:
276        import traceback
277
278        _print(
279            f'An unexpected error occurred during testing;'
280            f' please report this.\n'
281            f'{traceback.format_exc()}',
282            color=(1, 0, 0),
283        )
284
285
286def _dummy_success() -> None:
287    """Dummy success test."""
288    time.sleep(1.2)
289
290
291def _dummy_fail() -> None:
292    """Dummy fail test case."""
293    raise RuntimeError('fail-test')
294
295
296def _test_v1_transaction() -> None:
297    """Dummy fail test case."""
298    if ba.internal.get_v1_account_state() != 'signed_in':
299        raise RuntimeError('Not signed in.')
300
301    starttime = time.monotonic()
302
303    # Gets set to True on success or string on error.
304    results: list[Any] = [False]
305
306    def _cb(cbresults: Any) -> None:
307        # Simply set results here; our other thread acts on them.
308        if not isinstance(cbresults, dict) or 'party_code' not in cbresults:
309            results[0] = 'Unexpected transaction response'
310            return
311        results[0] = True  # Success!
312
313    def _do_it() -> None:
314        # Fire off a transaction with a callback.
315        ba.internal.add_transaction(
316            {
317                'type': 'PRIVATE_PARTY_QUERY',
318                'expire_time': time.time() + 20,
319            },
320            callback=_cb,
321        )
322        ba.internal.run_transactions()
323
324    ba.pushcall(_do_it, from_other_thread=True)
325
326    while results[0] is False:
327        time.sleep(0.01)
328        if time.monotonic() - starttime > MAX_TEST_SECONDS:
329            raise RuntimeError(
330                f'test timed out after {MAX_TEST_SECONDS} seconds'
331            )
332
333    # If we got left a string, its an error.
334    if isinstance(results[0], str):
335        raise RuntimeError(results[0])
336
337
338def _test_v2_cloud_message() -> None:
339    from dataclasses import dataclass
340    import bacommon.cloud
341
342    @dataclass
343    class _Results:
344        errstr: str | None = None
345        send_time: float | None = None
346        response_time: float | None = None
347
348    results = _Results()
349
350    def _cb(response: bacommon.cloud.PingResponse | Exception) -> None:
351        # Note: this runs in another thread so need to avoid exceptions.
352        results.response_time = time.monotonic()
353        if isinstance(response, Exception):
354            results.errstr = str(response)
355        if not isinstance(response, bacommon.cloud.PingResponse):
356            results.errstr = f'invalid response type: {type(response)}.'
357
358    def _send() -> None:
359        # Note: this runs in another thread so need to avoid exceptions.
360        results.send_time = time.monotonic()
361        ba.app.cloud.send_message_cb(bacommon.cloud.PingMessage(), _cb)
362
363    # This stuff expects to be run from the logic thread.
364    ba.pushcall(_send, from_other_thread=True)
365
366    wait_start_time = time.monotonic()
367    while True:
368        if results.response_time is not None:
369            break
370        time.sleep(0.01)
371        if time.monotonic() - wait_start_time > MAX_TEST_SECONDS:
372            raise RuntimeError(
373                f'Timeout ({MAX_TEST_SECONDS} seconds)'
374                f' waiting for cloud message response'
375            )
376    if results.errstr is not None:
377        raise RuntimeError(results.errstr)
378
379
380def _test_v2_time() -> None:
381    offset = ba.app.net.server_time_offset_hours
382    if offset is None:
383        raise RuntimeError(
384            'no time offset found;'
385            ' perhaps unable to communicate with v2 server?'
386        )
387    if abs(offset) >= 2.0:
388        raise CleanError(
389            f'Your device time is off from world time by {offset:.1f} hours.\n'
390            'This may cause network operations to fail due to your device\n'
391            ' incorrectly treating SSL certificates as not-yet-valid, etc.\n'
392            'Check your device time and time-zone settings to fix this.\n'
393        )
394
395
396def _test_fetch(baseaddr: str) -> None:
397    # pylint: disable=consider-using-with
398    import urllib.request
399
400    response = urllib.request.urlopen(
401        urllib.request.Request(
402            f'{baseaddr}/ping', None, {'User-Agent': ba.app.user_agent_string}
403        ),
404        context=ba.app.net.sslcontext,
405        timeout=MAX_TEST_SECONDS,
406    )
407    if response.getcode() != 200:
408        raise RuntimeError(
409            f'Got unexpected response code {response.getcode()}.'
410        )
411    data = response.read()
412    if data != b'pong':
413        raise RuntimeError('Got unexpected response data.')
414
415
416def _test_nearby_zone_ping(nearest_zone: tuple[str, float] | None) -> None:
417    """Try to ping nearest v2 zone."""
418    if nearest_zone is None:
419        raise RuntimeError('No nearest zone.')
420    if nearest_zone[1] > 500:
421        raise RuntimeError('Ping too high.')
422
423
424class NetValTestingWindow(TestingWindow):
425    """Window to test network related settings."""
426
427    def __init__(self, transition: str = 'in_right'):
428
429        entries = [
430            {'name': 'bufferTime', 'label': 'Buffer Time', 'increment': 1.0},
431            {
432                'name': 'delaySampling',
433                'label': 'Delay Sampling',
434                'increment': 1.0,
435            },
436            {
437                'name': 'dynamicsSyncTime',
438                'label': 'Dynamics Sync Time',
439                'increment': 10,
440            },
441            {'name': 'showNetInfo', 'label': 'Show Net Info', 'increment': 1},
442        ]
443        super().__init__(
444            title=ba.Lstr(resource='settingsWindowAdvanced.netTestingText'),
445            entries=entries,
446            transition=transition,
447            back_call=lambda: NetTestingWindow(transition='in_left'),
448        )
class NetTestingWindow(ba.ui.Window):
 27class NetTestingWindow(ba.Window):
 28    """Window that runs a networking test suite to help diagnose issues."""
 29
 30    def __init__(self, transition: str = 'in_right'):
 31        self._width = 820
 32        self._height = 500
 33        self._printed_lines: list[str] = []
 34        uiscale = ba.app.ui.uiscale
 35        super().__init__(
 36            root_widget=ba.containerwidget(
 37                size=(self._width, self._height),
 38                scale=(
 39                    1.56
 40                    if uiscale is ba.UIScale.SMALL
 41                    else 1.2
 42                    if uiscale is ba.UIScale.MEDIUM
 43                    else 0.8
 44                ),
 45                stack_offset=(0.0, -7 if uiscale is ba.UIScale.SMALL else 0.0),
 46                transition=transition,
 47            )
 48        )
 49        self._done_button = ba.buttonwidget(
 50            parent=self._root_widget,
 51            position=(40, self._height - 77),
 52            size=(120, 60),
 53            scale=0.8,
 54            autoselect=True,
 55            label=ba.Lstr(resource='doneText'),
 56            on_activate_call=self._done,
 57        )
 58
 59        self._copy_button = ba.buttonwidget(
 60            parent=self._root_widget,
 61            position=(self._width - 200, self._height - 77),
 62            size=(100, 60),
 63            scale=0.8,
 64            autoselect=True,
 65            label=ba.Lstr(resource='copyText'),
 66            on_activate_call=self._copy,
 67        )
 68
 69        self._settings_button = ba.buttonwidget(
 70            parent=self._root_widget,
 71            position=(self._width - 100, self._height - 77),
 72            size=(60, 60),
 73            scale=0.8,
 74            autoselect=True,
 75            label=ba.Lstr(value='...'),
 76            on_activate_call=self._show_val_testing,
 77        )
 78
 79        twidth = self._width - 450
 80        ba.textwidget(
 81            parent=self._root_widget,
 82            position=(self._width * 0.5, self._height - 55),
 83            size=(0, 0),
 84            text=ba.Lstr(resource='settingsWindowAdvanced.netTestingText'),
 85            color=(0.8, 0.8, 0.8, 1.0),
 86            h_align='center',
 87            v_align='center',
 88            maxwidth=twidth,
 89        )
 90
 91        self._scroll = ba.scrollwidget(
 92            parent=self._root_widget,
 93            position=(50, 50),
 94            size=(self._width - 100, self._height - 140),
 95            capture_arrows=True,
 96            autoselect=True,
 97        )
 98        self._rows = ba.columnwidget(parent=self._scroll)
 99
100        ba.containerwidget(
101            edit=self._root_widget, cancel_button=self._done_button
102        )
103
104        # Now kick off the tests.
105        # Pass a weak-ref to this window so we don't keep it alive
106        # if we back out before it completes. Also set is as daemon
107        # so it doesn't keep the app running if the user is trying to quit.
108        Thread(
109            daemon=True,
110            target=ba.Call(_run_diagnostics, weakref.ref(self)),
111        ).start()
112
113    def print(self, text: str, color: tuple[float, float, float]) -> None:
114        """Print text to our console thingie."""
115        for line in text.splitlines():
116            txt = ba.textwidget(
117                parent=self._rows,
118                color=color,
119                text=line,
120                scale=0.75,
121                flatness=1.0,
122                shadow=0.0,
123                size=(0, 20),
124            )
125            ba.containerwidget(edit=self._rows, visible_child=txt)
126            self._printed_lines.append(line)
127
128    def _copy(self) -> None:
129        if not ba.clipboard_is_supported():
130            ba.screenmessage(
131                'Clipboard not supported on this platform.', color=(1, 0, 0)
132            )
133            return
134        ba.clipboard_set_text('\n'.join(self._printed_lines))
135        ba.screenmessage(f'{len(self._printed_lines)} lines copied.')
136
137    def _show_val_testing(self) -> None:
138        ba.app.ui.set_main_menu_window(NetValTestingWindow().get_root_widget())
139        ba.containerwidget(edit=self._root_widget, transition='out_left')
140
141    def _done(self) -> None:
142        # pylint: disable=cyclic-import
143        from bastd.ui.settings.advanced import AdvancedSettingsWindow
144
145        ba.app.ui.set_main_menu_window(
146            AdvancedSettingsWindow(transition='in_left').get_root_widget()
147        )
148        ba.containerwidget(edit=self._root_widget, transition='out_right')

Window that runs a networking test suite to help diagnose issues.

NetTestingWindow(transition: str = 'in_right')
 30    def __init__(self, transition: str = 'in_right'):
 31        self._width = 820
 32        self._height = 500
 33        self._printed_lines: list[str] = []
 34        uiscale = ba.app.ui.uiscale
 35        super().__init__(
 36            root_widget=ba.containerwidget(
 37                size=(self._width, self._height),
 38                scale=(
 39                    1.56
 40                    if uiscale is ba.UIScale.SMALL
 41                    else 1.2
 42                    if uiscale is ba.UIScale.MEDIUM
 43                    else 0.8
 44                ),
 45                stack_offset=(0.0, -7 if uiscale is ba.UIScale.SMALL else 0.0),
 46                transition=transition,
 47            )
 48        )
 49        self._done_button = ba.buttonwidget(
 50            parent=self._root_widget,
 51            position=(40, self._height - 77),
 52            size=(120, 60),
 53            scale=0.8,
 54            autoselect=True,
 55            label=ba.Lstr(resource='doneText'),
 56            on_activate_call=self._done,
 57        )
 58
 59        self._copy_button = ba.buttonwidget(
 60            parent=self._root_widget,
 61            position=(self._width - 200, self._height - 77),
 62            size=(100, 60),
 63            scale=0.8,
 64            autoselect=True,
 65            label=ba.Lstr(resource='copyText'),
 66            on_activate_call=self._copy,
 67        )
 68
 69        self._settings_button = ba.buttonwidget(
 70            parent=self._root_widget,
 71            position=(self._width - 100, self._height - 77),
 72            size=(60, 60),
 73            scale=0.8,
 74            autoselect=True,
 75            label=ba.Lstr(value='...'),
 76            on_activate_call=self._show_val_testing,
 77        )
 78
 79        twidth = self._width - 450
 80        ba.textwidget(
 81            parent=self._root_widget,
 82            position=(self._width * 0.5, self._height - 55),
 83            size=(0, 0),
 84            text=ba.Lstr(resource='settingsWindowAdvanced.netTestingText'),
 85            color=(0.8, 0.8, 0.8, 1.0),
 86            h_align='center',
 87            v_align='center',
 88            maxwidth=twidth,
 89        )
 90
 91        self._scroll = ba.scrollwidget(
 92            parent=self._root_widget,
 93            position=(50, 50),
 94            size=(self._width - 100, self._height - 140),
 95            capture_arrows=True,
 96            autoselect=True,
 97        )
 98        self._rows = ba.columnwidget(parent=self._scroll)
 99
100        ba.containerwidget(
101            edit=self._root_widget, cancel_button=self._done_button
102        )
103
104        # Now kick off the tests.
105        # Pass a weak-ref to this window so we don't keep it alive
106        # if we back out before it completes. Also set is as daemon
107        # so it doesn't keep the app running if the user is trying to quit.
108        Thread(
109            daemon=True,
110            target=ba.Call(_run_diagnostics, weakref.ref(self)),
111        ).start()
def print(self, text: str, color: tuple[float, float, float]) -> None:
113    def print(self, text: str, color: tuple[float, float, float]) -> None:
114        """Print text to our console thingie."""
115        for line in text.splitlines():
116            txt = ba.textwidget(
117                parent=self._rows,
118                color=color,
119                text=line,
120                scale=0.75,
121                flatness=1.0,
122                shadow=0.0,
123                size=(0, 20),
124            )
125            ba.containerwidget(edit=self._rows, visible_child=txt)
126            self._printed_lines.append(line)

Print text to our console thingie.

Inherited Members
ba.ui.Window
get_root_widget
class NetValTestingWindow(bastd.ui.settings.testing.TestingWindow):
425class NetValTestingWindow(TestingWindow):
426    """Window to test network related settings."""
427
428    def __init__(self, transition: str = 'in_right'):
429
430        entries = [
431            {'name': 'bufferTime', 'label': 'Buffer Time', 'increment': 1.0},
432            {
433                'name': 'delaySampling',
434                'label': 'Delay Sampling',
435                'increment': 1.0,
436            },
437            {
438                'name': 'dynamicsSyncTime',
439                'label': 'Dynamics Sync Time',
440                'increment': 10,
441            },
442            {'name': 'showNetInfo', 'label': 'Show Net Info', 'increment': 1},
443        ]
444        super().__init__(
445            title=ba.Lstr(resource='settingsWindowAdvanced.netTestingText'),
446            entries=entries,
447            transition=transition,
448            back_call=lambda: NetTestingWindow(transition='in_left'),
449        )

Window to test network related settings.

NetValTestingWindow(transition: str = 'in_right')
428    def __init__(self, transition: str = 'in_right'):
429
430        entries = [
431            {'name': 'bufferTime', 'label': 'Buffer Time', 'increment': 1.0},
432            {
433                'name': 'delaySampling',
434                'label': 'Delay Sampling',
435                'increment': 1.0,
436            },
437            {
438                'name': 'dynamicsSyncTime',
439                'label': 'Dynamics Sync Time',
440                'increment': 10,
441            },
442            {'name': 'showNetInfo', 'label': 'Show Net Info', 'increment': 1},
443        ]
444        super().__init__(
445            title=ba.Lstr(resource='settingsWindowAdvanced.netTestingText'),
446            entries=entries,
447            transition=transition,
448            back_call=lambda: NetTestingWindow(transition='in_left'),
449        )
Inherited Members
ba.ui.Window
get_root_widget