From 25a2a48abedd3023cdc7c4459d0886df6b98288e Mon Sep 17 00:00:00 2001 From: raiden00pl Date: Tue, 4 Aug 2026 17:29:44 +0200 Subject: [PATCH 01/14] builder.py: register app_bindir and exec_cwd for kernel-mode builds Kernel-mode CMake builds install applications to /bin. On qemu arm/riscv the target mounts them over a semihosting hostfs relative to the spawned process working directory. When the generated .config selects CONFIG_BUILD_KERNEL=y, register app_bindir=/bin and exec_cwd=, keeping values set in YAML. Signed-off-by: raiden00pl Assisted-by: Claude Code --- src/ntfc/builder.py | 17 ++++++++++++ tests/test_builder.py | 64 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/src/ntfc/builder.py b/src/ntfc/builder.py index ace5eeb..47f8c33 100644 --- a/src/ntfc/builder.py +++ b/src/ntfc/builder.py @@ -417,6 +417,23 @@ def _build_core( cores[core]["elf_path"] = nuttx_elf_path cores[core]["conf_path"] = nuttx_conf_path + if self._is_kernel_config(nuttx_conf_path): + # kernel-mode: applications are installed to /bin + # and hostfs mounts resolve relative to the process cwd + cores[core].setdefault( + "app_bindir", os.path.join(build_path, "bin") + ) + cores[core].setdefault("exec_cwd", build_path) + + @staticmethod + def _is_kernel_config(conf_path: str) -> bool: + """Check if a generated .config selects a kernel build.""" + if not os.path.isfile(conf_path): + return False + + with open(conf_path, "r", encoding="utf-8") as f: + return any(line.strip() == "CONFIG_BUILD_KERNEL=y" for line in f) + def _reboot_core( self, core: str, cores: Dict[str, Any] ) -> None: # pragma: no cover diff --git a/tests/test_builder.py b/tests/test_builder.py index 0ed6d65..e06a854 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -79,6 +79,70 @@ def test_builder_init(): ) +def test_builder_kernel_mode_core_config(tmp_path) -> None: + config = copy.deepcopy(conf_dir) + config["config"]["build_dir"] = str(tmp_path) + config["product"]["cores"]["core0"]["defconfig"] = "dummy/path" + + build_path = tmp_path / "product-xxx-dummy" + build_path.mkdir() + (build_path / ".config").write_text("CONFIG_BUILD_KERNEL=y\n") + + b = NuttXBuilder(config) + b._run_command = builder_run_command_dummy + b._make_dir = builder_make_dir_dummy + b.build_all() + + core = b.new_conf()["product"]["cores"]["core0"] + assert core["app_bindir"] == str(build_path / "bin") + assert core["exec_cwd"] == str(build_path) + + +def test_builder_kernel_mode_keeps_user_overrides(tmp_path) -> None: + config = copy.deepcopy(conf_dir) + config["config"]["build_dir"] = str(tmp_path) + config["product"]["cores"]["core0"]["defconfig"] = "dummy/path" + config["product"]["cores"]["core0"]["app_bindir"] = "/custom/bin" + config["product"]["cores"]["core0"]["exec_cwd"] = "/custom/cwd" + + build_path = tmp_path / "product-xxx-dummy" + build_path.mkdir() + (build_path / ".config").write_text("CONFIG_BUILD_KERNEL=y\n") + + b = NuttXBuilder(config) + b._run_command = builder_run_command_dummy + b._make_dir = builder_make_dir_dummy + b.build_all() + + core = b.new_conf()["product"]["cores"]["core0"] + assert core["app_bindir"] == "/custom/bin" + assert core["exec_cwd"] == "/custom/cwd" + + +def test_builder_flat_mode_no_kernel_keys(tmp_path) -> None: + config = copy.deepcopy(conf_dir) + config["config"]["build_dir"] = str(tmp_path) + config["product"]["cores"]["core0"]["defconfig"] = "dummy/path" + + build_path = tmp_path / "product-xxx-dummy" + build_path.mkdir() + (build_path / ".config").write_text("CONFIG_BUILD_FLAT=y\n") + + b = NuttXBuilder(config) + b._run_command = builder_run_command_dummy + b._make_dir = builder_make_dir_dummy + b.build_all() + + core = b.new_conf()["product"]["cores"]["core0"] + assert "app_bindir" not in core + assert "exec_cwd" not in core + + # missing .config (dummy build) is treated as a flat build + assert ( + NuttXBuilder._is_kernel_config(str(tmp_path / "nonexistent")) is False + ) + + def test_builder_passes_build_env() -> None: config = copy.deepcopy(conf_dir) config["config"]["build_env"] = {"CC": "gcc-13", "CXX": "g++-13"} From abfb9be43906b5a4b2ba5bc9892034fffc20260f Mon Sep 17 00:00:00 2001 From: raiden00pl Date: Tue, 4 Aug 2026 17:34:27 +0200 Subject: [PATCH 02/14] mypytest: support alternatives and wildcards in ntfc.yaml requirements Requirements only supported exact [key, value] matching, which cannot express init configuration that differs between build modes: flat builds use CONFIG_INIT_ENTRYPOINT, kernel builds CONFIG_INIT_FILEPATH. Add, backward compatibly: - [key, "*"]: any truthy value satisfies - [[key, value], ...]: alternatives, any one satisfies Also name the product, core and unmet requirement in the abort message. Signed-off-by: raiden00pl Assisted-by: Claude Code --- Documentation/ntfc.yaml | 4 +- Documentation/writing-test-cases.rst | 14 ++++++ src/ntfc/pytest/mypytest.py | 30 ++++++++++-- tests/pytest/test_mypytest.py | 70 ++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 6 deletions(-) diff --git a/Documentation/ntfc.yaml b/Documentation/ntfc.yaml index 55558a1..a338833 100644 --- a/Documentation/ntfc.yaml +++ b/Documentation/ntfc.yaml @@ -5,4 +5,6 @@ dependencies: ["toml"] # python dependencies for test cases module requirements: # nuttx config requirements - ["CONFIG_DEBUG_SYMBOLS", True] - ["CONFIG_SYSTEM_NSH", True] - - ["CONFIG_INIT_ENTRYPOINT", "nsh_main"] + # alternatives: any one entry satisfies the requirement; + # "*" accepts any set value + - [["CONFIG_INIT_ENTRYPOINT", "nsh_main"], ["CONFIG_INIT_FILEPATH", "*"]] diff --git a/Documentation/writing-test-cases.rst b/Documentation/writing-test-cases.rst index b13a59b..7637292 100644 --- a/Documentation/writing-test-cases.rst +++ b/Documentation/writing-test-cases.rst @@ -70,6 +70,20 @@ String/Value Requirements: - ["CONFIG_INIT_ENTRYPOINT", "nsh_main"] # CONFIG must equal value - ["CONFIG_TASK_NAME_SIZE", "32"] # CONFIG must equal value +Wildcard Requirements: + +.. code-block:: yaml + + requirements: + - ["CONFIG_INIT_FILEPATH", "*"] # CONFIG must be set (any value) + +Alternative Requirements (any one entry satisfies the requirement): + +.. code-block:: yaml + + requirements: + - [["CONFIG_INIT_ENTRYPOINT", "nsh_main"], ["CONFIG_INIT_FILEPATH", "*"]] + How Requirements Work: 1. NTFC reads NuttX ``.config`` file from configuration diff --git a/src/ntfc/pytest/mypytest.py b/src/ntfc/pytest/mypytest.py index b85f695..5a1c15d 100644 --- a/src/ntfc/pytest/mypytest.py +++ b/src/ntfc/pytest/mypytest.py @@ -124,14 +124,31 @@ def _write_session_config(self, result_dir: str) -> None: json.dump(self._config.config, f, indent=2, sort_keys=True) f.write("\n") + def _req_satisfied(self, product: Product, core: int, req: Any) -> bool: + """Check a single ntfc.yaml requirement entry. + + Supported entry forms: + + - ``[key, value]``: config value must equal ``value`` + - ``[key, "*"]``: any truthy config value satisfies + - ``[[key, value], ...]``: alternatives, any one satisfies + """ + if isinstance(req[0], list): + return any(self._req_satisfied(product, core, r) for r in req) + + value = product.conf.kv_check(req[0], core) + if req[1] == "*": + return bool(value) + return bool(value == req[1]) + def _kv_validate( self, product: Product, core: int - ) -> Tuple[bool, Optional[Any]]: # pragma: no cover + ) -> Tuple[bool, Optional[Any]]: """Check if configuration can be used with this tool.""" requirements = pytest.ntfcyaml.get("requirements", {}) for req in requirements: - if product.conf.kv_check(req[0], core) != req[1]: + if not self._req_satisfied(product, core, req): return False, req return True, None @@ -143,9 +160,12 @@ def _create_products(self, config: "EnvConfig") -> List[Product]: # check config requirements only on cores that participate in tests for core in p.conf.active_core_indices: - ret = self._kv_validate(p, core) - if ret[0] is False: # pragma: no cover - raise IOError(f"Missing kconfig dependency: {ret[1]}") + ok, req = self._kv_validate(p, core) + if not ok: + raise IOError( + f"product '{p.conf.name}' core {core}: " + f"missing kconfig requirement: {req}" + ) tmp.append(p) diff --git a/tests/pytest/test_mypytest.py b/tests/pytest/test_mypytest.py index c8affd5..6686b4f 100644 --- a/tests/pytest/test_mypytest.py +++ b/tests/pytest/test_mypytest.py @@ -212,6 +212,76 @@ def test_create_products_skips_requirements_for_flash_only_core( assert products[0].cores == ["cpuapp"] +def test_create_products_requirement_forms(device_dummy, monkeypatch): + import pytest as pytest_module + + config = { + "config": {}, + "product": { + "name": "product", + "cores": { + "core0": { + "name": "cpuapp", + "device": "sim", + "conf_path": "./tests/resources/nuttx/sim/kv_config", + "elf_path": "./tests/resources/nuttx/sim/nuttx", + }, + }, + }, + } + + monkeypatch.setattr( + pytest_module, + "ntfcyaml", + { + "requirements": [ + # plain form: exact match + ["CONFIG_HOST_LINUX", True], + # alternatives: first unset, second matches + [ + ["CONFIG_INIT_FILEPATH", "*"], + ["CONFIG_INIT_ENTRYPOINT", "nsh_main"], + ], + # wildcard: any truthy value + ["CONFIG_NSH_PROMPT_STRING", "*"], + ] + }, + raising=False, + ) + with patch("ntfc.cores.get_device", return_value=device_dummy): + products = MyPytest(config)._create_products(EnvConfig(config)) + assert len(products) == 1 + + +def test_create_products_requirement_unmet(device_dummy, monkeypatch): + import pytest as pytest_module + + config = { + "config": {}, + "product": { + "name": "product", + "cores": { + "core0": { + "name": "cpuapp", + "device": "sim", + "conf_path": "./tests/resources/nuttx/sim/kv_config", + "elf_path": "./tests/resources/nuttx/sim/nuttx", + }, + }, + }, + } + + monkeypatch.setattr( + pytest_module, + "ntfcyaml", + {"requirements": [["CONFIG_INIT_FILEPATH", "*"]]}, + raising=False, + ) + with patch("ntfc.cores.get_device", return_value=device_dummy): + with pytest.raises(IOError, match="product.*core 0.*INIT_FILEPATH"): + MyPytest(config)._create_products(EnvConfig(config)) + + def test_device_stop_calls_stop(config_dummy, device_dummy): """_device_stop calls device.stop() for each core.""" with patch("ntfc.cores.get_device", return_value=device_dummy): From 13d3dbb555950d95e61d20a6e76a9d96486714f5 Mon Sep 17 00:00:00 2001 From: raiden00pl Date: Tue, 4 Aug 2026 17:40:25 +0200 Subject: [PATCH 03/14] coreconfig.py: dispatch cmd_check to application binaries With app_bindir configured, cmd_check resolves commands against the application binary directory first and falls back to symbol lookups over bin_debug for names that are not applications (NSH builtins like cmd_df, cmocka entries). Flat-mode behavior is unchanged. Signed-off-by: raiden00pl Assisted-by: Claude Code --- Documentation/writing-test-cases.rst | 6 +++- src/ntfc/coreconfig.py | 34 +++++++++++++++++++-- src/ntfc/testfilter.py | 4 +-- tests/test_coreconfig.py | 44 ++++++++++++++++++++++++++++ tests/test_filtertest.py | 2 +- 5 files changed, 83 insertions(+), 7 deletions(-) diff --git a/Documentation/writing-test-cases.rst b/Documentation/writing-test-cases.rst index 7637292..1f3a30e 100644 --- a/Documentation/writing-test-cases.rst +++ b/Documentation/writing-test-cases.rst @@ -192,7 +192,11 @@ Execute NSH command and verify output: Decorators: -- ``@pytest.mark.cmd_check("symbol_name")``: Verify ELF symbol exists +- ``@pytest.mark.cmd_check("symbol_name")``: Verify ELF symbol exists. + On kernel-mode targets (``CONFIG_BUILD_KERNEL=y``) the marker is first + matched against application file names (a trailing ``_main`` maps to + the file name, so ``hello_main`` matches the ``hello`` binary) and + then against symbols in the unstripped application binaries - ``@pytest.mark.dep_config("CONFIG_X", "CONFIG_Y")``: Skip if configs not enabled diff --git a/src/ntfc/coreconfig.py b/src/ntfc/coreconfig.py index 9f7f99d..6bb50b4 100644 --- a/src/ntfc/coreconfig.py +++ b/src/ntfc/coreconfig.py @@ -21,8 +21,10 @@ """Product core configuration handler.""" import os +from functools import cached_property from typing import Any, Dict, Optional, Union +from ntfc.lib.elf.app_bindir import AppBinDir, symbol_patterns from ntfc.lib.elf.elf_parser import ElfParser @@ -193,10 +195,36 @@ def kv_check(self, cfg: str) -> Any: return self._kv_values.get(cfg, False) + @cached_property + def _appbin(self) -> Optional[AppBinDir]: + """Return the installed kernel-mode application binaries.""" + bindir = self.app_bindir + if not bindir or not os.path.isdir(bindir): + return None + + return AppBinDir(bindir) + + @property + def has_app_bindir(self) -> bool: + """Return True when kernel-mode application binaries are found.""" + return self._appbin is not None + def cmd_check(self, cmd: str, core: int = 0) -> bool: - """Check if command is available in binary.""" + """Check if command is available in binary. + + Kernel-mode cores resolve commands against the application binary + directory; otherwise the symbol must exist in the core ELF. + """ + if self._appbin: + if self._appbin.has_command(cmd): + return True + # not an application: NSH builtins and test entry points + # are symbols inside the application binaries + return self._appbin.has_symbol(cmd) + if not self._elf: raise AttributeError("no elf data") - symbol_name = f"{cmd}_main" if "cmocka" in cmd else cmd - return self._elf.has_symbol(symbol_name) + return any( + self._elf.has_symbol(symbol) for symbol in symbol_patterns(cmd) + ) diff --git a/src/ntfc/testfilter.py b/src/ntfc/testfilter.py index 155c882..00d5bab 100644 --- a/src/ntfc/testfilter.py +++ b/src/ntfc/testfilter.py @@ -85,12 +85,12 @@ def check_test_support( reason = f"Required config '{d}' not enabled" break - # command available in ELF + # command available on the target if skip is False: for c in cmd: if self._config.cmd_check(c) is False: skip = True - reason = f"Required symbol '{c}' not found in ELF" + reason = f"Required command '{c}' not available" break # check extra parameters diff --git a/tests/test_coreconfig.py b/tests/test_coreconfig.py index f4e80f2..67943bb 100644 --- a/tests/test_coreconfig.py +++ b/tests/test_coreconfig.py @@ -18,6 +18,8 @@ # ############################################################################ +import shutil + import pytest from ntfc.coreconfig import CoreConfig @@ -164,6 +166,48 @@ def test_core_config_app_bindir(tmp_path): } assert CoreConfig(conf).app_bindir is None + # a derived directory that does not exist is not a command source + conf = { + "name": "t", + "conf_path": str(kernel_cfg), + "elf_path": "./tests/resources/nuttx/sim/nuttx", + } + core_conf = CoreConfig(conf) + assert core_conf.app_bindir == "./tests/resources/nuttx/sim/bin" + assert core_conf.has_app_bindir is False + + +def test_core_config_cmd_check_kernel_mode(tmp_path): + kernel_cfg = tmp_path / "kv_config" + kernel_cfg.write_text("CONFIG_BUILD_KERNEL=y\n") + bindir = tmp_path / "bin" + bindir.mkdir() + (bindir / "hello").write_bytes(b"\x7fELF" + b"\x00" * 12) + + conf = { + "name": "t", + "conf_path": str(kernel_cfg), + "app_bindir": str(bindir), + } + + p = CoreConfig(conf) + assert p.has_app_bindir is True + # command resolution by application file name, no ELF configured + assert p.cmd_check("hello") is True + assert p.cmd_check("hello_main") is True + assert p.cmd_check("cmd_df") is False + + # symbol fallback over unstripped binaries in bin_debug + debug = tmp_path / "bin_debug" + debug.mkdir() + shutil.copy("./tests/resources/nuttx/sim/nuttx", debug / "sh") + + p = CoreConfig(conf) + assert p.cmd_check("cmd_df") is True + assert p.cmd_check("missing|cmd_df") is True + assert p.cmd_check("cmd_d.*") is True + assert p.cmd_check("no_such_symbol_xyz") is False + def test_core_config_prompt(): # Test with explicit prompt in YAML config diff --git a/tests/test_filtertest.py b/tests/test_filtertest.py index e7df74b..cf85ce6 100644 --- a/tests/test_filtertest.py +++ b/tests/test_filtertest.py @@ -57,7 +57,7 @@ def test_filterest_filter(): f.extract_test_requirements = mock_extract_test_requirements2 skip, reason = f.check_test_support(None) assert skip is True - assert reason == "Required symbol 'CMD1' not found in ELF" + assert reason == "Required command 'CMD1' not available" config.kv_check.return_value = True config.cmd_check.return_value = True From 04b8a5972fd4a21adfd0d220e6a490ed18ff1c6e Mon Sep 17 00:00:00 2001 From: raiden00pl Date: Tue, 4 Aug 2026 17:42:22 +0200 Subject: [PATCH 04/14] core.py: route runtime check_cmd through config on kernel builds Runtime checks searched the core ELF or the NSH help output, neither of which lists kernel-mode applications. Route check_cmd through CoreConfig.cmd_check when the core is a kernel build with a known application directory, so runtime checks agree with collection-time filtering. Signed-off-by: raiden00pl Assisted-by: Claude Code --- src/ntfc/core.py | 57 ++++++++++++++++++++-------------------------- tests/test_core.py | 36 +++++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 34 deletions(-) diff --git a/src/ntfc/core.py b/src/ntfc/core.py index e51376a..95014f9 100644 --- a/src/ntfc/core.py +++ b/src/ntfc/core.py @@ -28,6 +28,7 @@ Any, List, Optional, + Pattern, Tuple, Union, ) @@ -528,66 +529,58 @@ def start(self) -> None: self._device.start() def check_cmd(self, cmd_pattern: str) -> bool: - """Check if a command pattern is available in the ELF binary. + """Check if a command pattern is available on the core. - This method validates whether a specific command or set of - commands is present in the core's ELF binary by searching for - corresponding symbols. It supports both single commands and - alternative command patterns separated by '|'. + Commands are resolved from the core configuration when it knows + the application binaries, from the device ELF parser or the + shell help output otherwise. It supports both single commands + and alternative command patterns separated by '|'. :param cmd_pattern: Command pattern to check for. Can contain alternatives separated by '|' (e.g., 'test1|test2') :return: True if the command pattern is found, False otherwise - - Note: - - Requires ELF parser to be available in device - - Supports alternative patterns with '|' - - Used to validate core capabilities before executing """ - # Check if device supports command checking + # Kernel-mode: resolve against the application binary directory + # so runtime checks agree with collection-time filtering + if self._conf.has_app_bindir: + return self._conf.cmd_check(cmd_pattern) + + # Devices that expose their own ELF parser resolve the command + # from its symbols if hasattr(self._device, "elf_parser") and self._device.elf_parser: logger.debug(f"Checking command pattern: {cmd_pattern}") - # Split by '|' to support alternative patterns - alternatives = ( - cmd_pattern.split("|") if "|" in cmd_pattern else [cmd_pattern] - ) - - for pattern in alternatives: + for pattern in cmd_pattern.split("|"): # For cmocka tests, append _main to symbol name - symbol_pattern_str: str = ( + symbol_str = ( f"{pattern}_main" if "cmocka" in pattern else pattern ) # Support regex wildcards - if ".*" in symbol_pattern_str: - symbol_pattern = re.compile(symbol_pattern_str) - else: - tmp = symbol_pattern_str - symbol_pattern = tmp # type: ignore[assignment] + symbol: Union[str, Pattern[str]] = ( + re.compile(symbol_str) + if ".*" in symbol_str + else symbol_str + ) - if self._device.elf_parser.has_symbol(symbol_pattern): + if self._device.elf_parser.has_symbol(symbol): return True return False - # Fallback: try to execute the command and check if it exists + # Fallback: check the command against the shell help output logger.warning( "ELF parser not available, trying command check for: " f"{cmd_pattern}" ) - # Send 'help' command to check if command exists result = self.sendCommandReadUntilPattern("help", timeout=5) if result.status == CmdStatus.SUCCESS: - # Check if any of the command alternatives are in the help output - alternatives = ( - cmd_pattern.split("|") if "|" in cmd_pattern else [cmd_pattern] + return any( + pattern.lower() in result.output.lower() + for pattern in cmd_pattern.split("|") ) - for pattern in alternatives: - if pattern.lower() in result.output.lower(): - return True return False diff --git a/tests/test_core.py b/tests/test_core.py index 838513d..ef67281 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -585,8 +585,6 @@ def test_core_check_cmd_with_elf_parser(envconfig_dummy): # Test regex wildcard pattern mock_elf_parser.has_symbol.side_effect = None mock_elf_parser.has_symbol.return_value = True - import re - assert p.check_cmd("test.*") is True # The pattern should be compiled as regex call_args = mock_elf_parser.has_symbol.call_args @@ -595,3 +593,37 @@ def test_core_check_cmd_with_elf_parser(envconfig_dummy): # Test all alternatives not found mock_elf_parser.has_symbol.return_value = False assert p.check_cmd("nonexistent1|nonexistent2") is False + + # Test with failed help command + dev.send_cmd_read_until_pattern.return_value = CmdReturn( + CmdStatus.TIMEOUT + ) + assert p.check_cmd("test") is False + + +def test_core_check_cmd_kernel_mode(tmp_path): + """Test check_cmd resolves via app_bindir on kernel builds.""" + from ntfc.coreconfig import CoreConfig + + kernel_cfg = tmp_path / "kv_config" + kernel_cfg.write_text("CONFIG_BUILD_KERNEL=y\n") + bindir = tmp_path / "bin" + bindir.mkdir() + (bindir / "hello").write_bytes(b"\x7fELF" + b"\x00" * 12) + + conf = CoreConfig( + { + "name": "t", + "conf_path": str(kernel_cfg), + "app_bindir": str(bindir), + } + ) + + with patch("ntfc.device.common.DeviceCommon") as mockdevice: + p = ProductCore(mockdevice.return_value, conf) + + # resolved from the application directory, device is not queried + assert p.check_cmd("hello") is True + assert p.check_cmd("hello_main") is True + assert p.check_cmd("nonexistent") is False + mockdevice.return_value.send_cmd_read_until_pattern.assert_not_called() From 67b3332357371b540a20bc88ef3667ceac1d623f Mon Sep 17 00:00:00 2001 From: raiden00pl Date: Tue, 4 Aug 2026 17:50:28 +0200 Subject: [PATCH 05/14] device: resolve image paths before spawning with a custom exec_cwd With exec_cwd set, QEMU resolved a relative '-kernel' path against the spawn directory instead of the NTFC working directory it is expressed in, so boot timed out. Absolutize the image path in the qemu and sim start paths when a custom spawn directory is configured. Signed-off-by: raiden00pl Assisted-by: Claude Code --- src/ntfc/device/host.py | 12 ++++++++++++ src/ntfc/device/qemu.py | 5 +---- src/ntfc/device/sim.py | 6 +----- tests/device/test_qemu.py | 26 ++++++++++++++++++++++++++ tests/device/test_sim.py | 30 ++++++++++++++++++++++++++++++ 5 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/ntfc/device/host.py b/src/ntfc/device/host.py index fb11750..9a1372b 100644 --- a/src/ntfc/device/host.py +++ b/src/ntfc/device/host.py @@ -58,6 +58,18 @@ def pid(self) -> Optional[int]: """Return the spawned child PID, or ``None`` before start.""" return self._child.pid if self._child else None + def _image_path(self) -> str: + """Return the image path, absolute when spawning in another cwd. + + Relative paths are relative to the NTFC working directory, not + to the spawn directory. + """ + elf = self._conf.elf_path + if not elf: + raise IOError + + return os.path.abspath(elf) if self._cwd else str(elf) + def _dev_is_health_priv(self) -> bool: """Check if the host device is OK.""" if not self._child: diff --git a/src/ntfc/device/qemu.py b/src/ntfc/device/qemu.py index 81521b8..ee9c26a 100644 --- a/src/ntfc/device/qemu.py +++ b/src/ntfc/device/qemu.py @@ -44,12 +44,9 @@ def __init__(self, conf: "CoreConfig"): def _start_impl(self) -> None: """Start QEMU emulator implementation.""" - elf = self._conf.elf_path + elf = self._image_path() exec_path = self._conf.exec_path exec_args = self._conf.exec_args - - if not elf: - raise IOError if not exec_path: raise KeyError("no exec_path in configuration file!") diff --git a/src/ntfc/device/sim.py b/src/ntfc/device/sim.py index 720c5fc..5e1e850 100644 --- a/src/ntfc/device/sim.py +++ b/src/ntfc/device/sim.py @@ -41,11 +41,7 @@ def __init__(self, conf: "CoreConfig"): def _start_impl(self) -> None: """Start sim emulator implementation.""" - elf = self._conf.elf_path - if not elf: - raise IOError - - cmd = [elf] + cmd = [self._image_path()] uptime = self._conf.uptime # open host-based emulation diff --git a/tests/device/test_qemu.py b/tests/device/test_qemu.py index aed9d1c..737c899 100644 --- a/tests/device/test_qemu.py +++ b/tests/device/test_qemu.py @@ -18,6 +18,7 @@ # ############################################################################ +import os from unittest.mock import patch import pytest @@ -33,6 +34,7 @@ def test_device_qemu_open(): config.exec_path = "" config.exec_args = "" config.elf_path = "" + config.exec_cwd = None qemu = DeviceQemu(config) @@ -119,3 +121,27 @@ def host_open_dummy4(cmd, uptime): config.uptime = 3 qemu.start() + + +def test_device_qemu_exec_cwd_absolute_image(tmp_path): + + from ntfc.coreconfig import CoreConfig + + config = CoreConfig( + { + "name": "t", + "device": "qemu", + "exec_path": "qemu-system-riscv64", + "exec_args": "-nographic", + "exec_cwd": str(tmp_path), + } + ) + # relative image path, resolved against the NTFC cwd at spawn time + config._config["elf_path"] = "some/image" + + qemu = DeviceQemu(config) + cmds = [] + qemu.host_open = lambda cmd, uptime: cmds.append(cmd) + qemu.start() + + assert cmds[0][2] == "-kernel " + os.path.abspath("some/image") diff --git a/tests/device/test_sim.py b/tests/device/test_sim.py index d722058..ec06f56 100644 --- a/tests/device/test_sim.py +++ b/tests/device/test_sim.py @@ -101,3 +101,33 @@ def send(self, data): sim._write(b"abc\n") assert sent == [b"a", b"b", b"c", b"\n"] + + +def test_device_sim_exec_cwd_absolute_image(tmp_path): + + import os + + from ntfc.coreconfig import CoreConfig + + config = CoreConfig( + {"name": "t", "device": "sim", "exec_cwd": str(tmp_path)} + ) + # relative image path, resolved against the NTFC cwd at spawn time + config._config["elf_path"] = "some/image" + + sim = DeviceSim(config) + cmds = [] + sim.host_open = lambda cmd, uptime: cmds.append(cmd) + sim.start() + + assert cmds[0] == [os.path.abspath("some/image")] + + # without exec_cwd the image path is passed through unchanged + config = CoreConfig({"name": "t", "device": "sim"}) + config._config["elf_path"] = "some/image" + + sim = DeviceSim(config) + sim.host_open = lambda cmd, uptime: cmds.append(cmd) + sim.start() + + assert cmds[1] == ["some/image"] From 070c477e9b9e93c0101a294a49da34d595d1f374 Mon Sep 17 00:00:00 2001 From: raiden00pl Date: Tue, 4 Aug 2026 17:56:40 +0200 Subject: [PATCH 06/14] device: add kernel-mode crash signatures for user task faults In kernel builds a faulting user task is killed while the kernel keeps running and the prompt returns, so the console message is the only crash evidence; such faults were reported as command timeouts. Register the fault messages as SEGFAULT signatures: - 'Segmentation fault in' (risc-v) - 'PANIC: Unhandled user exception' (arm64) Signed-off-by: raiden00pl Assisted-by: Claude Code --- src/ntfc/device/nuttx.py | 7 +++++++ tests/device/test_nuttx.py | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/ntfc/device/nuttx.py b/src/ntfc/device/nuttx.py index a12cc94..6d40ef9 100644 --- a/src/ntfc/device/nuttx.py +++ b/src/ntfc/device/nuttx.py @@ -51,6 +51,13 @@ class DeviceNuttx(OSCommon): b"up_dump_register", b"dump_tasks", ], + # kernel-mode (CONFIG_BUILD_KERNEL) user task faults: the kernel + # kills the offending task and keeps running, so the console + # message is the only crash evidence + CrashType.SEGFAULT: [ + b"Segmentation fault in", + b"PANIC: Unhandled user exception", + ], } _PANIC_CHAR = r"/" diff --git a/tests/device/test_nuttx.py b/tests/device/test_nuttx.py index dd90df5..0275236 100644 --- a/tests/device/test_nuttx.py +++ b/tests/device/test_nuttx.py @@ -43,6 +43,22 @@ def test_device_nuttx_init(): assert CrashType.ASSERTION in sigs assert all(isinstance(v, list) for v in sigs.values()) + # kernel-mode user task faults kill only the task; the console + # message is the only crash evidence + assert CrashType.SEGFAULT in sigs + assert b"Segmentation fault in" in sigs[CrashType.SEGFAULT] + assert b"PANIC: Unhandled user exception" in sigs[CrashType.SEGFAULT] + + # real rv-virt knsh64 console output is classified as SEGFAULT + from ntfc.device.state import DeviceStateManager + + mgr = DeviceStateManager(crash_signatures=sigs) + line = ( + b"[ 2.966000] riscv_fault_handler: " + b"Segmentation fault in getprime (PID 4: getprime)" + ) + assert mgr._detect_crash_type(line) is CrashType.SEGFAULT + config.kv_check.return_value = 0 assert d.panic_char == "" config.kv_check.return_value = 1 From ec9032834988c981cfd2a6ff0cd0a59aa863f81e Mon Sep 17 00:00:00 2001 From: raiden00pl Date: Tue, 4 Aug 2026 18:01:45 +0200 Subject: [PATCH 07/14] builder.py: add application image support for kernel-mode flashing Real hardware has no hostfs, so kernel-mode applications must be shipped as a filesystem image. Add the per-core apps_image option, which generates a ROMFS image from app_bindir with genromfs after the build, and the $APPS_BINDIR and $APPS_IMG flash command placeholders. Signed-off-by: raiden00pl Assisted-by: Claude Code --- Documentation/config-yaml.rst | 9 ++++ Documentation/config.yaml | 3 ++ src/ntfc/builder.py | 65 +++++++++++++++++++++--- tests/test_builder.py | 93 +++++++++++++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 6 deletions(-) diff --git a/Documentation/config-yaml.rst b/Documentation/config-yaml.rst index 294f1ba..9c0d4e3 100644 --- a/Documentation/config-yaml.rst +++ b/Documentation/config-yaml.rst @@ -350,6 +350,10 @@ Flash command can use special tags that are handled by NTFC: - ``$IMAGE_BIN`` is replaced by path to ``nuttx.bin``. - ``$IMAGE_HEX`` is replaced by path to ``nuttx.hex``. +- ``$APPS_BINDIR`` is replaced by the application binaries directory + (kernel-mode builds). +- ``$APPS_IMG`` is replaced by the generated application filesystem + image (requires ``apps_image``). Example usage with ``st-flash`` tool: @@ -430,6 +434,11 @@ These fields are parsed by :class:`ntfc.coreconfig.CoreConfig`. - (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``) + * - ``apps_image`` + - (Optional) Generate a filesystem image with the application + binaries after build, e.g. ``apps_image: {type: romfs}``. The + image path is available as ``$APPS_IMG`` in the ``flash`` + command. Requires ``genromfs`` and a kernel-mode build * - ``defconfig`` - Path to NuttX defconfig (auto-build) * - ``elf_path`` diff --git a/Documentation/config.yaml b/Documentation/config.yaml index 1b38027..d684b27 100644 --- a/Documentation/config.yaml +++ b/Documentation/config.yaml @@ -77,6 +77,9 @@ product: # many products can be supported in tests (pro app_bindir: '' # (optional) directory with kernel-mode application binaries. # Defaults to the bin/ directory next to the NuttX ELF # for kernel-mode builds. + apps_image: # (optional) generate a filesystem image with the application + type: romfs # binaries after build (kernel-mode only, requires genromfs). + # The image path is available as $APPS_IMG in 'flash'. # NTFC can use pre-build image or build it from defconfig # the behavior will depend on the parameters specified in config. diff --git a/src/ntfc/builder.py b/src/ntfc/builder.py index 47f8c33..673ae9c 100644 --- a/src/ntfc/builder.py +++ b/src/ntfc/builder.py @@ -39,6 +39,9 @@ class NuttXBuilder: IMAGE_BIN_STR = "$IMAGE_BIN" IMAGE_HEX_STR = "$IMAGE_HEX" + APPS_BINDIR_STR = "$APPS_BINDIR" + APPS_IMG_STR = "$APPS_IMG" + APPS_IMG_NAME = "apps.romfs.img" _KCONFIG_DISABLED_RE = re.compile( r"^#\s+(CONFIG_[A-Za-z0-9_]+)\s+is not set" ) @@ -425,6 +428,61 @@ def _build_core( ) cores[core].setdefault("exec_cwd", build_path) + self._make_apps_image(cores[core], build_path) + + def _make_apps_image( + self, core_cfg: Dict[str, Any], build_path: str + ) -> None: + """Generate a filesystem image with application binaries. + + Enabled with the per-core ``apps_image`` option; the image path + is registered as ``apps_img`` for the ``$APPS_IMG`` flash + placeholder. + """ + img_cfg = core_cfg.get("apps_image", None) + if not img_cfg: + return + + if not isinstance(img_cfg, dict): + raise BuilderConfigError("apps_image must be a mapping") + + img_type = img_cfg.get("type", "romfs") + if img_type != "romfs": + raise BuilderConfigError( + f"unsupported apps_image type: {img_type}" + ) + + bindir = core_cfg.get("app_bindir", None) + if not bindir: + raise BuilderConfigError( + "apps_image requires app_bindir (kernel-mode build)" + ) + + tool = shutil.which("genromfs") + if not tool: + raise BuilderConfigError("genromfs not found in PATH") + + img_path = os.path.join(build_path, self.APPS_IMG_NAME) + self._run_command([tool, "-f", img_path, "-d", bindir], env=None) + core_cfg["apps_img"] = img_path + + def _expand_flash_cmd( + self, flash_cmd: str, core_cfg: Dict[str, Any] + ) -> str: + """Expand image placeholders in a flash command.""" + parent = Path(core_cfg["elf_path"]).parent + values = { + self.IMAGE_BIN_STR: str(parent / "nuttx.bin"), + self.IMAGE_HEX_STR: str(parent / "nuttx.hex"), + self.APPS_BINDIR_STR: core_cfg.get("app_bindir", ""), + self.APPS_IMG_STR: core_cfg.get("apps_img", ""), + } + + for placeholder, value in values.items(): + flash_cmd = flash_cmd.replace(placeholder, value) + + return flash_cmd + @staticmethod def _is_kernel_config(conf_path: str) -> bool: """Check if a generated .config selects a kernel build.""" @@ -450,12 +508,7 @@ def _flash_core( """Flash single core image.""" flash_cmd = cores[core].get("flash", None) if flash_cmd: - img_path = Path(cores[core]["elf_path"]) - image_hex = str(img_path.parent) + "/nuttx.hex" - image_bin = str(img_path.parent) + "/nuttx.bin" - - flash_cmd = flash_cmd.replace(self.IMAGE_BIN_STR, image_bin) - flash_cmd = flash_cmd.replace(self.IMAGE_HEX_STR, image_hex) + flash_cmd = self._expand_flash_cmd(flash_cmd, cores[core]) cmd = flash_cmd.split() diff --git a/tests/test_builder.py b/tests/test_builder.py index e06a854..367de7b 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -143,6 +143,99 @@ def test_builder_flat_mode_no_kernel_keys(tmp_path) -> None: ) +def test_builder_expand_flash_cmd() -> None: + b = NuttXBuilder(copy.deepcopy(conf_dir)) + core_cfg = { + "elf_path": "bbb/core/nuttx", + "app_bindir": "bbb/core/bin", + "apps_img": "bbb/core/apps.romfs.img", + } + + cmd = b._expand_flash_cmd( + "flash $IMAGE_BIN $IMAGE_HEX $APPS_BINDIR $APPS_IMG", core_cfg + ) + assert cmd == ( + "flash bbb/core/nuttx.bin bbb/core/nuttx.hex " + "bbb/core/bin bbb/core/apps.romfs.img" + ) + + # placeholders without registered values expand to empty strings + cmd = b._expand_flash_cmd( + "flash $APPS_BINDIR $APPS_IMG", {"elf_path": "bbb/core/nuttx"} + ) + assert cmd == "flash " + + +def test_builder_apps_image(tmp_path, monkeypatch) -> None: + config = copy.deepcopy(conf_dir) + config["config"]["build_dir"] = str(tmp_path) + config["product"]["cores"]["core0"]["defconfig"] = "dummy/path" + config["product"]["cores"]["core0"]["apps_image"] = {"type": "romfs"} + + build_path = tmp_path / "product-xxx-dummy" + build_path.mkdir() + (build_path / ".config").write_text("CONFIG_BUILD_KERNEL=y\n") + + calls = [] + + def run_command_capture(cmd, env): + calls.append(cmd) + + monkeypatch.setattr( + "ntfc.builder.shutil.which", lambda tool: f"/usr/bin/{tool}" + ) + + b = NuttXBuilder(config) + b._run_command = run_command_capture + b._make_dir = builder_make_dir_dummy + b.build_all() + + core = b.new_conf()["product"]["cores"]["core0"] + img_path = str(build_path / "apps.romfs.img") + assert core["apps_img"] == img_path + assert calls[-1] == [ + "/usr/bin/genromfs", + "-f", + img_path, + "-d", + str(build_path / "bin"), + ] + + +def test_builder_apps_image_errors(tmp_path, monkeypatch) -> None: + def make_builder(config_txt, apps_image): + config = copy.deepcopy(conf_dir) + config["config"]["build_dir"] = str(tmp_path) + config["product"]["cores"]["core0"]["defconfig"] = "dummy/path" + config["product"]["cores"]["core0"]["apps_image"] = apps_image + + build_path = tmp_path / "product-xxx-dummy" + build_path.mkdir(exist_ok=True) + (build_path / ".config").write_text(config_txt) + + b = NuttXBuilder(config) + b._run_command = builder_run_command_dummy + b._make_dir = builder_make_dir_dummy + return b + + # unsupported image type + with pytest.raises(BuilderConfigError): + make_builder("CONFIG_BUILD_KERNEL=y\n", {"type": "vfat"}).build_all() + + # not a mapping + with pytest.raises(BuilderConfigError): + make_builder("CONFIG_BUILD_KERNEL=y\n", "romfs").build_all() + + # no application directory (flat build) + with pytest.raises(BuilderConfigError): + make_builder("CONFIG_BUILD_FLAT=y\n", {"type": "romfs"}).build_all() + + # genromfs not installed + monkeypatch.setattr("ntfc.builder.shutil.which", lambda tool: None) + with pytest.raises(BuilderConfigError): + make_builder("CONFIG_BUILD_KERNEL=y\n", {"type": "romfs"}).build_all() + + def test_builder_passes_build_env() -> None: config = copy.deepcopy(conf_dir) config["config"]["build_env"] = {"CC": "gcc-13", "CXX": "g++-13"} From c793ffb245fa20bf9249e11b1f716f78419f59c9 Mon Sep 17 00:00:00 2001 From: raiden00pl Date: Tue, 4 Aug 2026 18:04:32 +0200 Subject: [PATCH 08/14] core.py: runtime command discovery fallback via target PATH listing Prebuilt kernel-mode images have no host application directory, so check_cmd could not resolve commands. List CONFIG_PATH_INITIAL (default /system/bin) once on the running target, cache it, and match cmd_check patterns against it with the matcher shared with AppBinDir. A failed listing is not cached and is retried. Signed-off-by: raiden00pl Assisted-by: Claude Code --- src/ntfc/core.py | 68 ++++++++++++++++++++++++------------ src/ntfc/coreconfig.py | 5 +++ tests/test_core.py | 78 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 21 deletions(-) diff --git a/src/ntfc/core.py b/src/ntfc/core.py index 95014f9..4e6a3e9 100644 --- a/src/ntfc/core.py +++ b/src/ntfc/core.py @@ -28,7 +28,6 @@ Any, List, Optional, - Pattern, Tuple, Union, ) @@ -36,6 +35,7 @@ from ntfc.command_builder import CommandBuilder from ntfc.coreconfig import CoreConfig from ntfc.device.common import CmdReturn, CmdStatus +from ntfc.lib.elf.app_bindir import match_command, symbol_patterns from ntfc.log.logger import logger if TYPE_CHECKING: @@ -73,6 +73,8 @@ class CoreStatus(_Enum): class ProductCore: """This class implements product core under test.""" + _PATH_NAME_RE = re.compile(r"[A-Za-z0-9_.+-]+") + def __init__( self, device: "DeviceCommon", @@ -100,6 +102,7 @@ def __init__( list(ignored_cores) if ignored_cores is not None else ["dsp"] ) self._builder = CommandBuilder(device.prompt, device.no_cmd) + self._runtime_cmds: Optional[List[str]] = None self._prompt = device.prompt self._main_prompt = self._prompt @@ -528,17 +531,47 @@ def start(self) -> None: """Start device.""" self._device.start() + def _check_cmd_runtime(self, cmd_pattern: str) -> bool: + """Check a command against the target PATH listing. + + The listing is fetched once from the running target and cached; + a failed listing is not cached so it is retried on next use. + """ + if self._runtime_cmds is None: + result = self.sendCommandReadUntilPattern( + f"ls {self._conf.path_initial}", timeout=5 + ) + if result.status != CmdStatus.SUCCESS: + return False + + output_lower = result.output.lower() + no_cmd = str(self._device.no_cmd).lower() + if no_cmd in output_lower or any( + line.lstrip().lower().startswith("ls:") + for line in result.output.splitlines() + ): + return False + + # drop the command echo line; path headers and the prompt + # do not match the name pattern + tokens = " ".join(result.output.splitlines()[1:]).split() + self._runtime_cmds = [ + token + for token in tokens + if self._PATH_NAME_RE.fullmatch(token) + ] + + return match_command(cmd_pattern, self._runtime_cmds) + def check_cmd(self, cmd_pattern: str) -> bool: """Check if a command pattern is available on the core. - Commands are resolved from the core configuration when it knows - the application binaries, from the device ELF parser or the - shell help output otherwise. It supports both single commands - and alternative command patterns separated by '|'. + Commands are resolved from the application binaries, the running + target, the device ELF parser or the shell help output, in that + order. - :param cmd_pattern: Command pattern to check for. Can contain - alternatives separated by '|' - (e.g., 'test1|test2') + :param cmd_pattern: Command pattern, may contain alternatives + separated by '|' (e.g. 'test1|test2') :return: True if the command pattern is found, False otherwise """ # Kernel-mode: resolve against the application binary directory @@ -546,24 +579,17 @@ def check_cmd(self, cmd_pattern: str) -> bool: if self._conf.has_app_bindir: return self._conf.cmd_check(cmd_pattern) + if self._conf.is_kernel_build: + # prebuilt image without host binaries: discover the + # command set once from the running target + return self._check_cmd_runtime(cmd_pattern) + # Devices that expose their own ELF parser resolve the command # from its symbols if hasattr(self._device, "elf_parser") and self._device.elf_parser: logger.debug(f"Checking command pattern: {cmd_pattern}") - for pattern in cmd_pattern.split("|"): - # For cmocka tests, append _main to symbol name - symbol_str = ( - f"{pattern}_main" if "cmocka" in pattern else pattern - ) - - # Support regex wildcards - symbol: Union[str, Pattern[str]] = ( - re.compile(symbol_str) - if ".*" in symbol_str - else symbol_str - ) - + for symbol in symbol_patterns(cmd_pattern): if self._device.elf_parser.has_symbol(symbol): return True diff --git a/src/ntfc/coreconfig.py b/src/ntfc/coreconfig.py index 6bb50b4..c93aa01 100644 --- a/src/ntfc/coreconfig.py +++ b/src/ntfc/coreconfig.py @@ -209,6 +209,11 @@ def has_app_bindir(self) -> bool: """Return True when kernel-mode application binaries are found.""" return self._appbin is not None + @property + def path_initial(self) -> str: + """Return the initial target PATH, see CONFIG_PATH_INITIAL.""" + return str(self.kv_check("CONFIG_PATH_INITIAL") or "/system/bin") + def cmd_check(self, cmd: str, core: int = 0) -> bool: """Check if command is available in binary. diff --git a/tests/test_core.py b/tests/test_core.py index ef67281..7439831 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -627,3 +627,81 @@ def test_core_check_cmd_kernel_mode(tmp_path): assert p.check_cmd("hello_main") is True assert p.check_cmd("nonexistent") is False mockdevice.return_value.send_cmd_read_until_pattern.assert_not_called() + + +def test_core_check_cmd_kernel_runtime_fallback(tmp_path, monkeypatch): + """Test check_cmd discovers commands from the running target.""" + from ntfc.coreconfig import CoreConfig + + kernel_cfg = tmp_path / "kv_config" + kernel_cfg.write_text( + 'CONFIG_BUILD_KERNEL=y\nCONFIG_PATH_INITIAL="/system/bin"\n' + ) + # a kernel core whose application binaries are not on the host: + # the command set has to come from the running target + conf = CoreConfig( + { + "name": "t", + "conf_path": str(kernel_cfg), + "elf_path": "./tests/resources/nuttx/sim/nuttx", + } + ) + assert conf.has_app_bindir is False + + with patch("ntfc.device.common.DeviceCommon") as mockdevice: + p = ProductCore(mockdevice.return_value, conf) + + calls = [] + status = [CmdStatus.TIMEOUT] + + def fake_send(cmd, pattern=None, args=None, timeout=30): + calls.append(cmd) + output = "ls /system/bin\n/system/bin:\n hello\n init\n sh\nnsh> " + return CmdReturn(status[0], output=output) + + monkeypatch.setattr(p, "sendCommandReadUntilPattern", fake_send) + + # listing failure is not cached + assert p.check_cmd("hello") is False + assert len(calls) == 1 + + status[0] = CmdStatus.SUCCESS + assert p.check_cmd("hello") is True + assert p.check_cmd("hello_main") is True + assert p.check_cmd("missing|sh") is True + assert p.check_cmd("missing") is False + + # target was listed once after the failed attempt + assert calls == ["ls /system/bin", "ls /system/bin"] + + +def test_core_check_cmd_kernel_runtime_does_not_cache_ls_error( + tmp_path, monkeypatch +): + """Do not interpret words from an ls error as target commands.""" + from ntfc.coreconfig import CoreConfig + + kernel_cfg = tmp_path / "kv_config" + kernel_cfg.write_text("CONFIG_BUILD_KERNEL=y\n") + conf = CoreConfig({"name": "t", "conf_path": str(kernel_cfg)}) + + with patch("ntfc.device.common.DeviceCommon") as mockdevice: + p = ProductCore(mockdevice.return_value, conf) + calls = [] + + def fake_send(cmd, pattern=None, args=None, timeout=30): + calls.append(cmd) + return CmdReturn( + CmdStatus.SUCCESS, + output=( + "ls /system/bin\n" + "ls: /system/bin: No such file or directory\n" + "nsh> " + ), + ) + + monkeypatch.setattr(p, "sendCommandReadUntilPattern", fake_send) + + assert p.check_cmd("No") is False + assert p.check_cmd("file") is False + assert calls == ["ls /system/bin", "ls /system/bin"] From a4bfa4c0df1a3300ba4b93f22383d76e01ddcd49 Mon Sep 17 00:00:00 2001 From: raiden00pl Date: Tue, 4 Aug 2026 20:51:08 +0200 Subject: [PATCH 09/14] builder.py: reconfigure after applying Kconfig overrides CMake registers application targets at configure time from .config, but kv overrides are applied after configuring, so an override enabling a new application changed .config without ever creating its build target: code-level options took effect through the .config -> config.h rule while the application never appeared in the image. Overrides can also unlock suboptions (e.g. *_PROGNAME) whose missing defaults make cmake drop the application silently. Run olddefconfig and configure again after applying overrides. Signed-off-by: raiden00pl Assisted-by: Claude Code --- src/ntfc/builder.py | 29 +++++++++++++++++++++++--- tests/test_builder.py | 47 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/src/ntfc/builder.py b/src/ntfc/builder.py index 673ae9c..496f137 100644 --- a/src/ntfc/builder.py +++ b/src/ntfc/builder.py @@ -24,6 +24,7 @@ import re import shutil import subprocess +from functools import partial from pathlib import Path from typing import Any, Dict, List, Optional @@ -334,7 +335,10 @@ def _run_cmake( self._run_command(cmd, env=run_env) def _run_build( - self, build: str, env: Optional[Dict[str, str]] = None + self, + build: str, + env: Optional[Dict[str, str]] = None, + target: Optional[str] = None, ) -> None: """Run the CMake build step.""" build_path = Path(build) @@ -344,6 +348,8 @@ def _run_build( "--build", str(build_path), ] + if target: + cmd += ["--target", target] run_env = os.environ.copy() if env: @@ -399,8 +405,8 @@ def _build_core( if not already_build or self._rebuild: # pragma: no cover self._log_kconfig_overrides(kv_overrides) - # configure build - self._run_cmake( + configure = partial( + self._run_cmake, source=nuttx_dir, build=build_path, generator="Ninja", @@ -408,11 +414,28 @@ def _build_core( env=build_env, ) + # configure build + configure() + # apply Kconfig overrides to generated .config before build self._apply_kconfig_overrides( nuttx_conf_path, kv_overrides, cfg_cwd ) + if kv_overrides: + # fill in defaults of suboptions the overrides + # unlocked (e.g. *_PROGNAME): applications are + # silently dropped at configure when these are + # missing from .config + self._run_build( + build_path, env=build_env, target="olddefconfig" + ) + + # application targets are registered at configure + # time from .config: configure again so overrides + # that enable new applications take effect + configure() + # build self._run_build(build_path, env=build_env) diff --git a/tests/test_builder.py b/tests/test_builder.py index 367de7b..627b47c 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -143,6 +143,35 @@ def test_builder_flat_mode_no_kernel_keys(tmp_path) -> None: ) +def test_builder_reconfigures_after_kv_overrides(monkeypatch) -> None: + config = copy.deepcopy(conf_dir) + config["product"]["cores"]["core0"]["defconfig"] = "dummy/path" + config["product"]["cores"]["core0"]["kv"] = {"CONFIG_SYSTEM_X": "y"} + + calls = [] + + def run_command_capture(cmd, env): + calls.append(cmd) + + b = NuttXBuilder(config) + b._run_command = run_command_capture + b._make_dir = builder_make_dir_dummy + monkeypatch.setattr( + b, "_apply_kconfig_overrides", lambda *args, **kwargs: None + ) + + b.build_all() + + # application targets are registered at configure time: overrides + # require an olddefconfig (defaults of unlocked suboptions) and a + # second configure before the build + assert [cmd[0] for cmd in calls] == ["cmake"] * 4 + assert calls[1][-1] == "olddefconfig" + assert calls[0][:2] == calls[2][:2] + assert calls[3][:2] == ["cmake", "--build"] + assert "olddefconfig" not in calls[3] + + def test_builder_expand_flash_cmd() -> None: b = NuttXBuilder(copy.deepcopy(conf_dir)) core_cfg = { @@ -587,11 +616,14 @@ def test_builder_applies_kv_before_build() -> None: def run_command_capture(cmd, env): calls.append(cmd) if "--build" not in cmd: + # cmake generates .config only when it does not exist yet expected_build_path.mkdir(parents=True, exist_ok=True) - expected_conf_path.write_text( - "# CONFIG_TEST_BOOL is not set\n" "CONFIG_TEST_STR=old\n", - encoding="utf-8", - ) + if not expected_conf_path.exists(): + expected_conf_path.write_text( + "# CONFIG_TEST_BOOL is not set\n" + "CONFIG_TEST_STR=old\n", + encoding="utf-8", + ) else: cfg_text = expected_conf_path.read_text(encoding="utf-8") assert "CONFIG_TEST_BOOL=y\n" in cfg_text @@ -611,9 +643,12 @@ def fake_apply_with_tool(conf_path, overrides, _cfg_cwd): with patch("ntfc.builder.logger.info", side_effect=logs.append): b.build_all() - assert len(calls) == 2 + # configure, olddefconfig and reconfigure after overrides, build + assert len(calls) == 4 assert calls[0][0] == "cmake" - assert calls[1][:2] == ["cmake", "--build"] + assert calls[1][-1] == "olddefconfig" + assert calls[2][0] == "cmake" + assert calls[3][:2] == ["cmake", "--build"] assert any( "Applying Kconfig overrides before build:" == msg for msg in logs ) From 99fbf973e4104f1176c3ad511a1d4fc194e6675e Mon Sep 17 00:00:00 2001 From: raiden00pl Date: Tue, 4 Aug 2026 17:31:45 +0200 Subject: [PATCH 10/14] config: add qemu rv-virt knsh64 kernel-mode reference config First kernel-mode reference target. The S-mode knsh64 build boots through the QEMU bundled OpenSBI firmware, so '-bios none' is not passed. The stock defconfig mounts hostfs 'fs=../apps', which does not match the CMake layout; override it with 'fs=.' so /system maps to the build directory holding bin/. Signed-off-by: raiden00pl Assisted-by: Claude Code --- config/nuttx-qemu-riscv-rv-virt-knsh64.yaml | 31 +++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 config/nuttx-qemu-riscv-rv-virt-knsh64.yaml diff --git a/config/nuttx-qemu-riscv-rv-virt-knsh64.yaml b/config/nuttx-qemu-riscv-rv-virt-knsh64.yaml new file mode 100644 index 0000000..08f4df1 --- /dev/null +++ b/config/nuttx-qemu-riscv-rv-virt-knsh64.yaml @@ -0,0 +1,31 @@ +# NuttX kernel-mode (CONFIG_BUILD_KERNEL=y) reference target. +# +# The knsh64 configuration is an S-mode build: QEMU boots its bundled +# OpenSBI firmware (no '-bios none') and NTFC appends '-kernel '. +# +# The CMake build installs application binaries to /bin and the +# builder sets exec_cwd= for kernel-mode cores, so the hostfs +# mount data 'fs=.' maps /system to the build directory and +# /system/bin/init resolves to /bin/init. + +config: + cwd: './external' + build_dir: './build' + +product: + + name: "ntfc-rv-virt-knsh64" + cores: + core0: + name: 'main' + device: 'qemu' + exec_path: 'qemu-system-riscv64' + exec_args: '-semihosting -M virt,aclint=on -cpu rv64 -smp 1 -nographic' + defconfig: 'boards/risc-v/qemu-rv/rv-virt/configs/knsh64' + boot_timeout: 15 + kv: + CONFIG_INIT_MOUNT_DATA: "fs=." + # required by the arch/os ostest test case + CONFIG_SYSTEM_SETLOGMASK: "y" + # required by the arch/os heap stability test case + CONFIG_TESTING_HEAP: "y" From 299926f6fea52f2d6dd24355e472069cac56e126 Mon Sep 17 00:00:00 2001 From: raiden00pl Date: Tue, 4 Aug 2026 18:05:47 +0200 Subject: [PATCH 11/14] config: add kernel-mode hardware reference config template Board-agnostic template for kernel-mode targets on a serial console, documenting the apps_image/$APPS_IMG flashing pattern, prebuilt image discovery and the boot_timeout requirement. Board specifics are filled in once the hardware target is selected. Signed-off-by: raiden00pl Assisted-by: Claude Code --- config/nuttx-serial-knsh.yaml.example | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 config/nuttx-serial-knsh.yaml.example diff --git a/config/nuttx-serial-knsh.yaml.example b/config/nuttx-serial-knsh.yaml.example new file mode 100644 index 0000000..db4b083 --- /dev/null +++ b/config/nuttx-serial-knsh.yaml.example @@ -0,0 +1,33 @@ +# Template for a kernel-mode (CONFIG_BUILD_KERNEL=y) hardware target +# on a serial console. Copy, rename and fill in the board specifics. +# +# Kernel-mode hardware notes: +# - There is no hostfs on hardware: application binaries must reach the +# target filesystem. Either the board defconfig bakes a ROMFS into the +# kernel image, or 'apps_image' generates one from /bin and the +# 'flash' command writes it with $APPS_IMG. +# - When NTFC builds the image, command discovery uses /bin. For +# prebuilt images (no 'defconfig'), commands are discovered once from +# the running target by listing CONFIG_PATH_INITIAL. +# - Kernel boot (mount filesystem, load init ELF) plus a bootloader can +# exceed the 5 second default boot wait; raise 'boot_timeout'. + +config: + cwd: './external' + build_dir: './build' + +product: + + name: "ntfc--knsh" + cores: + core0: + name: 'main' + device: 'serial' + exec_path: '/dev/ttyUSB0' + exec_args: '115200,n,8,1' + defconfig: 'boards////configs/knsh' + boot_timeout: 30 + apps_image: + type: romfs + flash: ' write $IMAGE_BIN $APPS_IMG ' + reboot: '' From 69109b886070edef45ef905866b274f5f26c0ae5 Mon Sep 17 00:00:00 2001 From: raiden00pl Date: Tue, 4 Aug 2026 18:40:12 +0200 Subject: [PATCH 12/14] config: add qemu intel64 knsh_romfs kernel-mode config Second kernel-mode reference target, exercising the ROMFS deployment path (no hostfs on x86_64): applications are packed into a ROMFS image linked into the kernel. kv overrides supply what the stock defconfig lacks, each annotated in the file. Requires the CMake kernel-build fixes for x86_64 from the vendored NuttX branch fix-x86_64-cmake-kernel-build. Signed-off-by: raiden00pl Assisted-by: Claude Code --- config/nuttx-qemu-intel64-knsh.yaml | 39 +++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 config/nuttx-qemu-intel64-knsh.yaml diff --git a/config/nuttx-qemu-intel64-knsh.yaml b/config/nuttx-qemu-intel64-knsh.yaml new file mode 100644 index 0000000..95b727f --- /dev/null +++ b/config/nuttx-qemu-intel64-knsh.yaml @@ -0,0 +1,39 @@ +# NuttX kernel-mode (CONFIG_BUILD_KERNEL=y) target for QEMU intel64. +# +# The knsh_romfs configuration loads user-space applications from a +# ROMFS image linked into the kernel; there is no hostfs, so exec_cwd +# is not relevant for the mount. Application command discovery uses the +# /bin directory registered by the builder. +# +# Requires NuttX with CMake kernel-build support for x86_64 (branch +# fix-x86_64-cmake-kernel-build in the vendored checkout: arch_interface +# guard, CMAKE_LD, relocatable binary install, board ROMFS generation). + +config: + cwd: './external' + build_dir: './build' + +product: + + name: "ntfc-intel64-knsh" + cores: + core0: + name: 'main' + device: 'qemu' + exec_path: 'qemu-system-x86_64' + exec_args: '-m 2G -cpu host -enable-kvm -nographic -serial mon:stdio' + defconfig: 'boards/x86_64/qemu/qemu-intel64/configs/knsh_romfs' + boot_timeout: 15 + kv: + # resolve bare command names against the ROMFS mount point + CONFIG_LIBC_ENVPATH: "y" + CONFIG_PATH_INITIAL: "/system/bin" + # required by the arch/os ostest test case + CONFIG_SYSTEM_SETLOGMASK: "y" + # required by the arch/os heap stability test case + CONFIG_TESTING_HEAP: "y" + # setlocale()/nl_langinfo(), referenced by the LTP strftime cases + CONFIG_LIBC_LOCALE: "y" + # without it SIGKILL is absent from the default action table and + # can be ignored, which the LTP sigignore cases check for + CONFIG_SIG_SIGKILL_ACTION: "y" From b14ce386e636b12d4e9de9e39c64361b2f0abac6 Mon Sep 17 00:00:00 2001 From: raiden00pl Date: Thu, 6 Aug 2026 09:44:12 +0200 Subject: [PATCH 13/14] config: add qemu armv8a and armv7a kernel-mode configs Kernel-mode reference targets for arm64 and arm, mirroring the riscv knsh64 hostfs pattern: semihosting hostfs with 'fs=.' mapped to the build directory. Signed-off-by: raiden00pl Assisted-by: Claude Code --- config/nuttx-qemu-armv7a-knsh.yaml | 32 ++++++++++++++++++++++++++++++ config/nuttx-qemu-armv8a-knsh.yaml | 32 ++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 config/nuttx-qemu-armv7a-knsh.yaml create mode 100644 config/nuttx-qemu-armv8a-knsh.yaml diff --git a/config/nuttx-qemu-armv7a-knsh.yaml b/config/nuttx-qemu-armv7a-knsh.yaml new file mode 100644 index 0000000..aa431fd --- /dev/null +++ b/config/nuttx-qemu-armv7a-knsh.yaml @@ -0,0 +1,32 @@ +# NuttX kernel-mode (CONFIG_BUILD_KERNEL=y) target for QEMU armv7a. +# +# The CMake build installs application binaries to /bin and the +# builder sets exec_cwd= for kernel-mode cores, so the hostfs +# mount data 'fs=.' maps /system to the build directory and +# /system/bin/init resolves to /bin/init. Semihosting is +# required for the hostfs mount. + +config: + cwd: './external' + build_dir: './build' + +product: + + name: "ntfc-armv7a-knsh" + cores: + core0: + name: 'main' + device: 'qemu' + exec_path: 'qemu-system-arm' + exec_args: '-semihosting -cpu cortex-a7 -nographic + -machine virt,highmem=off,virtualization=off,gic-version=2 + -chardev stdio,id=con,mux=on -serial chardev:con + -mon chardev=con,mode=readline' + defconfig: 'boards/arm/qemu/qemu-armv7a/configs/knsh' + boot_timeout: 15 + kv: + CONFIG_INIT_MOUNT_DATA: "fs=." + # required by the arch/os ostest test case + CONFIG_SYSTEM_SETLOGMASK: "y" + # required by the arch/os heap stability test case + CONFIG_TESTING_HEAP: "y" diff --git a/config/nuttx-qemu-armv8a-knsh.yaml b/config/nuttx-qemu-armv8a-knsh.yaml new file mode 100644 index 0000000..c54e37f --- /dev/null +++ b/config/nuttx-qemu-armv8a-knsh.yaml @@ -0,0 +1,32 @@ +# NuttX kernel-mode (CONFIG_BUILD_KERNEL=y) target for QEMU armv8a. +# +# The CMake build installs application binaries to /bin and the +# builder sets exec_cwd= for kernel-mode cores, so the hostfs +# mount data 'fs=.' maps /system to the build directory and +# /system/bin/init resolves to /bin/init. Semihosting is +# required for the hostfs mount. + +config: + cwd: './external' + build_dir: './build' + +product: + + name: "ntfc-armv8a-knsh" + cores: + core0: + name: 'main' + device: 'qemu' + exec_path: 'qemu-system-aarch64' + exec_args: '-semihosting -cpu cortex-a53 -nographic + -machine virt,virtualization=on,gic-version=3 + -net none -chardev stdio,id=con,mux=on + -serial chardev:con -mon chardev=con,mode=readline' + defconfig: 'boards/arm64/qemu/qemu-armv8a/configs/knsh' + boot_timeout: 15 + kv: + CONFIG_INIT_MOUNT_DATA: "fs=." + # required by the arch/os ostest test case + CONFIG_SYSTEM_SETLOGMASK: "y" + # required by the arch/os heap stability test case + CONFIG_TESTING_HEAP: "y" From cb413d80d7288dac80f50fb3b57821e052c5ca11 Mon Sep 17 00:00:00 2001 From: raiden00pl Date: Tue, 25 Aug 2026 13:41:23 +0200 Subject: [PATCH 14/14] tests: fix race in GdbController gcore tests The gcore tests wrote the "Saved corefile" and GCORE_MARKER reply lines into the pipe before calling generate_coredump(). If the reader thread consumed those lines before generate_coredump() reset _gcore_done and _last_corefile, the reset discarded them and the wait timed out, returning None (seen as a flaky CI failure on the Python 3.14 job). Feed the reply via a proc.stdin.write side effect triggered by the GCORE_MARKER echo command instead, so it arrives only after the controller has issued the gcore command -- mirroring real gdb and making the ordering deterministic. Signed-off-by: raiden00pl Assisted-by: Claude Code --- tests/debug/test_gdb_controller.py | 42 ++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/tests/debug/test_gdb_controller.py b/tests/debug/test_gdb_controller.py index 48eaf2f..0218fda 100644 --- a/tests/debug/test_gdb_controller.py +++ b/tests/debug/test_gdb_controller.py @@ -76,6 +76,24 @@ def _pipe_process() -> Tuple[MagicMock, BinaryIO]: return proc, w_file +def _respond_on_gcore( + proc: MagicMock, w_file: BinaryIO, response: bytes +) -> None: + """Feed *response* into the pipe when the gcore marker echo is sent. + + Replying only after GdbController has issued the command mirrors real + gdb and avoids racing generate_coredump()'s state reset: lines written + to the pipe before the call may be consumed by the reader thread and + then discarded by the reset, leaving the wait to time out. + """ + + def write(data: bytes) -> None: + if GdbController.GCORE_MARKER.encode() in data: + w_file.write(response) + + proc.stdin.write.side_effect = write + + @pytest.fixture def elf(tmp_path: "Path") -> "Path": p = tmp_path / "app.elf" @@ -468,8 +486,14 @@ def test_generate_coredump_returns_path_on_success( assert start_ok is True - w_file.write(f"Saved corefile {corefile}\n".encode()) - w_file.write(f"{GdbController.GCORE_MARKER}\n".encode()) + _respond_on_gcore( + proc, + w_file, + ( + f"Saved corefile {corefile}\n" + f"{GdbController.GCORE_MARKER}\n" + ).encode(), + ) result = ctrl.generate_coredump(tmp_path, "test", timeout=5.0) # Close write end → EOF → reader exits; join to avoid ResourceWarning w_file.close() @@ -489,8 +513,14 @@ def test_generate_coredump_returns_none_when_gcore_fails( w_file.write(b"(gdb) \n") ctrl.start(timeout=5.0) - w_file.write(b"Unable to fetch a corefile\n") - w_file.write(f"{GdbController.GCORE_MARKER}\n".encode()) + _respond_on_gcore( + proc, + w_file, + ( + "Unable to fetch a corefile\n" + f"{GdbController.GCORE_MARKER}\n" + ).encode(), + ) result = ctrl.generate_coredump(tmp_path, "test", timeout=5.0) w_file.close() ctrl.stop() @@ -553,7 +583,9 @@ def test_generate_coredump_uses_gcore_cmd( ctrl = GdbController(elf, _cfg(gcore_cmd="gcore -t nuttx")) w_file.write(b"(gdb) \n") ctrl.start(timeout=5.0) - w_file.write(f"{GdbController.GCORE_MARKER}\n".encode()) + _respond_on_gcore( + proc, w_file, f"{GdbController.GCORE_MARKER}\n".encode() + ) ctrl.generate_coredump(tmp_path, "t", timeout=5.0) w_file.close() ctrl.stop()