ci(tools): Keep Windows build-system work dir short and logs off the live log

Relative CI_PROJECT_DIR after cd nested --work-dir under tools/test_build_system
and blew MAX_PATH. Save failed-command output next to artifacts instead of
streaming a 12 KB record that still hangs shard 3/6.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Kocka
2026-09-17 20:45:13 +08:00
co-authored by Cursor
parent fa7372d1c4
commit c082e6a25d
6 changed files with 71 additions and 60 deletions
+13 -5
View File
@@ -55,7 +55,9 @@ test_tools_win:
# Build tests # Build tests
.test_build_system_template_win: .test_build_system_template_win:
extends: .host_test_win_template extends: .host_test_win_template
timeout: 2 hours # Shard 6/6 can take ~2h1m on master; 2h is too tight. 3h still cuts the
# 4h freeze cost without failing a healthy long shard.
timeout: 3 hours
artifacts: artifacts:
paths: paths:
- XUNIT_RESULT.xml - XUNIT_RESULT.xml
@@ -65,9 +67,13 @@ test_tools_win:
reports: reports:
junit: XUNIT_RESULT.xml junit: XUNIT_RESULT.xml
script: script:
# CI_PROJECT_DIR is relative on these runners (builds/espressif/esp-idf), so it
# cannot be used after cd. The job starts in the directory artifact paths are
# relative to, so record it before cd and build absolute paths from it.
- $Env:IDF_TEST_PROJECT_DIR = (Get-Location).Path
- cd tools\test_build_system - cd tools\test_build_system
- idf-ci gitlab download-known-failure-cases-file ${KNOWN_FAILURE_CASES_FILE_NAME} - idf-ci gitlab download-known-failure-cases-file ${KNOWN_FAILURE_CASES_FILE_NAME}
- pytest --parallel-count ${CI_NODE_TOTAL} --parallel-index ${CI_NODE_INDEX} --work-dir ${CI_PROJECT_DIR}\test_build_system --junitxml=${CI_PROJECT_DIR}\XUNIT_RESULT.xml --ignore-result-files ${KNOWN_FAILURE_CASES_FILE_NAME} --durations=10 - pytest --parallel-count ${CI_NODE_TOTAL} --parallel-index ${CI_NODE_INDEX} --work-dir "$Env:IDF_TEST_PROJECT_DIR\test_build_system" --junitxml="$Env:IDF_TEST_PROJECT_DIR\XUNIT_RESULT.xml" --ignore-result-files ${KNOWN_FAILURE_CASES_FILE_NAME} --durations=10
pytest_build_system_win: pytest_build_system_win:
extends: extends:
@@ -91,9 +97,10 @@ pytest_build_system_win_minimal_cmake:
Write-Error "ERROR: Wrong CMake version! Detected: $actualVersion, but expected: $Env:MINIMAL_CMAKE_VERSION" Write-Error "ERROR: Wrong CMake version! Detected: $actualVersion, but expected: $Env:MINIMAL_CMAKE_VERSION"
exit 1 exit 1
} }
- $Env:IDF_TEST_PROJECT_DIR = (Get-Location).Path
- cd tools\test_build_system - cd tools\test_build_system
- idf-ci gitlab download-known-failure-cases-file ${KNOWN_FAILURE_CASES_FILE_NAME} - idf-ci gitlab download-known-failure-cases-file ${KNOWN_FAILURE_CASES_FILE_NAME}
- pytest -k cmake --work-dir ${CI_PROJECT_DIR}\test_build_system --junitxml=${CI_PROJECT_DIR}\XUNIT_RESULT.xml --ignore-result-files ${KNOWN_FAILURE_CASES_FILE_NAME} --durations=10 - pytest -k cmake --work-dir "$Env:IDF_TEST_PROJECT_DIR\test_build_system" --junitxml="$Env:IDF_TEST_PROJECT_DIR\XUNIT_RESULT.xml" --ignore-result-files ${KNOWN_FAILURE_CASES_FILE_NAME} --durations=10
pytest_buildv2_system_win: pytest_buildv2_system_win:
extends: extends:
@@ -101,12 +108,13 @@ pytest_buildv2_system_win:
- .rules:labels:buildv2 - .rules:labels:buildv2
parallel: 6 parallel: 6
script: script:
- $Env:IDF_TEST_PROJECT_DIR = (Get-Location).Path
- cd tools\test_build_system - cd tools\test_build_system
- idf-ci gitlab download-known-failure-cases-file ${KNOWN_FAILURE_CASES_FILE_NAME} - idf-ci gitlab download-known-failure-cases-file ${KNOWN_FAILURE_CASES_FILE_NAME}
- pytest - pytest
--buildv2 --buildv2
--parallel-count ${CI_NODE_TOTAL} --parallel-count ${CI_NODE_TOTAL}
--parallel-index ${CI_NODE_INDEX} --parallel-index ${CI_NODE_INDEX}
--work-dir ${CI_PROJECT_DIR}\test_build_system --work-dir "$Env:IDF_TEST_PROJECT_DIR\test_build_system"
--junitxml=${CI_PROJECT_DIR}\XUNIT_RESULT.xml --junitxml="$Env:IDF_TEST_PROJECT_DIR\XUNIT_RESULT.xml"
--ignore-result-files ${KNOWN_FAILURE_CASES_FILE_NAME} --ignore-result-files ${KNOWN_FAILURE_CASES_FILE_NAME}
+1 -1
View File
@@ -31,7 +31,7 @@ If you are working on a bug fix or a feature and one of the tests starts to fail
1. Find the name of the failing test in the CI job log 1. Find the name of the failing test in the CI job log
1. Follow the steps in the section above to run that one test 1. Follow the steps in the section above to run that one test
1. By default, the fixtures which create temporary directories will remove them after the test. To prevent the directories from being removed, run `pytest` with `--work-dir /some/path` flag. The temporary directories will be created under `/some/path`, and you will be able to inspect them once the test fails. 1. By default, the fixtures which create temporary directories will remove them after the test. To prevent the directories from being removed, run `pytest` with `--work-dir /some/path` flag. The temporary directories will be created under `/some/path`, and you will be able to inspect them once the test fails. Failed `idf.py` / `cmake` output is also written to `failed_command_logs/` under that work directory.
1. You can increase the logging level to see the commands being executed by the test by running `pytest` with `--log-cli-level DEBUG` argument. 1. You can increase the logging level to see the commands being executed by the test by running `pytest` with `--log-cli-level DEBUG` argument.
## Adding new tests ## Adding new tests
+21 -8
View File
@@ -15,6 +15,7 @@ from _pytest.fixtures import FixtureRequest
from _pytest.main import Session from _pytest.main import Session
from _pytest.nodes import Item from _pytest.nodes import Item
from test_build_system_helpers import EXT_IDF_PATH from test_build_system_helpers import EXT_IDF_PATH
from test_build_system_helpers import FAILED_COMMAND_LOG_DIR_ENV
from test_build_system_helpers import EnvDict from test_build_system_helpers import EnvDict
from test_build_system_helpers import IdfPyFunc from test_build_system_helpers import IdfPyFunc
from test_build_system_helpers import get_idf_build_env from test_build_system_helpers import get_idf_build_env
@@ -186,21 +187,33 @@ def pytest_addoption(parser: pytest.Parser) -> None:
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
def _session_work_dir(request: FixtureRequest) -> typing.Generator[tuple[Path, bool], None, None]: def _session_work_dir(request: FixtureRequest) -> typing.Generator[tuple[Path, bool], None, None]:
work_dir = request.config.getoption('--work-dir') work_dir = request.config.getoption('--work-dir')
previous_log_dir = os.environ.get(FAILED_COMMAND_LOG_DIR_ENV)
if work_dir: if work_dir:
work_dir = os.path.join(work_dir, datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d_%H-%M-%S')) # resolve allows using relative paths with --work-dir option
logging.debug(f'using work directory: {work_dir}') work_dir_path = Path(work_dir).resolve() / datetime.datetime.now(datetime.timezone.utc).strftime(
os.makedirs(work_dir, exist_ok=True) '%Y-%m-%d_%H-%M-%S'
)
logging.debug(f'using work directory: {work_dir_path}')
os.makedirs(work_dir_path, exist_ok=True)
clean_dir = None clean_dir = None
is_temp_dir = False is_temp_dir = False
else: else:
work_dir = mkdtemp() work_dir_path = Path(mkdtemp()).resolve()
logging.debug(f'created temporary work directory: {work_dir}') logging.debug(f'created temporary work directory: {work_dir_path}')
clean_dir = work_dir clean_dir = work_dir_path
is_temp_dir = True is_temp_dir = True
# resolve allows using relative paths with --work-dir option log_dir = work_dir_path / 'failed_command_logs'
yield Path(work_dir).resolve(), is_temp_dir log_dir.mkdir(parents=True, exist_ok=True)
os.environ[FAILED_COMMAND_LOG_DIR_ENV] = str(log_dir)
yield work_dir_path, is_temp_dir
if previous_log_dir is None:
os.environ.pop(FAILED_COMMAND_LOG_DIR_ENV, None)
else:
os.environ[FAILED_COMMAND_LOG_DIR_ENV] = previous_log_dir
if clean_dir: if clean_dir:
logging.debug(f'cleaning up {clean_dir}') logging.debug(f'cleaning up {clean_dir}')
@@ -13,6 +13,7 @@ from .file_utils import bin_files_differ
from .file_utils import file_contains from .file_utils import file_contains
from .file_utils import replace_in_file from .file_utils import replace_in_file
from .idf_utils import EXT_IDF_PATH from .idf_utils import EXT_IDF_PATH
from .idf_utils import FAILED_COMMAND_LOG_DIR_ENV
from .idf_utils import EnvDict from .idf_utils import EnvDict
from .idf_utils import IdfPyFunc from .idf_utils import IdfPyFunc
from .idf_utils import find_python from .idf_utils import find_python
@@ -33,6 +34,7 @@ __all__ = [
'run_idf_py', 'run_idf_py',
'EXT_IDF_PATH', 'EXT_IDF_PATH',
'EnvDict', 'EnvDict',
'FAILED_COMMAND_LOG_DIR_ENV',
'IdfPyFunc', 'IdfPyFunc',
'Snapshot', 'Snapshot',
'get_snapshot', 'get_snapshot',
@@ -19,6 +19,10 @@ except KeyError:
EnvDict = dict[str, str] EnvDict = dict[str, str]
IdfPyFunc = typing.Callable[..., subprocess.CompletedProcess] IdfPyFunc = typing.Callable[..., subprocess.CompletedProcess]
# Session fixture in conftest.py sets this to the pytest --work-dir tree so
# failed-command files survive --cleanup-idf-copy of the app directory.
FAILED_COMMAND_LOG_DIR_ENV = 'IDF_TEST_FAILED_COMMAND_LOG_DIR'
_LOG_ERROR_MARKERS = ( _LOG_ERROR_MARKERS = (
'CMake Error', 'CMake Error',
@@ -29,44 +33,23 @@ _LOG_ERROR_MARKERS = (
) )
def _clip_log_output(text: str | None, max_lines: int = 80, max_line_len: int = 400) -> str: def _shorten_log_line(line: str, max_line_len: int = 200) -> str:
"""Last ``max_lines`` of process output for logging, plus failure lines. if len(line) <= max_line_len:
return line
return line[:max_line_len] + f'... [{len(line) - max_line_len} chars omitted]'
pytest.ini enables ``log_cli``, so ``logging.error(full_stdout)`` after a
failed build is one record. On Windows CI that live-log can stall for hours def _failure_lines(text: str | None, max_lines: int = 20) -> list[str]:
even when the line count is small: CMake's ``-- Component paths:`` line is """Marker lines only. Do not send build tails over the CI live log."""
a single multi-KB (sometimes multi-MB) string.
"""
if not text: if not text:
return '' return []
lines = text.splitlines() lines: list[str] = []
for line in text.splitlines():
def _short(line: str) -> str:
if len(line) <= max_line_len:
return line
return line[:max_line_len] + f'... [{len(line) - max_line_len} chars omitted]'
omitted = max(0, len(lines) - max_lines)
tail_start = len(lines) - max_lines if omitted else 0
tail = lines[tail_start:]
failures: list[str] = []
for idx, line in enumerate(lines):
if idx >= tail_start:
break
if any(marker in line for marker in _LOG_ERROR_MARKERS): if any(marker in line for marker in _LOG_ERROR_MARKERS):
failures.append(line) lines.append(_shorten_log_line(line))
if len(failures) >= 40: if len(lines) >= max_lines:
break break
return lines
parts: list[str] = []
if failures:
parts.append('[... failure lines ...]')
parts.extend(_short(line) for line in failures)
if omitted:
parts.append(f'[... {omitted} lines omitted ...]')
parts.extend(_short(line) for line in tail)
return '\n'.join(parts)
def _log_process_failure( def _log_process_failure(
@@ -75,13 +58,14 @@ def _log_process_failure(
workdir: Path | str, workdir: Path | str,
error: subprocess.CalledProcessError, error: subprocess.CalledProcessError,
) -> None: ) -> None:
"""Save the untouched output to files, then log one clipped record. """Save the untouched output to files, then log paths and failure lines.
The files keep the whole output available whatever the failure is, so the The files keep the whole output. The live log only names those files and
clipped record no longer has to carry everything needed to debug it. Writing repeats a few marker lines: a 12 KB record of clipped stdout still hangs
them before logging also means the output survives a stalled live log. Windows CI the same way an unclipped one did.
""" """
log_dir = Path(workdir) / 'failed_command_logs' env_log_dir = os.environ.get(FAILED_COMMAND_LOG_DIR_ENV)
log_dir = Path(env_log_dir) if env_log_dir else Path(workdir) / 'failed_command_logs'
saved_paths: dict[str, Path] = {} saved_paths: dict[str, Path] = {}
try: try:
log_dir.mkdir(parents=True, exist_ok=True) log_dir.mkdir(parents=True, exist_ok=True)
@@ -99,8 +83,10 @@ def _log_process_failure(
] ]
for stream_name, output_path in saved_paths.items(): for stream_name, output_path in saved_paths.items():
message.append(f'Full {stream_name}: {output_path}') message.append(f'Full {stream_name}: {output_path}')
message.append(f'Stdout: {_clip_log_output(error.stdout)}') failure_lines = _failure_lines(error.stdout) + _failure_lines(error.stderr)
message.append(f'Stderr: {_clip_log_output(error.stderr)}') if failure_lines:
message.append('Failure lines:')
message.extend(failure_lines)
logging.error('\n'.join(message)) logging.error('\n'.join(message))
+7 -5
View File
@@ -6,7 +6,6 @@ import os
import re import re
import shutil import shutil
import stat import stat
import subprocess
import sys import sys
import textwrap import textwrap
from pathlib import Path from pathlib import Path
@@ -95,13 +94,16 @@ def test_hints_no_color_output_when_noninteractive(idf_py: IdfPyFunc) -> None:
'main/build_test_app.c', '// placeholder_inside_main', 'esp_chip_info_t chip_info; esp_chip_info(&chip_info);' 'main/build_test_app.c', '// placeholder_inside_main', 'esp_chip_info_t chip_info; esp_chip_info(&chip_info);'
) )
with pytest.raises(subprocess.CalledProcessError) as exc_info: # Expected failure: do not go through run_idf_py(check=True). That path
idf_py('build') # still has to write one logging.error record, and on Windows CI that write
# is what hangs shard 3/6 after this test.
ret = idf_py('build', check=False)
# the shared esp_pylib logger drops color escape sequences on # the shared esp_pylib logger drops color escape sequences on
# non-interactive (non-TTY) output, so the hint appears without any ANSI color codes. # non-interactive (non-TTY) output, so the hint appears without any ANSI color codes.
assert 'esp_chip_info.h' in exc_info.value.stdout assert ret.returncode != 0
assert '\x1b[' not in exc_info.value.stdout assert 'esp_chip_info.h' in ret.stdout
assert '\x1b[' not in ret.stdout
@pytest.mark.usefixtures('test_app_copy') @pytest.mark.usefixtures('test_app_copy')