From 3e66a2a88b501f01a0d605236a126717c463245b Mon Sep 17 00:00:00 2001 From: Ivan Gagis Date: Wed, 4 Nov 2026 19:00:32 +0200 Subject: [PATCH 1/6] ci: temporary diagnostic steps for the Windows vcpkg test hang Run the test exe in separate steps with per-step timeouts (kill on hang): - single non-IO test on the main thread (no runner involved) - single IO test on the main thread (no runner involved) - parser suite via the runner thread - full suite with --about-to-run --print-passed to identify the hanging test Also add job-level timeout-minutes: 30 so the job cannot hang indefinitely. --- .github/workflows/ci.yml | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e7875d..405005a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -478,6 +478,7 @@ jobs: defaults: run: shell: powershell + timeout-minutes: 30 name: vcpkg - windows steps: - name: git clone @@ -507,10 +508,40 @@ jobs: cd build/vcpkg/test cmake . cmake --build . --parallel - - name: test vcpkg port - test + # ====== TEMPORARY DIAGNOSTIC STEPS for investigating the Windows Debug test hang ====== + # each step runs the test exe with a hard timeout (kill on hang), because the tests + # normally complete in well under a second. + # single test without file I/O, run on the main thread (no runner thread involved) + - name: diag - single non-IO test (no threads) + continue-on-error: true run: | - cd build/vcpkg/test - cmake --build . --config Debug --target run-library-tests + cd build/vcpkg/test/exe\library-tests + $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite','tree_ext','--test','find_tree_from_forest_by_string' -NoNewWindow -PassThru + try { Wait-Process -Id $p.Id -Timeout 90; exit $p.ExitCode } + catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } + # single test with file I/O, run on the main thread (no runner thread involved) + - name: diag - single IO test (no threads) + continue-on-error: true + run: | + cd build/vcpkg/test/exe\library-tests + $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite','parser','--test','parse' -NoNewWindow -PassThru + try { Wait-Process -Id $p.Id -Timeout 90; exit $p.ExitCode } + catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } + # suite with a single test with file I/O, run via the runner thread (no --test, so runner is used) + - name: diag - parser suite (with runner thread) + continue-on-error: true + run: | + cd build/vcpkg/test/exe\library-tests + $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite','parser' -NoNewWindow -PassThru + try { Wait-Process -Id $p.Id -Timeout 90; exit $p.ExitCode } + catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } + # full suite with per-test output to identify the hanging test + - name: diag - full suite with per-test output + run: | + cd build/vcpkg/test/exe\library-tests + $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--about-to-run','--print-passed' -NoNewWindow -PassThru + try { Wait-Process -Id $p.Id -Timeout 120; exit $p.ExitCode } + catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } - name: upload vcpkg logs to artifacts if: always() # even if previous steps fail, this one needs to be run uses: actions/upload-artifact@v4 From 6f9ba21e08c4378457973a472ecff45f180ef62a Mon Sep 17 00:00:00 2001 From: Ivan Gagis Date: Wed, 4 Nov 2026 19:15:26 +0200 Subject: [PATCH 2/6] ci: fix diagnostic step CLI arguments clargs only supports '--key=value' form for key arguments with values (and the passed-test flag is named '--passed', not '--print-passed'). The previous arguments hit the clargs parser assertion (parser.cpp:271) or were rejected as unknown, which masked the real test behaviour. --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 405005a..901ddee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -516,7 +516,7 @@ jobs: continue-on-error: true run: | cd build/vcpkg/test/exe\library-tests - $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite','tree_ext','--test','find_tree_from_forest_by_string' -NoNewWindow -PassThru + $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=tree_ext','--test=find_tree_from_forest_by_string' -NoNewWindow -PassThru try { Wait-Process -Id $p.Id -Timeout 90; exit $p.ExitCode } catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } # single test with file I/O, run on the main thread (no runner thread involved) @@ -524,7 +524,7 @@ jobs: continue-on-error: true run: | cd build/vcpkg/test/exe\library-tests - $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite','parser','--test','parse' -NoNewWindow -PassThru + $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=parser','--test=parse' -NoNewWindow -PassThru try { Wait-Process -Id $p.Id -Timeout 90; exit $p.ExitCode } catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } # suite with a single test with file I/O, run via the runner thread (no --test, so runner is used) @@ -532,14 +532,14 @@ jobs: continue-on-error: true run: | cd build/vcpkg/test/exe\library-tests - $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite','parser' -NoNewWindow -PassThru + $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=parser' -NoNewWindow -PassThru try { Wait-Process -Id $p.Id -Timeout 90; exit $p.ExitCode } catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } # full suite with per-test output to identify the hanging test - name: diag - full suite with per-test output run: | cd build/vcpkg/test/exe\library-tests - $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--about-to-run','--print-passed' -NoNewWindow -PassThru + $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--about-to-run','--passed' -NoNewWindow -PassThru try { Wait-Process -Id $p.Id -Timeout 120; exit $p.ExitCode } catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } - name: upload vcpkg logs to artifacts From 443ea0fccaa42dc436d935eaa82e46c6a4dc7763 Mon Sep 17 00:00:00 2001 From: Ivan Gagis Date: Wed, 4 Nov 2026 19:26:42 +0200 Subject: [PATCH 3/6] ci: add per-suite diagnostic steps (all 12 suites via runner thread) Each suite is run separately via the runner thread with a 45s kill timeout, to find which suite (or multi-test handshake) deadlocks. Previous round proved single-test runs (main thread and runner thread) pass, while the full 531-test run hangs before any per-test output is flushed. --- .github/workflows/ci.yml | 96 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 901ddee..5be7ab2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -535,6 +535,102 @@ jobs: $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=parser' -NoNewWindow -PassThru try { Wait-Process -Id $p.Id -Timeout 90; exit $p.ExitCode } catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } + # suite 'tree_ext' via the runner thread + - name: diag - suite tree_ext (runner) + continue-on-error: true + run: | + cd build/vcpkg/test/exe\library-tests + $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=tree_ext' -NoNewWindow -PassThru + try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } + catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } + # suite 'to_non_ext' via the runner thread + - name: diag - suite to_non_ext (runner) + continue-on-error: true + run: | + cd build/vcpkg/test/exe\library-tests + $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=to_non_ext' -NoNewWindow -PassThru + try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } + catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } + # suite 'snippets' via the runner thread + - name: diag - suite snippets (runner) + continue-on-error: true + run: | + cd build/vcpkg/test/exe\library-tests + $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=snippets' -NoNewWindow -PassThru + try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } + catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } + # suite 'read_ext' via the runner thread + - name: diag - suite read_ext (runner) + continue-on-error: true + run: | + cd build/vcpkg/test/exe\library-tests + $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=read_ext' -NoNewWindow -PassThru + try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } + catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } + # suite 'parser_flags' via the runner thread + - name: diag - suite parser_flags (runner) + continue-on-error: true + run: | + cd build/vcpkg/test/exe\library-tests + $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=parser_flags' -NoNewWindow -PassThru + try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } + catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } + # suite 'parser_correctness' via the runner thread + - name: diag - suite parser_correctness (runner) + continue-on-error: true + run: | + cd build/vcpkg/test/exe\library-tests + $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=parser_correctness' -NoNewWindow -PassThru + try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } + catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } + # suite 'parser' via the runner thread + - name: diag - suite parser (runner) + continue-on-error: true + run: | + cd build/vcpkg/test/exe\library-tests + $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=parser' -NoNewWindow -PassThru + try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } + catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } + # suite 'value_conversion' via the runner thread + - name: diag - suite value_conversion (runner) + continue-on-error: true + run: | + cd build/vcpkg/test/exe\library-tests + $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=value_conversion' -NoNewWindow -PassThru + try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } + catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } + # suite 'tree_writing' via the runner thread + - name: diag - suite tree_writing (runner) + continue-on-error: true + run: | + cd build/vcpkg/test/exe\library-tests + $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=tree_writing' -NoNewWindow -PassThru + try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } + catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } + # suite 'tree_reading' via the runner thread + - name: diag - suite tree_reading (runner) + continue-on-error: true + run: | + cd build/vcpkg/test/exe\library-tests + $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=tree_reading' -NoNewWindow -PassThru + try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } + catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } + # suite 'parser_location' via the runner thread + - name: diag - suite parser_location (runner) + continue-on-error: true + run: | + cd build/vcpkg/test/exe\library-tests + $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=parser_location' -NoNewWindow -PassThru + try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } + catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } + # suite 'crawler' via the runner thread + - name: diag - suite crawler (runner) + continue-on-error: true + run: | + cd build/vcpkg/test/exe\library-tests + $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=crawler' -NoNewWindow -PassThru + try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } + catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } # full suite with per-test output to identify the hanging test - name: diag - full suite with per-test output run: | From 3ac3d3f287f5341a7240102c943f84bc5efbdc89 Mon Sep 17 00:00:00 2001 From: Ivan Gagis Date: Wed, 4 Nov 2026 21:27:20 +0200 Subject: [PATCH 4/6] ci: instrument tree_reading test with DIAG markers; test on main thread The per-suite run found that ONLY the 'tree_reading' suite hangs (all other 11 suites pass via the runner thread, including the other file-I/O suites). The parser is provably linear in the input, so the hang must be a silent thread death or deadlock. Added unbuffered stderr markers to the test body to locate the exact stop point, plus a main-thread variant of the test (--test implies no_catch, so thrown exceptions print before the break). --- .github/workflows/ci.yml | 103 +++++--------------------------- tests/unit/src/tree_reading.cpp | 36 +++++++++++ 2 files changed, 50 insertions(+), 89 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5be7ab2..2c62874 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -535,102 +535,27 @@ jobs: $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=parser' -NoNewWindow -PassThru try { Wait-Process -Id $p.Id -Timeout 90; exit $p.ExitCode } catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } - # suite 'tree_ext' via the runner thread - - name: diag - suite tree_ext (runner) + # tree_reading single test on the MAIN THREAD (no runner thread). + # --test forces main-thread execution with no_catch, so any thrown exception + # will print "terminate called after throwing ..." before the process hangs. + - name: diag - tree_reading single test (main thread) continue-on-error: true run: | cd build/vcpkg/test/exe\library-tests - $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=tree_ext' -NoNewWindow -PassThru - try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } - catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } - # suite 'to_non_ext' via the runner thread - - name: diag - suite to_non_ext (runner) - continue-on-error: true - run: | - cd build/vcpkg/test/exe\library-tests - $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=to_non_ext' -NoNewWindow -PassThru - try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } - catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } - # suite 'snippets' via the runner thread - - name: diag - suite snippets (runner) - continue-on-error: true - run: | - cd build/vcpkg/test/exe\library-tests - $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=snippets' -NoNewWindow -PassThru - try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } - catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } - # suite 'read_ext' via the runner thread - - name: diag - suite read_ext (runner) - continue-on-error: true - run: | - cd build/vcpkg/test/exe\library-tests - $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=read_ext' -NoNewWindow -PassThru - try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } - catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } - # suite 'parser_flags' via the runner thread - - name: diag - suite parser_flags (runner) - continue-on-error: true - run: | - cd build/vcpkg/test/exe\library-tests - $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=parser_flags' -NoNewWindow -PassThru - try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } - catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } - # suite 'parser_correctness' via the runner thread - - name: diag - suite parser_correctness (runner) - continue-on-error: true - run: | - cd build/vcpkg/test/exe\library-tests - $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=parser_correctness' -NoNewWindow -PassThru - try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } - catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } - # suite 'parser' via the runner thread - - name: diag - suite parser (runner) - continue-on-error: true - run: | - cd build/vcpkg/test/exe\library-tests - $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=parser' -NoNewWindow -PassThru - try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } - catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } - # suite 'value_conversion' via the runner thread - - name: diag - suite value_conversion (runner) - continue-on-error: true - run: | - cd build/vcpkg/test/exe\library-tests - $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=value_conversion' -NoNewWindow -PassThru - try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } - catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } - # suite 'tree_writing' via the runner thread - - name: diag - suite tree_writing (runner) - continue-on-error: true - run: | - cd build/vcpkg/test/exe\library-tests - $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=tree_writing' -NoNewWindow -PassThru - try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } - catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } - # suite 'tree_reading' via the runner thread - - name: diag - suite tree_reading (runner) + $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=tree_reading','--test=read' -NoNewWindow -PassThru + try {{ Wait-Process -Id $p.Id -Timeout 60; exit $p.ExitCode }} + catch {{ Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 }} + + # tree_reading suite via the RUNNER thread (the case that hangs), with the + # instrumented test printing DIAG markers to stderr to locate the hang. + - name: diag - tree_reading suite (runner thread) continue-on-error: true run: | cd build/vcpkg/test/exe\library-tests $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=tree_reading' -NoNewWindow -PassThru - try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } - catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } - # suite 'parser_location' via the runner thread - - name: diag - suite parser_location (runner) - continue-on-error: true - run: | - cd build/vcpkg/test/exe\library-tests - $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=parser_location' -NoNewWindow -PassThru - try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } - catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } - # suite 'crawler' via the runner thread - - name: diag - suite crawler (runner) - continue-on-error: true - run: | - cd build/vcpkg/test/exe\library-tests - $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=crawler' -NoNewWindow -PassThru - try { Wait-Process -Id $p.Id -Timeout 45; exit $p.ExitCode } - catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } + try {{ Wait-Process -Id $p.Id -Timeout 60; exit $p.ExitCode }} + catch {{ Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 }} + # full suite with per-test output to identify the hanging test - name: diag - full suite with per-test output run: | diff --git a/tests/unit/src/tree_reading.cpp b/tests/unit/src/tree_reading.cpp index 9cc3ac2..a3284c1 100644 --- a/tests/unit/src/tree_reading.cpp +++ b/tests/unit/src/tree_reading.cpp @@ -1,15 +1,26 @@ +#include + #include #include #include +// TEMPORARY: diagnostic markers for Windows CI freeze investigation +#define DIAG_MARK(msg) \ + do { \ + std::cerr << "DIAG: " << msg << std::endl; \ + } while (0) + #include "../../../src/tml/tree.hpp" namespace { const tst::set set("tree_reading", [](tst::suite& suite) { suite.add("read", []() { + DIAG_MARK("test start"); fsif::native_file fi("res/tree_reading_data/test.tml"); + DIAG_MARK("file object created"); auto roots = tml::read(fi); + DIAG_MARK("parsed, roots.size() = " << roots.size()); auto i = roots.begin(); @@ -17,62 +28,74 @@ const tst::set set("tree_reading", [](tst::suite& suite) { tst::check(i->children.size() == 0, SL); ++i; + DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "test string", SL) << i->value.string; tst::check(i->children.size() == 0, SL); ++i; + DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "anot/her string", SL) << i->value.string; tst::check(i->children.size() == 0, SL); ++i; + DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "unqu/otedString", SL); tst::check(i->children.size() == 0, SL); ++i; + DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "one\ntwo three\tfour", SL); tst::check(i->children.size() == 0, SL); ++i; + DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "string with empty children list", SL); tst::check(i->children.size() == 0, SL); ++i; + DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "unquoted_string_with_empty_children_list", SL); tst::check(i->children.size() == 0, SL); ++i; + DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "string_interrupted//tro", SL) << i->value.string; tst::check(i->children.size() == 0, SL); ++i; + DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "_by_comment", SL) << i->value.string; tst::check(i->children.size() == 0, SL); ++i; + DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "string_broken/*tro", SL) << i->value.string; tst::check(i->children.size() == 0, SL); ++i; + DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "*/_by_comment", SL) << i->value.string; tst::check(i->children.size() == 0, SL); ++i; + DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "", SL); tst::check(i->value.length() == 0, SL); tst::check(i->children.size() == 0, SL) << i->children.front().value.string; ++i; + DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "", SL); tst::check(i->value.length() == 0, SL); @@ -96,6 +119,7 @@ const tst::set set("tree_reading", [](tst::suite& suite) { } ++i; + DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "", SL); tst::check(i->children.size() == 5, SL) << i->children.size(); @@ -110,16 +134,19 @@ const tst::set set("tree_reading", [](tst::suite& suite) { tst::check(i->value == "child string", SL); ++i; + DIAG_MARK("root ++i"); tst::check(i != b.end(), SL); tst::check(i->value == "aaa", SL); tst::check(i->children.size() == 0, SL); ++i; + DIAG_MARK("root ++i"); tst::check(i != b.end(), SL); tst::check(i->value == "bbb", SL); tst::check(i->children.size() == 0, SL); ++i; + DIAG_MARK("root ++i"); tst::check(i != b.end(), SL); tst::check(i->value == "ccc", SL); { @@ -130,45 +157,54 @@ const tst::set set("tree_reading", [](tst::suite& suite) { } ++i; + DIAG_MARK("root ++i"); tst::check(i != b.end(), SL); tst::check(i->value == "", SL); tst::check(i->children.size() == 0, SL); ++i; + DIAG_MARK("root ++i"); tst::check(i == b.end(), SL); } ++i; + DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "Tab\t Backslash\\ Slash/ Doubleslash// Newline\n Doublequotes\" {}{}{}{}", SL); tst::check(i->children.size() == 0, SL); ++i; + DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "quoted string with trailing slash /", SL); tst::check(i->children.size() == 0, SL); ++i; + DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "fff ggg", SL); tst::check(i->children.size() == 0, SL); ++i; + DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "tralala tro lo lo\ntre lele", SL); tst::check(i->children.size() == 0, SL); ++i; + DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "-1213.33", SL); tst::check(i->children.size() == 0, SL); ++i; + DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "UnquotedStringAtTheVeryEndOfTheFile", SL); tst::check(i->children.size() == 0, SL); ++i; + DIAG_MARK("root ++i"); tst::check(i == roots.end(), SL); { From cc52411bdd72e3608c30fdfed23f1f8b40be3d49 Mon Sep 17 00:00:00 2001 From: Ivan Gagis Date: Wed, 4 Nov 2026 21:56:14 +0200 Subject: [PATCH 5/6] fix UB in tree_reading test that froze the Windows CI The tree_reading test evaluated i->children.front().value.string as the '<<' argument of a passing check. 'front()' on the empty children vector is undefined behaviour: the MSVC debug CRT (vcpkg CI builds with /MDd + /RTC1) detects it and the test runner thread dies without any visible message, so the main thread waits forever for a reply that never comes and the CI job appears frozen. Use the print-function overload of tst::check (the lambda is only invoked when the check fails) and guard front() with an empty() check. Also remove the temporary diagnostic CI steps and give the test step a 2-minute timeout as a safety net. --- .github/workflows/ci.yml | 61 +++++---------------------------- tests/unit/src/tree_reading.cpp | 46 +++++-------------------- 2 files changed, 17 insertions(+), 90 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c62874..2e1d39d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -508,61 +508,16 @@ jobs: cd build/vcpkg/test cmake . cmake --build . --parallel - # ====== TEMPORARY DIAGNOSTIC STEPS for investigating the Windows Debug test hang ====== - # each step runs the test exe with a hard timeout (kill on hang), because the tests - # normally complete in well under a second. - # single test without file I/O, run on the main thread (no runner thread involved) - - name: diag - single non-IO test (no threads) - continue-on-error: true - run: | - cd build/vcpkg/test/exe\library-tests - $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=tree_ext','--test=find_tree_from_forest_by_string' -NoNewWindow -PassThru - try { Wait-Process -Id $p.Id -Timeout 90; exit $p.ExitCode } - catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } - # single test with file I/O, run on the main thread (no runner thread involved) - - name: diag - single IO test (no threads) - continue-on-error: true - run: | - cd build/vcpkg/test/exe\library-tests - $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=parser','--test=parse' -NoNewWindow -PassThru - try { Wait-Process -Id $p.Id -Timeout 90; exit $p.ExitCode } - catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } - # suite with a single test with file I/O, run via the runner thread (no --test, so runner is used) - - name: diag - parser suite (with runner thread) - continue-on-error: true - run: | - cd build/vcpkg/test/exe\library-tests - $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=parser' -NoNewWindow -PassThru - try { Wait-Process -Id $p.Id -Timeout 90; exit $p.ExitCode } - catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } - # tree_reading single test on the MAIN THREAD (no runner thread). - # --test forces main-thread execution with no_catch, so any thrown exception - # will print "terminate called after throwing ..." before the process hangs. - - name: diag - tree_reading single test (main thread) - continue-on-error: true - run: | - cd build/vcpkg/test/exe\library-tests - $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=tree_reading','--test=read' -NoNewWindow -PassThru - try {{ Wait-Process -Id $p.Id -Timeout 60; exit $p.ExitCode }} - catch {{ Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 }} - # tree_reading suite via the RUNNER thread (the case that hangs), with the - # instrumented test printing DIAG markers to stderr to locate the hang. - - name: diag - tree_reading suite (runner thread) - continue-on-error: true + # the tests complete in well under a second; the 2-minute timeout is a + # safety net so that a hung test process fails the job quickly instead of + # freezing the runner for an hour (see the 2026-11-03 freeze, caused by + # undefined behaviour in the tree_reading test under MSVC debug CRT) + - name: test vcpkg port - test + timeout-minutes: 2 run: | - cd build/vcpkg/test/exe\library-tests - $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--suite=tree_reading' -NoNewWindow -PassThru - try {{ Wait-Process -Id $p.Id -Timeout 60; exit $p.ExitCode }} - catch {{ Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 }} - - # full suite with per-test output to identify the hanging test - - name: diag - full suite with per-test output - run: | - cd build/vcpkg/test/exe\library-tests - $p = Start-Process -FilePath '.\Debug\library-tests.exe' -ArgumentList '--about-to-run','--passed' -NoNewWindow -PassThru - try { Wait-Process -Id $p.Id -Timeout 120; exit $p.ExitCode } - catch { Write-Host 'DIAG TIMEOUT: test hung, killing the process'; Stop-Process -Id $p.Id -Force; exit 1 } + cd build/vcpkg/test + cmake --build . --target run-library-tests - name: upload vcpkg logs to artifacts if: always() # even if previous steps fail, this one needs to be run uses: actions/upload-artifact@v4 diff --git a/tests/unit/src/tree_reading.cpp b/tests/unit/src/tree_reading.cpp index a3284c1..2da8b67 100644 --- a/tests/unit/src/tree_reading.cpp +++ b/tests/unit/src/tree_reading.cpp @@ -1,26 +1,15 @@ -#include - #include #include #include -// TEMPORARY: diagnostic markers for Windows CI freeze investigation -#define DIAG_MARK(msg) \ - do { \ - std::cerr << "DIAG: " << msg << std::endl; \ - } while (0) - #include "../../../src/tml/tree.hpp" namespace { const tst::set set("tree_reading", [](tst::suite& suite) { suite.add("read", []() { - DIAG_MARK("test start"); fsif::native_file fi("res/tree_reading_data/test.tml"); - DIAG_MARK("file object created"); auto roots = tml::read(fi); - DIAG_MARK("parsed, roots.size() = " << roots.size()); auto i = roots.begin(); @@ -28,74 +17,70 @@ const tst::set set("tree_reading", [](tst::suite& suite) { tst::check(i->children.size() == 0, SL); ++i; - DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "test string", SL) << i->value.string; tst::check(i->children.size() == 0, SL); ++i; - DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "anot/her string", SL) << i->value.string; tst::check(i->children.size() == 0, SL); ++i; - DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "unqu/otedString", SL); tst::check(i->children.size() == 0, SL); ++i; - DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "one\ntwo three\tfour", SL); tst::check(i->children.size() == 0, SL); ++i; - DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "string with empty children list", SL); tst::check(i->children.size() == 0, SL); ++i; - DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "unquoted_string_with_empty_children_list", SL); tst::check(i->children.size() == 0, SL); ++i; - DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "string_interrupted//tro", SL) << i->value.string; tst::check(i->children.size() == 0, SL); ++i; - DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "_by_comment", SL) << i->value.string; tst::check(i->children.size() == 0, SL); ++i; - DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "string_broken/*tro", SL) << i->value.string; tst::check(i->children.size() == 0, SL); ++i; - DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "*/_by_comment", SL) << i->value.string; tst::check(i->children.size() == 0, SL); ++i; - DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "", SL); tst::check(i->value.length() == 0, SL); - tst::check(i->children.size() == 0, SL) << i->children.front().value.string; + tst::check( + i->children.size() == 0, + [&i](auto& o) { + if (!i->children.empty()) { + o << i->children.front().value.string; + } + }, + SL + ); ++i; - DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "", SL); tst::check(i->value.length() == 0, SL); @@ -119,7 +104,6 @@ const tst::set set("tree_reading", [](tst::suite& suite) { } ++i; - DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "", SL); tst::check(i->children.size() == 5, SL) << i->children.size(); @@ -134,19 +118,16 @@ const tst::set set("tree_reading", [](tst::suite& suite) { tst::check(i->value == "child string", SL); ++i; - DIAG_MARK("root ++i"); tst::check(i != b.end(), SL); tst::check(i->value == "aaa", SL); tst::check(i->children.size() == 0, SL); ++i; - DIAG_MARK("root ++i"); tst::check(i != b.end(), SL); tst::check(i->value == "bbb", SL); tst::check(i->children.size() == 0, SL); ++i; - DIAG_MARK("root ++i"); tst::check(i != b.end(), SL); tst::check(i->value == "ccc", SL); { @@ -157,54 +138,45 @@ const tst::set set("tree_reading", [](tst::suite& suite) { } ++i; - DIAG_MARK("root ++i"); tst::check(i != b.end(), SL); tst::check(i->value == "", SL); tst::check(i->children.size() == 0, SL); ++i; - DIAG_MARK("root ++i"); tst::check(i == b.end(), SL); } ++i; - DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "Tab\t Backslash\\ Slash/ Doubleslash// Newline\n Doublequotes\" {}{}{}{}", SL); tst::check(i->children.size() == 0, SL); ++i; - DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "quoted string with trailing slash /", SL); tst::check(i->children.size() == 0, SL); ++i; - DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "fff ggg", SL); tst::check(i->children.size() == 0, SL); ++i; - DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "tralala tro lo lo\ntre lele", SL); tst::check(i->children.size() == 0, SL); ++i; - DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "-1213.33", SL); tst::check(i->children.size() == 0, SL); ++i; - DIAG_MARK("root ++i"); tst::check(i != roots.end(), SL); tst::check(i->value == "UnquotedStringAtTheVeryEndOfTheFile", SL); tst::check(i->children.size() == 0, SL); ++i; - DIAG_MARK("root ++i"); tst::check(i == roots.end(), SL); { From 707636c0efca350b41d5d560ebec6a4e9eff7882 Mon Sep 17 00:00:00 2001 From: Ivan Gagis Date: Wed, 4 Nov 2026 22:31:33 +0200 Subject: [PATCH 6/6] address review: use utki::assert instead of if guard in print lambda The print-function lambda of tst::check is only invoked when the check has failed, i.e. when the children list is not empty, so the empty() guard was redundant. Assert the invariant instead. --- tests/unit/src/tree_reading.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/unit/src/tree_reading.cpp b/tests/unit/src/tree_reading.cpp index 2da8b67..fb84a86 100644 --- a/tests/unit/src/tree_reading.cpp +++ b/tests/unit/src/tree_reading.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include "../../../src/tml/tree.hpp" @@ -73,9 +74,9 @@ const tst::set set("tree_reading", [](tst::suite& suite) { tst::check( i->children.size() == 0, [&i](auto& o) { - if (!i->children.empty()) { - o << i->children.front().value.string; - } + // if the check has failed, then the children list is not empty + utki::assert(!i->children.empty(), SL); + o << i->children.front().value.string; }, SL );