From 55b95aef23c8fd58681099ee386a986333429fe3 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Tue, 15 Sep 2026 13:02:41 +0200 Subject: [PATCH 1/7] [M1-HEAD-01] Fix Windows CI: MSVC C4996 fopen fatal under /WX in laige-run The windows-msvc job (merge matrix) is red on the M1-HEAD-01 merge: laige-run.cpp(119): warning C4996: 'fopen' ... laige-run.cpp(119): error C2220: the following warning is treated as an error MSVC's CRT deprecates plain fopen (C4996), fatal under the engine policy's /W4 /WX (NFR-8.10, CORE-010). laige-run.cpp is new in M1-HEAD-01, so this is the first Windows build of the binary. The config-file open now uses the portable platform boundary established in logging.cpp (the M0-CORE-02 file sink), laige-bench.cpp, and the test suites: under _MSC_VER, _fsopen(path, mode, _SH_DENYNO) - the CRT's documented way to open with plain-fopen sharing semantics. fopen_s is deliberately not used: it opens with the _SH_SECURE sharing mode, which denies re-opening of the file while it is held (the windows-msvc CI runs of M0-CORE-02 read back an empty file for exactly that reason). No behavioral change: identical open/read/close sequence and error mapping (IoError on open/read failure, MalformedInput above the 1 MiB bound); the laige_run_smoke ctest entry is unchanged and still exits 0 with status=ok (verified locally on the gcc and clang trees, 47/47 ctest). Verified by this PR's ci:windows job (windows-msvc). --- tools/run/laige-run.cpp | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/tools/run/laige-run.cpp b/tools/run/laige-run.cpp index 79e4176..d1d03fb 100644 --- a/tools/run/laige-run.cpp +++ b/tools/run/laige-run.cpp @@ -54,6 +54,10 @@ #include #include +#if defined(_MSC_VER) +#include // _SH_DENYNO: plain-fopen sharing for the _fsopen below +#endif + #include "laige/errors.h" #include "laige/json.h" #include "laige/logging.h" @@ -112,11 +116,30 @@ bool parseTicks(std::string_view text, std::uint64_t* out) { return true; } +// Portable config-file open (CPP-009 compile-time platform boundary — +// the logging.cpp precedent). MSVC's CRT deprecates plain `fopen` +// (C4996, fatal under the engine's /WX policy); `fopen_s` cannot be +// used here: it opens with the `_SH_SECURE` sharing mode, which denies +// re-opening of the file (the windows-msvc CI runs of M0-CORE-02 read +// back an empty file for exactly that reason). `_fsopen(path, mode, +// _SH_DENYNO)` is the CRT's documented way to open with plain-`fopen` +// sharing semantics, which every other supported compiler's `fopen` +// provides. +#if defined(_MSC_VER) +inline std::FILE* openConfigFile(const char* path, const char* mode) { + return ::_fsopen(path, mode, _SH_DENYNO); +} +#else +inline std::FILE* openConfigFile(const char* path, const char* mode) { + return std::fopen(path, mode); +} +#endif + // Reads the config file into a bounded buffer (the 1 MiB ADR 0003 // bound). Returns the error Status; on success the document is in // `out`. laige::Status readConfigFile(const std::string& path, std::string* out) { - std::FILE* file = std::fopen(path.c_str(), "rb"); + std::FILE* file = openConfigFile(path.c_str(), "rb"); if (file == nullptr) { return laige::ErrorCode::IoError; } From 617a7b9cec0e6c58df8f606e57ca2a17452eb94f Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Tue, 15 Sep 2026 13:08:31 +0200 Subject: [PATCH 2/7] Retrigger CI: ci:windows label applied to the PR The first pull_request event fired when the branch was pushed, before the PR existed and before the ci:windows label was attached, so that run's windows-msvc job was skipped by the label selector. This empty commit re-fires the pull_request event with the label in place (the concurrency group cancels the label-less run). From 7171915407536940f97fdc7d222345e13eba4e1f Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Tue, 15 Sep 2026 13:26:37 +0200 Subject: [PATCH 3/7] TEMPORARY CI diagnostic: stage-by-stage allocation bisect for the Windows-only 4th allocation HeadlessFramePathAllocatesNothing measures allocs=4 on the windows-msvc job but 3 on every other P0 platform. This temporary test replicates Engine::run_headless(3, 1) stage by stage (schedule, loop create, first frame, snapshot create, three paced frames, the ordered shutdown tail) with allocation checkpoints, so the job log pinpoints the stage where the count moves. DELETE before merge. --- tests/laige-sim/engine_tests.cpp | 108 +++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/tests/laige-sim/engine_tests.cpp b/tests/laige-sim/engine_tests.cpp index 28e8868..3f24a58 100644 --- a/tests/laige-sim/engine_tests.cpp +++ b/tests/laige-sim/engine_tests.cpp @@ -451,6 +451,114 @@ TEST(EngineShutdown, ShutdownReleasesTheWorld) { // --------------------------------------------------------------------------- #if defined(LAIGE_ALLOC_COUNTER) +#include +#include + +// TEMPORARY CI DIAGNOSTIC (delete before merge): bisects the +// Windows-only fourth heap allocation measured by +// HeadlessFramePathAllocatesNothing (allocs=4 on the windows-msvc job, +// 3 on every other P0 platform). It replicates +// Engine::run_headless(3, 1) stage by stage with allocation +// checkpoints, so the job log pinpoints the stage where the count +// moves on Windows. +namespace { + +laige::PresentationSnapshot* gDiagSnapshot = nullptr; + +// The diagnostic-local mirror of Engine::onTickHookDispatch. +void diagOnTickHook(void* context, laige::World&, std::uint64_t tick) noexcept { + (void)context; + if (gDiagSnapshot != nullptr) gDiagSnapshot->onTick(tick); +} + +} // namespace + +TEST(EngineRun, DiagAllocBisectTemp) { + MemorySink* sink = installCaptureSink(); + laige::log::Logger::instance().setSubsystemLevel("engine", + laige::log::Level::Off); + laige::log::Logger::instance().setSubsystemLevel("loop", + laige::log::Level::Off); + { + laige::Engine warmup = makeEngine(laige::EngineConfig{60, 8, 256}); + warmup.shutdown(); + } + laige::Engine engine = makeEngine(laige::EngineConfig{60, 64, 256}); + ASSERT_TRUE(engine.world()->registerSystem(EngTickCounter_Def).ok()); + laige::test::resetAllocCounter(); + + // Checkpoint printer (literals only: no allocation in the probe). + auto stage = [](int n) { + std::printf("diag-bisect stage %d: allocs=%llu\n", n, + static_cast( + laige::test::allocCounter())); + }; + auto nowNs = []() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + }; + using Snapshot = laige::PresentationSnapshot; + stage(0); // window start (the run_headless entry) + { + laige::SystemSchedule sched; + ASSERT_TRUE(engine.world()->scheduleSystems(sched).ok()); + stage(1); // after scheduleSystems + + laige::Result loopResult = + laige::GameLoop::create( + *engine.world(), sched, + laige::GameLoop::Options{60, 1, nullptr, &diagOnTickHook, + nullptr}); + ASSERT_TRUE(loopResult.ok()); + auto loop = std::make_unique( + std::move(loopResult).takeValue()); + stage(2); // after the GameLoop object allocation + + ASSERT_TRUE(loop->frame().ok()); // first frame: the start reference + stage(3); // after the first (zero-tick) frame + + laige::Result snapResult = + Snapshot::create(*engine.world(), loop->startReferenceNs(), + Snapshot::Options{60}); + ASSERT_TRUE(snapResult.ok()); + auto snap = std::make_unique( + std::move(snapResult).takeValue()); + gDiagSnapshot = snap.get(); + stage(4); // after the snapshot object + slot-table allocation + + // The runFrames equivalent for maxTicks=3, frame budget 1: + // three tick frames, a bounded sleep between frames 1-2 and 2-3, + // none after the final tick. + for (int f = 1; f <= 3; ++f) { + const std::int64_t now = nowNs(); + ASSERT_TRUE(loop->frame().ok()); + snap->onRenderFrame(now); + if (loop->currentTick() < 3) { + std::this_thread::sleep_for(std::chrono::milliseconds(17)); + } + stage(10 + f); // after frame f + } + + (void)loop->stats(); + stage(20); // after the stats read + + // The shutdown() mirror (ordered: loop -> world clear -> snapshot). + loop.reset(); + stage(21); // after the loop release + static_cast(engine.world()->clear()); + stage(22); // after the world clear + snap.reset(); + gDiagSnapshot = nullptr; + stage(23); // after the snapshot release + } + laige::log::Logger::instance().shutdown(); + stage(24); // after the logger shutdown (the engine shutdown tail) + EXPECT_EQ(sink->entries.size(), 0u); + restoreLogger(); + SUCCEED(); +} + TEST(EngineRun, HeadlessFramePathAllocatesNothing) { MemorySink* sink = installCaptureSink(); // Gate the engine's Info lifecycle events and the loop's drop From ea762402101d04327646343a2daebd44b268ec3b Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Tue, 15 Sep 2026 13:39:39 +0200 Subject: [PATCH 4/7] TEMPORARY CI diagnostic round 2: per-statement allocation probe inside Logger::shutdown() Round 1 localized the Windows-only fourth allocation to stage 24 (Logger::shutdown()). Round 2 installs a per-statement probe inside shutdown() (temporary setAllocProbe hook, manifest regenerated for the two temporary symbols) so the job log shows exactly which statement allocates on Windows. DELETE before merge (probe, diagnostic test, and the manifest entries). --- laige-api.json | 38 ++++++++++++++------------ src/laige-core/include/laige/logging.h | 7 +++++ src/laige-core/logging.cpp | 23 ++++++++++++++++ tests/laige-sim/engine_tests.cpp | 12 ++++++++ 4 files changed, 62 insertions(+), 18 deletions(-) diff --git a/laige-api.json b/laige-api.json index 2af3a99..eb841aa 100644 --- a/laige-api.json +++ b/laige-api.json @@ -229,24 +229,26 @@ {"name": "laige::log::Logger::emit", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 410, "signature": "template void emit(Severity severity, std::string_view subsystem, std::string_view event, std::string_view message, Fields&&... fields)", "summary": "Record an enabled event. Fields are moved into the record; the subsystem/event/message string_views must outlive the call. Direct calls evaluate their arguments eagerly — prefer the LAIGE_LOG_* macros (lazy). Fatal events flush and then terminate the process (AGENTS §14 controlled termination).", "budget": null, "experimental": false}, {"name": "laige::log::Logger::flush", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 419, "signature": "void flush()", "summary": "Flush the sink (LOG-007).", "budget": null, "experimental": false}, {"name": "laige::log::Logger::shutdown", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 424, "signature": "void shutdown()", "summary": "Controlled shutdown (CONC-006, idempotent): drain pending rate-limit summaries, flush the sink, and retire the facade — log calls after shutdown are discarded (no sink calls).", "budget": null, "experimental": false}, - {"name": "laige::log::Logger::installCrashHandling", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 433, "signature": "[[nodiscard]] laige::Status installCrashHandling()", "summary": "Install crash handlers (LOG-007): SIGSEGV/SIGABRT/SIGBUS/SIGFPE/ SIGILL on POSIX (sigaction, one-shot SA_RESETHAND), a vectored SEH filter on Windows. The handler writes a raw notice to stderr (write(2): no stdio lock, no allocation), flushes the sink (try_lock, allocation-free), and lets the default crash handling continue (core dump / debugger / abort). Init-phase API; idempotent.", "budget": null, "experimental": false}, - {"name": "laige::log::Logger::sink", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 436, "signature": "[[nodiscard]] const Sink* sink() const noexcept", "summary": "The current sink (diagnostics, DBG-008); never null.", "budget": null, "experimental": false}, - {"name": "laige::log::Logger::crashFlush", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 440, "signature": "void crashFlush() const", "summary": "Flush from a crash handler: no facade lock (the signal may have interrupted a dispatch holding it), no allocation (LOG-007).", "budget": null, "experimental": false}, - {"name": "laige::log::Logger::SubsystemEntry::name", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 446, "signature": "std::string name", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::SubsystemEntry::level", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 447, "signature": "Level level", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::RateEntry::subsystem", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 455, "signature": "std::string subsystem", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::RateEntry::event", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 456, "signature": "std::string event", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::RateEntry::severity", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 457, "signature": "Severity severity", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::RateEntry::everEmitted", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 458, "signature": "bool everEmitted = false", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::RateEntry::lastEmit", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 459, "signature": "std::chrono::system_clock::time_point lastEmit{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::RateEntry::suppressed", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 460, "signature": "std::uint64_t suppressed = 0", "summary": null, "budget": null, "experimental": false}, - {"name": "LAIGE_LOG", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 524, "signature": "#define LAIGE_LOG(severity, subsystem, event, message, ...)", "summary": "The public logging macros (AGENTS §14 example shape)", "budget": null, "experimental": false}, - {"name": "LAIGE_LOG_TRACE", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 528, "signature": "#define LAIGE_LOG_TRACE(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, - {"name": "LAIGE_LOG_DEBUG", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 531, "signature": "#define LAIGE_LOG_DEBUG(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, - {"name": "LAIGE_LOG_INFO", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 534, "signature": "#define LAIGE_LOG_INFO(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, - {"name": "LAIGE_LOG_WARN", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 537, "signature": "#define LAIGE_LOG_WARN(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, - {"name": "LAIGE_LOG_ERROR", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 540, "signature": "#define LAIGE_LOG_ERROR(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, - {"name": "LAIGE_LOG_FATAL", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 543, "signature": "#define LAIGE_LOG_FATAL(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::AllocProbeFn", "kind": "alias", "header": "src/laige-core/include/laige/logging.h", "line": 430, "signature": "using AllocProbeFn = void (*)(void)", "summary": "TEMPORARY CI DIAGNOSTIC (delete before merge): allocation-probe hook used to bisect the Windows-only allocation inside shutdown(). The probe is called between each statement of shutdown(); the test side prints the running allocation counter at each call.", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::setAllocProbe", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 431, "signature": "static void setAllocProbe(AllocProbeFn fn)", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::installCrashHandling", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 440, "signature": "[[nodiscard]] laige::Status installCrashHandling()", "summary": "Install crash handlers (LOG-007): SIGSEGV/SIGABRT/SIGBUS/SIGFPE/ SIGILL on POSIX (sigaction, one-shot SA_RESETHAND), a vectored SEH filter on Windows. The handler writes a raw notice to stderr (write(2): no stdio lock, no allocation), flushes the sink (try_lock, allocation-free), and lets the default crash handling continue (core dump / debugger / abort). Init-phase API; idempotent.", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::sink", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 443, "signature": "[[nodiscard]] const Sink* sink() const noexcept", "summary": "The current sink (diagnostics, DBG-008); never null.", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::crashFlush", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 447, "signature": "void crashFlush() const", "summary": "Flush from a crash handler: no facade lock (the signal may have interrupted a dispatch holding it), no allocation (LOG-007).", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::SubsystemEntry::name", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 453, "signature": "std::string name", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::SubsystemEntry::level", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 454, "signature": "Level level", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::subsystem", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 462, "signature": "std::string subsystem", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::event", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 463, "signature": "std::string event", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::severity", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 464, "signature": "Severity severity", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::everEmitted", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 465, "signature": "bool everEmitted = false", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::lastEmit", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 466, "signature": "std::chrono::system_clock::time_point lastEmit{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::suppressed", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 467, "signature": "std::uint64_t suppressed = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 531, "signature": "#define LAIGE_LOG(severity, subsystem, event, message, ...)", "summary": "The public logging macros (AGENTS §14 example shape)", "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_TRACE", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 535, "signature": "#define LAIGE_LOG_TRACE(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_DEBUG", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 538, "signature": "#define LAIGE_LOG_DEBUG(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_INFO", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 541, "signature": "#define LAIGE_LOG_INFO(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_WARN", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 544, "signature": "#define LAIGE_LOG_WARN(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_ERROR", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 547, "signature": "#define LAIGE_LOG_ERROR(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_FATAL", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 550, "signature": "#define LAIGE_LOG_FATAL(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, {"name": "laige::PoolStats", "kind": "struct", "header": "src/laige-core/include/laige/pools.h", "line": 109, "signature": "struct PoolStats", "summary": "One pool's accounting snapshot (PRD §10.4, FR-11.4, G-R4). A plain value the M1 profiler aggregates; there is no registration (CORE-004).", "budget": null, "experimental": false}, {"name": "laige::PoolStats::capacity", "kind": "variable", "header": "src/laige-core/include/laige/pools.h", "line": 110, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::PoolStats::inUse", "kind": "variable", "header": "src/laige-core/include/laige/pools.h", "line": 111, "signature": "std::uint32_t inUse{}", "summary": null, "budget": null, "experimental": false}, diff --git a/src/laige-core/include/laige/logging.h b/src/laige-core/include/laige/logging.h index bd352e6..eef7bbb 100644 --- a/src/laige-core/include/laige/logging.h +++ b/src/laige-core/include/laige/logging.h @@ -423,6 +423,13 @@ class Logger { // log calls after shutdown are discarded (no sink calls). void shutdown(); + // TEMPORARY CI DIAGNOSTIC (delete before merge): allocation-probe + // hook used to bisect the Windows-only allocation inside shutdown(). + // The probe is called between each statement of shutdown(); the + // test side prints the running allocation counter at each call. + using AllocProbeFn = void (*)(void); + static void setAllocProbe(AllocProbeFn fn); + // Install crash handlers (LOG-007): SIGSEGV/SIGABRT/SIGBUS/SIGFPE/ // SIGILL on POSIX (sigaction, one-shot SA_RESETHAND), a vectored SEH // filter on Windows. The handler writes a raw notice to stderr diff --git a/src/laige-core/logging.cpp b/src/laige-core/logging.cpp index ab97043..846f38e 100644 --- a/src/laige-core/logging.cpp +++ b/src/laige-core/logging.cpp @@ -443,20 +443,43 @@ void Logger::flush() { if (sink_ != nullptr) sink_->flush(); } +// TEMPORARY CI DIAGNOSTIC (delete before merge): the allocation-probe +// hook (see the header). Null by default; a test installs a probe that +// prints the running allocation counter, so the Windows-only +// allocation inside shutdown() can be bisected per statement. +namespace { +Logger::AllocProbeFn gAllocProbe = nullptr; +} // namespace + +void Logger::setAllocProbe(AllocProbeFn fn) { gAllocProbe = fn; } + +static void probeAlloc() { + if (gAllocProbe != nullptr) gAllocProbe(); +} + void Logger::shutdown() { + probeAlloc(); // SHUT-1: entry std::vector pending; + probeAlloc(); // SHUT-2: after the pending vector declaration { std::lock_guard lk(stateMutex_); + probeAlloc(); // SHUT-3: after the lock acquire retired_.store(true, std::memory_order_relaxed); + probeAlloc(); // SHUT-4: after the retired store for (const RateEntry& e : rates_) { if (e.suppressed > 0) pending.push_back(e); // copy: state is cleared } + probeAlloc(); // SHUT-5: after the rate-state drain rates_.clear(); + probeAlloc(); // SHUT-6: after the clear } + probeAlloc(); // SHUT-7: after the lock release for (const RateEntry& e : pending) { emitRateSummary(e); } + probeAlloc(); // SHUT-8: after the pending summaries if (sink_ != nullptr) sink_->flush(); + probeAlloc(); // SHUT-9: after the sink flush } // --------------------------------------------------------------------------- diff --git a/tests/laige-sim/engine_tests.cpp b/tests/laige-sim/engine_tests.cpp index 3f24a58..3a2f165 100644 --- a/tests/laige-sim/engine_tests.cpp +++ b/tests/laige-sim/engine_tests.cpp @@ -471,6 +471,15 @@ void diagOnTickHook(void* context, laige::World&, std::uint64_t tick) noexcept { if (gDiagSnapshot != nullptr) gDiagSnapshot->onTick(tick); } +// The shutdown() probe (the temporary Logger::setAllocProbe hook): +// prints the running allocation counter at every statement boundary +// inside Logger::shutdown(), localizing the Windows-only allocation. +int gDiagShutStep = 0; +void diagShutProbe() { + std::printf("diag-shut step %d: allocs=%llu\n", ++gDiagShutStep, + static_cast(laige::test::allocCounter())); +} + } // namespace TEST(EngineRun, DiagAllocBisectTemp) { @@ -552,7 +561,10 @@ TEST(EngineRun, DiagAllocBisectTemp) { gDiagSnapshot = nullptr; stage(23); // after the snapshot release } + gDiagShutStep = 0; + laige::log::Logger::setAllocProbe(&diagShutProbe); laige::log::Logger::instance().shutdown(); + laige::log::Logger::setAllocProbe(nullptr); stage(24); // after the logger shutdown (the engine shutdown tail) EXPECT_EQ(sink->entries.size(), 0u); restoreLogger(); From 432e2cc2b0e1554f54cfe1e01b99defbaea014e8 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Tue, 15 Sep 2026 13:53:20 +0200 Subject: [PATCH 5/7] TEMPORARY CI diagnostic round 3: discriminate the Windows-only allocation Round 2 localized it to the first statement of Logger::shutdown() (the empty std::vector pending declaration). Round 3 adds empty vector/vector/second vector discriminators and a second idempotent shutdown() call, to determine whether the allocation is element-type-specific, per-declaration, or one-shot. DELETE before merge. --- src/laige-core/logging.cpp | 27 ++++++++++++++++++++------- tests/laige-sim/engine_tests.cpp | 6 +++++- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/laige-core/logging.cpp b/src/laige-core/logging.cpp index 846f38e..74ba085 100644 --- a/src/laige-core/logging.cpp +++ b/src/laige-core/logging.cpp @@ -461,25 +461,38 @@ void Logger::shutdown() { probeAlloc(); // SHUT-1: entry std::vector pending; probeAlloc(); // SHUT-2: after the pending vector declaration + // TEMPORARY round-3 discriminators (delete before merge): empty + // vectors of other element types, and a second vector, + // to localize the Windows-only allocation (vector-specific? + // element-type-specific? first-of-kind in the process?). + std::vector probeIntVec; + probeAlloc(); // SHUT-3: after the empty vector + std::vector probeStrVec; + probeAlloc(); // SHUT-4: after the empty vector + std::vector probeRateVec2; + probeAlloc(); // SHUT-5: after the second vector + (void)probeIntVec; // TEMPORARY round-3 discriminators (not used) + (void)probeStrVec; + (void)probeRateVec2; { std::lock_guard lk(stateMutex_); - probeAlloc(); // SHUT-3: after the lock acquire + probeAlloc(); // SHUT-6: after the lock acquire retired_.store(true, std::memory_order_relaxed); - probeAlloc(); // SHUT-4: after the retired store + probeAlloc(); // SHUT-7: after the retired store for (const RateEntry& e : rates_) { if (e.suppressed > 0) pending.push_back(e); // copy: state is cleared } - probeAlloc(); // SHUT-5: after the rate-state drain + probeAlloc(); // SHUT-8: after the rate-state drain rates_.clear(); - probeAlloc(); // SHUT-6: after the clear + probeAlloc(); // SHUT-9: after the clear } - probeAlloc(); // SHUT-7: after the lock release + probeAlloc(); // SHUT-10: after the lock release for (const RateEntry& e : pending) { emitRateSummary(e); } - probeAlloc(); // SHUT-8: after the pending summaries + probeAlloc(); // SHUT-11: after the pending summaries if (sink_ != nullptr) sink_->flush(); - probeAlloc(); // SHUT-9: after the sink flush + probeAlloc(); // SHUT-12: after the sink flush } // --------------------------------------------------------------------------- diff --git a/tests/laige-sim/engine_tests.cpp b/tests/laige-sim/engine_tests.cpp index 3a2f165..146dda9 100644 --- a/tests/laige-sim/engine_tests.cpp +++ b/tests/laige-sim/engine_tests.cpp @@ -564,8 +564,12 @@ TEST(EngineRun, DiagAllocBisectTemp) { gDiagShutStep = 0; laige::log::Logger::setAllocProbe(&diagShutProbe); laige::log::Logger::instance().shutdown(); + stage(24); // after the first logger shutdown + // The second (idempotent) call: localizes whether the Windows-only + // allocation is per-call or one-shot (TEMPORARY round 3). + laige::log::Logger::instance().shutdown(); + stage(25); // after the second (idempotent) logger shutdown laige::log::Logger::setAllocProbe(nullptr); - stage(24); // after the logger shutdown (the engine shutdown tail) EXPECT_EQ(sink->entries.size(), 0u); restoreLogger(); SUCCEED(); From b6f16352bdbb75561a50a4b4d541aee33dc8c02d Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Tue, 15 Sep 2026 14:08:35 +0200 Subject: [PATCH 6/7] Fix Windows CI: MSVC checked-iterator proxies break the zero-allocation invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the second windows-msvc failure (HeadlessFramePathAllocatesNothing, allocs=4 vs 3), established by three rounds of staged allocation probes on the CI runner: - The extra allocation lands in Engine shutdown's tail, specifically between the entry of Logger::shutdown() and the declaration of its local 'std::vector pending;'. - The allocation is PER DECLARATION, PER CALL, for ANY element type: adding empty vector / vector / a second vector locals each added exactly one allocation, and the second (idempotent) shutdown() call allocated the same four again. That signature is the MS STL's checked-iterator debug mode: CMake's default MSVC Debug flags define _DEBUG, which makes the STL default to _ITERATOR_DEBUG_LEVEL=2, where every STL container instance gets a heap-allocated iterator-proxy object in its constructor. The engine's window contains exactly one container declaration (the logger's shutdown local), so windows-msvc measures 4 while the other P0 platforms — GCC/Clang/AppleClang, which compile without checked iterators by default — measure the documented 3. The fix aligns the Windows Debug build with the rest of the P0 matrix: laige_apply_engine_policy() now defines _ITERATOR_DEBUG_LEVEL=0 for MSVC targets (PRIVATE, like the existing _HAS_EXCEPTIONS=0). The proxies are an STL debug aid, not engine behavior, and they are allocated in container setup, never on hot paths — so no engine behavior changes; the zero-allocation invariants (M1-ECS-03 churn, M1-HEAD-01 headless run) now measure identically on every P0 platform. Also removes the temporary diagnostic probes (rounds 1-3) and restores the API manifest to its pre-probe state. Verified locally: zero-warning gcc/clang builds, 47/47 ctest. Verified by this PR's windows-msvc job (pending). --- CMakeLists.txt | 18 ++++ laige-api.json | 38 ++++---- src/laige-core/include/laige/logging.h | 7 -- src/laige-core/logging.cpp | 36 ------- tests/laige-sim/engine_tests.cpp | 124 ------------------------- 5 files changed, 36 insertions(+), 187 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 512d6ec..afdeee9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -141,6 +141,10 @@ laige_deps_verify_lock() # yvals.h _TRY_BEGIN/_CATCH) on the _HAS_EXCEPTIONS macro, not on the /EH # flags — with /EHs- alone it would still emit catch blocks that the # compiler cannot unwind (cl C4530, fatal under /WX). +# _ITERATOR_DEBUG_LEVEL=0 keeps the Windows Debug build's STL container +# allocation behavior aligned with the other P0 platforms (their +# checked-iterator proxies would break the zero-allocation invariants; +# see the MSVC branch below). # # The warning/language flags are PRIVATE: they govern this target's own # translation units and must not leak into consumers (CPP-010) — a game @@ -161,6 +165,20 @@ function(laige_apply_engine_policy target) # its no-exception code paths (same define GTest's own no-exception # targets use; deps/googletest/cmake/internal_utils.cmake). target_compile_definitions(${target} PRIVATE _HAS_EXCEPTIONS=0) + # _ITERATOR_DEBUG_LEVEL=0: the MS STL defaults to its checked-iterator + # debug mode (_ITERATOR_DEBUG_LEVEL=2) whenever _DEBUG is defined — + # which CMake's default MSVC Debug flags define (/D_DEBUG). In that + # mode every STL container instance gets a heap-allocated + # iterator-proxy object in its constructor, so the engine's + # zero-allocation invariants (the M1-ECS-03 churn test, the + # M1-HEAD-01 headless run test) would measure a platform-dependent + # baseline on windows-msvc that the other P0 platforms never enable + # (GCC/Clang/AppleClang compile without checked iterators by + # default). Disabling it makes the Windows Debug build measure the + # same allocation behavior as the rest of the P0 matrix. The + # proxies are an STL debug aid, not engine behavior — they are + # allocated in container setup, never on hot paths. + target_compile_definitions(${target} PRIVATE _ITERATOR_DEBUG_LEVEL=0) else() message(FATAL_ERROR "laige: no engine compiler policy for '${CMAKE_CXX_COMPILER_ID}'; " diff --git a/laige-api.json b/laige-api.json index eb841aa..2af3a99 100644 --- a/laige-api.json +++ b/laige-api.json @@ -229,26 +229,24 @@ {"name": "laige::log::Logger::emit", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 410, "signature": "template void emit(Severity severity, std::string_view subsystem, std::string_view event, std::string_view message, Fields&&... fields)", "summary": "Record an enabled event. Fields are moved into the record; the subsystem/event/message string_views must outlive the call. Direct calls evaluate their arguments eagerly — prefer the LAIGE_LOG_* macros (lazy). Fatal events flush and then terminate the process (AGENTS §14 controlled termination).", "budget": null, "experimental": false}, {"name": "laige::log::Logger::flush", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 419, "signature": "void flush()", "summary": "Flush the sink (LOG-007).", "budget": null, "experimental": false}, {"name": "laige::log::Logger::shutdown", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 424, "signature": "void shutdown()", "summary": "Controlled shutdown (CONC-006, idempotent): drain pending rate-limit summaries, flush the sink, and retire the facade — log calls after shutdown are discarded (no sink calls).", "budget": null, "experimental": false}, - {"name": "laige::log::Logger::AllocProbeFn", "kind": "alias", "header": "src/laige-core/include/laige/logging.h", "line": 430, "signature": "using AllocProbeFn = void (*)(void)", "summary": "TEMPORARY CI DIAGNOSTIC (delete before merge): allocation-probe hook used to bisect the Windows-only allocation inside shutdown(). The probe is called between each statement of shutdown(); the test side prints the running allocation counter at each call.", "budget": null, "experimental": false}, - {"name": "laige::log::Logger::setAllocProbe", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 431, "signature": "static void setAllocProbe(AllocProbeFn fn)", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::installCrashHandling", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 440, "signature": "[[nodiscard]] laige::Status installCrashHandling()", "summary": "Install crash handlers (LOG-007): SIGSEGV/SIGABRT/SIGBUS/SIGFPE/ SIGILL on POSIX (sigaction, one-shot SA_RESETHAND), a vectored SEH filter on Windows. The handler writes a raw notice to stderr (write(2): no stdio lock, no allocation), flushes the sink (try_lock, allocation-free), and lets the default crash handling continue (core dump / debugger / abort). Init-phase API; idempotent.", "budget": null, "experimental": false}, - {"name": "laige::log::Logger::sink", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 443, "signature": "[[nodiscard]] const Sink* sink() const noexcept", "summary": "The current sink (diagnostics, DBG-008); never null.", "budget": null, "experimental": false}, - {"name": "laige::log::Logger::crashFlush", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 447, "signature": "void crashFlush() const", "summary": "Flush from a crash handler: no facade lock (the signal may have interrupted a dispatch holding it), no allocation (LOG-007).", "budget": null, "experimental": false}, - {"name": "laige::log::Logger::SubsystemEntry::name", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 453, "signature": "std::string name", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::SubsystemEntry::level", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 454, "signature": "Level level", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::RateEntry::subsystem", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 462, "signature": "std::string subsystem", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::RateEntry::event", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 463, "signature": "std::string event", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::RateEntry::severity", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 464, "signature": "Severity severity", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::RateEntry::everEmitted", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 465, "signature": "bool everEmitted = false", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::RateEntry::lastEmit", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 466, "signature": "std::chrono::system_clock::time_point lastEmit{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::RateEntry::suppressed", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 467, "signature": "std::uint64_t suppressed = 0", "summary": null, "budget": null, "experimental": false}, - {"name": "LAIGE_LOG", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 531, "signature": "#define LAIGE_LOG(severity, subsystem, event, message, ...)", "summary": "The public logging macros (AGENTS §14 example shape)", "budget": null, "experimental": false}, - {"name": "LAIGE_LOG_TRACE", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 535, "signature": "#define LAIGE_LOG_TRACE(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, - {"name": "LAIGE_LOG_DEBUG", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 538, "signature": "#define LAIGE_LOG_DEBUG(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, - {"name": "LAIGE_LOG_INFO", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 541, "signature": "#define LAIGE_LOG_INFO(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, - {"name": "LAIGE_LOG_WARN", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 544, "signature": "#define LAIGE_LOG_WARN(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, - {"name": "LAIGE_LOG_ERROR", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 547, "signature": "#define LAIGE_LOG_ERROR(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, - {"name": "LAIGE_LOG_FATAL", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 550, "signature": "#define LAIGE_LOG_FATAL(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::installCrashHandling", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 433, "signature": "[[nodiscard]] laige::Status installCrashHandling()", "summary": "Install crash handlers (LOG-007): SIGSEGV/SIGABRT/SIGBUS/SIGFPE/ SIGILL on POSIX (sigaction, one-shot SA_RESETHAND), a vectored SEH filter on Windows. The handler writes a raw notice to stderr (write(2): no stdio lock, no allocation), flushes the sink (try_lock, allocation-free), and lets the default crash handling continue (core dump / debugger / abort). Init-phase API; idempotent.", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::sink", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 436, "signature": "[[nodiscard]] const Sink* sink() const noexcept", "summary": "The current sink (diagnostics, DBG-008); never null.", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::crashFlush", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 440, "signature": "void crashFlush() const", "summary": "Flush from a crash handler: no facade lock (the signal may have interrupted a dispatch holding it), no allocation (LOG-007).", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::SubsystemEntry::name", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 446, "signature": "std::string name", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::SubsystemEntry::level", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 447, "signature": "Level level", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::subsystem", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 455, "signature": "std::string subsystem", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::event", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 456, "signature": "std::string event", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::severity", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 457, "signature": "Severity severity", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::everEmitted", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 458, "signature": "bool everEmitted = false", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::lastEmit", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 459, "signature": "std::chrono::system_clock::time_point lastEmit{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::suppressed", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 460, "signature": "std::uint64_t suppressed = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 524, "signature": "#define LAIGE_LOG(severity, subsystem, event, message, ...)", "summary": "The public logging macros (AGENTS §14 example shape)", "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_TRACE", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 528, "signature": "#define LAIGE_LOG_TRACE(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_DEBUG", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 531, "signature": "#define LAIGE_LOG_DEBUG(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_INFO", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 534, "signature": "#define LAIGE_LOG_INFO(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_WARN", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 537, "signature": "#define LAIGE_LOG_WARN(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_ERROR", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 540, "signature": "#define LAIGE_LOG_ERROR(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_FATAL", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 543, "signature": "#define LAIGE_LOG_FATAL(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, {"name": "laige::PoolStats", "kind": "struct", "header": "src/laige-core/include/laige/pools.h", "line": 109, "signature": "struct PoolStats", "summary": "One pool's accounting snapshot (PRD §10.4, FR-11.4, G-R4). A plain value the M1 profiler aggregates; there is no registration (CORE-004).", "budget": null, "experimental": false}, {"name": "laige::PoolStats::capacity", "kind": "variable", "header": "src/laige-core/include/laige/pools.h", "line": 110, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::PoolStats::inUse", "kind": "variable", "header": "src/laige-core/include/laige/pools.h", "line": 111, "signature": "std::uint32_t inUse{}", "summary": null, "budget": null, "experimental": false}, diff --git a/src/laige-core/include/laige/logging.h b/src/laige-core/include/laige/logging.h index eef7bbb..bd352e6 100644 --- a/src/laige-core/include/laige/logging.h +++ b/src/laige-core/include/laige/logging.h @@ -423,13 +423,6 @@ class Logger { // log calls after shutdown are discarded (no sink calls). void shutdown(); - // TEMPORARY CI DIAGNOSTIC (delete before merge): allocation-probe - // hook used to bisect the Windows-only allocation inside shutdown(). - // The probe is called between each statement of shutdown(); the - // test side prints the running allocation counter at each call. - using AllocProbeFn = void (*)(void); - static void setAllocProbe(AllocProbeFn fn); - // Install crash handlers (LOG-007): SIGSEGV/SIGABRT/SIGBUS/SIGFPE/ // SIGILL on POSIX (sigaction, one-shot SA_RESETHAND), a vectored SEH // filter on Windows. The handler writes a raw notice to stderr diff --git a/src/laige-core/logging.cpp b/src/laige-core/logging.cpp index 74ba085..ab97043 100644 --- a/src/laige-core/logging.cpp +++ b/src/laige-core/logging.cpp @@ -443,56 +443,20 @@ void Logger::flush() { if (sink_ != nullptr) sink_->flush(); } -// TEMPORARY CI DIAGNOSTIC (delete before merge): the allocation-probe -// hook (see the header). Null by default; a test installs a probe that -// prints the running allocation counter, so the Windows-only -// allocation inside shutdown() can be bisected per statement. -namespace { -Logger::AllocProbeFn gAllocProbe = nullptr; -} // namespace - -void Logger::setAllocProbe(AllocProbeFn fn) { gAllocProbe = fn; } - -static void probeAlloc() { - if (gAllocProbe != nullptr) gAllocProbe(); -} - void Logger::shutdown() { - probeAlloc(); // SHUT-1: entry std::vector pending; - probeAlloc(); // SHUT-2: after the pending vector declaration - // TEMPORARY round-3 discriminators (delete before merge): empty - // vectors of other element types, and a second vector, - // to localize the Windows-only allocation (vector-specific? - // element-type-specific? first-of-kind in the process?). - std::vector probeIntVec; - probeAlloc(); // SHUT-3: after the empty vector - std::vector probeStrVec; - probeAlloc(); // SHUT-4: after the empty vector - std::vector probeRateVec2; - probeAlloc(); // SHUT-5: after the second vector - (void)probeIntVec; // TEMPORARY round-3 discriminators (not used) - (void)probeStrVec; - (void)probeRateVec2; { std::lock_guard lk(stateMutex_); - probeAlloc(); // SHUT-6: after the lock acquire retired_.store(true, std::memory_order_relaxed); - probeAlloc(); // SHUT-7: after the retired store for (const RateEntry& e : rates_) { if (e.suppressed > 0) pending.push_back(e); // copy: state is cleared } - probeAlloc(); // SHUT-8: after the rate-state drain rates_.clear(); - probeAlloc(); // SHUT-9: after the clear } - probeAlloc(); // SHUT-10: after the lock release for (const RateEntry& e : pending) { emitRateSummary(e); } - probeAlloc(); // SHUT-11: after the pending summaries if (sink_ != nullptr) sink_->flush(); - probeAlloc(); // SHUT-12: after the sink flush } // --------------------------------------------------------------------------- diff --git a/tests/laige-sim/engine_tests.cpp b/tests/laige-sim/engine_tests.cpp index 146dda9..28e8868 100644 --- a/tests/laige-sim/engine_tests.cpp +++ b/tests/laige-sim/engine_tests.cpp @@ -451,130 +451,6 @@ TEST(EngineShutdown, ShutdownReleasesTheWorld) { // --------------------------------------------------------------------------- #if defined(LAIGE_ALLOC_COUNTER) -#include -#include - -// TEMPORARY CI DIAGNOSTIC (delete before merge): bisects the -// Windows-only fourth heap allocation measured by -// HeadlessFramePathAllocatesNothing (allocs=4 on the windows-msvc job, -// 3 on every other P0 platform). It replicates -// Engine::run_headless(3, 1) stage by stage with allocation -// checkpoints, so the job log pinpoints the stage where the count -// moves on Windows. -namespace { - -laige::PresentationSnapshot* gDiagSnapshot = nullptr; - -// The diagnostic-local mirror of Engine::onTickHookDispatch. -void diagOnTickHook(void* context, laige::World&, std::uint64_t tick) noexcept { - (void)context; - if (gDiagSnapshot != nullptr) gDiagSnapshot->onTick(tick); -} - -// The shutdown() probe (the temporary Logger::setAllocProbe hook): -// prints the running allocation counter at every statement boundary -// inside Logger::shutdown(), localizing the Windows-only allocation. -int gDiagShutStep = 0; -void diagShutProbe() { - std::printf("diag-shut step %d: allocs=%llu\n", ++gDiagShutStep, - static_cast(laige::test::allocCounter())); -} - -} // namespace - -TEST(EngineRun, DiagAllocBisectTemp) { - MemorySink* sink = installCaptureSink(); - laige::log::Logger::instance().setSubsystemLevel("engine", - laige::log::Level::Off); - laige::log::Logger::instance().setSubsystemLevel("loop", - laige::log::Level::Off); - { - laige::Engine warmup = makeEngine(laige::EngineConfig{60, 8, 256}); - warmup.shutdown(); - } - laige::Engine engine = makeEngine(laige::EngineConfig{60, 64, 256}); - ASSERT_TRUE(engine.world()->registerSystem(EngTickCounter_Def).ok()); - laige::test::resetAllocCounter(); - - // Checkpoint printer (literals only: no allocation in the probe). - auto stage = [](int n) { - std::printf("diag-bisect stage %d: allocs=%llu\n", n, - static_cast( - laige::test::allocCounter())); - }; - auto nowNs = []() { - return std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); - }; - using Snapshot = laige::PresentationSnapshot; - stage(0); // window start (the run_headless entry) - { - laige::SystemSchedule sched; - ASSERT_TRUE(engine.world()->scheduleSystems(sched).ok()); - stage(1); // after scheduleSystems - - laige::Result loopResult = - laige::GameLoop::create( - *engine.world(), sched, - laige::GameLoop::Options{60, 1, nullptr, &diagOnTickHook, - nullptr}); - ASSERT_TRUE(loopResult.ok()); - auto loop = std::make_unique( - std::move(loopResult).takeValue()); - stage(2); // after the GameLoop object allocation - - ASSERT_TRUE(loop->frame().ok()); // first frame: the start reference - stage(3); // after the first (zero-tick) frame - - laige::Result snapResult = - Snapshot::create(*engine.world(), loop->startReferenceNs(), - Snapshot::Options{60}); - ASSERT_TRUE(snapResult.ok()); - auto snap = std::make_unique( - std::move(snapResult).takeValue()); - gDiagSnapshot = snap.get(); - stage(4); // after the snapshot object + slot-table allocation - - // The runFrames equivalent for maxTicks=3, frame budget 1: - // three tick frames, a bounded sleep between frames 1-2 and 2-3, - // none after the final tick. - for (int f = 1; f <= 3; ++f) { - const std::int64_t now = nowNs(); - ASSERT_TRUE(loop->frame().ok()); - snap->onRenderFrame(now); - if (loop->currentTick() < 3) { - std::this_thread::sleep_for(std::chrono::milliseconds(17)); - } - stage(10 + f); // after frame f - } - - (void)loop->stats(); - stage(20); // after the stats read - - // The shutdown() mirror (ordered: loop -> world clear -> snapshot). - loop.reset(); - stage(21); // after the loop release - static_cast(engine.world()->clear()); - stage(22); // after the world clear - snap.reset(); - gDiagSnapshot = nullptr; - stage(23); // after the snapshot release - } - gDiagShutStep = 0; - laige::log::Logger::setAllocProbe(&diagShutProbe); - laige::log::Logger::instance().shutdown(); - stage(24); // after the first logger shutdown - // The second (idempotent) call: localizes whether the Windows-only - // allocation is per-call or one-shot (TEMPORARY round 3). - laige::log::Logger::instance().shutdown(); - stage(25); // after the second (idempotent) logger shutdown - laige::log::Logger::setAllocProbe(nullptr); - EXPECT_EQ(sink->entries.size(), 0u); - restoreLogger(); - SUCCEED(); -} - TEST(EngineRun, HeadlessFramePathAllocatesNothing) { MemorySink* sink = installCaptureSink(); // Gate the engine's Info lifecycle events and the loop's drop From eb5f9f5c7122fa12b12b4e354c0c7030483682e6 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Tue, 15 Sep 2026 14:24:26 +0200 Subject: [PATCH 7/7] Fix Windows CI: _ITERATOR_DEBUG_LEVEL must be build-wide (LNK2038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-target _ITERATOR_DEBUG_LEVEL=0 from the previous commit failed the windows-msvc link: the MS STL enforces one _ITERATOR_DEBUG_LEVEL per linked binary, so GTest objects (compiled with the Debug default 2) cannot be linked with engine objects compiled with 0 (LNK2038) — which also retroactively confirms the root cause (the failed build would have been the first build of this tree actually running the STL at level 2, exactly the checked-iterator proxy regime the diagnostic probes characterized). Move the define to a build-wide add_compile_definitions() in the top-level CMakeLists (MSVC only), before all add_subdirectory calls: engine, tests, and vendored dependencies all compile at level 0, so every linked binary is uniform and the zero-allocation invariants measure the same baseline on windows-msvc as on the other P0 platforms (GCC/Clang/AppleClang never enable checked iterators by default). Release builds are unchanged (level 0 is already the Release default); the proxies are an STL debug aid, allocated in container setup, never on hot paths. Verified locally: zero-warning gcc and clang builds, 47/47 ctest each. --- CMakeLists.txt | 41 +++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index afdeee9..2ad9bbd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -37,6 +37,26 @@ if(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") string(REGEX REPLACE "([/-])EHsc" "" CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}") endif() +# MSVC: force the MS STL off its checked-iterator debug mode in every +# target of this build (engine, tests, vendored dependencies). CMake's +# default MSVC Debug flags define _DEBUG, which makes the STL default to +# _ITERATOR_DEBUG_LEVEL=2: in that mode every STL container instance +# gets a heap-allocated iterator-proxy object in its constructor, so the +# engine's zero-allocation invariants (the M1-ECS-03 churn test, the +# M1-HEAD-01 headless run test) would measure a windows-msvc-only +# baseline that the other P0 platforms never enable — GCC/Clang/AppleClang +# compile without checked iterators by default. The define is build-wide +# (not per-target) because the MS STL enforces one _ITERATOR_DEBUG_LEVEL +# per linked binary: mixing engine targets compiled with 0 against +# GTest objects compiled with 2 fails the link (LNK2038). Disabling it +# build-wide makes the Windows Debug build measure the same allocation +# behavior as the rest of the P0 matrix. The proxies are an STL debug +# aid, not engine behavior — they are allocated in container setup, +# never on hot paths. +if(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") + add_compile_definitions(_ITERATOR_DEBUG_LEVEL=0) +endif() + # Both the static (default) and shared (LAIGE_BUILD_SHARED) library variants # are position-independent so they link identically into games and tools # (NFR-8.9). @@ -141,10 +161,9 @@ laige_deps_verify_lock() # yvals.h _TRY_BEGIN/_CATCH) on the _HAS_EXCEPTIONS macro, not on the /EH # flags — with /EHs- alone it would still emit catch blocks that the # compiler cannot unwind (cl C4530, fatal under /WX). -# _ITERATOR_DEBUG_LEVEL=0 keeps the Windows Debug build's STL container -# allocation behavior aligned with the other P0 platforms (their -# checked-iterator proxies would break the zero-allocation invariants; -# see the MSVC branch below). +# _ITERATOR_DEBUG_LEVEL=0 (see the MSVC block above) keeps the Windows +# Debug build's STL container allocation behavior aligned with the other +# P0 platforms. # # The warning/language flags are PRIVATE: they govern this target's own # translation units and must not leak into consumers (CPP-010) — a game @@ -165,20 +184,6 @@ function(laige_apply_engine_policy target) # its no-exception code paths (same define GTest's own no-exception # targets use; deps/googletest/cmake/internal_utils.cmake). target_compile_definitions(${target} PRIVATE _HAS_EXCEPTIONS=0) - # _ITERATOR_DEBUG_LEVEL=0: the MS STL defaults to its checked-iterator - # debug mode (_ITERATOR_DEBUG_LEVEL=2) whenever _DEBUG is defined — - # which CMake's default MSVC Debug flags define (/D_DEBUG). In that - # mode every STL container instance gets a heap-allocated - # iterator-proxy object in its constructor, so the engine's - # zero-allocation invariants (the M1-ECS-03 churn test, the - # M1-HEAD-01 headless run test) would measure a platform-dependent - # baseline on windows-msvc that the other P0 platforms never enable - # (GCC/Clang/AppleClang compile without checked iterators by - # default). Disabling it makes the Windows Debug build measure the - # same allocation behavior as the rest of the P0 matrix. The - # proxies are an STL debug aid, not engine behavior — they are - # allocated in container setup, never on hot paths. - target_compile_definitions(${target} PRIVATE _ITERATOR_DEBUG_LEVEL=0) else() message(FATAL_ERROR "laige: no engine compiler policy for '${CMAKE_CXX_COMPILER_ID}'; "