From 138c56e541d072979c466c630c7b29472cf17192 Mon Sep 17 00:00:00 2001 From: David Connolly Date: Fri, 12 Jun 2026 17:00:45 +0100 Subject: [PATCH 1/6] ci: bake the manifest in universally by driving windres with the full-path preprocessor The native-Windows parity job (MSYS2 MINGW32, real cmd.exe/Winsock/ConPTY) died at the RC step: its windres (binutils 2.46) fails to spawn a bare-named preprocessor ('CreateProcess failed: The system cannot find the file specified'). Rather than skip the manifest there (which would build a different binary than ships and weaken the parity), drive windres with the FULL resolved path to the C compiler CMake already verified - which spawns reliably on every host. The RT_MANIFEST now bakes into the SAME binary on the Ubuntu cross build and the native-Windows build alike. The .rc is self-contained (expands only its own two #defines, no system include), so any cpp suffices; this only makes the spawn reliable. Verified locally: windres uses the full path and wrestool shows one type-24 manifest. Supersedes the toolchain's bare-name Windows-only override. Co-Authored-By: Claude Opus 4.8 --- CMakeLists.txt | 15 +++++++++++++++ toolchains/mingw-w64-i386.cmake | 10 ++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index fb3017c..b787691 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,6 +20,21 @@ project(mcp-w32s C) # both drive a .rc through this language - no per-toolchain special-casing. enable_language(RC) +# Universal windres preprocessor (MinGW only): drive it with the FULL PATH to the +# C compiler CMake already resolved and verified, never a bare name. MSYS2's +# windres (binutils 2.46, the native-Windows CI runner) fails to spawn a +# bare-named preprocessor ("CreateProcess failed: The system cannot find the file +# specified"); a resolved path with its .exe spawns on every host, so the +# RT_MANIFEST bakes into the SAME binary on the Ubuntu cross build AND the +# native-Windows build (no runner-specific divergence). The .rc only expands its +# own two #defines - no system include - so any cpp suffices; this just makes the +# spawn reliable. CMAKE_C_COMPILER is the full resolved path here (post-project). +# VC6's rc.exe ignores these windres flags, so guard to the GNU/windres path. +if(CMAKE_C_COMPILER_ID STREQUAL "GNU") + set(CMAKE_RC_FLAGS + "--use-temp-file --preprocessor=${CMAKE_C_COMPILER} --preprocessor-arg=-E --preprocessor-arg=-xc-header --preprocessor-arg=-DRC_INVOKED") +endif() + # C89 strict, no GNU extensions (matches the historic -std=c89 -pedantic). set(CMAKE_C_STANDARD 90) set(CMAKE_C_STANDARD_REQUIRED ON) diff --git a/toolchains/mingw-w64-i386.cmake b/toolchains/mingw-w64-i386.cmake index 1ddec3b..6600d8d 100644 --- a/toolchains/mingw-w64-i386.cmake +++ b/toolchains/mingw-w64-i386.cmake @@ -17,19 +17,13 @@ set(CMAKE_RC_COMPILER ${TOOLCHAIN_PREFIX}-windres) # the PEs run against real cmd.exe/Winsock/ConPTY instead of Wine), the # compiler's own sysroot is already correct and the Linux path below does not # exist, so leave CMake's default search alone there. +# (The windres preprocessor is configured universally in CMakeLists.txt - with +# the resolved full-path compiler - so it bakes the manifest in on both hosts.) if(NOT CMAKE_HOST_WIN32) set(CMAKE_FIND_ROOT_PATH /usr/${TOOLCHAIN_PREFIX}) set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) -else() - # MSYS2's i686 windres fails to spawn its default preprocessor ("CreateProcess - # failed: The system cannot find the file specified") when CMake drives it. - # Point it at the mingw32 gcc explicitly and use a temp file rather than the - # pipe spawn that misbehaves on Windows. (binutils 2.36+ --preprocessor-arg - # syntax.) Linux cross-windres needs none of this and is left untouched. - set(CMAKE_RC_FLAGS - "--use-temp-file --preprocessor=i686-w64-mingw32-gcc --preprocessor-arg=-E --preprocessor-arg=-xc-header --preprocessor-arg=-DRC_INVOKED") endif() # i386-only codegen + the strict warning gate the project has always used. From b59a3da95cf7d99ddb418aa65f421fcd99ef9c25 Mon Sep 17 00:00:00 2001 From: David Connolly Date: Fri, 12 Jun 2026 17:53:34 +0100 Subject: [PATCH 2/6] ci: embed the manifest from a prebuilt .res so the native runner needs no windres preprocessor The MSYS2 native-Windows parity job's windres (binutils 2.46) cannot spawn a preprocessor to compile the .rc - even a full-path .exe gives 'CreateProcess failed: The system cannot find the file specified'. Ship the manifest as a prebuilt, byte-deterministic src/mcp-w32s.res instead: compiling .res -> coff needs NO preprocessor, so it builds identically on the Ubuntu cross host and the native-Windows runner - the SAME manifest in every binary (no runner-specific divergence). MSVC keeps rc.exe on the .rc. A new Ubuntu CI step regenerates the .res from the .rc + .manifest (where the cross preprocessor works) and byte- diffs it, so the committed artifact can never drift from source. Verified locally: res->coff runs without --preprocessor, the exe carries one RT_MANIFEST, imports stay {kernel32,user32}. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build-and-test.yml | 15 +++++++++ CMakeLists.txt | 46 +++++++++++++++------------ src/mcp-w32s.res | Bin 0 -> 528 bytes 3 files changed, 41 insertions(+), 20 deletions(-) create mode 100644 src/mcp-w32s.res diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 379b642..18d8f52 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -125,6 +125,21 @@ jobs: # one alongside ours) would be a malformed resource directory; zero means # the *A APIs never switch to UTF-8. wrestool (icoutils) lists and extracts # the resource; --raw dumps the manifest bytes verbatim for the content grep. + # The MinGW build embeds a PREBUILT src/mcp-w32s.res (so the MSYS2 native + # runner needs no windres preprocessor, which is broken there on binutils + # 2.46). Guard the committed artifact against drift: regenerate the .res from + # the .rc + .manifest here - where the cross windres preprocessor works - and + # byte-diff. A stale .res fails the build and prints the regen command. + - name: Verify the prebuilt manifest .res matches its source + run: | + i686-w64-mingw32-windres -I src src/mcp-w32s.rc -O res -o /tmp/regen.res + if ! cmp -s /tmp/regen.res src/mcp-w32s.res; then + echo "ERROR: src/mcp-w32s.res is stale - regenerate it:" + echo " i686-w64-mingw32-windres -I src src/mcp-w32s.rc -O res -o src/mcp-w32s.res" + exit 1 + fi + echo "OK: prebuilt manifest .res matches mcp-w32s.rc + mcp-w32s.manifest." + - name: Verify exactly one UTF-8 activeCodePage manifest at RT_MANIFEST ID 1 run: | N=$(wrestool -l build/mingw/mcp-w32s.exe | grep -c -- '--type=24 --name=1' || true) diff --git a/CMakeLists.txt b/CMakeLists.txt index b787691..164e507 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,24 +15,30 @@ cmake_minimum_required(VERSION 3.21) project(mcp-w32s C) -# RC compiles the application manifest (src/mcp-w32s.rc) into mcp-w32s.exe only. -# windres on MinGW (CMAKE_RC_COMPILER, set by the toolchain) and rc.exe on MSVC -# both drive a .rc through this language - no per-toolchain special-casing. -enable_language(RC) - -# Universal windres preprocessor (MinGW only): drive it with the FULL PATH to the -# C compiler CMake already resolved and verified, never a bare name. MSYS2's -# windres (binutils 2.46, the native-Windows CI runner) fails to spawn a -# bare-named preprocessor ("CreateProcess failed: The system cannot find the file -# specified"); a resolved path with its .exe spawns on every host, so the -# RT_MANIFEST bakes into the SAME binary on the Ubuntu cross build AND the -# native-Windows build (no runner-specific divergence). The .rc only expands its -# own two #defines - no system include - so any cpp suffices; this just makes the -# spawn reliable. CMAKE_C_COMPILER is the full resolved path here (post-project). -# VC6's rc.exe ignores these windres flags, so guard to the GNU/windres path. -if(CMAKE_C_COMPILER_ID STREQUAL "GNU") - set(CMAKE_RC_FLAGS - "--use-temp-file --preprocessor=${CMAKE_C_COMPILER} --preprocessor-arg=-E --preprocessor-arg=-xc-header --preprocessor-arg=-DRC_INVOKED") +# RT_MANIFEST (the UTF-8 activeCodePage manifest), embedded in mcp-w32s.exe only. +# On MSVC, rc.exe compiles src/mcp-w32s.rc directly. On MinGW the manifest is +# shipped as a PREBUILT, byte-deterministic resource (src/mcp-w32s.res, generated +# from mcp-w32s.rc + mcp-w32s.manifest): compiling the committed .res to a coff +# object needs NO preprocessor, so it builds on every runner - including the +# MSYS2 native-Windows CI runner whose windres (binutils 2.46) cannot spawn a +# preprocessor to compile the .rc directly ("CreateProcess failed: The system +# cannot find the file specified", even with a full-path .exe). The SAME manifest +# lands in every binary; the Ubuntu CI job regenerates the .res from source and +# byte-diffs it, so the committed artifact can never drift from the .rc/.manifest. +if(MSVC) + enable_language(RC) + set(MCP_MANIFEST src/mcp-w32s.rc) +else() + set(MCP_MANIFEST ${CMAKE_BINARY_DIR}/mcp-w32s_manifest.obj) + add_custom_command( + OUTPUT ${MCP_MANIFEST} + COMMAND ${CMAKE_RC_COMPILER} --input-format=res -O coff + ${CMAKE_CURRENT_SOURCE_DIR}/src/mcp-w32s.res ${MCP_MANIFEST} + DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/src/mcp-w32s.res + COMMENT "Embedding the prebuilt RT_MANIFEST (res -> coff, no preprocessor)" + VERBATIM) + set_source_files_properties(${MCP_MANIFEST} PROPERTIES + EXTERNAL_OBJECT TRUE GENERATED TRUE) endif() # C89 strict, no GNU extensions (matches the historic -std=c89 -pedantic). @@ -110,11 +116,11 @@ set(CORE_SOURCES # never on Win32s. The VC6 toolchain path is unchanged. # (Phase 6 Finding #1 remediation; see plan/PHASE6.md in the host repo.) if(CMAKE_C_COMPILER_ID STREQUAL "GNU") - add_executable(mcp-w32s src/mcp-w32s.c ${CORE_SOURCES} src/mini_crt.c src/mcp-w32s.rc) + add_executable(mcp-w32s src/mcp-w32s.c ${CORE_SOURCES} src/mini_crt.c ${MCP_MANIFEST}) target_link_options(mcp-w32s PRIVATE -nostdlib -mconsole) target_link_libraries(mcp-w32s PRIVATE kernel32 user32 gcc) else() - add_executable(mcp-w32s src/mcp-w32s.c ${CORE_SOURCES} src/mcp-w32s.rc) + add_executable(mcp-w32s src/mcp-w32s.c ${CORE_SOURCES} ${MCP_MANIFEST}) target_link_libraries(mcp-w32s PRIVATE kernel32 user32) endif() diff --git a/src/mcp-w32s.res b/src/mcp-w32s.res new file mode 100644 index 0000000000000000000000000000000000000000..1bee3b27c65bcd2e46b839d78b59278d149e3206 GIT binary patch literal 528 zcma)(O>4t242CnE*?FS2Ue{xKeDPMjL!4he;|0coPci zSDAcyeu!_RK+?X@W$=*622jrO!BiuHdoLTo{T?&9sr>}a8l1-1SyKlLONvc!6OAs} z4yAC~+a(~q{(dYU2yxPy7vRZKBdDqPsh#!XkrZA#PMoRY-FSqu2b-iVa8(Bbh@rl# z&Ds(e+SJ@~E!9E3s!D4#@r3`R9m}?n$rzysJ6r$}8-j<^me`Qp!hI;(w~X=D^&Lv`5^iN9$Ka4 literal 0 HcmV?d00001 From 9c28166d00c2904feabb44d994f76fbbd7d71cd9 Mon Sep 17 00:00:00 2001 From: David Connolly Date: Fri, 12 Jun 2026 18:06:08 +0100 Subject: [PATCH 3/6] ci: resolve windres to a full path for the prebuilt-.res custom command The res->coff custom command runs via cmd.exe on the MSYS2 native runner, where a bare 'i686-w64-mingw32-windres' is 'not recognized' - CMake resolves the RC compiler internally for its own RC rule, but ${CMAKE_RC_COMPILER} stays the bare toolchain name. find_program (run at configure time in the MSYS2 PATH) yields the full path cmd.exe can spawn. Verified locally: MCP_WINDRES resolves to the full path, the custom command uses it, the manifest is present. Co-Authored-By: Claude Opus 4.8 --- CMakeLists.txt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 164e507..0b35b14 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,14 +25,20 @@ project(mcp-w32s C) # cannot find the file specified", even with a full-path .exe). The SAME manifest # lands in every binary; the Ubuntu CI job regenerates the .res from source and # byte-diffs it, so the committed artifact can never drift from the .rc/.manifest. +enable_language(RC) # MSVC: rc.exe compiles the .rc. (MinGW uses the .res below.) if(MSVC) - enable_language(RC) set(MCP_MANIFEST src/mcp-w32s.rc) else() + # Resolve windres to a FULL PATH. The custom command below runs via cmd.exe on + # the MSYS2 native runner, where a bare `i686-w64-mingw32-windres` is "not + # recognized" - CMake's own RC rule resolves it internally, but the + # ${CMAKE_RC_COMPILER} variable stays the bare toolchain name. find_program runs + # at configure time in the MSYS2 PATH and yields the full path cmd.exe can spawn. + find_program(MCP_WINDRES NAMES "${CMAKE_RC_COMPILER}" windres REQUIRED) set(MCP_MANIFEST ${CMAKE_BINARY_DIR}/mcp-w32s_manifest.obj) add_custom_command( OUTPUT ${MCP_MANIFEST} - COMMAND ${CMAKE_RC_COMPILER} --input-format=res -O coff + COMMAND ${MCP_WINDRES} --input-format=res -O coff ${CMAKE_CURRENT_SOURCE_DIR}/src/mcp-w32s.res ${MCP_MANIFEST} DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/src/mcp-w32s.res COMMENT "Embedding the prebuilt RT_MANIFEST (res -> coff, no preprocessor)" From fe0c120ca34148bb28c0e1a9d57f7bf5c7447c35 Mon Sep 17 00:00:00 2001 From: David Connolly Date: Fri, 12 Jun 2026 18:15:28 +0100 Subject: [PATCH 4/6] ci: use unprefixed objdump in the native job's import check (MSYS2 ships unprefixed binutils) The native runner got past the build (prebuilt .res) but the import check died with 'i686-w64-mingw32-objdump: command not found' (exit 127): MSYS2 MINGW32 provides binutils UNPREFIXED (objdump/windres/nm), not under the cross prefix that exists on the Ubuntu host. Use plain objdump on the native job (it is the i686 one in /mingw32/bin). The Ubuntu job keeps the cross-prefixed name. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build-and-test.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 18d8f52..45d50b7 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -484,7 +484,9 @@ jobs: cmake --build --preset mingw - name: Verify the device still imports ONLY kernel32 + user32 run: | - IMPORTS=$(i686-w64-mingw32-objdump -p build/mingw/mcp-w32s.exe \ + # MSYS2 MINGW32 ships UNPREFIXED binutils (objdump, not + # i686-w64-mingw32-objdump - the cross-prefixed name does not exist here). + IMPORTS=$(objdump -p build/mingw/mcp-w32s.exe \ | sed -n 's/.*DLL Name: //p' | tr 'A-Z' 'a-z' | sort -u) echo "imports: $IMPORTS" echo "$IMPORTS" | grep -vqE '^(kernel32|user32)\.dll$' && { From 3354c3fb3141068329a0af5a6b88104667554d11 Mon Sep 17 00:00:00 2001 From: David Connolly Date: Fri, 12 Jun 2026 21:07:12 +0100 Subject: [PATCH 5/6] test: use 'ver' not 'dir' for the shell-builtin tests (host-tolerant subject) shell_builtin_is_shell + shell_dir_routing assumed a bare 'dir' has no .exe on PATH and spawn-fails. That is false on runners that ship a coreutils dir.exe (MSYS2 /usr/bin on the native-Windows CI), where the device CORRECTLY resolves and classifies dir as a real PE - so the assertions failed there even though the device behaved right. Switch the subject to 'ver', a cmd built-in with no .exe equivalent on any host, so the 'unresolvable bare name -> shell built-in' path is exercised deterministically across the dev host, Wine, and real Windows. test_binfmt verified green; test_exec_ops is spawn-based (CI-verified, not the interop-down local wine). --- tests/test_binfmt.c | 11 +++++++---- tests/test_exec_ops.c | 23 +++++++++++++---------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/tests/test_binfmt.c b/tests/test_binfmt.c index 7b149b3..f1233e2 100644 --- a/tests/test_binfmt.c +++ b/tests/test_binfmt.c @@ -209,16 +209,19 @@ TEST_CASE(missing_file_errors) { TEST_ASSERT(err[0] != '\0', "error message populated"); } -/* 6. Shell built-in name ("dir") -> BIN_SHELL without file read */ +/* 6. Shell built-in name -> BIN_SHELL without file read. Use "ver" (a cmd + * built-in with no .exe equivalent on ANY host), not "dir": some runners ship a + * coreutils dir.exe on PATH (e.g. MSYS2 /usr/bin on the native-Windows CI), so + * "dir" correctly resolves to a real PE there - a host-tolerance trap. */ TEST_CASE(shell_builtin_is_shell) { char err[128]; BinaryType type; int ok; type = BIN_UNKNOWN; - ok = BinFmtClassify("dir", &type, err, sizeof(err)); - TEST_ASSERT_INT_EQUAL(1, ok, "classify 'dir' succeeds"); - TEST_ASSERT_INT_EQUAL(BIN_SHELL, type, "'dir' is shell built-in"); + ok = BinFmtClassify("ver", &type, err, sizeof(err)); + TEST_ASSERT_INT_EQUAL(1, ok, "classify 'ver' succeeds"); + TEST_ASSERT_INT_EQUAL(BIN_SHELL, type, "'ver' is shell built-in"); } /* 7. Uplift: after FeatInit, if pGetBinaryTypeA present, self still PE32. */ diff --git a/tests/test_exec_ops.c b/tests/test_exec_ops.c index 3077566..91a788c 100644 --- a/tests/test_exec_ops.c +++ b/tests/test_exec_ops.c @@ -335,11 +335,14 @@ TEST_CASE(invalid_base64_is_dispatcher) } /* ================================================================ - * #15 - shell=true vs shell=false: dir works only via shell. exec_ops - * receives a fully-built command line; the shell decision is the - * dispatcher's (decision 3, catalog auto-route). At the exec_ops - * layer we verify that "cmd /c dir" works and a bare "dir" (no - * shell wrap) spawn-fails - dir is not an .exe. + * #15 - shell=true vs shell=false: a cmd built-in works only via shell. + * exec_ops receives a fully-built command line; the shell decision is + * the dispatcher's (decision 3, catalog auto-route). At the exec_ops + * layer we verify that "cmd /c " works and a bare "" + * (no shell wrap) spawn-fails - the built-in is not an .exe. Use "ver", + * not "dir": some runners ship a coreutils dir.exe on PATH (e.g. MSYS2 + * /usr/bin on the native-Windows CI), so a bare "dir" correctly spawns + * that real PE there. "ver" has no .exe equivalent on any host. * ================================================================ */ TEST_CASE(shell_dir_routing) { @@ -347,17 +350,17 @@ TEST_CASE(shell_dir_routing) char msg[128]; int ok_shell, ok_bare; clear_bufs(); - ok_shell = ExecOpRun("cmd /c dir", "C:\\", T_TIMEOUT, 1, + ok_shell = ExecOpRun("cmd /c ver", "C:\\", T_TIMEOUT, 1, NULL, 0, g_out, sizeof(g_out), g_err, sizeof(g_err), 0, 0, BIN_PE32, &r, msg, sizeof(msg)); - TEST_ASSERT(ok_shell, "dir via shell works"); - TEST_ASSERT_INT_EQUAL(0, r.exit_code, "shelled dir exit 0"); + TEST_ASSERT(ok_shell, "ver via shell works"); + TEST_ASSERT_INT_EQUAL(0, r.exit_code, "shelled ver exit 0"); clear_bufs(); - ok_bare = ExecOpRun("dir", "C:\\", T_TIMEOUT, 1, + ok_bare = ExecOpRun("ver", "C:\\", T_TIMEOUT, 1, NULL, 0, g_out, sizeof(g_out), g_err, sizeof(g_err), 0, 0, BIN_PE32, &r, msg, sizeof(msg)); - TEST_ASSERT(!ok_bare, "bare dir (no .exe) spawn-fails"); + TEST_ASSERT(!ok_bare, "bare ver (no .exe) spawn-fails"); } /* ================================================================ From 2ee04fc83e6e82f51258468635f18cc8998b7172 Mon Sep 17 00:00:00 2001 From: David Connolly Date: Fri, 12 Jun 2026 21:16:24 +0100 Subject: [PATCH 6/6] ci: make the real-Windows job gating (drop continue-on-error) now it is green The native-Windows parity job builds and runs the full exec/Winsock/ConPTY suite green on real Windows (manifest from a prebuilt .res, unprefixed objdump, host-robust shell-builtin subject). Drop continue-on-error so its result counts toward the run conclusion. (A hard merge gate also needs it added to the branch-protection required checks.) Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build-and-test.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 45d50b7..1c6c7c3 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -459,10 +459,13 @@ jobs: windows-native: name: real-Windows (exec / Winsock / ConPTY, no Wine) runs-on: windows-latest - # Newly introduced job, still being stabilised on the MSYS2 toolchain - keep - # it informational so it cannot gate merges until it is reliably green; flip - # to a required check once the real-Windows run is consistently passing. - continue-on-error: true + # Gating (no longer continue-on-error): the manifest is embedded from a + # prebuilt .res (no windres preprocessor), the import check uses the + # MSYS2-unprefixed objdump, and the shell-builtin tests use a host-robust + # subject - so this job builds and runs the full exec/Winsock/ConPTY suite + # green on real Windows. It was informational while the MSYS2 toolchain was + # being stabilised. (Add it to the branch-protection required checks to make + # it a hard merge gate.) defaults: run: shell: msys2 {0}