xref: /aosp_15_r20/external/pigweed/pw_emu/py/pw_emu/core.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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