Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Documentation/config-yaml.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand Down
8 changes: 7 additions & 1 deletion Documentation/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand Down
1 change: 0 additions & 1 deletion Documentation/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
===============
Expand Down
32 changes: 32 additions & 0 deletions src/ntfc/coreconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions src/ntfc/device/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand Down
2 changes: 1 addition & 1 deletion src/ntfc/device/serial.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
4 changes: 1 addition & 3 deletions src/ntfc/envconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", {})
Expand Down
137 changes: 137 additions & 0 deletions src/ntfc/lib/elf/app_bindir.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 2 additions & 2 deletions src/ntfc/pytest/configure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
33 changes: 33 additions & 0 deletions tests/device/test_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import pytest
from pexpect.exceptions import ExceptionPexpect

from ntfc.coreconfig import CoreConfig
from ntfc.device.host import DeviceHost


Expand Down Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions tests/device/test_serial.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Loading