Repository navigation
Windows GUI subsystem has no target-scoped declaration: package ldflags reach tests and consumers, so apps fall back to #pragma comment(linker) #618
Description
Problem
A Windows GUI application needs its PE image marked with the WINDOWS subsystem. Otherwise the loader allocates a console window when the program is launched from Explorer or from an installer shortcut, and closing that console kills the application.
On the MSVC ABI this takes two linker options that only work as a pair:
/subsystem:windowsalone makes the linker default the entry toWinMainCRTStartup, so a portableint main()fails withLNK2019: unresolved external symbol WinMain./entry:mainCRTStartupkeeps CRT initialization and callsmain(). Writing/entry:maininstead links, but it skips CRT initialization, so static constructors and stdio setup never run.
MinGW spells the same selection -mwindows.
mcpp has no scope where this selection belongs. It is a property of one executable, but every place that can carry a link option today is package-wide:
| Where | Why it does not fit |
|---|---|
[build] ldflags / [target.windows.build] ldflags |
Lands in the global $ldflags of build.ninja, so every test binary from mcpp test also becomes a GUI-subsystem program with no console output |
mcpp:link-flag= from build.mcpp |
Documented to reach the consumer, exactly like [build] ldflags (docs/30-build-mcpp.md) |
[targets.<name>] |
Accepts defines / cxxflags / cflags / required_features (plus kind / main / soname / exports); the flags apply to the entry TU only, and there is no link-side key |
The subsystem also cannot be added by a shared build-rule package. A rule package's configure() is used by component libraries too, and anything it emits through link-flag reaches their consumers.
What projects do today
HuxerUI puts linker directives into every application entry source (HuxerUI/HuxerUI@445488a):
#if defined(_WIN32) && defined(_MSC_VER)
// Keep GUI linking local to this entry; mcpp package link flags also reach tests and consumers.
#pragma comment(linker, "/subsystem:windows")
#pragma comment(linker, "/entry:mainCRTStartup")
#endif
int main() { return huxerui::RunApplication(); }This is the only leak-free spelling available, because the directive lives in the one object file that only the application links. Its costs:
- It only works on the MSVC ABI. GNU
ldignores#pragma comment(linker), so awindows-gnubuild of the same application still opens a console, and nothing in the source can fix that. - It is repeated in every entry source: 2 application templates and 3 examples in HuxerUI, plus every application generated from those templates.
- The build tool cannot see it. mcpp has no way to know the artifact is a GUI program (for example, when
mcpp runlaunches it and the terminal shows no output).
#365's reporter used the package-wide form, which has the test and consumer leak described above:
[target.'cfg(windows)'.build]
ldflags = ["-Wl,-subsystem:windows", "-Wl,-entry:mainCRTStartup", ...]The design note for #365 already set this aside for later: .agents/docs/2026-08-07-windows-resources-and-version-identity-design.md §A6 says subsystem / entry belong to the same "Windows GUI application" story and deserve separate triage. [resources] has landed, and this is the missing half.
Proposal
Add a target-scoped, dialect-neutral statement on kind = "bin" targets, in the style of exports and [resources] (one statement, rendered per target):
[targets.myapp]
kind = "bin"
main = "src/main.cpp"
subsystem = "windows" # "console" (default) | "windows"; name open to discussion| Target | Rendering |
|---|---|
| PE, MSVC ABI | /subsystem:windows /entry:mainCRTStartup (the -Wl, form under the GNU driver) |
| PE, MinGW | -mwindows |
| ELF / Mach-O | Inapplicable: no diagnostic, byte-identical build (same policy as [resources]) |
Details worth settling:
- Entry function. The default should keep a portable
main(). Programs that defineWinMain/wWinMain/wmainneed the matching CRT startup (WinMainCRTStartup,wWinMainCRTStartup,wmainCRTStartup), either through an explicit key such asentry = "wWinMain"or through a spelled-out rule. - Scope. The option applies to that target's
LinkUnit::Binaryonly. It must never reachTestBinarylink units, other targets of the package, or consumers. - Implementation surface. The engine already carries per-link-edge flags.
LinkUnit::linkFlags(src/build/plan.cppm) is rendered into$unit_ldflagsper edge (src/build/ninja_backend.cppm), so this looks like manifest parsing and validation plus appending to the matching link unit, with no new plumbing.
Optional follow-up: a build-program directive naming a target, such as mcpp::target_subsystem("myapp", "windows"). A framework's rule package already knows the application's bin target (HuxerUI's installer rule receives it), so it could select the subsystem without the application writing anything. Because the directive names a target of the package being built, it would not leak the way link-flag does. This is the mcpp equivalent of CMake's WIN32_EXECUTABLE being set by a framework's add_app helper.
A generic [targets.<name>] ldflags would also close the leak, but it keeps the linker-specific spelling, needs a cfg per ABI, and leaves the subsystem/entry pairing to every user. It could be added as an escape hatch, but it does not replace the neutral key.
Prior art
- CMake:
add_executable(app WIN32 ...)/ theWIN32_EXECUTABLEtarget property - Meson:
executable(..., win_subsystem: 'windows') - Rust:
#![windows_subsystem = "windows"]; rustc renders/SUBSYSTEM:WINDOWS /ENTRY:mainCRTStartupon MSVC
Acceptance
- A Windows
kind = "bin"target withsubsystem = "windows"andint main()links on both the MSVC ABI and MinGW, and its PE optional header readsIMAGE_SUBSYSTEM_WINDOWS_GUI(checked by bytes). - In the same package, test binaries built by
mcpp testand any otherbintarget keepIMAGE_SUBSYSTEM_WINDOWS_CUI, and a consumer of a library in the graph is unaffected. - Static constructors in the GUI target run before
main(), which shows CRT initialization is kept. - The same manifest builds on Linux and macOS with zero warnings and a byte-identical artifact.
- Changing the key triggers a relink.
Environment
- mcpp 2026.9.11.2 (docs and sources read at c58d61e)
- Reporter's project: HuxerUI (clang targeting
x86_64-pc-windows-msvc, mcpp's default Windows toolchain)
Activity
feat: the engine gaps left open after the SDK batch (2026.9.12.2)
The engine gaps left open after the SDK batch (2026.9.12.2) (#619)
Sunrisepeak commentedon Sep 11, 2026
Resolved in mcpp 2026.9.12.2 (#619).
[targets.<name>]gainswindows_subsystem = "console" | "windows"andwindows_entry = "main" | "wmain" | "WinMain" | "wWinMain". They are fields rather than link flags because the correct flags depend on the ABI:/SUBSYSTEM:together with the CRT startup symbol/ENTRY:<entry>CRTStartup, spelled with-Wl,under a GNU-style driver.-mwindowsand-municode.The keys reach the declaring executable's link and no other target, test binary or consumer. A library target is refused naming the key.
A build program sets the same fields for a named executable of its package with
mcpp::windows_subsystem(target, value)andmcpp::windows_entry(target, value)(directive protocol 10).Measured on native Windows CI (tests/e2e/642):
mainin the GUI program;tests/e2e/643 checks the GNU ABI bytes through mingw-cross.