diff --git a/.efrocachemap b/.efrocachemap index 58d7ae8a..44214a7d 100644 --- a/.efrocachemap +++ b/.efrocachemap @@ -4099,42 +4099,42 @@ "build/assets/windows/Win32/ucrtbased.dll": "bfd1180c269d3950b76f35a63655e9e1", "build/assets/windows/Win32/vc_redist.x86.exe": "15a5f1f876503885adbdf5b3989b3718", "build/assets/windows/Win32/vcruntime140d.dll": "865b2af4d1e26a1a8073c89acb06e599", - "build/prefab/full/linux_arm64_gui/debug/ballisticakit": "0921e644948438c78c8621f1199dc6d6", - "build/prefab/full/linux_arm64_gui/release/ballisticakit": "54486e895c6be0128a2e25624aaef051", - "build/prefab/full/linux_arm64_server/debug/dist/ballisticakit_headless": "bcfc357760d1030c66e3b65c30a649c8", - "build/prefab/full/linux_arm64_server/release/dist/ballisticakit_headless": "a398b7f179cf6ee31af8037bae59bdc4", - "build/prefab/full/linux_x86_64_gui/debug/ballisticakit": "9c5e37721e8834491c249b3f1e986e8a", - "build/prefab/full/linux_x86_64_gui/release/ballisticakit": "57dd4fa7356ea5a1a102c5a0422a4485", - "build/prefab/full/linux_x86_64_server/debug/dist/ballisticakit_headless": "cad5283f88be83233e7a8b674db02c17", - "build/prefab/full/linux_x86_64_server/release/dist/ballisticakit_headless": "f36a3b1ef5d5650c373ac3230f3e64a3", - "build/prefab/full/mac_arm64_gui/debug/ballisticakit": "327abf1bd532b104cbb63fa9dd11f9b7", - "build/prefab/full/mac_arm64_gui/release/ballisticakit": "685e787af247ba2694c7632c469e4853", - "build/prefab/full/mac_arm64_server/debug/dist/ballisticakit_headless": "6c41aad3f4c60d56350be66a6ba28a3f", - "build/prefab/full/mac_arm64_server/release/dist/ballisticakit_headless": "d752bc8ee063e3d95038b3ce8bdb0e3c", - "build/prefab/full/windows_x86_gui/debug/BallisticaKit.exe": "5f2e7c646ce28814d5d87ba2e4b826c4", - "build/prefab/full/windows_x86_gui/release/BallisticaKit.exe": "b440e073afd70d018fc14ee10f41bb85", - "build/prefab/full/windows_x86_server/debug/dist/BallisticaKitHeadless.exe": "bc56615fbc3d671deebf36942b36c706", - "build/prefab/full/windows_x86_server/release/dist/BallisticaKitHeadless.exe": "e5d47e94497951d438520cde86d487b2", - "build/prefab/lib/linux_arm64_gui/debug/libballisticaplus.a": "86eaadb3dee0a1f1137192fe943996f6", - "build/prefab/lib/linux_arm64_gui/release/libballisticaplus.a": "8c795d1a871f0d82a198b5aeb506d770", - "build/prefab/lib/linux_arm64_server/debug/libballisticaplus.a": "86eaadb3dee0a1f1137192fe943996f6", - "build/prefab/lib/linux_arm64_server/release/libballisticaplus.a": "8c795d1a871f0d82a198b5aeb506d770", - "build/prefab/lib/linux_x86_64_gui/debug/libballisticaplus.a": "ab0cf0e9d6001748927660d84c1da87f", - "build/prefab/lib/linux_x86_64_gui/release/libballisticaplus.a": "62c22d7a25fd62831cb4a089bbee1b0b", - "build/prefab/lib/linux_x86_64_server/debug/libballisticaplus.a": "ab0cf0e9d6001748927660d84c1da87f", - "build/prefab/lib/linux_x86_64_server/release/libballisticaplus.a": "62c22d7a25fd62831cb4a089bbee1b0b", - "build/prefab/lib/mac_arm64_gui/debug/libballisticaplus.a": "75a5ab56d54304e602fff8dae6435904", - "build/prefab/lib/mac_arm64_gui/release/libballisticaplus.a": "19b6e806d066affc80b32c4899c6ba2f", - "build/prefab/lib/mac_arm64_server/debug/libballisticaplus.a": "75a5ab56d54304e602fff8dae6435904", - "build/prefab/lib/mac_arm64_server/release/libballisticaplus.a": "19b6e806d066affc80b32c4899c6ba2f", - "build/prefab/lib/windows/Debug_Win32/BallisticaKitGenericPlus.lib": "ca6096df15041b6af9a89ed81880c98b", - "build/prefab/lib/windows/Debug_Win32/BallisticaKitGenericPlus.pdb": "f06c730c9cee3599679df778ec565842", - "build/prefab/lib/windows/Debug_Win32/BallisticaKitHeadlessPlus.lib": "1a77179b3548e78791ddd1205ff505a3", - "build/prefab/lib/windows/Debug_Win32/BallisticaKitHeadlessPlus.pdb": "d755dc558bba71715ed0b543fa966be4", - "build/prefab/lib/windows/Release_Win32/BallisticaKitGenericPlus.lib": "722aff67a010cc0a435bc70018b0b49a", - "build/prefab/lib/windows/Release_Win32/BallisticaKitGenericPlus.pdb": "373deb2c02283978f415c1b937229292", - "build/prefab/lib/windows/Release_Win32/BallisticaKitHeadlessPlus.lib": "fe26aafc763a1198e1921caeb261bdb8", - "build/prefab/lib/windows/Release_Win32/BallisticaKitHeadlessPlus.pdb": "73f3c9211175f452e15c45296db89744", + "build/prefab/full/linux_arm64_gui/debug/ballisticakit": "258017f68d714fa993399ba73fe35a47", + "build/prefab/full/linux_arm64_gui/release/ballisticakit": "fb7661a6c77d99491e577594dd132262", + "build/prefab/full/linux_arm64_server/debug/dist/ballisticakit_headless": "91fef82f37ca46dfaf4d3ebb1cd4c911", + "build/prefab/full/linux_arm64_server/release/dist/ballisticakit_headless": "97361c0f527572ee1e81f372b21c6eea", + "build/prefab/full/linux_x86_64_gui/debug/ballisticakit": "bb14d62cebe5f8889b36ee580db68f67", + "build/prefab/full/linux_x86_64_gui/release/ballisticakit": "f4b9c6ef2d3252e46f2e9e5987092259", + "build/prefab/full/linux_x86_64_server/debug/dist/ballisticakit_headless": "ba771845ad99c8274c0a2e390306c1c6", + "build/prefab/full/linux_x86_64_server/release/dist/ballisticakit_headless": "62a0abe5d11268e7433b7880066558e3", + "build/prefab/full/mac_arm64_gui/debug/ballisticakit": "b180ee641e805d1a6eed1bdf194a88ba", + "build/prefab/full/mac_arm64_gui/release/ballisticakit": "ec633c984c8be568434bdcc39d3fbf75", + "build/prefab/full/mac_arm64_server/debug/dist/ballisticakit_headless": "6ee62f6bec7345ad7863014cd6ab6f91", + "build/prefab/full/mac_arm64_server/release/dist/ballisticakit_headless": "74f3a19a8b11f0a26d3b61c01c5cfb0f", + "build/prefab/full/windows_x86_gui/debug/BallisticaKit.exe": "93e722f0f0286a20320372a0ed52fb3b", + "build/prefab/full/windows_x86_gui/release/BallisticaKit.exe": "56f71c820702382f2c50b6693426a9a4", + "build/prefab/full/windows_x86_server/debug/dist/BallisticaKitHeadless.exe": "83730297d803f6f7458b8c7ac95f6bbc", + "build/prefab/full/windows_x86_server/release/dist/BallisticaKitHeadless.exe": "69c38b343c62dc4f687d9ac937aacc73", + "build/prefab/lib/linux_arm64_gui/debug/libballisticaplus.a": "5ef674f19fa3e2bfc061d8ebc2e97624", + "build/prefab/lib/linux_arm64_gui/release/libballisticaplus.a": "55cd9cb73acf7a0005a091ebf362cba2", + "build/prefab/lib/linux_arm64_server/debug/libballisticaplus.a": "5ef674f19fa3e2bfc061d8ebc2e97624", + "build/prefab/lib/linux_arm64_server/release/libballisticaplus.a": "55cd9cb73acf7a0005a091ebf362cba2", + "build/prefab/lib/linux_x86_64_gui/debug/libballisticaplus.a": "e3bd2d824b6bcf4cf820d87ebf1f969a", + "build/prefab/lib/linux_x86_64_gui/release/libballisticaplus.a": "5fe38e71bdbd7daf2554965233677d41", + "build/prefab/lib/linux_x86_64_server/debug/libballisticaplus.a": "e3bd2d824b6bcf4cf820d87ebf1f969a", + "build/prefab/lib/linux_x86_64_server/release/libballisticaplus.a": "5fe38e71bdbd7daf2554965233677d41", + "build/prefab/lib/mac_arm64_gui/debug/libballisticaplus.a": "5f7c9ed9bdfcbf1806f613d8b1b479ef", + "build/prefab/lib/mac_arm64_gui/release/libballisticaplus.a": "fc11b650398c55a0124943939017e9ec", + "build/prefab/lib/mac_arm64_server/debug/libballisticaplus.a": "5f7c9ed9bdfcbf1806f613d8b1b479ef", + "build/prefab/lib/mac_arm64_server/release/libballisticaplus.a": "fc11b650398c55a0124943939017e9ec", + "build/prefab/lib/windows/Debug_Win32/BallisticaKitGenericPlus.lib": "668de5ef2c6c47adea8bf3b13cf29dce", + "build/prefab/lib/windows/Debug_Win32/BallisticaKitGenericPlus.pdb": "27c9d03cf46b9771d6bbbebb18e7c6b2", + "build/prefab/lib/windows/Debug_Win32/BallisticaKitHeadlessPlus.lib": "6045009c5fd0f8b3e21429fa5594ed9a", + "build/prefab/lib/windows/Debug_Win32/BallisticaKitHeadlessPlus.pdb": "3430e09462e8e6954e276c70c9e692be", + "build/prefab/lib/windows/Release_Win32/BallisticaKitGenericPlus.lib": "543e53a1521fbf4acd6c47a6d124c5ee", + "build/prefab/lib/windows/Release_Win32/BallisticaKitGenericPlus.pdb": "8412cac6845eed87cd792a9b6cd7a540", + "build/prefab/lib/windows/Release_Win32/BallisticaKitHeadlessPlus.lib": "4b93778e70a8149dbe91115cae916fe3", + "build/prefab/lib/windows/Release_Win32/BallisticaKitHeadlessPlus.pdb": "6bfb80e2784c47f48b11079f240299b5", "src/assets/ba_data/python/babase/_mgen/__init__.py": "f885fed7f2ed98ff2ba271f9dbe3391c", "src/assets/ba_data/python/babase/_mgen/enums.py": "794d258d59fd17a61752843a9a0551ad", "src/ballistica/base/mgen/pyembed/binding_base.inc": "3a583e7e03bd4907b21adc3bf5729d15", diff --git a/CHANGELOG.md b/CHANGELOG.md index 4394d4ec..1b68f044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -### 1.7.37 (build 22112, api 9, 2024-11-23) +### 1.7.37 (build 22113, api 9, 2024-11-26) - Bumping api version to 9. As you'll see below, there's some UI changes that will require a bit of work for any UI mods to adapt to. If your mods don't touch UI stuff at all you can simply bump your api version and call it a day. @@ -164,6 +164,8 @@ app-modes at runtime; to change this you need to either change your projectconfig and rebuild or replace `ba*.app.mode_selector` at runtime with a custom selector that selects your custom app-mode(s). +- The `ba*.app.threadpool_submit_no_wait()` method has been merged into the + `threadpool` object, so it now is `ba*.app.threadpool.submit_no_wait()`. ### 1.7.36 (build 21944, api 8, 2024-07-26) - Wired up Tokens, BombSquad's new purchasable currency. The first thing these diff --git a/Makefile b/Makefile index 56006a21..0f0e878e 100644 --- a/Makefile +++ b/Makefile @@ -929,6 +929,11 @@ test-rpc: @$(PCOMMAND) pytest -o log_cli=true -o log_cli_level=debug -s -vv \ tests/test_efro/test_rpc.py +# Shortcut to test efro.threadpool only. +test-threadpool: + @$(PCOMMAND) pytest -o log_cli=true -o log_cli_level=debug -s -vv \ + tests/test_efro/test_threadpool.py + # Tell make which of these targets don't represent files. .PHONY: test test-fast test-verbose test-full test-fast-full \ test-message test-dataclassio test-rpc diff --git a/config/projectconfig.json b/config/projectconfig.json index c0ece20e..121042a8 100644 --- a/config/projectconfig.json +++ b/config/projectconfig.json @@ -46,7 +46,8 @@ "daemon", "jinja2", "jinja2.Environment", - "tomlkit" + "tomlkit", + "urllib3.exceptions" ], "python_paths": [ "src/assets/ba_data/python", diff --git a/src/assets/.asset_manifest_public.json b/src/assets/.asset_manifest_public.json index 0da9d678..896bf162 100644 --- a/src/assets/.asset_manifest_public.json +++ b/src/assets/.asset_manifest_public.json @@ -590,6 +590,7 @@ "ba_data/python/efro/__pycache__/logging.cpython-312.opt-1.pyc", "ba_data/python/efro/__pycache__/rpc.cpython-312.opt-1.pyc", "ba_data/python/efro/__pycache__/terminal.cpython-312.opt-1.pyc", + "ba_data/python/efro/__pycache__/threadpool.cpython-312.opt-1.pyc", "ba_data/python/efro/__pycache__/util.cpython-312.opt-1.pyc", "ba_data/python/efro/call.py", "ba_data/python/efro/cloudshell.py", @@ -626,6 +627,7 @@ "ba_data/python/efro/message/_sender.py", "ba_data/python/efro/rpc.py", "ba_data/python/efro/terminal.py", + "ba_data/python/efro/threadpool.py", "ba_data/python/efro/util.py", "server_package/__pycache__/ballisticakit_server.cpython-312.opt-1.pyc", "server_package/ballisticakit_server.py" diff --git a/src/assets/Makefile b/src/assets/Makefile index 41763c73..32ffe5ee 100644 --- a/src/assets/Makefile +++ b/src/assets/Makefile @@ -770,6 +770,7 @@ SCRIPT_TARGETS_PY_PUBLIC_TOOLS = \ $(BUILD_DIR)/ba_data/python/efro/message/_sender.py \ $(BUILD_DIR)/ba_data/python/efro/rpc.py \ $(BUILD_DIR)/ba_data/python/efro/terminal.py \ + $(BUILD_DIR)/ba_data/python/efro/threadpool.py \ $(BUILD_DIR)/ba_data/python/efro/util.py SCRIPT_TARGETS_PYC_PUBLIC_TOOLS = \ @@ -809,6 +810,7 @@ SCRIPT_TARGETS_PYC_PUBLIC_TOOLS = \ $(BUILD_DIR)/ba_data/python/efro/message/__pycache__/_sender.cpython-312.opt-1.pyc \ $(BUILD_DIR)/ba_data/python/efro/__pycache__/rpc.cpython-312.opt-1.pyc \ $(BUILD_DIR)/ba_data/python/efro/__pycache__/terminal.cpython-312.opt-1.pyc \ + $(BUILD_DIR)/ba_data/python/efro/__pycache__/threadpool.cpython-312.opt-1.pyc \ $(BUILD_DIR)/ba_data/python/efro/__pycache__/util.cpython-312.opt-1.pyc # Rule to copy src asset scripts to dst. diff --git a/src/assets/ba_data/python/babase/__init__.py b/src/assets/ba_data/python/babase/__init__.py index d2d2f4c0..4efe6758 100644 --- a/src/assets/ba_data/python/babase/__init__.py +++ b/src/assets/ba_data/python/babase/__init__.py @@ -101,6 +101,7 @@ from _babase import ( set_analytics_screen, set_low_level_config_value, set_thread_name, + set_ui_account_state, set_ui_input_device, set_ui_scale, show_progress_bar, @@ -336,6 +337,7 @@ __all__ = [ 'set_analytics_screen', 'set_low_level_config_value', 'set_thread_name', + 'set_ui_account_state', 'set_ui_input_device', 'set_ui_scale', 'show_progress_bar', diff --git a/src/assets/ba_data/python/babase/_app.py b/src/assets/ba_data/python/babase/_app.py index 693946ea..c58bc9d1 100644 --- a/src/assets/ba_data/python/babase/_app.py +++ b/src/assets/ba_data/python/babase/_app.py @@ -9,9 +9,10 @@ import logging from enum import Enum from functools import partial from typing import TYPE_CHECKING, TypeVar, override -from concurrent.futures import ThreadPoolExecutor from threading import RLock +from efro.threadpool import ThreadPoolExecutorPlus + import _babase from babase._language import LanguageSubsystem from babase._plugin import PluginSubsystem @@ -178,7 +179,7 @@ class App: # processing. It should also be passed to any additional asyncio # loops we create so that everything shares the same single set # of worker threads. - self.threadpool = ThreadPoolExecutor( + self.threadpool = ThreadPoolExecutorPlus( thread_name_prefix='baworker', initializer=self._thread_pool_thread_init, ) @@ -482,18 +483,6 @@ class App: """ _babase.run_app() - def threadpool_submit_no_wait(self, call: Callable[[], Any]) -> None: - """Submit a call to the app threadpool where result is not needed. - - Normally, doing work in a thread-pool involves creating a future - and waiting for its result, which is an important step because it - propagates any Exceptions raised by the submitted work. When the - result in not important, however, this call can be used. The app - will log any exceptions that occur. - """ - fut = self.threadpool.submit(call) - fut.add_done_callback(self._threadpool_no_wait_done) - def set_intent(self, intent: AppIntent) -> None: """Set the intent for the app. @@ -511,7 +500,7 @@ class App: # Do the actual work of calcing our app-mode/etc. in a bg thread # since it may block for a moment to load modules/etc. - self.threadpool_submit_no_wait(partial(self._set_intent, intent)) + self.threadpool.submit_no_wait(self._set_intent, intent) def push_apply_app_config(self) -> None: """Internal. Use app.config.apply() to apply app config changes.""" @@ -1108,14 +1097,6 @@ class App: await asyncio.sleep(0.01) lifecyclelog.info('fade-and-shutdown-audio end') - def _threadpool_no_wait_done(self, fut: Future) -> None: - try: - fut.result() - except Exception: - logging.exception( - 'Error in work submitted via threadpool_submit_no_wait()' - ) - def _thread_pool_thread_init(self) -> None: # Help keep things clear in profiling tools/etc. self._pool_thread_count += 1 diff --git a/src/assets/ba_data/python/baclassic/_appmode.py b/src/assets/ba_data/python/baclassic/_appmode.py index e19942e3..6b12e3dc 100644 --- a/src/assets/ba_data/python/baclassic/_appmode.py +++ b/src/assets/ba_data/python/baclassic/_appmode.py @@ -9,15 +9,7 @@ from functools import partial from typing import TYPE_CHECKING, override from bacommon.app import AppExperience -from babase import ( - app, - AppMode, - AppIntentExec, - AppIntentDefault, - invoke_main_menu, - in_logic_thread, - screenmessage, -) +import babase import _baclassic @@ -25,21 +17,20 @@ if TYPE_CHECKING: from typing import Callable, Any from efro.call import CallbackRegistration - from bacommon.cloud import ClassicAccountData - from babase import AppIntent, AccountV2Handle, CloudSubscription - from bauiv1 import UIV1AppSubsystem, MainWindow, MainWindowState + import bacommon.cloud + import bauiv1 # ba_meta export babase.AppMode -class ClassicAppMode(AppMode): +class ClassicAppMode(babase.AppMode): """AppMode for the classic BombSquad experience.""" def __init__(self) -> None: self._on_primary_account_changed_callback: ( CallbackRegistration | None ) = None - self._test_sub: CloudSubscription | None = None - self._account_data_sub: CloudSubscription | None = None + self._test_sub: babase.CloudSubscription | None = None + self._account_data_sub: babase.CloudSubscription | None = None @override @classmethod @@ -48,16 +39,18 @@ class ClassicAppMode(AppMode): @override @classmethod - def _supports_intent(cls, intent: AppIntent) -> bool: + def _supports_intent(cls, intent: babase.AppIntent) -> bool: # We support default and exec intents currently. - return isinstance(intent, AppIntentExec | AppIntentDefault) + return isinstance( + intent, babase.AppIntentExec | babase.AppIntentDefault + ) @override - def handle_intent(self, intent: AppIntent) -> None: - if isinstance(intent, AppIntentExec): + def handle_intent(self, intent: babase.AppIntent) -> None: + if isinstance(intent, babase.AppIntentExec): _baclassic.classic_app_mode_handle_app_intent_exec(intent.code) return - assert isinstance(intent, AppIntentDefault) + assert isinstance(intent, babase.AppIntentDefault) _baclassic.classic_app_mode_handle_app_intent_default() @override @@ -66,7 +59,9 @@ class ClassicAppMode(AppMode): # Let the native layer do its thing. _baclassic.classic_app_mode_activate() - assert app.plus is not None + app = babase.app + plus = app.plus + assert plus is not None # Wire up the root ui to do what we want. ui = app.ui_v1 @@ -124,16 +119,18 @@ class ClassicAppMode(AppMode): # We want to be informed when primary account changes. self._on_primary_account_changed_callback = ( - app.plus.accounts.on_primary_account_changed_callbacks.register( + plus.accounts.on_primary_account_changed_callbacks.register( self.update_for_primary_account ) ) # Establish subscriptions/etc. for any current primary account. - self.update_for_primary_account(app.plus.accounts.primary) + self.update_for_primary_account(plus.accounts.primary) @override def on_deactivate(self) -> None: + classic = babase.app.classic + # Stop being informed of account changes. self._on_primary_account_changed_callback = None @@ -141,8 +138,8 @@ class ClassicAppMode(AppMode): self.update_for_primary_account(None) # Save where we were in the UI so we return there next time. - if app.classic is not None: - app.classic.save_ui_state() + if classic is not None: + classic.save_ui_state() # Let the native layer do its thing. _baclassic.classic_app_mode_deactivate() @@ -151,15 +148,22 @@ class ClassicAppMode(AppMode): def on_app_active_changed(self) -> None: # If we've gone inactive, bring up the main menu, which has the # side effect of pausing the action (when possible). - if not app.active: - invoke_main_menu() + if not babase.app.active: + babase.invoke_main_menu() def update_for_primary_account( - self, account: AccountV2Handle | None + self, account: babase.AccountV2Handle | None ) -> None: """Update subscriptions/etc. for a new primary account state.""" - assert in_logic_thread() - assert app.plus is not None + assert babase.in_logic_thread() + plus = babase.app.plus + + assert plus is not None + + if account is not None: + babase.set_ui_account_state(True, account.tag) + else: + babase.set_ui_account_state(False) # For testing subscription functionality. if os.environ.get('BA_SUBSCRIPTION_TEST') == '1': @@ -167,18 +171,18 @@ class ClassicAppMode(AppMode): self._test_sub = None else: with account: - self._test_sub = app.plus.cloud.subscribe_test( + self._test_sub = plus.cloud.subscribe_test( self._on_sub_test_update ) else: self._test_sub = None - if account is None or bool(True): + if account is None: self._account_data_sub = None else: with account: self._account_data_sub = ( - app.plus.cloud.subscribe_classic_account_data( + plus.cloud.subscribe_classic_account_data( self._on_classic_account_data_change ) ) @@ -186,19 +190,22 @@ class ClassicAppMode(AppMode): def _on_sub_test_update(self, val: int | None) -> None: print(f'GOT SUB TEST UPDATE: {val}') - def _on_classic_account_data_change(self, val: ClassicAccountData) -> None: - print(f'GOT CLASSIC ACCOUNT DATA: {val}') + def _on_classic_account_data_change( + self, val: bacommon.cloud.ClassicAccountLiveData + ) -> None: + pass + # print(f'GOT CLASSIC ACCOUNT DATA: {val}') def _root_ui_menu_press(self) -> None: from babase import push_back_press - ui = app.ui_v1 + ui = babase.app.ui_v1 # If *any* main-window is up, kill it and resume play. old_window = ui.get_main_window() if old_window is not None: - classic = app.classic + classic = babase.app.classic assert classic is not None classic.resume() @@ -241,8 +248,8 @@ class ClassicAppMode(AppMode): def _auxiliary_window_nav( self, - win_type: type[MainWindow], - win_create_call: Callable[[], MainWindow], + win_type: type[bauiv1.MainWindow], + win_create_call: Callable[[], bauiv1.MainWindow], ) -> None: """Navigate to or away from an Auxiliary window. @@ -253,14 +260,14 @@ class ClassicAppMode(AppMode): """ # pylint: disable=unidiomatic-typecheck - ui = app.ui_v1 + ui = babase.app.ui_v1 current_main_window = ui.get_main_window() # Scan our ancestors for auxiliary states matching our type as # well as auxiliary states in general. - aux_matching_state: MainWindowState | None = None - aux_state: MainWindowState | None = None + aux_matching_state: bauiv1.MainWindowState | None = None + aux_state: bauiv1.MainWindowState | None = None if current_main_window is None: raise RuntimeError( @@ -433,4 +440,4 @@ class ClassicAppMode(AppMode): def _root_ui_chest_slot_pressed(self, index: int) -> None: print(f'CHEST {index} PRESSED') - screenmessage('UNDER CONSTRUCTION.') + babase.screenmessage('UNDER CONSTRUCTION.') diff --git a/src/assets/ba_data/python/baenv.py b/src/assets/ba_data/python/baenv.py index 9956e98a..8711b6fa 100644 --- a/src/assets/ba_data/python/baenv.py +++ b/src/assets/ba_data/python/baenv.py @@ -53,7 +53,7 @@ if TYPE_CHECKING: # Build number and version of the ballistica binary we expect to be # using. -TARGET_BALLISTICA_BUILD = 22112 +TARGET_BALLISTICA_BUILD = 22113 TARGET_BALLISTICA_VERSION = '1.7.37' diff --git a/src/assets/ba_data/python/baplus/_cloud.py b/src/assets/ba_data/python/baplus/_cloud.py index 12f6e02f..47728319 100644 --- a/src/assets/ba_data/python/baplus/_cloud.py +++ b/src/assets/ba_data/python/baplus/_cloud.py @@ -183,7 +183,8 @@ class CloudSubsystem(babase.AppSubsystem): ) def subscribe_classic_account_data( - self, updatecall: Callable[[bacommon.cloud.ClassicAccountData], None] + self, + updatecall: Callable[[bacommon.cloud.ClassicAccountLiveData], None], ) -> babase.CloudSubscription: """Subscribe to classic account data.""" raise NotImplementedError( diff --git a/src/assets/ba_data/python/bauiv1lib/coop/browser.py b/src/assets/ba_data/python/bauiv1lib/coop/browser.py index 690ce138..77702a4c 100644 --- a/src/assets/ba_data/python/bauiv1lib/coop/browser.py +++ b/src/assets/ba_data/python/bauiv1lib/coop/browser.py @@ -35,7 +35,7 @@ class CoopBrowserWindow(bui.MainWindow): # Preload some modules we use in a background thread so we won't # have a visual hitch when the user taps them. - bui.app.threadpool_submit_no_wait(self._preload_modules) + bui.app.threadpool.submit_no_wait(self._preload_modules) bui.set_analytics_screen('Coop Window') diff --git a/src/assets/ba_data/python/bauiv1lib/mainmenu.py b/src/assets/ba_data/python/bauiv1lib/mainmenu.py index 278d5019..26db7be2 100644 --- a/src/assets/ba_data/python/bauiv1lib/mainmenu.py +++ b/src/assets/ba_data/python/bauiv1lib/mainmenu.py @@ -25,7 +25,7 @@ class MainMenuWindow(bui.MainWindow): # Preload some modules we use in a background thread so we won't # have a visual hitch when the user taps them. - bui.app.threadpool_submit_no_wait(self._preload_modules) + bui.app.threadpool.submit_no_wait(self._preload_modules) bui.set_analytics_screen('Main Menu') self._show_remote_app_info_on_first_launch() diff --git a/src/assets/ba_data/python/bauiv1lib/play.py b/src/assets/ba_data/python/bauiv1lib/play.py index 2276f0cd..b94e7840 100644 --- a/src/assets/ba_data/python/bauiv1lib/play.py +++ b/src/assets/ba_data/python/bauiv1lib/play.py @@ -34,7 +34,7 @@ class PlayWindow(bui.MainWindow): # Preload some modules we use in a background thread so we won't # have a visual hitch when the user taps them. - bui.app.threadpool_submit_no_wait(self._preload_modules) + bui.app.threadpool.submit_no_wait(self._preload_modules) classic = bui.app.classic assert classic is not None diff --git a/src/assets/ba_data/python/bauiv1lib/settings/advanced.py b/src/assets/ba_data/python/bauiv1lib/settings/advanced.py index 1852b0dc..343d4a8d 100644 --- a/src/assets/ba_data/python/bauiv1lib/settings/advanced.py +++ b/src/assets/ba_data/python/bauiv1lib/settings/advanced.py @@ -32,7 +32,7 @@ class AdvancedSettingsWindow(bui.MainWindow): # Preload some modules we use in a background thread so we won't # have a visual hitch when the user taps them. - bui.app.threadpool_submit_no_wait(self._preload_modules) + bui.app.threadpool.submit_no_wait(self._preload_modules) app = bui.app assert app.classic is not None diff --git a/src/assets/ba_data/python/bauiv1lib/settings/allsettings.py b/src/assets/ba_data/python/bauiv1lib/settings/allsettings.py index 395e9efb..badb77e5 100644 --- a/src/assets/ba_data/python/bauiv1lib/settings/allsettings.py +++ b/src/assets/ba_data/python/bauiv1lib/settings/allsettings.py @@ -26,7 +26,7 @@ class AllSettingsWindow(bui.MainWindow): # Preload some modules we use in a background thread so we won't # have a visual hitch when the user taps them. - bui.app.threadpool_submit_no_wait(self._preload_modules) + bui.app.threadpool.submit_no_wait(self._preload_modules) bui.set_analytics_screen('Settings Window') assert bui.app.classic is not None diff --git a/src/ballistica/base/python/methods/python_methods_base_2.cc b/src/ballistica/base/python/methods/python_methods_base_2.cc index ea6ad45e..bbd017cd 100644 --- a/src/ballistica/base/python/methods/python_methods_base_2.cc +++ b/src/ballistica/base/python/methods/python_methods_base_2.cc @@ -928,6 +928,43 @@ static PyMethodDef PyShowProgressBarDef = { "Category: **General Utility Functions**", }; +// ------------------------- set_ui_account_state ------------------------------ + +static auto PySetUIAccountState(PyObject* self, PyObject* args, + PyObject* keywds) -> PyObject* { + BA_PYTHON_TRY; + + BA_PRECONDITION(g_base->InLogicThread()); + + int signed_in{}; + PyObject* name_obj{Py_None}; + static const char* kwlist[] = {"signed_in", "name", nullptr}; + if (!PyArg_ParseTupleAndKeywords(args, keywds, "p|O", + const_cast(kwlist), &signed_in, + &name_obj)) { + return nullptr; + } + + if (signed_in) { + auto name = Python::GetPyString(name_obj); + g_base->ui->SetAccountState(true, name); + } else { + g_base->ui->SetAccountState(false, ""); + } + + Py_RETURN_NONE; + BA_PYTHON_CATCH; +} + +static PyMethodDef PySetUIAccountStateDef = { + "set_ui_account_state", // name + (PyCFunction)PySetUIAccountState, // method + METH_VARARGS | METH_KEYWORDS, // flags + + "set_ui_account_state(signed_in: bool, name: str | None = None) -> None\n" + "\n" + "(internal)\n", +}; // ----------------------------------------------------------------------------- auto PythonMethodsBase2::GetMethods() -> std::vector { @@ -961,6 +998,7 @@ auto PythonMethodsBase2::GetMethods() -> std::vector { PyFullscreenControlKeyShortcutDef, PyFullscreenControlGetDef, PyFullscreenControlSetDef, + PySetUIAccountStateDef, }; } diff --git a/src/ballistica/base/ui/ui.cc b/src/ballistica/base/ui/ui.cc index f2ccf18e..1056d70e 100644 --- a/src/ballistica/base/ui/ui.cc +++ b/src/ballistica/base/ui/ui.cc @@ -223,8 +223,37 @@ void UI::ActivatePartyIcon() { } void UI::SetSquadSizeLabel(int val) { + assert(g_base->InLogicThread()); + + // No-op if this exactly matches what we already have. + if (val == squad_size_label_) { + return; + } + + // Store the val so we'll have it for future delegates. + squad_size_label_ = val; + + // Pass it to any current delegate. if (auto* ui_delegate = g_base->ui->delegate()) { - ui_delegate->SetSquadSizeLabel(val); + ui_delegate->SetSquadSizeLabel(squad_size_label_); + } +} + +void UI::SetAccountState(bool signed_in, const std::string& name) { + assert(g_base->InLogicThread()); + + // No-op if this exactly matches what we already have. + if (account_state_signed_in_ == signed_in && account_state_name_ == name) { + return; + } + + // Store the val so we'll have it for future delegates. + account_state_signed_in_ = signed_in; + account_state_name_ = name; + + // Pass it to any current delegate. + if (auto* ui_delegate = g_base->ui->delegate()) { + ui_delegate->SetAccountState(account_state_signed_in_, account_state_name_); } } @@ -306,10 +335,6 @@ auto UI::UIHasDirectKeyboardInput() const -> bool { void UI::HandleMouseMotion(float x, float y) { SendWidgetMessage( WidgetMessage(WidgetMessage::Type::kMouseMove, nullptr, x, y)); - - // if (auto* ui_delegate = g_base->ui->delegate()) { - // ui_delegate->HandleLegacyRootUIMouseMotion(x, y); - // } } void UI::PushBackButtonCall(InputDevice* input_device) { @@ -612,12 +637,13 @@ void UI::SetUIDelegate(base::UIDelegateInterface* delegate) { if (delegate_) { delegate_->OnActivate(); - // Inform them that a few things changed, since they might have since - // the last time they were active (these callbacks only go to the - // *active* ui delegate). + // Push values to them and trigger various 'changed' callbacks so they + // pick up the latest state of the world. delegate_->DoApplyAppConfig(); delegate_->OnScreenSizeChange(); delegate_->OnLanguageChange(); + delegate_->SetSquadSizeLabel(squad_size_label_); + delegate_->SetAccountState(account_state_signed_in_, account_state_name_); } } catch (const Exception& exc) { // Switching UI delegates is a big deal; don't try to continue if diff --git a/src/ballistica/base/ui/ui.h b/src/ballistica/base/ui/ui.h index 9453288f..1ce34491 100644 --- a/src/ballistica/base/ui/ui.h +++ b/src/ballistica/base/ui/ui.h @@ -63,8 +63,14 @@ class UI { auto PartyWindowOpen() -> bool; void ActivatePartyIcon(); + /// Set persistent squad size label; will be provided to current and + /// future delegates. void SetSquadSizeLabel(int val); + /// Set persistent account state info; will be provided to current and + /// future delegates. + void SetAccountState(bool signed_in, const std::string& name); + auto HandleMouseDown(int button, float x, float y, bool double_click) -> bool; void HandleMouseUp(int button, float x, float y); void HandleMouseMotion(float x, float y); @@ -154,6 +160,7 @@ class UI { Object::Ref dev_console_button_txt_; Object::WeakRef ui_input_device_; + std::string account_state_name_; OperationContext* operation_context_{}; base::UIDelegateInterface* delegate_{}; DevConsole* dev_console_{}; @@ -162,6 +169,8 @@ class UI { millisecs_t last_input_device_use_time_{}; millisecs_t last_widget_input_reject_err_sound_time_{}; UIScale scale_{UIScale::kLarge}; + int squad_size_label_{}; + bool account_state_signed_in_{}; bool force_scale_{}; bool show_dev_console_button_{}; bool dev_console_button_pressed_{}; diff --git a/src/ballistica/base/ui/ui_delegate.h b/src/ballistica/base/ui/ui_delegate.h index e87955d6..5ccfa760 100644 --- a/src/ballistica/base/ui/ui_delegate.h +++ b/src/ballistica/base/ui/ui_delegate.h @@ -43,6 +43,7 @@ class UIDelegateInterface { virtual auto GetRootWidget() -> ui_v1::Widget* = 0; virtual auto SendWidgetMessage(const WidgetMessage& m) -> int = 0; virtual void SetSquadSizeLabel(int num) = 0; + virtual void SetAccountState(bool signed_in, const std::string& name) = 0; /// Should return true if this app mode can confirm quitting the app. virtual auto HasQuitConfirmDialog() -> bool = 0; diff --git a/src/ballistica/shared/ballistica.cc b/src/ballistica/shared/ballistica.cc index 2eeb9e06..a2d88270 100644 --- a/src/ballistica/shared/ballistica.cc +++ b/src/ballistica/shared/ballistica.cc @@ -39,7 +39,7 @@ auto main(int argc, char** argv) -> int { namespace ballistica { // These are set automatically via script; don't modify them here. -const int kEngineBuildNumber = 22112; +const int kEngineBuildNumber = 22113; const char* kEngineVersion = "1.7.37"; const int kEngineApiVersion = 9; diff --git a/src/ballistica/ui_v1/ui_v1.cc b/src/ballistica/ui_v1/ui_v1.cc index 5cda5ddf..e227e7e2 100644 --- a/src/ballistica/ui_v1/ui_v1.cc +++ b/src/ballistica/ui_v1/ui_v1.cc @@ -81,21 +81,24 @@ bool UIV1FeatureSet::PartyIconVisible() { // Currently this is always visible. return true; } + void UIV1FeatureSet::SetAccountState(bool signed_in, const std::string& name) { + assert(root_widget_.Exists()); // Store the value and plug it in if we've got a live widget. - account_signed_in_ = signed_in; - account_name_ = name; - if (auto* r = root_widget()) { - root_widget_->SetAccountState(signed_in, name); - } + // account_signed_in_ = signed_in; + // account_name_ = name; + // if (auto* r = root_widget()) { + root_widget_->SetAccountState(signed_in, name); + // } } void UIV1FeatureSet::SetSquadSizeLabel(int num) { + assert(root_widget_.Exists()); // Store the value and plug it in if we've got a live widget. - party_icon_number_ = num; - if (auto* r = root_widget()) { - root_widget_->SetSquadSizeLabel(num); - } + // party_icon_number_ = num; + // if (auto* r = root_widget()) { + root_widget_->SetSquadSizeLabel(num); + // } } void UIV1FeatureSet::ActivatePartyIcon() { @@ -185,9 +188,9 @@ void UIV1FeatureSet::OnActivate() { rw->Setup(); rw->SetOverlayWidget(ow.Get()); - // Plug in current values for everything. - rw->SetSquadSizeLabel(party_icon_number_); - rw->SetAccountState(account_signed_in_, account_name_); + // Plug in all values we're storing. + // rw->SetSquadSizeLabel(party_icon_number_); + // rw->SetAccountState(account_signed_in_, account_name_); sw->GlobalSelect(); } @@ -199,11 +202,6 @@ void UIV1FeatureSet::OnDeactivate() { screen_root_widget_.Clear(); } -// void UIV1FeatureSet::Reset() { -// printf("UIV1::Reset()\n"); - -// } - void UIV1FeatureSet::AddWidget(Widget* w, ContainerWidget* parent) { assert(g_base->InLogicThread()); diff --git a/src/ballistica/ui_v1/ui_v1.h b/src/ballistica/ui_v1/ui_v1.h index cc24d260..e1493e31 100644 --- a/src/ballistica/ui_v1/ui_v1.h +++ b/src/ballistica/ui_v1/ui_v1.h @@ -91,7 +91,7 @@ class UIV1FeatureSet : public FeatureSetNativeComponent, void Draw(base::FrameDef* frame_def) override; void SetSquadSizeLabel(int num) override; - void SetAccountState(bool signed_in, const std::string& name); + void SetAccountState(bool signed_in, const std::string& name) override; UIV1Python* const python; @@ -143,11 +143,11 @@ class UIV1FeatureSet : public FeatureSetNativeComponent, Object::Ref root_widget_; int ui_lock_count_{}; int language_state_{}; - int party_icon_number_{}; + // int party_icon_number_{}; bool always_use_internal_on_screen_keyboard_{}; bool party_window_open_{}; - bool account_signed_in_{}; - std::string account_name_{}; + // bool account_signed_in_{}; + // std::string account_name_{}; }; } // namespace ballistica::ui_v1 diff --git a/src/ballistica/ui_v1/widget/root_widget.cc b/src/ballistica/ui_v1/widget/root_widget.cc index 896dca75..8d6d00f5 100644 --- a/src/ballistica/ui_v1/widget/root_widget.cc +++ b/src/ballistica/ui_v1/widget/root_widget.cc @@ -1261,14 +1261,22 @@ auto RootWidget::GetSpecialWidget(const std::string& s) const -> Widget* { void RootWidget::SetAccountState(bool signed_in, const std::string& name) { if (account_name_text_) { auto* w{account_name_text_->widget.Get()}; + auto* wb{account_button_->widget.Get()}; assert(w); + assert(wb); if (signed_in) { w->SetText(name); - w->set_color(0.0f, 1.0f, 0.0f, 1.0f); + w->set_color(0.0f, 0.4f, 0.1f, 1.0f); + w->set_shadow(0.2f); + w->set_flatness(1.0f); + wb->SetColor(0.8f, 1.2f, 0.8f); } else { - w->SetText("NOT SIGNED IN"); + w->SetText("{\"r\":\"notSignedInText\"}"); w->set_color(1.0f, 0.2f, 0.2f, 1.0f); + w->set_shadow(0.5f); + w->set_flatness(1.0f); + wb->SetColor(0.45f, 0.4f, 0.4f); } } } diff --git a/tests/test_efro/test_threadpool.py b/tests/test_efro/test_threadpool.py new file mode 100644 index 00000000..097b91e6 --- /dev/null +++ b/tests/test_efro/test_threadpool.py @@ -0,0 +1,64 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Testing rpc functionality.""" + +from __future__ import annotations + +import os +import time +import logging +from typing import TYPE_CHECKING + +import pytest + +from efro.threadpool import ThreadPoolExecutorPlus + +if TYPE_CHECKING: + from typing import Awaitable + +FAST_MODE = os.environ.get('BA_TEST_FAST_MODE') == '1' + + +@pytest.mark.skipif(FAST_MODE, reason='fast mode') +def test_no_wait_back_pressure(caplog: pytest.LogCaptureFixture) -> None: + """Make sure we start blocking if too many no-wait calls are submitted.""" + + tasktime = 0.1 + + def _do_test(max_no_wait_count: int) -> None: + threadpool = ThreadPoolExecutorPlus( + max_workers=10, max_no_wait_count=max_no_wait_count + ) + + def _long_call() -> None: + time.sleep(tasktime) + print('HELLO FROM FINISHED CALL') + + for _i in range(10): + threadpool.submit_no_wait(_long_call) + + threadpool.shutdown(wait=True) + + assert threadpool.no_wait_count == 0 + + # If we limit our no-wait-tasks it should take roughtly 2 * tasktime + # to get everything through and we should see a warning about + # hitting the limit. + print('\nTesting WITH no-wait-tasks bottleneck...') + starttime = time.monotonic() + with caplog.at_level(logging.WARNING): + _do_test(max_no_wait_count=6) + duration = time.monotonic() - starttime + print(f'TOOK {duration}') + assert duration > 2.0 * tasktime + assert 'hit max no-wait limit' in caplog.text + + # If no-wait-tasks is not the bottleneck, it should take just about + # tasktime exactly and there should be no warnings. + print('\nTesting WITHOUT no-wait-tasks bottleneck...') + starttime = time.monotonic() + with caplog.at_level(logging.WARNING): + _do_test(max_no_wait_count=20) + duration = time.monotonic() - starttime + print(f'TOOK {duration}') + assert duration < 1.2 * tasktime diff --git a/tools/bacommon/cloud.py b/tools/bacommon/cloud.py index a372f288..5e96a18b 100644 --- a/tools/bacommon/cloud.py +++ b/tools/bacommon/cloud.py @@ -327,8 +327,31 @@ class BSPrivatePartyResponse(Response): @ioprepped @dataclass -class ClassicAccountData: - """Account related data for classic app mode.""" +class ClassicAccountLiveData: + """Account related data kept up to date live for classic app mode.""" - tokens: Annotated[int, IOAttrs('t')] + class LeagueType(Enum): + """Type of league we are in.""" + + BRONZE = 'b' + SILVER = 's' + GOLD = 'g' + DIAMOND = 'd' + + tickets: Annotated[int, IOAttrs('ti')] + + tokens: Annotated[int, IOAttrs('to')] gold_pass: Annotated[bool, IOAttrs('g')] + + achievements: Annotated[int, IOAttrs('a')] + achievements_total: Annotated[int, IOAttrs('at')] + + league_type: Annotated[LeagueType | None, IOAttrs('lt')] + league_num: Annotated[int | None, IOAttrs('ln')] + league_rank: Annotated[int | None, IOAttrs('lr')] + + level: Annotated[int, IOAttrs('lv')] + xp: Annotated[int, IOAttrs('xp')] + xpmax: Annotated[int, IOAttrs('xpm')] + + inbox_count: Annotated[int, IOAttrs('ibc')] diff --git a/tools/efro/call.py b/tools/efro/call.py index 7f6fbe7b..11a5dbd1 100644 --- a/tools/efro/call.py +++ b/tools/efro/call.py @@ -4,14 +4,14 @@ from __future__ import annotations -import threading import weakref +import threading from typing import TYPE_CHECKING, TypeVar, Generic T = TypeVar('T') if TYPE_CHECKING: - pass + from typing import Callable class CallbackSet(Generic[T]): diff --git a/tools/efro/error.py b/tools/efro/error.py index 3411573f..d1dfcb75 100644 --- a/tools/efro/error.py +++ b/tools/efro/error.py @@ -9,6 +9,7 @@ import errno if TYPE_CHECKING: from typing import Any + import urllib3.response from efro.terminal import ClrBase @@ -105,6 +106,71 @@ class AuthenticationError(Exception): """ +class _Urllib3HttpError(Exception): + """Exception raised for non-200 html codes.""" + + def __init__(self, code: int) -> None: + self.code = code + + # So we can see code in tracebacks. + @override + def __str__(self) -> str: + from http import HTTPStatus + + try: + desc = HTTPStatus(self.code).description + except ValueError: + desc = 'Unknown HTTP Status Code' + return f'{self.code}: {desc}' + + +def raise_for_urllib3_status( + response: urllib3.response.BaseHTTPResponse, +) -> None: + """Raise an exception for html error codes aside from 200.""" + if response.status != 200: + raise _Urllib3HttpError(code=response.status) + + +def is_urllib3_communication_error(exc: BaseException, url: str | None) -> bool: + """Is the provided exception from urllib3 a communication-related error? + + Url, if provided, can provide extra context for when to treat an error + as such an error. + + This should be passed an exception which resulted from making + requests with urllib3. It returns True for any errors that could + conceivably arise due to unavailable/poor network connections, + firewall/connectivity issues, or other issues out of our control. + These errors can often be safely ignored or presented to the user as + general 'network-unavailable' states. + """ + # Need to start building these up. For now treat everything as a + # real error. + import urllib3.exceptions + + if isinstance(exc, _Urllib3HttpError): + # Special sub-case: appspot.com hosting seems to give 403 errors + # (forbidden) to some countries. I'm assuming for legal reasons?.. + # Let's consider that a communication error since its out of our + # control so we don't fill up logs with it. + if exc.code == 403 and url is not None and '.appspot.com' in url: + return True + + elif isinstance(exc, urllib3.exceptions.ReadTimeoutError): + return True + + elif isinstance(exc, urllib3.exceptions.ProtocolError): + # Most protocol errors quality as CommunicationErrors, but some + # may be due to server misconfigurations or whatnot so let's + # take it on a case by case basis. + excstr = str(exc) + if 'Connection aborted.' in excstr: + return True + + return False + + def is_urllib_communication_error(exc: BaseException, url: str | None) -> bool: """Is the provided exception from urllib a communication-related error? diff --git a/tools/efro/threadpool.py b/tools/efro/threadpool.py new file mode 100644 index 00000000..9086e51d --- /dev/null +++ b/tools/efro/threadpool.py @@ -0,0 +1,87 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Thread pool functionality.""" + +from __future__ import annotations + +import time +import logging +import threading +from typing import TYPE_CHECKING, ParamSpec +from concurrent.futures import ThreadPoolExecutor + +if TYPE_CHECKING: + from typing import Any, Callable + from concurrent.futures import Future + +P = ParamSpec('P') + +logger = logging.getLogger(__name__) + + +class ThreadPoolExecutorPlus(ThreadPoolExecutor): + """A ThreadPoolExecutor with additional functionality added.""" + + def __init__( + self, + max_workers: int | None = None, + thread_name_prefix: str = '', + initializer: Callable[[], None] | None = None, + max_no_wait_count: int | None = None, + ) -> None: + super().__init__( + max_workers=max_workers, + thread_name_prefix=thread_name_prefix, + initializer=initializer, + ) + self.no_wait_count = 0 + + self._max_no_wait_count = ( + max_no_wait_count + if max_no_wait_count is not None + else 50 if max_workers is None else max_workers * 4 + ) + self._last_no_wait_warn_time: float | None = None + self._no_wait_count_lock = threading.Lock() + + def submit_no_wait( + self, call: Callable[P, Any], *args: P.args, **keywds: P.kwargs + ) -> None: + """Submit work to the threadpool with no expectation of waiting. + + Any errors occurring in the passed callable will be logged. This + call will block and log a warning if the threadpool reaches its + max queued no-wait call count. + """ + # If we're too backlogged, issue a warning and block until we + # aren't. We don't bother with the lock here since this can be + # slightly inexact. In general we should aim to not hit this + # limit but it is good to have backpressure to avoid runaway + # queues in cases of network outages/etc. + if self.no_wait_count > self._max_no_wait_count: + now = time.monotonic() + if ( + self._last_no_wait_warn_time is None + or now - self._last_no_wait_warn_time > 10.0 + ): + logger.warning( + 'ThreadPoolExecutorPlus hit max no-wait limit of %s;' + ' blocking.', + self._max_no_wait_count, + ) + self._last_no_wait_warn_time = now + while self.no_wait_count > self._max_no_wait_count: + time.sleep(0.01) + + fut = self.submit(call, *args, **keywds) + with self._no_wait_count_lock: + self.no_wait_count += 1 + fut.add_done_callback(self._no_wait_done) + + def _no_wait_done(self, fut: Future) -> None: + with self._no_wait_count_lock: + self.no_wait_count -= 1 + try: + fut.result() + except Exception: + logger.exception('Error in work submitted via submit_no_wait().') diff --git a/tools/efro/util.py b/tools/efro/util.py index 1ef63588..0aa02d23 100644 --- a/tools/efro/util.py +++ b/tools/efro/util.py @@ -7,9 +7,10 @@ from __future__ import annotations import os import time import weakref +import functools import datetime from enum import Enum -from typing import TYPE_CHECKING, cast, TypeVar, Generic, overload +from typing import TYPE_CHECKING, cast, TypeVar, Generic, overload, ParamSpec if TYPE_CHECKING: import asyncio @@ -22,6 +23,8 @@ SelfT = TypeVar('SelfT') RetT = TypeVar('RetT') EnumT = TypeVar('EnumT', bound=Enum) +P = ParamSpec('P') + class _EmptyObj: pass @@ -32,6 +35,36 @@ class _EmptyObj: _g_empty_weak_ref = weakref.ref(_EmptyObj()) assert _g_empty_weak_ref() is None +# Note to self: adding a special form of partial for when we don't need +# to pass further args/kwargs (which I think is most cases). Even though +# partial is now type-checked in Mypy (as of Nov 2024) there are still some +# pitfalls that this avoids (see func docs below). Perhaps it would make +# sense to simply define a Call class for this purpose; it might be more +# efficient than wrapping partial anyway (should test this). +if TYPE_CHECKING: + + def strict_partial( + func: Callable[P, T], *args: P.args, **kwargs: P.kwargs + ) -> Callable[[], T]: + """A version of functools.partial requiring all args to be passed. + + This helps avoid pitfalls where a function is wrapped in a + partial but then an extra required arg is added to the function + but no type checking error is triggered at usage sites because + vanilla partial assumes that extra arg will be provided at call + time. + + Note: it would seem like this pitfall could also be avoided on + the back end by ensuring that the thing accepting the partial + asks for Callable[[], None] instead of just Callable, but as of + Nov 2024 it seems that Mypy does not support this; it in fact + allows partials to be passed for any callable signature(!). + """ + ... + +else: + strict_partial = functools.partial + def explicit_bool(val: bool) -> bool: """Return a non-inferable boolean value.