diff --git a/Documentation/config-yaml.rst b/Documentation/config-yaml.rst index 5b2ffb8..294f1ba 100644 --- a/Documentation/config-yaml.rst +++ b/Documentation/config-yaml.rst @@ -419,6 +419,17 @@ These fields are parsed by :class:`ntfc.coreconfig.CoreConfig`. etc.) * - ``exec_args`` - QEMU arguments or serial settings + * - ``exec_cwd`` + - (Optional) Working directory for the spawned sim/qemu process. + Kernel-mode hostfs mounts resolve relative to this directory. + Defaults to the core build directory for kernel-mode builds + * - ``boot_timeout`` + - (Optional) Seconds to wait for the first shell prompt after device + start. Defaults to ``5`` + * - ``app_bindir`` + - (Optional) Directory with kernel-mode application binaries. Defaults + to the ``bin/`` directory next to the NuttX ELF for kernel-mode + builds (``CONFIG_BUILD_KERNEL=y``) * - ``defconfig`` - Path to NuttX defconfig (auto-build) * - ``elf_path`` diff --git a/Documentation/config.yaml b/Documentation/config.yaml index 208aa94..1b38027 100644 --- a/Documentation/config.yaml +++ b/Documentation/config.yaml @@ -17,7 +17,6 @@ config: # common, global configuration options recovery: # device recovery configuration max_retries: 3 # maximum number of reboot attempts before giving up. Defaults to 3 base_delay: 2.0 # initial delay in seconds between retries (doubles each attempt, capped at 60). Defaults to 2.0 - reboot_timeout: 30 # timeout in seconds to wait for device to come back after reboot. Defaults to 30 product: # many products can be supported in tests (product == product0) @@ -71,6 +70,13 @@ product: # many products can be supported in tests (pro exec_args: '' # Args for emulator execution for QEMU tragets. # Serial port configuration for serial targets, eg '9600,n,8,1' # Empty for simulator. + exec_cwd: '' # (optional) working directory for the spawned sim/qemu process. + # Kernel-mode hostfs mounts resolve relative to this directory. + # Defaults to the core build directory for kernel-mode builds. + boot_timeout: 5 # (optional) seconds to wait for the first shell prompt. Defaults to 5 + app_bindir: '' # (optional) directory with kernel-mode application binaries. + # Defaults to the bin/ directory next to the NuttX ELF + # for kernel-mode builds. # NTFC can use pre-build image or build it from defconfig # the behavior will depend on the parameters specified in config. diff --git a/Documentation/usage.rst b/Documentation/usage.rst index d92ada1..531383d 100644 --- a/Documentation/usage.rst +++ b/Documentation/usage.rst @@ -228,7 +228,6 @@ Remaining tests are skipped if all retries fail. recovery: max_retries: 3 # reboot attempts before skipping remaining tests base_delay: 2.0 # seconds between retries (doubles each attempt) - reboot_timeout: 30 # seconds to wait for device after reboot Signal Handlers =============== diff --git a/src/ntfc/coreconfig.py b/src/ntfc/coreconfig.py index 2283c55..1d54090 100644 --- a/src/ntfc/coreconfig.py +++ b/src/ntfc/coreconfig.py @@ -20,6 +20,7 @@ """Product core configuration handler.""" +import os from typing import Any, Dict, Optional, Union from ntfc.lib.elf.elf_parser import ElfParser @@ -144,6 +145,37 @@ def flash_only(self) -> bool: """ return bool(self._config.get("flash_only", False)) + @property + def is_kernel_build(self) -> bool: + """Return True when the core .config has CONFIG_BUILD_KERNEL=y.""" + return self.kv_check("CONFIG_BUILD_KERNEL") is True + + @property + def exec_cwd(self) -> Optional[str]: + """Return working directory for spawned sim/qemu processes. + + Kernel-mode hostfs mounts resolve relative to this directory. + """ + return self._config.get("exec_cwd", None) + + @property + def boot_timeout(self) -> int: + """Return seconds to wait for the first shell prompt after start.""" + return int(self._config.get("boot_timeout", 5)) + + @property + def app_bindir(self) -> Optional[str]: + """Return directory with kernel-mode application binaries. + + Defaults to the bin/ directory next to the NuttX ELF. + """ + bindir = self._config.get("app_bindir", None) + if bindir: + return str(bindir) + if self.is_kernel_build and self.elf_path: + return os.path.join(os.path.dirname(self.elf_path), "bin") + return None + def kv_check(self, cfg: str) -> Any: """Check Kconfig option and return its value. diff --git a/src/ntfc/device/host.py b/src/ntfc/device/host.py index d92c442..fb11750 100644 --- a/src/ntfc/device/host.py +++ b/src/ntfc/device/host.py @@ -50,7 +50,7 @@ def __init__(self, conf: "CoreConfig"): """ DeviceCommon.__init__(self, conf) self._child = None - self._cwd = None + self._cwd = conf.exec_cwd self._cmd: Optional[List[str]] = None @property @@ -139,7 +139,7 @@ def host_open(self, cmd: List[str], uptime: int = 0) -> pexpect.spawn: time.sleep(uptime) - ret = self._wait_for_boot() + ret = self._wait_for_boot(self._conf.boot_timeout) if ret is False: # pragma: no cover raise TimeoutError("device boot timeout") diff --git a/src/ntfc/device/serial.py b/src/ntfc/device/serial.py index a937064..fca70fb 100644 --- a/src/ntfc/device/serial.py +++ b/src/ntfc/device/serial.py @@ -150,7 +150,7 @@ def _start_impl(self) -> None: # reboot device if possible self.reboot() - ret = self._wait_for_boot() + ret = self._wait_for_boot(self._conf.boot_timeout) if ret is False: raise TimeoutError("device boot timeout") diff --git a/src/ntfc/envconfig.py b/src/ntfc/envconfig.py index 9de0f78..e742da8 100644 --- a/src/ntfc/envconfig.py +++ b/src/ntfc/envconfig.py @@ -103,13 +103,11 @@ def recovery(self) -> Dict[str, Any]: """Return device recovery configuration. :return: Dictionary with keys: 'max_retries' (int), - 'base_delay' (float), 'reboot_timeout' (int). - Defaults to 3 retries, 2s base delay, 30s reboot timeout. + 'base_delay' (float). Defaults to 3 retries, 2s base delay. """ default_config = { "max_retries": 3, "base_delay": 2.0, - "reboot_timeout": 30, } config = self._cfg_values.get("config", {}) recovery_cfg = config.get("recovery", {}) diff --git a/src/ntfc/lib/elf/app_bindir.py b/src/ntfc/lib/elf/app_bindir.py new file mode 100644 index 0000000..8f6e845 --- /dev/null +++ b/src/ntfc/lib/elf/app_bindir.py @@ -0,0 +1,137 @@ +############################################################################ +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. The +# ASF licenses this file to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +############################################################################ + +"""Kernel-mode application binary directory handler. + +In kernel builds every application is a standalone ELF binary installed +to the target PATH, so the command name is the file name; entry points +are renamed to ``main`` and symbols like ``hello_main`` do not exist. +""" + +import os +import re +from functools import cached_property +from typing import Collection, Iterator, List, Optional, Pattern, Set, Union + +from ntfc.lib.elf.elf_parser import ElfParser + +_MAIN_SUFFIX = "_main" + + +def _match_one(name: str, commands: List[str]) -> bool: + """Match a single name against a list of command names.""" + if ".*" in name: + regex = re.compile(name) + return any(regex.fullmatch(cmd) for cmd in commands) + return name in commands + + +def match_command(pattern: str, commands: List[str]) -> bool: + """Match a cmd_check pattern against a list of command names. + + Accepts ``a|b`` alternatives and ``.*`` regex patterns. A trailing + ``_main`` is stripped so flat-mode symbol markers (e.g. + ``hello_main``) match the ``hello`` command. + """ + return any( + _match_one(name, commands) + for alt in pattern.split("|") + for name in (alt, alt.removesuffix(_MAIN_SUFFIX)) + ) + + +def symbol_patterns(pattern: str) -> Iterator[Union[str, Pattern[str]]]: + """Yield normalized ELF symbol patterns for a cmd_check expression.""" + for alternative in pattern.split("|"): + symbol = ( + f"{alternative}{_MAIN_SUFFIX}" + if "cmocka" in alternative + else alternative + ) + yield re.compile(symbol) if ".*" in symbol else symbol + + +def match_symbol(pattern: str, symbols: Collection[str]) -> bool: + """Match a cmd_check expression against ELF symbol names.""" + for candidate in symbol_patterns(pattern): + if isinstance(candidate, str): + if candidate in symbols: + return True + elif any(candidate.search(symbol) for symbol in symbols): + return True + + return False + + +class AppBinDir: + """Application binary directory of a kernel-mode build.""" + + #: sibling directory with the unstripped application binaries + DEBUG_DIR_NAME = "bin_debug" + + def __init__(self, bindir: str, debug_bindir: Optional[str] = None): + """Initialize application binary directory handler. + + :param bindir: directory with application binaries + :param debug_bindir: unstripped binaries for symbol lookups, + the sibling ``bin_debug`` by default + """ + self._bindir = bindir + self._debug_bindir = debug_bindir or os.path.join( + os.path.dirname(bindir), self.DEBUG_DIR_NAME + ) + + @staticmethod + def _scan(dirpath: Optional[str]) -> List[ElfParser]: + """Return a parser for every ELF file in a directory.""" + if not dirpath or not os.path.isdir(dirpath): + return [] + + parsers = [] + for name in sorted(os.listdir(dirpath)): + try: + parsers.append(ElfParser(os.path.join(dirpath, name))) + except AttributeError: + # not an ELF file, e.g. a script or a subdirectory + continue + + return parsers + + @cached_property + def commands(self) -> List[str]: + """Return available command names (application file names).""" + return [os.path.basename(p.elf_path) for p in self._scan(self._bindir)] + + @cached_property + def _symbols(self) -> Set[str]: + """Return the symbols defined by the debug application binaries.""" + return { + symbol.name + for parser in self._scan(self._debug_bindir) + for symbol in parser.symbols + } + + def has_command(self, pattern: str) -> bool: + """Check if a command is available, see :func:`match_command`.""" + return match_command(pattern, self.commands) + + def has_symbol(self, pattern: str) -> bool: + """Check symbols using cmd_check alternatives and regex syntax.""" + return match_symbol(pattern, self._symbols) diff --git a/src/ntfc/pytest/configure.py b/src/ntfc/pytest/configure.py index 0115919..2520fee 100644 --- a/src/ntfc/pytest/configure.py +++ b/src/ntfc/pytest/configure.py @@ -68,8 +68,8 @@ def _device_reboot(self) -> None: # pragma: no cover """Reboot the device with retry and exponential back-off. Uses recovery configuration from EnvConfig (max_retries, - base_delay, reboot_timeout). Doubles the delay after each - failed attempt, capped at 60 seconds. + base_delay). Doubles the delay after each failed attempt, + capped at 60 seconds. """ recovery_cfg = self._config.recovery max_retries = recovery_cfg["max_retries"] diff --git a/tests/device/test_host.py b/tests/device/test_host.py index 6ddebd9..710334a 100644 --- a/tests/device/test_host.py +++ b/tests/device/test_host.py @@ -23,6 +23,7 @@ import pytest from pexpect.exceptions import ExceptionPexpect +from ntfc.coreconfig import CoreConfig from ntfc.device.host import DeviceHost @@ -202,6 +203,38 @@ def test_device_host_pid(envconfig_dummy): assert dev.pid == 4242 +def test_device_host_exec_cwd_boot_timeout(tmp_path, monkeypatch): + + conf = CoreConfig( + {"name": "t", "exec_cwd": str(tmp_path), "boot_timeout": 9} + ) + dev = DeviceHost2(conf) + + spawn_kwargs = {} + boot_timeouts = [] + + class FakeChild: + pid = 1 + + def fake_spawn(cmd, **kwargs): + spawn_kwargs.update(kwargs) + return FakeChild() + + def fake_wait(timeout=5): + boot_timeouts.append(timeout) + return True + + monkeypatch.setattr("ntfc.device.host.pexpect.spawn", fake_spawn) + monkeypatch.setattr(dev, "_wait_for_boot", fake_wait) + + dev.host_open(["dummy"]) + + # exec_cwd is passed to the spawned process + assert spawn_kwargs["cwd"] == str(tmp_path) + # boot wait uses the configured boot_timeout + assert boot_timeouts == [9] + + # TODO: more tests for host device !!!! # - test for timeout # - test for very long output diff --git a/tests/device/test_serial.py b/tests/device/test_serial.py index 003ed6d..74822a2 100644 --- a/tests/device/test_serial.py +++ b/tests/device/test_serial.py @@ -137,3 +137,31 @@ def test_device_sim_init(serial_config, serial_pair): stop.set() device_thread.join(timeout=1) + + +def test_device_serial_boot_timeout(serial_pair, monkeypatch): + + config = CoreConfig( + { + "name": "main", + "device": "serial", + "exec_path": serial_pair[1], + "boot_timeout": 9, + } + ) + ser = DeviceSerial(config) + + boot_timeouts = [] + + def fake_wait(timeout=5): + boot_timeouts.append(timeout) + return True + + monkeypatch.setattr(ser, "_wait_for_boot", fake_wait) + monkeypatch.setattr(ser, "reboot", lambda *args, **kwargs: True) + + ser._start_impl() + ser.stop() + + # boot wait uses the configured boot_timeout + assert boot_timeouts == [9] diff --git a/tests/lib/test_app_bindir.py b/tests/lib/test_app_bindir.py new file mode 100644 index 0000000..af5ae38 --- /dev/null +++ b/tests/lib/test_app_bindir.py @@ -0,0 +1,103 @@ +############################################################################ +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. The +# ASF licenses this file to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +############################################################################ + +import shutil + +import pytest + +from ntfc.lib.elf.app_bindir import AppBinDir + +ELF_MAGIC = b"\x7fELF" + b"\x00" * 12 + + +@pytest.fixture +def bindir(tmp_path): + path = tmp_path / "bin" + path.mkdir() + for name in ("init", "hello", "getprime", "sh"): + (path / name).write_bytes(ELF_MAGIC) + # non-ELF entries are not commands + (path / "README").write_text("not an application") + (path / "subdir").mkdir() + return path + + +def test_app_bindir_commands(bindir): + b = AppBinDir(str(bindir)) + assert b.commands == ["getprime", "hello", "init", "sh"] + + +def test_app_bindir_missing_dir(tmp_path): + # neither the binaries nor the sibling debug directory exist + b = AppBinDir(str(tmp_path / "nonexistent")) + assert b.commands == [] + assert b.has_command("hello") is False + assert b.has_symbol("hello_main") is False + + +def test_app_bindir_has_command(bindir): + b = AppBinDir(str(bindir)) + + # exact file name + assert b.has_command("hello") is True + assert b.has_command("free") is False + + # flat-mode symbol markers map to the file name + assert b.has_command("hello_main") is True + assert b.has_command("free_main") is False + + # alternatives + assert b.has_command("free|hello") is True + assert b.has_command("free|df") is False + + # regex + assert b.has_command("get.*") is True + assert b.has_command("xyz.*") is False + + +def test_app_bindir_has_symbol(bindir): + # unstripped sim ELF stands in for a debug application binary + debug = bindir.parent / "bin_debug" + debug.mkdir() + shutil.copy("./tests/resources/nuttx/sim/nuttx", debug / "sh") + (debug / "README").write_text("skipped: not an ELF") + + b = AppBinDir(str(bindir), str(debug)) + assert b.has_symbol("hello_main") is True + assert b.has_symbol("missing|hello_main") is True + assert b.has_symbol("missing.*|hello_main") is True + assert b.has_symbol("hello_.*") is True + assert b.has_symbol("no_such_symbol_xyz") is False + + # the sibling bin_debug directory is the default + assert AppBinDir(str(bindir)).has_symbol("hello_main") is True + + +def test_app_bindir_symbols_span_all_binaries(bindir): + debug = bindir.parent / "bin_debug" + debug.mkdir() + for name in ("a", "b"): + shutil.copy("./tests/resources/nuttx/sim/nuttx", debug / name) + + b = AppBinDir(str(bindir), str(debug)) + + # every binary is read, not just the first one + assert b.has_symbol("hello_main") is True + assert b.has_symbol("no_such_symbol_xyz") is False diff --git a/tests/test_coreconfig.py b/tests/test_coreconfig.py index 2ee0c93..1fa1c38 100644 --- a/tests/test_coreconfig.py +++ b/tests/test_coreconfig.py @@ -97,6 +97,68 @@ def test_core_config_flash_only_property(): assert CoreConfig({"name": "test"}).flash_only is False +def test_core_config_is_kernel_build(tmp_path): + cfg_file = tmp_path / "kv_config" + cfg_file.write_text("CONFIG_BUILD_KERNEL=y\n") + assert ( + CoreConfig({"name": "t", "conf_path": str(cfg_file)}).is_kernel_build + is True + ) + + cfg_file.write_text("CONFIG_BUILD_FLAT=y\n") + assert ( + CoreConfig({"name": "t", "conf_path": str(cfg_file)}).is_kernel_build + is False + ) + + # no .config at all + assert CoreConfig({"name": "t"}).is_kernel_build is False + + +def test_core_config_exec_cwd(): + assert ( + CoreConfig({"name": "t", "exec_cwd": "/some/dir"}).exec_cwd + == "/some/dir" + ) + assert CoreConfig({"name": "t"}).exec_cwd is None + + +def test_core_config_boot_timeout(): + assert CoreConfig({"name": "t", "boot_timeout": 15}).boot_timeout == 15 + assert CoreConfig({"name": "t"}).boot_timeout == 5 + + +def test_core_config_app_bindir(tmp_path): + kernel_cfg = tmp_path / "kv_config" + kernel_cfg.write_text("CONFIG_BUILD_KERNEL=y\n") + + # explicit YAML value always wins + conf = {"name": "t", "app_bindir": "/custom/bin"} + assert CoreConfig(conf).app_bindir == "/custom/bin" + + # kernel build: derived from elf_path sibling bin/ + conf = { + "name": "t", + "conf_path": str(kernel_cfg), + "elf_path": "./tests/resources/nuttx/sim/nuttx", + } + assert CoreConfig(conf).app_bindir == "./tests/resources/nuttx/sim/bin" + + # kernel build without elf_path: nothing to derive from + assert ( + CoreConfig({"name": "t", "conf_path": str(kernel_cfg)}).app_bindir + is None + ) + + # flat build: never derived + conf = { + "name": "t", + "conf_path": "./tests/resources/nuttx/sim/kv_config", + "elf_path": "./tests/resources/nuttx/sim/nuttx", + } + assert CoreConfig(conf).app_bindir is None + + def test_core_config_prompt(): # Test with explicit prompt in YAML config conf = { diff --git a/tests/test_envconfig.py b/tests/test_envconfig.py index 9eecd44..2bd385d 100644 --- a/tests/test_envconfig.py +++ b/tests/test_envconfig.py @@ -183,7 +183,6 @@ def test_envconfig_recovery_defaults(): recovery = env.recovery assert recovery["max_retries"] == 3 assert recovery["base_delay"] == 2.0 - assert recovery["reboot_timeout"] == 30 def test_envconfig_recovery_custom(): @@ -200,4 +199,3 @@ def test_envconfig_recovery_custom(): recovery = env.recovery assert recovery["max_retries"] == 5 assert recovery["base_delay"] == 1.0 - assert recovery["reboot_timeout"] == 30 # default preserved