From a38ca01ade25361b54e0893306fb37b1d3ac60e9 Mon Sep 17 00:00:00 2001 From: codeskyblue Date: Mon, 22 Jan 2024 10:40:28 +0800 Subject: [PATCH 1/2] process_control: add extra_options to launch() --- .../services/dvt/instruments/process_control.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/pymobiledevice3/services/dvt/instruments/process_control.py b/pymobiledevice3/services/dvt/instruments/process_control.py index 617760908..546e013cb 100644 --- a/pymobiledevice3/services/dvt/instruments/process_control.py +++ b/pymobiledevice3/services/dvt/instruments/process_control.py @@ -2,6 +2,7 @@ import datetime import typing +from pymobiledevice3.services.dvt.dvt_secure_socket_proxy import DvtSecureSocketProxyService from pymobiledevice3.services.remote_server import MessageAux @@ -24,7 +25,7 @@ def create(cls, message) -> 'OutputReceivedEvent': class ProcessControl: IDENTIFIER = 'com.apple.instruments.server.services.processcontrol' - def __init__(self, dvt): + def __init__(self, dvt: DvtSecureSocketProxyService): self._channel = dvt.make_channel(self.IDENTIFIER) def signal(self, pid: int, sig: int): @@ -44,7 +45,7 @@ def kill(self, pid: int): self._channel.killPid_(MessageAux().append_obj(pid), expects_reply=False) def launch(self, bundle_id: str, arguments=None, kill_existing: bool = True, start_suspended: bool = False, - environment: typing.Mapping = None) -> int: + environment: typing.Mapping = None, extra_options: typing.Mapping = None) -> int: """ Launch a process. :param bundle_id: Bundle id of the process. @@ -52,15 +53,19 @@ def launch(self, bundle_id: str, arguments=None, kill_existing: bool = True, sta :param kill_existing: Whether to kill an existing instance of this process. :param start_suspended: Same as WaitForDebugger. :param environment: Environment variables to pass to process. + :param extra_options: Extra options to pass to process. :return: PID of created process. """ arguments = [] if arguments is None else arguments environment = {} if environment is None else environment + options = { + 'StartSuspendedKey': start_suspended, + 'KillExisting': kill_existing, + } + if extra_options: + options.update(extra_options) args = MessageAux().append_obj('').append_obj(bundle_id).append_obj(environment).append_obj( - arguments).append_obj({ - 'StartSuspendedKey': start_suspended, - 'KillExisting': kill_existing, - }) + arguments).append_obj(options) self._channel.launchSuspendedProcessWithDevicePath_bundleIdentifier_environment_arguments_options_(args) result = self._channel.receive_plist() assert result From 051ed4f47c2e24967483ec06f104fbc64952c780 Mon Sep 17 00:00:00 2001 From: codeskyblue Date: Mon, 22 Jan 2024 10:43:06 +0800 Subject: [PATCH 2/2] developer: add xcuitest support --- pymobiledevice3/cli/developer.py | 15 + pymobiledevice3/exceptions.py | 4 + .../services/dvt/dvt_testmanaged_proxy.py | 23 ++ .../services/dvt/instruments/screenshot.py | 5 +- .../services/dvt/testmanaged/xcuitest.py | 303 ++++++++++++++++++ pymobiledevice3/services/remote_server.py | 116 ++++++- 6 files changed, 459 insertions(+), 7 deletions(-) create mode 100644 pymobiledevice3/services/dvt/dvt_testmanaged_proxy.py create mode 100644 pymobiledevice3/services/dvt/testmanaged/xcuitest.py diff --git a/pymobiledevice3/cli/developer.py b/pymobiledevice3/cli/developer.py index dd1a4c204..d95180609 100644 --- a/pymobiledevice3/cli/developer.py +++ b/pymobiledevice3/cli/developer.py @@ -43,6 +43,7 @@ from pymobiledevice3.services.dvt.instruments.process_control import ProcessControl from pymobiledevice3.services.dvt.instruments.screenshot import Screenshot from pymobiledevice3.services.dvt.instruments.sysmontap import Sysmontap +from pymobiledevice3.services.dvt.testmanaged.xcuitest import XCUITestService from pymobiledevice3.services.remote_fetch_symbols import RemoteFetchSymbolsService from pymobiledevice3.services.remote_server import RemoteServer from pymobiledevice3.services.screenshot import ScreenshotService @@ -271,6 +272,20 @@ def screenshot(service_provider: LockdownClient, out): out.write(Screenshot(dvt).get_screenshot()) +@dvt.command('xcuitest', cls=Command) +@click.argument('bundle-id') +def xcuitest(service_provider: LockdownClient, bundle_id: str) -> None: + """\b + start XCUITest + Usage example: + iOS<17: + python3 -m pymobiledevice3 developer dvt xcuitest com.facebook.WebDriverAgentRunner.xctrunner + iOS>=17: + python3 -m pymobiledevice3 developer dvt xcuitest com.facebook.WebDriverAgentRunner.xctrunner --tunnel $UDID + """ + XCUITestService(service_provider).run(bundle_id) + + @dvt.group('sysmon') def sysmon(): """ System monitor options. """ diff --git a/pymobiledevice3/exceptions.py b/pymobiledevice3/exceptions.py index 34da00d07..5b29312d7 100644 --- a/pymobiledevice3/exceptions.py +++ b/pymobiledevice3/exceptions.py @@ -319,6 +319,10 @@ class AppInstallError(PyMobileDevice3Exception): pass +class AppNotInstalledError(PyMobileDevice3Exception): + pass + + class CoreDeviceError(PyMobileDevice3Exception): pass diff --git a/pymobiledevice3/services/dvt/dvt_testmanaged_proxy.py b/pymobiledevice3/services/dvt/dvt_testmanaged_proxy.py new file mode 100644 index 000000000..b09007549 --- /dev/null +++ b/pymobiledevice3/services/dvt/dvt_testmanaged_proxy.py @@ -0,0 +1,23 @@ +from packaging.version import Version + +from pymobiledevice3.lockdown_service_provider import LockdownServiceProvider +from pymobiledevice3.remote.remote_service_discovery import RemoteServiceDiscoveryService +from pymobiledevice3.services.remote_server import RemoteServer + + +class DvtTestmanagedProxyService(RemoteServer): + SERVICE_NAME = 'com.apple.testmanagerd.lockdown.secure' + OLD_SERVICE_NAME = 'com.apple.testmanagerd.lockdown' + RSD_SERVICE_NAME = 'com.apple.dt.testmanagerd.remote' + + # TODO: there is also service named 'com.apple.dt.testmanagerd.remote.automation', but not used + + def __init__(self, lockdown: LockdownServiceProvider): + if isinstance(lockdown, RemoteServiceDiscoveryService): # only happends when >=17.0 + service_name = self.RSD_SERVICE_NAME + elif Version(lockdown.product_version) >= Version('14.0'): + service_name = self.SERVICE_NAME + else: + service_name = self.OLD_SERVICE_NAME + + super().__init__(lockdown, service_name, remove_ssl_context=False) diff --git a/pymobiledevice3/services/dvt/instruments/screenshot.py b/pymobiledevice3/services/dvt/instruments/screenshot.py index 3948b8c7e..03f3bd72a 100644 --- a/pymobiledevice3/services/dvt/instruments/screenshot.py +++ b/pymobiledevice3/services/dvt/instruments/screenshot.py @@ -1,7 +1,10 @@ +from pymobiledevice3.services.dvt.dvt_secure_socket_proxy import DvtSecureSocketProxyService + + class Screenshot: IDENTIFIER = 'com.apple.instruments.server.services.screenshot' - def __init__(self, dvt): + def __init__(self, dvt: DvtSecureSocketProxyService): self._channel = dvt.make_channel(self.IDENTIFIER) def get_screenshot(self) -> bytes: diff --git a/pymobiledevice3/services/dvt/testmanaged/xcuitest.py b/pymobiledevice3/services/dvt/testmanaged/xcuitest.py new file mode 100644 index 000000000..f68c91260 --- /dev/null +++ b/pymobiledevice3/services/dvt/testmanaged/xcuitest.py @@ -0,0 +1,303 @@ +import logging +import time +from typing import Any, Mapping, Optional + +from bpylist2 import archiver +from packaging.version import Version + +from pymobiledevice3.exceptions import AppNotInstalledError +from pymobiledevice3.lockdown import LockdownClient +from pymobiledevice3.lockdown_service_provider import LockdownServiceProvider +from pymobiledevice3.services.dvt.dvt_secure_socket_proxy import DvtSecureSocketProxyService +from pymobiledevice3.services.dvt.dvt_testmanaged_proxy import DvtTestmanagedProxyService +from pymobiledevice3.services.dvt.instruments.process_control import ProcessControl +from pymobiledevice3.services.house_arrest import HouseArrestService +from pymobiledevice3.services.installation_proxy import InstallationProxyService +from pymobiledevice3.services.remote_server import NSURL, NSUUID, Channel, ChannelFragmenter, MessageAux, \ + XCTestConfiguration, dtx_message_header_struct, dtx_message_payload_header_struct + +logger = logging.getLogger(__name__) + + +class XCUITestService: + IDENTIFIER = "dtxproxy:XCTestManager_IDEInterface:XCTestManager_DaemonConnectionInterface" + XCODE_VERSION = 36 # not important + + def __init__(self, service_provider: LockdownServiceProvider): + self.service_provider = service_provider + self.pctl = self.init_process_control() + self.product_major_version = Version(service_provider.product_version).major + + def run( + self, + bundle_id: str, + test_runner_env: Optional[dict] = None, + test_runner_args: Optional[list] = None, + ): + # Test OK with + # - iPhone SE (iPhone8,4) 15.8 + # + # Test Failed with + # - iPhone 12 Pro (iPhone13,3) 17.2 + # + # TODO: it seems the protocol changed when iOS>=17 + session_identifier = NSUUID.uuid4() + app_info = get_app_info(self.service_provider, bundle_id) + + xctest_configuration = generate_xctestconfiguration( + app_info, session_identifier, bundle_id, test_runner_env, test_runner_args + ) + xctest_path = f"/tmp/{str(session_identifier).upper()}.xctestconfiguration" # yapf: disable + + self.setup_xcuitest(bundle_id, xctest_path, xctest_configuration) + dvt1, chan1, dvt2, chan2 = self.init_ide_channels(session_identifier) + + pid = self.launch_test_app( + app_info, bundle_id, xctest_path, test_runner_env, test_runner_args + ) + logger.info("Runner started with pid:%d, waiting for testBundleReady", pid) + + time.sleep(1) + self.authorize_test_process_id(chan1, pid) + self.start_executing_test_plan_with_protocol_version(dvt2, self.XCODE_VERSION) + + # TODO: boradcast message is not handled + # TODO: RemoteServer.receive_message is not thread safe and will block if no message received + try: + self.dispatch(dvt2, chan2) + except KeyboardInterrupt: + logger.info("Signal Interrupt catched") + finally: + logger.info("Killing UITest with pid %d ...", pid) + self.pctl.kill(pid) + dvt1.close() + dvt2.close() + + def dispatch(self, dvt: DvtTestmanagedProxyService, chan: Channel): + while True: + self.dispatch_proxy(dvt, chan) + + def dispatch_proxy(self, dvt: DvtTestmanagedProxyService, chan: Channel): + # Ref code: + # https://github.com/danielpaulus/go-ios/blob/a49a3582ef4438fee794912c167d2cccf45d8efa/ios/testmanagerd/xcuitestrunner.go#L182 + # https://github.com/alibaba/tidevice/blob/main/tidevice/_device.py#L1117 + + key, value = dvt.recv_plist(chan) + value = value and value[0].value.strip() + if key == "_XCT_logDebugMessage:": + logger.debug("logDebugMessage: %s", value) + elif key == "_XCT_testRunnerReadyWithCapabilities:": + logger.info("testRunnerReadyWithCapabilities: %s", value) + self.send_response_capabilities(dvt, chan, dvt.cur_message) + else: + # There are still unhandled messages + # - _XCT_testBundleReadyWithProtocolVersion:minimumVersion: + # - _XCT_didFinishExecutingTestPlan + logger.info("unhandled %s %r", key, value) + + def send_response_capabilities( + self, dvt: DvtTestmanagedProxyService, chan: Channel, cur_message: int + ): + pheader = dtx_message_payload_header_struct.build( + dict(flags=3, auxiliaryLength=0, totalLength=0) + ) + mheader = dtx_message_header_struct.build( + dict( + cb=dtx_message_header_struct.sizeof(), + fragmentId=0, + fragmentCount=1, + length=dtx_message_payload_header_struct.sizeof(), + identifier=cur_message, + conversationIndex=1, + channelCode=chan, + expectsReply=int(0), + ) + ) + msg = mheader + pheader + dvt.service.sendall(msg) + + def init_process_control(self): + dvt_proxy = DvtSecureSocketProxyService(lockdown=self.service_provider) + dvt_proxy.perform_handshake() + return ProcessControl(dvt_proxy) + + def init_ide_channels(self, session_identifier: NSUUID): + # XcodeIDE require two connections + dvt1 = DvtTestmanagedProxyService(lockdown=self.service_provider) + dvt1.perform_handshake() + + logger.info("make channel %s", self.IDENTIFIER) + chan1 = dvt1.make_channel(self.IDENTIFIER) + if self.product_major_version >= 11: + dvt1.send_message( + chan1, + "_IDE_initiateControlSessionWithProtocolVersion:", + MessageAux().append_obj(self.XCODE_VERSION), + ) + reply = chan1.receive_plist() + logger.info("conn1 handshake xcode version: %s", reply) + + dvt2 = DvtTestmanagedProxyService(lockdown=self.service_provider) + dvt2.perform_handshake() + chan2 = dvt2.make_channel(self.IDENTIFIER) + dvt2.send_message( + channel=chan2, + selector="_IDE_initiateSessionWithIdentifier:forClient:atPath:protocolVersion:", + args=MessageAux() + .append_obj(session_identifier) + .append_obj("not-very-import-part") # this part is not important + .append_obj("/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild") + .append_obj(self.XCODE_VERSION), + ) + reply = chan2.receive_plist() + logger.info("conn2 handshake xcode version: %s", reply) + return dvt1, chan1, dvt2, chan2 + + def setup_xcuitest( + self, + bundle_id: str, + xctest_path: str, + xctest_configuration: XCTestConfiguration, + ): + """push xctestconfiguration to app VendDocuments""" + with HouseArrestService( + lockdown=self.service_provider, bundle_id=bundle_id, documents_only=False + ) as afc: + for name in afc.listdir("/tmp"): + if name.endswith(".xctestconfiguration"): + logger.debug("remove /tmp/%s", name) + afc.rm("/tmp/" + name) + afc.set_file_contents(xctest_path, archiver.archive(xctest_configuration)) + + def start_executing_test_plan_with_protocol_version(self, dvt: DvtTestmanagedProxyService, protocol_version: int): + ide_channel = Channel.create(-1, dvt) + dvt.channel_messages[ide_channel] = ChannelFragmenter() + dvt.send_message( + ide_channel, + "_IDE_startExecutingTestPlanWithProtocolVersion:", + MessageAux().append_obj(protocol_version), + expects_reply=False, + ) + + def authorize_test_process_id(self, chan: Channel, pid: int): + selector = None + aux = MessageAux() + if self.product_major_version >= 12: + selector = "_IDE_authorizeTestSessionWithProcessID:" + aux.append_obj(pid) + elif self.product_major_version >= 10: + selector = "_IDE_initiateControlSessionForTestProcessID:protocolVersion:" + aux.append_obj(pid) + aux.append_obj(self.XCODE_VERSION) + else: + selector = "_IDE_initiateControlSessionForTestProcessID:" + aux.append_obj(pid) + chan.send_message(selector, aux) + reply = chan.receive_plist() + if isinstance(reply, bool) and reply is True: + logger.info("authorizing test session for pid %d successful %r", pid, reply) + else: + raise RuntimeError("Failed to authorize test process id: %s" % reply) + + def launch_test_app( + self, + app_info: dict, + bundle_id: str, + xctest_path: str, + test_runner_env: Optional[dict] = None, + test_runner_args: Optional[list] = None, + ) -> int: + app_container = app_info["Container"] + app_path = app_info["Path"] + exec_name = app_info["CFBundleExecutable"] + # # logger.info('CFBundleExecutable: %s', exec_name) + # # CFBundleName always endswith -Runner + assert exec_name.endswith("-Runner"), ( + "Invalid CFBundleExecutable: %s" % exec_name + ) + target_name = exec_name[: -len("-Runner")] + + app_env = { + "CA_ASSERT_MAIN_THREAD_TRANSACTIONS": "0", + "CA_DEBUG_TRANSACTIONS": "0", + "DYLD_FRAMEWORK_PATH": app_path + "/Frameworks:", + "DYLD_LIBRARY_PATH": app_path + "/Frameworks", + "MTC_CRASH_ON_REPORT": "1", + "NSUnbufferedIO": "YES", + "SQLITE_ENABLE_THREAD_ASSERTIONS": "1", + "WDA_PRODUCT_BUNDLE_IDENTIFIER": "", + "XCTestBundlePath": f'{app_info["Path"]}/PlugIns/{target_name}.xctest', + "XCTestConfigurationFilePath": app_container + xctest_path, + "XCODE_DBG_XPC_EXCLUSIONS": "com.apple.dt.xctestSymbolicator", + # the following maybe no needed + # 'MJPEG_SERVER_PORT': '', + # 'USE_PORT': '', + # 'LLVM_PROFILE_FILE': app_container + '/tmp/%p.profraw', # %p means pid + } + if test_runner_env: + app_env.update(test_runner_env) + + if self.product_major_version >= 11: + app_env[ + "DYLD_INSERT_LIBRARIES" + ] = "/Developer/usr/lib/libMainThreadChecker.dylib" + app_env["OS_ACTIVITY_DT_MODE"] = "YES" + + app_args = [ + "-NSTreatUnknownArgumentsAsOpen", + "NO", + "-ApplePersistenceIgnoreState", + "YES", + ] + app_args.extend(test_runner_args or []) + app_options = {"StartSuspendedKey": False} + if self.product_major_version >= 12: + app_options["ActivateSuspended"] = True + + pid = self.pctl.launch( + bundle_id, + arguments=app_args, + environment=app_env, + extra_options=app_options, + ) + for message in self.pctl: + logger.info("ProcessOutput: %s", message) + return pid + + +def get_app_info(service_provider: LockdownClient, bundle_id: str) -> Mapping[str, Any]: + with InstallationProxyService(lockdown=service_provider) as install_service: + apps = install_service.get_apps(bundle_identifiers=[bundle_id]) + if not apps: + raise AppNotInstalledError(f"No app with bundle id {bundle_id} found") + return apps[bundle_id] + + +def generate_xctestconfiguration( + app_info: dict, + session_identifier: NSUUID, + target_app_bundle_id: str = None, + target_app_env: Optional[dict] = None, + target_app_args: Optional[list] = None, + tests_to_run: Optional[list] = None, +) -> XCTestConfiguration: + exec_name: str = app_info["CFBundleExecutable"] + assert exec_name.endswith("-Runner"), "Invalid CFBundleExecutable: %s" % exec_name + config_name = exec_name[: -len("-Runner")] + + return XCTestConfiguration( + { + "testBundleURL": NSURL( + None, f'file://{app_info["Path"]}/PlugIns/{config_name}.xctest' + ), + "sessionIdentifier": session_identifier, + "targetApplicationBundleID": target_app_bundle_id, + "targetApplicationEnvironment": target_app_env or {}, + "targetApplicationArguments": target_app_args or [], + "testsToRun": tests_to_run or set(), + "testsMustRunOnMainThread": True, + "reportResultsToIDE": True, + "reportActivities": True, + "automationFrameworkPath": "/Developer/Library/PrivateFrameworks/XCTAutomationSupport.framework", + } + ) diff --git a/pymobiledevice3/services/remote_server.py b/pymobiledevice3/services/remote_server.py index d089040f2..6e0a98cf9 100644 --- a/pymobiledevice3/services/remote_server.py +++ b/pymobiledevice3/services/remote_server.py @@ -1,6 +1,9 @@ +import copy import io +import os import plistlib import typing +import uuid from functools import partial from pprint import pprint from queue import Empty, Queue @@ -114,6 +117,10 @@ def decode_archive(archive_obj): class NSError: + @staticmethod + def encode_archive(archive_obj): + return archiver.archive(archive_obj) + @staticmethod def decode_archive(archive_obj): user_info = archive_obj.decode('NSUserInfo') @@ -122,6 +129,88 @@ def decode_archive(archive_obj): raise DvtException(archive_obj.decode('NSUserInfo')) +class NSUUID(uuid.UUID): + @staticmethod + def uuid4(): + """Generate a random UUID.""" + return NSUUID(bytes=os.urandom(16)) + + def encode_archive(self, archive_obj: archiver.ArchivingObject): + archive_obj.encode('NS.uuidbytes', self.bytes) + + @staticmethod + def decode_archive(archive_obj: archiver.ArchivedObject): + return NSUUID(bytes=archive_obj.decode('NS.uuidbytes')) + + +class NSURL: + def __init__(self, base, relative): + self.base = base + self.relative = relative + + def encode_archive(self, archive_obj: archiver.ArchivingObject): + archive_obj.encode('NS.base', self.base) + archive_obj.encode('NS.relative', self.relative) + + @staticmethod + def decode_archive(archive_obj: archiver.ArchivedObject): + return NSURL(archive_obj.decode('NS.base'), archive_obj.decode('NS.relative')) + + +class XCTestConfiguration: + _default = { + # 'testBundleURL': UID(3), + # 'sessionIdentifier': UID(8), # UUID + 'aggregateStatisticsBeforeCrash': { + 'XCSuiteRecordsKey': {} + }, + 'automationFrameworkPath': '/Developer/Library/PrivateFrameworks/XCTAutomationSupport.framework', + 'baselineFileRelativePath': None, + 'baselineFileURL': None, + 'defaultTestExecutionTimeAllowance': None, + 'disablePerformanceMetrics': False, + 'emitOSLogs': False, + 'formatVersion': plistlib.UID(2), # store in UID + 'gatherLocalizableStringsData': False, + 'initializeForUITesting': True, + 'maximumTestExecutionTimeAllowance': None, + 'productModuleName': 'WebDriverAgentRunner', # set to other value is also OK + 'randomExecutionOrderingSeed': None, + 'reportActivities': True, + 'reportResultsToIDE': True, + 'systemAttachmentLifetime': 2, + 'targetApplicationArguments': [], # maybe useless + 'targetApplicationBundleID': None, + 'targetApplicationEnvironment': None, + 'targetApplicationPath': '/whatever-it-does-not-matter/but-should-not-be-empty', + 'testApplicationDependencies': {}, + 'testApplicationUserOverrides': None, + 'testBundleRelativePath': None, + 'testExecutionOrdering': 0, + 'testTimeoutsEnabled': False, + 'testsDrivenByIDE': False, + 'testsMustRunOnMainThread': True, + 'testsToRun': None, + 'testsToSkip': None, + 'treatMissingBaselinesAsFailures': False, + 'userAttachmentLifetime': 1 + } + + def __init__(self, kv: dict): + assert 'testBundleURL' in kv + assert 'sessionIdentifier' in kv + self._config = copy.deepcopy(self._default) + self._config.update(kv) + + def encode_archive(self, archive_obj: archiver.ArchivingObject): + for k, v in self._config.items(): + archive_obj.encode(k, v) + + @staticmethod + def decode_archive(archive_obj: archiver.ArchivedObject): + return archive_obj.object + + archiver.update_class_map({'DTSysmonTapMessage': DTTapMessage, 'DTTapHeartbeatMessage': DTTapMessage, 'DTTapStatusMessage': DTTapMessage, @@ -129,12 +218,18 @@ def decode_archive(archive_obj): 'DTActivityTraceTapMessage': DTTapMessage, 'DTTapMessage': DTTapMessage, 'NSNull': NSNull, - 'NSError': NSError}) + 'NSError': NSError, + 'NSUUID': NSUUID, + 'NSURL': NSURL, + 'XCTestConfiguration': XCTestConfiguration}) + + +archiver.Archive.inline_types = list(set(archiver.Archive.inline_types + [bytes])) class Channel(int): @classmethod - def create(cls, value: int, service): + def create(cls, value: int, service: 'RemoteServer'): channel = cls(value) channel._service = service return channel @@ -148,6 +243,9 @@ def receive_plist(self): def receive_message(self): return self._service.recv_message(self)[0] + def send_message(self, selector: str, args: MessageAux = None, expects_reply: bool = True): + self._service.send_message(self, selector, args, expects_reply=expects_reply) + @staticmethod def _sanitize_name(name: str): """ @@ -274,7 +372,8 @@ def perform_handshake(self): self.supported_identifiers = aux[0].value def make_channel(self, identifier) -> Channel: - assert identifier in self.supported_identifiers + # NOTE: There is also identifier not in self.supported_identifiers + # assert identifier in self.supported_identifiers if identifier in self.channel_cache: return self.channel_cache[identifier] @@ -375,10 +474,15 @@ def __enter__(self): def close(self): aux = MessageAux() - for code in self.channel_messages.keys(): - if code > 0: + codes = [code for code in self.channel_messages.keys() if code > 0] + if codes: + for code in codes: aux.append_int(code) - self.send_message(self.BROADCAST_CHANNEL, '_channelCanceled:', aux, expects_reply=False) + try: + self.send_message(self.BROADCAST_CHANNEL, '_channelCanceled:', aux, expects_reply=False) + except OSError: + # ignore: OSError: [Errno 9] Bad file descriptor + pass super().close()