From f53c6ae5b347bf68ca05d69937d5936ee420f3d0 Mon Sep 17 00:00:00 2001 From: hathach Date: Sat, 3 Oct 2026 00:31:18 +0700 Subject: [PATCH 01/18] ci: collect Windows code-size evidence --- .github/workflows/code_size_windows.yml | 76 +++++++++++++++++++++++++ tools/ci_select.py | 2 +- 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/code_size_windows.yml diff --git a/.github/workflows/code_size_windows.yml b/.github/workflows/code_size_windows.yml new file mode 100644 index 0000000000..957ff82fbe --- /dev/null +++ b/.github/workflows/code_size_windows.yml @@ -0,0 +1,76 @@ +name: Code size on Windows + +on: + workflow_dispatch: + push: + branches: [ claude/code-size-windows ] + +permissions: + contents: read + +jobs: + evidence: + runs-on: windows-latest + steps: + - name: Checkout TinyUSB + uses: actions/checkout@v6 + + - name: Setup Toolchain + uses: ./.github/actions/setup_toolchain + with: + toolchain: 'arm-gcc-windows-latest' + + - name: Get Dependencies + uses: ./.github/actions/get_deps + with: + arg: '-b stm32f407disco' + + - name: Install membrowse + run: 'python -m pip install --only-binary :all: membrowse==1.2.12' + shell: bash + + - name: Build one example as code_size.py does + run: | + cmake -B evidence -G Ninja -DBOARD=stm32f407disco -DCMAKE_BUILD_TYPE=MinSizeRel examples + ninja -C evidence cdc_msc + shell: bash + + - name: Dump link command and membrowse source paths + run: | + mkdir -p evidence-out + find evidence -name 'cdc_msc.elf' | tee evidence-out/elf.txt + ninja -C evidence -t commands -s device/cdc_msc/cdc_msc.elf > evidence-out/link-fwd.txt 2>&1 || true + ninja -C evidence -t commands -s 'device\cdc_msc\cdc_msc.elf' > evidence-out/link-back.txt 2>&1 || true + tail -c 4000 evidence-out/link-fwd.txt + python -X utf8 - <<'EOF' > evidence-out/membrowse-paths.txt 2>&1 || true + import json, os, subprocess, sys + sys.path.insert(0, 'tools') + from membrowse_cli import extract_ld_scripts, extract_defsyms, link_command, report_inputs + elf = os.path.join('evidence', 'device', 'cdc_msc', 'cdc_msc.elf') + commands = link_command('ninja', 'evidence', elf) + print('ld scripts:', extract_ld_scripts(commands)) + print('defsyms:', extract_defsyms(commands)) + cmd = ['membrowse', 'report'] + report_inputs(elf, extract_ld_scripts(commands), extract_defsyms(commands)) + ['--json', '--all-symbols'] + print('cmd:', cmd) + r = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='replace') + print('rc', r.returncode, r.stderr[-2000:]) + if r.returncode == 0: + data = json.loads(r.stdout) + files = sorted({f"{s.get('object_file')} | {s.get('source_file')}" for s in data.get('symbols', [])}) + print('source files:', len(files)) + print('\n'.join(files[:80])) + EOF + cat evidence-out/membrowse-paths.txt + shell: bash + + - name: Probe the unchanged CLI (expected to fail) + continue-on-error: true + run: python -X utf8 tools/code_size.py report -b stm32f407disco -e device/cdc_msc 2>&1 | tee evidence-out/probe.txt + shell: bash + + - name: Upload evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: code-size-windows-evidence + path: evidence-out/ diff --git a/tools/ci_select.py b/tools/ci_select.py index e7e989e27a..e140663629 100755 --- a/tools/ci_select.py +++ b/tools/ci_select.py @@ -122,7 +122,7 @@ def _read(path: str) -> str: # .github, minus the build machinery named in _FULL_RE and _CI_BOARDS_RE r'\.github/(FUNDING\.yml$|labeler\.yml$|' r'ISSUE_TEMPLATE/|scripts/code_size_ci\.py$|' - r'workflows/(cifuzz|claude|claude-code-review|labeler|' + r'workflows/(cifuzz|claude|claude-code-review|code_size_windows|labeler|' r'pr_comment|pre-commit|static_analysis|trigger)\.yml$)|' # tools/ scripts no build invokes (tools/build*.py and local tooling are handled separately) r'tools/(check_example_pids|file2carray|iar_gen|mksunxi|pcapng_to_corpus)\.py$|' From 207be943a5af21d8e8e201b608368bbabdddce8f Mon Sep 17 00:00:00 2001 From: hathach Date: Sat, 3 Oct 2026 00:43:46 +0700 Subject: [PATCH 02/18] code_size: run report and diff on Windows --- .github/workflows/code_size_windows.yml | 93 +++++++++++++------------ test/hil/test/test_code_size.py | 37 ++++++++++ test/hil/test/test_membrowse_cli.py | 9 +++ tools/code_size.py | 39 ++++++++--- tools/membrowse_cli.py | 3 + 5 files changed, 130 insertions(+), 51 deletions(-) diff --git a/.github/workflows/code_size_windows.yml b/.github/workflows/code_size_windows.yml index 957ff82fbe..e62a67f14e 100644 --- a/.github/workflows/code_size_windows.yml +++ b/.github/workflows/code_size_windows.yml @@ -2,18 +2,48 @@ name: Code size on Windows on: workflow_dispatch: + pull_request: + paths: &code_size_paths + - 'tools/code_size.py' + - 'tools/membrowse_cli.py' + - 'tools/build_utils.py' + - 'test/hil/test/test_code_size*.py' + - 'test/hil/test/test_membrowse_cli.py' + - '.github/scripts/code_size_ci.py' + - '.github/workflows/code_size_windows.yml' push: - branches: [ claude/code-size-windows ] + branches: [ master, claude/code-size-windows ] + paths: *code_size_paths permissions: contents: read + actions: read + +env: + PYTHONUTF8: '1' jobs: - evidence: + unit-test: runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + - run: python -m pip install pytest + - run: python -m pytest -q test/hil/test/test_code_size.py test/hil/test/test_code_size_ci.py test/hil/test/test_membrowse_cli.py + + smoke: + runs-on: windows-latest + env: + BOARD: stm32f407disco + EXAMPLE: device/cdc_msc + GH_TOKEN: ${{ github.token }} + defaults: + run: + shell: bash steps: - name: Checkout TinyUSB uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Setup Toolchain uses: ./.github/actions/setup_toolchain @@ -27,50 +57,27 @@ jobs: - name: Install membrowse run: 'python -m pip install --only-binary :all: membrowse==1.2.12' - shell: bash - - name: Build one example as code_size.py does + - name: Report run: | - cmake -B evidence -G Ninja -DBOARD=stm32f407disco -DCMAKE_BUILD_TYPE=MinSizeRel examples - ninja -C evidence cdc_msc - shell: bash + python tools/code_size.py report -b $BOARD -e $EXAMPLE --json + python - <<'EOF' + import json + data = json.load(open('cmake-code-size/stm32f407disco/report_device_cdc_msc.json')) + files = {f for e in data['elfs'] for f in e['sizes']['files']} + print(data['status'], sorted(files)) + assert data['status'] == 'complete' and any(f.endswith('usbd.c') for f in files), 'no TinyUSB files' + EOF - - name: Dump link command and membrowse source paths + - name: Diff against a local base run: | - mkdir -p evidence-out - find evidence -name 'cdc_msc.elf' | tee evidence-out/elf.txt - ninja -C evidence -t commands -s device/cdc_msc/cdc_msc.elf > evidence-out/link-fwd.txt 2>&1 || true - ninja -C evidence -t commands -s 'device\cdc_msc\cdc_msc.elf' > evidence-out/link-back.txt 2>&1 || true - tail -c 4000 evidence-out/link-fwd.txt - python -X utf8 - <<'EOF' > evidence-out/membrowse-paths.txt 2>&1 || true - import json, os, subprocess, sys - sys.path.insert(0, 'tools') - from membrowse_cli import extract_ld_scripts, extract_defsyms, link_command, report_inputs - elf = os.path.join('evidence', 'device', 'cdc_msc', 'cdc_msc.elf') - commands = link_command('ninja', 'evidence', elf) - print('ld scripts:', extract_ld_scripts(commands)) - print('defsyms:', extract_defsyms(commands)) - cmd = ['membrowse', 'report'] + report_inputs(elf, extract_ld_scripts(commands), extract_defsyms(commands)) + ['--json', '--all-symbols'] - print('cmd:', cmd) - r = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='replace') - print('rc', r.returncode, r.stderr[-2000:]) - if r.returncode == 0: - data = json.loads(r.stdout) - files = sorted({f"{s.get('object_file')} | {s.get('source_file')}" for s in data.get('symbols', [])}) - print('source files:', len(files)) - print('\n'.join(files[:80])) + python tools/code_size.py diff -b $BOARD -e $EXAMPLE --base-source local --base-branch HEAD --json + python - <<'EOF' + import json + data = json.load(open('cmake-code-size/stm32f407disco/diff_device_cdc_msc.json')) + assert data['status'] == 'complete' and data['pairs'], data['status'] + assert all(p['base']['files'] == p['current']['files'] for p in data['pairs']), 'HEAD vs HEAD changed' EOF - cat evidence-out/membrowse-paths.txt - shell: bash - - name: Probe the unchanged CLI (expected to fail) - continue-on-error: true - run: python -X utf8 tools/code_size.py report -b stm32f407disco -e device/cdc_msc 2>&1 | tee evidence-out/probe.txt - shell: bash - - - name: Upload evidence - if: always() - uses: actions/upload-artifact@v7 - with: - name: code-size-windows-evidence - path: evidence-out/ + - name: Diff against CI snapshots + run: python tools/code_size.py diff -b $BOARD -e $EXAMPLE --base-source ci --base-branch origin/master diff --git a/test/hil/test/test_code_size.py b/test/hil/test/test_code_size.py index 83b1ea05b2..ec326c0482 100644 --- a/test/hil/test/test_code_size.py +++ b/test/hil/test/test_code_size.py @@ -1068,6 +1068,7 @@ def test_without_idf_or_its_image_the_espressif_build_fails_saying_how_to_get_on self.assertEqual(runs, []) self.assertIn('esp needs ESP-IDF: source $IDF_PATH/export.sh', error) + @mock.patch.object(sd, 'WINDOWS', new=False) def test_without_idf_report_and_diff_refuse_an_espressif_board_before_building(self): pinned = ['stm32f407disco', 'espressif_s3_devkitm'] for argv in (['report', '-b', pinned[0], '-b', pinned[1]], ['diff', '-b', pinned[0], '-b', pinned[1]], @@ -1236,6 +1237,7 @@ def test_a_real_checkout_has_a_hash(self): self.assertRegex(sd.short_hash(sd.TINYUSB_ROOT), r'^[0-9a-f]{7,}(-dirty)?$') +@unittest.skipIf(os.name == 'nt', 'the Windows filter drops the drive') class SymlinkedCheckout(unittest.TestCase): def test_the_root_matches_its_own_filter_through_a_symlink(self): with tempfile.TemporaryDirectory() as tmp: @@ -1248,6 +1250,41 @@ def test_the_root_matches_its_own_filter_through_a_symlink(self): self.assertEqual(out, 'True\n') +@mock.patch.object(sd, 'WINDOWS', new=True) +class WindowsHost(unittest.TestCase): + def test_a_backslash_object_path_matches_the_forward_slash_filter(self): + for f in ('/a/tinyusb/src/', '\\a\\tinyusb\\src\\'): + self.assertEqual(sd._relative_key(r'cdc_msc.dir\D_\a\tinyusb\src\device\usbd.c.obj', [f]), 'device/usbd.c') + + def test_espressif_boards_are_refused(self): + self.assertRegex(sd.esp_without_idf(['espressif_s3_devkitc', 'stm32f407disco']), + r'^espressif_s3_devkitc need ESP-IDF, .* not support on Windows') + self.assertIsNone(sd.esp_without_idf(['stm32f407disco'])) + + def test_a_timeout_kills_the_command_tree(self): + with mock.patch('subprocess.run') as taskkill: + ret = sd.run([sys.executable, '-c', 'import time; time.sleep(30)'], timeout=0.5) + self.assertEqual(ret.returncode, 124) + argv = taskkill.call_args.args[0] + self.assertEqual(argv[:2] + argv[3:], ['taskkill', '/PID', '/T', '/F']) + + def test_a_symlink_without_the_privilege_names_developer_mode(self): + denied = OSError(22, 'A required privilege is not held by the client') + denied.winerror = 1314 + with tempfile.TemporaryDirectory() as main, tempfile.TemporaryDirectory() as wt: + os.makedirs(os.path.join(main, 'lib/lwip')) + os.makedirs(os.path.join(wt, 'tools')) + with open(os.path.join(wt, 'tools', 'get_deps.py'), 'w') as f: + f.write("deps_all = {'lib/lwip': []}\n") + with mock.patch('os.symlink', side_effect=denied), \ + self.assertRaisesRegex(SystemExit, 'Developer Mode, or use --base-source ci'): + sd.symlink_deps(main, wt) + + def test_a_path_on_another_drive_is_shown_absolute(self): + with mock.patch('os.path.relpath', side_effect=ValueError('path is on mount C:, start on mount D:')): + self.assertEqual(sd._shown('C:/x/report.md'), 'C:/x/report.md') + + # main() tests stub the builds and sizing, so need no engine tool @mock.patch.object(sd, 'engine_missing', new=lambda _engine: False) class MainFailure(unittest.TestCase): diff --git a/test/hil/test/test_membrowse_cli.py b/test/hil/test/test_membrowse_cli.py index a75271647a..ba4e002077 100644 --- a/test/hil/test/test_membrowse_cli.py +++ b/test/hil/test/test_membrowse_cli.py @@ -36,6 +36,15 @@ def test_ld_script_extraction_handles_drive_letters_and_spaces(self): self.assertEqual(cli.extract_ld_scripts(text), ['C:/work/tinyusb/board.ld', '/work/a b/board.ld']) + def test_ld_script_extraction_unwraps_the_windows_ninja_cmd_wrapper(self): + # shortened from a windows-latest `ninja -t commands` link line + text = (r'C:\Windows\system32\cmd.exe /C "cd . && C:\gcc\bin\arm-none-eabi-gcc.exe -Os ' + r'-Wl,-Map=D:/a/out/cdc_msc.elf.map -Wl,--script=D:/a/bsp/STM32F407VGTx_FLASH.ld ' + r'main.c.obj -o device\cdc_msc\cdc_msc.elf lib/libboard.a && C:\Windows\system32\cmd.exe /C ' + r'"cd /D D:\a\out && C:\gcc\bin\arm-none-eabi-objcopy.exe -Obinary D:/a/out/cdc_msc.elf ' + r'D:/a/out/cdc_msc.bin""' '\n') + self.assertEqual(cli.extract_ld_scripts(text), ['D:/a/bsp/STM32F407VGTx_FLASH.ld']) + def test_defsym_extraction_both_separators(self): text = 'cc -Wl,--defsym=FOO=0x10 -Wl,--defsym,BAR=1 -o out.elf\n' self.assertEqual(cli.DEFSYM_RE.findall(text), ['FOO=0x10', 'BAR=1']) diff --git a/tools/code_size.py b/tools/code_size.py index a15013add5..adaed78e0a 100644 --- a/tools/code_size.py +++ b/tools/code_size.py @@ -60,9 +60,11 @@ # resolved like tinyusb_src_filter(), so a symlinked checkout still matches its filter TINYUSB_ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) CODE_SIZE_DIR = os.path.join(TINYUSB_ROOT, 'cmake-code-size') +WINDOWS = os.name == 'nt' # a master run's snapshots are the same for every checkout; XDG ignores a relative XDG_CACHE_HOME -_xdg_cache = os.environ.get('XDG_CACHE_HOME', '') -BASELINE_CACHE_DIR = os.path.join(_xdg_cache if os.path.isabs(_xdg_cache) else os.path.expanduser('~/.cache'), +_cache_home = os.environ.get('LOCALAPPDATA' if WINDOWS else 'XDG_CACHE_HOME', '') +BASELINE_CACHE_DIR = os.path.join(_cache_home if os.path.isabs(_cache_home) else + os.path.expanduser('~/AppData/Local' if WINDOWS else '~/.cache'), 'tinyusb', 'code-size-baseline') CI_PINNED_BOARDS = os.path.join(TINYUSB_ROOT, '.github', 'ci-pinned-boards.json') # a diff's side names when git cannot give their commit hashes @@ -131,6 +133,9 @@ def _classify_region(name): def _relative_key(src, filters): """Source path relative to the first matching filter, object suffix stripped, or None when no filter matches.""" + if WINDOWS: + src = src.replace('\\', '/') + filters = [f.replace('\\', '/') for f in filters] for f in filters: idx = src.find(f) if idx < 0: @@ -836,7 +841,9 @@ def tinyusb_src_filter(checkout_dir): in `checkout_dir`. The substring is the absolute path to the checkout's `src/` dir — collision-free with vendored deps (pico-sdk, lwip, FreeRTOS, etc.) which live at unrelated paths.""" - return os.path.realpath(os.path.join(checkout_dir, 'src')) + os.sep + path = os.path.realpath(os.path.join(checkout_dir, 'src')) + os.sep + # Windows: CMake names an out-of-tree object D_/a/.../src/x.c.obj and DWARF D:/a/...: match past the drive + return os.path.splitdrive(path)[1].replace('\\', '/') if WINDOWS else path verbose = False @@ -861,6 +868,10 @@ def run(cmd, timeout=None): try: out, err = proc.communicate(timeout=timeout) except BaseException as stopped: + if WINDOWS: # terminate() and kill() stop only the command, not ninja's compilers + with contextlib.suppress(OSError, subprocess.SubprocessError): + subprocess.run(['taskkill', '/PID', str(proc.pid), '/T', '/F'], + capture_output=True, timeout=TERMINATE_GRACE) # SIGTERM lets ninja stop its jobs, which it runs in process groups of their own; # a descendant can outlive the SIGKILL holding the pipes open, so stop reading then for stop, wait in ((proc.terminate, TERMINATE_GRACE), (proc.kill, KILL_DRAIN)): @@ -880,8 +891,9 @@ def run(cmd, timeout=None): def exit_on_termination(): """Exit on a SIGTERM or SIGHUP sent to this process alone as on Ctrl-C: through run()'s stop of its command and main()'s worktree removal, instead of orphaning the build.""" - for signum in (signal.SIGTERM, signal.SIGHUP): - signal.signal(signum, lambda sig, _frame: sys.exit(128 + sig)) + for name in ('SIGTERM', 'SIGHUP'): # Windows has no SIGHUP + if hasattr(signal, name): + signal.signal(getattr(signal, name), lambda sig, _frame: sys.exit(128 + sig)) def symlink_deps(main_root, worktree_dir): @@ -894,7 +906,13 @@ def symlink_deps(main_root, worktree_dir): dst = os.path.join(worktree_dir, rel) if os.path.isdir(src) and not os.path.lexists(dst): os.makedirs(os.path.dirname(dst), exist_ok=True) - os.symlink(src, dst) + try: + os.symlink(src, dst, target_is_directory=True) + except OSError as e: + if getattr(e, 'winerror', None) == 1314: # ERROR_PRIVILEGE_NOT_HELD + sys.exit(f'Error: cannot symlink {rel} into the base worktree: enable Windows Developer ' + 'Mode, or use --base-source ci when CI stored a baseline') + raise def short_hash(checkout): @@ -991,6 +1009,8 @@ def _idf_image(): def esp_without_idf(boards): """The error for espressif `boards` no exported ESP-IDF or CI's image can build, else None.""" esp = [b for b in boards if is_espressif(b)] + if esp and WINDOWS: + return f'{", ".join(esp)} need ESP-IDF, which code_size.py does not support on Windows' if esp and not (shutil.which('idf.py') or _idf_image()): return f'{", ".join(esp)} need {ESP_IDF_MISSING}' return None @@ -1128,7 +1148,7 @@ def report(elf): f'or pick another --engine')] sizes, errors = {}, [] for elf, (elf_sizes, error) in zip(elfs, results): - rel = os.path.relpath(elf, build_dir) + rel = os.path.relpath(elf, build_dir).replace(os.sep, '/') # one elf id on every host sizes[rel] = elf_sizes if error: errors.append((rel, error)) @@ -1178,7 +1198,10 @@ def print_result(lines, prefix='', tables=()): def _shown(path): """`path` relative to the working directory when under it.""" - rel = os.path.relpath(path) + try: + rel = os.path.relpath(path) + except ValueError: # on another Windows drive + return path return path if rel.startswith('..') else rel diff --git a/tools/membrowse_cli.py b/tools/membrowse_cli.py index 12b06f7aee..053beb4fed 100644 --- a/tools/membrowse_cli.py +++ b/tools/membrowse_cli.py @@ -42,6 +42,9 @@ def extract_ld_scripts(commands_text): for line in commands_text.splitlines(): try: args = shlex.split(line) + # Windows ninja links through `cmd.exe /C "cd . && "` + if len(args) >= 3 and args[0].lower().endswith('cmd.exe') and args[1].lower() == '/c': + args = shlex.split(args[2]) except ValueError: continue for i, arg in enumerate(args): From faccbaec4b0060d07ec449557dcfa36c43b5453a Mon Sep 17 00:00:00 2001 From: hathach Date: Sat, 3 Oct 2026 00:51:58 +0700 Subject: [PATCH 03/18] code_size: shorten Windows compiler error paths, skip POSIX-only tests there --- .github/workflows/code_size_windows.yml | 2 +- test/hil/test/test_code_size.py | 15 +++++++++++++-- test/hil/test/test_membrowse_cli.py | 10 ++++++++++ tools/code_size.py | 5 ++++- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/.github/workflows/code_size_windows.yml b/.github/workflows/code_size_windows.yml index e62a67f14e..b80fd3c03b 100644 --- a/.github/workflows/code_size_windows.yml +++ b/.github/workflows/code_size_windows.yml @@ -27,7 +27,7 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v6 - - run: python -m pip install pytest + - run: python -m pip install pytest pyyaml pyelftools - run: python -m pytest -q test/hil/test/test_code_size.py test/hil/test/test_code_size_ci.py test/hil/test/test_membrowse_cli.py smoke: diff --git a/test/hil/test/test_code_size.py b/test/hil/test/test_code_size.py index ec326c0482..1111951e29 100644 --- a/test/hil/test/test_code_size.py +++ b/test/hil/test/test_code_size.py @@ -27,6 +27,9 @@ sys.path.insert(0, os.path.join(REPO, 'tools')) import code_size as sd # noqa: E402 +posix_only = unittest.skipIf(os.name == 'nt', 'needs a POSIX shell and signals') +no_esp_on_windows = unittest.skipIf(os.name == 'nt', 'code_size.py refuses ESP-IDF on Windows') + def fake_report(symbols): return {'symbols': symbols} @@ -66,6 +69,7 @@ def test_uses_the_elfs_link_and_runs_from_the_build_dir(self): self.assertEqual(cmd[cmd.index('--def') + 1], 'X=1') self.assertEqual(run.call_args.kwargs['cwd'], build) + @no_esp_on_windows def test_an_esp_idf_build_uses_its_generated_scripts(self): with tempfile.TemporaryDirectory() as build: open(os.path.join(build, 'build.ninja'), 'w').close() @@ -636,7 +640,7 @@ def test_the_real_parser_reads_a_gnu_map(self): class DwarfSources(unittest.TestCase): - @unittest.skipUnless(shutil.which('gcc'), 'needs gcc to build an elf with DWARF') + @unittest.skipUnless(shutil.which('gcc') and os.name != 'nt', 'needs a gcc that builds an elf with DWARF') def test_compile_units_are_found_by_basename(self): with tempfile.TemporaryDirectory() as tmp: for rel, code in (('src/device/usbd.c', 'int usbd(void) { return 1; }\n'), @@ -1037,6 +1041,7 @@ def run(cmd, timeout=None): error = sd.build_board(self._esp_src(tmp), os.path.join(tmp, 'b'), 'esp', example, 'build') return error, runs + @no_esp_on_windows def test_an_espressif_board_builds_each_examples_app_as_an_idf_project(self): with tempfile.TemporaryDirectory() as tmp: error, runs = self._build_esp(tmp) @@ -1045,6 +1050,7 @@ def test_an_espressif_board_builds_each_examples_app_as_an_idf_project(self): '-B', os.path.join(tmp, 'b', 'device', 'a_freertos'), '-GNinja', '-DBOARD=esp', 'app'], 600)]) + @no_esp_on_windows def test_without_an_exported_idf_it_builds_in_cis_image_as_this_user(self): with tempfile.TemporaryDirectory() as tmp: error, runs = self._build_esp(tmp, which=('docker',)) @@ -1091,6 +1097,7 @@ def test_an_example_the_tree_does_not_build_for_espressif_fails_the_build(self): error, runs = self._build_esp(tmp, example=example) self.assertEqual((error, runs), (f'esp builds no {example}', [])) + @no_esp_on_windows def test_a_timed_out_or_interrupted_docker_build_removes_its_container(self): rm = ['docker', 'rm', '-f', f'tinyusb-code-size-{os.getpid()}'] for which, rc, removed in ((('docker',), 124, True), (('docker',), 2, False), (('idf.py',), 124, False)): @@ -1115,6 +1122,7 @@ def test_output_that_is_not_utf8_is_decoded_with_replacement(self): self.assertEqual((ret.returncode, ret.stdout), (rc, 'w: \ufffd\n')) self.assertTrue(ret.stderr.startswith('\ufffd')) + @posix_only def test_sigterm_to_the_script_alone_stops_its_command_and_runs_cleanup(self): """Popen's exit waits for the command, so the handler passes the signal on, and kills a command ignoring it after the grace.""" @@ -1158,6 +1166,7 @@ def test_a_command_ignoring_sigterm_is_killed_after_the_grace(self): ret = sd.run(['sh', '-c', "trap '' TERM; while :; do sleep 0.1; done"], timeout=1) self.assertEqual(ret.returncode, 124) + @posix_only def test_a_child_holding_the_pipes_after_the_kill_does_not_hang_it(self): start = time.monotonic() with mock.patch.object(sd, 'TERMINATE_GRACE', 0.5): @@ -1168,6 +1177,7 @@ def test_a_child_holding_the_pipes_after_the_kill_does_not_hang_it(self): self.assertEqual(ret.returncode, 124) self.assertIn('Command timed out after 1s', ret.stderr) + @posix_only def test_a_child_holding_the_pipes_after_sigterm_does_not_hang_it(self): start = time.monotonic() with mock.patch.object(sd, 'TERMINATE_GRACE', 0.5), mock.patch.object(sd, 'KILL_DRAIN', 0.2): @@ -1986,6 +1996,7 @@ def test_ci_adds_the_pinned_boards_after_the_named_ones_once_each(self): '"uncovered": []}') self.assertEqual(built, ['extra', 'b1', 'b2']) + @mock.patch.object(sd, 'WINDOWS', new=False) def test_ci_builds_pinned_espressif_boards_too(self): with tempfile.TemporaryDirectory() as tmp: built = self._boards_built(tmp, '{"boards": [{"board": "b1"}, ' @@ -2176,7 +2187,7 @@ def test_a_variant_reports_its_board_under_the_build_name(self): self.assertEqual(json.load(f)['boards'], ['b1-DMA']) with open(os.path.join(out, 'code-size-b1-DMA.json')) as f: self.assertEqual(json.load(f)['board'], 'b1-DMA') - self.assertEqual([os.path.relpath(p, build_root) for p in sized], ['cmake-build-b1-DMA/device/a/a.elf']) + self.assertEqual([os.path.relpath(p, build_root) for p in sized], [os.path.join('cmake-build-b1-DMA', 'device', 'a', 'a.elf')]) # the board's own skip rules, with the leg's defines as build.py applied them boards.assert_called_once_with([], ['b1'], ['device/a'], ('MAX3421_HOST=1',)) skip.assert_called_with('device/a', 'b1', ('MAX3421_HOST=1',)) diff --git a/test/hil/test/test_membrowse_cli.py b/test/hil/test/test_membrowse_cli.py index ba4e002077..80216627ba 100644 --- a/test/hil/test/test_membrowse_cli.py +++ b/test/hil/test/test_membrowse_cli.py @@ -17,6 +17,10 @@ import membrowse_cli as cli # noqa: E402 +# the tests run executable #! stubs, and onboard composes a bash build script +posix_only = unittest.skipIf(os.name == 'nt', 'needs POSIX executables and shell') + + def _write_stub(directory, name, body): path = os.path.join(directory, name) with open(path, 'w') as f: @@ -55,6 +59,7 @@ def test_defsym_extraction_dedupes_preserving_first_seen_order(self): class LinkCommand(unittest.TestCase): + @posix_only def test_asks_ninja_for_only_the_elfs_final_command(self): # -s: without it, a target's commands include helper executables' links # (pico-sdk's boot_stage2) whose linker scripts are not the elf's @@ -145,6 +150,7 @@ def test_no_key_leaves_the_argv_alone(self): ['membrowse', 'report', 'x']) +@posix_only class ReportCli(unittest.TestCase): """End-to-end CLI tests: real `python3 tools/membrowse_cli.py report` subprocess, with a stub `membrowse` injected into PATH so the real tool is never invoked.""" @@ -327,6 +333,7 @@ def test_explicit_commits_omit_mutually_exclusive_count(self): cmd = cli.compose('b', 'device/x', 5, False, 'k', ['--commits', 'a b']) self.assertNotIn('5', cmd[:4]) + @posix_only def test_each_historical_build_fetches_its_own_deps_and_linker_settings(self): cmd = cli.compose('b', 'device/x', 5, False, 'k', []) i = cmd.index('--ld-scripts') @@ -350,6 +357,7 @@ def test_uses_each_builds_scripts_and_defsyms(self): with open(out) as f: self.assertEqual(f.read(), 'FLASH_SIZE = 256K;\nINCLUDE "/tree/board.ld"\n') + @posix_only def test_explicit_scripts_are_resolved_from_the_build_dir(self): with tempfile.TemporaryDirectory() as tmp: build = os.path.join(tmp, 'build') @@ -368,6 +376,7 @@ def test_explicit_scripts_are_resolved_from_the_build_dir(self): f'INCLUDE "{script_dir}/memory.ld"\n' f'INCLUDE "{script_dir}/sections.ld"\n') + @posix_only def test_subcommand_runs_as_onboards_build_script_calls_it(self): # the historical build script runs ` linker-shim ninja ...` with tempfile.TemporaryDirectory() as tmp: @@ -390,6 +399,7 @@ def test_subcommand_is_hidden_from_help(self): self.assertNotIn('linker-shim', r.stdout) +@posix_only class DisposableWorktree(unittest.TestCase): """`membrowse onboard` checks out and `git clean -fdx`s every historical commit unconditionally, in whatever directory it runs (membrowse/utils/ diff --git a/tools/code_size.py b/tools/code_size.py index adaed78e0a..6fc256c886 100644 --- a/tools/code_size.py +++ b/tools/code_size.py @@ -971,7 +971,10 @@ def build_error(ret, src_dir): if line.strip() and not _NINJA_PROGRESS.match(line) and not line.startswith(('ninja: build stopped', 'ninja: Entering directory', 'FAILED: '))] error = build_utils.first_error(out) or (lines[-1] if lines else 'no output') - return error.replace(src_dir.rstrip(os.sep) + os.sep, '') + root = src_dir.rstrip(os.sep) + for prefix in {root + os.sep, root.replace(os.sep, '/') + '/'}: # Windows: CMake passes the compiler D:/a/... + error = error.replace(prefix, '') + return error ESP_IDF_IMAGE = 'espressif/idf:tinyusb' # .github/actions/setup_toolchain/espressif's tag From 965e130fbfe410bbd3934d025d969812ef9b5b4a Mon Sep 17 00:00:00 2001 From: hathach Date: Sat, 3 Oct 2026 01:02:27 +0700 Subject: [PATCH 04/18] code_size: normalize Windows filters once at entry, trim the Windows smoke job --- .github/workflows/code_size_windows.yml | 30 ++++++++++++------------- test/hil/test/test_code_size.py | 3 ++- tools/code_size.py | 16 +++++++------ 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/.github/workflows/code_size_windows.yml b/.github/workflows/code_size_windows.yml index b80fd3c03b..adabe7db7c 100644 --- a/.github/workflows/code_size_windows.yml +++ b/.github/workflows/code_size_windows.yml @@ -19,6 +19,10 @@ permissions: contents: read actions: read +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} + env: PYTHONUTF8: '1' @@ -27,8 +31,10 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v6 - - run: python -m pip install pytest pyyaml pyelftools - - run: python -m pytest -q test/hil/test/test_code_size.py test/hil/test/test_code_size_ci.py test/hil/test/test_membrowse_cli.py + - run: python -m pip install pyyaml pyelftools + # as pre-commit's unit-test hook runs them + - run: python -m unittest -q test_code_size test_code_size_ci test_membrowse_cli + working-directory: test/hil/test smoke: runs-on: windows-latest @@ -53,29 +59,23 @@ jobs: - name: Get Dependencies uses: ./.github/actions/get_deps with: - arg: '-b stm32f407disco' + arg: '-b ${{ env.BOARD }}' - name: Install membrowse run: 'python -m pip install --only-binary :all: membrowse==1.2.12' + # report exits 1 unless complete with TinyUSB files matched - name: Report - run: | - python tools/code_size.py report -b $BOARD -e $EXAMPLE --json - python - <<'EOF' - import json - data = json.load(open('cmake-code-size/stm32f407disco/report_device_cdc_msc.json')) - files = {f for e in data['elfs'] for f in e['sizes']['files']} - print(data['status'], sorted(files)) - assert data['status'] == 'complete' and any(f.endswith('usbd.c') for f in files), 'no TinyUSB files' - EOF + run: python tools/code_size.py report -b $BOARD -e $EXAMPLE - name: Diff against a local base run: | python tools/code_size.py diff -b $BOARD -e $EXAMPLE --base-source local --base-branch HEAD --json python - <<'EOF' - import json - data = json.load(open('cmake-code-size/stm32f407disco/diff_device_cdc_msc.json')) - assert data['status'] == 'complete' and data['pairs'], data['status'] + import json, os + path = f"cmake-code-size/{os.environ['BOARD']}/diff_{os.environ['EXAMPLE'].replace('/', '_')}.json" + data = json.load(open(path)) + assert data['status'] == 'complete', data['status'] assert all(p['base']['files'] == p['current']['files'] for p in data['pairs']), 'HEAD vs HEAD changed' EOF diff --git a/test/hil/test/test_code_size.py b/test/hil/test/test_code_size.py index 1111951e29..66f753e639 100644 --- a/test/hil/test/test_code_size.py +++ b/test/hil/test/test_code_size.py @@ -1264,7 +1264,8 @@ def test_the_root_matches_its_own_filter_through_a_symlink(self): class WindowsHost(unittest.TestCase): def test_a_backslash_object_path_matches_the_forward_slash_filter(self): for f in ('/a/tinyusb/src/', '\\a\\tinyusb\\src\\'): - self.assertEqual(sd._relative_key(r'cdc_msc.dir\D_\a\tinyusb\src\device\usbd.c.obj', [f]), 'device/usbd.c') + self.assertEqual(sd._relative_key(r'cdc_msc.dir\D_\a\tinyusb\src\device\usbd.c.obj', [sd.filter_arg(f)]), + 'device/usbd.c') def test_espressif_boards_are_refused(self): self.assertRegex(sd.esp_without_idf(['espressif_s3_devkitc', 'stm32f407disco']), diff --git a/tools/code_size.py b/tools/code_size.py index 6fc256c886..e318793f59 100644 --- a/tools/code_size.py +++ b/tools/code_size.py @@ -63,8 +63,7 @@ WINDOWS = os.name == 'nt' # a master run's snapshots are the same for every checkout; XDG ignores a relative XDG_CACHE_HOME _cache_home = os.environ.get('LOCALAPPDATA' if WINDOWS else 'XDG_CACHE_HOME', '') -BASELINE_CACHE_DIR = os.path.join(_cache_home if os.path.isabs(_cache_home) else - os.path.expanduser('~/AppData/Local' if WINDOWS else '~/.cache'), +BASELINE_CACHE_DIR = os.path.join(_cache_home if os.path.isabs(_cache_home) else os.path.expanduser('~/.cache'), 'tinyusb', 'code-size-baseline') CI_PINNED_BOARDS = os.path.join(TINYUSB_ROOT, '.github', 'ci-pinned-boards.json') # a diff's side names when git cannot give their commit hashes @@ -135,7 +134,6 @@ def _relative_key(src, filters): or None when no filter matches.""" if WINDOWS: src = src.replace('\\', '/') - filters = [f.replace('\\', '/') for f in filters] for f in filters: idx = src.find(f) if idx < 0: @@ -841,8 +839,12 @@ def tinyusb_src_filter(checkout_dir): in `checkout_dir`. The substring is the absolute path to the checkout's `src/` dir — collision-free with vendored deps (pico-sdk, lwip, FreeRTOS, etc.) which live at unrelated paths.""" - path = os.path.realpath(os.path.join(checkout_dir, 'src')) + os.sep - # Windows: CMake names an out-of-tree object D_/a/.../src/x.c.obj and DWARF D:/a/...: match past the drive + return filter_arg(os.path.realpath(os.path.join(checkout_dir, 'src')) + os.sep) + + +def filter_arg(path): + """A path filter in the form sizes are matched in: on Windows, past the drive with '/', + as CMake names an out-of-tree object D_/a/.../src/x.c.obj and DWARF D:/a/...""" return os.path.splitdrive(path)[1].replace('\\', '/') if WINDOWS else path @@ -1856,7 +1858,7 @@ def main(): common = argparse.ArgumentParser(add_help=False) common.add_argument('-b', '--board', action='append', default=[], help='Board name (repeatable). Required unless diff --ci is given.') - common.add_argument('-f', '--filter', action='append', default=None, + common.add_argument('-f', '--filter', action='append', default=None, type=filter_arg, help='Path-substring filter (repeatable). When given, ' 'overrides the default and is applied to every build. ' 'Default: each build\'s own absolute /src/ path, ' @@ -1919,7 +1921,7 @@ def main(): help='CI event; pull_request reads base/head from the merge commit ' '(default: $GITHUB_EVENT_NAME, else push)') snap.add_argument('--build-outcome', default='success', help='The Build step\'s outcome, recorded as is') - snap.add_argument('-f', '--filter', action='append', default=None, + snap.add_argument('-f', '--filter', action='append', default=None, type=filter_arg, help='Path-substring filter (repeatable); default: this checkout\'s /src/') snap.add_argument('--symbols', action='store_true', help='Include each file\'s symbols') snap.add_argument('-v', '--verbose', action='store_true', help='Print commands') From bd03114bbd0d5a7745db8d09b2dc592796c4c920 Mon Sep 17 00:00:00 2001 From: hathach Date: Sat, 3 Oct 2026 01:09:51 +0700 Subject: [PATCH 05/18] test_code_size: stop a real Windows grandchild on timeout --- test/hil/test/test_code_size.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/hil/test/test_code_size.py b/test/hil/test/test_code_size.py index 66f753e639..08f304ed8a 100644 --- a/test/hil/test/test_code_size.py +++ b/test/hil/test/test_code_size.py @@ -1279,6 +1279,28 @@ def test_a_timeout_kills_the_command_tree(self): argv = taskkill.call_args.args[0] self.assertEqual(argv[:2] + argv[3:], ['taskkill', '/PID', '/T', '/F']) + @unittest.skipUnless(os.name == 'nt', 'a real Windows process tree') + def test_a_timeout_stops_a_grandchild_holding_the_pipes(self): + # as ninja's compilers do: terminate() alone stops only the direct child + parent = ('import subprocess, sys, time; ' + 'p = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"], ' + 'stdout=sys.stdout, stderr=sys.stderr); ' + 'print(p.pid, flush=True); time.sleep(60)') + start = time.monotonic() + ret = sd.run([sys.executable, '-c', parent], timeout=5) + elapsed = time.monotonic() - start + pid = ret.stdout.strip() + try: + self.assertEqual(ret.returncode, 124) + self.assertTrue(pid.isdigit(), ret.stdout) + tasks = subprocess.run(['tasklist', '/FI', f'PID eq {pid}', '/FO', 'CSV', '/NH'], + capture_output=True, text=True, check=True, timeout=10).stdout + self.assertNotIn(f'"{pid}"', tasks) + self.assertLess(elapsed, 30) # well before the grandchild's own exit + finally: + if pid.isdigit(): + subprocess.run(['taskkill', '/PID', pid, '/F'], capture_output=True) + def test_a_symlink_without_the_privilege_names_developer_mode(self): denied = OSError(22, 'A required privilege is not held by the client') denied.winerror = 1314 From a10770fdf824d2b2b1b9d0d72a720b6cf8721b3f Mon Sep 17 00:00:00 2001 From: hathach Date: Sat, 3 Oct 2026 01:45:46 +0700 Subject: [PATCH 06/18] ci: run the Windows code-size job on master pushes and PRs only --- .github/workflows/code_size_windows.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/code_size_windows.yml b/.github/workflows/code_size_windows.yml index adabe7db7c..be140b3bc6 100644 --- a/.github/workflows/code_size_windows.yml +++ b/.github/workflows/code_size_windows.yml @@ -12,7 +12,7 @@ on: - '.github/scripts/code_size_ci.py' - '.github/workflows/code_size_windows.yml' push: - branches: [ master, claude/code-size-windows ] + branches: [ master ] paths: *code_size_paths permissions: From 846f40d199cc9da1ab6538101f943d66485fd0d6 Mon Sep 17 00:00:00 2001 From: hathach Date: Sat, 3 Oct 2026 01:48:30 +0700 Subject: [PATCH 07/18] code-size skill: document running on Windows --- .claude/skills/code-size/SKILL.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/skills/code-size/SKILL.md b/.claude/skills/code-size/SKILL.md index c340a4c094..46d45784e4 100644 --- a/.claude/skills/code-size/SKILL.md +++ b/.claude/skills/code-size/SKILL.md @@ -14,7 +14,7 @@ description: Use when sizing TinyUSB examples per file, section or symbol (repor | all examples, CI-pinned, combined | `python3 tools/code_size.py diff --ci` | | one tree, no diff | `python3 tools/code_size.py report -b BOARD -e device/cdc_msc` | -By default the base comes from the master snapshots CI stored for `--base-branch`'s commit (`--baseline-repo`, default `hathach/tinyusb`, through your `gh` login; cached per master run in `${XDG_CACHE_HOME:-~/.cache}/tinyusb/code-size-baseline/`, shared by every checkout), else its nearest first-parent ancestor's; the `Base:` line names the commit used and how far back it is, and master changes after it count as yours. `--base-branch` means its tip; pass a merge-base SHA to diff against that. When the CI base cannot be had (no `gh` or login, no stored run within 30 commits, a base off master, a failed download) or `-f`, `--bloaty` or another `--engine` needs an elf, the base is built locally in a temporary worktree and the report's `Base:` line says why; `--base-source ci` fails instead, `--base-source local` always builds and makes no GitHub call. Against a CI base, a board whose compiler or membrowse version differs from yours, or is unknown, is still compared with a warning naming both. A local diff never builds CI's `-DMA` variants; DMA-path changes are measured only by the PR's CI comment. A local base worktree symlinks this checkout's fetched dependencies, so a `tools/get_deps.py` pin bump's own size change is not in its diff, while a CI base was built with CI's. A local `report` or `diff` cannot size a `-D` variant build. +By default the base comes from the master snapshots CI stored for `--base-branch`'s commit (`--baseline-repo`, default `hathach/tinyusb`, through your `gh` login; cached per master run in `${XDG_CACHE_HOME:-~/.cache}/tinyusb/code-size-baseline/` (`%LOCALAPPDATA%` on Windows), shared by every checkout), else its nearest first-parent ancestor's; the `Base:` line names the commit used and how far back it is, and master changes after it count as yours. `--base-branch` means its tip; pass a merge-base SHA to diff against that. When the CI base cannot be had (no `gh` or login, no stored run within 30 commits, a base off master, a failed download) or `-f`, `--bloaty` or another `--engine` needs an elf, the base is built locally in a temporary worktree and the report's `Base:` line says why; `--base-source ci` fails instead, `--base-source local` always builds and makes no GitHub call. Against a CI base, a board whose compiler or membrowse version differs from yours, or is unknown, is still compared with a warning naming both. A local diff never builds CI's `-DMA` variants; DMA-path changes are measured only by the PR's CI comment. A local base worktree symlinks this checkout's fetched dependencies, so a `tools/get_deps.py` pin bump's own size change is not in its diff, while a CI base was built with CI's. A local `report` or `diff` cannot size a `-D` variant build. ## Setup and choices @@ -25,6 +25,7 @@ Flags and syntax: `python3 tools/code_size.py --help`. The choices: - `--symbols` when Flash/RAM cancel: a pair counts as changed when its sections or symbols moved. - `--ci` for "all boards" / "CI"; it needs the arm, riscv, msp430 and ft9xx toolchains, and ESP-IDF or docker. - An espressif board builds each example `tools/build.py` picks as its own ESP-IDF project: with the exported ESP-IDF (`. "$IDF_PATH/export.sh"`), else in CI's image through docker, as your user (once: `docker pull espressif/idf:v5.5.3 && docker tag espressif/idf:v5.5.3 espressif/idf:tinyusb`, about 8.4 GB). +- Windows: run `python -X utf8 tools/code_size.py …` (or set `PYTHONUTF8=1`), else redirected output fails on `Δ`. `report` and `diff` work for paths without spaces; espressif boards are refused. A local base, including the fallback, symlinks its dependencies: enable Developer Mode unless running elevated. ## Results From 0b86154996c007e2f39c2aee6cb8fea4a3a67f4e Mon Sep 17 00:00:00 2001 From: hathach Date: Sat, 3 Oct 2026 23:50:39 +0700 Subject: [PATCH 08/18] code_size: match filters case-insensitively on Windows --- test/hil/test/test_code_size.py | 4 ++++ tools/code_size.py | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/test/hil/test/test_code_size.py b/test/hil/test/test_code_size.py index 08f304ed8a..3120e591d5 100644 --- a/test/hil/test/test_code_size.py +++ b/test/hil/test/test_code_size.py @@ -1267,6 +1267,10 @@ def test_a_backslash_object_path_matches_the_forward_slash_filter(self): self.assertEqual(sd._relative_key(r'cdc_msc.dir\D_\a\tinyusb\src\device\usbd.c.obj', [sd.filter_arg(f)]), 'device/usbd.c') + def test_a_filter_matches_regardless_of_case_and_the_key_keeps_the_recorded_case(self): + self.assertEqual(sd._relative_key(r'cdc_msc.dir\D_\A\TinyUSB\src\Device\usbd.c.obj', + ['/nomatch/', '/a/tinyusb/src/']), 'Device/usbd.c') + def test_espressif_boards_are_refused(self): self.assertRegex(sd.esp_without_idf(['espressif_s3_devkitc', 'stm32f407disco']), r'^espressif_s3_devkitc need ESP-IDF, .* not support on Windows') diff --git a/tools/code_size.py b/tools/code_size.py index e318793f59..f44a08fac7 100644 --- a/tools/code_size.py +++ b/tools/code_size.py @@ -135,10 +135,11 @@ def _relative_key(src, filters): if WINDOWS: src = src.replace('\\', '/') for f in filters: - idx = src.find(f) - if idx < 0: + # Windows paths are case-insensitive; the key keeps the recorded case + m = re.search(re.escape(f), src, re.IGNORECASE if WINDOWS else 0) + if not m: continue - key = src[idx + len(f):] + key = src[m.end():] for suffix in ('.obj', '.o'): if key.endswith(suffix): return key[:-len(suffix)] From 030f5bf195b547ca319d9ef76193d59e9a830ce1 Mon Sep 17 00:00:00 2001 From: hathach Date: Sun, 4 Oct 2026 00:12:23 +0700 Subject: [PATCH 09/18] code_size: name a Windows compiler without .exe --- test/hil/test/test_code_size.py | 7 +++++++ tools/code_size.py | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/test/hil/test/test_code_size.py b/test/hil/test/test_code_size.py index 3120e591d5..4a552d1fe5 100644 --- a/test/hil/test/test_code_size.py +++ b/test/hil/test/test_code_size.py @@ -2090,6 +2090,13 @@ def test_compiler_comes_from_cmake_not_the_host(self): 'name': 'arm-none-eabi-gcc', 'build_type': 'MinSizeRel'}) self.assertEqual(sd._cmake_compiler('/nonexistent'), {'id': '', 'version': '', 'name': '', 'build_type': ''}) + def test_a_windows_compiler_is_named_without_exe(self): + with tempfile.TemporaryDirectory() as tmp: + os.makedirs(os.path.join(tmp, 'CMakeFiles', '4.1.2')) + with open(os.path.join(tmp, 'CMakeFiles', '4.1.2', 'CMakeCCompiler.cmake'), 'w') as f: + f.write('set(CMAKE_C_COMPILER "C:/arm/bin/arm-none-eabi-gcc.EXE")\n') + self.assertEqual(sd._cmake_compiler(tmp)['name'], 'arm-none-eabi-gcc') + def test_an_esp_idf_board_reads_its_first_example_project(self): with tempfile.TemporaryDirectory() as tmp: for ex, ver in (('device/b', '14.2.0'), ('device/a', '14.2.0'), ('device/a/bootloader', '1.0')): diff --git a/tools/code_size.py b/tools/code_size.py index f44a08fac7..1159d3e1a7 100644 --- a/tools/code_size.py +++ b/tools/code_size.py @@ -1319,7 +1319,9 @@ def _cmake_compiler(build_dir): ('name', 'CMAKE_C_COMPILER')): m = re.search(rf'^set\({var} "([^"]*)"\)', text, re.M) if m: - info[key] = os.path.basename(m.group(1)) if key == 'name' else m.group(1) + info[key] = m.group(1) + # without .exe, so a Windows build's name matches a Linux snapshot's + info['name'] = re.sub(r'\.exe$', '', os.path.basename(info['name']), flags=re.I) cache = os.path.join(build_dir, 'CMakeCache.txt') if os.path.isfile(cache): with open(cache) as f: From 39b98961dc11faa91ddab4d7ca119a47a4f5474d Mon Sep 17 00:00:00 2001 From: hathach Date: Sun, 4 Oct 2026 00:14:14 +0700 Subject: [PATCH 10/18] code_size: fail on a CMake-shortened object path on Windows --- .claude/skills/code-size/SKILL.md | 2 +- test/hil/test/test_code_size.py | 7 +++++++ tools/code_size.py | 9 +++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.claude/skills/code-size/SKILL.md b/.claude/skills/code-size/SKILL.md index 46d45784e4..7342da6732 100644 --- a/.claude/skills/code-size/SKILL.md +++ b/.claude/skills/code-size/SKILL.md @@ -25,7 +25,7 @@ Flags and syntax: `python3 tools/code_size.py --help`. The choices: - `--symbols` when Flash/RAM cancel: a pair counts as changed when its sections or symbols moved. - `--ci` for "all boards" / "CI"; it needs the arm, riscv, msp430 and ft9xx toolchains, and ESP-IDF or docker. - An espressif board builds each example `tools/build.py` picks as its own ESP-IDF project: with the exported ESP-IDF (`. "$IDF_PATH/export.sh"`), else in CI's image through docker, as your user (once: `docker pull espressif/idf:v5.5.3 && docker tag espressif/idf:v5.5.3 espressif/idf:tinyusb`, about 8.4 GB). -- Windows: run `python -X utf8 tools/code_size.py …` (or set `PYTHONUTF8=1`), else redirected output fails on `Δ`. `report` and `diff` work for paths without spaces; espressif boards are refused. A local base, including the fallback, symlinks its dependencies: enable Developer Mode unless running elevated. +- Windows: run `python -X utf8 tools/code_size.py …` (or set `PYTHONUTF8=1`), else redirected output fails on `Δ`. `report` and `diff` work for paths without spaces; espressif boards are refused. A local base, including the fallback, symlinks its dependencies: enable Developer Mode unless running elevated. A deep checkout path, deeper still for a local base built under `cmake-code-size/_worktree`, can push object paths past CMake's 250-character limit, which fails sizing; use a shorter checkout path. ## Results diff --git a/test/hil/test/test_code_size.py b/test/hil/test/test_code_size.py index 4a552d1fe5..a6d74f85ac 100644 --- a/test/hil/test/test_code_size.py +++ b/test/hil/test/test_code_size.py @@ -1271,6 +1271,13 @@ def test_a_filter_matches_regardless_of_case_and_the_key_keeps_the_recorded_case self.assertEqual(sd._relative_key(r'cdc_msc.dir\D_\A\TinyUSB\src\Device\usbd.c.obj', ['/nomatch/', '/a/tinyusb/src/']), 'Device/usbd.c') + def test_an_md5_shortened_object_path_fails_naming_the_cmake_limit(self): + shortened = r'cdc_msc.dir\0123456789abcdef0123456789abcdef\claude\cs\src\device\usbd.c.obj' + with self.assertRaisesRegex(RuntimeError, 'CMAKE_OBJECT_PATH_MAX .* shorter checkout path'): + sd._Sizes(['/Users/u/tinyusb/.worktrees/claude/cs/src/']).add(shortened, '.text', {'flash'}, 4, 'f') + with mock.patch.object(sd, 'WINDOWS', new=False): + sd._Sizes(['/nomatch/']).add(shortened.replace('\\', '/'), '.text', {'flash'}, 4, 'f') + def test_espressif_boards_are_refused(self): self.assertRegex(sd.esp_without_idf(['espressif_s3_devkitc', 'stm32f407disco']), r'^espressif_s3_devkitc need ESP-IDF, .* not support on Windows') diff --git a/tools/code_size.py b/tools/code_size.py index 1159d3e1a7..46342fc71d 100644 --- a/tools/code_size.py +++ b/tools/code_size.py @@ -272,6 +272,11 @@ def _esp_idf_buckets(sections): return buckets +# CMake replaces the start of an over-long object name with its md5 (cmLocalGenerator.cxx, +# cmLocalGeneratorShortenObjectName): .dir//.obj +_SHORTENED_OBJECT_RE = re.compile(r'\.dir/[0-9a-f]{32}/') + + class _Sizes: """Accumulates one elf's filtered per-file flash/RAM, section and symbol sizes, and its total flash/RAM.""" @@ -281,6 +286,10 @@ def __init__(self, filters): def add(self, path, section, buckets, size, name): key = _relative_key(path, self.filters) if path else None + if key is None and path and WINDOWS and _SHORTENED_OBJECT_RE.search(path.replace('\\', '/')): + raise RuntimeError(f'{path}: CMake shortened this object path to fit CMAKE_OBJECT_PATH_MAX ' + '(250 on Windows), hiding its source dir from the filters - ' + 'use a shorter checkout path') for b in buckets: self.all[b] += size if key is not None: From ebad350c0b9d673ede9fb19d581a251c664ee57a Mon Sep 17 00:00:00 2001 From: hathach Date: Sun, 4 Oct 2026 00:16:05 +0700 Subject: [PATCH 11/18] code_size: accept a backslash example on Windows --- test/hil/test/test_code_size.py | 8 ++++++++ tools/code_size.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/test/hil/test/test_code_size.py b/test/hil/test/test_code_size.py index a6d74f85ac..5c43faf46f 100644 --- a/test/hil/test/test_code_size.py +++ b/test/hil/test/test_code_size.py @@ -10,6 +10,7 @@ import functools import io import json +import ntpath import os import re import shutil @@ -1267,6 +1268,13 @@ def test_a_backslash_object_path_matches_the_forward_slash_filter(self): self.assertEqual(sd._relative_key(r'cdc_msc.dir\D_\a\tinyusb\src\device\usbd.c.obj', [sd.filter_arg(f)]), 'device/usbd.c') + def test_a_drive_is_stripped_from_a_filter(self): + with mock.patch.object(sd.os, 'path', ntpath): + self.assertEqual(sd.filter_arg('D:\\a\\tinyusb\\src\\'), '/a/tinyusb/src/') + + def test_a_backslash_example_names_the_forward_slash_elf_id(self): + self.assertEqual(sd.example_arg('device\\cdc_msc\\'), 'device/cdc_msc') + def test_a_filter_matches_regardless_of_case_and_the_key_keeps_the_recorded_case(self): self.assertEqual(sd._relative_key(r'cdc_msc.dir\D_\A\TinyUSB\src\Device\usbd.c.obj', ['/nomatch/', '/a/tinyusb/src/']), 'Device/usbd.c') diff --git a/tools/code_size.py b/tools/code_size.py index 46342fc71d..d957c779e5 100644 --- a/tools/code_size.py +++ b/tools/code_size.py @@ -1858,7 +1858,7 @@ def show(c): def example_arg(value): - example = value.rstrip('/') + example = (value.replace('\\', '/') if WINDOWS else value).rstrip('/') if not example: raise argparse.ArgumentTypeError(f'{value!r} names no example') return example From 7aa7241f805391678b2e6fd92dba0743ddd5a10c Mon Sep 17 00:00:00 2001 From: hathach Date: Sun, 4 Oct 2026 00:17:35 +0700 Subject: [PATCH 12/18] test_ci_select: assert the Windows code-size workflow is metadata --- test/hil/test/test_ci_select.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/hil/test/test_ci_select.py b/test/hil/test/test_ci_select.py index 35e7a8e59e..d23add521a 100644 --- a/test/hil/test/test_ci_select.py +++ b/test/hil/test/test_ci_select.py @@ -1875,7 +1875,8 @@ def test_repo_metadata_is_not_a_build_input(self): '.clang-format', '.idea/misc.xml', 'version.yml', 'library.json', 'examples/CMakePresets.json', 'test/fuzz/fuzz.cc', 'test/unit-test/project.yml', '.github/workflows/pr_comment.yml', - '.github/scripts/code_size_ci.py', '.claude/skills/build-doc/scripts/gen_doc.py', '.agents'): + '.github/workflows/code_size_windows.yml', '.github/scripts/code_size_ci.py', + '.claude/skills/build-doc/scripts/gen_doc.py', '.agents'): s = self.b([p]) self.assertFalse(s['full'], p) self.assertEqual(s['families'], [], p) From 8fdedadab45e9c3d3ed79c63f666b020f08b90b6 Mon Sep 17 00:00:00 2001 From: hathach Date: Sun, 4 Oct 2026 00:33:31 +0700 Subject: [PATCH 13/18] code_size: resolve a CMake-shortened object's source through build.ninja --- .claude/skills/code-size/SKILL.md | 2 +- test/hil/test/test_code_size.py | 74 ++++++++++++++++++++++++++++--- tools/code_size.py | 40 +++++++++++++---- 3 files changed, 100 insertions(+), 16 deletions(-) diff --git a/.claude/skills/code-size/SKILL.md b/.claude/skills/code-size/SKILL.md index 7342da6732..46d45784e4 100644 --- a/.claude/skills/code-size/SKILL.md +++ b/.claude/skills/code-size/SKILL.md @@ -25,7 +25,7 @@ Flags and syntax: `python3 tools/code_size.py --help`. The choices: - `--symbols` when Flash/RAM cancel: a pair counts as changed when its sections or symbols moved. - `--ci` for "all boards" / "CI"; it needs the arm, riscv, msp430 and ft9xx toolchains, and ESP-IDF or docker. - An espressif board builds each example `tools/build.py` picks as its own ESP-IDF project: with the exported ESP-IDF (`. "$IDF_PATH/export.sh"`), else in CI's image through docker, as your user (once: `docker pull espressif/idf:v5.5.3 && docker tag espressif/idf:v5.5.3 espressif/idf:tinyusb`, about 8.4 GB). -- Windows: run `python -X utf8 tools/code_size.py …` (or set `PYTHONUTF8=1`), else redirected output fails on `Δ`. `report` and `diff` work for paths without spaces; espressif boards are refused. A local base, including the fallback, symlinks its dependencies: enable Developer Mode unless running elevated. A deep checkout path, deeper still for a local base built under `cmake-code-size/_worktree`, can push object paths past CMake's 250-character limit, which fails sizing; use a shorter checkout path. +- Windows: run `python -X utf8 tools/code_size.py …` (or set `PYTHONUTF8=1`), else redirected output fails on `Δ`. `report` and `diff` work for paths without spaces; espressif boards are refused. A local base, including the fallback, symlinks its dependencies: enable Developer Mode unless running elevated. ## Results diff --git a/test/hil/test/test_code_size.py b/test/hil/test/test_code_size.py index 5c43faf46f..8852fd71b4 100644 --- a/test/hil/test/test_code_size.py +++ b/test/hil/test/test_code_size.py @@ -8,6 +8,7 @@ import contextlib import errno import functools +import hashlib import io import json import ntpath @@ -1261,6 +1262,72 @@ def test_the_root_matches_its_own_filter_through_a_symlink(self): self.assertEqual(out, 'True\n') +def cmake_shortened(obj_dir, obj_name): + """CMake v4.1.2 cmLocalGeneratorCheckObjectName and cmLocalGeneratorShortenObjectName + (Source/cmLocalGenerator.cxx) at Windows' CMAKE_OBJECT_PATH_MAX of 250.""" + max_len = 250 - len(obj_dir) + if len(obj_name) <= max_len: + return obj_name + pos = obj_name.find('/', len(obj_name) - max_len + 32) + return hashlib.md5(obj_name[:pos].encode()).hexdigest() + obj_name[pos:] + + +@mock.patch.object(sd, 'WINDOWS', new=True) +class CMakeShortenedObject(unittest.TestCase): + CHECKOUT = 'C:/Users/username/code/tinyusb/.worktrees/claude/code-size-windows/cmake-code-size/_worktree' + BUILD = 'C:/Users/username/code/tinyusb/.worktrees/claude/code-size-windows/cmake-code-size/base/' + TARGET = 'device/audio_4_channel_mic_freertos/CMakeFiles/audio_4_channel_mic_freertos.dir/' + FILTER = CHECKOUT[2:] + '/src/' + USBD = CHECKOUT + '/src/device/usbd.c' + STARTUP = CHECKOUT + '/hw/mcu/st/cmsis_device_f4/Source/Templates/gcc/startup_stm32f407xx.s' + + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.build_dir = tmp.name + self.elf = os.path.join(tmp.name, 'device', 'audio_4_channel_mic_freertos', 'audio_4_channel_mic_freertos.elf') + + def obj(self, source): + """The map path of source's object, and its build.ninja compile edge.""" + obj = self.TARGET + cmake_shortened(self.BUILD + self.TARGET, source.replace(':', '_') + '.obj') + self.assertRegex(obj, r'\.dir/[0-9a-f]{32}/') + self.assertNotIn(self.FILTER, obj) + edge = f'build {obj}: C_COMPILER__audio_4_channel_mic_freertos_unscanned_MinSizeRel {source.replace(":", "$:")}' \ + ' || cmake_object_order_depends_target_audio_4_channel_mic_freertos\n' + return obj.replace('/', '\\'), edge + + def sizes(self, edges): + with open(os.path.join(self.build_dir, 'build.ninja'), 'w') as f: + f.writelines(edges) + return sd._Sizes([self.FILTER], self.elf) + + def test_a_shortened_vendor_object_counts_in_all_only(self): + obj, edge = self.obj(self.STARTUP) + sizes = self.sizes([edge]) + sizes.add(obj, '.isr_vector', {'flash'}, 4, 'g_pfnVectors') + self.assertEqual(sizes.result()['files'], {}) + self.assertEqual(sizes.result()['all'], {'flash': 4, 'ram': 0}) + + def test_a_shortened_tinyusb_object_keeps_its_source_key(self): + obj, edge = self.obj(self.USBD) + sizes = self.sizes([edge]) + sizes.add(obj, '.text', {'flash'}, 4, 'tud_task_ext') + self.assertEqual(sizes.result()['files'], {'device/usbd.c': {'flash': 4, 'ram': 0}}) + + def test_a_shortened_object_build_ninja_does_not_build_fails(self): + obj, _ = self.obj(self.USBD) + _, edge = self.obj(self.STARTUP) + with self.assertRaisesRegex(RuntimeError, 'CMAKE_OBJECT_PATH_MAX .* no compile edge'): + self.sizes([edge]).add(obj, '.text', {'flash'}, 4, 'tud_task_ext') + + def test_linux_does_not_resolve_a_shortened_object(self): + obj, _ = self.obj(self.USBD) + with mock.patch.object(sd, 'WINDOWS', new=False): + sizes = self.sizes([]) + sizes.add(obj.replace('\\', '/'), '.text', {'flash'}, 4, 'tud_task_ext') + self.assertEqual(sizes.result()['files'], {}) + + @mock.patch.object(sd, 'WINDOWS', new=True) class WindowsHost(unittest.TestCase): def test_a_backslash_object_path_matches_the_forward_slash_filter(self): @@ -1279,13 +1346,6 @@ def test_a_filter_matches_regardless_of_case_and_the_key_keeps_the_recorded_case self.assertEqual(sd._relative_key(r'cdc_msc.dir\D_\A\TinyUSB\src\Device\usbd.c.obj', ['/nomatch/', '/a/tinyusb/src/']), 'Device/usbd.c') - def test_an_md5_shortened_object_path_fails_naming_the_cmake_limit(self): - shortened = r'cdc_msc.dir\0123456789abcdef0123456789abcdef\claude\cs\src\device\usbd.c.obj' - with self.assertRaisesRegex(RuntimeError, 'CMAKE_OBJECT_PATH_MAX .* shorter checkout path'): - sd._Sizes(['/Users/u/tinyusb/.worktrees/claude/cs/src/']).add(shortened, '.text', {'flash'}, 4, 'f') - with mock.patch.object(sd, 'WINDOWS', new=False): - sd._Sizes(['/nomatch/']).add(shortened.replace('\\', '/'), '.text', {'flash'}, 4, 'f') - def test_espressif_boards_are_refused(self): self.assertRegex(sd.esp_without_idf(['espressif_s3_devkitc', 'stm32f407disco']), r'^espressif_s3_devkitc need ESP-IDF, .* not support on Windows') diff --git a/tools/code_size.py b/tools/code_size.py index d957c779e5..9481239789 100644 --- a/tools/code_size.py +++ b/tools/code_size.py @@ -275,21 +275,45 @@ def _esp_idf_buckets(sections): # CMake replaces the start of an over-long object name with its md5 (cmLocalGenerator.cxx, # cmLocalGeneratorShortenObjectName): .dir//.obj _SHORTENED_OBJECT_RE = re.compile(r'\.dir/[0-9a-f]{32}/') +# first output, rule and first input of a build.ninja edge; '$' escapes a path's ':' and ' ' +_NINJA_EDGE_RE = re.compile(r'build ((?:[^$:\s]|\$.)+)(?: (?:[^$:]|\$.)*)?: \S+ ((?:[^$\s]|\$.)+)') + + +@functools.lru_cache(maxsize=None) +def _shortened_object_sources(build_dir): + """{shortened object: source} of the compile edges in build_dir's build.ninja.""" + sources = {} + with open(os.path.join(build_dir, 'build.ninja'), encoding='utf-8') as f: + for line in f: + m = _NINJA_EDGE_RE.match(line) + if m and _SHORTENED_OBJECT_RE.search(m[1]): + obj, src = (re.sub(r'\$(.)', r'\1', p).replace('\\', '/') for p in m.groups()) + sources[obj] = src + return sources class _Sizes: """Accumulates one elf's filtered per-file flash/RAM, section and symbol sizes, and its total flash/RAM.""" - def __init__(self, filters): - self.filters, self.files, self.all = filters, {}, {'flash': 0, 'ram': 0} + def __init__(self, filters, elf): + self.filters, self.elf, self.files, self.all = filters, elf, {}, {'flash': 0, 'ram': 0} self.sections, self.symbols = {}, {} + def _shortened_key(self, path): + """Key of the source build.ninja compiles to a CMake-shortened object; the object + path no longer holds the filtered dir.""" + build_dir = _find_ninja_build_dir(self.elf) + src = build_dir and _shortened_object_sources(build_dir).get(path.replace('\\', '/')) + if not src: + raise RuntimeError(f'{path}: CMake shortened this object path to fit CMAKE_OBJECT_PATH_MAX ' + f'(250 on Windows) and the build.ninja above {self.elf} has no compile edge ' + 'for it to name its source - use a shorter checkout path') + return _relative_key(src, self.filters) + def add(self, path, section, buckets, size, name): key = _relative_key(path, self.filters) if path else None if key is None and path and WINDOWS and _SHORTENED_OBJECT_RE.search(path.replace('\\', '/')): - raise RuntimeError(f'{path}: CMake shortened this object path to fit CMAKE_OBJECT_PATH_MAX ' - '(250 on Windows), hiding its source dir from the filters - ' - 'use a shorter checkout path') + key = self._shortened_key(path) for b in buckets: self.all[b] += size if key is not None: @@ -346,7 +370,7 @@ def membrowse_sizes(elf, filters): """Symbols of `membrowse report`; linker-defined symbols without a section (`__StackLimit`) are not counted.""" buckets = section_buckets(elf, _map_path(elf)) - sizes = _Sizes(filters) + sizes = _Sizes(filters, elf) for sym in report_for_elf(elf).get('symbols', []): section = sym.get('section') if not sym.get('size') or not section: @@ -385,7 +409,7 @@ def linkermap_sizes(elf, filters): sections = _linkermap().parseSections(f) if sections is None: raise RuntimeError(f'{map_path}: no Memory Configuration, not a GNU ld map') - sizes = _Sizes(filters) + sizes = _Sizes(filters, elf) for out in sections: if not out.children: # memory regions continue @@ -407,7 +431,7 @@ def bloaty_sizes(elf, filters): if not {'compileunits', 'sections', 'symbols', 'vmsize'} <= set(rows.fieldnames or ()): raise RuntimeError(f'unexpected bloaty csv columns for {elf}: {rows.fieldnames}') buckets = section_buckets(elf, _map_path(elf)) - sizes = _Sizes(filters) + sizes = _Sizes(filters, elf) for row in rows: section = row['sections'] try: From 44563dc00cd6ba9d51f9c5db2834998384311d68 Mon Sep 17 00:00:00 2001 From: hathach Date: Sun, 4 Oct 2026 00:47:42 +0700 Subject: [PATCH 14/18] code_size: fail an elf whose build.ninja cannot be read as UTF-8 --- test/hil/test/test_code_size.py | 8 ++++++++ tools/code_size.py | 17 +++++++++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/test/hil/test/test_code_size.py b/test/hil/test/test_code_size.py index 8852fd71b4..5731451aee 100644 --- a/test/hil/test/test_code_size.py +++ b/test/hil/test/test_code_size.py @@ -1320,6 +1320,14 @@ def test_a_shortened_object_build_ninja_does_not_build_fails(self): with self.assertRaisesRegex(RuntimeError, 'CMAKE_OBJECT_PATH_MAX .* no compile edge'): self.sizes([edge]).add(obj, '.text', {'flash'}, 4, 'tud_task_ext') + def test_a_build_ninja_not_in_utf_8_fails(self): + obj, edge = self.obj(self.USBD) + with open(os.path.join(self.build_dir, 'build.ninja'), 'wb') as f: + f.write(b'# Calf\xe9\n' + edge.encode()) + sizes = sd._Sizes([self.FILTER], self.elf) + with self.assertRaisesRegex(RuntimeError, r'build\.ninja.*utf-8'): + sizes.add(obj, '.text', {'flash'}, 4, 'tud_task_ext') + def test_linux_does_not_resolve_a_shortened_object(self): obj, _ = self.obj(self.USBD) with mock.patch.object(sd, 'WINDOWS', new=False): diff --git a/tools/code_size.py b/tools/code_size.py index 9481239789..4d66d8f36a 100644 --- a/tools/code_size.py +++ b/tools/code_size.py @@ -282,13 +282,18 @@ def _esp_idf_buckets(sections): @functools.lru_cache(maxsize=None) def _shortened_object_sources(build_dir): """{shortened object: source} of the compile edges in build_dir's build.ninja.""" + path = os.path.join(build_dir, 'build.ninja') sources = {} - with open(os.path.join(build_dir, 'build.ninja'), encoding='utf-8') as f: - for line in f: - m = _NINJA_EDGE_RE.match(line) - if m and _SHORTENED_OBJECT_RE.search(m[1]): - obj, src = (re.sub(r'\$(.)', r'\1', p).replace('\\', '/') for p in m.groups()) - sources[obj] = src + # CMake writes it in the ANSI code page unless ninja >= 1.11 reports UTF-8 (`ninja -t wincodepage`) + try: + with open(path, encoding='utf-8') as f: + for line in f: + m = _NINJA_EDGE_RE.match(line) + if m and _SHORTENED_OBJECT_RE.search(m[1]): + obj, src = (re.sub(r'\$(.)', r'\1', p).replace('\\', '/') for p in m.groups()) + sources[obj] = src + except (OSError, UnicodeDecodeError) as e: + raise RuntimeError(f'cannot read {path}: {e}') from e return sources From d2f623b71302b86187dbfa581be5e2ec168e43b6 Mon Sep 17 00:00:00 2001 From: hathach Date: Sun, 4 Oct 2026 01:50:35 +0700 Subject: [PATCH 15/18] code_size: name the ninja >= 1.11 remedy for a non-UTF-8 build.ninja --- .claude/skills/code-size/SKILL.md | 2 +- test/hil/test/test_code_size.py | 2 +- tools/code_size.py | 7 +++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.claude/skills/code-size/SKILL.md b/.claude/skills/code-size/SKILL.md index 46d45784e4..c3784bc806 100644 --- a/.claude/skills/code-size/SKILL.md +++ b/.claude/skills/code-size/SKILL.md @@ -25,7 +25,7 @@ Flags and syntax: `python3 tools/code_size.py --help`. The choices: - `--symbols` when Flash/RAM cancel: a pair counts as changed when its sections or symbols moved. - `--ci` for "all boards" / "CI"; it needs the arm, riscv, msp430 and ft9xx toolchains, and ESP-IDF or docker. - An espressif board builds each example `tools/build.py` picks as its own ESP-IDF project: with the exported ESP-IDF (`. "$IDF_PATH/export.sh"`), else in CI's image through docker, as your user (once: `docker pull espressif/idf:v5.5.3 && docker tag espressif/idf:v5.5.3 espressif/idf:tinyusb`, about 8.4 GB). -- Windows: run `python -X utf8 tools/code_size.py …` (or set `PYTHONUTF8=1`), else redirected output fails on `Δ`. `report` and `diff` work for paths without spaces; espressif boards are refused. A local base, including the fallback, symlinks its dependencies: enable Developer Mode unless running elevated. +- Windows: run `python -X utf8 tools/code_size.py …` (or set `PYTHONUTF8=1`), else redirected output fails on `Δ`. `report` and `diff` work for paths without spaces, and a non-ASCII checkout path needs ninja >= 1.11 (UTF-8 `build.ninja`); espressif boards are refused. A local base, including the fallback, symlinks its dependencies: enable Developer Mode unless running elevated. ## Results diff --git a/test/hil/test/test_code_size.py b/test/hil/test/test_code_size.py index 5731451aee..d8b5810056 100644 --- a/test/hil/test/test_code_size.py +++ b/test/hil/test/test_code_size.py @@ -1325,7 +1325,7 @@ def test_a_build_ninja_not_in_utf_8_fails(self): with open(os.path.join(self.build_dir, 'build.ninja'), 'wb') as f: f.write(b'# Calf\xe9\n' + edge.encode()) sizes = sd._Sizes([self.FILTER], self.elf) - with self.assertRaisesRegex(RuntimeError, r'build\.ninja.*utf-8'): + with self.assertRaisesRegex(RuntimeError, r'build\.ninja is not UTF-8 .* use ninja >= 1\.11'): sizes.add(obj, '.text', {'flash'}, 4, 'tud_task_ext') def test_linux_does_not_resolve_a_shortened_object(self): diff --git a/tools/code_size.py b/tools/code_size.py index 4d66d8f36a..44e01f580f 100644 --- a/tools/code_size.py +++ b/tools/code_size.py @@ -284,7 +284,6 @@ def _shortened_object_sources(build_dir): """{shortened object: source} of the compile edges in build_dir's build.ninja.""" path = os.path.join(build_dir, 'build.ninja') sources = {} - # CMake writes it in the ANSI code page unless ninja >= 1.11 reports UTF-8 (`ninja -t wincodepage`) try: with open(path, encoding='utf-8') as f: for line in f: @@ -292,8 +291,12 @@ def _shortened_object_sources(build_dir): if m and _SHORTENED_OBJECT_RE.search(m[1]): obj, src = (re.sub(r'\$(.)', r'\1', p).replace('\\', '/') for p in m.groups()) sources[obj] = src - except (OSError, UnicodeDecodeError) as e: + except OSError as e: raise RuntimeError(f'cannot read {path}: {e}') from e + except UnicodeDecodeError as e: + raise RuntimeError(f'{path} is not UTF-8 ({e}): CMake writes it in the ANSI code page when ninja is older ' + 'than 1.11 or its code page is not UTF-8 (`ninja -t wincodepage`); use ninja >= 1.11 ' + 'with a UTF-8 code page or an ASCII-only checkout path') from e return sources From e53805051805a6423a4e6df71588e9762b365fcf Mon Sep 17 00:00:00 2001 From: hathach Date: Sun, 4 Oct 2026 02:01:24 +0700 Subject: [PATCH 16/18] code_size: key a shortened object by its source and sharpen its build.ninja errors --- .claude/skills/code-size/SKILL.md | 2 +- test/hil/test/test_code_size.py | 22 +++++++++++++++++++--- tools/code_size.py | 25 +++++++++++++++---------- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/.claude/skills/code-size/SKILL.md b/.claude/skills/code-size/SKILL.md index c3784bc806..6b53b5189d 100644 --- a/.claude/skills/code-size/SKILL.md +++ b/.claude/skills/code-size/SKILL.md @@ -25,7 +25,7 @@ Flags and syntax: `python3 tools/code_size.py --help`. The choices: - `--symbols` when Flash/RAM cancel: a pair counts as changed when its sections or symbols moved. - `--ci` for "all boards" / "CI"; it needs the arm, riscv, msp430 and ft9xx toolchains, and ESP-IDF or docker. - An espressif board builds each example `tools/build.py` picks as its own ESP-IDF project: with the exported ESP-IDF (`. "$IDF_PATH/export.sh"`), else in CI's image through docker, as your user (once: `docker pull espressif/idf:v5.5.3 && docker tag espressif/idf:v5.5.3 espressif/idf:tinyusb`, about 8.4 GB). -- Windows: run `python -X utf8 tools/code_size.py …` (or set `PYTHONUTF8=1`), else redirected output fails on `Δ`. `report` and `diff` work for paths without spaces, and a non-ASCII checkout path needs ninja >= 1.11 (UTF-8 `build.ninja`); espressif boards are refused. A local base, including the fallback, symlinks its dependencies: enable Developer Mode unless running elevated. +- Windows: run `python -X utf8 tools/code_size.py …` (or set `PYTHONUTF8=1`), else redirected output fails on `Δ`. `report` and `diff` work for paths without spaces, and a non-ASCII checkout, CMake or toolchain path needs ninja >= 1.11 whose `ninja -t wincodepage` reports UTF-8; espressif boards are refused. A local base, including the fallback, symlinks its dependencies: enable Developer Mode unless running elevated. ## Results diff --git a/test/hil/test/test_code_size.py b/test/hil/test/test_code_size.py index d8b5810056..25a282df06 100644 --- a/test/hil/test/test_code_size.py +++ b/test/hil/test/test_code_size.py @@ -1279,6 +1279,7 @@ class CMakeShortenedObject(unittest.TestCase): TARGET = 'device/audio_4_channel_mic_freertos/CMakeFiles/audio_4_channel_mic_freertos.dir/' FILTER = CHECKOUT[2:] + '/src/' USBD = CHECKOUT + '/src/device/usbd.c' + CDC = CHECKOUT + '/src/class/cdc/cdc_device.c' STARTUP = CHECKOUT + '/hw/mcu/st/cmsis_device_f4/Source/Templates/gcc/startup_stm32f407xx.s' def setUp(self): @@ -1296,10 +1297,10 @@ def obj(self, source): ' || cmake_object_order_depends_target_audio_4_channel_mic_freertos\n' return obj.replace('/', '\\'), edge - def sizes(self, edges): + def sizes(self, edges, filters=(FILTER,)): with open(os.path.join(self.build_dir, 'build.ninja'), 'w') as f: f.writelines(edges) - return sd._Sizes([self.FILTER], self.elf) + return sd._Sizes(list(filters), self.elf) def test_a_shortened_vendor_object_counts_in_all_only(self): obj, edge = self.obj(self.STARTUP) @@ -1314,6 +1315,19 @@ def test_a_shortened_tinyusb_object_keeps_its_source_key(self): sizes.add(obj, '.text', {'flash'}, 4, 'tud_task_ext') self.assertEqual(sizes.result()['files'], {'device/usbd.c': {'flash': 4, 'ram': 0}}) + def test_a_later_filter_matching_the_kept_tail_keys_as_for_the_full_source(self): + obj, edge = self.obj(self.CDC) + self.assertIn('/class/cdc/', obj.replace('\\', '/')) + sizes = self.sizes([edge], (self.FILTER, 'class/')) + sizes.add(obj, '.text', {'flash'}, 4, 'tud_cdc_n_write') + self.assertEqual(sizes.result()['files'], {'class/cdc/cdc_device.c': {'flash': 4, 'ram': 0}}) + + def test_a_shortened_object_without_build_ninja_fails(self): + obj, _ = self.obj(self.USBD) + with mock.patch.object(sd, '_find_ninja_build_dir', return_value=None): + with self.assertRaisesRegex(RuntimeError, 'CMAKE_OBJECT_PATH_MAX .* no build\\.ninja was found above'): + sd._Sizes([self.FILTER], self.elf).add(obj, '.text', {'flash'}, 4, 'tud_task_ext') + def test_a_shortened_object_build_ninja_does_not_build_fails(self): obj, _ = self.obj(self.USBD) _, edge = self.obj(self.STARTUP) @@ -1325,8 +1339,10 @@ def test_a_build_ninja_not_in_utf_8_fails(self): with open(os.path.join(self.build_dir, 'build.ninja'), 'wb') as f: f.write(b'# Calf\xe9\n' + edge.encode()) sizes = sd._Sizes([self.FILTER], self.elf) - with self.assertRaisesRegex(RuntimeError, r'build\.ninja is not UTF-8 .* use ninja >= 1\.11'): + with self.assertRaisesRegex(RuntimeError, r'build\.ninja is not UTF-8: .* use ninja >= 1\.11 whose ' + r'`ninja -t wincodepage` reports UTF-8') as cm: sizes.add(obj, '.text', {'flash'}, 4, 'tud_task_ext') + self.assertNotIn('position', str(cm.exception)) # relative to the decoder's chunk, not the file def test_linux_does_not_resolve_a_shortened_object(self): obj, _ = self.obj(self.USBD) diff --git a/tools/code_size.py b/tools/code_size.py index 44e01f580f..89e5692e89 100644 --- a/tools/code_size.py +++ b/tools/code_size.py @@ -294,9 +294,10 @@ def _shortened_object_sources(build_dir): except OSError as e: raise RuntimeError(f'cannot read {path}: {e}') from e except UnicodeDecodeError as e: - raise RuntimeError(f'{path} is not UTF-8 ({e}): CMake writes it in the ANSI code page when ninja is older ' - 'than 1.11 or its code page is not UTF-8 (`ninja -t wincodepage`); use ninja >= 1.11 ' - 'with a UTF-8 code page or an ASCII-only checkout path') from e + raise RuntimeError(f'{path} is not UTF-8: CMake writes it in the ANSI code page when ninja is older than ' + '1.11 or `ninja -t wincodepage` is not UTF-8; use ninja >= 1.11 whose ' + '`ninja -t wincodepage` reports UTF-8, or keep the checkout, CMake and toolchain paths ' + 'ASCII-only') from e return sources @@ -310,18 +311,22 @@ def __init__(self, filters, elf): def _shortened_key(self, path): """Key of the source build.ninja compiles to a CMake-shortened object; the object path no longer holds the filtered dir.""" + shortened = f'{path}: CMake shortened this object path to fit CMAKE_OBJECT_PATH_MAX (250 on Windows) and' build_dir = _find_ninja_build_dir(self.elf) - src = build_dir and _shortened_object_sources(build_dir).get(path.replace('\\', '/')) + if build_dir is None: + raise RuntimeError(f'{shortened} no build.ninja was found above {self.elf} to name its source - ' + 'use a shorter checkout path') + src = _shortened_object_sources(build_dir).get(path.replace('\\', '/')) if not src: - raise RuntimeError(f'{path}: CMake shortened this object path to fit CMAKE_OBJECT_PATH_MAX ' - f'(250 on Windows) and the build.ninja above {self.elf} has no compile edge ' - 'for it to name its source - use a shorter checkout path') + raise RuntimeError(f'{shortened} the build.ninja above {self.elf} has no compile edge for it ' + 'to name its source - use a shorter checkout path') return _relative_key(src, self.filters) def add(self, path, section, buckets, size, name): - key = _relative_key(path, self.filters) if path else None - if key is None and path and WINDOWS and _SHORTENED_OBJECT_RE.search(path.replace('\\', '/')): - key = self._shortened_key(path) + if path and WINDOWS and _SHORTENED_OBJECT_RE.search(path.replace('\\', '/')): + key = self._shortened_key(path) # a filter could match the kept tail of the source path + else: + key = _relative_key(path, self.filters) if path else None for b in buckets: self.all[b] += size if key is not None: From 197f63c675e78c843e79c7812e13401491988bae Mon Sep 17 00:00:00 2001 From: hathach Date: Mon, 5 Oct 2026 09:50:24 +0700 Subject: [PATCH 17/18] ci: check code_size.py in build-os's Windows leg instead of its own workflow --- .github/workflows/build.yml | 1 + .github/workflows/build_util.yml | 26 ++++++++ .github/workflows/code_size_windows.yml | 83 ------------------------- test/hil/test/test_ci_select.py | 3 +- tools/ci_select.py | 2 +- 5 files changed, 29 insertions(+), 86 deletions(-) delete mode 100644 .github/workflows/code_size_windows.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7d4a319c58..a05ec364fa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -284,6 +284,7 @@ jobs: toolchain: 'arm-gcc-${{ matrix.os }}' build-args: '["stm32h7rs"]' build-options: '--one-random' + code-size-check: ${{ matrix.os == 'windows-latest' && matrix.build-system == 'cmake' && '-b stm32h7s3nucleo -e device/cdc_msc' || '' }} # --------------------------------------- # Zephyr diff --git a/.github/workflows/build_util.yml b/.github/workflows/build_util.yml index 5dcf92c824..b42c51f02a 100644 --- a/.github/workflows/build_util.yml +++ b/.github/workflows/build_util.yml @@ -36,6 +36,11 @@ on: required: false default: true type: boolean + code-size-check: + description: 'code_size.py -b/-e args to check tools/code_size.py on this host with' + required: false + default: '' + type: string jobs: family: @@ -117,6 +122,27 @@ jobs: fi shell: bash + - name: Code size check + # code_size.py on a host CI never snapshots on (build-os's Windows leg); its diffs + # rebuild twice more, so only a manual run takes them + if: inputs.code-size-check != '' + env: + PYTHONUTF8: '1' + CHECK: ${{ inputs.code-size-check }} + GH_TOKEN: ${{ github.token }} + run: | + python -m pip install --only-binary :all: membrowse==1.2.12 pyyaml pyelftools + (cd test/hil/test && python -m unittest -q test_code_size test_code_size_ci test_membrowse_cli) + python tools/code_size.py report $CHECK + if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ]; then + git fetch -q --depth=1 origin master:refs/remotes/origin/master + python tools/code_size.py diff $CHECK --base-source local --base-branch HEAD --json + # diff passes a size change and some INCOMPLETE results; HEAD against HEAD must be neither + python -c "import glob, json; d = json.load(open(*glob.glob('cmake-code-size/*/diff_*.json'))); assert d['status'] == 'complete' and all(p['base']['files'] == p['current']['files'] for p in d['pairs']), d['status']" + python tools/code_size.py diff $CHECK --base-source ci --base-branch origin/master + fi + shell: bash + - name: Code size snapshot id: code-size # measurement only: it also runs after a failed Build and records that in the diff --git a/.github/workflows/code_size_windows.yml b/.github/workflows/code_size_windows.yml deleted file mode 100644 index be140b3bc6..0000000000 --- a/.github/workflows/code_size_windows.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Code size on Windows - -on: - workflow_dispatch: - pull_request: - paths: &code_size_paths - - 'tools/code_size.py' - - 'tools/membrowse_cli.py' - - 'tools/build_utils.py' - - 'test/hil/test/test_code_size*.py' - - 'test/hil/test/test_membrowse_cli.py' - - '.github/scripts/code_size_ci.py' - - '.github/workflows/code_size_windows.yml' - push: - branches: [ master ] - paths: *code_size_paths - -permissions: - contents: read - actions: read - -concurrency: - group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} - -env: - PYTHONUTF8: '1' - -jobs: - unit-test: - runs-on: windows-latest - steps: - - uses: actions/checkout@v6 - - run: python -m pip install pyyaml pyelftools - # as pre-commit's unit-test hook runs them - - run: python -m unittest -q test_code_size test_code_size_ci test_membrowse_cli - working-directory: test/hil/test - - smoke: - runs-on: windows-latest - env: - BOARD: stm32f407disco - EXAMPLE: device/cdc_msc - GH_TOKEN: ${{ github.token }} - defaults: - run: - shell: bash - steps: - - name: Checkout TinyUSB - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Setup Toolchain - uses: ./.github/actions/setup_toolchain - with: - toolchain: 'arm-gcc-windows-latest' - - - name: Get Dependencies - uses: ./.github/actions/get_deps - with: - arg: '-b ${{ env.BOARD }}' - - - name: Install membrowse - run: 'python -m pip install --only-binary :all: membrowse==1.2.12' - - # report exits 1 unless complete with TinyUSB files matched - - name: Report - run: python tools/code_size.py report -b $BOARD -e $EXAMPLE - - - name: Diff against a local base - run: | - python tools/code_size.py diff -b $BOARD -e $EXAMPLE --base-source local --base-branch HEAD --json - python - <<'EOF' - import json, os - path = f"cmake-code-size/{os.environ['BOARD']}/diff_{os.environ['EXAMPLE'].replace('/', '_')}.json" - data = json.load(open(path)) - assert data['status'] == 'complete', data['status'] - assert all(p['base']['files'] == p['current']['files'] for p in data['pairs']), 'HEAD vs HEAD changed' - EOF - - - name: Diff against CI snapshots - run: python tools/code_size.py diff -b $BOARD -e $EXAMPLE --base-source ci --base-branch origin/master diff --git a/test/hil/test/test_ci_select.py b/test/hil/test/test_ci_select.py index d23add521a..35e7a8e59e 100644 --- a/test/hil/test/test_ci_select.py +++ b/test/hil/test/test_ci_select.py @@ -1875,8 +1875,7 @@ def test_repo_metadata_is_not_a_build_input(self): '.clang-format', '.idea/misc.xml', 'version.yml', 'library.json', 'examples/CMakePresets.json', 'test/fuzz/fuzz.cc', 'test/unit-test/project.yml', '.github/workflows/pr_comment.yml', - '.github/workflows/code_size_windows.yml', '.github/scripts/code_size_ci.py', - '.claude/skills/build-doc/scripts/gen_doc.py', '.agents'): + '.github/scripts/code_size_ci.py', '.claude/skills/build-doc/scripts/gen_doc.py', '.agents'): s = self.b([p]) self.assertFalse(s['full'], p) self.assertEqual(s['families'], [], p) diff --git a/tools/ci_select.py b/tools/ci_select.py index e140663629..e7e989e27a 100755 --- a/tools/ci_select.py +++ b/tools/ci_select.py @@ -122,7 +122,7 @@ def _read(path: str) -> str: # .github, minus the build machinery named in _FULL_RE and _CI_BOARDS_RE r'\.github/(FUNDING\.yml$|labeler\.yml$|' r'ISSUE_TEMPLATE/|scripts/code_size_ci\.py$|' - r'workflows/(cifuzz|claude|claude-code-review|code_size_windows|labeler|' + r'workflows/(cifuzz|claude|claude-code-review|labeler|' r'pr_comment|pre-commit|static_analysis|trigger)\.yml$)|' # tools/ scripts no build invokes (tools/build*.py and local tooling are handled separately) r'tools/(check_example_pids|file2carray|iar_gen|mksunxi|pcapng_to_corpus)\.py$|' From e000182d7c0d5627901db2cd5f120fb2ea8979bf Mon Sep 17 00:00:00 2001 From: hathach Date: Mon, 5 Oct 2026 10:59:25 +0700 Subject: [PATCH 18/18] ci: drop the Windows code-size check from build-os --- .github/workflows/build.yml | 1 - .github/workflows/build_util.yml | 26 -------------------------- 2 files changed, 27 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a05ec364fa..7d4a319c58 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -284,7 +284,6 @@ jobs: toolchain: 'arm-gcc-${{ matrix.os }}' build-args: '["stm32h7rs"]' build-options: '--one-random' - code-size-check: ${{ matrix.os == 'windows-latest' && matrix.build-system == 'cmake' && '-b stm32h7s3nucleo -e device/cdc_msc' || '' }} # --------------------------------------- # Zephyr diff --git a/.github/workflows/build_util.yml b/.github/workflows/build_util.yml index b42c51f02a..5dcf92c824 100644 --- a/.github/workflows/build_util.yml +++ b/.github/workflows/build_util.yml @@ -36,11 +36,6 @@ on: required: false default: true type: boolean - code-size-check: - description: 'code_size.py -b/-e args to check tools/code_size.py on this host with' - required: false - default: '' - type: string jobs: family: @@ -122,27 +117,6 @@ jobs: fi shell: bash - - name: Code size check - # code_size.py on a host CI never snapshots on (build-os's Windows leg); its diffs - # rebuild twice more, so only a manual run takes them - if: inputs.code-size-check != '' - env: - PYTHONUTF8: '1' - CHECK: ${{ inputs.code-size-check }} - GH_TOKEN: ${{ github.token }} - run: | - python -m pip install --only-binary :all: membrowse==1.2.12 pyyaml pyelftools - (cd test/hil/test && python -m unittest -q test_code_size test_code_size_ci test_membrowse_cli) - python tools/code_size.py report $CHECK - if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ]; then - git fetch -q --depth=1 origin master:refs/remotes/origin/master - python tools/code_size.py diff $CHECK --base-source local --base-branch HEAD --json - # diff passes a size change and some INCOMPLETE results; HEAD against HEAD must be neither - python -c "import glob, json; d = json.load(open(*glob.glob('cmake-code-size/*/diff_*.json'))); assert d['status'] == 'complete' and all(p['base']['files'] == p['current']['files'] for p in d['pairs']), d['status']" - python tools/code_size.py diff $CHECK --base-source ci --base-branch origin/master - fi - shell: bash - - name: Code size snapshot id: code-size # measurement only: it also runs after a failed Build and records that in the