Skip to content

Build on Windows with MSVC - #4

Open
quartzjer wants to merge 5 commits into
localai-org:mainfrom
solpbc:fix/msvc-build
Open

Build on Windows with MSVC#4
quartzjer wants to merge 5 commits into
localai-org:mainfrom
solpbc:fix/msvc-build

Conversation

@quartzjer

@quartzjer quartzjer commented Aug 20, 2026

Copy link
Copy Markdown

What

Three small portability fixes, a test-registration guard, and one CI job so it stays fixed. Together they get a clean MSVC build of the library, rfdetr-cli, and the portable part of the test suite. Fixes 1 to 3 are independent of each other; fix 5 needs 1 through 4. Nothing here changes detection behaviour on gcc or clang, and one behaviour difference is called out in Notes.

  1. src/common.hpp: rfdetr_logf is declared with __attribute__((format(printf, 2, 3))). MSVC has no __attribute__, so the declaration does not parse (C3646/C2059) and the definition in common.cpp then reads as a redefinition (C2084). Wrapped in RFDETR_ATTRIBUTE_FORMAT, with the same three cases ggml.h already handles for GGML_ATTRIBUTE_FORMAT: empty on non-__GNUC__, gnu_printf on MinGW-without-clang, printf elsewhere.

  2. src/model_loader.cpp: bicubic_resample_patch_grid declares constexpr float A as an automatic and then names it from a captureless lambda. gcc and clang accept that (A is read as a constant, never odr-used); MSVC rejects it with C3493, and the two errors that follow at line 159 are that failure cascading through kernel. static constexpr gives it static storage duration, so no capture is needed. Same constant, same value.

  3. examples/cli/main.cpp: the --masks path calls POSIX ::mkdir, which MSVC does not provide (it has _mkdir, in <direct.h>). Replaced the stat-probe-then-mkdir pair with std::filesystem::create_directories, which is a no-op when the directory exists, so the probe goes with it. This adds no new build requirement: the vendored ggml already uses std::filesystem in ggml-backend-reg.cpp, which every default build compiles, and nothing in the tree links stdc++fs. The two remaining ::stat calls are left alone, since MSVC provides stat.

  4. tests/CMakeLists.txt: test_cli_integration drives rfdetr-cli as a child process through <sys/wait.h>, so building the suite with MSVC fails outright on that one file. Its registration is now guarded with NOT WIN32. The other 25 tests are portable and still build and run under MSVC; on Linux nothing changes, it stays registered and run as before.

  5. .github/workflows/ci.yml: one windows-2022 job in the same shape as the existing ubuntu build job: configure, compile, ctest, usage banner. CPU only and no model downloads, so it guards the compile rather than the numbers, which the ubuntu smoke-test job already covers. It brings the first non-actions/* dependency into this repo's CI: ilammy/msvc-dev-cmd@v1 for the MSVC environment, the same action at the same ref your release workflows in ced.cpp, voice-detect.cpp, face-detect.cpp and parakeet.cpp already use. It also runs the ggml patch script explicitly, since the configure-time hook needs bash on PATH.

Why

ci.yml runs on ubuntu-latest only, so nothing exercises MSVC today, and the tree currently stops on the first translation unit under it. These came up one at a time while adding windows jobs to a release workflow (#5). Each fix revealed the next, which is the argument for fix 5: without a Windows job the next one lands the same way, unnoticed.

Notes

  • The CI job costs 1m49s of runner time and no added wall clock. Measured on a fork: 27s configure, 38s build, 4s for the 25 tests, the rest checkout and MSVC setup. It has no needs:, so it starts alongside the ubuntu build job and finishes well inside the existing critical path, which is build then smoke-test and the ~370 MB of model downloads your own comment at ci.yml:86 budgets for. A PR does not wait any longer than it does today. If you would rather not carry it, deleting the build-windows job is the only change needed and the fixes stand on their own.

  • The behaviour differences worth calling out, all in fix 3: create_directories also creates missing parents, where ::mkdir created only the leaf, so --masks some/new/dir now works instead of failing; it uses the default mode (0777 & ~umask) where ::mkdir passed 0755; and a --masks path that already exists as a file now prints the error, where the stat probe used to succeed and stay quiet. The error message itself is unchanged. Happy to keep any of these as they were.

  • Fix 1 is the one worth a second look, since a wrong macro would silently disable format checking rather than fail. After the change, rfdetr_logf(RFDETR_LOG_INFO, "%d", "not an int") compiled with -Wformat still warns on gcc.

Validation

On Linux/gcc, from a clean checkout:

cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=OFF \
      -DBUILD_SHARED_LIBS=OFF -DRFDETR_BUILD_CLI=ON -DRFDETR_BUILD_TESTS=OFF
cmake --build build -j$(getconf _NPROCESSORS_ONLN)
./build/bin/rfdetr-cli detect --model rfdetr-nano-f16.gguf \
      --input tests/fixtures/ci/test_image.jpg --threshold 0.55 --threads 4 --output out.json
python3 tests/ci/compare_detections.py --actual out.json \
      --reference tests/fixtures/ci/expected_nano-f16.json --label gcc

detect output is byte-identical to the same build before these commits, and matches the committed reference (5/5 detections, 0 extras). The --masks path was exercised both ways: a nested target directory is created, and an uncreatable one still prints failed to create masks dir '...'. With tests on, test_cli_integration is still registered and run on Linux, Test #6 of 26.

On Windows, two things ran on a fork:

Both runs are on the fork's main, which is this branch plus the release workflow from the companion PR; the five changes here are byte-identical on both. Worth knowing if you review them side by side: the release matrix builds with RFDETR_BUILD_TESTS=OFF, so it never compiled the suite and could not have found fix 4, the test guard. The CI job with tests on is what surfaced test_cli_integration.

src/common.hpp declares rfdetr_logf with __attribute__((format(printf,
2, 3))). MSVC has no __attribute__, so the declaration fails to parse
(C3646 / C2059) and the definition in common.cpp then reads as a
redefinition (C2084). The whole tree stops on the first translation
unit, so rf-detr.cpp does not build on Windows at all today.

Wrap it in RFDETR_ATTRIBUTE_FORMAT with the same three cases ggml.h
already handles for GGML_ATTRIBUTE_FORMAT: empty on non-GNUC, gnu_printf
on MinGW, printf elsewhere. No change on gcc/clang — the attribute is
still applied and -Wformat still fires on a mismatched call.
bicubic_resample_patch_grid declares `constexpr float A` as an automatic
and then names it from a captureless lambda. gcc and clang accept that —
A is never odr-used, only read as a constant — but MSVC rejects it with
C3493 ('cannot be implicitly captured because no default capture mode
has been specified'), and the two follow-on errors at line 159 are that
failure cascading through `kernel`.

Making A `static constexpr` gives it static storage duration, so no
capture is needed and every compiler accepts it. Same constant, same
value, no behaviour change.
examples/cli/main.cpp calls POSIX ::mkdir, which MSVC does not provide —
it has _mkdir in <direct.h> — so the CLI is the last thing blocking a
Windows build once the library compiles.

Replace the stat-probe-then-mkdir pair with std::filesystem::create_
directories, which is already available (the project is C++17) and is a
no-op when the directory exists, so the probe is redundant. The two
remaining ::stat calls are left alone: MSVC does provide stat.

One behaviour difference worth naming: create_directories also creates
missing parents, where ::mkdir created only the leaf. --masks some/new/dir
now works instead of failing. The error message is unchanged.
test_cli_integration drives rfdetr-cli as a child process and includes
<sys/wait.h>, which MSVC does not have, so enabling RFDETR_BUILD_TESTS on
Windows fails the build outright on that one file.

Guard its registration with NOT WIN32. Everything else under tests/ is
portable, so the remaining 25 tests still build and run under MSVC, which
is what makes a Windows CI job worth having. On Linux the test is registered
and run exactly as before.
Everything in ci.yml is ubuntu-only, so nothing has ever compiled this tree
with MSVC. That is why three portability breaks reached main unnoticed: an
__attribute__ on a declaration, an automatic constexpr named from a
captureless lambda, and a POSIX-only ::mkdir. All three build clean on gcc
and stop MSVC on the first translation unit.

Add one windows-2022 job in the same shape as the ubuntu build job -
configure, compile, ctest, usage banner. CPU only and no model downloads:
this guards the compile, not the detection numbers, which the existing
ubuntu smoke-test job already covers.

The ggml patch script is invoked explicitly before configure because the
CMake configure-time hook shells out to bash, which is not on PATH in the
MSVC environment.
@localai-org-maint-bot

Copy link
Copy Markdown

@mudler This is good to merge. The MSVC fixes are narrowly scoped, Linux behavior is preserved, and the added Windows job covers the remaining portable test suite. The linked fork runs provide the missing branch checks: 25 tests and the CLI usage smoke test pass under MSVC.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants