[PATCH 4/7] kunit: tool: Add an outfile argument to run_kernel()

From: Mark Brown

Date: Tue Oct 13 2026 - 14:17:24 EST


run_kernel() always tees the kernel's console output to test.log in the
build directory. If more than one kernel were to be run at a time they
would all write to the same file, so allow the caller to choose where
the log goes with a new optional outfile argument.

Also teach get_outfile_path() to generate per-kernel log names of the
form test.<name>.log when given a name, for callers that need to keep
the logs of several kernels apart. Characters that are awkward in file
names, such as the '*' in a filter glob, are replaced with '_'.

Add a unit test covering the new argument.

Signed-off-by: Mark Brown <broonie@xxxxxxxxxx>
---
tools/testing/kunit/kunit_kernel.py | 28 ++++++++++++++++++++++++----
tools/testing/kunit/kunit_tool_test.py | 17 +++++++++++++++++
2 files changed, 41 insertions(+), 4 deletions(-)

diff --git a/tools/testing/kunit/kunit_kernel.py b/tools/testing/kunit/kunit_kernel.py
index 58557c47d85f..2641db9eddba 100644
--- a/tools/testing/kunit/kunit_kernel.py
+++ b/tools/testing/kunit/kunit_kernel.py
@@ -11,6 +11,7 @@ import importlib.util
import logging
import subprocess
import os
+import re
import shlex
import shutil
import signal
@@ -193,8 +194,19 @@ def get_parsed_kunitconfig(build_dir: str,
merged.merge_in_entries(partial)
return merged

-def get_outfile_path(build_dir: str) -> str:
- return os.path.join(build_dir, OUTFILE_PATH)
+def get_outfile_path(build_dir: str, name: Optional[str]=None) -> str:
+ """Returns the path of the kernel console log.
+
+ When several kernels are run each needs its own log, so `name` is
+ used to tell them apart, giving test.<name>.log. Characters that are
+ awkward in file names (anything other than letters, digits, '_', '-'
+ and '.') are replaced with '_'.
+ """
+ if name is None:
+ return os.path.join(build_dir, OUTFILE_PATH)
+ base, ext = os.path.splitext(OUTFILE_PATH)
+ name = re.sub(r'[^\w.-]', '_', name)
+ return os.path.join(build_dir, f'{base}.{name}{ext}')

def _default_qemu_config_path(arch: str) -> str:
config_path = os.path.join(QEMU_CONFIGS_DIR, arch + '.py')
@@ -352,7 +364,12 @@ class LinuxSourceTree:
return
subprocess.call(['stty', 'sane'])

- def run_kernel(self, args: Optional[List[str]]=None, build_dir: str='', filter_glob: str='', filter: str='', filter_action: Optional[str]=None, timeout: Optional[int]=None) -> Iterator[str]:
+ def run_kernel(self, args: Optional[List[str]]=None, build_dir: str='', filter_glob: str='', filter: str='', filter_action: Optional[str]=None, timeout: Optional[int]=None, outfile: Optional[str]=None) -> Iterator[str]:
+ """Boots the kernel and yields its console output line by line.
+
+ The output is also written to `outfile`, which defaults to
+ test.log in the build directory.
+ """
# Copy to avoid mutating the caller-supplied list. exec_tests() reuses
# the same args across repeated run_kernel() calls (e.g. --run_isolated),
# so appending to the original would accumulate stale flags on each call.
@@ -365,6 +382,9 @@ class LinuxSourceTree:
args.append('kunit.filter_action=' + filter_action)
args.append('kunit.enable=1')

+ if outfile is None:
+ outfile = get_outfile_path(build_dir)
+
self._process = self._ops.start(args, build_dir)
assert self._process is not None # tell mypy it's set
assert self._process.stdout is not None # tell mypy it's set
@@ -382,7 +402,7 @@ class LinuxSourceTree:
waiter = threading.Thread(target=_wait_proc)
waiter.start()

- output = open(get_outfile_path(build_dir), 'w')
+ output = open(outfile, 'w')
try:
# Tee the output to the file and to our caller in real time.
for line in self._process.stdout:
diff --git a/tools/testing/kunit/kunit_tool_test.py b/tools/testing/kunit/kunit_tool_test.py
index 17f926ef2820..4e8068dce918 100755
--- a/tools/testing/kunit/kunit_tool_test.py
+++ b/tools/testing/kunit/kunit_tool_test.py
@@ -522,6 +522,23 @@ class LinuxSourceTreeTest(unittest.TestCase):
with open(kunit_kernel.get_outfile_path(build_dir), 'rt') as outfile:
self.assertEqual(outfile.read(), 'hi\nbye\n', msg='Missing some output')

+ def test_run_kernel_outfile(self):
+ """Verify run_kernel() logs to the given outfile instead of test.log."""
+ def fake_start(unused_args, unused_build_dir):
+ return subprocess.Popen(['printf', 'hi\n'], text=True, stdout=subprocess.PIPE)
+
+ with tempfile.TemporaryDirectory('') as build_dir:
+ tree = kunit_kernel.LinuxSourceTree(build_dir, kunitconfig_paths=[os.devnull])
+ mock.patch.object(tree._ops, 'start', side_effect=fake_start).start()
+
+ # Awkward characters in the name are sanitised.
+ outfile = kunit_kernel.get_outfile_path(build_dir, 'my suite.test*')
+ self.assertEqual(outfile, os.path.join(build_dir, 'test.my_suite.test_.log'))
+ self.assertEqual(list(tree.run_kernel(build_dir=build_dir, outfile=outfile)), ['hi\n'])
+ with open(outfile, 'rt') as f:
+ self.assertEqual(f.read(), 'hi\n')
+ self.assertFalse(os.path.exists(kunit_kernel.get_outfile_path(build_dir)))
+
def test_run_kernel_args_not_mutated(self):
"""Verify run_kernel() copies args so callers can reuse them."""
start_calls = []

--
2.47.3