1# Copyright 2023 The Pigweed Authors 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may not 4# use this file except in compliance with the License. You may obtain a copy of 5# the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations under 13# the License. 14"""Infrastructure used by the user interface or specific emulators.""" 15 16import io 17import json 18import logging 19import os 20import re 21import socket 22import subprocess 23import sys 24import time 25 26from abc import ABC, abstractmethod 27from importlib import import_module 28from pathlib import Path 29from typing import Any, Type 30 31import psutil # type: ignore 32 33from pw_emu.pigweed_emulators import pigweed_emulators 34from pw_env_setup.config_file import load as pw_config_load 35from pw_env_setup.config_file import path as pw_config_path 36from serial import Serial 37 38 39_LAUNCHER_LOG = logging.getLogger('pw_qemu.core.launcher') 40 41 42def _stop_process(pid: int) -> None: 43 """Gracefully stops a running process.""" 44 45 try: 46 proc = psutil.Process(pid) 47 proc.terminate() 48 try: 49 proc.wait(timeout=5) 50 except psutil.TimeoutExpired: 51 proc.kill() 52 except psutil.NoSuchProcess: 53 pass 54 55 56def _get_class(name: str) -> type: 57 """Returns a class from a full qualified class name 58 (e.g. "package.module.Class"). 59 60 """ 61 try: 62 module_path, class_name = name.rsplit('.', 1) 63 module = import_module(module_path) 64 return getattr(module, class_name) 65 except (ImportError, AttributeError): 66 raise ImportError(name) 67 68 69class Error(Exception): 70 """Generic pw_emu exception.""" 71 72 73class ConfigError(Error): 74 """Exception raised for configuration errors.""" 75 76 def __init__(self, config: Path | None, err: str) -> None: 77 msg = f'{config}: {err}\n' 78 try: 79 if config: 80 with open(config, 'r') as file: 81 msg += json.dumps(json.load(file), indent=4) 82 except (OSError, json.decoder.JSONDecodeError): 83 pass 84 super().__init__(msg) 85 86 87class AlreadyRunning(Error): 88 """Exception raised if an emulator process is already running.""" 89 90 def __init__(self, wdir: Path) -> None: 91 super().__init__(f'{wdir}: emulator already started') 92 93 94class NotRunning(Error): 95 """Exception raised if an emulator process is not running.""" 96 97 def __init__(self, wdir: Path) -> None: 98 super().__init__(f'{wdir}: emulator not started') 99 100 101class InvalidEmulator(Error): 102 """Exception raised if an different backend is running.""" 103 104 def __init__(self, emu: str) -> None: 105 super().__init__(f'invalid emulator `{emu}`') 106 107 108class InvalidTarget(Error): 109 """Exception raised if the target is invalid.""" 110 111 def __init__(self, config: Path, emu: str | None, target: str) -> None: 112 emu_str = f'for `{emu}`' if emu else '' 113 super().__init__(f'{config}: invalid target `{target}` {emu_str}') 114 115 116class InvalidChannelName(Error): 117 """Exception raised if a channel name is invalid.""" 118 119 def __init__(self, name: str, target: str, valid: str) -> None: 120 msg = f""" 121 `{name}` is not a valid device name for {target}` 122 try: {valid} 123 """ 124 super().__init__(msg) 125 126 127class InvalidChannelType(Error): 128 """Exception raised if a channel type is invalid.""" 129 130 def __init__(self, name: str) -> None: 131 super().__init__(f'`{name}` is not a valid channel type') 132 133 134class WrongEmulator(Error): 135 """Exception raised if a different backend is running.""" 136 137 def __init__(self, exp: str, found: str) -> None: 138 super().__init__(f'wrong emulator: expected `{exp}, found {found}`') 139 140 141class RunError(Error): 142 """Exception raised when a command failed to run.""" 143 144 def __init__(self, proc: str, msg: str) -> None: 145 super().__init__(f'error running `{proc}`: {msg}') 146 self.proc = proc 147 self.msg = msg 148 149 150class InvalidPropertyPath(Error): 151 """Exception raised for an invalid property path.""" 152 153 def __init__(self, path: str) -> None: 154 super().__init__(f'invalid property path `{path}`') 155 156 157class InvalidProperty(Error): 158 """Exception raised for an invalid property path.""" 159 160 def __init__(self, path: str, name: str) -> None: 161 super().__init__(f'invalid property `{name}` at `{path}`') 162 163 164class HandlesError(Error): 165 """Exception raised if the load of an emulator handle fails.""" 166 167 def __init__(self, msg: str) -> None: 168 super().__init__(f'error loading handles: {msg}') 169 170 171class Handles: 172 """Running emulator handles.""" 173 174 class Channel: 175 def __init__(self, chan_type: str): 176 self.type = chan_type 177 178 class PtyChannel(Channel): 179 def __init__(self, path: str): 180 super().__init__('pty') 181 self.path = path 182 183 class TcpChannel(Channel): 184 def __init__(self, host: str, port: int): 185 super().__init__('tcp') 186 self.host = host 187 self.port = port 188 189 class Proc: 190 def __init__(self, pid: int): 191 self.pid = pid 192 193 @staticmethod 194 def _ser_obj(obj) -> Any: 195 if isinstance(obj, dict): 196 data = {} 197 for key, val in obj.items(): 198 data[key] = Handles._ser_obj(val) 199 return data 200 if hasattr(obj, "__iter__") and not isinstance(obj, str): 201 return [Handles._ser_obj(item) for item in obj] 202 if hasattr(obj, "__dict__"): 203 return Handles._ser_obj(obj.__dict__) 204 return obj 205 206 def _serialize(self): 207 return Handles._ser_obj(self) 208 209 def save(self, wdir: Path) -> None: 210 """Saves handles to the given working directory.""" 211 212 with open(os.path.join(wdir, 'handles.json'), 'w') as file: 213 json.dump(self._serialize(), file) 214 215 @staticmethod 216 def load(wdir: Path): 217 try: 218 with open(os.path.join(wdir, 'handles.json'), 'r') as file: 219 data = json.load(file) 220 except (KeyError, OSError, json.decoder.JSONDecodeError): 221 raise NotRunning(wdir) 222 223 handles = Handles(data['emu'], data['config']) 224 handles.set_target(data['target']) 225 gdb_cmd = data.get('gdb_cmd') 226 if gdb_cmd: 227 handles.set_gdb_cmd(gdb_cmd) 228 for name, chan in data['channels'].items(): 229 chan_type = chan['type'] 230 if chan_type == 'tcp': 231 handles.add_channel_tcp(name, chan['host'], chan['port']) 232 elif chan_type == 'pty': 233 handles.add_channel_pty(name, chan['path']) 234 else: 235 raise InvalidChannelType(chan_type) 236 for name, proc in data['procs'].items(): 237 handles.add_proc(name, proc['pid']) 238 return handles 239 240 def __init__(self, emu: str, config: str) -> None: 241 self.emu = emu 242 self.config = config 243 self.gdb_cmd: list[str] = [] 244 self.target = '' 245 self.channels: dict[str, Handles.Channel] = {} 246 self.procs: dict[str, Handles.Proc] = {} 247 248 def add_channel_tcp(self, name: str, host: str, port: int) -> None: 249 """Adds a TCP channel.""" 250 251 self.channels[name] = self.TcpChannel(host, port) 252 253 def add_channel_pty(self, name: str, path: str) -> None: 254 """Adds a pty channel.""" 255 256 self.channels[name] = self.PtyChannel(path) 257 258 def add_proc(self, name: str, pid: int) -> None: 259 """Adds a process ID.""" 260 261 self.procs[name] = self.Proc(pid) 262 263 def set_target(self, target: str) -> None: 264 """Sets the target.""" 265 266 self.target = target 267 268 def set_gdb_cmd(self, cmd: list[str]) -> None: 269 """Sets the ``gdb`` command.""" 270 271 self.gdb_cmd = cmd.copy() 272 273 274def _stop_processes(handles: Handles, wdir: Path) -> None: 275 """Stops all processes for a (partially) running emulator instance. 276 277 Remove pid files as well. 278 """ 279 280 for _, proc in handles.procs.items(): 281 _stop_process(proc.pid) 282 path = os.path.join(wdir, f'{proc}.pid') 283 if os.path.exists(path): 284 os.unlink(path) 285 286 287class Config: 288 """Get and validate options from the configuration file.""" 289 290 def __init__( 291 self, 292 config_path: Path | None = None, 293 target: str | None = None, 294 emu: str | None = None, 295 ) -> None: 296 """Loads the emulator configuration. 297 298 If no configuration file path is given, the root project 299 configuration is used. 300 301 This method set ups the generic configuration (e.g. ``gdb``). 302 303 It loads emulator target files and gathers them under the ``targets`` 304 key for each emulator backend. The ``targets`` settings in the 305 configuration file takes precedence over the loaded target files. 306 307 """ 308 try: 309 if config_path: 310 with open(config_path, 'r') as file: 311 config = json.load(file)['pw']['pw_emu'] 312 else: 313 config_path = pw_config_path() 314 config = pw_config_load()['pw']['pw_emu'] 315 except KeyError: 316 raise ConfigError(config_path, 'missing `pw_emu` configuration') 317 318 if not config_path: 319 raise ConfigError(None, 'unable to deterine config path') 320 321 if config.get('target_files'): 322 tmp = {} 323 for path in config['target_files']: 324 if not os.path.isabs(path): 325 path = os.path.join(os.path.dirname(config_path), path) 326 with open(path, 'r') as file: 327 tmp.update(json.load(file).get('targets')) 328 if config.get('targets'): 329 tmp.update(config['targets']) 330 config['targets'] = tmp 331 332 self.path = config_path 333 self._config = {'emulators': pigweed_emulators} 334 self._config.update(config) 335 self._emu = emu 336 self._target = target 337 338 def set_target(self, target: str) -> None: 339 """Sets the current target. 340 341 The current target is used by the 342 :py:meth:`pw_emu.core.Config.get_target` method. 343 344 """ 345 346 self._target = target 347 try: 348 self.get(['targets', target], optional=False, entry_type=dict) 349 except ConfigError: 350 raise InvalidTarget(self.path, self._emu, self._target) 351 352 def get_targets(self) -> list[str]: 353 return list(self.get(['targets'], entry_type=dict).keys()) 354 355 def _subst(self, string: str) -> str: 356 """Substitutes $pw_<subst_type>{arg} statements.""" 357 358 match = re.search(r'\$pw_([^{]+){([^}]+)}', string) 359 if not match: 360 return string 361 362 subst_type = match.group(1) 363 arg = match.group(2) 364 365 if subst_type == 'env': 366 value = os.environ.get(arg) 367 if value is None: 368 msg = f'Environment variable `{arg}` not set' 369 raise ConfigError(self.path, msg) 370 return string.replace(f'$pw_{subst_type}{{{arg}}}', value) 371 372 raise ConfigError(self.path, f'Invalid substitution type: {subst_type}') 373 374 def _subst_list(self, items: list[Any]) -> list[Any]: 375 new_list = [] 376 for item in items: 377 if isinstance(item, str): 378 new_list.append(self._subst(item)) 379 else: 380 new_list.append(item) 381 return new_list 382 383 def get( 384 self, 385 keys: list[str], 386 optional: bool = True, 387 entry_type: Type | None = None, 388 ) -> Any: 389 """Gets a config entry. 390 391 ``keys`` identifies the config entry, e.g. 392 ``['targets', 'test-target']`` looks in the config dictionary for 393 ``['targets']['test-target']``. 394 395 If the option is not found and optional is ``True`` it returns ``None`` 396 if ``entry_type`` is ``None`` or a new (empty) object of type 397 ``entry_type``. 398 399 If the option is not found and ``optional`` is ``False`` it raises 400 ``ConfigError``. 401 402 If ``entry_type`` is not ``None`` it checks the option to be of 403 that type. If it is not it will raise ``ConfigError``. 404 405 """ 406 407 keys_str = ': '.join(keys) 408 entry: dict[str, Any] | None = self._config 409 410 for key in keys: 411 if not isinstance(entry, dict): 412 if optional: 413 if entry_type: 414 return entry_type() 415 return None 416 raise ConfigError(self.path, f'{keys_str}: not found') 417 entry = entry.get(key) 418 419 if entry is None: 420 if optional: 421 if entry_type: 422 return entry_type() 423 return None 424 raise ConfigError(self.path, f'{keys_str}: not found') 425 426 if entry_type and not isinstance(entry, entry_type): 427 msg = f'{keys_str}: expected entry of type `{entry_type}`' 428 raise ConfigError(self.path, msg) 429 430 if isinstance(entry, str): 431 entry = self._subst(entry) 432 elif isinstance(entry, list): 433 entry = self._subst_list(entry) 434 435 return entry 436 437 def get_target( 438 self, 439 keys: list[str], 440 optional: bool = True, 441 entry_type: Type | None = None, 442 ) -> Any: 443 """Gets a config option starting at ``['targets'][target]``.""" 444 445 if not self._target: 446 raise Error('target not set') 447 return self.get(['targets', self._target] + keys, optional, entry_type) 448 449 def get_emu( 450 self, 451 keys: list[str], 452 optional: bool = True, 453 entry_type: Type | None = None, 454 ) -> Any: 455 """Gets a config option starting at ``[emu]``.""" 456 457 if not self._emu: 458 raise Error('emu not set') 459 return self.get([self._emu] + keys, optional, entry_type) 460 461 def get_target_emu( 462 self, 463 keys: list[str], 464 optional: bool = True, 465 entry_type: Type | None = None, 466 ) -> Any: 467 """Gets a config option starting at ``['targets'][target][emu]``.""" 468 469 if not self._emu or not self._target: 470 raise Error('emu or target not set') 471 return self.get( 472 ['targets', self._target, self._emu] + keys, optional, entry_type 473 ) 474 475 476class Connector(ABC): 477 """Interface between a running emulator and the user-visible APIs.""" 478 479 def __init__(self, wdir: Path) -> None: 480 self._wdir = wdir 481 self._handles = Handles.load(wdir) 482 self._channels = self._handles.channels 483 self._target = self._handles.target 484 485 @staticmethod 486 def get(wdir: Path) -> Any: 487 """Returns a connector instance for a given emulator type.""" 488 handles = Handles.load(wdir) 489 config = Config(handles.config) 490 emu = handles.emu 491 try: 492 name = config.get(['emulators', emu, 'connector']) 493 cls = _get_class(name) 494 except (ConfigError, ImportError): 495 raise InvalidEmulator(emu) 496 return cls(wdir) 497 498 def get_emu(self) -> str: 499 """Returns the emulator type.""" 500 501 return self._handles.emu 502 503 def get_gdb_cmd(self) -> list[str]: 504 """Returns the configured ``gdb`` command.""" 505 return self._handles.gdb_cmd 506 507 def get_config_path(self) -> Path: 508 """Returns the configuration path.""" 509 510 return self._handles.config 511 512 def get_procs(self) -> dict[str, Handles.Proc]: 513 """Returns the running processes indexed by the process name.""" 514 515 return self._handles.procs 516 517 def get_channel_type(self, name: str) -> str: 518 """Returns the channel type.""" 519 520 try: 521 return self._channels[name].type 522 except KeyError: 523 channels = ' '.join(self._channels.keys()) 524 raise InvalidChannelName(name, self._target, channels) 525 526 def get_channel_path(self, name: str) -> str: 527 """Returns the channel path. Raises ``InvalidChannelType`` if this 528 is not a ``pty`` channel. 529 530 """ 531 532 try: 533 if self._channels[name].type != 'pty': 534 raise InvalidChannelType(self._channels[name].type) 535 return self._channels[name].path 536 except KeyError: 537 raise InvalidChannelName(name, self._target, self._channels.keys()) 538 539 def get_channel_addr(self, name: str) -> tuple: 540 """Returns a pair of ``(host, port)`` for the channel. Raises 541 ``InvalidChannelType`` if this is not a ``tcp`` channel. 542 543 """ 544 545 try: 546 if self._channels[name].type != 'tcp': 547 raise InvalidChannelType(self._channels[name].type) 548 return (self._channels[name].host, self._channels[name].port) 549 except KeyError: 550 raise InvalidChannelName(name, self._target, self._channels.keys()) 551 552 def get_channel_stream( 553 self, 554 name: str, 555 timeout: float | None = None, 556 ) -> io.RawIOBase: 557 """Returns a file object for a given host-exposed device. 558 559 If ``timeout`` is ``None`` then reads and writes are blocking. If 560 ``timeout`` is ``0`` the stream is operating in non-blocking 561 mode. Otherwise reads and writes will timeout after the given 562 value. 563 564 """ 565 566 chan_type = self.get_channel_type(name) 567 if chan_type == 'tcp': 568 host, port = self.get_channel_addr(name) 569 if ':' in host: 570 sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) 571 else: 572 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 573 sock.connect((host, port)) 574 sock.settimeout(timeout) 575 ret = sock.makefile('rwb', buffering=0) 576 sock.close() 577 return ret 578 if chan_type == 'pty': 579 ser = Serial(self.get_channel_path(name)) 580 ser.timeout = timeout 581 return ser 582 raise InvalidChannelType(chan_type) 583 584 def get_channels(self) -> list[str]: 585 return self._handles.channels.keys() 586 587 def get_logs(self) -> str: 588 """Returns the emulator logs.""" 589 590 log_path = self._wdir / f'{self._handles.emu}.log' 591 return log_path.read_text() 592 593 def stop(self) -> None: 594 """Stops the emulator.""" 595 596 _stop_processes(self._handles, self._wdir) 597 598 try: 599 os.unlink(os.path.join(self._wdir, 'handles.json')) 600 except OSError: 601 pass 602 603 def proc_running(self, proc: str) -> bool: 604 try: 605 return psutil.pid_exists(self._handles.procs[proc].pid) 606 except (NotRunning, KeyError): 607 return False 608 609 def running(self) -> bool: 610 """Checks if the main emulator process is already running.""" 611 612 try: 613 return psutil.pid_exists(self._handles.procs[self._handles.emu].pid) 614 except (NotRunning, KeyError): 615 return False 616 617 @abstractmethod 618 def reset(self) -> None: 619 """Performs a software reset.""" 620 621 @abstractmethod 622 def cont(self) -> None: 623 """Resumes the emulator's execution.""" 624 625 @abstractmethod 626 def list_properties(self, path: str) -> list[Any]: 627 """Returns the property list for an emulator object.""" 628 629 @abstractmethod 630 def set_property(self, path: str, prop: str, value: str) -> None: 631 """Sets the value of an emulator's object property.""" 632 633 @abstractmethod 634 def get_property(self, path: str, prop: str) -> Any: 635 """Returns the value of an emulator's object property.""" 636 637 638class Launcher(ABC): 639 """Starts an emulator based on the target and configuration file.""" 640 641 def __init__( 642 self, 643 emu: str, 644 config_path: Path | None = None, 645 ) -> None: 646 """Initializes a ``Launcher`` instance.""" 647 648 self._wdir: Path | None = None 649 """Working directory""" 650 651 self._emu = emu 652 """Emulator type (e.g. "qemu", "renode").""" 653 654 self._target: str | None = None 655 """Target, initialized to None and set with _prep_start.""" 656 657 self._config = Config(config_path, emu=emu) 658 """Global, emulator and target configuration.""" 659 660 self._handles = Handles(self._emu, str(self._config.path)) 661 """Handles for processes, channels, etc.""" 662 663 gdb_cmd = self._config.get(['gdb'], entry_type=list) 664 if gdb_cmd: 665 self._handles.set_gdb_cmd(gdb_cmd) 666 667 @staticmethod 668 def get(emu: str, config_path: Path | None = None) -> Any: 669 """Returns a launcher for a given emulator type.""" 670 config = Config(config_path) 671 try: 672 name = config.get(['emulators', emu, 'launcher']) 673 cls = _get_class(name) 674 except (ConfigError, ImportError): 675 raise InvalidEmulator(str(emu)) 676 return cls(config_path) 677 678 @abstractmethod 679 def _pre_start( 680 self, 681 target: str, 682 file: Path | None = None, 683 pause: bool = False, 684 debug: bool = False, 685 args: str | None = None, 686 ) -> list[str]: 687 """Pre-start work, returns command to start the emulator. 688 689 The target and emulator configuration can be accessed through 690 :py:attr:`pw_emu.core.Launcher._config` with 691 :py:meth:`pw_emu.core.Config.get`, 692 :py:meth:`pw_emu.core.Config.get_target`, 693 :py:meth:`pw_emu.core.Config.get_emu`, 694 :py:meth:`pw_emu.core.Config.get_target_emu`. 695 """ 696 697 @abstractmethod 698 def _post_start(self) -> None: 699 """Post-start work, finalize emulator handles. 700 701 Perform any post-start emulator initialization and finalize the emulator 702 handles information. 703 704 Typically an internal monitor channel is used to inquire information 705 about the the configured channels (e.g. TCP ports, pty paths) and 706 :py:attr:`pw_emu.core.Launcher._handles` is updated via 707 :py:meth:`pw_emu.core.Handles.add_channel_tcp`, 708 :py:meth:`pw_emu.core.Handles.add_channel_pty`, etc. 709 710 """ 711 712 @abstractmethod 713 def _get_connector(self, wdir: Path) -> Connector: 714 """Gets a connector for this emulator type.""" 715 716 def _path(self, name: Path | str) -> Path: 717 """Returns the full path for a given emulator file.""" 718 if self._wdir is None: 719 raise Error('internal error') 720 return Path(os.path.join(self._wdir, name)) 721 722 def _subst_channel(self, subst_type: str, arg: str, string: str) -> str: 723 """Substitutes $pw_emu_channel_{func}{arg} statements.""" 724 725 try: 726 chan = self._handles.channels[arg] 727 except KeyError: 728 return string 729 730 if subst_type == 'channel_port': 731 if not isinstance(chan, Handles.TcpChannel): 732 return string 733 return str(chan.port) 734 735 if subst_type == 'channel_host': 736 if not isinstance(chan, Handles.TcpChannel): 737 return string 738 return chan.host 739 740 if subst_type == 'channel_path': 741 if not isinstance(chan, Handles.PtyChannel): 742 return string 743 return chan.path 744 745 return string 746 747 def _subst(self, string: str) -> str: 748 """Substitutes $pw_emu_<subst_type>{arg} statements.""" 749 750 match = re.search(r'\$pw_emu_([^{]+){([^}]+)}', string) 751 if not match: 752 return string 753 754 subst_type = match.group(1) 755 arg = match.group(2) 756 757 if subst_type == 'wdir': 758 if self._wdir: 759 return os.path.join(self._wdir, arg) 760 return string 761 762 if 'channel_' in subst_type: 763 return self._subst_channel(subst_type, arg, string) 764 765 return string 766 767 # pylint: disable=protected-access 768 # use os._exit after fork instead of os.exit 769 def _daemonize( 770 self, 771 name: str, 772 cmd: list[str], 773 ) -> None: 774 """Daemonize process for UNIX hosts.""" 775 776 if sys.platform == 'win32': 777 raise Error('_daemonize not supported on win32') 778 779 # pylint: disable=no-member 780 # avoid pylint false positive on win32 781 pid = os.fork() 782 if pid < 0: 783 raise RunError(name, f'fork failed: {pid}') 784 if pid > 0: 785 return 786 787 path: Path = Path('/dev/null') 788 fd = os.open(path, os.O_RDONLY) 789 os.dup2(fd, sys.stdin.fileno()) 790 os.close(fd) 791 792 path = self._path(f'{name}.log') 793 fd = os.open(path, os.O_WRONLY | os.O_TRUNC | os.O_CREAT) 794 os.dup2(fd, sys.stdout.fileno()) 795 os.dup2(fd, sys.stderr.fileno()) 796 os.close(fd) 797 798 os.setsid() 799 800 if os.fork() > 0: 801 os._exit(0) 802 803 try: 804 # Make the pid file create and pid write operations atomic to avoid 805 # races with readers. 806 with open(self._path(f'{name}.pid.tmp'), 'w') as file: 807 file.write(f'{os.getpid()}') 808 os.rename(self._path(f'{name}.pid.tmp'), self._path(f'{name}.pid')) 809 os.execvp(cmd[0], cmd) 810 finally: 811 os._exit(1) 812 813 def _start_proc( 814 self, 815 name: str, 816 cmd: list[str], 817 foreground: bool = False, 818 ) -> subprocess.Popen | None: 819 """Run the main emulator process. 820 821 The process pid is stored and can later be accessed by its name to 822 terminate it when the emulator is stopped. 823 824 If foreground is True the process run in the foreground and a 825 subprocess.Popen object is returned. Otherwise the process is started in 826 the background and None is returned. 827 828 When running in the background stdin is redirected to the NULL device 829 and stdout and stderr are redirected to a file named <name>.log which is 830 stored in the emulator's instance working directory. 831 832 """ 833 for idx, item in enumerate(cmd): 834 cmd[idx] = self._subst(item) 835 836 pid_file_path = self._path(f'{name}.pid') 837 if os.path.exists(pid_file_path): 838 os.unlink(pid_file_path) 839 840 if foreground: 841 proc = subprocess.Popen(cmd) 842 self._handles.add_proc(name, proc.pid) 843 with open(pid_file_path, 'w') as file: 844 file.write(f'{proc.pid}') 845 return proc 846 847 if sys.platform == 'win32': 848 file = open(self._path(f'{name}.log'), 'w') 849 proc = subprocess.Popen( 850 cmd, 851 stdin=subprocess.DEVNULL, 852 stdout=file, 853 stderr=file, 854 creationflags=subprocess.DETACHED_PROCESS, 855 ) 856 file.close() 857 with open(pid_file_path, 'w') as file: 858 file.write(f'{proc.pid}') 859 self._handles.add_proc(name, proc.pid) 860 # avoids resource warnings due to not calling wait which 861 # we don't want to do since we've started the process in 862 # the background 863 proc.returncode = 0 864 else: 865 self._daemonize(name, cmd) 866 867 # wait for the pid file to avoid double start race conditions 868 timeout = time.monotonic() + 30 869 while not os.path.exists(self._path(f'{name}.pid')): 870 time.sleep(0.1) 871 if time.monotonic() > timeout: 872 break 873 if not os.path.exists(self._path(f'{name}.pid')): 874 raise RunError(name, 'pid file timeout') 875 try: 876 with open(pid_file_path, 'r') as file: 877 pid = int(file.readline()) 878 self._handles.add_proc(name, pid) 879 except (OSError, ValueError) as err: 880 raise RunError(name, str(err)) 881 882 return None 883 884 def _stop_procs(self): 885 """Stop all registered processes.""" 886 887 for name, proc in self._handles.procs.items(): 888 _stop_process(proc.pid) 889 if os.path.exists(self._path(f'{name}.pid')): 890 os.unlink(self._path(f'{name}.pid')) 891 892 def _start_procs(self, procs_list: str) -> None: 893 """Start additional processes besides the main emulator one.""" 894 895 procs = self._config.get_target([procs_list], entry_type=dict) 896 for name, cmd in procs.items(): 897 self._start_proc(name, cmd) 898 899 def start( 900 self, 901 wdir: Path, 902 target: str, 903 file: Path | None = None, 904 pause: bool = False, 905 debug: bool = False, 906 foreground: bool = False, 907 args: str | None = None, 908 ) -> Connector: 909 """Starts the emulator for the given target. 910 911 If ``file`` is set the emulator loads that file before starting. 912 913 If ``pause`` is ``True`` the emulator gets paused. 914 915 If ``debug`` is ``True`` the emulator runs in the foreground with 916 debug output enabled. This is useful for seeing errors, traces, etc. 917 918 If ``foreground`` is ``True`` the emulator is run in the foreground 919 otherwise it is started in daemon mode. This is useful when there is 920 another process controlling the emulator's life cycle, e.g. cuttlefish. 921 922 ``args`` are passed directly to the emulator. 923 924 """ 925 926 try: 927 handles = Handles.load(wdir) 928 if psutil.pid_exists(handles.procs[handles.emu].pid): 929 raise AlreadyRunning(wdir) 930 except NotRunning: 931 pass 932 933 self._wdir = wdir 934 self._target = target 935 self._config.set_target(target) 936 self._handles.set_target(target) 937 gdb_cmd = self._config.get_target(['gdb'], entry_type=list) 938 if gdb_cmd: 939 self._handles.set_gdb_cmd(gdb_cmd) 940 os.makedirs(wdir, mode=0o700, exist_ok=True) 941 942 cmd = self._pre_start( 943 target=target, file=file, pause=pause, debug=debug, args=args 944 ) 945 946 if debug: 947 foreground = True 948 _LAUNCHER_LOG.setLevel(logging.DEBUG) 949 950 _LAUNCHER_LOG.debug('starting emulator with command: %s', ' '.join(cmd)) 951 952 try: 953 self._start_procs('pre-start-cmds') 954 proc = self._start_proc(self._emu, cmd, foreground) 955 self._start_procs('post-start-cmds') 956 except RunError as err: 957 self._stop_procs() 958 raise err 959 960 try: 961 self._post_start() 962 except RunError as err: 963 self._handles.save(wdir) 964 connector = self._get_connector(self._wdir) 965 if not connector.running(): 966 msg = err.msg + '; dumping logs:\n' + connector.get_logs() 967 raise RunError(err.proc, msg) 968 raise err 969 self._handles.save(wdir) 970 971 if proc: 972 proc.wait() 973 self._stop_procs() 974 975 return self._get_connector(self._wdir) 976