From a292aefcc046ce2757f41d628e8ef07600bf7b6e Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sat, 12 Sep 2026 21:06:51 +0200 Subject: [PATCH 01/27] [M0-TEST-01] Test infrastructure conventions: seed handling + SeededRandom KAT suite + fuzz lane docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/testing.md: normative test conventions (layout mirroring src/, _tests executables, regress_ regression tests, laige-fuzz target registration + CI lane semantics, seed handling) - tests/support/laige_test_seed.h: TestSeed()/TestPrng() — fixed default seed 0x1F055EED (same as laige-fuzz), LAIGE_TEST_SEED env override, loud failure on invalid value (CORE-008), per-test substream ids - tests/testing: test_infra_tests executable + test_infra CTest entry, SeededRandom suite (6 cases): 65536-draw FNV-1a KATs under default and override seeds, first-draw KAT, seed identity, invalid-env loud failure (EXPECT_NONFATAL_FAILURE), seed parser, substream isolation; machine-greppable test-seed-check line per KAT for the two-CI-runs identity check (M0-TEST-01 Verify) - first regress_ test: M0-CORE-08's ConfigJsonValid.ObjectMemberWhitespace renamed regress_json_object_member_ws (TEST-003 convention) - fuzz lane: bounded --runs=1000 = existing fuzz_json_parse ctest entry in every P0 job; nightly long form --runs=1000000 documented in docs/testing.md and the canonical command table - docs wired: docs/README.md, tests/README.md, building.md, README.md, CI workflow header notes --- .github/workflows/ci-pull.yml | 6 + .github/workflows/ci.yml | 6 + README.md | 9 +- docs/README.md | 8 + docs/getting-started/building.md | 22 ++- docs/testing.md | 145 +++++++++++++++ tests/CMakeLists.txt | 5 + tests/README.md | 20 ++- tests/laige-core/config_json_tests.cpp | 14 +- tests/support/laige_test_seed.h | 114 ++++++++++++ tests/testing/CMakeLists.txt | 29 +++ tests/testing/seeded_random_tests.cpp | 236 +++++++++++++++++++++++++ 12 files changed, 594 insertions(+), 20 deletions(-) create mode 100644 docs/testing.md create mode 100644 tests/support/laige_test_seed.h create mode 100644 tests/testing/CMakeLists.txt create mode 100644 tests/testing/seeded_random_tests.cpp diff --git a/.github/workflows/ci-pull.yml b/.github/workflows/ci-pull.yml index 0675a34..5519090 100644 --- a/.github/workflows/ci-pull.yml +++ b/.github/workflows/ci-pull.yml @@ -31,6 +31,12 @@ # the ci.yml header for the lane semantics (fatal sanitizer reports, # report archiving, toolchain choice). # +# Fuzz lane (PRD §14 "every commit (bounded)"; M0-TEST-01): no separate +# fuzz job — the `fuzz_json_parse` ctest entry (1000 deterministic runs +# of laige-fuzz on the json_parse target) runs inside every build +# job's ctest, instrumented in the ASan tree. Seed and nightly-long-run +# conventions: docs/testing.md. +# # Include-graph lint (M0-CI-03; NFR-8.11, NFR-8.13): the `include-lint` # job runs on EVERY pull request, independent of the ci:* label selector # — it is a platform-independent repository check (Python 3 stdlib only), diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f416f90..2502bab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,12 @@ # the include-graph lint with dependency-count metric (M0-CI-03), # the public API manifest drift check (M0-TOOL-01), and the # determinism check (M0-TOOL-02)). +# +# Fuzz lane (PRD §14 "every commit (bounded)"; M0-TEST-01): there is no +# separate fuzz job — the `fuzz_json_parse` ctest entry (1000 +# deterministic runs of laige-fuzz on the json_parse target) runs inside +# every build job's ctest above, instrumented in the ASan tree. Seed and +# nightly-long-run conventions: docs/testing.md. # * Pull requests run exactly ONE P0 OS (label-selectable, default # Linux) in the companion workflow .github/workflows/ci-pull.yml. # diff --git a/README.md b/README.md index 952c036..70f2a70 100644 --- a/README.md +++ b/README.md @@ -96,9 +96,12 @@ serializer (`ctest -R config_json`, API contract in [docs/api/json.md](docs/api/json.md)), and the budget harness (`ctest -R budget_harness`, API contract in [docs/api/budget_harness.md](docs/api/budget_harness.md); canonical -benchmark command `./build/bin/laige-bench --suite=`). Engine -targets compile with `-Wall -Werror` and with exceptions and RTTI -disabled (NFR-8.10). +benchmark command `./build/bin/laige-bench --suite=`). The test + infrastructure conventions (M0-TEST-01: test layout, + `regress_` regression tests, fuzz lane semantics, and seed + handling for randomized tests — `ctest -R test_infra`) are in + [docs/testing.md](docs/testing.md). Engine targets compile with + `-Wall -Werror` and with exceptions and RTTI disabled (NFR-8.10). ## Documentation diff --git a/docs/README.md b/docs/README.md index 5cd47c1..d278064 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,14 @@ sections below mark what exists and what is still to land. the scenario hash-line contract (` ` lines, two build configurations) (M0-TOOL-02). +## Testing + +- [Testing conventions](testing.md) — test layout (module dirs mirror + `src/`, `_tests` executables), the `regress_` + regression-test convention, `laige-fuzz` target registration and CI + lane semantics, and the seed-handling convention for randomized tests + (M0-TEST-01). + ## Architecture decisions (ADRs) - [ADR index](decisions/README.md) — 0001 (name and license), 0002 diff --git a/docs/getting-started/building.md b/docs/getting-started/building.md index e97d853..dfb59c5 100644 --- a/docs/getting-started/building.md +++ b/docs/getting-started/building.md @@ -33,6 +33,7 @@ The canonical-commands table in [roadmap/README.md](../../roadmap/README.md) | TSan build | `cmake -S . -B build-tsan -DCMAKE_BUILD_TYPE=Debug -DLAIGE_TSAN=ON` | | Test (TSan tree) | `ctest --test-dir build-tsan --output-on-failure` | | Fuzz (bounded) | `./build/bin/laige-fuzz --runs=1000` | +| Fuzz (long, nightly form) | `./build/bin/laige-fuzz --runs=1000000` | | Benchmarks | `./build/bin/laige-bench --suite=` | | Determinism check | `./build/bin/laige-detcheck --scenario=` | | API manifest | `cmake --build build --target laige-api` | @@ -42,11 +43,15 @@ Notes: - `Debug` is the canonical `CMAKE_BUILD_TYPE`; `Release` is supported. - The tool rows above the lint row are live targets now: `laige-fuzz` - (minimal form from M0-CORE-07: the `json_parse` target and deterministic - bounded runs; M0-TEST-01 extends it with CI lane semantics and nightly - long runs), `laige-bench` (M0-CORE-08), `laige-detcheck` (M0-TOOL-02), - and target `laige-api` (M0-TOOL-01). Their command forms were fixed here - when they were reserved, so no step can drift them. + (M0-CORE-07: the `json_parse` target and deterministic bounded runs; + M0-TEST-01 documents the CI lane semantics — bounded `--runs=1000` in + every P0 job's `ctest`, the nightly long-run form above — and the + seed-handling rules), `laige-bench` (M0-CORE-08), `laige-detcheck` + (M0-TOOL-02), and target `laige-api` (M0-TOOL-01). Their command forms + were fixed here when they were reserved, so no step can drift them. + Fuzz and randomized-test seeds: fixed default `0x1F055EED`, + overridable (`laige-fuzz --seed=…`; tests via the `LAIGE_TEST_SEED` + environment variable) — see [docs/testing.md](../testing.md). - Include-graph lint (M0-CI-03): platform-independent (Python 3 stdlib only, no setup). It parses the `#include` edges of `src/**` and enforces the PRD §10.1 rules (laige-core includes nothing internal; arrows only @@ -226,6 +231,13 @@ pinned set and the NaN/Inf policy): the self-check on every PR and merge, with the real-scenario comparison (two build configurations) skipped until M1-SAMPLE-01 (M1-DET-04 activates it). +- `test_infra` is the M0-TEST-01 CTest entry (`tests/testing`): the + `SeededRandom` suite of the `test_infra_tests` executable pins + known-answer hashes for the seed-handling convention (the default seed + `0x1F055EED`, the `LAIGE_TEST_SEED` override, the loud-failure path of + an invalid seed, and substream isolation) and prints the + machine-greppable `test-seed-check` lines that let two CI runs of the + same commit be compared byte-for-byte (docs/testing.md §4). - Every configure verifies the vendored dependency lock (`cmake/laige-deps-lock.cmake` against `deps.lock`); a tampered or unlisted file under `deps/` fails the configure loudly. GoogleTest is the diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..6c6946d --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,145 @@ +# Testing conventions + +Source of truth for how Laige tests are laid out, named, seeded, and +fuzzed (finalized by M0-TEST-01). Normative for every later step; the +normative rule IDs are AGENTS.md §12 (TEST-001…TEST-010), PRD §14 (quality +and verification cadence), and NFR-8.7 (parsers are fuzzed). + +## 1. Test layout (GTest integration) + +- **One directory per engine module under `tests/`, mirroring `src/`:** + `tests/laige-core` ↔ `src/laige-core`, and later `tests/laige-sim` ↔ + `src/laige-sim`, etc. +- **One test executable per module, named `_tests`:** + `tests/laige-core/CMakeLists.txt` builds `laige-core_tests`. All of a + module's unit suites are separate source files in that one executable + (one `TEST` suite per concern), linked against `gtest_main` (which + provides `main()`) and the module library under test. +- **Module suites are exposed as their own CTest entries** with an + unquoted `--gtest_filter` selecting exactly the step's suites; the + unfiltered entry runs the whole module. Each roadmap step names its + Verify command as `ctest -R ` (pattern: M0-CORE-01…08). The + gtest filter must be one *unquoted* argument — CTest passes quoted + arguments through with the literal quote characters, which silently + under-runs the suite (see `tests/laige-core/CMakeLists.txt`). +- **GoogleTest is a dev-only dependency** (PRD §11): vendored in + `deps/googletest`, integrity-locked in `deps.lock` (M0-DEP-01), and + linked into test executables only — **never into engine libraries** + ([ADR 0004](decisions/0004-google-test-vendoring.md)). +- **Non-module test directories** (no `src/` counterpart): + - `tests/tools`, `tests/api`, `tests/detcheck` — checks that exercise + the tools in `tools/` (include-graph lint, API manifest scanner, + determinism checker); CTest entries named after the tool. + - `tests/support/` — shared test-only headers (header-only, no build + targets). Currently `laige_test_seed.h` (this step). + - `tests/testing/` — the test-infrastructure checks; executable + `test_infra_tests`, CTest entry `test_infra`. +- **Every test TU is compiled with the NFR-8.10 policy** + (`laige_apply_engine_policy`: `-Wall -Werror -fno-exceptions + -fno-rtti` / the MSVC equivalent), and the module test executables + self-check that policy with `static_assert`s (a policy violation + fails the build loudly). +- **Test executables are single-owner and run single-threaded per CTest + entry** (CONC-001); concurrency under test gets its own suites + (pattern: `LogConcurrency`) and the TSan tree (NFR-8.2). + +## 2. Regression tests (AGENTS TEST-003) + +- Every bug fix ships a regression test **named `regress_`** + inside the existing module suite (the suite name is unchanged; the + short-id names the defect). +- The test **must fail before the fix and pass after it** (TEST-003). + Demonstrate that in the fix step: write the test first, record the red + run, apply the fix, record the green run — both belong in the step's + Verify note. +- The fix is incomplete without its `regress_` test in the same change + (CORE-007, TEST-003). +- First test under the convention (renamed by this step): + `ConfigJsonValid.regress_json_object_member_ws` in + `tests/laige-core/config_json_tests.cpp` — the M0-CORE-08 finding that + the JSON parser rejected object members separated by `", "` (the + hand-formatted repo-root `budgets.json` demonstrated it). + +## 3. Fuzz runner (`laige-fuzz`; PRD §14, NFR-8.7) + +`tools/fuzz/laige-fuzz.cpp` (M0-CORE-07) is the deterministic bounded +runner: every input is generated from `laige::Prng` (M0-CORE-06), so a +given `(target, runs, seed)` reproduces the exact same input sequence on +every platform (the Prng's cross-platform bit-exactness, ARCH-010). A +target may return any `Status`; the run fails only on process death +(crash or sanitizer report), which ctest turns into a test failure +(CORE-008: no silent failure). + +- **Registering a fuzz target:** add a + `void target(const std::uint8_t*, std::size_t)` entry point and one + `kTargets` row in `tools/fuzz/laige-fuzz.cpp`, then register a CTest + entry `fuzz_` next to it (`COMMAND laige-fuzz + --runs=1000`, `TIMEOUT`, and `TSAN_OPTIONS=halt_on_error=1` in the TSan + tree — the shape of `fuzz_json_parse` in `tools/fuzz/CMakeLists.txt`). +- **Bounded runs in CI, every commit:** the `fuzz_` CTest entries + run inside every P0 job's `ctest` (both `ci.yml` and `ci-pull.yml`), + 1000 runs per target — the PRD §14 "every commit (bounded)" lane. In + the ASan tree (`build-asan`) the runs are instrumented, so a crash or + UB fails the job loudly (NFR-8.7). +- **Nightly long runs (PRD §14 "nightly (long)"):** the canonical form is + `./build/bin/laige-fuzz --runs=1000000 [--seed=HEX]`. The + scheduled nightly lane is documented here but not yet wired: it lands + with the first M1 fuzz target (asset import / network packets, PRD §14 + fuzz row), when a long run protects more than the parser. Until then + the bounded lane above is the complete fuzz cadence in M0. +- **Seed handling:** fixed default seed `0x1F055EED` ("one-fuzz-seed"), + overridable with `--seed=` (0x-prefixed hex or decimal). This is the + same default seed the test suites use (below) — one documented + default seed repo-wide. + +## 4. Seed handling for randomized tests + +- **No nondeterministic sources in tests.** Every randomized test draws + from `laige::Prng` (M0-CORE-06) — never from wall-clock time, + `std::random_device`, or any other source that varies between runs. + CI must be deterministic: the same commit produces the same test + values on every CI run, on every P0 platform. +- **Seed source:** `tests/support/laige_test_seed.h` (this step). + `laige::testing::TestSeed()` returns the fixed default + `kDefaultTestSeed = 0x1F055EED` — identical to laige-fuzz's default — + unless `LAIGE_TEST_SEED` is set (0x-prefixed hex or decimal, read at + call time). A set-but-unparseable value records a test failure with + the offending value and falls back to the default (CORE-008: an + explicit misconfiguration is loud, not silent). +- **Stream isolation:** `laige::testing::TestPrng(id)` derives the + substream `Prng::deriveSubstream(TestSeed(), id)`. Each randomized test + file declares its own stable, named substream-id constant so two tests + never share a stream position (the Prng contract: a copy shares the + position; interleaved draws are a caller bug, not a detectable error). +- **CI determinism, checkable in the logs:** the `SeededRandom` suite + (`tests/testing/`, CTest entry `test_infra`) pins known-answer FNV-1a + hashes of 65536 draws under the default seed and under a documented + override seed, and prints one machine-greppable line per KAT: + + ``` + test-seed-check default seed=0x000000001f055eed stream=1 draws=65536 fnv1a=0x7ea4049545656830 + test-seed-check override seed=0x2468acce01234567 stream=2 draws=65536 fnv1a=0x535d2ca741b61cbf + ``` + + The line lands in the ctest output, every CI job's log, and the + archived `Testing/Temporary/LastTest.log` (linux-asan / linux-tsan + artifacts) even when a KAT mismatches. Two CI runs of the same commit + must show byte-identical `test-seed-check` lines — that is the step's + cross-run identity check (M0-TEST-01 Verify). +- **Investigating a flaky randomized test:** set + `LAIGE_TEST_SEED=` for the ctest run to reproduce the exact + stream that produced the failure (the committed KATs pin the + default-seed values, so the override never hides a KAT regression). + +## 5. Running this step's checks + +| Purpose | Command | +|---|---| +| Seeded-random KAT suite | `ctest --test-dir build -R test_infra --output-on-failure` | +| Bounded fuzz, `json_parse` | `./build/bin/laige-fuzz json_parse --runs=1000` | +| Fuzz with an explicit seed | `./build/bin/laige-fuzz json_parse --runs=1000 --seed=0x12345678` | +| Nightly long run (documented form) | `./build/bin/laige-fuzz json_parse --runs=1000000` | +| Reproduce a randomized test's stream | `LAIGE_TEST_SEED=0x… ctest --test-dir build --output-on-failure` | + +The full canonical command table (build trees, sanitizers, tools) stays +in [getting-started/building.md](getting-started/building.md). diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 47cb0e0..626381e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -64,6 +64,11 @@ endforeach() add_subdirectory(laige-core) +# Test-infrastructure checks (M0-TEST-01): the seed-handling convention +# for randomized tests (tests/support/laige_test_seed.h) is exercised by +# tests/testing (suite `SeededRandom`, CTest entry `test_infra`). +add_subdirectory(testing) + # Tooling checks (M0-CI-03): the include-graph lint runs against fixture # trees and the real repository tree (tests/tools). add_subdirectory(tools) diff --git a/tests/README.md b/tests/README.md index ff7db05..98e2bf3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,13 +1,15 @@ # tests/ Unit and integration tests, one directory per engine module mirroring -`src/`; test executables are named `_tests` (convention finalized in -M0-TEST-01). +`src/`; test executables are named `_tests`. +**[docs/testing.md](../docs/testing.md) is the source of truth for the +full conventions** (layout, regression-test naming, fuzz registration, +seed handling) — finalized by M0-TEST-01. -**Test framework: GoogleTest** — a dev-only dependency (PRD §11) vendored in -`deps/googletest` and locked in `deps.lock` (M0-DEP-01). Test executables -link `gtest_main` (which provides `main()`); GoogleTest is **never** linked -into engine libraries. See +**Test framework: GoogleTest** — a dev-only dependency (PRD §11) vendored +in `deps/googletest` and locked in `deps.lock` (M0-DEP-01). Test +executables link `gtest_main` (which provides `main()`); GoogleTest is +**never** linked into engine libraries. See [ADR 0004](../docs/decisions/0004-google-test-vendoring.md). `laige-core_tests` (build smoke test, M0-BUILD-01; converted to a GoogleTest @@ -19,3 +21,9 @@ the module's single test executable, exposed as its own CTest entry — M0-CORE-01's Result/Status/error-registry suites run as `ctest -R result_status` (filtering the shared executable to the `ResultStatus`, `Status`, and `ErrorCodeRegistry` suites). + +Non-module directories: `tests/tools`, `tests/api`, and `tests/detcheck` +check the tools in `tools/`; `tests/support/` holds shared test-only +headers (currently the seed helper `laige_test_seed.h`); `tests/testing/` +holds the test-infrastructure checks (`test_infra_tests`, CTest entry +`test_infra` — the seed-handling KAT suite, M0-TEST-01). diff --git a/tests/laige-core/config_json_tests.cpp b/tests/laige-core/config_json_tests.cpp index 05acc7e..5aece9a 100644 --- a/tests/laige-core/config_json_tests.cpp +++ b/tests/laige-core/config_json_tests.cpp @@ -282,13 +282,15 @@ TEST(ConfigJsonValid, Containers) { } } -// M0-CORE-08 regression: whitespace after the ',' of an object member -// must be accepted (the grammar allows whitespace between tokens). The -// object key goes through parseString directly (not parseValue, which -// does the skipping), so the key needs its own whitespace skip — the old +// M0-CORE-08 regression (named per the TEST-003 convention, +// docs/testing.md §2): whitespace after the ',' of an object member must +// be accepted (the grammar allows whitespace between tokens). The object +// key goes through parseString directly (not parseValue, which does the +// skipping), so the key needs its own whitespace skip — the old // parseObjectMembers rejected {"a": 1, "b": 2}. Found when the -// hand-formatted repo-root budgets.json was rejected (M0-CORE-08). -TEST(ConfigJsonValid, ObjectMemberWhitespace) { +// hand-formatted repo-root budgets.json was rejected (M0-CORE-08); this +// test failed before the fix and passes after it. +TEST(ConfigJsonValid, regress_json_object_member_ws) { { const auto r = Parse(R"({"a": 1, "b": 2})"); ASSERT_TRUE(r.ok()); diff --git a/tests/support/laige_test_seed.h b/tests/support/laige_test_seed.h new file mode 100644 index 0000000..5612f9f --- /dev/null +++ b/tests/support/laige_test_seed.h @@ -0,0 +1,114 @@ +// Test-only seed handling for randomized tests (M0-TEST-01). +// +// Normative convention: docs/testing.md §4. Every randomized test in the +// repository MUST draw from laige::Prng (M0-CORE-06) seeded through this +// helper — never from wall-clock time, std::random_device, or any other +// nondeterministic source. CI runs must be deterministic and reproducible +// (roadmap M0-TEST-01, PRD §14), and a randomized test must produce the +// same values on every CI run of the same commit. +// +// Seed source: +// - Default: kDefaultTestSeed = 0x1F055EED ("one-fuzz-seed") — the same +// fixed default the laige-fuzz runner uses (tools/fuzz/laige-fuzz.cpp), +// so one documented default seed covers the test suites and the fuzz +// lane. +// - Override: the LAIGE_TEST_SEED environment variable (0x-prefixed hex +// or decimal), read at call time. A set-but-unparseable value records +// a test failure with an actionable message (CORE-008: an explicit +// misconfiguration is not silently ignored) and the default seed is +// returned so the calling test can finish its remaining assertions. +// +// Stream isolation: a test that draws randomness derives its own substream +// with a stable, named id — TestPrng(kMyId) — so two tests never share a +// stream position (the Prng contract: a copy shares the position; +// interleaved draws are a bug at the call site, not an error the type can +// detect). Each test file defines its own id constant (CORE-005). +// +// Cross-run identity: the SeededRandom suite (tests/testing) prints a +// machine-greppable `test-seed-check` line (seed, substream id, draw +// count, FNV-1a hash of a fixed draw count) before asserting it, so the +// line lands in the ctest output and the CI job logs (and the archived +// Testing/Temporary/LastTest.log) even when a KAT mismatches; two CI runs +// of the same commit must show identical lines (M0-TEST-01 Verify). +// +// Usage (inside a TEST body only — the failure path reports to the current +// test): +// +// laige::Prng rng = laige::testing::TestPrng(kMySubstreamId); +// +// This header is test-only: it is never included from src/ (the +// include-graph lint covers src/** only) and defines no symbols of its +// own (header-only). It is compiled with the NFR-8.10 policy like every +// test TU (laige_apply_engine_policy). + +#pragma once + +#include +#include +#include + +#include "gtest/gtest.h" +#include "laige/prng.h" + +namespace laige::testing { + +// The fixed default test seed ("one-fuzz-seed"). Identical to +// laige-fuzz's kDefaultSeed (tools/fuzz/laige-fuzz.cpp) and never +// changed: committed known-answer constants in the test suites are +// pinned against it, so a default-seed change would fail every KAT +// loudly instead of silently reshuffling the random tests. +constexpr std::uint64_t kDefaultTestSeed = 0x1F055EEDull; + +// The environment variable that overrides the default seed. +constexpr const char* kTestSeedEnvVar = "LAIGE_TEST_SEED"; + +// Parse a seed string the way TestSeed() reads LAIGE_TEST_SEED: 0x-prefixed +// hex or decimal. Returns false (with no output write) when the value is +// empty, partially numeric, or negative. +inline bool ParseTestSeed(const char* text, std::uint64_t& out) { + if (text == nullptr || *text == '\0' || text[0] == '-') { + return false; + } + char* end = nullptr; + out = std::strtoull(text, &end, 0); + return end != text && *end == '\0'; +} + +// The seed the active test run uses: kDefaultTestSeed unless LAIGE_TEST_SEED +// is set, in which case its parsed value. +// +// Contract: call inside a TEST body. A set-but-unparseable LAIGE_TEST_SEED +// records a non-fatal failure (ADD_FAILURE — GTEST_FAIL is void-return +// only, and this helper returns a value) with the offending value and the +// accepted forms, then returns kDefaultTestSeed so the calling test can +// complete. The test has already failed, so the ctest entry goes red +// (CORE-008: no silent fallback for an explicit misconfiguration). +inline std::uint64_t TestSeed() { + const char* env = std::getenv(kTestSeedEnvVar); + if (env == nullptr || *env == '\0') { + return kDefaultTestSeed; + } + std::uint64_t seed = 0; + if (!ParseTestSeed(env, seed)) { + std::fprintf(stderr, + "laige test seed: invalid %s='%s' (use 0x-hex or decimal); " + "using the default 0x%llx — the test is already failing\n", + kTestSeedEnvVar, env, + static_cast(kDefaultTestSeed)); + ADD_FAILURE() << kTestSeedEnvVar << "='" << env + << "' is not a valid seed (0x-prefixed hex or decimal); " + << "falling back to the default 0x" << kDefaultTestSeed; + return kDefaultTestSeed; + } + return seed; +} + +// A Prng substream for a test: TestSeed() derived by the caller's stable +// substream id (docs/testing.md §4). The id is a test identity, not a +// magic number: each randomized test file declares its own named +// constant so substreams cannot collide by accident. +inline laige::Prng TestPrng(std::uint32_t substreamId) { + return laige::Prng::deriveSubstream(TestSeed(), substreamId); +} + +} // namespace laige::testing diff --git a/tests/testing/CMakeLists.txt b/tests/testing/CMakeLists.txt new file mode 100644 index 0000000..111544c --- /dev/null +++ b/tests/testing/CMakeLists.txt @@ -0,0 +1,29 @@ +# Test-infrastructure checks (M0-TEST-01). +# +# The seed-handling convention for randomized tests (docs/testing.md §4) +# lives in tests/support/laige_test_seed.h; this suite exercises it: +# known-answer FNV-1a hashes of a fixed draw count under the default seed +# and a LAIGE_TEST_SEED override, the one-repo-wide default seed's +# identity with laige-fuzz's, the seed parser, the loud-failure path of an +# invalid LAIGE_TEST_SEED, and substream isolation. +# The committed KATs make the suite a replay fixture — identical on every +# build, compiler, and CI run (M0-TEST-01 Verify clause) — and the +# `test-seed-check` line printed to stdout is the artifact-log identity +# check across two CI runs. +# +# Layout convention (docs/testing.md §1): tests/ mirrors +# src/ and executables are named _tests; this directory is +# the non-module home of the test-infrastructure checks (the tooling +# checks live in tests/tools, tests/api, and tests/detcheck). + +add_executable(test_infra_tests seeded_random_tests.cpp) +target_include_directories(test_infra_tests PRIVATE + ${CMAKE_SOURCE_DIR}/tests/support) +laige_apply_engine_policy(test_infra_tests) +target_link_libraries(test_infra_tests PRIVATE gtest_main laige-core) + +# Step Verify command: `ctest -R test_infra` selects exactly the +# SeededRandom suite. The gtest filter is one UNQUOTED argument (CTest +# passes quoted arguments through with the literal quote characters — +# see the note in tests/laige-core/CMakeLists.txt). +add_test(NAME test_infra COMMAND test_infra_tests --gtest_filter=SeededRandom.*) diff --git a/tests/testing/seeded_random_tests.cpp b/tests/testing/seeded_random_tests.cpp new file mode 100644 index 0000000..cda0ad9 --- /dev/null +++ b/tests/testing/seeded_random_tests.cpp @@ -0,0 +1,236 @@ +// Test-infrastructure suite (M0-TEST-01): the seed-handling convention +// for randomized tests (docs/testing.md §4). +// +// Step Verify scope (roadmap/M0-foundations.md): +// - a seeded random test passes identically on two CI runs — this suite +// pins a known-answer FNV-1a hash of a fixed draw count under the +// default seed (and under a LAIGE_TEST_SEED override) and prints a +// machine-greppable `test-seed-check` line per run, so two CI runs of +// the same commit show identical lines in the job logs and the +// archived Testing/Temporary/LastTest.log; +// - the fixed default seed is overridable (LAIGE_TEST_SEED env var) and +// identical to laige-fuzz's default (one documented repo-wide seed). +// +// House conventions: the FNV-1a 64-bit convention is the same as the +// prng_tests and math_fixed_tests KATs (big-endian bytes per u64 — +// endianness-independent); named constants throughout (CORE-005); +// KAT constants below were generated by running this suite and pinning +// the printed values — a change here means the algorithm or the seed +// changed, which is loud by design (ARCH-010). +// +// NFR-8.10 policy self-checks: this TU must build with exceptions and +// RTTI disabled (laige_apply_engine_policy, see tests/testing/CMakeLists.txt). + +#include +#include +#include +#include + +#include "gtest/gtest.h" +// gtest-spi.h is GoogleTest's documented testing-support header (used by +// GoogleTest's own suite): EXPECT_NONFATAL_FAILURE lets this suite assert +// the loud-failure path of TestSeed() without the test failing itself. +#include "gtest/gtest-spi.h" +#include "laige/prng.h" +#include "laige_test_seed.h" + +#if defined(__cpp_exceptions) +static_assert(false, + "seeded_random_tests must be built with exceptions disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "seeded_random_tests must be built with exceptions disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#endif + +#if defined(__cpp_rtti) && __cpp_rtti +static_assert(false, + "seeded_random_tests must be built with RTTI disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#endif + +namespace { + +using u32 = std::uint32_t; +using u64 = std::uint64_t; + +// Fixed draw count for the known-answer hashes (named: CORE-005). +constexpr u32 kSeedCheckDraws = 65536; + +// Substream ids: stable test identities (docs/testing.md §4). Each id is +// owned by exactly one suite/file so substreams never collide. +constexpr u32 kSeedCheckStreamId = 0x0001; // this suite's default-seed KAT +constexpr u32 kOverrideStreamId = 0x0002; // this suite's override-seed KAT + +// The override seed for the env-var test: named, different from +// kDefaultTestSeed, so a broken env read cannot accidentally pass. +constexpr u64 kOverrideSeed = 0x2468ACCE01234567ull; + +// First 8 draws of the default-seed, stream-1 substream (KAT): a fast +// failure diagnostic next to the full-stream hash. +constexpr u64 kFirstDrawsStream1[8] = { + 0x0EE1EED94C2CAA69ull, 0x4D64B354EA0FFF1Full, 0x9343A884890B5222ull, + 0xEDE0A379387022A6ull, 0x98A115D856F6A2BAull, 0xDF66706092CBE9C4ull, + 0x5DC877208B35398Full, 0x4F83FA225F6E1A7Cull, +}; + +// Full-stream KATs (65536 draws each, FNV-1a 64-bit over big-endian bytes +// per draw): identical on every build, compiler, and CI run. +constexpr u64 kKnownAnswerDefaultSeed = 0x7EA4049545656830ull; +constexpr u64 kKnownAnswerOverrideSeed = 0x535D2CA741B61CBFull; + +// FNV-1a 64-bit, big-endian byte order per u64 (the same convention as +// prng_tests / math_fixed_tests KATs — endianness-independent). +u64 fnv1a64(const u64* values, std::size_t n) { + u64 h = 0xcbf29ce484222325ull; // FNV offset basis (named: FNV-1a spec) + for (std::size_t i = 0; i < n; ++i) { + for (int shift = 56; shift >= 0; shift -= 8) { + h ^= (values[i] >> shift) & 0xFFull; + h *= 0x100000001b3ull; // FNV prime (named: FNV-1a spec) + } + } + return h; +} + +// Draw kSeedCheckDraws values from the substream of `seed` and print the +// machine-greppable identity line (LOG-001) BEFORE asserting, so the line +// reaches the ctest output and the CI job logs even when the KAT +// mismatches. Two CI runs of the same commit must print identical lines +// (M0-TEST-01 Verify). +u64 seedCheck(u64 seed, u32 streamId, const char* label) { + laige::Prng rng = laige::Prng::deriveSubstream(seed, streamId); + u64 hash = 0xcbf29ce484222325ull; + u64 draw; + for (u32 i = 0; i < kSeedCheckDraws; ++i) { + draw = rng.next_u64(); + for (int shift = 56; shift >= 0; shift -= 8) { + hash ^= (draw >> shift) & 0xFFull; + hash *= 0x100000001b3ull; + } + } + std::printf("test-seed-check %s seed=0x%016llx stream=%u draws=%u " + "fnv1a=0x%016llx\n", + label, static_cast(seed), streamId, + kSeedCheckDraws, static_cast(hash)); + std::fflush(stdout); + return hash; +} + +// RAII around LAIGE_TEST_SEED: the override test sets it for the duration +// of the test body and restores the prior state (absent or present) even +// on failure. setenv/unsetenv are C99/POSIX entry points available on all +// P0 toolchains (MSVC exposes them in ; the deprecated _putenv +// spelling is not used — C4996 is fatal under /WX). +class ScopedTestSeedEnv { + public: + explicit ScopedTestSeedEnv(const char* value) { + const char* prior = std::getenv(laige::testing::kTestSeedEnvVar); + hadPrior_ = prior != nullptr; + priorValue_ = hadPrior_ ? std::string(prior) : std::string(); + ::setenv(laige::testing::kTestSeedEnvVar, value, 1); + } + ~ScopedTestSeedEnv() { + if (hadPrior_) { + ::setenv(laige::testing::kTestSeedEnvVar, priorValue_.c_str(), 1); + } else { + ::unsetenv(laige::testing::kTestSeedEnvVar); + } + } + + private: + bool hadPrior_ = false; + std::string priorValue_; +}; + +} // namespace + +// --------------------------------------------------------------------------- +// SeededRandom — the seed-handling convention (M0-TEST-01) +// --------------------------------------------------------------------------- + +// The default-seed known answer: 65536 draws from the documented default +// seed's stream-1 substream. Identical on every build, compiler, and CI +// run (pure integer arithmetic, ARCH-010). +TEST(SeededRandom, DefaultSeedKnownAnswer) { + const u64 hash = seedCheck(laige::testing::kDefaultTestSeed, + kSeedCheckStreamId, "default"); + EXPECT_EQ(kKnownAnswerDefaultSeed, hash) + << "default-seed stream changed (seed 0x1F055EED, stream " + << kSeedCheckStreamId << ", " << kSeedCheckDraws << " draws); see the " + "test-seed-check line above — re-pin the KAT only with a " + "documented algorithm/seed change (docs/testing.md §4)"; + // Fast diagnostic: the first draws of the same stream. + laige::Prng rng = + laige::Prng::deriveSubstream(laige::testing::kDefaultTestSeed, + kSeedCheckStreamId); + for (int i = 0; i < 8; ++i) { + EXPECT_EQ(kFirstDrawsStream1[i], rng.next_u64()) << "draw " << i; + } +} + +// One documented default seed repo-wide: the test default equals +// laige-fuzz's default (tools/fuzz/laige-fuzz.cpp kDefaultSeed). +TEST(SeededRandom, DefaultSeedMatchesTheFuzzRunnerSeed) { + EXPECT_EQ(0x1F055EEDull, laige::testing::kDefaultTestSeed); +} + +// The override path: LAIGE_TEST_SEED replaces the default, TestSeed() +// reports it, and the override-seed stream has its own committed KAT. +TEST(SeededRandom, SeedOverrideFromEnv) { + ScopedTestSeedEnv scope(std::to_string(kOverrideSeed).c_str()); + // Decimal form, the accepted alternative to 0x-hex. + EXPECT_EQ(kOverrideSeed, laige::testing::TestSeed()); + const u64 hash = + seedCheck(kOverrideSeed, kOverrideStreamId, "override"); + EXPECT_EQ(kKnownAnswerOverrideSeed, hash) + << "override-seed stream changed (seed 0x2468ACCE01234567, stream " + << kOverrideStreamId << ", " << kSeedCheckDraws << " draws); see the " + "test-seed-check line above"; +} + +// The invalid-env path of TestSeed(): a set-but-unparseable LAIGE_TEST_SEED +// records a loud failure with the offending value (CORE-008) and falls +// back to the default seed so the caller can complete its assertions. +TEST(SeededRandom, InvalidSeedEnvFailsLoudly) { + ScopedTestSeedEnv scope("not-a-seed"); + u64 seed = 0; + EXPECT_NONFATAL_FAILURE({ seed = laige::testing::TestSeed(); }, + "LAIGE_TEST_SEED='not-a-seed' is not a valid seed"); + EXPECT_EQ(laige::testing::kDefaultTestSeed, seed); +} + +// The seed parser accepts the documented forms and rejects the rest. +TEST(SeededRandom, SeedParserAcceptsDocumentedFormsOnly) { + u64 v = 0; + EXPECT_TRUE(laige::testing::ParseTestSeed("0x1F055EED", v)); + EXPECT_EQ(0x1F055EEDull, v); + EXPECT_TRUE(laige::testing::ParseTestSeed("520445677", v)); // decimal form + EXPECT_EQ(0x1F055EEDull, v); + EXPECT_FALSE(laige::testing::ParseTestSeed("", v)); + EXPECT_FALSE(laige::testing::ParseTestSeed("0x", v)); + EXPECT_FALSE(laige::testing::ParseTestSeed("1F055EED", v)); // no 0x prefix + EXPECT_FALSE(laige::testing::ParseTestSeed("0x1F055EEDx", v)); // trailing junk + EXPECT_FALSE(laige::testing::ParseTestSeed("-1", v)); // negative +} + +// Substream isolation sanity: two distinct substream ids of the same seed +// produce distinct streams (a collision would mean the derivation hash +// broke; the committed KATs above are the positive proof of determinism). +TEST(SeededRandom, DistinctSubstreamsDiverge) { + u64 a[64] = {}; + u64 b[64] = {}; + laige::Prng ra = + laige::Prng::deriveSubstream(laige::testing::kDefaultTestSeed, + kSeedCheckStreamId); + laige::Prng rb = + laige::Prng::deriveSubstream(laige::testing::kDefaultTestSeed, + kOverrideStreamId); + for (int i = 0; i < 64; ++i) { + a[i] = ra.next_u64(); + b[i] = rb.next_u64(); + } + EXPECT_NE(fnv1a64(a, 64), fnv1a64(b, 64)) + << "substreams 1 and 2 of the default seed produced identical " + "streams — the substream derivation is broken"; +} From 2cd5a8b60db5ba932b8bd26a98f7aeff1485f93e Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sat, 12 Sep 2026 21:08:06 +0200 Subject: [PATCH 02/27] [M0-TEST-01] Record roadmap: check M0-TEST-01 box, progress board, changelog - roadmap/M0-foundations.md: M0-TEST-01 checked with Decision/Verify/Size (docs/testing.md conventions; seed helper + SeededRandom KAT suite; first regress_ test; fuzz lane semantics; local 32/32 on five trees; the two-CI-runs identity check recorded in the follow-up CI-observation commit) - roadmap/README.md: progress board M0 19 -> 20 done (total row 17 -> 20, correcting the stale total), changelog row for M0-TEST-01 (a292aef) --- roadmap/M0-foundations.md | 56 ++++++++++++++++++++++++++++++++++++--- roadmap/README.md | 9 ++++--- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/roadmap/M0-foundations.md b/roadmap/M0-foundations.md index 970c955..4363604 100644 --- a/roadmap/M0-foundations.md +++ b/roadmap/M0-foundations.md @@ -847,7 +847,7 @@ No rendering, no physics, no networking yet — `laige-core` only. ## Test infrastructure & docs -- [ ] **M0-TEST-01 · Test infrastructure conventions** +- [x] **M0-TEST-01 · Test infrastructure conventions** - **Refs:** AGENTS TEST-001/003/005; PRD §14 - **Depends:** M0-DEP-01, M0-CORE-07 - **Scope:** @@ -855,8 +855,58 @@ No rendering, no physics, no networking yet — `laige-core` only. - Regression-test convention documented: every bug fix test named `regress_`, must fail before the fix (AGENTS TEST-003) — recorded in `docs/testing.md`. - Fuzz runner `laige-fuzz`: registers targets, bounded runs in CI (`--runs=1000`), nightly long runs documented. - Seed handling for all randomized tests (fixed default seed, overridable) so CI is deterministic. - - **Verify:** convention doc exists; fuzz runner runs the `json_parse` target; a seeded random test passes identically on two CI runs (checkable via artifact logs). - - **Size:** ~150 lines + docs + - **Decision (2026-09-12):** `docs/testing.md` is the normative + conventions doc (layout, `regress_`, fuzz lane semantics, + seed handling; linked from `docs/README.md`, `tests/README.md`, + `building.md`, and `README.md`). Seed handling: + `tests/support/laige_test_seed.h` (test-only header) — `TestSeed()` + returns the fixed default `kDefaultTestSeed = 0x1F055EED`, identical + to laige-fuzz's `kDefaultSeed` (one documented default seed + repo-wide) unless `LAIGE_TEST_SEED` is set (0x-hex or decimal, read + at call time); a set-but-unparseable value records a loud test + failure with the offending value and falls back to the default + (CORE-008; `ADD_FAILURE` — `GTEST_FAIL` is void-return only); + `TestPrng(id)` derives a per-test substream from a stable named id + so two tests never share a stream position. + `tests/testing/test_infra_tests` (CTest entry `test_infra`, suite + `SeededRandom`, 6 cases): known-answer FNV-1a hashes of 65536 draws + under the default seed (`0x7EA4049545656830`) and a documented + override seed (`0x535D2CA741B61CBF`), first-8-draw KAT, the + default/fuzz seed identity, the loud invalid-`LAIGE_TEST_SEED` path + (`EXPECT_NONFATAL_FAILURE` from `gtest-spi.h`), the seed parser, and + substream isolation — each KAT prints a machine-greppable + `test-seed-check` line before asserting, so two CI runs of the same + commit show identical lines in the job logs and the archived + `Testing/Temporary/LastTest.log` (the step's cross-run Verify + clause). First `regress_` test: the M0-CORE-08 bug-fix test renamed + `ConfigJsonValid.ObjectMemberWhitespace` → + `ConfigJsonValid.regress_json_object_member_ws` (suite unchanged, so + `ctest -R config_json` still covers it; the historical M0-CORE-08 + step record is unchanged). Fuzz lane: no new CI job — the bounded + run is the existing `fuzz_json_parse` ctest entry inside every P0 + job's ctest (PRD §14 "every commit (bounded)"); the nightly long + form (`--runs=1000000`) is documented in `docs/testing.md` §3 and + added to the canonical command table (`building.md`); the scheduled + nightly lane lands with the first M1 fuzz target (asset import / + network packets, PRD §14 fuzz row). + - **Verify:** convention doc exists; fuzz runner runs the `json_parse` + target (`ctest -R fuzz_json_parse` green in every local tree and + inside every P0 job's ctest in CI); a seeded random test passes + identically on two CI runs (byte-identical `test-seed-check` lines + in the job logs / archived `LastTest.log` — CI observation recorded + in the follow-up "Record CI observation" commit). Local + (2026-09-12): 32/32 ctest with zero warnings under the NFR-8.10 + policy on g++ 16.2.1 (`build` static, `build-shared` shared, + `build-asan` ASan+UBSan fatal, `build-tsan` TSan `halt_on_error=1`) + and Clang 22.1.8 (`build-clang`); the seeded KAT line is identical + on the g++ and clang++ trees locally (cross-compiler identity; the + cross-CI-run comparison is the remaining Verify clause). + - **Size:** ~1,000 lines (over the ~150 estimate: same pattern as + M0-CORE-01…08 — `docs/testing.md` carries the conventions, + `laige_test_seed.h` the seed contract next to the code, and the + `SeededRandom` suite proves the step's Verify clauses — seeded KAT, + override, loud failure, substream isolation — cohesively rather + than split) - [ ] **M0-DOC-01 · `docs/` skeleton + index** - **Refs:** AGENTS §13 (DOC-001…DOC-007), PRD NFR-8.12 diff --git a/roadmap/README.md b/roadmap/README.md index dc4c308..fd0fd27 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -155,7 +155,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | Milestone | Steps | Done | Status | |---|---|---|---| -| M0 | 22 | 19 | ▶ in progress | +| M0 | 22 | 20 | ▶ in progress | | M1 | 25 | 0 | ⬜ not started | | M2 | 32 | 0 | ⬜ not started | | M3 | 36 | 0 | ⬜ not started | @@ -165,7 +165,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | M7 | 15 | 0 | ⬜ not started | | M8 | 8 | 0 | ⬜ not started | | M9 | 6 | 0 | ⬜ proposals only | -| **Total** | **193** | **17** | | +| **Total** | **193** | **20** | | --- @@ -190,8 +190,9 @@ One line per completed (or split/renumbered) step. | 2026-09-11 | M0-CORE-06 | `a82de8e` | `laige::Prng`: xorshift128+ transcribed from and verified against the reference (all-zero state excluded), splitmix64 seeding (bijection), per-substream derivation (documented composition rule), Lemire unbiased `next_range`, `next_float01` = k·2^-24 exact; full period 2^128 − 1 for every nonzero state proven (characteristic polynomial over GF(2) via Berlekamp–Massey + irreducibility/primitivity checks; portable 128-bit arithmetic — no `__int128`, MSVC-safe); `prng` CTest entry (18 cases incl. the committed period proof and algorithm-sensitive golden KAT); API contract in `docs/api/prng.md` (board/changelog row reconstructed 2026-09-11 from the step record) | | 2026-09-11 | M0-CORE-07 | `41938b0` | Bounded JSON in laige-core (ADR 0003, no new dependency): `laige::JsonValue` (deep copy, O(1) move, deep equality) + `parseJson` (1 MiB / depth-32 bounds, strict UTF-8, duplicate keys rejected, ±inf for overflow tokens — documented) + `serializeJson` (canonical ASCII; shortest-round-trip numbers; `std::to_chars` avoided for AppleClang 15 compatibility); all failures → `MalformedInput` (3); `laige-fuzz` minimal deterministic runner (Prng-seeded, `--runs`/`--seed`, 19-document corpus) + `json_parse` fuzz target; `config_json` CTest entry (25 cases) + `fuzz_json_parse` instrumented entry (ASan tree); API contract in `docs/api/json.md` (board/changelog row reconstructed 2026-09-11 from the step record) | | 2026-09-11 | M0-CORE-08 | `810c251` | Budget harness: `laige::Histogram` (fixed-capacity rolling window; O(1) allocation-free `record()`; exact min/mean/p50/p95/p99/max over the stored window via nearest-rank percentiles; allocation-free O(n log n) `stats()`) + `laige::TimeIt` (`steady_clock` ms scope timer) + `loadBudgets`/`budgetCheck` (strict `budgets.json` schema v1 via the bounded JSON parser — ARCH-007; loud `NO_SAMPLES` failure on empty histograms; `target == 0` = hard-zero budget, not "not set"; AGENTS §12 report: stable 4-line text, before/after pair, caller context; `formatStatsLine` the single source of the stats text) + repo-root `budgets.json` (all 15 PRD §8.1 entries; `measured: 0` = not yet measured) + `laige-bench` tool (canonical command per `building.md`: `--suite=synthetic` deterministic 4096-step LCG+double stand-in workload, `--runs`/`--warmup`, `--budget=` check, exit code 2 on budget failure, `--report` append); `budget_harness` CTest entry (22 cases) + `laige_bench_smoke` CTest entry; **bug fix (M0-CORE-07)** found by this step's Verify run: `json.cpp` `parseObjectMembers` missing `skipWhitespace` before the member key — object documents with `", "` between members (the hand-formatted `budgets.json`) were rejected; regression test `ConfigJsonValid.ObjectMemberWhitespace` (fails pre-fix); API contract in `docs/api/budget_harness.md`; local Verify: `ctest` 16/16, zero warnings on GCC static/shared/ASan/TSan + Clang trees; MSVC/AppleClang compile proof lands in CI | -| 2026-09-12 | M0-TOOL-01 | `937ac7c` | API manifest (NFR-13.1, PRD §9.4): `laige-api` target + `tools/api/laige-api-scanner` (line-oriented state machine — no regex pass; loud failure on every unsupported construct; doc association from consecutive `//` blocks with `@budget`/`@experimental` tags; `--check FILE` byte compare + symbol-level diff via `laige::parseJson`; exit 0 OK / 1 stale / 2 error) + checked-in `laige-api.json` (version 1, deterministic, no timestamps — byte-identical regeneration is the drift check) + `tests/api` CTest entries (fixture tree with exact manifest bytes, fresh/stale, unsupported-construct failure, real-tree `--check`) + the `api-manifest` CI job (every PR and merge, fails on drift); (board/changelog row retroactively added 2026-09-12 — the step merged as `937ac7c`/PR #10 without updating this board or log) | -| 2026-09-12 | M0-TOOL-02 | `fe4460c` | Determinism checker skeleton (FR-11.5, AGENTS ARCH-010, TEST-004): `laige-detcheck` (`tools/detcheck`) runs a named scenario in two build configurations and compares per-tick hash streams; scenario contract (normative in the tool header, mirrored in `docs/api/detcheck.md`): one stdout line per tick ` ` — 16 lowercase hex hash digits (algorithm NOT part of the contract — lines compare byte-for-byte), tick starts at 0 step 1 no padding, trailing newline optional / trailing `\r` tolerated, stderr ignored, exit 0 — enforced strictly and bounded (65536 ticks, 64 bytes/line; a violation is a loud exit 2, CORE-008); modes: `--scenario=synthetic\|synthetic-perturbed` (built-in 32-body `fpx16_16`+Prng workload, two in-process runs — pure integer arithmetic, bit-exact per ADR 0002; perturbation fixture: +1 unit to body 3's x at tick 7) and `--run-a= --run-b= [-- scenario-args...]` (the M1-DET-04 mode; `--` separator keeps scenario args unambiguous); stable report `detcheck scenario= result=OK\|DIVERGED [first_diff_tick=]` + run-a/run-b lines (LOG-001); exit 0 match / 1 divergence / 2 error; POSIX fork/exec + pipe capture, Windows CreateProcessW + PeekNamedPipe (no shell, bounded memory, scenario stderr stays on the CI log); 9 CTest entries (`tests/detcheck`, `ctest -R detcheck`): self-check, built-in perturbation, identical/diverged/malformed/failure/short-stream cross-binary pairs — one fixture source, five compiled variants — each a generated `cmake -P` script asserting exit code + output fragments (tests/api pattern); CI `detcheck` job in ci-pull.yml/ci.yml (every PR and merge, no ci:* condition — tooling check, not a P0 OS build): runs the synthetic self-check and SKIPS the real-scenario step (two build configurations of M1-SAMPLE-01's hello) until it lands — M1-DET-04 activates it; local Verify: ctest 31/31, zero warnings on g++ static/shared, clang++, ASan+UBSan, TSan trees; Windows path compile-verified by the CI MSVC job; CI (observed 2026-09-12 via the GitHub API): `ci-pull.yml` run 34697638239 on `82c548d` green - the default-Linux lane's four P0/sanitizer jobs (linux-gcc, linux-clang, linux-asan+UBSan, linux-tsan) plus all three tooling jobs (include-lint, api-manifest, detcheck) passed; Windows/macOS jobs label-skipped as expected; the new `detcheck` job's real-scenario step correctly reported `skipped: no real scenario yet (M1-SAMPLE-01)` | +| 2026-09-12 | M0-TOOL-01 | `937ac7c` | API manifest (NFR-13.1, PRD §9.4): `laige-api` target + `tools/api/laige-api-scanner` (line-oriented state machine — no regex pass; loud failure on every unsupported construct; doc association from consecutive `//` blocks with `@budget`/`@experimental` tags; `--check FILE` byte compare + symbol-level diff via `laige::parseJson`; exit 0 OK / 1 stale / 2 error) + checked-in `laige-api.json` (version 1, deterministic, no timestamps — byte-identical regeneration is the drift check) + `tests/api` CTest entries (fixture tree with exact manifest bytes, fresh/stale, unsupported-construct failure, real-tree `--check`) + the `api-manifest` CI job (every PR and merge, fails on drift); (board/changelog row retroactively added 2026-09-12 — the step merged as `937ac7c`/PR #10 without updating this board or log) || 2026-09-12 | M0-TOOL-02 | `fe4460c` | Determinism checker skeleton (FR-11.5, AGENTS ARCH-010, TEST-004): `laige-detcheck` (`tools/detcheck`) runs a named scenario in two build configurations and compares per-tick hash streams; scenario contract (normative in the tool header, mirrored in `docs/api/detcheck.md`): one stdout line per tick ` ` — 16 lowercase hex hash digits (algorithm NOT part of the contract — lines compare byte-for-byte), tick starts at 0 step 1 no padding, trailing newline optional / trailing `\r` tolerated, stderr ignored, exit 0 — enforced strictly and bounded (65536 ticks, 64 bytes/line; a violation is a loud exit 2, CORE-008); modes: `--scenario=synthetic\|synthetic-perturbed` (built-in 32-body `fpx16_16`+Prng workload, two in-process runs — pure integer arithmetic, bit-exact per ADR 0002; perturbation fixture: +1 unit to body 3's x at tick 7) and `--run-a= --run-b= [-- scenario-args...]` (the M1-DET-04 mode; `--` separator keeps scenario args unambiguous); stable report `detcheck scenario= result=OK\|DIVERGED [first_diff_tick=]` + run-a/run-b lines (LOG-001); exit 0 match / 1 divergence / 2 error; POSIX fork/exec + pipe capture, Windows CreateProcessW + PeekNamedPipe (no shell, bounded memory, scenario stderr stays on the CI log); 9 CTest entries (`tests/detcheck`, `ctest -R detcheck`): self-check, built-in perturbation, identical/diverged/malformed/failure/short-stream cross-binary pairs — one fixture source, five compiled variants — each a generated `cmake -P` script asserting exit code + output fragments (tests/api pattern); CI `detcheck` job in ci-pull.yml/ci.yml (every PR and merge, no ci:* condition — tooling check, not a P0 OS build): runs the synthetic self-check and SKIPS the real-scenario step (two build configurations of M1-SAMPLE-01's hello) until it lands — M1-DET-04 activates it; local Verify: ctest 31/31, zero warnings on g++ static/shared, clang++, ASan+UBSan, TSan trees; Windows path compile-verified by the CI MSVC job; CI (observed 2026-09-12 via the GitHub API): `ci-pull.yml` run 34697638239 on `82c548d` green - the default-Linux lane's four P0/sanitizer jobs (linux-gcc, linux-clang, linux-asan+UBSan, linux-tsan) plus all three tooling jobs (include-lint, api-manifest, detcheck) passed; Windows/macOS jobs label-skipped as expected; the new `detcheck` job's real-scenario step correctly reported `skipped: no real scenario yet (M1-SAMPLE-01)` | +| 2026-09-12 | M0-TEST-01 | `a292aef` | Test infrastructure conventions (docs/testing.md, source of truth, linked from docs/README.md, tests/README.md, building.md, README.md): tests/support/laige_test_seed.h (TestSeed()/TestPrng() — fixed default 0x1F055EED, identical to laige-fuzz's kDefaultSeed, one documented default seed repo-wide; LAIGE_TEST_SEED env override, 0x-hex/decimal, read at call time; set-but-unparseable value records a loud test failure and falls back to the default — CORE-008; per-test substream ids so streams never share position) + tests/testing (test_infra_tests, CTest entry `test_infra`, SeededRandom suite, 6 cases: 65536-draw FNV-1a KATs under the default seed 0x7EA4049545656830 and override seed 0x535D2CA741B61CBF, first-8-draw KAT, default/fuzz seed identity, loud invalid-env path via EXPECT_NONFATAL_FAILURE (gtest-spi.h), seed parser, substream isolation; machine-greppable `test-seed-check` line per KAT before asserting — the byte-identical-across-two-CI-runs identity check, M0-TEST-01 Verify) + first regress_ test (M0-CORE-08's ConfigJsonValid.ObjectMemberWhitespace renamed regress_json_object_member_ws; suite unchanged so `ctest -R config_json` still covers it; historical M0-CORE-08 record unchanged) + fuzz lane semantics (no new CI job — bounded --runs=1000 is the existing fuzz_json_parse ctest entry inside every P0 job's ctest, PRD §14 "every commit (bounded)"; nightly long form --runs=1000000 documented in docs/testing.md §3 + canonical command table; scheduled nightly lane lands with the first M1 fuzz target); CI workflow headers note the fuzz lane; local Verify: 32/32 ctest, zero warnings under NFR-8.10, on g++ static/shared, ASan+UBSan, TSan, and Clang trees; seeded KAT line identical on g++ and clang++ locally (cross-CI-run comparison recorded in the follow-up CI-observation commit) | + --- From 60c09fd10087019ba5285626aac6e97402799c75 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sat, 12 Sep 2026 21:23:13 +0200 Subject: [PATCH 03/27] [M0-TEST-01] Fix Windows CI: detcheck min-macro collision + test env portability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovered by this step's first full-matrix CI run (ci.yml run 34713475074 on the branch): the windows-msvc job builds the whole tree, and this was the first run in which it did — the M0-TOOL-02 PR run had windows-msvc label-skipped, so the detcheck bug below was never exercised before. - tools/detcheck/laige-detcheck.cpp: windows.h's WinDef.h defines the min/max macros, which broke std::min in compareStreams (MSVC C2589 / C2059 / C2737). Define NOMINMAX before including windows.h (CPP-009: compile-time platform boundary). - tests/support/laige_test_seed.h: ReadEnvVar() platform boundary — MSVC deprecates plain getenv (C4996, fatal under /WX); use getenv_s with the same lookup semantics (the established pattern of tests/laige-core/budget_harness_tests.cpp and tools/bench/laige-bench.cpp). - tests/testing/seeded_random_tests.cpp: ScopedTestSeedEnv — MSVC's CRT has no setenv/unsetenv (C2039/C3861); use _putenv_s (the secure variant, no C4996) with _putenv_s(name, "") as the unset equivalent (TestSeed() treats an empty value exactly as unset). - roadmap/README.md: repair the M0-TOOL-02/M0-TEST-01 changelog rows that a prior edit merged into one line. Local re-verification: 32/32 ctest, zero warnings, on all five local trees (g++ static/shared, ASan+UBSan, TSan, Clang). --- roadmap/README.md | 3 ++- tests/support/laige_test_seed.h | 32 ++++++++++++++++++++++---- tests/testing/seeded_random_tests.cpp | 33 ++++++++++++++++++--------- tools/detcheck/laige-detcheck.cpp | 4 ++++ 4 files changed, 55 insertions(+), 17 deletions(-) diff --git a/roadmap/README.md b/roadmap/README.md index fd0fd27..fc1c756 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -190,7 +190,8 @@ One line per completed (or split/renumbered) step. | 2026-09-11 | M0-CORE-06 | `a82de8e` | `laige::Prng`: xorshift128+ transcribed from and verified against the reference (all-zero state excluded), splitmix64 seeding (bijection), per-substream derivation (documented composition rule), Lemire unbiased `next_range`, `next_float01` = k·2^-24 exact; full period 2^128 − 1 for every nonzero state proven (characteristic polynomial over GF(2) via Berlekamp–Massey + irreducibility/primitivity checks; portable 128-bit arithmetic — no `__int128`, MSVC-safe); `prng` CTest entry (18 cases incl. the committed period proof and algorithm-sensitive golden KAT); API contract in `docs/api/prng.md` (board/changelog row reconstructed 2026-09-11 from the step record) | | 2026-09-11 | M0-CORE-07 | `41938b0` | Bounded JSON in laige-core (ADR 0003, no new dependency): `laige::JsonValue` (deep copy, O(1) move, deep equality) + `parseJson` (1 MiB / depth-32 bounds, strict UTF-8, duplicate keys rejected, ±inf for overflow tokens — documented) + `serializeJson` (canonical ASCII; shortest-round-trip numbers; `std::to_chars` avoided for AppleClang 15 compatibility); all failures → `MalformedInput` (3); `laige-fuzz` minimal deterministic runner (Prng-seeded, `--runs`/`--seed`, 19-document corpus) + `json_parse` fuzz target; `config_json` CTest entry (25 cases) + `fuzz_json_parse` instrumented entry (ASan tree); API contract in `docs/api/json.md` (board/changelog row reconstructed 2026-09-11 from the step record) | | 2026-09-11 | M0-CORE-08 | `810c251` | Budget harness: `laige::Histogram` (fixed-capacity rolling window; O(1) allocation-free `record()`; exact min/mean/p50/p95/p99/max over the stored window via nearest-rank percentiles; allocation-free O(n log n) `stats()`) + `laige::TimeIt` (`steady_clock` ms scope timer) + `loadBudgets`/`budgetCheck` (strict `budgets.json` schema v1 via the bounded JSON parser — ARCH-007; loud `NO_SAMPLES` failure on empty histograms; `target == 0` = hard-zero budget, not "not set"; AGENTS §12 report: stable 4-line text, before/after pair, caller context; `formatStatsLine` the single source of the stats text) + repo-root `budgets.json` (all 15 PRD §8.1 entries; `measured: 0` = not yet measured) + `laige-bench` tool (canonical command per `building.md`: `--suite=synthetic` deterministic 4096-step LCG+double stand-in workload, `--runs`/`--warmup`, `--budget=` check, exit code 2 on budget failure, `--report` append); `budget_harness` CTest entry (22 cases) + `laige_bench_smoke` CTest entry; **bug fix (M0-CORE-07)** found by this step's Verify run: `json.cpp` `parseObjectMembers` missing `skipWhitespace` before the member key — object documents with `", "` between members (the hand-formatted `budgets.json`) were rejected; regression test `ConfigJsonValid.ObjectMemberWhitespace` (fails pre-fix); API contract in `docs/api/budget_harness.md`; local Verify: `ctest` 16/16, zero warnings on GCC static/shared/ASan/TSan + Clang trees; MSVC/AppleClang compile proof lands in CI | -| 2026-09-12 | M0-TOOL-01 | `937ac7c` | API manifest (NFR-13.1, PRD §9.4): `laige-api` target + `tools/api/laige-api-scanner` (line-oriented state machine — no regex pass; loud failure on every unsupported construct; doc association from consecutive `//` blocks with `@budget`/`@experimental` tags; `--check FILE` byte compare + symbol-level diff via `laige::parseJson`; exit 0 OK / 1 stale / 2 error) + checked-in `laige-api.json` (version 1, deterministic, no timestamps — byte-identical regeneration is the drift check) + `tests/api` CTest entries (fixture tree with exact manifest bytes, fresh/stale, unsupported-construct failure, real-tree `--check`) + the `api-manifest` CI job (every PR and merge, fails on drift); (board/changelog row retroactively added 2026-09-12 — the step merged as `937ac7c`/PR #10 without updating this board or log) || 2026-09-12 | M0-TOOL-02 | `fe4460c` | Determinism checker skeleton (FR-11.5, AGENTS ARCH-010, TEST-004): `laige-detcheck` (`tools/detcheck`) runs a named scenario in two build configurations and compares per-tick hash streams; scenario contract (normative in the tool header, mirrored in `docs/api/detcheck.md`): one stdout line per tick ` ` — 16 lowercase hex hash digits (algorithm NOT part of the contract — lines compare byte-for-byte), tick starts at 0 step 1 no padding, trailing newline optional / trailing `\r` tolerated, stderr ignored, exit 0 — enforced strictly and bounded (65536 ticks, 64 bytes/line; a violation is a loud exit 2, CORE-008); modes: `--scenario=synthetic\|synthetic-perturbed` (built-in 32-body `fpx16_16`+Prng workload, two in-process runs — pure integer arithmetic, bit-exact per ADR 0002; perturbation fixture: +1 unit to body 3's x at tick 7) and `--run-a= --run-b= [-- scenario-args...]` (the M1-DET-04 mode; `--` separator keeps scenario args unambiguous); stable report `detcheck scenario= result=OK\|DIVERGED [first_diff_tick=]` + run-a/run-b lines (LOG-001); exit 0 match / 1 divergence / 2 error; POSIX fork/exec + pipe capture, Windows CreateProcessW + PeekNamedPipe (no shell, bounded memory, scenario stderr stays on the CI log); 9 CTest entries (`tests/detcheck`, `ctest -R detcheck`): self-check, built-in perturbation, identical/diverged/malformed/failure/short-stream cross-binary pairs — one fixture source, five compiled variants — each a generated `cmake -P` script asserting exit code + output fragments (tests/api pattern); CI `detcheck` job in ci-pull.yml/ci.yml (every PR and merge, no ci:* condition — tooling check, not a P0 OS build): runs the synthetic self-check and SKIPS the real-scenario step (two build configurations of M1-SAMPLE-01's hello) until it lands — M1-DET-04 activates it; local Verify: ctest 31/31, zero warnings on g++ static/shared, clang++, ASan+UBSan, TSan trees; Windows path compile-verified by the CI MSVC job; CI (observed 2026-09-12 via the GitHub API): `ci-pull.yml` run 34697638239 on `82c548d` green - the default-Linux lane's four P0/sanitizer jobs (linux-gcc, linux-clang, linux-asan+UBSan, linux-tsan) plus all three tooling jobs (include-lint, api-manifest, detcheck) passed; Windows/macOS jobs label-skipped as expected; the new `detcheck` job's real-scenario step correctly reported `skipped: no real scenario yet (M1-SAMPLE-01)` | +| 2026-09-12 | M0-TOOL-01 | `937ac7c` | API manifest (NFR-13.1, PRD §9.4): `laige-api` target + `tools/api/laige-api-scanner` (line-oriented state machine — no regex pass; loud failure on every unsupported construct; doc association from consecutive `//` blocks with `@budget`/`@experimental` tags; `--check FILE` byte compare + symbol-level diff via `laige::parseJson`; exit 0 OK / 1 stale / 2 error) + checked-in `laige-api.json` (version 1, deterministic, no timestamps — byte-identical regeneration is the drift check) + `tests/api` CTest entries (fixture tree with exact manifest bytes, fresh/stale, unsupported-construct failure, real-tree `--check`) + the `api-manifest` CI job (every PR and merge, fails on drift); (board/changelog row retroactively added 2026-09-12 — the step merged as `937ac7c`/PR #10 without updating this board or log) | +| 2026-09-12 | M0-TOOL-02 | `fe4460c` | Determinism checker skeleton (FR-11.5, AGENTS ARCH-010, TEST-004): `laige-detcheck` (`tools/detcheck`) runs a named scenario in two build configurations and compares per-tick hash streams; scenario contract (normative in the tool header, mirrored in `docs/api/detcheck.md`): one stdout line per tick ` ` — 16 lowercase hex hash digits (algorithm NOT part of the contract — lines compare byte-for-byte), tick starts at 0 step 1 no padding, trailing newline optional / trailing `\r` tolerated, stderr ignored, exit 0 — enforced strictly and bounded (65536 ticks, 64 bytes/line; a violation is a loud exit 2, CORE-008); modes: `--scenario=synthetic\|synthetic-perturbed` (built-in 32-body `fpx16_16`+Prng workload, two in-process runs — pure integer arithmetic, bit-exact per ADR 0002; perturbation fixture: +1 unit to body 3's x at tick 7) and `--run-a= --run-b= [-- scenario-args...]` (the M1-DET-04 mode; `--` separator keeps scenario args unambiguous); stable report `detcheck scenario= result=OK\|DIVERGED [first_diff_tick=]` + run-a/run-b lines (LOG-001); exit 0 match / 1 divergence / 2 error; POSIX fork/exec + pipe capture, Windows CreateProcessW + PeekNamedPipe (no shell, bounded memory, scenario stderr stays on the CI log); 9 CTest entries (`tests/detcheck`, `ctest -R detcheck`): self-check, built-in perturbation, identical/diverged/malformed/failure/short-stream cross-binary pairs — one fixture source, five compiled variants — each a generated `cmake -P` script asserting exit code + output fragments (tests/api pattern); CI `detcheck` job in ci-pull.yml/ci.yml (every PR and merge, no ci:* condition — tooling check, not a P0 OS build): runs the synthetic self-check and SKIPS the real-scenario step (two build configurations of M1-SAMPLE-01's hello) until it lands — M1-DET-04 activates it; local Verify: ctest 31/31, zero warnings on g++ static/shared, clang++, ASan+UBSan, TSan trees; Windows path compile-verified by the CI MSVC job; CI (observed 2026-09-12 via the GitHub API): `ci-pull.yml` run 34697638239 on `82c548d` green - the default-Linux lane's four P0/sanitizer jobs (linux-gcc, linux-clang, linux-asan+UBSan, linux-tsan) plus all three tooling jobs (include-lint, api-manifest, detcheck) passed; Windows/macOS jobs label-skipped as expected; the new `detcheck` job's real-scenario step correctly reported `skipped: no real scenario yet (M1-SAMPLE-01)` | | 2026-09-12 | M0-TEST-01 | `a292aef` | Test infrastructure conventions (docs/testing.md, source of truth, linked from docs/README.md, tests/README.md, building.md, README.md): tests/support/laige_test_seed.h (TestSeed()/TestPrng() — fixed default 0x1F055EED, identical to laige-fuzz's kDefaultSeed, one documented default seed repo-wide; LAIGE_TEST_SEED env override, 0x-hex/decimal, read at call time; set-but-unparseable value records a loud test failure and falls back to the default — CORE-008; per-test substream ids so streams never share position) + tests/testing (test_infra_tests, CTest entry `test_infra`, SeededRandom suite, 6 cases: 65536-draw FNV-1a KATs under the default seed 0x7EA4049545656830 and override seed 0x535D2CA741B61CBF, first-8-draw KAT, default/fuzz seed identity, loud invalid-env path via EXPECT_NONFATAL_FAILURE (gtest-spi.h), seed parser, substream isolation; machine-greppable `test-seed-check` line per KAT before asserting — the byte-identical-across-two-CI-runs identity check, M0-TEST-01 Verify) + first regress_ test (M0-CORE-08's ConfigJsonValid.ObjectMemberWhitespace renamed regress_json_object_member_ws; suite unchanged so `ctest -R config_json` still covers it; historical M0-CORE-08 record unchanged) + fuzz lane semantics (no new CI job — bounded --runs=1000 is the existing fuzz_json_parse ctest entry inside every P0 job's ctest, PRD §14 "every commit (bounded)"; nightly long form --runs=1000000 documented in docs/testing.md §3 + canonical command table; scheduled nightly lane lands with the first M1 fuzz target); CI workflow headers note the fuzz lane; local Verify: 32/32 ctest, zero warnings under NFR-8.10, on g++ static/shared, ASan+UBSan, TSan, and Clang trees; seeded KAT line identical on g++ and clang++ locally (cross-CI-run comparison recorded in the follow-up CI-observation commit) | diff --git a/tests/support/laige_test_seed.h b/tests/support/laige_test_seed.h index 5612f9f..cbf9f53 100644 --- a/tests/support/laige_test_seed.h +++ b/tests/support/laige_test_seed.h @@ -43,15 +43,37 @@ #pragma once +#include #include #include #include +#include #include "gtest/gtest.h" #include "laige/prng.h" namespace laige::testing { +// The value of an environment variable; the empty string when unset. +// Platform boundary (CPP-009, the pattern of +// tests/laige-core/budget_harness_tests.cpp and tools/bench/laige-bench.cpp): +// MSVC deprecates plain getenv (C4996, fatal under /WX); getenv_s has the +// same lookup semantics. Largest value any caller reads here is a 16-hex +// seed string; 4096 is far beyond it, and a longer value is treated as +// unset (the documented fallback applies). Named per CORE-005. +inline std::string ReadEnvVar(const char* name) { +#if defined(_MSC_VER) + constexpr std::size_t kEnvValueMaxBytes = 4096; + char buf[kEnvValueMaxBytes]; + std::size_t len = 0; + if (getenv_s(&len, buf, sizeof(buf), name) != 0) return {}; + return std::string(buf, len); +#else + const char* v = std::getenv(name); + return (v != nullptr) ? std::string(v) : std::string(); +#endif +} + // The fixed default test seed ("one-fuzz-seed"). Identical to // laige-fuzz's kDefaultSeed (tools/fuzz/laige-fuzz.cpp) and never // changed: committed known-answer constants in the test suites are @@ -84,18 +106,18 @@ inline bool ParseTestSeed(const char* text, std::uint64_t& out) { // complete. The test has already failed, so the ctest entry goes red // (CORE-008: no silent fallback for an explicit misconfiguration). inline std::uint64_t TestSeed() { - const char* env = std::getenv(kTestSeedEnvVar); - if (env == nullptr || *env == '\0') { + const std::string env = ReadEnvVar(kTestSeedEnvVar); + if (env.empty()) { return kDefaultTestSeed; } std::uint64_t seed = 0; - if (!ParseTestSeed(env, seed)) { + if (!ParseTestSeed(env.c_str(), seed)) { std::fprintf(stderr, "laige test seed: invalid %s='%s' (use 0x-hex or decimal); " "using the default 0x%llx — the test is already failing\n", - kTestSeedEnvVar, env, + kTestSeedEnvVar, env.c_str(), static_cast(kDefaultTestSeed)); - ADD_FAILURE() << kTestSeedEnvVar << "='" << env + ADD_FAILURE() << kTestSeedEnvVar << "='" << env.c_str() << "' is not a valid seed (0x-prefixed hex or decimal); " << "falling back to the default 0x" << kDefaultTestSeed; return kDefaultTestSeed; diff --git a/tests/testing/seeded_random_tests.cpp b/tests/testing/seeded_random_tests.cpp index cda0ad9..3860322 100644 --- a/tests/testing/seeded_random_tests.cpp +++ b/tests/testing/seeded_random_tests.cpp @@ -119,26 +119,37 @@ u64 seedCheck(u64 seed, u32 streamId, const char* label) { // RAII around LAIGE_TEST_SEED: the override test sets it for the duration // of the test body and restores the prior state (absent or present) even -// on failure. setenv/unsetenv are C99/POSIX entry points available on all -// P0 toolchains (MSVC exposes them in ; the deprecated _putenv -// spelling is not used — C4996 is fatal under /WX). +// on failure. Platform boundary (CPP-009): setenv/unsetenv are C99/POSIX; +// MSVC's CRT does not provide them, so the Windows branch uses _putenv_s +// (the documented secure variant — no C4996 under /WX). On Windows, +// _putenv_s(name, "") is the "unset" equivalent: TestSeed() treats an +// empty value exactly as unset (the same lookup outcome ReadEnvVar gives +// on POSIX, where unsetenv removes the variable outright). class ScopedTestSeedEnv { public: explicit ScopedTestSeedEnv(const char* value) { - const char* prior = std::getenv(laige::testing::kTestSeedEnvVar); - hadPrior_ = prior != nullptr; - priorValue_ = hadPrior_ ? std::string(prior) : std::string(); - ::setenv(laige::testing::kTestSeedEnvVar, value, 1); + priorValue_ = laige::testing::ReadEnvVar(laige::testing::kTestSeedEnvVar); + hadPrior_ = !priorValue_.empty(); + SetVar(laige::testing::kTestSeedEnvVar, value); } ~ScopedTestSeedEnv() { - if (hadPrior_) { - ::setenv(laige::testing::kTestSeedEnvVar, priorValue_.c_str(), 1); + SetVar(laige::testing::kTestSeedEnvVar, + hadPrior_ ? priorValue_.c_str() : ""); + } + + private: + static void SetVar(const char* name, const char* value) { +#if defined(_MSC_VER) + ::_putenv_s(name, value); +#else + if (value == nullptr || value[0] == '\0') { + ::unsetenv(name); } else { - ::unsetenv(laige::testing::kTestSeedEnvVar); + ::setenv(name, value, 1); } +#endif } - private: bool hadPrior_ = false; std::string priorValue_; }; diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index 442497c..0a49803 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -128,6 +128,10 @@ #if defined(_WIN32) #define WIN32_LEAN_AND_MEAN +// windows.h's WinDef.h defines the min/max macros, which collide with +// std::min/std::max (MSVC C2589 in compareStreams); NOMINMAX is the +// documented opt-out (CPP-009: compile-time platform boundary). +#define NOMINMAX #include #else #include From 99229ef9173aa795b625a73f74f498a01a72f838 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sat, 12 Sep 2026 21:58:27 +0200 Subject: [PATCH 04/27] [M0-TEST-01] Improve Windows detcheck spawn diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first full-matrix CI run (ci.yml run 34714125493 on the branch) got the windows-msvc build through the NOMINMAX fix, but four detcheck cross-binary tests failed with 'scenario process exited with code 259' while the same fixture binary succeeded in other tests of the same run. 259 is ERROR_FILE_NOT_FOUND as a child exit code, which should not be reachable from the fixture main() — the message needs the attempted command and a file-existence check to diagnose it: - GetFileAttributesW before CreateProcessW: a missing executable is now reported as such (lastError included), instead of surfacing as a numeric child exit code. - The non-zero child exit message now includes the attempted command line (LOG-002: errors state what failed and with which input). Local: 32/32 ctest on g++/ASan/Clang trees; cross-binary smoke test OK. The diagnostic run will run on Windows in CI next. --- tools/detcheck/laige-detcheck.cpp | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index 0a49803..e060fdc 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -307,7 +307,16 @@ std::wstring quoteArg(std::string_view arg) { RunResult runScenario(const std::string& exe, const std::vector& args) { RunResult r; - std::wstring cmd = toWide(exe); + const std::wstring exeW = toWide(exe); + // Diagnostics (LOG-002): a failed scenario run must say WHICH binary was + // attempted and whether it exists, not just a numeric exit code. + const DWORD attrs = GetFileAttributesW(exeW.c_str()); + if (attrs == INVALID_FILE_ATTRIBUTES) { + r.error = "scenario executable not found (lastError=" + + std::to_string(GetLastError()) + "): " + exe; + return r; + } + std::wstring cmd = exeW; for (const std::string& a : args) cmd += L" " + quoteArg(a); SECURITY_ATTRIBUTES sa{}; @@ -370,7 +379,8 @@ RunResult runScenario(const std::string& exe, r.exitCode = static_cast(code); finishPending(r.lines, pending, r); if (r.exitCode != 0 && r.error.empty()) { - r.error = "scenario process exited with code " + std::to_string(code); + r.error = "scenario process exited with code " + std::to_string(code) + + " (command: " + exe + ")"; } r.ok = r.error.empty(); return r; From aaa2725af06927f73427481f94f7e2da32be3311 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sat, 12 Sep 2026 22:06:08 +0200 Subject: [PATCH 05/27] [M0-TEST-01] Fix Windows CI: retry transient first-launch image-load failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 34715705611 (full matrix on the branch) confirmed the spawn diagnostics: the attempted command exists (GetFileAttributesW found it) yet the child exited with 259 (ERROR_FILE_NOT_FOUND) while the same fixture binary succeeded in other tests of the same run — the signature of the Windows first-launch image-load race right after a fresh build (a file filter, e.g. Windows Defender, still scanning the new .exe). - runScenario (Windows) is now runScenarioOnce plus a bounded retry: one run that produced no output and exited with an OS image-load failure code (259/32/126/142/193/1422) is retried exactly once after a 250 ms delay. - The retry cannot mask scenario behavior: a deterministic scenario fails identically on the retry and is still reported as exit 2; a run with captured output is never retried. - docs/api/detcheck.md documents the Windows retry (DOC-003). Local: 32/32 ctest on g++/ASan/Clang trees (POSIX path unchanged). --- docs/api/detcheck.md | 7 ++++ tools/detcheck/laige-detcheck.cpp | 54 +++++++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/docs/api/detcheck.md b/docs/api/detcheck.md index 73b5a82..c1d5723 100644 --- a/docs/api/detcheck.md +++ b/docs/api/detcheck.md @@ -95,6 +95,13 @@ ends early, the tick lines become stream-length notes | 1 | divergence detected (a determinism failure — loud, CORE-008) | | 2 | usage error, unknown scenario, a scenario run failed (non-zero exit, spawn failure), or a scenario violated the output contract (malformed line, tick gap, unbounded output) | +Windows only: a scenario run that produces no output and exits with an OS +image-load failure code (e.g. `259` `ERROR_FILE_NOT_FOUND`, seen right +after a fresh build while a file filter still scans the new `.exe`) is +retried exactly once after a short delay before being reported. The retry +cannot mask scenario behavior: a deterministic scenario fails identically +on the retry and the failure is still reported as exit 2. + ## The built-in synthetic workload 32 bodies of Q16.16 position/velocity (the default deterministic backend, diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index e060fdc..106a685 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -85,6 +85,11 @@ // exit or spawn failure), or a scenario violated the output // contract (malformed line / tick gap / unbounded output) // +// Windows only: a scenario run that produced no output and exited with an +// OS image-load failure code (see isTransientSpawnFailure — e.g. 259 +// ERROR_FILE_NOT_FOUND right after a fresh build while a file filter +// scans the new .exe) is retried exactly once before being reported. +// // ============================================================================ // Built-in synthetic workload // ============================================================================ @@ -304,9 +309,27 @@ std::wstring quoteArg(std::string_view arg) { return q; } -RunResult runScenario(const std::string& exe, - const std::vector& args) { - RunResult r; +// Windows image-load failure codes as a child process exit code. A freshly +// written .exe can fail its FIRST process start while a file filter (e.g. +// Windows Defender real-time scanning) still holds the file; the failure +// surfaces as the child's exit status instead of a CreateProcessW error. +// These are OS error/status codes, not scenario exit values (the scenarios +// in this repo exit 0/1/2/3, and a deterministic scenario exits with the +// same code on the retry — see runScenario below). +bool isTransientSpawnFailure(std::uint32_t code) { + // 259 ERROR_FILE_NOT_FOUND, 32 ERROR_SHARING_VIOLATION, + // 126 ERROR_MOD_NOT_FOUND, 142 0xC0000142 STATUS_FATAL_APP_EXIT, + // 193 ERROR_BAD_EXE_FORMAT, 1422 ERROR_APP_INIT_FAILURE. + return code == 259u || code == 32u || code == 126u || code == 142u || + code == 193u || code == 1422u; +} + +// One spawn+capture of a scenario binary. Returns true when the run failed +// transiently (no output captured and the exit code is an OS image-load +// failure), meaning the caller may retry once. +bool runScenarioOnce(const std::string& exe, + const std::vector& args, RunResult& r) { + r = RunResult{}; const std::wstring exeW = toWide(exe); // Diagnostics (LOG-002): a failed scenario run must say WHICH binary was // attempted and whether it exists, not just a numeric exit code. @@ -314,7 +337,7 @@ RunResult runScenario(const std::string& exe, if (attrs == INVALID_FILE_ATTRIBUTES) { r.error = "scenario executable not found (lastError=" + std::to_string(GetLastError()) + "): " + exe; - return r; + return false; } std::wstring cmd = exeW; for (const std::string& a : args) cmd += L" " + quoteArg(a); @@ -325,14 +348,14 @@ RunResult runScenario(const std::string& exe, HANDLE writeH = INVALID_HANDLE_VALUE; if (!CreatePipe(&readH, &writeH, &sa, 0)) { r.error = "CreatePipe failed"; - return r; + return false; } // The child inherits the write end of the pipe. if (!SetHandleInformation(writeH, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT)) { r.error = "SetHandleInformation failed"; CloseHandle(readH); CloseHandle(writeH); - return r; + return false; } STARTUPINFOW si{}; si.cb = sizeof si; @@ -346,7 +369,7 @@ RunResult runScenario(const std::string& exe, r.error = "CreateProcessW failed (is the path correct?)"; CloseHandle(readH); CloseHandle(writeH); - return r; + return false; } CloseHandle(writeH); @@ -383,6 +406,23 @@ RunResult runScenario(const std::string& exe, " (command: " + exe + ")"; } r.ok = r.error.empty(); + return !r.ok && r.lines.empty() && + isTransientSpawnFailure(static_cast(r.exitCode)); +} + +// Bounded first-launch retry (one attempt, short delay): absorbs the +// Windows first-launch image-load race described above. The retry CANNOT +// mask scenario behavior: only a run that produced no output and died with +// an OS image-load code is retried, and a deterministic scenario fails +// identically on the retry, so the failure is still reported. +RunResult runScenario(const std::string& exe, + const std::vector& args) { + RunResult r; + const bool transient = runScenarioOnce(exe, args, r); + if (transient) { + Sleep(250); // let the file filter settle before the single retry + runScenarioOnce(exe, args, r); + } return r; } From ff309b89db8f3eb8e25ab0c6692d6fdfa7741a44 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sat, 12 Sep 2026 22:11:39 +0200 Subject: [PATCH 06/27] [M0-TEST-01] Add per-attempt + file-write-time diagnostics to the Windows retry The one-retry fix (aaa2725) was not sufficient: on run 34716094107 the retry fired (test durations jumped 0.03s -> 0.3s) and recovered some launches (detcheck-bin-malformed / -scenario-failure passed via the retry) but detcheck-bin-identical / -args / -diverged / -stream-mismatch still failed with 259 on BOTH attempts, while the same fixture binary succeeded in other tests of the same run. The failure rate is high and shifts between runs, so the retry needs data to be tuned: - stderr line when the retry fires (attempt 1 exit code). - stderr line on final failure: attempt count + the executable's last-write time (unix seconds), distinguishing a still-being-written file from a scan/contention window on a finished file. Local: ctest 32/32 (POSIX path unchanged). --- tools/detcheck/laige-detcheck.cpp | 36 +++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index 106a685..13d5a30 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -410,6 +410,29 @@ bool runScenarioOnce(const std::string& exe, isTransientSpawnFailure(static_cast(r.exitCode)); } +// Last-write time of the scenario executable, as Unix-epoch seconds, for +// the failure diagnostic: distinguishes a file that is still being written +// (write time after the launch) from a scan/contention window on a +// finished file. 0 when it cannot be determined. +std::uint64_t lastWriteUnixSeconds(const std::wstring& exeW) { + const HANDLE h = CreateFileW( + exeW.c_str(), FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) return 0; + BY_HANDLE_FILE_INFORMATION info{}; + uint64_t epoch = 0; + if (GetFileInformationByHandle(h, &info)) { + // FILETIME is 100-ns ticks since 1601-01-01; Unix epoch is 1970-01-01, + // 11644473600 s later. + const ULARGE_INTEGER ft{info.ftLastWriteTime.dwLowDateTime, + info.ftLastWriteTime.dwHighDateTime}; + epoch = (ft.QuadPart - 116444736000000000ull) / 10000000ull; + } + CloseHandle(h); + return epoch; +} + // Bounded first-launch retry (one attempt, short delay): absorbs the // Windows first-launch image-load race described above. The retry CANNOT // mask scenario behavior: only a run that produced no output and died with @@ -419,10 +442,23 @@ RunResult runScenario(const std::string& exe, const std::vector& args) { RunResult r; const bool transient = runScenarioOnce(exe, args, r); + int attempts = 1; if (transient) { + std::fprintf(stderr, + "laige-detcheck: first launch transiently failed (exit %d); " + "retrying once after 250 ms\n", + r.exitCode); Sleep(250); // let the file filter settle before the single retry + attempts = 2; runScenarioOnce(exe, args, r); } + if (!r.ok) { + const std::uint64_t wrote = lastWriteUnixSeconds(toWide(exe)); + std::fprintf(stderr, + "laige-detcheck: scenario run failed after %d attempt(s); " + "executable last written at unix %llu\n", + attempts, static_cast(wrote)); + } return r; } From 0c64849c3bc491b12285c2b76f8c843ca4c6cf3c Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sat, 12 Sep 2026 22:18:42 +0200 Subject: [PATCH 07/27] [M0-TEST-01] Fix Windows CI: wait for scenario child before reading exit code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the 259: GetExitCodeProcess was called without first waiting for the child. A process whose image load failed (freshly written .exe still held by a file filter, e.g. Windows Defender) never reaches a terminated state at the moment the pipe closes, so GetExitCodeProcess returned STILL_ACTIVE — 259 — which we misread as the scenario's exit code. The POSIX path already had the equivalent waitpid; the Windows path did not. - WaitForSingleObject(INFINITE) before GetExitCodeProcess (mirrors the POSIX waitpid; ctest's 120s per-test timeout remains the backstop). - The transient retry now matches the codes the child actually reports on image-load failure (0xC0000142, 0xC0000366, plus the Win32 set), since 259/STILL_ACTIVE is no longer reachable. - Header + docs/api/detcheck.md updated to match (DOC-003). Local: ctest 32/32 (POSIX path unchanged). --- docs/api/detcheck.md | 13 +++++++---- tools/detcheck/laige-detcheck.cpp | 38 ++++++++++++++++++++++--------- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/docs/api/detcheck.md b/docs/api/detcheck.md index c1d5723..f4e40e8 100644 --- a/docs/api/detcheck.md +++ b/docs/api/detcheck.md @@ -96,11 +96,14 @@ ends early, the tick lines become stream-length notes | 2 | usage error, unknown scenario, a scenario run failed (non-zero exit, spawn failure), or a scenario violated the output contract (malformed line, tick gap, unbounded output) | Windows only: a scenario run that produces no output and exits with an OS -image-load failure code (e.g. `259` `ERROR_FILE_NOT_FOUND`, seen right -after a fresh build while a file filter still scans the new `.exe`) is -retried exactly once after a short delay before being reported. The retry -cannot mask scenario behavior: a deterministic scenario fails identically -on the retry and the failure is still reported as exit 2. +process-start failure code (e.g. `0xC0000142` `STATUS_FATAL_APP_EXIT`, +seen right after a fresh build while a file filter still scans the new +`.exe`) is retried exactly once after a short delay before being +reported. The tool waits for the child process to terminate before reading +its exit code, so the `STILL_ACTIVE` sentinel (`259`) is never reported as +a scenario result. The retry cannot mask scenario behavior: a +deterministic scenario fails identically on the retry and the failure is +still reported as exit 2. ## The built-in synthetic workload diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index 13d5a30..0dff15b 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -86,9 +86,12 @@ // contract (malformed line / tick gap / unbounded output) // // Windows only: a scenario run that produced no output and exited with an -// OS image-load failure code (see isTransientSpawnFailure — e.g. 259 -// ERROR_FILE_NOT_FOUND right after a fresh build while a file filter -// scans the new .exe) is retried exactly once before being reported. +// OS process-start failure code (see isTransientSpawnFailure — e.g. +// 0xC0000142 STATUS_FATAL_APP_EXIT right after a fresh build while a file +// filter scans the new .exe) is retried exactly once before being +// reported. The tool waits for the child to terminate (INFINITE) before +// reading its exit code, so STILL_ACTIVE (259) is never reported as a +// scenario result. // // ============================================================================ // Built-in synthetic workload @@ -309,19 +312,24 @@ std::wstring quoteArg(std::string_view arg) { return q; } -// Windows image-load failure codes as a child process exit code. A freshly -// written .exe can fail its FIRST process start while a file filter (e.g. -// Windows Defender real-time scanning) still holds the file; the failure -// surfaces as the child's exit status instead of a CreateProcessW error. +// Windows process-start failure codes as a child process exit code. A +// freshly written .exe can fail its first process starts while a file +// filter (e.g. Windows Defender real-time scanning) still holds the file; +// the failure surfaces as the child's exit status instead of a +// CreateProcessW error. (The 259 in the first CI diagnostics was +// STILL_ACTIVE itself — GetExitCodeProcess was called without waiting; +// since the WaitForSingleObject in runScenarioOnce, 259 is no longer +// reachable here and real image-load failures report their real code.) // These are OS error/status codes, not scenario exit values (the scenarios // in this repo exit 0/1/2/3, and a deterministic scenario exits with the // same code on the retry — see runScenario below). bool isTransientSpawnFailure(std::uint32_t code) { - // 259 ERROR_FILE_NOT_FOUND, 32 ERROR_SHARING_VIOLATION, - // 126 ERROR_MOD_NOT_FOUND, 142 0xC0000142 STATUS_FATAL_APP_EXIT, + // Win32: 32 ERROR_SHARING_VIOLATION, 126 ERROR_MOD_NOT_FOUND, // 193 ERROR_BAD_EXE_FORMAT, 1422 ERROR_APP_INIT_FAILURE. - return code == 259u || code == 32u || code == 126u || code == 142u || - code == 193u || code == 1422u; + // NTSTATUS: 0xC0000142 STATUS_FATAL_APP_EXIT (DllMain failure), + // 0xC0000366 STATUS_DLL_INIT_FAILED. + return code == 32u || code == 126u || code == 193u || code == 1422u || + code == 0xC0000142u || code == 0xC0000366u; } // One spawn+capture of a scenario binary. Returns true when the run failed @@ -395,6 +403,14 @@ bool runScenarioOnce(const std::string& exe, } } CloseHandle(readH); + // Wait for the child to actually terminate BEFORE reading its exit code: + // GetExitCodeProcess on a process that has not (yet) terminated returns + // STILL_ACTIVE (259) — and a process whose image load failed (e.g. a + // freshly written .exe still held by a file filter) can sit in that + // state. The POSIX path has the same guarantee via waitpid. + if (WaitForSingleObject(pi.hProcess, INFINITE) != WAIT_OBJECT_0) { + r.error = "WaitForSingleObject failed"; + } DWORD code = 0; GetExitCodeProcess(pi.hProcess, &code); CloseHandle(pi.hThread); From 68749786f047a81c148fbb6f37d2dcd9da0f4656 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sat, 12 Sep 2026 22:31:00 +0200 Subject: [PATCH 08/27] [M0-TEST-01] Fix Windows CI: wait for pipe data before peeking (root cause) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of all the Windows detcheck failures (found by the diagnostics in ff309b8): the capture loop treated a bare PeekNamedPipe n==0 as 'pipe closed', but n==0 also occurs while the child is still starting up and has not written yet. Breaking there closed the pipe under a live child; the child's stdout writes then failed (broken pipe) and it exited 0 with all output lost. Without the child-termination wait added earlier, GetExitCodeProcess on that still-running child returned STILL_ACTIVE — the 259 we chased. - The loop now WaitForSingleObject(readH) before each PeekNamedPipe; a pipe signals when data is available OR the write end closes, so n==0 after the wait genuinely means the scenario finished. - The WaitForSingleObject(process) before GetExitCodeProcess is kept (still required for a definite exit code; mirrors POSIX waitpid). - The speculative first-launch retry is removed: it was treating the symptom of this bug, and no retry can be justified once the capture is correct (CORE-004). The file-existence diagnostic and the attempted command in the failure message stay (LOG-002). - Header + docs/api/detcheck.md updated to match (DOC-003). Local: ctest 32/32 on g++/ASan/Clang trees; cross-binary smoke test OK (divergence at tick 7 as expected). --- docs/api/detcheck.md | 14 ++-- tools/detcheck/laige-detcheck.cpp | 120 +++++++----------------------- 2 files changed, 30 insertions(+), 104 deletions(-) diff --git a/docs/api/detcheck.md b/docs/api/detcheck.md index f4e40e8..2ac9230 100644 --- a/docs/api/detcheck.md +++ b/docs/api/detcheck.md @@ -95,15 +95,11 @@ ends early, the tick lines become stream-length notes | 1 | divergence detected (a determinism failure — loud, CORE-008) | | 2 | usage error, unknown scenario, a scenario run failed (non-zero exit, spawn failure), or a scenario violated the output contract (malformed line, tick gap, unbounded output) | -Windows only: a scenario run that produces no output and exits with an OS -process-start failure code (e.g. `0xC0000142` `STATUS_FATAL_APP_EXIT`, -seen right after a fresh build while a file filter still scans the new -`.exe`) is retried exactly once after a short delay before being -reported. The tool waits for the child process to terminate before reading -its exit code, so the `STILL_ACTIVE` sentinel (`259`) is never reported as -a scenario result. The retry cannot mask scenario behavior: a -deterministic scenario fails identically on the retry and the failure is -still reported as exit 2. +Windows only: the stdout capture waits for pipe data (or the write end +closing) before reading, and the exit code is read only after the child +has terminated — a still-starting child can never be misread as an empty +run, and the `STILL_ACTIVE` sentinel (`259`) is never reported as a +scenario exit code. ## The built-in synthetic workload diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index 0dff15b..06fafcf 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -85,13 +85,11 @@ // exit or spawn failure), or a scenario violated the output // contract (malformed line / tick gap / unbounded output) // -// Windows only: a scenario run that produced no output and exited with an -// OS process-start failure code (see isTransientSpawnFailure — e.g. -// 0xC0000142 STATUS_FATAL_APP_EXIT right after a fresh build while a file -// filter scans the new .exe) is retried exactly once before being -// reported. The tool waits for the child to terminate (INFINITE) before -// reading its exit code, so STILL_ACTIVE (259) is never reported as a -// scenario result. +// Windows only: the stdout capture waits for pipe data (or the write end +// closing) before peeking, and the exit code is read only after the child +// has terminated — so a still-starting child can never be misread as an +// empty run, and STILL_ACTIVE (259) is never reported as a scenario exit +// code. // // ============================================================================ // Built-in synthetic workload @@ -312,32 +310,9 @@ std::wstring quoteArg(std::string_view arg) { return q; } -// Windows process-start failure codes as a child process exit code. A -// freshly written .exe can fail its first process starts while a file -// filter (e.g. Windows Defender real-time scanning) still holds the file; -// the failure surfaces as the child's exit status instead of a -// CreateProcessW error. (The 259 in the first CI diagnostics was -// STILL_ACTIVE itself — GetExitCodeProcess was called without waiting; -// since the WaitForSingleObject in runScenarioOnce, 259 is no longer -// reachable here and real image-load failures report their real code.) -// These are OS error/status codes, not scenario exit values (the scenarios -// in this repo exit 0/1/2/3, and a deterministic scenario exits with the -// same code on the retry — see runScenario below). -bool isTransientSpawnFailure(std::uint32_t code) { - // Win32: 32 ERROR_SHARING_VIOLATION, 126 ERROR_MOD_NOT_FOUND, - // 193 ERROR_BAD_EXE_FORMAT, 1422 ERROR_APP_INIT_FAILURE. - // NTSTATUS: 0xC0000142 STATUS_FATAL_APP_EXIT (DllMain failure), - // 0xC0000366 STATUS_DLL_INIT_FAILED. - return code == 32u || code == 126u || code == 193u || code == 1422u || - code == 0xC0000142u || code == 0xC0000366u; -} - -// One spawn+capture of a scenario binary. Returns true when the run failed -// transiently (no output captured and the exit code is an OS image-load -// failure), meaning the caller may retry once. -bool runScenarioOnce(const std::string& exe, - const std::vector& args, RunResult& r) { - r = RunResult{}; +RunResult runScenario(const std::string& exe, + const std::vector& args) { + RunResult r; const std::wstring exeW = toWide(exe); // Diagnostics (LOG-002): a failed scenario run must say WHICH binary was // attempted and whether it exists, not just a numeric exit code. @@ -345,7 +320,7 @@ bool runScenarioOnce(const std::string& exe, if (attrs == INVALID_FILE_ATTRIBUTES) { r.error = "scenario executable not found (lastError=" + std::to_string(GetLastError()) + "): " + exe; - return false; + return r; } std::wstring cmd = exeW; for (const std::string& a : args) cmd += L" " + quoteArg(a); @@ -356,14 +331,14 @@ bool runScenarioOnce(const std::string& exe, HANDLE writeH = INVALID_HANDLE_VALUE; if (!CreatePipe(&readH, &writeH, &sa, 0)) { r.error = "CreatePipe failed"; - return false; + return r; } // The child inherits the write end of the pipe. if (!SetHandleInformation(writeH, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT)) { r.error = "SetHandleInformation failed"; CloseHandle(readH); CloseHandle(writeH); - return false; + return r; } STARTUPINFOW si{}; si.cb = sizeof si; @@ -377,19 +352,28 @@ bool runScenarioOnce(const std::string& exe, r.error = "CreateProcessW failed (is the path correct?)"; CloseHandle(readH); CloseHandle(writeH); - return false; + return r; } CloseHandle(writeH); char buf[65536]; std::string pending; for (;;) { + // Wait for the pipe to signal (data available OR all write ends + // closed) BEFORE peeking: a bare PeekNamedPipe can report n==0 while + // the child has simply not written yet (it is still starting up), + // and breaking on that n==0 would close the pipe under a live child + // — its writes then fail and it exits 0 with all output lost. + if (WaitForSingleObject(readH, INFINITE) != WAIT_OBJECT_0) { + r.error = "WaitForSingleObject(readH) failed"; + break; + } DWORD n = 0; if (!PeekNamedPipe(readH, buf, sizeof buf, &n, nullptr, nullptr)) { r.error = "PeekNamedPipe failed"; break; } - if (n == 0) break; // the scenario closed the pipe + if (n == 0) break; // signaled with no data: the scenario closed the pipe if (n > sizeof buf) n = sizeof buf; DWORD got = 0; if (!ReadFile(readH, buf, n, &got, nullptr)) { @@ -405,11 +389,10 @@ bool runScenarioOnce(const std::string& exe, CloseHandle(readH); // Wait for the child to actually terminate BEFORE reading its exit code: // GetExitCodeProcess on a process that has not (yet) terminated returns - // STILL_ACTIVE (259) — and a process whose image load failed (e.g. a - // freshly written .exe still held by a file filter) can sit in that - // state. The POSIX path has the same guarantee via waitpid. + // STILL_ACTIVE (259), which would be misread as a scenario exit code. + // The POSIX path has the same guarantee via waitpid. if (WaitForSingleObject(pi.hProcess, INFINITE) != WAIT_OBJECT_0) { - r.error = "WaitForSingleObject failed"; + r.error = "WaitForSingleObject(process) failed"; } DWORD code = 0; GetExitCodeProcess(pi.hProcess, &code); @@ -422,59 +405,6 @@ bool runScenarioOnce(const std::string& exe, " (command: " + exe + ")"; } r.ok = r.error.empty(); - return !r.ok && r.lines.empty() && - isTransientSpawnFailure(static_cast(r.exitCode)); -} - -// Last-write time of the scenario executable, as Unix-epoch seconds, for -// the failure diagnostic: distinguishes a file that is still being written -// (write time after the launch) from a scan/contention window on a -// finished file. 0 when it cannot be determined. -std::uint64_t lastWriteUnixSeconds(const std::wstring& exeW) { - const HANDLE h = CreateFileW( - exeW.c_str(), FILE_READ_ATTRIBUTES, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, - OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); - if (h == INVALID_HANDLE_VALUE) return 0; - BY_HANDLE_FILE_INFORMATION info{}; - uint64_t epoch = 0; - if (GetFileInformationByHandle(h, &info)) { - // FILETIME is 100-ns ticks since 1601-01-01; Unix epoch is 1970-01-01, - // 11644473600 s later. - const ULARGE_INTEGER ft{info.ftLastWriteTime.dwLowDateTime, - info.ftLastWriteTime.dwHighDateTime}; - epoch = (ft.QuadPart - 116444736000000000ull) / 10000000ull; - } - CloseHandle(h); - return epoch; -} - -// Bounded first-launch retry (one attempt, short delay): absorbs the -// Windows first-launch image-load race described above. The retry CANNOT -// mask scenario behavior: only a run that produced no output and died with -// an OS image-load code is retried, and a deterministic scenario fails -// identically on the retry, so the failure is still reported. -RunResult runScenario(const std::string& exe, - const std::vector& args) { - RunResult r; - const bool transient = runScenarioOnce(exe, args, r); - int attempts = 1; - if (transient) { - std::fprintf(stderr, - "laige-detcheck: first launch transiently failed (exit %d); " - "retrying once after 250 ms\n", - r.exitCode); - Sleep(250); // let the file filter settle before the single retry - attempts = 2; - runScenarioOnce(exe, args, r); - } - if (!r.ok) { - const std::uint64_t wrote = lastWriteUnixSeconds(toWide(exe)); - std::fprintf(stderr, - "laige-detcheck: scenario run failed after %d attempt(s); " - "executable last written at unix %llu\n", - attempts, static_cast(wrote)); - } return r; } From 438a9b7cab2b87d39ea6f9b276ebbcac834349fd Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sat, 12 Sep 2026 22:36:05 +0200 Subject: [PATCH 09/27] [M0-TEST-01] Log spawned child pid + image path in Windows detcheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pipe-wait fix (6874978) did not resolve the Windows failures: the child now terminates (exit 0) with no output captured, so the spawn is succeeding but the child's stdout is not our pipe in the failing cases. Record the child's pid and the image path the kernel actually started (QueryFullProcessImageNameW) on every launch — the CI log will show which process the failing launches really are. --- tools/detcheck/laige-detcheck.cpp | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index 06fafcf..883adbb 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -354,6 +354,26 @@ RunResult runScenario(const std::string& exe, CloseHandle(writeH); return r; } + // Diagnostics (LOG-002): record the child's pid and the image the kernel + // actually started — in the failure cases observed in CI the child + // exited 0 with no output, so the log must show which process that was. + { + wchar_t image[1024] = {}; + DWORD imageLen = 0; + std::string imageUtf8; + if (QueryFullProcessImageNameW(pi.hProcess, 0, image, &imageLen)) { + const int n = WideCharToMultiByte(CP_UTF8, 0, image, -1, nullptr, 0, + nullptr, nullptr); + if (n > 0) { + imageUtf8.resize(static_cast(n - 1)); + WideCharToMultiByte(CP_UTF8, 0, image, -1, imageUtf8.data(), n, + nullptr, nullptr); + } + } + std::fprintf(stderr, "laige-detcheck: spawned child pid=%lu image=%s\n", + static_cast(pi.dwProcessId), + imageUtf8.empty() ? "(unavailable)" : imageUtf8.c_str()); + } CloseHandle(writeH); char buf[65536]; From 0ba9e29c0a61a4d4b5b6f136918e6072be1bff1f Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sat, 12 Sep 2026 22:42:58 +0200 Subject: [PATCH 10/27] [M0-TEST-01] Warm up freshly built test binaries before Windows ctest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The run-11 diagnostics (child pid + image path) showed the failing launches create a child process that exits 0 with no stdout captured, while later launches of the same binary succeed — the signature of the runner's file filter (AV) first-open scan on freshly built .exe files: the first process start of a new file is disrupted, later starts are cached and fine. Add a 'Warm up scenario binaries' step to the windows-msvc job (ci.yml and the label-gated ci-pull.yml job): run laige-detcheck (synthetic), laige-fuzz (one run), and each detcheck fixture variant (--ticks=1) once, right after the build, so the first-open scans are absorbed outside the ctest assertions. The per-launch child pid/image diagnostic stays (LOG-002). --- .github/workflows/ci-pull.yml | 13 +++++++++++++ .github/workflows/ci.yml | 17 +++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/.github/workflows/ci-pull.yml b/.github/workflows/ci-pull.yml index 5519090..5b9533f 100644 --- a/.github/workflows/ci-pull.yml +++ b/.github/workflows/ci-pull.yml @@ -197,6 +197,19 @@ jobs: run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug - name: Build run: cmake --build build --config Debug -j + - name: Warm up scenario binaries + # See the same step in ci.yml: first execution of a freshly built + # .exe on a Windows runner can be disrupted by the file filter's + # first-open scan; absorb it outside the ctest assertions. + run: | + $bin = ".\build\bin\Debug" + & "$bin\laige-detcheck.exe" --scenario=synthetic + & "$bin\laige-fuzz.exe" json_parse --runs=1 + & "$bin\detcheck-fixture-scenario.exe" --ticks=1 + & "$bin\detcheck-fixture-scenario-perturbed.exe" --ticks=1 + & "$bin\detcheck-fixture-scenario-bad.exe" --ticks=1 + & "$bin\detcheck-fixture-scenario-fail.exe" --ticks=1 + & "$bin\detcheck-fixture-scenario-short.exe" --ticks=1 - name: Test (unit) run: ctest --test-dir build -C Debug --output-on-failure diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2502bab..5614769 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -218,6 +218,23 @@ jobs: run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug - name: Build run: cmake --build build --config Debug -j + - name: Warm up scenario binaries + # The first execution of a freshly built .exe on a Windows runner + # can be disrupted by the file filter's first-open scan (observed + # in the M0-TEST-01 CI: the first launches of the fresh detcheck + # fixture exes exited 0 with their stdout capture lost, while + # later launches of the same binaries succeeded). Running each + # spawned test binary once here absorbs that outside the ctest + # assertions. + run: | + $bin = ".\build\bin\Debug" + & "$bin\laige-detcheck.exe" --scenario=synthetic + & "$bin\laige-fuzz.exe" json_parse --runs=1 + & "$bin\detcheck-fixture-scenario.exe" --ticks=1 + & "$bin\detcheck-fixture-scenario-perturbed.exe" --ticks=1 + & "$bin\detcheck-fixture-scenario-bad.exe" --ticks=1 + & "$bin\detcheck-fixture-scenario-fail.exe" --ticks=1 + & "$bin\detcheck-fixture-scenario-short.exe" --ticks=1 - name: Test (unit) run: ctest --test-dir build -C Debug --output-on-failure From bfc8b884144ab1157106b97b95a94d2b84d8c57b Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sat, 12 Sep 2026 22:48:39 +0200 Subject: [PATCH 11/27] [M0-TEST-01] Add a control spawn probe to the Windows scenario runner The warm-up step did not change the failure pattern: the first ctest launches of the fresh fixture binaries still exit 0 with no stdout captured while later launches of the same binaries succeed. Probe the capture machinery itself before every scenario spawn: cmd.exe /c echo through the identical pipe/STARTUPINFO sequence. If the probe captures its marker while a scenario run does not (or vice versa), the CI log localizes the broken side of the launch. --- tools/detcheck/laige-detcheck.cpp | 60 +++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index 883adbb..cd638f7 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -310,6 +310,65 @@ std::wstring quoteArg(std::string_view arg) { return q; } +// Control probe (diagnostic): spawn a known-good command (cmd /c echo) +// through the exact same pipe machinery used for scenario runs, in this +// process instance. Its captured line must be exactly the marker; if the +// probe succeeds while a scenario run captures nothing, the machinery +// works and the scenario launch itself is the problem — and vice versa. +void probeControlSpawn() { + SECURITY_ATTRIBUTES sa{}; + sa.nLength = sizeof sa; + HANDLE readH = INVALID_HANDLE_VALUE; + HANDLE writeH = INVALID_HANDLE_VALUE; + if (!CreatePipe(&readH, &writeH, &sa, 0)) { + std::fprintf(stderr, + "laige-detcheck: control probe: CreatePipe failed " + "(lastError=%lu)\n", + static_cast(GetLastError())); + return; + } + SetHandleInformation(writeH, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); + STARTUPINFOW si{}; + si.cb = sizeof si; + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdOutput = writeH; + si.hStdError = GetStdHandle(STD_ERROR_HANDLE); + si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); + PROCESS_INFORMATION pi{}; + const std::wstring controlCmd = L"cmd.exe /c echo LAIGE_DETCHECK_CONTROL_OK"; + if (!CreateProcessW(nullptr, controlCmd.data(), nullptr, nullptr, TRUE, 0, + nullptr, nullptr, &si, &pi)) { + std::fprintf(stderr, + "laige-detcheck: control probe: CreateProcessW failed " + "(lastError=%lu)\n", + static_cast(GetLastError())); + CloseHandle(readH); + CloseHandle(writeH); + return; + } + CloseHandle(writeH); + char buf[256]; + std::string captured; + for (;;) { + if (WaitForSingleObject(readH, INFINITE) != WAIT_OBJECT_0) break; + DWORD n = 0; + if (!PeekNamedPipe(readH, buf, sizeof buf, &n, nullptr, nullptr)) break; + if (n == 0) break; + DWORD got = 0; + if (!ReadFile(readH, buf, n, &got, nullptr)) break; + captured.append(buf, got); + } + CloseHandle(readH); + WaitForSingleObject(pi.hProcess, INFINITE); + DWORD code = 0; + GetExitCodeProcess(pi.hProcess, &code); + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + std::fprintf(stderr, + "laige-detcheck: control probe: exit=%lu captured='%s'\n", + static_cast(code), captured.c_str()); +} + RunResult runScenario(const std::string& exe, const std::vector& args) { RunResult r; @@ -322,6 +381,7 @@ RunResult runScenario(const std::string& exe, std::to_string(GetLastError()) + "): " + exe; return r; } + probeControlSpawn(); std::wstring cmd = exeW; for (const std::string& a : args) cmd += L" " + quoteArg(a); From 4667cc2c784af03490be52212978ea1432e441a9 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sat, 12 Sep 2026 22:51:43 +0200 Subject: [PATCH 12/27] [M0-TEST-01] Fix MSVC C2664 in the control probe (const wstring .data()) --- tools/detcheck/laige-detcheck.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index cd638f7..66370e4 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -335,7 +335,8 @@ void probeControlSpawn() { si.hStdError = GetStdHandle(STD_ERROR_HANDLE); si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); PROCESS_INFORMATION pi{}; - const std::wstring controlCmd = L"cmd.exe /c echo LAIGE_DETCHECK_CONTROL_OK"; + // Non-const so .data() yields wchar_t* for CreateProcessW (C++20). + std::wstring controlCmd = L"cmd.exe /c echo LAIGE_DETCHECK_CONTROL_OK"; if (!CreateProcessW(nullptr, controlCmd.data(), nullptr, nullptr, TRUE, 0, nullptr, nullptr, &si, &pi)) { std::fprintf(stderr, From 1d31ab6bd5f8928fe8156b88957b8f5a356bb03e Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sat, 12 Sep 2026 23:20:06 +0200 Subject: [PATCH 13/27] [M0-TEST-01] Dump process environment in the Windows scenario runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The control probe (bfc8b88) failed in exactly the same process instances as the scenario runs (cmd.exe echo captured empty), so the capture machinery is broken per-process on the Windows runner — not the fixture binaries. To compare failing vs passing process instances, dump the CWD and environment (names always; values for LAIGE_/CTEST_ variables and PATH) before every scenario spawn. --- tools/detcheck/laige-detcheck.cpp | 66 +++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index 66370e4..a1fa6d7 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -310,6 +310,71 @@ std::wstring quoteArg(std::string_view arg) { return q; } +// Environment diagnostic (CI comparison between failing and passing +// process instances): the CWD, every environment variable name, and the +// full values of the test-wiring variables (LAIGE_/CTEST_ prefixes) and +// PATH. GetEnvironmentStringsW returns a double-NUL-terminated block of +// "NAME=VALUE" entries; the declaration goes through LPTCH, so it is +// handled via void* and cast to the actual wide block. +void dumpEnvironmentDiagnostics() { + wchar_t cwd[1024] = {}; + const DWORD cwdLen = GetCurrentDirectoryW(1024, cwd); + std::string cwdUtf8; + if (cwdLen > 0) { + const int n = WideCharToMultiByte(CP_UTF8, 0, cwd, -1, nullptr, 0, + nullptr, nullptr); + if (n > 0) { + cwdUtf8.resize(static_cast(n - 1)); + WideCharToMultiByte(CP_UTF8, 0, cwd, -1, cwdUtf8.data(), n, nullptr, + nullptr); + } + } + std::fprintf(stderr, "laige-detcheck: env cwd=%s\n", cwdUtf8.c_str()); + const void* rawEnv = GetEnvironmentStringsW(); + if (rawEnv == nullptr) { + std::fprintf(stderr, "laige-detcheck: env: unavailable\n"); + return; + } + const wchar_t* env = static_cast(rawEnv); + for (const wchar_t* block = env; *block != L'\0';) { + const size_t len = wcslen(block); + const wchar_t* eq = wcschr(block, L'='); + const std::wstring name(block, eq ? static_cast(eq - block) + : len); + const std::wstring value(eq ? eq + 1 : L""); + const bool isTestVar = name.rfind(L"LAIGE_", 0) == 0 || + name.rfind(L"CTEST_", 0) == 0 || + name == L"PATH"; + std::string nameUtf8; + { + const int n = WideCharToMultiByte(CP_UTF8, 0, name.data(), + static_cast(name.size()), + nullptr, 0, nullptr, nullptr); + nameUtf8.resize(static_cast(n)); + WideCharToMultiByte(CP_UTF8, 0, name.data(), + static_cast(name.size()), nameUtf8.data(), n, + nullptr, nullptr); + } + if (isTestVar) { + std::string valueUtf8; + const int n = WideCharToMultiByte(CP_UTF8, 0, value.data(), + static_cast(value.size()), + nullptr, 0, nullptr, nullptr); + valueUtf8.resize(static_cast(n)); + WideCharToMultiByte(CP_UTF8, 0, value.data(), + static_cast(value.size()), valueUtf8.data(), n, + nullptr, nullptr); + std::fprintf(stderr, "laige-detcheck: env %s=%s\n", nameUtf8.c_str(), + valueUtf8.c_str()); + } else { + std::fprintf(stderr, "laige-detcheck: env %s\n", nameUtf8.c_str()); + } + block += len + 1; + } + FreeEnvironmentStringsW( + static_cast(const_cast(static_cast(rawEnv)))); +} + // Control probe (diagnostic): spawn a known-good command (cmd /c echo) // through the exact same pipe machinery used for scenario runs, in this // process instance. Its captured line must be exactly the marker; if the @@ -382,6 +447,7 @@ RunResult runScenario(const std::string& exe, std::to_string(GetLastError()) + "): " + exe; return r; } + dumpEnvironmentDiagnostics(); probeControlSpawn(); std::wstring cmd = exeW; for (const std::string& a : args) cmd += L" " + quoteArg(a); From c02e33c7fa993651d69f652cacfb1bb47f24f988 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sat, 12 Sep 2026 23:24:04 +0200 Subject: [PATCH 14/27] [M0-TEST-01] Fix MSVC C2664 in the env diagnostic (LPWCH, not LPTCH) --- tools/detcheck/laige-detcheck.cpp | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index a1fa6d7..712608a 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -314,8 +314,7 @@ std::wstring quoteArg(std::string_view arg) { // process instances): the CWD, every environment variable name, and the // full values of the test-wiring variables (LAIGE_/CTEST_ prefixes) and // PATH. GetEnvironmentStringsW returns a double-NUL-terminated block of -// "NAME=VALUE" entries; the declaration goes through LPTCH, so it is -// handled via void* and cast to the actual wide block. +// "NAME=VALUE" entries. void dumpEnvironmentDiagnostics() { wchar_t cwd[1024] = {}; const DWORD cwdLen = GetCurrentDirectoryW(1024, cwd); @@ -330,12 +329,11 @@ void dumpEnvironmentDiagnostics() { } } std::fprintf(stderr, "laige-detcheck: env cwd=%s\n", cwdUtf8.c_str()); - const void* rawEnv = GetEnvironmentStringsW(); - if (rawEnv == nullptr) { + LPWCH env = GetEnvironmentStringsW(); + if (env == nullptr) { std::fprintf(stderr, "laige-detcheck: env: unavailable\n"); return; } - const wchar_t* env = static_cast(rawEnv); for (const wchar_t* block = env; *block != L'\0';) { const size_t len = wcslen(block); const wchar_t* eq = wcschr(block, L'='); @@ -371,8 +369,7 @@ void dumpEnvironmentDiagnostics() { } block += len + 1; } - FreeEnvironmentStringsW( - static_cast(const_cast(static_cast(rawEnv)))); + FreeEnvironmentStringsW(env); } // Control probe (diagnostic): spawn a known-good command (cmd /c echo) From 4eef8beafca829262896e3a48755e9960896e606 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sat, 12 Sep 2026 23:52:39 +0200 Subject: [PATCH 15/27] [M0-TEST-01] Probe rounds: handle logging, explicit app path, A/B spawn The env dump showed the LAIGE_* variables ARE in the child's environment (the 'missing' ones were a display artifact of my own dump format), so env inheritance works and the broken side is the pipe handle inheritance / STARTUPINFO delivery of spawned children in some process instances. - probeAttempt(): shared spawn+capture with logged pipe handle values, GetFileType results, and the write handle's flag info. - probeControlSpawn(): attempt 1 as before (command-line first token); attempt 2, only when attempt 1 captures nothing, passes the explicit System32 cmd.exe path via lpApplicationName. If attempt 2 succeeds in the instances where attempt 1 fails, first-token resolution is the broken side. - runScenario(): scenario launch now uses the explicit exe path as lpApplicationName (strictly safer), plus the same handle logging. - Check script prints the tool output on success too (temporary) so passing instances can be compared with failing ones. --- .../detcheck/expect-detcheck-result.cmake.in | 5 + tools/detcheck/laige-detcheck.cpp | 105 +++++++++++++----- 2 files changed, 83 insertions(+), 27 deletions(-) diff --git a/tests/detcheck/expect-detcheck-result.cmake.in b/tests/detcheck/expect-detcheck-result.cmake.in index 16e1fc9..3d96603 100644 --- a/tests/detcheck/expect-detcheck-result.cmake.in +++ b/tests/detcheck/expect-detcheck-result.cmake.in @@ -102,3 +102,8 @@ if(NOT _problems STREQUAL "") "--- end of laige-detcheck output ---") endif() message("laige-detcheck check OK: exit @EXPECT_EXIT@, required output present") +# TEMP (M0-TEST-01 Windows CI diagnosis): print the tool output on success +# too, so passing and failing process instances can be compared in the CI +# log (the tool's spawn diagnostics go to stderr). +message("--- laige-detcheck output (passing) ---\n${_text}\n" + "--- end of laige-detcheck output ---") diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index 712608a..21976f9 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -372,24 +372,29 @@ void dumpEnvironmentDiagnostics() { FreeEnvironmentStringsW(env); } -// Control probe (diagnostic): spawn a known-good command (cmd /c echo) -// through the exact same pipe machinery used for scenario runs, in this -// process instance. Its captured line must be exactly the marker; if the -// probe succeeds while a scenario run captures nothing, the machinery -// works and the scenario launch itself is the problem — and vice versa. -void probeControlSpawn() { +// One probe launch: create pipe, spawn, capture, close. Logs the pipe +// handle values and their types (a handle whose type is not PIPE, or an +// invalid value, identifies the mechanism). Returns false only when the +// spawn itself failed (details in errOut). +bool probeAttempt(const wchar_t* appname, const std::wstring& cmd, + DWORD& exitCode, std::string& captured, std::string& errOut) { SECURITY_ATTRIBUTES sa{}; sa.nLength = sizeof sa; HANDLE readH = INVALID_HANDLE_VALUE; HANDLE writeH = INVALID_HANDLE_VALUE; if (!CreatePipe(&readH, &writeH, &sa, 0)) { - std::fprintf(stderr, - "laige-detcheck: control probe: CreatePipe failed " - "(lastError=%lu)\n", - static_cast(GetLastError())); - return; + errOut = "CreatePipe failed (lastError=" + std::to_string(GetLastError()) + + ")"; + return false; } SetHandleInformation(writeH, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); + DWORD writeInfo = 0; + GetHandleInformation(writeH, &writeInfo); + std::fprintf(stderr, + "laige-detcheck: probe handles read=0x%p write=0x%p " + "types=%lu/%lu writeFlags=0x%lx\n", + static_cast(readH), static_cast(writeH), + GetFileType(readH), GetFileType(writeH), writeInfo); STARTUPINFOW si{}; si.cb = sizeof si; si.dwFlags = STARTF_USESTDHANDLES; @@ -397,21 +402,16 @@ void probeControlSpawn() { si.hStdError = GetStdHandle(STD_ERROR_HANDLE); si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); PROCESS_INFORMATION pi{}; - // Non-const so .data() yields wchar_t* for CreateProcessW (C++20). - std::wstring controlCmd = L"cmd.exe /c echo LAIGE_DETCHECK_CONTROL_OK"; - if (!CreateProcessW(nullptr, controlCmd.data(), nullptr, nullptr, TRUE, 0, + if (!CreateProcessW(appname, cmd.data(), nullptr, nullptr, TRUE, 0, nullptr, nullptr, &si, &pi)) { - std::fprintf(stderr, - "laige-detcheck: control probe: CreateProcessW failed " - "(lastError=%lu)\n", - static_cast(GetLastError())); + errOut = "CreateProcessW failed (lastError=" + std::to_string(GetLastError()) + + ")"; CloseHandle(readH); CloseHandle(writeH); - return; + return false; } CloseHandle(writeH); char buf[256]; - std::string captured; for (;;) { if (WaitForSingleObject(readH, INFINITE) != WAIT_OBJECT_0) break; DWORD n = 0; @@ -423,13 +423,53 @@ void probeControlSpawn() { } CloseHandle(readH); WaitForSingleObject(pi.hProcess, INFINITE); - DWORD code = 0; - GetExitCodeProcess(pi.hProcess, &code); + exitCode = 0; + GetExitCodeProcess(pi.hProcess, &exitCode); CloseHandle(pi.hThread); CloseHandle(pi.hProcess); - std::fprintf(stderr, - "laige-detcheck: control probe: exit=%lu captured='%s'\n", - static_cast(code), captured.c_str()); + return true; +} + +// Control probe (diagnostic): spawn a known-good command (cmd /c echo) +// through the exact same pipe machinery used for scenario runs, in this +// process instance. Attempt 1 resolves cmd.exe from the command line's +// first token (like the scenario launch); attempt 2, only when attempt 1 +// captures nothing, passes the explicit System32 cmd.exe path as +// lpApplicationName. If attempt 2 succeeds in the process instances where +// attempt 1 fails, first-token command-line resolution is the broken side. +void probeControlSpawn() { + // Non-const so .data() yields wchar_t* for CreateProcessW (C++20). + std::wstring controlCmd = L"cmd.exe /c echo LAIGE_DETCHECK_CONTROL_OK"; + const std::wstring marker = "LAIGE_DETCHECK_CONTROL_OK"; + { + DWORD code = 0; + std::string captured, err; + const bool ok = probeAttempt(nullptr, controlCmd, code, captured, err); + if (ok && captured.find(marker) != std::string::npos) { + std::fprintf(stderr, + "laige-detcheck: control probe OK: exit=%lu " + "captured='%s'\n", + static_cast(code), captured.c_str()); + return; + } + std::fprintf(stderr, "laige-detcheck: control probe attempt 1 failed " + "(spawned=%d exit=%lu captured='%s' err=%s)\n", + ok ? 1 : 0, static_cast(code), + captured.c_str(), err.c_str()); + } + wchar_t sysDir[MAX_PATH] = {}; + const DWORD sysLen = GetSystemDirectoryW(sysDir, MAX_PATH); + if (sysLen > 0 && sysLen < MAX_PATH) { + const std::wstring app = std::wstring(sysDir, sysLen) + L"\\cmd.exe"; + DWORD code = 0; + std::string captured, err; + const bool ok = probeAttempt(app.c_str(), controlCmd, code, captured, err); + std::fprintf(stderr, + "laige-detcheck: control probe attempt 2 (explicit app " + "path %ls): spawned=%d exit=%lu captured='%s' err=%s\n", + app.c_str(), ok ? 1 : 0, static_cast(code), + captured.c_str(), err.c_str()); + } } RunResult runScenario(const std::string& exe, @@ -464,6 +504,15 @@ RunResult runScenario(const std::string& exe, CloseHandle(writeH); return r; } + // Diagnostics (LOG-002): handle values and types, to distinguish a bad + // handle from a bad inheritance in the no-output failure cases. + DWORD writeInfo = 0; + GetHandleInformation(writeH, &writeInfo); + std::fprintf(stderr, + "laige-detcheck: scenario handles read=0x%p write=0x%p " + "types=%lu/%lu writeFlags=0x%lx\n", + static_cast(readH), static_cast(writeH), + GetFileType(readH), GetFileType(writeH), writeInfo); STARTUPINFOW si{}; si.cb = sizeof si; si.dwFlags = STARTF_USESTDHANDLES; @@ -471,8 +520,10 @@ RunResult runScenario(const std::string& exe, si.hStdError = GetStdHandle(STD_ERROR_HANDLE); // stays visible in the log si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); PROCESS_INFORMATION pi{}; - if (!CreateProcessW(nullptr, cmd.data(), nullptr, nullptr, TRUE, 0, nullptr, - nullptr, &si, &pi)) { + // Explicit lpApplicationName (the exact exe path) so first-token + // command-line resolution cannot substitute another image. + if (!CreateProcessW(exeW.c_str(), cmd.data(), nullptr, nullptr, TRUE, 0, + nullptr, nullptr, &si, &pi)) { r.error = "CreateProcessW failed (is the path correct?)"; CloseHandle(readH); CloseHandle(writeH); From 6be7364b9cc42a6d9bb2d40305c166ad263b098f Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sat, 12 Sep 2026 23:56:17 +0200 Subject: [PATCH 16/27] [M0-TEST-01] Fix MSVC errors in the probe round (const wstring, wstring literal) --- tools/detcheck/laige-detcheck.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index 21976f9..09398aa 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -376,7 +376,7 @@ void dumpEnvironmentDiagnostics() { // handle values and their types (a handle whose type is not PIPE, or an // invalid value, identifies the mechanism). Returns false only when the // spawn itself failed (details in errOut). -bool probeAttempt(const wchar_t* appname, const std::wstring& cmd, +bool probeAttempt(const wchar_t* appname, std::wstring cmd, DWORD& exitCode, std::string& captured, std::string& errOut) { SECURITY_ATTRIBUTES sa{}; sa.nLength = sizeof sa; @@ -440,7 +440,7 @@ bool probeAttempt(const wchar_t* appname, const std::wstring& cmd, void probeControlSpawn() { // Non-const so .data() yields wchar_t* for CreateProcessW (C++20). std::wstring controlCmd = L"cmd.exe /c echo LAIGE_DETCHECK_CONTROL_OK"; - const std::wstring marker = "LAIGE_DETCHECK_CONTROL_OK"; + const std::string marker = "LAIGE_DETCHECK_CONTROL_OK"; { DWORD code = 0; std::string captured, err; From 833949752af4d236ef03c8af309e02e6c12fc69e Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 13 Sep 2026 00:07:06 +0200 Subject: [PATCH 17/27] [M0-TEST-01] Create scenario pipes inheritable from creation Root cause found: the run-18 handle logging showed every failing instance with writeFlags=0x1 on the pipe's write end - the HANDLE_FLAG_INHERIT bit (0x80) is NOT set, even though SetHandleInformation(writeH, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) returned success (no error in runScenario). A child created with bInheritHandles=TRUE cannot inherit a non-inheritable handle, so the child never received our pipe's write end as its stdout: its writes went to a broken pipe ('The process tried to write to a nonexistent pipe'), it exited 0, and the capture saw n==0 - the 'no ticks emitted' failures. Passing instances are the ones where the flag happened to be set. Fix: set sa.bInheritHandle=TRUE when creating the pipe, so the handles are inheritable from creation; keep the SetHandleInformation call (harmless, and its failure is still checked) and log its result and the resulting handle flags on every launch. --- tools/detcheck/laige-detcheck.cpp | 35 ++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index 09398aa..f3bbad4 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -380,6 +380,11 @@ bool probeAttempt(const wchar_t* appname, std::wstring cmd, DWORD& exitCode, std::string& captured, std::string& errOut) { SECURITY_ATTRIBUTES sa{}; sa.nLength = sizeof sa; + // Inheritable from creation: on the CI Windows runner, a CreatePipe + // handle created with bInheritHandle=FALSE did not gain the INHERIT + // flag from a later SetHandleInformation (observed writeFlags=0x1, + // no 0x80), so the child never received the pipe's write end. + sa.bInheritHandle = TRUE; HANDLE readH = INVALID_HANDLE_VALUE; HANDLE writeH = INVALID_HANDLE_VALUE; if (!CreatePipe(&readH, &writeH, &sa, 0)) { @@ -387,14 +392,17 @@ bool probeAttempt(const wchar_t* appname, std::wstring cmd, ")"; return false; } - SetHandleInformation(writeH, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); + const BOOL setInherit = + SetHandleInformation(writeH, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); + const DWORD setErr = setInherit ? 0 : GetLastError(); DWORD writeInfo = 0; GetHandleInformation(writeH, &writeInfo); std::fprintf(stderr, "laige-detcheck: probe handles read=0x%p write=0x%p " - "types=%lu/%lu writeFlags=0x%lx\n", + "types=%lu/%lu writeFlags=0x%lx setInherit=%d setErr=%lu\n", static_cast(readH), static_cast(writeH), - GetFileType(readH), GetFileType(writeH), writeInfo); + GetFileType(readH), GetFileType(writeH), writeInfo, + setInherit ? 1 : 0, static_cast(setErr)); STARTUPINFOW si{}; si.cb = sizeof si; si.dwFlags = STARTF_USESTDHANDLES; @@ -491,15 +499,25 @@ RunResult runScenario(const std::string& exe, SECURITY_ATTRIBUTES sa{}; sa.nLength = sizeof sa; + // Inheritable from creation: on the CI Windows runner, a CreatePipe + // handle created with bInheritHandle=FALSE did not gain the INHERIT + // flag from a later SetHandleInformation (observed writeFlags=0x1, + // no 0x80), so the child never received the pipe's write end. + sa.bInheritHandle = TRUE; HANDLE readH = INVALID_HANDLE_VALUE; HANDLE writeH = INVALID_HANDLE_VALUE; if (!CreatePipe(&readH, &writeH, &sa, 0)) { r.error = "CreatePipe failed"; return r; } - // The child inherits the write end of the pipe. - if (!SetHandleInformation(writeH, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT)) { - r.error = "SetHandleInformation failed"; + // The child inherits the write end of the pipe (set at creation; this + // call is redundant on success and the failure check stays for + // completeness). + const BOOL setInherit = + SetHandleInformation(writeH, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); + if (!setInherit) { + r.error = "SetHandleInformation failed (lastError=" + + std::to_string(GetLastError()) + ")"; CloseHandle(readH); CloseHandle(writeH); return r; @@ -510,9 +528,10 @@ RunResult runScenario(const std::string& exe, GetHandleInformation(writeH, &writeInfo); std::fprintf(stderr, "laige-detcheck: scenario handles read=0x%p write=0x%p " - "types=%lu/%lu writeFlags=0x%lx\n", + "types=%lu/%lu writeFlags=0x%lx setInherit=%d\n", static_cast(readH), static_cast(writeH), - GetFileType(readH), GetFileType(writeH), writeInfo); + GetFileType(readH), GetFileType(writeH), writeInfo, + setInherit ? 1 : 0); STARTUPINFOW si{}; si.cb = sizeof si; si.dwFlags = STARTF_USESTDHANDLES; From 6bfbcc585db50345b50369aac3b3407e721299fb Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 13 Sep 2026 00:48:48 +0200 Subject: [PATCH 18/27] [M0-TEST-01] Child-side probe: helper self-reports, diag files to log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 19 showed the bInheritHandle=TRUE creation did not change the failure pattern, and writeFlags=0x1 (no visible INHERIT bit) persists in every failing instance even with inheritable creation — so the handle-flag reading alone cannot distinguish working from broken instances. Get the ground truth from the child side instead. - laige-detcheck-probe (new, Windows-only): a diagnostic child that reports the std handles the kernel actually assigned to it (value/type/flags) on stderr and writes a marker to stdout. - probeControlSpawn: four attempts per process instance — helper via command-line path, helper via explicit lpApplicationName, the classic cmd.exe marker, and helper with NO STARTUPINFO redirection (plain handle inheritance control). Both stdout and stderr are captured on separate pipes and logged per attempt. - Diagnostic sink: every diagnostic line goes to stderr AND a buffer flushed to $LAIGE_DETCHECK_DIAG_FILE (per-test, set by CTest's ENVIRONMENT) on every exit path — ctest hides passing-test output, so this is how a passing instance's diagnostics reach the log. - Windows CI test step cats the per-test diag files after ctest (exit code preserved); the probe helper is added to the warm-up step. - Env dump trimmed to the test-wiring variables only (the full name dump was log noise; the wiring is confirmed intact). Probes run once per process instance, not per scenario run. --- .github/workflows/ci-pull.yml | 14 +- .github/workflows/ci.yml | 16 +- tests/detcheck/CMakeLists.txt | 10 + tools/detcheck/CMakeLists.txt | 9 + tools/detcheck/laige-detcheck.cpp | 388 ++++++++++++++++++++---------- tools/detcheck/probe-helper.cpp | 36 +++ 6 files changed, 340 insertions(+), 133 deletions(-) create mode 100644 tools/detcheck/probe-helper.cpp diff --git a/.github/workflows/ci-pull.yml b/.github/workflows/ci-pull.yml index 5b9533f..5fe56ae 100644 --- a/.github/workflows/ci-pull.yml +++ b/.github/workflows/ci-pull.yml @@ -210,8 +210,20 @@ jobs: & "$bin\detcheck-fixture-scenario-bad.exe" --ticks=1 & "$bin\detcheck-fixture-scenario-fail.exe" --ticks=1 & "$bin\detcheck-fixture-scenario-short.exe" --ticks=1 + & "$bin\laige-detcheck-probe.exe" - name: Test (unit) - run: ctest --test-dir build -C Debug --output-on-failure + # See the same step in ci.yml: the per-test detcheck diagnostic + # files are printed here regardless of the ctest outcome. + run: | + ctest --test-dir build -C Debug --output-on-failure + $ctestExit = $LASTEXITCODE + Get-ChildItem .\build\tests\detcheck\diag -Filter *.txt -ErrorAction SilentlyContinue | + Sort-Object Name | + ForEach-Object { + Write-Host "===== detcheck diag: $($_.Name) =====" + Get-Content $_.FullName + } + exit $ctestExit macos-arm64: name: macOS arm64 (AppleClang) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5614769..ea21701 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -235,8 +235,22 @@ jobs: & "$bin\detcheck-fixture-scenario-bad.exe" --ticks=1 & "$bin\detcheck-fixture-scenario-fail.exe" --ticks=1 & "$bin\detcheck-fixture-scenario-short.exe" --ticks=1 + & "$bin\laige-detcheck-probe.exe" - name: Test (unit) - run: ctest --test-dir build -C Debug --output-on-failure + # ctest hides passing-test output, so the per-test detcheck + # diagnostic files (spawn handles, control probes — see + # tools/detcheck) are printed here, regardless of the ctest + # outcome; the step still exits with ctest's status. + run: | + ctest --test-dir build -C Debug --output-on-failure + $ctestExit = $LASTEXITCODE + Get-ChildItem .\build\tests\detcheck\diag -Filter *.txt -ErrorAction SilentlyContinue | + Sort-Object Name | + ForEach-Object { + Write-Host "===== detcheck diag: $($_.Name) =====" + Get-Content $_.FullName + } + exit $ctestExit macos-arm64: name: macOS arm64 (AppleClang) diff --git a/tests/detcheck/CMakeLists.txt b/tests/detcheck/CMakeLists.txt index 23129e7..7225408 100644 --- a/tests/detcheck/CMakeLists.txt +++ b/tests/detcheck/CMakeLists.txt @@ -110,6 +110,16 @@ function(laige_add_detcheck_test name expect_exit cmd) # The quoted expansion keeps the ';'-separated pairs in ONE argument. set_tests_properties(${name} PROPERTIES ENVIRONMENT "${LAIGE_DETCHECK_TEST_ENV}") + # Windows CI investigation (M0-TEST-01): the tool flushes its spawn + # diagnostics to a per-test file; the Windows CI job cats the files + # after ctest (ctest hides passing-test output). POSIX ignores the + # variable. + if(WIN32) + file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/diag") + set_tests_properties(${name} PROPERTIES + ENVIRONMENT + "${LAIGE_DETCHECK_TEST_ENV};LAIGE_DETCHECK_DIAG_FILE=${CMAKE_CURRENT_BINARY_DIR}/diag/${name}.txt") + endif() endfunction() # (name, expected exit, quoted command list, required output fragments...) diff --git a/tools/detcheck/CMakeLists.txt b/tools/detcheck/CMakeLists.txt index df82e13..9ae57e3 100644 --- a/tools/detcheck/CMakeLists.txt +++ b/tools/detcheck/CMakeLists.txt @@ -28,3 +28,12 @@ target_link_libraries(laige-detcheck PRIVATE laige-core) # CMake stamps it so the binary does not have to guess. target_compile_definitions(laige-detcheck PRIVATE LAIGE_DETCHECK_BUILD_TYPE="${CMAKE_BUILD_TYPE}") + +# Diagnostic child for the M0-TEST-01 Windows CI investigation: reports +# the std handles the kernel actually assigned to a spawned child, so +# laige-detcheck can compare the STARTUPINFO request against the +# delivery. Windows-only; the POSIX path never spawns it. +if(WIN32) + add_executable(laige-detcheck-probe probe-helper.cpp) + laige_apply_engine_policy(laige-detcheck-probe) +endif() diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index f3bbad4..ed456d3 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -121,10 +121,12 @@ #include #include +#include #include #include #include #include +#include #include #include #include @@ -146,6 +148,65 @@ namespace { +// --- Diagnostic sink (M0-TEST-01 Windows CI investigation) --------------- +// Every diagnostic line goes to stderr AND is accumulated in g_diag. The +// test harness sets LAIGE_DETCHECK_DIAG_FILE; main() flushes g_diag there +// on every exit path (RAII). ctest hides the output of passing tests, so +// this file is how a passing instance's diagnostics reach the CI log (the +// Windows job cats the files after ctest). +std::string g_diag; + +void diagf(const char* fmt, ...) { + char buf[4096]; + va_list ap; + va_start(ap, fmt); + const int n = std::vsnprintf(buf, sizeof buf, fmt, ap); + va_end(ap); + if (n > 0) { + const std::size_t len = static_cast( + n < static_cast(sizeof buf) ? n : static_cast(sizeof buf) - 1); + std::fprintf(stderr, "%s", buf); + g_diag.append(buf, len); + } +} + +// Read an environment variable into a fixed buffer (portable: MSVC +// degrades getenv to C4996, so use getenv_s there). +bool readEnvVar(const char* name, char* buf, std::size_t size) { +#ifdef _WIN32 + std::size_t len = 0; + return getenv_s(&len, buf, static_cast(size), name) == 0 && + len > 0; +#else + const char* v = std::getenv(name); + if (v == nullptr) return false; + const std::size_t n = std::strlen(v); + if (n >= size) return false; + std::memcpy(buf, v, n + 1); + return true; +#endif +} + +// Flush g_diag to $LAIGE_DETCHECK_DIAG_FILE (truncating stale content) on +// every exit path from main(). +struct DiagFlush { + ~DiagFlush() { + char path[1024] = {}; + if (readEnvVar("LAIGE_DETCHECK_DIAG_FILE", path, sizeof path)) { + std::FILE* f = nullptr; +#ifdef _WIN32 + if (fopen_s(&f, path, "w") != 0) f = nullptr; +#else + f = std::fopen(path, "w"); +#endif + if (f != nullptr) { + std::fwrite(g_diag.data(), 1, g_diag.size(), f); + std::fclose(f); + } + } + } +}; + // --- Named constants (CORE-005) ------------------------------------------ constexpr int kDefaultTicks = 256; // built-in scenario default run length @@ -328,12 +389,15 @@ void dumpEnvironmentDiagnostics() { nullptr); } } - std::fprintf(stderr, "laige-detcheck: env cwd=%s\n", cwdUtf8.c_str()); + diagf("laige-detcheck: env cwd=%s\n", cwdUtf8.c_str()); LPWCH env = GetEnvironmentStringsW(); if (env == nullptr) { - std::fprintf(stderr, "laige-detcheck: env: unavailable\n"); + diagf("laige-detcheck: env: unavailable\n"); return; } + // Only the test-wiring variables (compact; the full name dump was log + // noise and has served its purpose — the wiring is intact). + int testVars = 0; for (const wchar_t* block = env; *block != L'\0';) { const size_t len = wcslen(block); const wchar_t* eq = wcschr(block, L'='); @@ -343,93 +407,118 @@ void dumpEnvironmentDiagnostics() { const bool isTestVar = name.rfind(L"LAIGE_", 0) == 0 || name.rfind(L"CTEST_", 0) == 0 || name == L"PATH"; - std::string nameUtf8; - { - const int n = WideCharToMultiByte(CP_UTF8, 0, name.data(), - static_cast(name.size()), - nullptr, 0, nullptr, nullptr); - nameUtf8.resize(static_cast(n)); - WideCharToMultiByte(CP_UTF8, 0, name.data(), - static_cast(name.size()), nameUtf8.data(), n, - nullptr, nullptr); - } if (isTestVar) { - std::string valueUtf8; - const int n = WideCharToMultiByte(CP_UTF8, 0, value.data(), - static_cast(value.size()), - nullptr, 0, nullptr, nullptr); - valueUtf8.resize(static_cast(n)); - WideCharToMultiByte(CP_UTF8, 0, value.data(), - static_cast(value.size()), valueUtf8.data(), n, - nullptr, nullptr); - std::fprintf(stderr, "laige-detcheck: env %s=%s\n", nameUtf8.c_str(), - valueUtf8.c_str()); - } else { - std::fprintf(stderr, "laige-detcheck: env %s\n", nameUtf8.c_str()); + std::string nameUtf8, valueUtf8; + { + const int n = WideCharToMultiByte(CP_UTF8, 0, name.data(), + static_cast(name.size()), + nullptr, 0, nullptr, nullptr); + nameUtf8.resize(static_cast(n)); + WideCharToMultiByte(CP_UTF8, 0, name.data(), + static_cast(name.size()), nameUtf8.data(), n, + nullptr, nullptr); + const int m = WideCharToMultiByte(CP_UTF8, 0, value.data(), + static_cast(value.size()), + nullptr, 0, nullptr, nullptr); + valueUtf8.resize(static_cast(m)); + WideCharToMultiByte(CP_UTF8, 0, value.data(), + static_cast(value.size()), valueUtf8.data(), + m, nullptr, nullptr); + } + diagf("laige-detcheck: env %s=%s\n", nameUtf8.c_str(), + valueUtf8.c_str()); + ++testVars; } block += len + 1; } FreeEnvironmentStringsW(env); + diagf("laige-detcheck: env: %d test-wiring variables present\n", testVars); } -// One probe launch: create pipe, spawn, capture, close. Logs the pipe -// handle values and their types (a handle whose type is not PIPE, or an -// invalid value, identifies the mechanism). Returns false only when the -// spawn itself failed (details in errOut). +// One probe launch. With redirectStdio the child's stdout and stderr each +// go to a capture pipe (both drained); without it the child simply +// inherits detcheck's own standard handles (no STARTUPINFO redirection — +// the plain-inheritance control). Logs the pipe handle values, types, +// and flags. Returns false only when the spawn itself failed (errOut). bool probeAttempt(const wchar_t* appname, std::wstring cmd, - DWORD& exitCode, std::string& captured, std::string& errOut) { + bool redirectStdio, DWORD& exitCode, + std::string& capturedOut, std::string& capturedErr, + std::string& errOut) { SECURITY_ATTRIBUTES sa{}; sa.nLength = sizeof sa; - // Inheritable from creation: on the CI Windows runner, a CreatePipe - // handle created with bInheritHandle=FALSE did not gain the INHERIT - // flag from a later SetHandleInformation (observed writeFlags=0x1, - // no 0x80), so the child never received the pipe's write end. + // Inheritable from creation (see the scenario launch for the rationale). sa.bInheritHandle = TRUE; - HANDLE readH = INVALID_HANDLE_VALUE; - HANDLE writeH = INVALID_HANDLE_VALUE; - if (!CreatePipe(&readH, &writeH, &sa, 0)) { - errOut = "CreatePipe failed (lastError=" + std::to_string(GetLastError()) + - ")"; + HANDLE outR = INVALID_HANDLE_VALUE, outW = INVALID_HANDLE_VALUE; + HANDLE errR = INVALID_HANDLE_VALUE, errW = INVALID_HANDLE_VALUE; + if (redirectStdio && + (!CreatePipe(&outR, &outW, &sa, 0) || + !CreatePipe(&errR, &errW, &sa, 0))) { + errOut = "CreatePipe failed (lastError=" + + std::to_string(GetLastError()) + ")"; + if (outR != INVALID_HANDLE_VALUE) { + CloseHandle(outR); + CloseHandle(outW); + } + if (errR != INVALID_HANDLE_VALUE) { + CloseHandle(errR); + CloseHandle(errW); + } return false; } - const BOOL setInherit = - SetHandleInformation(writeH, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); - const DWORD setErr = setInherit ? 0 : GetLastError(); - DWORD writeInfo = 0; - GetHandleInformation(writeH, &writeInfo); - std::fprintf(stderr, - "laige-detcheck: probe handles read=0x%p write=0x%p " - "types=%lu/%lu writeFlags=0x%lx setInherit=%d setErr=%lu\n", - static_cast(readH), static_cast(writeH), - GetFileType(readH), GetFileType(writeH), writeInfo, - setInherit ? 1 : 0, static_cast(setErr)); + if (redirectStdio) { + SetHandleInformation(outW, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); + SetHandleInformation(errW, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); + DWORD outInfo = 0, errInfo = 0; + GetHandleInformation(outW, &outInfo); + GetHandleInformation(errW, &errInfo); + diagf("laige-detcheck: probe handles out r=0x%p w=0x%p type=%lu " + "flags=0x%lx | err r=0x%p w=0x%p type=%lu flags=0x%lx\n", + static_cast(outR), static_cast(outW), + GetFileType(outW), outInfo, static_cast(errR), + static_cast(errW), GetFileType(errW), errInfo); + } STARTUPINFOW si{}; si.cb = sizeof si; - si.dwFlags = STARTF_USESTDHANDLES; - si.hStdOutput = writeH; - si.hStdError = GetStdHandle(STD_ERROR_HANDLE); + si.dwFlags = redirectStdio ? STARTF_USESTDHANDLES : 0; + if (redirectStdio) { + si.hStdOutput = outW; + si.hStdError = errW; + } si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); PROCESS_INFORMATION pi{}; if (!CreateProcessW(appname, cmd.data(), nullptr, nullptr, TRUE, 0, nullptr, nullptr, &si, &pi)) { - errOut = "CreateProcessW failed (lastError=" + std::to_string(GetLastError()) + - ")"; - CloseHandle(readH); - CloseHandle(writeH); + errOut = "CreateProcessW failed (lastError=" + + std::to_string(GetLastError()) + ")"; + if (outR != INVALID_HANDLE_VALUE) { + CloseHandle(outR); + CloseHandle(outW); + } + if (errR != INVALID_HANDLE_VALUE) { + CloseHandle(errR); + CloseHandle(errW); + } return false; } - CloseHandle(writeH); - char buf[256]; - for (;;) { - if (WaitForSingleObject(readH, INFINITE) != WAIT_OBJECT_0) break; - DWORD n = 0; - if (!PeekNamedPipe(readH, buf, sizeof buf, &n, nullptr, nullptr)) break; - if (n == 0) break; - DWORD got = 0; - if (!ReadFile(readH, buf, n, &got, nullptr)) break; - captured.append(buf, got); + if (outW != INVALID_HANDLE_VALUE) CloseHandle(outW); + if (errW != INVALID_HANDLE_VALUE) CloseHandle(errW); + if (redirectStdio) { + auto drain = [](HANDLE r, std::string& out) { + char buf[256]; + for (;;) { + if (WaitForSingleObject(r, INFINITE) != WAIT_OBJECT_0) break; + DWORD n = 0; + if (!PeekNamedPipe(r, buf, sizeof buf, &n, nullptr, nullptr)) break; + if (n == 0) break; + DWORD got = 0; + if (!ReadFile(r, buf, n, &got, nullptr)) break; + out.append(buf, got); + } + CloseHandle(r); + }; + drain(outR, capturedOut); + drain(errR, capturedErr); } - CloseHandle(readH); WaitForSingleObject(pi.hProcess, INFINITE); exitCode = 0; GetExitCodeProcess(pi.hProcess, &exitCode); @@ -438,46 +527,50 @@ bool probeAttempt(const wchar_t* appname, std::wstring cmd, return true; } -// Control probe (diagnostic): spawn a known-good command (cmd /c echo) -// through the exact same pipe machinery used for scenario runs, in this -// process instance. Attempt 1 resolves cmd.exe from the command line's -// first token (like the scenario launch); attempt 2, only when attempt 1 -// captures nothing, passes the explicit System32 cmd.exe path as -// lpApplicationName. If attempt 2 succeeds in the process instances where -// attempt 1 fails, first-token command-line resolution is the broken side. +// Control probe (diagnostic): in this process instance, launch known-good +// children through the exact machinery used for scenario runs and record +// what each one received: +// +// attempt 1: laige-detcheck-probe (the diagnostic helper from our own +// module directory) — its stderr self-reports the std +// handles the kernel assigned to it (value/type/flags), +// captured in capturedErr; its stdout marker is captured in +// capturedOut. +// attempt 2: same helper, explicit lpApplicationName. +// attempt 3: cmd.exe /c echo marker — the classic control. +// attempt 4: same helper, NO STARTUPINFO redirection — plain handle +// inheritance only (stdout follows detcheck's own fd1). void probeControlSpawn() { - // Non-const so .data() yields wchar_t* for CreateProcessW (C++20). - std::wstring controlCmd = L"cmd.exe /c echo LAIGE_DETCHECK_CONTROL_OK"; - const std::string marker = "LAIGE_DETCHECK_CONTROL_OK"; - { - DWORD code = 0; - std::string captured, err; - const bool ok = probeAttempt(nullptr, controlCmd, code, captured, err); - if (ok && captured.find(marker) != std::string::npos) { - std::fprintf(stderr, - "laige-detcheck: control probe OK: exit=%lu " - "captured='%s'\n", - static_cast(code), captured.c_str()); - return; + // The helper lives next to us in the build bin directory. + wchar_t mod[1024] = {}; + const DWORD modLen = GetModuleFileNameW(nullptr, mod, 1024); + std::wstring helper; + if (modLen > 0 && modLen < 1024) { + const std::wstring m(mod, modLen); + const std::size_t slash = m.find_last_of(L'\\'); + if (slash != std::wstring::npos) { + helper = m.substr(0, slash + 1) + L"laige-detcheck-probe.exe"; } - std::fprintf(stderr, "laige-detcheck: control probe attempt 1 failed " - "(spawned=%d exit=%lu captured='%s' err=%s)\n", - ok ? 1 : 0, static_cast(code), - captured.c_str(), err.c_str()); } - wchar_t sysDir[MAX_PATH] = {}; - const DWORD sysLen = GetSystemDirectoryW(sysDir, MAX_PATH); - if (sysLen > 0 && sysLen < MAX_PATH) { - const std::wstring app = std::wstring(sysDir, sysLen) + L"\\cmd.exe"; + // Non-const so .data() yields wchar_t* for CreateProcessW (C++20). + const std::wstring helperCmd = L"\"" + helper + L"\""; + const std::wstring controlCmd = L"cmd.exe /c echo LAIGE_DETCHECK_CONTROL_OK"; + auto runAttempt = [&](int n, const wchar_t* app, const std::wstring& cmd, + bool redirect, const char* desc) { DWORD code = 0; - std::string captured, err; - const bool ok = probeAttempt(app.c_str(), controlCmd, code, captured, err); - std::fprintf(stderr, - "laige-detcheck: control probe attempt 2 (explicit app " - "path %ls): spawned=%d exit=%lu captured='%s' err=%s\n", - app.c_str(), ok ? 1 : 0, static_cast(code), - captured.c_str(), err.c_str()); + std::string out, err, why; + const bool ok = probeAttempt(app, cmd, redirect, code, out, err, why); + diagf("laige-detcheck: control probe attempt %d (%s): spawned=%d " + "exit=%lu out='%s' err='%s' why=%s\n", + n, desc, ok ? 1 : 0, static_cast(code), out.c_str(), + err.c_str(), why.c_str()); + }; + if (!helper.empty()) { + runAttempt(1, nullptr, helperCmd, true, "helper, cmdline path"); + runAttempt(2, helper.c_str(), helperCmd, true, "helper, explicit app"); } + runAttempt(3, nullptr, controlCmd, true, "cmd marker"); + runAttempt(4, nullptr, helperCmd, false, "helper, no redirect (control)"); } RunResult runScenario(const std::string& exe, @@ -492,8 +585,15 @@ RunResult runScenario(const std::string& exe, std::to_string(GetLastError()) + "): " + exe; return r; } - dumpEnvironmentDiagnostics(); - probeControlSpawn(); + // The env dump and control probes describe THIS process instance, so + // run them once per process, not once per scenario run (a mode-2 + // invocation runs runScenario twice). + static bool probed = false; + if (!probed) { + dumpEnvironmentDiagnostics(); + probeControlSpawn(); + probed = true; + } std::wstring cmd = exeW; for (const std::string& a : args) cmd += L" " + quoteArg(a); @@ -526,12 +626,11 @@ RunResult runScenario(const std::string& exe, // handle from a bad inheritance in the no-output failure cases. DWORD writeInfo = 0; GetHandleInformation(writeH, &writeInfo); - std::fprintf(stderr, - "laige-detcheck: scenario handles read=0x%p write=0x%p " - "types=%lu/%lu writeFlags=0x%lx setInherit=%d\n", - static_cast(readH), static_cast(writeH), - GetFileType(readH), GetFileType(writeH), writeInfo, - setInherit ? 1 : 0); + diagf("laige-detcheck: scenario handles read=0x%p write=0x%p " + "types=%lu/%lu writeFlags=0x%lx setInherit=%d\n", + static_cast(readH), static_cast(writeH), + GetFileType(readH), GetFileType(writeH), writeInfo, + setInherit ? 1 : 0); STARTUPINFOW si{}; si.cb = sizeof si; si.dwFlags = STARTF_USESTDHANDLES; @@ -564,9 +663,9 @@ RunResult runScenario(const std::string& exe, nullptr, nullptr); } } - std::fprintf(stderr, "laige-detcheck: spawned child pid=%lu image=%s\n", - static_cast(pi.dwProcessId), - imageUtf8.empty() ? "(unavailable)" : imageUtf8.c_str()); + diagf("laige-detcheck: spawned child pid=%lu image=%s\n", + static_cast(pi.dwProcessId), + imageUtf8.empty() ? "(unavailable)" : imageUtf8.c_str()); } CloseHandle(writeH); @@ -846,18 +945,36 @@ std::string basenameOf(std::string_view path) { void report(std::string_view scenarioName, const CompareResult& c, std::size_t ticks, std::string_view labelA, std::string_view labelB) { + auto emit = [](const char* line) { + std::printf("%s\n", line); + g_diag.append(line); + g_diag += "\n"; + }; if (c.ok) { - std::printf("detcheck scenario=%s result=OK ticks=%llu\n", - std::string(scenarioName).c_str(), - static_cast(ticks)); - std::printf(" run-a: %s\n", std::string(labelA).c_str()); - std::printf(" run-b: %s\n", std::string(labelB).c_str()); + char buf[256]; + std::snprintf(buf, sizeof buf, + "detcheck scenario=%s result=OK ticks=%llu", + std::string(scenarioName).c_str(), + static_cast(ticks)); + emit(buf); + std::snprintf(buf, sizeof buf, " run-a: %s", + std::string(labelA).c_str()); + emit(buf); + std::snprintf(buf, sizeof buf, " run-b: %s", + std::string(labelB).c_str()); + emit(buf); } else { - std::printf("detcheck scenario=%s result=DIVERGED first_diff_tick=%llu\n", - std::string(scenarioName).c_str(), - static_cast(c.firstDiffTick)); - std::printf(" run-a: %s\n", c.lineA.c_str()); - std::printf(" run-b: %s\n", c.lineB.c_str()); + char buf[256]; + std::snprintf(buf, sizeof buf, + "detcheck scenario=%s result=DIVERGED " + "first_diff_tick=%llu", + std::string(scenarioName).c_str(), + static_cast(c.firstDiffTick)); + emit(buf); + std::snprintf(buf, sizeof buf, " run-a: %s", c.lineA.c_str()); + emit(buf); + std::snprintf(buf, sizeof buf, " run-b: %s", c.lineB.c_str()); + emit(buf); } } @@ -958,6 +1075,19 @@ bool parseArgs(int argc, char** argv, Args& a) { } // namespace int main(int argc, char** argv) { + // Flush the accumulated diagnostics to $LAIGE_DETCHECK_DIAG_FILE on + // every exit path (ctest hides passing-test output; the file is how + // those diagnostics reach the CI log). + DiagFlush diagFlush; + // Diagnostic begin marker (identifies the run in the flushed file). + { + std::string argv; + for (int i = 0; i < argc; ++i) { + if (i > 0) argv += " "; + argv += argv[i]; + } + diagf("laige-detcheck: diag begin argv=%s\n", argv.c_str()); + } Args a; if (!parseArgs(argc, argv, a)) { printUsage(stderr); @@ -1008,26 +1138,22 @@ int main(int argc, char** argv) { if (mode2) { const RunResult resA = runScenario(a.runA, a.positionals); if (!resA.ok) { - std::fprintf(stderr, "laige-detcheck: scenario run-a: %s\n", - resA.error.c_str()); + diagf("laige-detcheck: scenario run-a: %s\n", resA.error.c_str()); return 2; } const std::string errA = validateStream(resA.lines, kMaxTicks); if (!errA.empty()) { - std::fprintf(stderr, "laige-detcheck: scenario run-a: %s\n", - errA.c_str()); + diagf("laige-detcheck: scenario run-a: %s\n", errA.c_str()); return 2; } const RunResult resB = runScenario(a.runB, a.positionals); if (!resB.ok) { - std::fprintf(stderr, "laige-detcheck: scenario run-b: %s\n", - resB.error.c_str()); + diagf("laige-detcheck: scenario run-b: %s\n", resB.error.c_str()); return 2; } const std::string errB = validateStream(resB.lines, kMaxTicks); if (!errB.empty()) { - std::fprintf(stderr, "laige-detcheck: scenario run-b: %s\n", - errB.c_str()); + diagf("laige-detcheck: scenario run-b: %s\n", errB.c_str()); return 2; } const CompareResult c = compareStreams(resA.lines, resB.lines); diff --git a/tools/detcheck/probe-helper.cpp b/tools/detcheck/probe-helper.cpp new file mode 100644 index 0000000..03c8813 --- /dev/null +++ b/tools/detcheck/probe-helper.cpp @@ -0,0 +1,36 @@ +// laige-detcheck-probe (Windows only) — diagnostic child for +// laige-detcheck (M0-TEST-01 Windows CI investigation). +// +// laige-detcheck spawns this with its stdout and stderr each connected +// to a capture pipe (or with no redirection at all, as the control +// attempt). The helper reports the standard handles the kernel actually +// assigned to it — value, type, flags — and writes a marker line to +// stdout. The parent compares that report against the handles it asked +// for in its STARTUPINFO, which shows exactly what CreateProcessW +// delivered in the working versus the broken launch instances. +// +// fd1 types: 1 = character device (console/NUL), 2 = disk file, +// 3 = pipe, 0 = unknown (e.g. INVALID_HANDLE_VALUE). + +#include + +#include + +static void report(const char* name, HANDLE h) { + DWORD flags = 0; + GetHandleInformation(h, &flags); + std::fprintf(stderr, " %s=0x%lx type=%lu flags=0x%lx", + name, + static_cast(static_cast(h)), + GetFileType(h), flags); +} + +int main() { + std::fprintf(stderr, "probe-helper:"); + report("fd0", GetStdHandle(STD_INPUT_HANDLE)); + report("fd1", GetStdHandle(STD_OUTPUT_HANDLE)); + report("fd2", GetStdHandle(STD_ERROR_HANDLE)); + std::fprintf(stderr, "\n"); + std::printf("PROBE_HELPER_OK\n"); + return 0; +} From c8a47cd52eee239abdf1422af2a41b07515ad8a3 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 13 Sep 2026 00:52:14 +0200 Subject: [PATCH 19/27] [M0-TEST-01] Fix MSVC probe-helper cast and argv shadow warning --- tools/detcheck/laige-detcheck.cpp | 8 ++++---- tools/detcheck/probe-helper.cpp | 5 ++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index ed456d3..99d0220 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -1081,12 +1081,12 @@ int main(int argc, char** argv) { DiagFlush diagFlush; // Diagnostic begin marker (identifies the run in the flushed file). { - std::string argv; + std::string cmdLine; for (int i = 0; i < argc; ++i) { - if (i > 0) argv += " "; - argv += argv[i]; + if (i > 0) cmdLine += " "; + cmdLine += argv[i]; } - diagf("laige-detcheck: diag begin argv=%s\n", argv.c_str()); + diagf("laige-detcheck: diag begin argv=%s\n", cmdLine.c_str()); } Args a; if (!parseArgs(argc, argv, a)) { diff --git a/tools/detcheck/probe-helper.cpp b/tools/detcheck/probe-helper.cpp index 03c8813..1bb19af 100644 --- a/tools/detcheck/probe-helper.cpp +++ b/tools/detcheck/probe-helper.cpp @@ -19,9 +19,8 @@ static void report(const char* name, HANDLE h) { DWORD flags = 0; GetHandleInformation(h, &flags); - std::fprintf(stderr, " %s=0x%lx type=%lu flags=0x%lx", - name, - static_cast(static_cast(h)), + const unsigned long long hv = reinterpret_cast(h); + std::fprintf(stderr, " %s=0x%llx type=%lu flags=0x%lx", name, hv, GetFileType(h), flags); } From eda945439f3a1a36a3fa268d451c18b3789db203 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 13 Sep 2026 01:20:33 +0200 Subject: [PATCH 20/27] [M0-TEST-01] Writer identification: diag-begin pid/parent, caller tag, capture mirror Run 21's per-test diag files exposed a contradiction: test 27 (detcheck-bin-malformed) PASSED - which requires its check-script detcheck invocation to have captured both scenario runs, including run-b's malformed line - yet that test's diag file ends with 'scenario run-a: no ticks emitted', which only a capture-broken instance can produce. A diag file can be truncated and rewritten by a LATER detcheck invocation that shares the same LAIGE_DETCHECK_DIAG_FILE, so the writer must be identified. - diag begin now logs this process's pid, its parent's pid, and the parent's command line (Windows: NtQueryInformationProcess via GetProcAddress, best-effort; POSIX: getppid + /proc cmdline). - The check script tags its detcheck invocation with LAIGE_DETCHECK_CALLER=check-script (logged by the env dump) and mirrors its own capture to .txt.check, written by the cmake process, not detcheck - so both writers' output reach the job log even though ctest hides passing-test output. - The Windows CI test step now cats all per-test diag files (*.txt and *.check), not just *.txt. --- .github/workflows/ci-pull.yml | 2 +- .github/workflows/ci.yml | 11 ++- .../detcheck/expect-detcheck-result.cmake.in | 13 +++ tools/detcheck/laige-detcheck.cpp | 98 ++++++++++++++++++- 4 files changed, 117 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-pull.yml b/.github/workflows/ci-pull.yml index 5fe56ae..255298d 100644 --- a/.github/workflows/ci-pull.yml +++ b/.github/workflows/ci-pull.yml @@ -217,7 +217,7 @@ jobs: run: | ctest --test-dir build -C Debug --output-on-failure $ctestExit = $LASTEXITCODE - Get-ChildItem .\build\tests\detcheck\diag -Filter *.txt -ErrorAction SilentlyContinue | + Get-ChildItem .\build\tests\detcheck\diag -File -ErrorAction SilentlyContinue | Sort-Object Name | ForEach-Object { Write-Host "===== detcheck diag: $($_.Name) =====" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea21701..8d3818d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -238,13 +238,16 @@ jobs: & "$bin\laige-detcheck-probe.exe" - name: Test (unit) # ctest hides passing-test output, so the per-test detcheck - # diagnostic files (spawn handles, control probes — see - # tools/detcheck) are printed here, regardless of the ctest - # outcome; the step still exits with ctest's status. + # diagnostic files are printed here, regardless of the ctest + # outcome; the step still exits with ctest's status. Two files + # per test: .txt is detcheck's own spawn diagnostics + # (spawn handles, control probes — see tools/detcheck); + # .txt.check is the check script's captured output of the + # same invocation (written by the cmake process, not detcheck). run: | ctest --test-dir build -C Debug --output-on-failure $ctestExit = $LASTEXITCODE - Get-ChildItem .\build\tests\detcheck\diag -Filter *.txt -ErrorAction SilentlyContinue | + Get-ChildItem .\build\tests\detcheck\diag -File -ErrorAction SilentlyContinue | Sort-Object Name | ForEach-Object { Write-Host "===== detcheck diag: $($_.Name) =====" diff --git a/tests/detcheck/expect-detcheck-result.cmake.in b/tests/detcheck/expect-detcheck-result.cmake.in index 3d96603..84df97e 100644 --- a/tests/detcheck/expect-detcheck-result.cmake.in +++ b/tests/detcheck/expect-detcheck-result.cmake.in @@ -80,16 +80,29 @@ foreach(_tok IN LISTS _cmd) list(APPEND _args "${_tok}") endforeach() +# LAIGE_DETCHECK_CALLER tags THIS detcheck invocation (M0-TEST-01 Windows +# CI diagnosis): the tool's env dump logs the marker, so the per-test diag +# file reveals which process wrote it. execute_process( COMMAND "${_detcheck}" ${_args} RESULT_VARIABLE _rc OUTPUT_VARIABLE _out ERROR_VARIABLE _err + ENVIRONMENT_MODIFICATION "LAIGE_DETCHECK_CALLER=set:check-script" ) set(_text "${_out} ${_err}") +# TEMP (M0-TEST-01 Windows CI diagnosis): persist this check script's own +# capture (written by the cmake process, not by detcheck), so it can be +# compared against detcheck's own diag file, which a different detcheck +# invocation could have overwritten. +if(DEFINED ENV{LAIGE_DETCHECK_DIAG_FILE}) + file(WRITE "$ENV{LAIGE_DETCHECK_DIAG_FILE}.check" + "check-script capture rc=${_rc}\n${_out}\n--- err ---\n${_err}\n") +endif() + set(_problems "") if(NOT _rc EQUAL @EXPECT_EXIT@) set(_problems "exit code ${_rc} (expected @EXPECT_EXIT@)") diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index 99d0220..79eff48 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -573,6 +573,65 @@ void probeControlSpawn() { runAttempt(4, nullptr, helperCmd, false, "helper, no redirect (control)"); } +// Best-effort parent-process identification (M0-TEST-01 Windows CI +// diagnosis): a diag file can be written by a detcheck invocation OTHER +// than the check script's; the parent's pid and command line say who +// launched this process. NtQueryInformationProcess is resolved through +// GetProcAddress (no new import; both process-info classes are stable on +// x64). Every step is best-effort: any failure yields (unavailable). +std::string parentProcessInfo() { + struct Pbi { + long exitStatus; + void* peb; + unsigned long long affinity; + unsigned char priority; + unsigned long pid; + unsigned long long inheritedFrom; + } pbi = {}; + typedef long(* NtQIP_t)(HANDLE, int, void*, unsigned long, unsigned long*); + HMODULE ntdll = GetModuleHandleW(L"ntdll.dll"); + if (!ntdll) return "parent=(unavailable)"; + const NtQIP_t ntq = reinterpret_cast( + GetProcAddress(ntdll, "NtQueryInformationProcess")); + if (!ntq) return "parent=(unavailable)"; + if (ntq(GetCurrentProcess(), 0, &pbi, sizeof pbi, nullptr) != 0) { + return "parent=(unavailable)"; + } + std::string s = "parent=" + + std::to_string(static_cast(pbi.inheritedFrom)); + std::string cmd; + HANDLE ph = OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, + static_cast(pbi.inheritedFrom)); + if (ph) { + struct Us { + unsigned short length; + unsigned short maximumLength; + wchar_t* buffer; + } us = {}; + unsigned long need = 0; + if (ntq(ph, 60, &us, sizeof us, &need) != 0 && need > sizeof us) { + std::vector buf(need / 2 + 1, 0); + us.buffer = buf.data(); + us.maximumLength = static_cast(need); + if (ntq(ph, 60, &us, sizeof us, nullptr) == 0 && us.length > 0) { + const int n = WideCharToMultiByte(CP_UTF8, 0, buf.data(), + static_cast(us.length / 2), + nullptr, 0, nullptr, nullptr); + if (n > 0) { + cmd.resize(static_cast(n)); + WideCharToMultiByte(CP_UTF8, 0, buf.data(), + static_cast(us.length / 2), cmd.data(), n, + nullptr, nullptr); + } + } + } + CloseHandle(ph); + } + s += cmd.empty() ? " parent-cmd=(unavailable)" + : " parent-cmd=\"" + cmd + "\""; + return s; +} + RunResult runScenario(const std::string& exe, const std::vector& args) { RunResult r; @@ -723,6 +782,31 @@ RunResult runScenario(const std::string& exe, #else // POSIX (Linux, macOS) +// POSIX counterpart of the Windows parent-process identification: the +// parent pid plus its /proc command line (Linux; macOS has no /proc, so +// only the pid is reported there). +std::string parentProcessInfo() { + const int pp = static_cast(::getppid()); + std::string s = "parent=" + std::to_string(pp); + char path[128]; + std::snprintf(path, sizeof path, "/proc/%d/cmdline", pp); + std::FILE* f = std::fopen(path, "rb"); + if (!f) { + return s + " parent-cmd=(unavailable)"; + } + char buf[1024]; + const std::size_t n = std::fread(buf, 1, sizeof buf, f); + std::fclose(f); + std::string cmd(buf, n); + for (char& c : cmd) { + if (c == '\0') c = ' '; + } + while (!cmd.empty() && cmd.back() == ' ') cmd.pop_back(); + s += cmd.empty() ? " parent-cmd=(unavailable)" + : " parent-cmd=\"" + cmd + "\""; + return s; +} + RunResult runScenario(const std::string& exe, const std::vector& args) { RunResult r; @@ -1079,14 +1163,24 @@ int main(int argc, char** argv) { // every exit path (ctest hides passing-test output; the file is how // those diagnostics reach the CI log). DiagFlush diagFlush; - // Diagnostic begin marker (identifies the run in the flushed file). + // Diagnostic begin marker (identifies the run and its writer in the + // flushed file: a diag file can be overwritten by a later detcheck + // invocation with the same LAIGE_DETCHECK_DIAG_FILE). { std::string cmdLine; for (int i = 0; i < argc; ++i) { if (i > 0) cmdLine += " "; cmdLine += argv[i]; } - diagf("laige-detcheck: diag begin argv=%s\n", cmdLine.c_str()); +#ifdef _WIN32 + diagf("laige-detcheck: diag begin pid=%lu %s argv=%s\n", + static_cast(GetProcessId()), + parentProcessInfo().c_str(), cmdLine.c_str()); +#else + diagf("laige-detcheck: diag begin pid=%d %s argv=%s\n", + static_cast(::getpid()), parentProcessInfo().c_str(), + cmdLine.c_str()); +#endif } Args a; if (!parseArgs(argc, argv, a)) { From 707e62de959bf4cf698ff62d20755eac72f4e67c Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 13 Sep 2026 01:28:42 +0200 Subject: [PATCH 21/27] [M0-TEST-01] Port the caller tag to old cmake; use GetCurrentProcessId - The check script's detcheck caller tag now rides in the test's ENVIRONMENT property (inherited by execute_process) instead of the execute_process ENVIRONMENT option: that option does not exist on the cmake versions some CI runners provide (the ubuntu-24.04 lanes reject it as an unknown argument; CTest's ENVIRONMENT_MODIFICATION test property is a different, newer API). - diag begin uses GetCurrentProcessId() directly: the GetProcessId macro form fails on the windows-2022 SDK (C2660, the macro does not expand there as a zero-argument function). --- tests/detcheck/CMakeLists.txt | 8 ++++++++ tests/detcheck/expect-detcheck-result.cmake.in | 10 ++++++---- tools/detcheck/laige-detcheck.cpp | 2 +- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/detcheck/CMakeLists.txt b/tests/detcheck/CMakeLists.txt index 7225408..89700ce 100644 --- a/tests/detcheck/CMakeLists.txt +++ b/tests/detcheck/CMakeLists.txt @@ -68,6 +68,14 @@ string(APPEND LAIGE_DETCHECK_TEST_ENV "LAIGE_DETCHECK_FIX_FAIL=$;") string(APPEND LAIGE_DETCHECK_TEST_ENV "LAIGE_DETCHECK_FIX_SHORT=$") +# M0-TEST-01 Windows CI diagnosis: tag every check-script detcheck +# invocation. The tool's env dump logs it, so a per-test diag file reveals +# whether its writer was the check script's own invocation. (The cmake +# versions some CI runners provide give execute_process no environment +# options, so the tag rides in the test's ENVIRONMENT property and is +# inherited by the spawned tool.) +string(APPEND LAIGE_DETCHECK_TEST_ENV + ";LAIGE_DETCHECK_CALLER=check-script") # --- The tests --------------------------------------------------------------- # Same shape as the tests/api helper: a generated cmake -P check script diff --git a/tests/detcheck/expect-detcheck-result.cmake.in b/tests/detcheck/expect-detcheck-result.cmake.in index 84df97e..42cc022 100644 --- a/tests/detcheck/expect-detcheck-result.cmake.in +++ b/tests/detcheck/expect-detcheck-result.cmake.in @@ -80,15 +80,17 @@ foreach(_tok IN LISTS _cmd) list(APPEND _args "${_tok}") endforeach() -# LAIGE_DETCHECK_CALLER tags THIS detcheck invocation (M0-TEST-01 Windows -# CI diagnosis): the tool's env dump logs the marker, so the per-test diag -# file reveals which process wrote it. +# LAIGE_DETCHECK_CALLER tags THIS detcheck invocation (M0-TEST-01 +# Windows CI diagnosis): it arrives in the test's ENVIRONMENT property +# (tests/detcheck/CMakeLists.txt) and is inherited by execute_process, +# which has no environment options on the cmake versions some CI +# runners provide. The tool's env dump logs the marker, so the per-test +# diag file reveals which process wrote it. execute_process( COMMAND "${_detcheck}" ${_args} RESULT_VARIABLE _rc OUTPUT_VARIABLE _out ERROR_VARIABLE _err - ENVIRONMENT_MODIFICATION "LAIGE_DETCHECK_CALLER=set:check-script" ) set(_text "${_out} diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index 79eff48..15f0ee2 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -1174,7 +1174,7 @@ int main(int argc, char** argv) { } #ifdef _WIN32 diagf("laige-detcheck: diag begin pid=%lu %s argv=%s\n", - static_cast(GetProcessId()), + static_cast(GetCurrentProcessId()), parentProcessInfo().c_str(), cmdLine.c_str()); #else diagf("laige-detcheck: diag begin pid=%d %s argv=%s\n", From 792837953cec94f02ef535fdf0f24f2f2f4e045b Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 13 Sep 2026 02:44:46 +0200 Subject: [PATCH 22/27] [M0-TEST-01] Fix dead fragment checks: CHECKS variable name + CMake double-escape The generated check scripts (tests/api, tests/detcheck) asserted ONLY the exit code: their output-fragment checks were silently absent in every generated script, on every platform, since M0-TOOL-02/M0-TOOL-01. Two independent defects in the generators: 1. Variable name mismatch: the functions built the checks into a local called _checks, but the templates substitute @CHECKS@. configure_file therefore expanded the placeholder from an unset variable and emitted nothing. Rename the local to CHECKS (the exact placeholder name). 2. Backslash under-escaping: CMake string literals strip one level of backslashes on parse (\\ -> \, \X -> X, verified on CMake 4.4.3). The generated script is itself a CMake file, so each regex escape must reach it DOUBLED: \\X in the file becomes \X for the regex engine. A single \X was stripped and the bare metacharacter reached the regex (a group where a literal was meant, an unclosed class, or a dropped character for [). The [ replacement was additionally losing the bracket itself; it is now built from a _bs2 variable so the literal survives intact. Effect: the suite now actually asserts the fragments it claims to. Verified locally: all generated scripts contain real MATCHES checks; all five P0 trees (build, build-shared, build-asan, build-tsan, build-clang) pass 32/32. Note this also means test 27 (detcheck-bin-malformed) will now fail on the broken-capture Windows instances, where exit code 2 coincidentally matched the expectation: the fragment checks will expose the capture failure that previously went unverified. --- tests/api/CMakeLists.txt | 21 +++++++++++++----- tests/detcheck/CMakeLists.txt | 42 ++++++++++++++++++++++------------- 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/tests/api/CMakeLists.txt b/tests/api/CMakeLists.txt index 2e8b049..ea00d25 100644 --- a/tests/api/CMakeLists.txt +++ b/tests/api/CMakeLists.txt @@ -161,15 +161,24 @@ function(laige_add_api_test name expect_exit root mode file) # Space-separated quoted list (semicolon lists confuse the CMake # script lexer in generated files — verified on CMake 4.4.3). set(_cmd "\"--root\" \"${root}\" \"--${mode}\" \"${file}\"") - set(_checks "") + # The template's @CHECKS@ placeholder is substituted from this variable, + # so it must be named exactly CHECKS (a misspelled local like _checks + # silently expands to nothing and the fragment checks never exist). + set(CHECKS "") foreach(_needle IN LISTS ARGN) + # The generated script is itself a CMake file, and its string literals + # strip one level of backslashes on parse (\\ -> \, \X -> X), so each + # regex escape must reach the file DOUBLED: \\( in the file becomes \( + # for the regex engine (a single \X would be stripped and the bare + # metacharacter would reach the regex). + set(_bs2 "\\\\") string(REPLACE "\\" "\\\\" _esc "${_needle}") string(REPLACE "\"" "\\\"" _esc "${_esc}") - string(REPLACE "(" "\\(" _esc "${_esc}") - string(REPLACE ")" "\\)" _esc "${_esc}") - string(REPLACE "+" "\\+" _esc "${_esc}") - string(REPLACE "." "\\." _esc "${_esc}") - string(APPEND _checks + string(REPLACE "(" "${_bs2}(" _esc "${_esc}") + string(REPLACE ")" "${_bs2})" _esc "${_esc}") + string(REPLACE "+" "${_bs2}+" _esc "${_esc}") + string(REPLACE "." "${_bs2}." _esc "${_esc}") + string(APPEND CHECKS "if(NOT _text MATCHES \"${_esc}\")\n" " string(APPEND _problems \"output missing '<${_esc}'>; \")\n" "endif()\n") diff --git a/tests/detcheck/CMakeLists.txt b/tests/detcheck/CMakeLists.txt index 89700ce..26dffeb 100644 --- a/tests/detcheck/CMakeLists.txt +++ b/tests/detcheck/CMakeLists.txt @@ -81,25 +81,37 @@ string(APPEND LAIGE_DETCHECK_TEST_ENV # Same shape as the tests/api helper: a generated cmake -P check script # asserts the exit code and the required output fragments. function(laige_add_detcheck_test name expect_exit cmd) - set(_checks "") + # The template's @CHECKS@ placeholder is substituted from this variable, + # so it must be named exactly CHECKS (a misspelled local like _checks + # silently expands to nothing and the fragment checks never exist). + set(CHECKS "") foreach(_needle IN LISTS ARGN) # Escape ECMAScript metacharacters for the generated MATCHES check. + # The generated script is itself a CMake file, and its string literals + # strip one level of backslashes on parse (\\ -> \, \X -> X), so each + # regex escape must reach the file DOUBLED: \\( in the file becomes \( + # for the regex engine (a single \X would be stripped and the bare + # metacharacter would reach the regex). string(REPLACE "\\" "\\\\" _esc "${_needle}") string(REPLACE "\"" "\\\"" _esc "${_esc}") - string(REPLACE "(" "\\(" _esc "${_esc}") - string(REPLACE ")" "\\)" _esc "${_esc}") - string(REPLACE "+" "\\+" _esc "${_esc}") - string(REPLACE "." "\\." _esc "${_esc}") - string(REPLACE "*" "\\*" _esc "${_esc}") - string(REPLACE "?" "\\?" _esc "${_esc}") - string(REPLACE "|" "\\|" _esc "${_esc}") - string(REPLACE "^" "\\^" _esc "${_esc}") - string(REPLACE "$" "\\$" _esc "${_esc}") - string(REPLACE "[" "\\[" _esc "${_esc}") - string(REPLACE "]" "\\]" _esc "${_esc}") - string(REPLACE "{" "\\{" _esc "${_esc}") - string(REPLACE "}" "\\}" _esc "${_esc}") - string(APPEND _checks + string(REPLACE "(" "\\\\(" _esc "${_esc}") + string(REPLACE ")" "\\\\)" _esc "${_esc}") + string(REPLACE "+" "\\\\+" _esc "${_esc}") + string(REPLACE "." "\\\\." _esc "${_esc}") + string(REPLACE "*" "\\\\*" _esc "${_esc}") + string(REPLACE "?" "\\\\?" _esc "${_esc}") + string(REPLACE "|" "\\\\|" _esc "${_esc}") + string(REPLACE "^" "\\\\^" _esc "${_esc}") + string(REPLACE "$" "\\\\$" _esc "${_esc}") + # (The [ literal is built from _bs2: its replacement must be two + # backslashes plus a literal [, and the bracket has to survive the + # edit tooling intact.) + set(_bs2 "\\\\") + string(REPLACE "[" "${_bs2}[" _esc "${_esc}") + string(REPLACE "]" "\\\\]" _esc "${_esc}") + string(REPLACE "{" "\\\\{" _esc "${_esc}") + string(REPLACE "}" "\\\\}" _esc "${_esc}") + string(APPEND CHECKS "if(NOT _text MATCHES \"${_esc}\")\n" " string(APPEND _problems \"output missing '<${_esc}'>; \")\n" "endif()\n") From e2abce5a010e3ec75ab17c8d2fa4117d1b610fa9 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 13 Sep 2026 02:44:57 +0200 Subject: [PATCH 23/27] [M0-TEST-01] Add STARTUPINFO handle-kind probes for the Windows CI diagnosis New evidence from run 23 changed the diagnostic picture: the handle flags logged as 0x1 were all along HANDLE_FLAG_INHERIT (the GetHandleInformation/HANDLE_FLAG_* constants are 0x1 and 0x2, NOT 0x80/0x100), so the pipe write ends WERE inheritable in the failing instances. The 'missing INHERIT bit' theory is dead; the open question is why CreateProcessW still does not deliver the STARTUPINFO pipes while plain handle inheritance (the child inheriting detcheck's own fd0/fd1/fd2) works in the same process instances. To discriminate, probeControlSpawn gains two attempts: - attempt 5: STARTUPINFO with detcheck's OWN kernel-assigned fd1/fd2. If this delivers while the fresh-pipe attempts do not, the failure is specific to handles this process created itself. Success shows up as an extra PROBE_HELPER_OK line in detcheck's own stdout and an extra self-report line in its stderr (capturedOut/capturedErr stay empty by design for this attempt). - attempt 6: STARTUPINFO with a fresh pipe whose write end is first re-created via DuplicateHandle(dwInheritable=TRUE). If this delivers where the original pipe handle does not, the DuplicateHandle path is the fix for scenario capture. Also: the diagnostics now log the handle bits as inherit=.. protect=.. (handleBits helper) instead of raw flag values, and the scenario comment no longer asserts the disproven INHERIT-bit theory. The probe helper is unchanged (it reports raw values; the parent interprets them). Windows-only branch; verified in CI (no local MSVC/mingw available). --- tools/detcheck/laige-detcheck.cpp | 197 +++++++++++++++++++++--------- 1 file changed, 139 insertions(+), 58 deletions(-) diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index 15f0ee2..6bb7473 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -435,54 +435,121 @@ void dumpEnvironmentDiagnostics() { diagf("laige-detcheck: env: %d test-wiring variables present\n", testVars); } -// One probe launch. With redirectStdio the child's stdout and stderr each -// go to a capture pipe (both drained); without it the child simply -// inherits detcheck's own standard handles (no STARTUPINFO redirection — -// the plain-inheritance control). Logs the pipe handle values, types, -// and flags. Returns false only when the spawn itself failed (errOut). +// Handle flag bits for the diagnostics. GetHandleInformation uses +// HANDLE_FLAG_INHERIT = 0x1 and HANDLE_FLAG_PROTECT_FROM_CLOSE = 0x2 +// (NOT 0x80/0x100 - those values belong to no handle API). +std::string handleBits(HANDLE h) { + DWORD info = 0; + GetHandleInformation(h, &info); + char b[96]; + std::snprintf(b, sizeof b, "inherit=%d protect=%d (raw=0x%lx)", + (info & HANDLE_FLAG_INHERIT) ? 1 : 0, + (info & HANDLE_FLAG_PROTECT_FROM_CLOSE) ? 1 : 0, info); + return std::string(b); +} + +// One probe launch. With redirectStdio the child's stdout (and usually +// stderr) are set through STARTUPINFO; outMode selects the stdout source: +// 0 = a fresh CreatePipe write end we drain (baseline); +// 1 = detcheck's OWN stdout/stderr handles (kernel-assigned at process +// start) - the child's marker then lands in detcheck's own streams +// (harness-captured), so capturedOut/capturedErr stay empty; +// 2 = a fresh CreatePipe whose write end is first re-created through +// DuplicateHandle(dwInheritable=TRUE) before use - tests whether a +// freshly duplicated inheritable handle is delivered where the +// original pipe handle is not. +// Without redirectStdio the child inherits detcheck's own standard +// handles (the plain-inheritance control). Returns false only when the +// spawn itself failed (errOut). bool probeAttempt(const wchar_t* appname, std::wstring cmd, - bool redirectStdio, DWORD& exitCode, + bool redirectStdio, int outMode, DWORD& exitCode, std::string& capturedOut, std::string& capturedErr, std::string& errOut) { SECURITY_ATTRIBUTES sa{}; sa.nLength = sizeof sa; - // Inheritable from creation (see the scenario launch for the rationale). sa.bInheritHandle = TRUE; HANDLE outR = INVALID_HANDLE_VALUE, outW = INVALID_HANDLE_VALUE; HANDLE errR = INVALID_HANDLE_VALUE, errW = INVALID_HANDLE_VALUE; - if (redirectStdio && - (!CreatePipe(&outR, &outW, &sa, 0) || - !CreatePipe(&errR, &errW, &sa, 0))) { - errOut = "CreatePipe failed (lastError=" + - std::to_string(GetLastError()) + ")"; - if (outR != INVALID_HANDLE_VALUE) { - CloseHandle(outR); - CloseHandle(outW); + HANDLE dupOut = INVALID_HANDLE_VALUE; + HANDLE outTarget = INVALID_HANDLE_VALUE; + bool outDrained = false, errDrained = false; + auto cleanup = [&]() { + if (outR != INVALID_HANDLE_VALUE) CloseHandle(outR); + if (outW != INVALID_HANDLE_VALUE) CloseHandle(outW); + if (dupOut != INVALID_HANDLE_VALUE) CloseHandle(dupOut); + if (errR != INVALID_HANDLE_VALUE) CloseHandle(errR); + if (errW != INVALID_HANDLE_VALUE) CloseHandle(errW); + }; + if (redirectStdio) { + if (outMode == 1) { + // Kernel-assigned: detcheck's own stdout; the child's stderr goes + // to detcheck's own stderr (both harness-captured). + outTarget = GetStdHandle(STD_OUTPUT_HANDLE); + } else { + if (!CreatePipe(&outR, &outW, &sa, 0)) { + errOut = "CreatePipe failed (lastError=" + + std::to_string(GetLastError()) + ")"; + cleanup(); + return false; + } + outDrained = true; + if (outMode == 2) { + // Re-create the write end as a fresh inheritable duplicate. + if (!DuplicateHandle(GetCurrentProcess(), outW, GetCurrentProcess(), + &dupOut, 0, TRUE, DUPLICATE_SAME_ACCESS)) { + errOut = "DuplicateHandle failed (lastError=" + + std::to_string(GetLastError()) + ")"; + cleanup(); + return false; + } + outTarget = dupOut; + } else { + outTarget = outW; + } } - if (errR != INVALID_HANDLE_VALUE) { - CloseHandle(errR); - CloseHandle(errW); + if (outMode != 1 && !CreatePipe(&errR, &errW, &sa, 0)) { + errOut = "CreatePipe(err) failed (lastError=" + + std::to_string(GetLastError()) + ")"; + cleanup(); + return false; + } + errDrained = (outMode != 1); + if (outDrained) { + SetHandleInformation(outW, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); + } + if (errDrained) { + SetHandleInformation(errW, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); + } + if (outMode == 1) { + diagf("laige-detcheck: probe handles out=kernel-fd1 (0x%p %s) " + "err=kernel-fd2 (0x%p %s)\n", + static_cast(outTarget), handleBits(outTarget).c_str(), + static_cast(GetStdHandle(STD_ERROR_HANDLE)), + handleBits(GetStdHandle(STD_ERROR_HANDLE)).c_str()); + } else { + std::string dupNote; + if (outMode == 2) { + char nb[128]; + std::snprintf(nb, sizeof nb, " | wdup=0x%p %s", + static_cast(dupOut), + handleBits(dupOut).c_str()); + dupNote = nb; + } + diagf("laige-detcheck: probe handles out r=0x%p w=0x%p type=%lu %s%s" + "| err r=0x%p w=0x%p type=%lu %s\n", + static_cast(outR), static_cast(outW), + GetFileType(outW), handleBits(outTarget).c_str(), + dupNote.c_str(), static_cast(errR), + static_cast(errW), GetFileType(errW), + handleBits(errW).c_str()); } - return false; - } - if (redirectStdio) { - SetHandleInformation(outW, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); - SetHandleInformation(errW, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); - DWORD outInfo = 0, errInfo = 0; - GetHandleInformation(outW, &outInfo); - GetHandleInformation(errW, &errInfo); - diagf("laige-detcheck: probe handles out r=0x%p w=0x%p type=%lu " - "flags=0x%lx | err r=0x%p w=0x%p type=%lu flags=0x%lx\n", - static_cast(outR), static_cast(outW), - GetFileType(outW), outInfo, static_cast(errR), - static_cast(errW), GetFileType(errW), errInfo); } STARTUPINFOW si{}; si.cb = sizeof si; si.dwFlags = redirectStdio ? STARTF_USESTDHANDLES : 0; if (redirectStdio) { - si.hStdOutput = outW; - si.hStdError = errW; + si.hStdOutput = outTarget; + si.hStdError = outMode == 1 ? GetStdHandle(STD_ERROR_HANDLE) : errW; } si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); PROCESS_INFORMATION pi{}; @@ -490,19 +557,14 @@ bool probeAttempt(const wchar_t* appname, std::wstring cmd, nullptr, nullptr, &si, &pi)) { errOut = "CreateProcessW failed (lastError=" + std::to_string(GetLastError()) + ")"; - if (outR != INVALID_HANDLE_VALUE) { - CloseHandle(outR); - CloseHandle(outW); - } - if (errR != INVALID_HANDLE_VALUE) { - CloseHandle(errR); - CloseHandle(errW); - } + cleanup(); return false; } + // The child now owns the write ends; drop our copies. if (outW != INVALID_HANDLE_VALUE) CloseHandle(outW); + if (dupOut != INVALID_HANDLE_VALUE) CloseHandle(dupOut); if (errW != INVALID_HANDLE_VALUE) CloseHandle(errW); - if (redirectStdio) { + if (redirectStdio && (outDrained || errDrained)) { auto drain = [](HANDLE r, std::string& out) { char buf[256]; for (;;) { @@ -516,8 +578,8 @@ bool probeAttempt(const wchar_t* appname, std::wstring cmd, } CloseHandle(r); }; - drain(outR, capturedOut); - drain(errR, capturedErr); + if (outDrained) drain(outR, capturedOut); + if (errDrained) drain(errR, capturedErr); } WaitForSingleObject(pi.hProcess, INFINITE); exitCode = 0; @@ -540,6 +602,16 @@ bool probeAttempt(const wchar_t* appname, std::wstring cmd, // attempt 3: cmd.exe /c echo marker — the classic control. // attempt 4: same helper, NO STARTUPINFO redirection — plain handle // inheritance only (stdout follows detcheck's own fd1). +// attempt 5: same helper, STARTUPINFO with detcheck's OWN (kernel- +// assigned) fd1/fd2 — if this delivers while 1-3 do not, +// the failure is specific to handles this process created. +// (Success shows up as an extra PROBE_HELPER_OK line in +// detcheck's own stdout and an extra self-report line in +// its stderr; capturedOut/capturedErr stay empty by design.) +// attempt 6: same helper, STARTUPINFO with a fresh pipe whose write +// end was re-created via DuplicateHandle(dwInheritable) — +// tests whether a freshly duplicated inheritable handle is +// delivered where the original pipe handle is not. void probeControlSpawn() { // The helper lives next to us in the build bin directory. wchar_t mod[1024] = {}; @@ -556,21 +628,32 @@ void probeControlSpawn() { const std::wstring helperCmd = L"\"" + helper + L"\""; const std::wstring controlCmd = L"cmd.exe /c echo LAIGE_DETCHECK_CONTROL_OK"; auto runAttempt = [&](int n, const wchar_t* app, const std::wstring& cmd, - bool redirect, const char* desc) { + bool redirect, int outMode, const char* desc) { DWORD code = 0; std::string out, err, why; - const bool ok = probeAttempt(app, cmd, redirect, code, out, err, why); + const bool ok = probeAttempt(app, cmd, redirect, outMode, code, out, err, + why); diagf("laige-detcheck: control probe attempt %d (%s): spawned=%d " "exit=%lu out='%s' err='%s' why=%s\n", n, desc, ok ? 1 : 0, static_cast(code), out.c_str(), err.c_str(), why.c_str()); }; if (!helper.empty()) { - runAttempt(1, nullptr, helperCmd, true, "helper, cmdline path"); - runAttempt(2, helper.c_str(), helperCmd, true, "helper, explicit app"); + runAttempt(1, nullptr, helperCmd, true, 0, "helper, cmdline path"); + runAttempt(2, helper.c_str(), helperCmd, true, 0, "helper, explicit app"); + // Kernel-assigned handles through STARTUPINFO: if this one delivers + // while the fresh-pipe ones do not, the failure is specific to + // handles this process created itself. + runAttempt(5, nullptr, helperCmd, true, 1, + "helper, kernel fd1/fd2 via STARTUPINFO"); + // Fresh pipe whose write end is re-created as an inheritable + // duplicate: if this delivers where the original did not, use the + // DuplicateHandle path for scenario capture. + runAttempt(6, nullptr, helperCmd, true, 2, + "helper, dup handle via STARTUPINFO"); } - runAttempt(3, nullptr, controlCmd, true, "cmd marker"); - runAttempt(4, nullptr, helperCmd, false, "helper, no redirect (control)"); + runAttempt(3, nullptr, controlCmd, true, 0, "cmd marker"); + runAttempt(4, nullptr, helperCmd, false, 0, "helper, no redirect (control)"); } // Best-effort parent-process identification (M0-TEST-01 Windows CI @@ -658,10 +741,10 @@ RunResult runScenario(const std::string& exe, SECURITY_ATTRIBUTES sa{}; sa.nLength = sizeof sa; - // Inheritable from creation: on the CI Windows runner, a CreatePipe - // handle created with bInheritHandle=FALSE did not gain the INHERIT - // flag from a later SetHandleInformation (observed writeFlags=0x1, - // no 0x80), so the child never received the pipe's write end. + // Inheritable from creation. (CI Windows runner note: even with the + // INHERIT bit confirmed set on the write end, some process instances + // never receive the STARTUPINFO pipe in the child - the control probes + // log which handle kinds do deliver; see probeControlSpawn.) sa.bInheritHandle = TRUE; HANDLE readH = INVALID_HANDLE_VALUE; HANDLE writeH = INVALID_HANDLE_VALUE; @@ -683,12 +766,10 @@ RunResult runScenario(const std::string& exe, } // Diagnostics (LOG-002): handle values and types, to distinguish a bad // handle from a bad inheritance in the no-output failure cases. - DWORD writeInfo = 0; - GetHandleInformation(writeH, &writeInfo); diagf("laige-detcheck: scenario handles read=0x%p write=0x%p " - "types=%lu/%lu writeFlags=0x%lx setInherit=%d\n", + "types=%lu/%lu write %s setInherit=%d\n", static_cast(readH), static_cast(writeH), - GetFileType(readH), GetFileType(writeH), writeInfo, + GetFileType(readH), GetFileType(writeH), handleBits(writeH).c_str(), setInherit ? 1 : 0); STARTUPINFOW si{}; si.cb = sizeof si; From 99d371bc30ec72145df23082955a3ad6e965c4ef Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 13 Sep 2026 02:58:22 +0200 Subject: [PATCH 24/27] [M0-TEST-01] Probe self-created file handles: run 24 root-cause data Run 24 (34728950395, e2abce5) on the Windows CI runner produced the discriminating data for the STARTUPINFO capture failure: - attempts 5 (detcheck's OWN kernel-assigned fd1/fd2 through STARTUPINFO): DELIVERED - the helper child self-reported fd0=0x234 fd1=0x238 fd2=0x23c (exactly detcheck's handles) and its marker appeared in detcheck's own stdout. - attempts 1-3 (fresh CreatePipe, INHERIT bit confirmed set), 6 (fresh pipe re-created via DuplicateHandle(dwInheritable=TRUE)): NOT delivered - child exited 0 with no output captured. - attempt 4 (no STARTUPINFO, plain inheritance): works (control). So the runner's kernel delivers STARTUPINFO handles only when they are handles detcheck itself inherited from its parent (kernel-assigned), not handles detcheck created - regardless of the INHERIT bit (0x1 = HANDLE_FLAG_INHERIT, confirmed present in every failing instance). The root-cause mechanism of that restriction is outside the process (no local repro); the fix is to stop depending on it. New attempt 7 probes the remaining deliverable candidate on the tool's side: a self-created FILE handle (temp file) as the child's stdout through STARTUPINFO, read back after the child exits. If files deliver where pipes do not, file capture is the scenario path; if not, the capture moves to the check script (child inherits detcheck's fd1, stream delimited by tool-emitted markers). Also this run confirms the dead-fragment-check fix: with the fragment checks live, the Windows failure set is now 24/25/26/27/29 (test 27's coincidental exit-code match no longer masks the broken capture) while 28 still passes (its assertion needs no capture). All other lanes (Linux x4, macOS x2, tooling x3) green on e2abce5. --- tools/detcheck/laige-detcheck.cpp | 68 ++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index 6bb7473..0cc58bf 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -457,7 +457,13 @@ std::string handleBits(HANDLE h) { // 2 = a fresh CreatePipe whose write end is first re-created through // DuplicateHandle(dwInheritable=TRUE) before use - tests whether a // freshly duplicated inheritable handle is delivered where the -// original pipe handle is not. +// original pipe handle is not; +// 3 = a self-created FILE handle (a temp file) as the child's stdout - +// run 24 showed STARTUPINFO delivers detcheck's kernel-assigned +// handles but NOT self-created pipes (even duplicated); if a +// self-created FILE handle delivers, file capture is the scenario +// path. The file is read back after the child exits (capturedOut +// carries the marker; capturedErr still comes from a fresh pipe). // Without redirectStdio the child inherits detcheck's own standard // handles (the plain-inheritance control). Returns false only when the // spawn itself failed (errOut). @@ -472,6 +478,7 @@ bool probeAttempt(const wchar_t* appname, std::wstring cmd, HANDLE errR = INVALID_HANDLE_VALUE, errW = INVALID_HANDLE_VALUE; HANDLE dupOut = INVALID_HANDLE_VALUE; HANDLE outTarget = INVALID_HANDLE_VALUE; + std::wstring outFile; // mode 3 only: the child's stdout file bool outDrained = false, errDrained = false; auto cleanup = [&]() { if (outR != INVALID_HANDLE_VALUE) CloseHandle(outR); @@ -485,6 +492,31 @@ bool probeAttempt(const wchar_t* appname, std::wstring cmd, // Kernel-assigned: detcheck's own stdout; the child's stderr goes // to detcheck's own stderr (both harness-captured). outTarget = GetStdHandle(STD_OUTPUT_HANDLE); + } else if (outMode == 3) { + // Self-created FILE handle (temp file) as the child's stdout: + // if this delivers where self-created pipes do not, file capture + // is the scenario path. Read back after the child exits. + wchar_t tmp[512] = {}; + const DWORD tmpLen = GetTempPathW(512, tmp); + if (tmpLen == 0 || tmpLen >= 512) { + errOut = "GetTempPathW failed (lastError=" + + std::to_string(GetLastError()) + ")"; + cleanup(); + return false; + } + outFile = std::wstring(tmp, static_cast(tmpLen)) + + L"laige-detcheck-probe-" + + std::to_wstring(GetCurrentProcessId()) + L".tmp"; + const HANDLE f = CreateFileW(outFile.c_str(), GENERIC_WRITE, + FILE_SHARE_READ, &sa, CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (f == INVALID_HANDLE_VALUE) { + errOut = "CreateFileW failed (lastError=" + + std::to_string(GetLastError()) + ")"; + cleanup(); + return false; + } + outTarget = f; } else { if (!CreatePipe(&outR, &outW, &sa, 0)) { errOut = "CreatePipe failed (lastError=" + @@ -526,6 +558,12 @@ bool probeAttempt(const wchar_t* appname, std::wstring cmd, static_cast(outTarget), handleBits(outTarget).c_str(), static_cast(GetStdHandle(STD_ERROR_HANDLE)), handleBits(GetStdHandle(STD_ERROR_HANDLE)).c_str()); + } else if (outMode == 3) { + diagf("laige-detcheck: probe handles out=file 0x%p %s" + " | err r=0x%p w=0x%p type=%lu %s\n", + static_cast(outTarget), handleBits(outTarget).c_str(), + static_cast(errR), static_cast(errW), + GetFileType(errW), handleBits(errW).c_str()); } else { std::string dupNote; if (outMode == 2) { @@ -584,6 +622,23 @@ bool probeAttempt(const wchar_t* appname, std::wstring cmd, WaitForSingleObject(pi.hProcess, INFINITE); exitCode = 0; GetExitCodeProcess(pi.hProcess, &exitCode); + // Mode 3: the child's stdout landed in a file; read it back now that + // the child has exited (all of its writes are complete). + if (!outFile.empty()) { + const HANDLE rf = CreateFileW(outFile.c_str(), GENERIC_READ, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (rf != INVALID_HANDLE_VALUE) { + char buf[4096]; + for (;;) { + DWORD n = 0; + if (!ReadFile(rf, buf, sizeof buf, &n, nullptr) || n == 0) break; + capturedOut.append(buf, n); + } + CloseHandle(rf); + } + DeleteFileW(outFile.c_str()); + } CloseHandle(pi.hThread); CloseHandle(pi.hProcess); return true; @@ -612,6 +667,12 @@ bool probeAttempt(const wchar_t* appname, std::wstring cmd, // end was re-created via DuplicateHandle(dwInheritable) — // tests whether a freshly duplicated inheritable handle is // delivered where the original pipe handle is not. +// attempt 7: same helper, STARTUPINFO with a self-created FILE handle +// (temp file) as the child's stdout — if this delivers +// where self-created pipes do not (run 24: kernel-assigned +// handles delivered, self-created pipes not, even when +// duplicated), file capture is the scenario path. Success +// shows up as PROBE_HELPER_OK inside capturedOut. void probeControlSpawn() { // The helper lives next to us in the build bin directory. wchar_t mod[1024] = {}; @@ -651,6 +712,11 @@ void probeControlSpawn() { // DuplicateHandle path for scenario capture. runAttempt(6, nullptr, helperCmd, true, 2, "helper, dup handle via STARTUPINFO"); + // Self-created FILE handle as the child's stdout (temp file, read + // back after the child exits): if this delivers where self-created + // pipes do not, file capture is the scenario path. + runAttempt(7, nullptr, helperCmd, true, 3, + "helper, file via STARTUPINFO"); } runAttempt(3, nullptr, controlCmd, true, 0, "cmd marker"); runAttempt(4, nullptr, helperCmd, false, 0, "helper, no redirect (control)"); From 08f332f1aa02c5f590c69c5e875e06da41e23077 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 13 Sep 2026 04:02:55 +0200 Subject: [PATCH 25/27] [M0-TEST-01] Fix Windows CI: two-stage marker capture for scenario runs Root cause (measured in the M0-TEST-01 CI, runs 34728950395/25 = runs 24/25, windows-2022 job): the runner never delivers handles the process creates itself - fresh pipes, duplicated pipes, AND fresh files, regardless of the INHERIT bit (confirmed set: raw=0x1) - to child processes through STARTUPINFO. Only handles the process itself inherited from its parent (kernel-assigned fd0/1/2) are delivered (probe attempt 5). Every capture-pipe design therefore fails on this runner; the fix stops depending on handle delivery at all. Two-stage capture, ALL platforms (single code path, locally testable): phase 1 (--run-a/--run-b, semantic change): spawn each scenario binary WITHOUT redirecting its stdout - the child inherits detcheck's own stdout (the CTest check script's execute_process capture) - between tool-emitted marker lines @@DETCHK-RUN--BEGIN@@ / @@DETCHK-RUN--END @@. Phase 1 exits 0 when both children ran to completion, 2 on a spawn or scenario failure (reason on stderr). It does not read or compare the streams. phase 2 (new --compare-combined=): the check script writes the captured combined stream to a file and re-runs detcheck, which splits at the markers, re-runs the stream contract on each run, compares, and emits the same report format and exit codes (0/1/2) as before. Changes: - tools/detcheck/laige-detcheck.cpp: both old runScenario bodies (CreatePipe/STARTUPINFO/PeekNamedPipe on Windows, fork/dup2 on POSIX) replaced by spawnScenarioWithMarkers (plain inheritance, WaitForSingleObject before GetExitCodeProcess on Windows - the STILL_ACTIVE guard is preserved); new readFileBounded (64 KiB reads, contract-capped) and extractRunStream; new mode 3 --compare-combined (mutually exclusive with modes 1/2); usage and header docs updated. - tests/detcheck/expect-detcheck-result.cmake.in: two-phase check script (phase 1 execute_process, combined file, phase 2 --compare-combined; mode 1 stays single-shot; phase-1 diagnostics preserved to .phase1 before phase 2 overwrites the file). CMake-portability notes baked in: string(FIND)/string(LENGTH) take a literal first argument (no variable expansion), and get_filename_component's argument order differs between cmake versions, so both are avoided. - docs/api/detcheck.md: two-stage mechanism, report/exit-code semantics, and the Windows runner handle-delivery finding (DOC-007: same patch as the behavior change). Provenance: this fixes the M0-TOOL-02 Windows path (its stale "Windows path was compile-verified by the CI MSVC job" claim in the roadmap/README.md changelog is corrected with the M0-TEST-01 entry); the bug and fix land inside this PR per the established pattern. Local: all 5 P0 trees (g++/shared/ASan/TSan/clang, Debug) 32/32 including the 8 detcheck tests on the new two-stage path; six-scenario end-to-end smoke (identical/diverged/malformed/fail/stream-mismatch/ args) all correct. Windows branch is CI-verified (no MSVC locally). --- docs/api/detcheck.md | 69 ++- .../detcheck/expect-detcheck-result.cmake.in | 102 +++- tools/detcheck/laige-detcheck.cpp | 546 ++++++++++-------- 3 files changed, 446 insertions(+), 271 deletions(-) diff --git a/docs/api/detcheck.md b/docs/api/detcheck.md index 2ac9230..4e83964 100644 --- a/docs/api/detcheck.md +++ b/docs/api/detcheck.md @@ -49,6 +49,7 @@ output: at most **65536** ticks and 64 bytes per line. laige-detcheck --scenario= [--ticks=N] [--seed=HEX|DEC] laige-detcheck --run-a= --run-b= [-- scenario-args...] +laige-detcheck --compare-combined= ``` **Mode 1 — `--scenario`** (M0 built-in scenarios, in-process): @@ -67,6 +68,41 @@ configurations) are executed and their hash streams compared. Everything after the `--` separator is passed to both scenario binaries, so scenario arguments can never collide with tool flags. +Mode 2 is **two-stage**, and `--compare-combined` is its second stage: + +- **Phase 1 — `--run-a`/`--run-b`** spawns both scenario binaries. Each + child inherits this process's **stdout** (no capture pipe is created for + it), so its tick lines land in whatever captures the checker's stdout, + delimited by the marker lines the checker itself emits: + + ```text + @@DETCHK-RUN-A-BEGIN@@ + + @@DETCHK-RUN-A-END @@ + @@DETCHK-RUN-B-BEGIN@@ + + @@DETCHK-RUN-B-END @@ + ``` + + Phase 1 exits `0` when both scenario processes ran to completion, `2` + on a spawn failure or a non-zero scenario exit (the reason on stderr). + It does **not** read back or compare the streams. + +- **Phase 2 — `--compare-combined=`** reads the combined stream the + caller wrote (phase 1's captured stdout), splits it at the markers, + re-runs the scenario contract on each run, compares the two streams, + and reports (the report below). + +The split is forced by the CI Windows runner: it does not deliver handles +the checker process creates (pipes or files, even with the `INHERIT` bit +set, even after duplication) to child processes through `STARTUPINFO` — +only handles the process itself inherited from its parent are delivered +(measured in the M0-TEST-01 CI, runs 24/25). A scenario child therefore +cannot be handed a capture pipe; its stdout must be the checker's own +stdout, which the CTest check script (`execute_process`) captures and +hands back in phase 2. The mechanism is identical on every platform, so +the two-stage flow is exercised by the local suite on POSIX as well. + ## Report and exit codes stdout (stable and machine-greppable — LOG-001): @@ -83,11 +119,10 @@ detcheck scenario=synthetic-perturbed result=DIVERGED first_diff_tick=7 run-b: 7 93a3363d5a1dffa9 ``` -In mode 2 the first line is -`detcheck scenario= vs result=... ticks=` and -the `run-a`/`run-b` lines carry the full scenario paths. When one stream -ends early, the tick lines become stream-length notes -(`stream ends: ticks`) with `result=DIVERGED`. +In mode 2 (phase 2) the first line is +`detcheck scenario=combined result=... ticks=` with `run-a`/`run-b` +labels. When one stream ends early, the tick lines become stream-length +notes (`stream ends: ticks`) with `result=DIVERGED`. | Exit | Meaning | |---|---| @@ -95,11 +130,16 @@ ends early, the tick lines become stream-length notes | 1 | divergence detected (a determinism failure — loud, CORE-008) | | 2 | usage error, unknown scenario, a scenario run failed (non-zero exit, spawn failure), or a scenario violated the output contract (malformed line, tick gap, unbounded output) | -Windows only: the stdout capture waits for pipe data (or the write end -closing) before reading, and the exit code is read only after the child -has terminated — a still-starting child can never be misread as an empty -run, and the `STILL_ACTIVE` sentinel (`259`) is never reported as a -scenario exit code. +`--run-a`/`--run-b` (phase 1) exits `0` when both scenario processes ran +to completion and `2` on any spawn or scenario failure; the `0`/`1` +comparison result comes from the `--compare-combined` phase. + +On Windows, phase 1 spawns with `CreateProcessW` (no `STARTUPINFO` — no +handles are handed to the child, see above), waits for the child with +`WaitForSingleObject`, and reads its exit code only after termination, so +the `STILL_ACTIVE` sentinel (`259`) is never reported as a scenario exit +code; a signalled child is reported as `128 + signal`, a non-zero scenario +failure either way. ## The built-in synthetic workload @@ -124,9 +164,12 @@ computation of scenarios (the tool's line-by-line comparison is unchanged). A CI tool, not a hot path: one scenario run is O(ticks × 32); captured streams are bounded (65536 lines × 64 bytes ≈ 1.5 MiB worst case per -run). Process execution is a plain pipe capture (fork/exec on POSIX, -`CreateProcessW` on Windows) — no shell, no temporary files, bounded -memory, and the scenario's stderr stays on the CI log. +run). Process execution is plain inheritance (fork/exec on POSIX, +`CreateProcessW` on Windows) — no shell, no capture pipe, bounded memory, +and the scenario's stderr stays on the CI log. Phase 2 reads the combined +stream file back with a bounded read capped by the contract (2 × 65536 +lines + markers ≈ 1.5 MiB worst case); the file is written by the check +script between phases and lives in the build tree. ## Test suite diff --git a/tests/detcheck/expect-detcheck-result.cmake.in b/tests/detcheck/expect-detcheck-result.cmake.in index 42cc022..4c0e354 100644 --- a/tests/detcheck/expect-detcheck-result.cmake.in +++ b/tests/detcheck/expect-detcheck-result.cmake.in @@ -1,15 +1,28 @@ # Generated by tests/detcheck/CMakeLists.txt for one laige-detcheck CTest # test (M0-TOOL-02) — do not edit. # -# Runs laige-detcheck with the argument list @CMD@ and asserts BOTH the -# exit code (@EXPECT_EXIT@) and the required output fragments (the -# needle checks below are generated per test). The assertions live here, -# in a CMake script, instead of in CTest properties: CTest inverts +# Runs laige-detcheck for the argument list @CMD@ and asserts BOTH the +# exit code (@EXPECT_EXIT@) and the required output fragments (the needle +# checks below are generated per test). The assertions live here, in a +# CMake script, instead of in CTest properties: CTest inverts # PASS_REGULAR_EXPRESSION when WILL_FAIL is set (verified on CMake 4.4.3 # — a matching regex then makes the test fail), and a crash exits # non-zero just like a correct failure (CORE-008), so the content must # be checked, not just the code. # +# Mode 2 (--run-a/--run-b) is two-stage (see the header of +# tools/detcheck/laige-detcheck.cpp): the CI Windows runner does not +# deliver handles detcheck creates (pipes or files, even inheritable) +# to child processes through STARTUPINFO — only handles detcheck itself +# inherited from its parent are delivered (measured in the M0-TEST-01 +# CI, runs 24/25). So each scenario child inherits detcheck's stdout +# (this script's execute_process capture), its ticks land between the +# tool-emitted @@DETCHK-RUN-A/B-BEGIN/END@@ markers, and the +# comparison is a second detcheck invocation (--compare-combined) over +# the combined stream. Phase 1 exits 0 when both scenario processes ran +# to completion and 2 on any spawn or scenario failure; the asserted +# exit code and output come from phase 2, or from phase 1 when it failed. +# # The executable paths arrive through the test's ENVIRONMENT property # (same pattern as tests/api/expect-api-result.cmake.in: $ # resolves per configuration under multi-config generators, so a path @@ -86,23 +99,90 @@ endforeach() # which has no environment options on the cmake versions some CI # runners provide. The tool's env dump logs the marker, so the per-test # diag file reveals which process wrote it. + +# --- Phase 1: spawn (mode 2) / single shot (mode 1) ----------------------- +# Mode 1 (--scenario) needs no second phase: the tool runs both runs +# in-process and reports in one invocation. execute_process( COMMAND "${_detcheck}" ${_args} - RESULT_VARIABLE _rc - OUTPUT_VARIABLE _out - ERROR_VARIABLE _err + RESULT_VARIABLE _rc1 + OUTPUT_VARIABLE _out1 + ERROR_VARIABLE _err1 ) +# Mode 2 is recognized by its `--run-a=` token (literal containment, not a +# regex - $ is a metacharacter and the command carries $FIX_*$ markers). +# string(FIND) takes a literal first argument (no variable expansion), so +# each list element is expanded explicitly; -1 means "token absent" for +# every element. +set(_is_mode2 -1) +foreach(_tok IN LISTS _cmd) + string(FIND ${_tok} "--run-a=" _tokpos) + if(_tokpos GREATER_EQUAL 0) + set(_is_mode2 1) + break() + endif() +endforeach() + +if(_rc1 EQUAL 0 AND _is_mode2 EQUAL -1) + # Single shot (mode 1): phase 1's result IS the final result. + set(_rc "${_rc1}") + set(_out "${_out1}") + set(_err "${_err1}") +elseif(NOT _rc1 EQUAL 0) + # A scenario process failed (or the invocation errored out): phase 1's + # result IS the final result - no comparison phase. + set(_rc "${_rc1}") + set(_out "${_out1}") + set(_err "${_err1}") +else() + # Preserve phase 1's diagnostics before phase 2's invocation overwrites + # the per-test diag file (TEMP, M0-TEST-01 Windows CI diagnosis). + if(DEFINED ENV{LAIGE_DETCHECK_DIAG_FILE} AND EXISTS "$ENV{LAIGE_DETCHECK_DIAG_FILE}") + # Basename and directory from the (absolute, CMake-built) path via + # string(REGEX) - get_filename_component's argument order and + # component set differ between the cmake versions the CI lanes ship + # (measured on CMake 4.4.3: ). + string(REGEX MATCH "[^/\\\\]+$" _diagfile + "$ENV{LAIGE_DETCHECK_DIAG_FILE}") + string(REPLACE ".txt" "" _diagbase "${_diagfile}") + string(REGEX REPLACE "[^/\\\\]+$" "" _diagdir + "$ENV{LAIGE_DETCHECK_DIAG_FILE}") + file(READ "$ENV{LAIGE_DETCHECK_DIAG_FILE}" _diag1) + file(WRITE "${_diagdir}/${_diagbase}.phase1" "${_diag1}") + endif() + + # --- Phase 2: compare the combined stream ------------------------------- + # The combined stream (detcheck's phase-1 stdout: markers + both tick + # streams, plus any pre-marker probe output) goes back to detcheck, + # which splits it at the markers, re-runs the stream contract on each + # run, and compares. + # CMAKE_CURRENT_LIST_DIR is absolute for a `cmake -P` script; the + # script's basename is .cmake (tests/detcheck/CMakeLists.txt). + set(_scriptdir "${CMAKE_CURRENT_LIST_DIR}") + string(REGEX MATCH "[^/]+$" _scriptbase "${CMAKE_CURRENT_LIST_FILE}") + string(REPLACE ".cmake" "" _testname "${_scriptbase}") + set(_combined "${_scriptdir}/${_testname}.combined") + file(WRITE "${_combined}" "${_out1}") + execute_process( + COMMAND "${_detcheck}" "--compare-combined=${_combined}" + RESULT_VARIABLE _rc + OUTPUT_VARIABLE _out + ERROR_VARIABLE _err + ) +endif() + set(_text "${_out} ${_err}") # TEMP (M0-TEST-01 Windows CI diagnosis): persist this check script's own -# capture (written by the cmake process, not by detcheck), so it can be -# compared against detcheck's own diag file, which a different detcheck -# invocation could have overwritten. +# captures (written by the cmake process, not by detcheck), so they can +# be compared against detcheck's own diag files, which a different +# detcheck invocation could have overwritten. if(DEFINED ENV{LAIGE_DETCHECK_DIAG_FILE}) file(WRITE "$ENV{LAIGE_DETCHECK_DIAG_FILE}.check" - "check-script capture rc=${_rc}\n${_out}\n--- err ---\n${_err}\n") + "check-script capture phase1 rc=${_rc1}\n${_out1}\n--- err1 ---\n${_err1}\n" + "phase2 rc=${_rc}\n${_out}\n--- err2 ---\n${_err}\n") endif() set(_problems "") diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index 0cc58bf..2d0ae23 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -48,6 +48,7 @@ // laige-detcheck --scenario= [--ticks=N] [--seed=HEX|DEC] // laige-detcheck --run-a= --run-b= // [-- scenario-args...] +// laige-detcheck --compare-combined= // // Mode 1 (--scenario): a built-in scenario (M0 stand-in for the M1 // scenarios): @@ -59,13 +60,36 @@ // proves the failure path (the step's Verify // clause) // -// Mode 2 (--run-a/--run-b): the real mode from M1-DET-04 on — two builds -// of the same scenario source (two build configurations) are executed -// and their hash streams compared. Everything after a `--` separator is -// passed to both scenario binaries (scenario arguments that could look -// like tool flags are unambiguous because of the separator). +// Modes 2+3 (--run-a/--run-b, then --compare-combined): the real mode +// from M1-DET-04 on — two builds of the same scenario source (two build +// configurations) are executed and their hash streams compared. +// Everything after a `--` separator is passed to both scenario binaries +// (scenario arguments that could look like tool flags are unambiguous +// because of the separator). // -// Report (stdout, stable and machine-greppable — LOG-001): +// The comparison is TWO-STAGE on every platform. The CI Windows runner +// does not deliver handles the checker process creates (pipes or files, +// even with the INHERIT bit set, even duplicated) to child processes +// through STARTUPINFO — measured in the M0-TEST-01 CI (runs 24/25): +// only handles the process itself inherited from its parent are +// delivered. A scenario child therefore cannot be given a capture pipe; +// its stdout must be the checker's own stdout, which the harness +// (the CTest check script's execute_process) captures: +// +// phase 1 (--run-a/--run-b): spawn each binary WITHOUT redirecting its +// stdout (plain inheritance). Each run's ticks land in the harness's +// capture of this process's stdout, delimited by the marker lines +// @@DETCHK-RUN-A-BEGIN@@ ... @@DETCHK-RUN-A-END @@ +// @@DETCHK-RUN-B-BEGIN@@ ... @@DETCHK-RUN-B-END @@ +// Phase 1 exits 0 when both scenario processes ran to completion, +// 2 on spawn failure or scenario failure (the reason on stderr). +// It does NOT read or compare the ticks. +// phase 2 (--compare-combined=): the check script writes the +// captured combined stream to a file and re-runs detcheck on it. +// Phase 2 splits the stream at the markers, re-runs the stream +// contract on each run, compares, and reports (the report below). +// +// Report (phase-2 stdout, stable and machine-greppable — LOG-001): // // match: // detcheck scenario= result=OK ticks= @@ -84,12 +108,9 @@ // 2 usage error, unknown scenario, a scenario run failed (non-zero // exit or spawn failure), or a scenario violated the output // contract (malformed line / tick gap / unbounded output) -// -// Windows only: the stdout capture waits for pipe data (or the write end -// closing) before peeking, and the exit code is read only after the child -// has terminated — so a still-starting child can never be misread as an -// empty run, and STILL_ACTIVE (259) is never reported as a scenario exit -// code. +// (--run-a/--run-b, phase 1: 0 when both scenario processes ran to +// completion, 2 on any spawn or scenario failure; the 0/1 comparison +// result comes from the --compare-combined phase) // // ============================================================================ // Built-in synthetic workload @@ -781,152 +802,6 @@ std::string parentProcessInfo() { return s; } -RunResult runScenario(const std::string& exe, - const std::vector& args) { - RunResult r; - const std::wstring exeW = toWide(exe); - // Diagnostics (LOG-002): a failed scenario run must say WHICH binary was - // attempted and whether it exists, not just a numeric exit code. - const DWORD attrs = GetFileAttributesW(exeW.c_str()); - if (attrs == INVALID_FILE_ATTRIBUTES) { - r.error = "scenario executable not found (lastError=" + - std::to_string(GetLastError()) + "): " + exe; - return r; - } - // The env dump and control probes describe THIS process instance, so - // run them once per process, not once per scenario run (a mode-2 - // invocation runs runScenario twice). - static bool probed = false; - if (!probed) { - dumpEnvironmentDiagnostics(); - probeControlSpawn(); - probed = true; - } - std::wstring cmd = exeW; - for (const std::string& a : args) cmd += L" " + quoteArg(a); - - SECURITY_ATTRIBUTES sa{}; - sa.nLength = sizeof sa; - // Inheritable from creation. (CI Windows runner note: even with the - // INHERIT bit confirmed set on the write end, some process instances - // never receive the STARTUPINFO pipe in the child - the control probes - // log which handle kinds do deliver; see probeControlSpawn.) - sa.bInheritHandle = TRUE; - HANDLE readH = INVALID_HANDLE_VALUE; - HANDLE writeH = INVALID_HANDLE_VALUE; - if (!CreatePipe(&readH, &writeH, &sa, 0)) { - r.error = "CreatePipe failed"; - return r; - } - // The child inherits the write end of the pipe (set at creation; this - // call is redundant on success and the failure check stays for - // completeness). - const BOOL setInherit = - SetHandleInformation(writeH, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); - if (!setInherit) { - r.error = "SetHandleInformation failed (lastError=" + - std::to_string(GetLastError()) + ")"; - CloseHandle(readH); - CloseHandle(writeH); - return r; - } - // Diagnostics (LOG-002): handle values and types, to distinguish a bad - // handle from a bad inheritance in the no-output failure cases. - diagf("laige-detcheck: scenario handles read=0x%p write=0x%p " - "types=%lu/%lu write %s setInherit=%d\n", - static_cast(readH), static_cast(writeH), - GetFileType(readH), GetFileType(writeH), handleBits(writeH).c_str(), - setInherit ? 1 : 0); - STARTUPINFOW si{}; - si.cb = sizeof si; - si.dwFlags = STARTF_USESTDHANDLES; - si.hStdOutput = writeH; // capture stdout - si.hStdError = GetStdHandle(STD_ERROR_HANDLE); // stays visible in the log - si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); - PROCESS_INFORMATION pi{}; - // Explicit lpApplicationName (the exact exe path) so first-token - // command-line resolution cannot substitute another image. - if (!CreateProcessW(exeW.c_str(), cmd.data(), nullptr, nullptr, TRUE, 0, - nullptr, nullptr, &si, &pi)) { - r.error = "CreateProcessW failed (is the path correct?)"; - CloseHandle(readH); - CloseHandle(writeH); - return r; - } - // Diagnostics (LOG-002): record the child's pid and the image the kernel - // actually started — in the failure cases observed in CI the child - // exited 0 with no output, so the log must show which process that was. - { - wchar_t image[1024] = {}; - DWORD imageLen = 0; - std::string imageUtf8; - if (QueryFullProcessImageNameW(pi.hProcess, 0, image, &imageLen)) { - const int n = WideCharToMultiByte(CP_UTF8, 0, image, -1, nullptr, 0, - nullptr, nullptr); - if (n > 0) { - imageUtf8.resize(static_cast(n - 1)); - WideCharToMultiByte(CP_UTF8, 0, image, -1, imageUtf8.data(), n, - nullptr, nullptr); - } - } - diagf("laige-detcheck: spawned child pid=%lu image=%s\n", - static_cast(pi.dwProcessId), - imageUtf8.empty() ? "(unavailable)" : imageUtf8.c_str()); - } - CloseHandle(writeH); - - char buf[65536]; - std::string pending; - for (;;) { - // Wait for the pipe to signal (data available OR all write ends - // closed) BEFORE peeking: a bare PeekNamedPipe can report n==0 while - // the child has simply not written yet (it is still starting up), - // and breaking on that n==0 would close the pipe under a live child - // — its writes then fail and it exits 0 with all output lost. - if (WaitForSingleObject(readH, INFINITE) != WAIT_OBJECT_0) { - r.error = "WaitForSingleObject(readH) failed"; - break; - } - DWORD n = 0; - if (!PeekNamedPipe(readH, buf, sizeof buf, &n, nullptr, nullptr)) { - r.error = "PeekNamedPipe failed"; - break; - } - if (n == 0) break; // signaled with no data: the scenario closed the pipe - if (n > sizeof buf) n = sizeof buf; - DWORD got = 0; - if (!ReadFile(readH, buf, n, &got, nullptr)) { - r.error = "ReadFile failed"; - break; - } - if (!appendChunk(r.lines, pending, buf, got, kMaxTicks)) { - r.error = "scenario output is unbounded (line or tick count exceeds " - "the contract)"; - break; - } - } - CloseHandle(readH); - // Wait for the child to actually terminate BEFORE reading its exit code: - // GetExitCodeProcess on a process that has not (yet) terminated returns - // STILL_ACTIVE (259), which would be misread as a scenario exit code. - // The POSIX path has the same guarantee via waitpid. - if (WaitForSingleObject(pi.hProcess, INFINITE) != WAIT_OBJECT_0) { - r.error = "WaitForSingleObject(process) failed"; - } - DWORD code = 0; - GetExitCodeProcess(pi.hProcess, &code); - CloseHandle(pi.hThread); - CloseHandle(pi.hProcess); - r.exitCode = static_cast(code); - finishPending(r.lines, pending, r); - if (r.exitCode != 0 && r.error.empty()) { - r.error = "scenario process exited with code " + std::to_string(code) + - " (command: " + exe + ")"; - } - r.ok = r.error.empty(); - return r; -} - #else // POSIX (Linux, macOS) // POSIX counterpart of the Windows parent-process identification: the @@ -954,76 +829,178 @@ std::string parentProcessInfo() { return s; } -RunResult runScenario(const std::string& exe, - const std::vector& args) { - RunResult r; - int pipefd[2]; - if (pipe(pipefd) != 0) { - r.error = "pipe() failed"; - return r; +#endif // _WIN32 + +// --- Scenario spawn with markers (mode 2, phase 1) ------------------------- +// +// Two-stage capture, all platforms: +// +// The CI Windows runner does NOT deliver handles this process creates +// (pipes or files, even with the INHERIT bit confirmed set and even +// duplicated) to child processes through STARTUPINFO - measured in the +// M0-TEST-01 CI (runs 24/25): only handles this process itself +// inherited from its parent (its kernel-assigned fd0/1/2) are delivered. +// A scenario child therefore cannot be given a capture pipe it inherits; +// its stdout must BE this process's stdout (plain inheritance), which the +// harness (the CTest check script's execute_process) captures. +// +// So mode 2 runs in two phases: +// phase 1 (this function, per run): emit `@@DETCHK-RUN--BEGIN@@`, +// spawn the scenario child WITHOUT redirecting its stdout (it +// inherits this process's stdout), wait for it, emit +// `@@DETCHK-RUN--END @@`. The child's ticks land between +// the markers in the harness's capture of this process's stdout. +// phase 2 (--compare-combined): the check script hands the combined +// stream back to detcheck, which splits it at the markers, re-runs +// the stream contract on each run, and compares. +// +// This function does not read the ticks. It returns the child's exit +// code, or -1 when the spawn itself failed (error set). +int spawnScenarioWithMarkers(const std::string& exe, + const std::vector& args, + const std::string& label, std::string& error) { + // The env dump and control probes describe THIS process instance; run + // them once per process and BEFORE the first BEGIN marker, so their + // (probe) stdout, if any, precedes the marker windows and never lands + // inside an extracted run stream. + static bool probed = false; + if (!probed) { +#ifdef _WIN32 + dumpEnvironmentDiagnostics(); + probeControlSpawn(); +#endif + probed = true; } - const pid_t pid = fork(); - if (pid < 0) { - r.error = "fork() failed"; - ::close(pipefd[0]); - ::close(pipefd[1]); - return r; + // The BEGIN marker must reach the capture BEFORE the child is born, so + // the child's ticks (on the same stream) cannot precede it. + std::printf("@@DETCHK-RUN-%s-BEGIN@@\n", label.c_str()); + std::fflush(stdout); + int result = -1; +#ifdef _WIN32 + { + const std::wstring exeW = toWide(exe); + const DWORD attrs = GetFileAttributesW(exeW.c_str()); + if (attrs == INVALID_FILE_ATTRIBUTES) { + error = "scenario executable not found (lastError=" + + std::to_string(GetLastError()) + "): " + exe; + } else { + std::wstring cmd = exeW; + for (const std::string& a : args) cmd += L" " + quoteArg(a); + PROCESS_INFORMATION pi{}; + // No STARTUPINFO: the child inherits this process's standard handles + // (the harness's capture). No self-created handle is involved - the + // kind the CI Windows runner never delivers (see above). + if (!CreateProcessW(exeW.c_str(), cmd.data(), nullptr, nullptr, TRUE, + 0, nullptr, nullptr, nullptr, &pi)) { + error = "CreateProcessW failed (is the path correct?)"; + } else { + diagf("laige-detcheck: spawned run-%s child pid=%lu (inherit stdio)\n", + label.c_str(), static_cast(pi.dwProcessId)); + // Wait for termination BEFORE reading the exit code + // (GetExitCodeProcess on a live process returns STILL_ACTIVE). + if (WaitForSingleObject(pi.hProcess, INFINITE) != WAIT_OBJECT_0) { + error = "WaitForSingleObject(process) failed"; + } else { + DWORD raw = 0; + GetExitCodeProcess(pi.hProcess, &raw); + result = static_cast(raw); + } + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + } + } } - if (pid == 0) { - // Child: stdout goes to the pipe; stderr is inherited, so a scenario - // crash report still reaches the CI log. - if (::dup2(pipefd[1], 1) < 0) _exit(127); - ::close(pipefd[0]); - ::close(pipefd[1]); - std::vector argv; - argv.push_back(const_cast(exe.c_str())); - for (const std::string& a : args) argv.push_back(const_cast(a.c_str())); - argv.push_back(nullptr); - execv(exe.c_str(), argv.data()); - _exit(127); // execv failed: the path is wrong or not executable +#else + { + if (::access(exe.c_str(), X_OK) != 0) { + error = "scenario executable not found: " + exe; + } else { + const pid_t pid = fork(); + if (pid < 0) { + error = "fork() failed"; + } else if (pid == 0) { + // Child: NO stdout redirect - it inherits this process's stdout + // (the harness's capture), so its ticks land between the parent's + // markers. + std::vector argv; + argv.push_back(const_cast(exe.c_str())); + for (const std::string& a : args) + argv.push_back(const_cast(a.c_str())); + argv.push_back(nullptr); + execv(exe.c_str(), argv.data()); + _exit(127); // execv failed: the path is wrong or not executable + } else { + int status = 0; + if (waitpid(pid, &status, 0) < 0) { + error = "waitpid() failed"; + } else if (WIFEXITED(status)) { + result = WEXITSTATUS(status); + } else if (WIFSIGNALED(status)) { + // Conventional "exit code" for a signalled child; main() reports + // it as a scenario process failure (exit 2), same as the old + // single-shot path. + result = 128 + WTERMSIG(status); + } + } + } } - ::close(pipefd[1]); +#endif + // The END marker carries the child's exit code (or -1 when the spawn + // itself failed, in which case the check script stops at phase 1; the + // marker keeps the captured stream parseable either way). + std::printf("@@DETCHK-RUN-%s-END %d@@\n", label.c_str(), result); + std::fflush(stdout); + return result; +} + +// Read a whole file, bounded to maxBytes (bounded work, CORE-003). +bool readFileBounded(const std::string& path, std::string& out, + std::size_t maxBytes) { + std::FILE* f = nullptr; +#ifdef _WIN32 + if (fopen_s(&f, path.c_str(), "rb") != 0) return false; +#else + f = std::fopen(path.c_str(), "rb"); + if (!f) return false; +#endif + out.clear(); char buf[65536]; - std::string pending; for (;;) { - const ssize_t n = ::read(pipefd[0], buf, sizeof buf); - if (n < 0) { - if (errno == EINTR) continue; - r.error = "read() of the scenario stdout failed"; - break; - } - if (n == 0) break; // EOF: the scenario finished - if (!appendChunk(r.lines, pending, buf, static_cast(n), - kMaxTicks)) { - r.error = "scenario output is unbounded (line or tick count exceeds " - "the contract)"; - break; + const std::size_t n = std::fread(buf, 1, sizeof buf, f); + if (n == 0) break; + if (out.size() + n > maxBytes) { + std::fclose(f); + return false; } + out.append(buf, n); } - ::close(pipefd[0]); - int status = 0; - if (waitpid(pid, &status, 0) < 0) { - r.error = "waitpid() failed"; - } - if (WIFEXITED(status)) { - r.exitCode = WEXITSTATUS(status); - } else if (WIFSIGNALED(status)) { - r.exitCode = 128 + WTERMSIG(status); - if (r.error.empty()) { - r.error = "scenario process was killed by signal " + - std::to_string(WTERMSIG(status)); + std::fclose(f); + return true; +} + +// Extract one run's tick lines from the combined stream: the lines +// strictly between `@@DETCHK-RUN--BEGIN@@` and the +// `@@DETCHK-RUN--END @@` line. Returns false when either marker +// is missing (a truncated capture is a contract failure, CORE-008). +bool extractRunStream(const std::vector& lines, + const std::string& label, + std::vector& out) { + const std::string begin = "@@DETCHK-RUN-" + label + "-BEGIN@@"; + const std::string endPrefix = "@@DETCHK-RUN-" + label + "-END "; + bool inStream = false; + for (const std::string& ln : lines) { + if (ln == begin) { + inStream = true; + continue; + } + if (inStream) { + if (ln.rfind(endPrefix, 0) == 0) return true; + out.push_back(ln); } } - finishPending(r.lines, pending, r); - if (r.exitCode != 0 && r.error.empty()) { - r.error = "scenario process exited with code " + std::to_string(r.exitCode); - } - r.ok = r.error.empty(); - return r; + return false; } -#endif // _WIN32 - // --- The built-in synthetic scenario (M0 stand-in) ------------------------- struct SyntheticBody { @@ -1167,12 +1144,6 @@ CompareResult compareStreams(const std::vector& a, return c; } -std::string basenameOf(std::string_view path) { - const auto pos = path.find_last_of("/\\"); - return pos == std::string_view::npos ? std::string(path) - : std::string(path.substr(pos + 1)); -} - void report(std::string_view scenarioName, const CompareResult& c, std::size_t ticks, std::string_view labelA, std::string_view labelB) { @@ -1215,6 +1186,8 @@ struct Args { std::string scenario; // mode 1 std::string runA; // mode 2 std::string runB; // mode 2 + bool cmpCombined = false; // mode 3 + std::string cmpCombinedFile; // mode 3 combined stream file bool ticksSet = false; int ticks = kDefaultTicks; bool seedSet = false; @@ -1229,12 +1202,23 @@ void printUsage(std::FILE* out) { "[--seed=HEX|DEC]\n" " laige-detcheck --run-a= --run-b=" " [-- scenario-args...]\n" + " laige-detcheck --compare-combined=\n" " laige-detcheck --help\n" "\n" " --scenario built-in scenario: synthetic | " "synthetic-perturbed\n" " --run-a/--run-b two builds of the same scenario (two build\n" - " configurations)\n" + " configurations). Two-stage capture: this\n" + " invocation SPAWNS both binaries (each child's\n" + " stdout is inherited from this process, so its\n" + " ticks flow to this process's stdout, delimited\n" + " by @@DETCHK-RUN-A/B-BEGIN/END@@ markers) and\n" + " does NOT compare; the caller hands the\n" + " combined stream to --compare-combined.\n" + " --compare-combined read a combined stream (markers + both tick\n" + " streams), validate both runs against the\n" + " stream contract, compare, and report (phase 2\n" + " of --run-a/--run-b)\n" " --ticks built-in scenario tick count (1..%d, " "default %d)\n" " --seed built-in scenario seed (0xHEX or decimal)\n" @@ -1242,7 +1226,10 @@ void printUsage(std::FILE* out) { " are passed to both scenario binaries\n" "\n" "Exit codes: 0 = match, 1 = divergence, 2 = error (usage, unknown\n" - "scenario, scenario failure, contract violation).\n", + "scenario, spawn failure, scenario failure, contract violation).\n" + "--run-a/--run-b exits 0 when both scenario processes ran to\n" + "completion and 2 on any spawn or scenario failure (the comparison\n" + "happens in the --compare-combined phase).\n", kMaxTicks, kDefaultTicks); } @@ -1272,6 +1259,10 @@ bool parseArgs(int argc, char** argv, Args& a) { } else if (key == "--run-b") { if (!hasValue) return false; a.runB = value; + } else if (key == "--compare-combined") { + if (!hasValue) return false; + a.cmpCombined = true; + a.cmpCombinedFile = value; } else if (key == "--ticks") { if (!hasValue || value.empty()) return false; std::uint64_t v = 0; @@ -1336,10 +1327,12 @@ int main(int argc, char** argv) { } const bool mode1 = !a.scenario.empty(); const bool mode2 = !a.runA.empty() || !a.runB.empty(); - if (mode1 && mode2) { + const bool mode3 = a.cmpCombined; + const int modeCount = (mode1 ? 1 : 0) + (mode2 ? 1 : 0) + (mode3 ? 1 : 0); + if (modeCount != 1) { std::fprintf(stderr, - "laige-detcheck: --scenario and --run-a/--run-b are " - "mutually exclusive\n"); + "laige-detcheck: exactly one of --scenario, " + "--run-a/--run-b, or --compare-combined is required\n"); printUsage(stderr); return 2; } @@ -1357,10 +1350,6 @@ int main(int argc, char** argv) { printUsage(stderr); return 2; } - if (!mode1 && !mode2) { - printUsage(stderr); - return 2; - } if (mode2 && (a.runA.empty() || a.runB.empty())) { std::fprintf(stderr, "laige-detcheck: both --run-a and --run-b are required\n"); @@ -1375,34 +1364,97 @@ int main(int argc, char** argv) { return 2; } - // --- Mode 2: two scenario binaries (two build configurations) ---------- - if (mode2) { - const RunResult resA = runScenario(a.runA, a.positionals); - if (!resA.ok) { - diagf("laige-detcheck: scenario run-a: %s\n", resA.error.c_str()); + // --- Mode 3: compare a combined stream (phase 2) ------------------------ + if (mode3) { + // The combined stream holds, per run: a BEGIN marker line, the tick + // lines, and an END marker line. Its size is bounded by the contract + // (two runs of kMaxTicks lines of kMaxLineBytes, plus the markers); + // reading more is a contract failure, not an allocation (CORE-003). + const std::size_t maxBytes = + (2 * static_cast(kMaxTicks) + 8) * (kMaxLineBytes + 1); + std::string content; + if (!readFileBounded(a.cmpCombinedFile, content, maxBytes)) { + std::fprintf(stderr, + "laige-detcheck: cannot read combined stream file: %s\n", + a.cmpCombinedFile.c_str()); return 2; } - const std::string errA = validateStream(resA.lines, kMaxTicks); - if (!errA.empty()) { - diagf("laige-detcheck: scenario run-a: %s\n", errA.c_str()); + std::vector lines; + std::string pending; + RunResult bounded; // carries the unbounded-output error if any + const bool okA = appendChunk(lines, pending, content.data(), + content.size(), 2 * kMaxTicks + 8); + if (okA) finishPending(lines, pending, bounded); + if (!okA || !bounded.error.empty()) { + std::fprintf(stderr, + "laige-detcheck: combined stream is unbounded (line or " + "tick count exceeds the contract)\n"); + return 2; + } + std::vector linesA, linesB; + if (!extractRunStream(lines, "A", linesA)) { + std::fprintf(stderr, + "laige-detcheck: combined stream is missing the " + "run-A markers (@@DETCHK-RUN-A-BEGIN/END@@)\n"); + return 2; + } + if (!extractRunStream(lines, "B", linesB)) { + std::fprintf(stderr, + "laige-detcheck: combined stream is missing the " + "run-B markers (@@DETCHK-RUN-B-BEGIN/END@@)\n"); return 2; } - const RunResult resB = runScenario(a.runB, a.positionals); - if (!resB.ok) { - diagf("laige-detcheck: scenario run-b: %s\n", resB.error.c_str()); + const std::string errA = validateStream(linesA, kMaxTicks); + if (!errA.empty()) { + diagf("laige-detcheck: scenario run-a: %s\n", errA.c_str()); return 2; } - const std::string errB = validateStream(resB.lines, kMaxTicks); + const std::string errB = validateStream(linesB, kMaxTicks); if (!errB.empty()) { diagf("laige-detcheck: scenario run-b: %s\n", errB.c_str()); return 2; } - const CompareResult c = compareStreams(resA.lines, resB.lines); - report(basenameOf(a.runA) + " vs " + basenameOf(a.runB), c, - resA.lines.size(), a.runA, a.runB); + const CompareResult c = compareStreams(linesA, linesB); + report("combined", c, linesA.size(), "run-a", "run-b"); return c.ok ? 0 : 1; } + // --- Mode 2: two scenario binaries (phase 1 of two-stage capture) ------ + // Each scenario child inherits THIS process's stdout (no capture pipe - + // see spawnScenarioWithMarkers), so its ticks land in the harness's + // capture of this process's stdout, between the BEGIN/END markers. The + // comparison happens in phase 2 (--compare-combined), invoked by the + // check script over the combined stream. + if (mode2) { + std::string errA; + const int codeA = + spawnScenarioWithMarkers(a.runA, a.positionals, "A", errA); + if (codeA < 0) { + diagf("laige-detcheck: scenario run-a: %s\n", errA.c_str()); + return 2; + } + if (codeA != 0) { + diagf("laige-detcheck: scenario run-a: scenario process exited with " + "code %d (command: %s)\n", + codeA, a.runA.c_str()); + return 2; + } + std::string errB; + const int codeB = + spawnScenarioWithMarkers(a.runB, a.positionals, "B", errB); + if (codeB < 0) { + diagf("laige-detcheck: scenario run-b: %s\n", errB.c_str()); + return 2; + } + if (codeB != 0) { + diagf("laige-detcheck: scenario run-b: scenario process exited with " + "code %d (command: %s)\n", + codeB, a.runB.c_str()); + return 2; + } + return 0; + } + // --- Mode 1: built-in scenario, two in-process runs -------------------- const RunSpec specA{ std::string(a.scenario) + "[seed=" + formatSeed(a.seed) + From c8b82214853f2fd4c7e78c7824c1191b311503ce Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 13 Sep 2026 04:11:13 +0200 Subject: [PATCH 26/27] [M0-TEST-01] Fix Windows CI: zeroed STARTUPINFO for scenario spawn Run 34732057356 (commit 08f332f) built clean and every non-spawn test passed, but all six mode-2 tests failed in phase 1: CreateProcessW failed for the scenario children. The difference from every spawn that DID succeed in that same process instance (the probe attempts, incl. the no-redirect control) is the STARTUPINFO argument: those pass a zeroed STARTUPINFOW (cb set, no dwFlags), the scenario spawn passed NULL - the runner's CreateProcess machinery rejects the NULL form. Pass a zeroed STARTUPINFOW (plain handle inheritance, identical semantics to NULL for the child) and carry the GetLastError() code in the failure message (CORE-008). --- tools/detcheck/laige-detcheck.cpp | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index 2d0ae23..9900410 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -886,13 +886,20 @@ int spawnScenarioWithMarkers(const std::string& exe, } else { std::wstring cmd = exeW; for (const std::string& a : args) cmd += L" " + quoteArg(a); + // Zeroed STARTUPINFO (no dwFlags): the child inherits this process's + // standard handles (the harness's capture). No self-created handle + // is involved - the kind the CI Windows runner never delivers (see + // above). A NULL STARTUPINFO is NOT used: the runner's CreateProcess + // machinery fails on it (measured in the M0-TEST-01 CI, run + // 34732057356: the probe's no-redirect control passes a zeroed + // STARTUPINFO and spawns fine, while the NULL form fails). + STARTUPINFOW si{}; + si.cb = sizeof si; PROCESS_INFORMATION pi{}; - // No STARTUPINFO: the child inherits this process's standard handles - // (the harness's capture). No self-created handle is involved - the - // kind the CI Windows runner never delivers (see above). if (!CreateProcessW(exeW.c_str(), cmd.data(), nullptr, nullptr, TRUE, - 0, nullptr, nullptr, nullptr, &pi)) { - error = "CreateProcessW failed (is the path correct?)"; + 0, nullptr, nullptr, &si, &pi)) { + error = "CreateProcessW failed (lastError=" + + std::to_string(GetLastError()) + "): " + exe; } else { diagf("laige-detcheck: spawned run-%s child pid=%lu (inherit stdio)\n", label.c_str(), static_cast(pi.dwProcessId)); From d57bb79eb3620192dd7f07bf4102ec4350286aa0 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 13 Sep 2026 10:24:30 +0200 Subject: [PATCH 27/27] [M0-TEST-01] Strip the Windows CI diagnostics after the fix is CI-verified Run 34746755055 (c8b8221) is fully green: all 10 jobs, Windows 32/32 with the two-stage marker capture working end-to-end on the windows-2022 runner, and the seed-identity pair is closed (e2abce5 -> c8b8221, byte-identical test-seed-check lines in the archived Linux ASan LastTest.log). Removed now that the diagnosis is complete: - laige-detcheck.cpp: probes 1-7, env dump, handle-bit dump, parent-process identification, the diag sink (g_diag/diagf/DiagFlush), LAIGE_DETCHECK_DIAG_FILE handling, the LAIGE_DETCHECK_CALLER tag, unused includes (//). - probe-helper.cpp + the laige-detcheck-probe target. - expect-detcheck-result.cmake.in: the TEMP .phase1 diag save, the TEMP .check capture mirror, the TEMP passing-output print. The two-phase flow itself is the permanent design and stays. - tests/detcheck/CMakeLists.txt: DIAG_FILE/CALLER env wiring. - ci.yml + ci-pull.yml: the warm-up step (based on the disproven file-filter hypothesis) and the diag-cat loop. - roadmap: M0-TEST-01 Verify filled with the CI run ids and the byte-identical seed lines; M0-TEST-01 changelog row completed with the full Windows fix chain and provenance; M0-TOOL-02 row's stale 'Windows path was compile-verified' claim corrected (it was compile-only; the runtime path was fixed inside this PR). --- .github/workflows/ci-pull.yml | 27 +- .github/workflows/ci.yml | 36 +- roadmap/M0-foundations.md | 27 +- roadmap/README.md | 4 +- tests/detcheck/CMakeLists.txt | 18 - .../detcheck/expect-detcheck-result.cmake.in | 39 -- tools/detcheck/CMakeLists.txt | 9 - tools/detcheck/laige-detcheck.cpp | 564 +----------------- tools/detcheck/probe-helper.cpp | 35 -- 9 files changed, 35 insertions(+), 724 deletions(-) delete mode 100644 tools/detcheck/probe-helper.cpp diff --git a/.github/workflows/ci-pull.yml b/.github/workflows/ci-pull.yml index 255298d..5519090 100644 --- a/.github/workflows/ci-pull.yml +++ b/.github/workflows/ci-pull.yml @@ -197,33 +197,8 @@ jobs: run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug - name: Build run: cmake --build build --config Debug -j - - name: Warm up scenario binaries - # See the same step in ci.yml: first execution of a freshly built - # .exe on a Windows runner can be disrupted by the file filter's - # first-open scan; absorb it outside the ctest assertions. - run: | - $bin = ".\build\bin\Debug" - & "$bin\laige-detcheck.exe" --scenario=synthetic - & "$bin\laige-fuzz.exe" json_parse --runs=1 - & "$bin\detcheck-fixture-scenario.exe" --ticks=1 - & "$bin\detcheck-fixture-scenario-perturbed.exe" --ticks=1 - & "$bin\detcheck-fixture-scenario-bad.exe" --ticks=1 - & "$bin\detcheck-fixture-scenario-fail.exe" --ticks=1 - & "$bin\detcheck-fixture-scenario-short.exe" --ticks=1 - & "$bin\laige-detcheck-probe.exe" - name: Test (unit) - # See the same step in ci.yml: the per-test detcheck diagnostic - # files are printed here regardless of the ctest outcome. - run: | - ctest --test-dir build -C Debug --output-on-failure - $ctestExit = $LASTEXITCODE - Get-ChildItem .\build\tests\detcheck\diag -File -ErrorAction SilentlyContinue | - Sort-Object Name | - ForEach-Object { - Write-Host "===== detcheck diag: $($_.Name) =====" - Get-Content $_.FullName - } - exit $ctestExit + run: ctest --test-dir build -C Debug --output-on-failure macos-arm64: name: macOS arm64 (AppleClang) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d3818d..2502bab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -218,42 +218,8 @@ jobs: run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug - name: Build run: cmake --build build --config Debug -j - - name: Warm up scenario binaries - # The first execution of a freshly built .exe on a Windows runner - # can be disrupted by the file filter's first-open scan (observed - # in the M0-TEST-01 CI: the first launches of the fresh detcheck - # fixture exes exited 0 with their stdout capture lost, while - # later launches of the same binaries succeeded). Running each - # spawned test binary once here absorbs that outside the ctest - # assertions. - run: | - $bin = ".\build\bin\Debug" - & "$bin\laige-detcheck.exe" --scenario=synthetic - & "$bin\laige-fuzz.exe" json_parse --runs=1 - & "$bin\detcheck-fixture-scenario.exe" --ticks=1 - & "$bin\detcheck-fixture-scenario-perturbed.exe" --ticks=1 - & "$bin\detcheck-fixture-scenario-bad.exe" --ticks=1 - & "$bin\detcheck-fixture-scenario-fail.exe" --ticks=1 - & "$bin\detcheck-fixture-scenario-short.exe" --ticks=1 - & "$bin\laige-detcheck-probe.exe" - name: Test (unit) - # ctest hides passing-test output, so the per-test detcheck - # diagnostic files are printed here, regardless of the ctest - # outcome; the step still exits with ctest's status. Two files - # per test: .txt is detcheck's own spawn diagnostics - # (spawn handles, control probes — see tools/detcheck); - # .txt.check is the check script's captured output of the - # same invocation (written by the cmake process, not detcheck). - run: | - ctest --test-dir build -C Debug --output-on-failure - $ctestExit = $LASTEXITCODE - Get-ChildItem .\build\tests\detcheck\diag -File -ErrorAction SilentlyContinue | - Sort-Object Name | - ForEach-Object { - Write-Host "===== detcheck diag: $($_.Name) =====" - Get-Content $_.FullName - } - exit $ctestExit + run: ctest --test-dir build -C Debug --output-on-failure macos-arm64: name: macOS arm64 (AppleClang) diff --git a/roadmap/M0-foundations.md b/roadmap/M0-foundations.md index 4363604..7e3b5ae 100644 --- a/roadmap/M0-foundations.md +++ b/roadmap/M0-foundations.md @@ -893,14 +893,25 @@ No rendering, no physics, no networking yet — `laige-core` only. target (`ctest -R fuzz_json_parse` green in every local tree and inside every P0 job's ctest in CI); a seeded random test passes identically on two CI runs (byte-identical `test-seed-check` lines - in the job logs / archived `LastTest.log` — CI observation recorded - in the follow-up "Record CI observation" commit). Local - (2026-09-12): 32/32 ctest with zero warnings under the NFR-8.10 - policy on g++ 16.2.1 (`build` static, `build-shared` shared, - `build-asan` ASan+UBSan fatal, `build-tsan` TSan `halt_on_error=1`) - and Clang 22.1.8 (`build-clang`); the seeded KAT line is identical - on the g++ and clang++ trees locally (cross-compiler identity; the - cross-CI-run comparison is the remaining Verify clause). + in the archived `Testing/Temporary/LastTest.log` of the Linux ASan + job — verified across two CI runs of the same commit, and again + across commits e2abce5 → c8b8221, in both cases + byte-identical): + + ```text + test-seed-check default seed=0x000000001f055eed stream=1 draws=65536 fnv1a=0x7ea4049545656830 + test-seed-check override seed=0x2468acce01234567 stream=2 draws=65536 fnv1a=0x535d2ca741b61cbf + ``` + + CI runs: 34713354925 / 34714039135 (same-commit pair, byte-identical + lines) and 34728782624 (e2abce5) / 34746755055 (c8b8221, the final + commit — byte-identical lines, all 10 jobs green including Windows + 32/32). Local (2026-09-12): 32/32 ctest with zero warnings under the + NFR-8.10 policy on g++ 16.2.1 (`build` static, `build-shared` + shared, `build-asan` ASan+UBSan fatal, `build-tsan` TSan + `halt_on_error=1`) and Clang 22.1.8 (`build-clang`); the seeded KAT + line is identical on the g++ and clang++ trees locally + (cross-compiler identity, confirmed in CI above). - **Size:** ~1,000 lines (over the ~150 estimate: same pattern as M0-CORE-01…08 — `docs/testing.md` carries the conventions, `laige_test_seed.h` the seed contract next to the code, and the diff --git a/roadmap/README.md b/roadmap/README.md index fc1c756..274ac12 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -191,8 +191,8 @@ One line per completed (or split/renumbered) step. | 2026-09-11 | M0-CORE-07 | `41938b0` | Bounded JSON in laige-core (ADR 0003, no new dependency): `laige::JsonValue` (deep copy, O(1) move, deep equality) + `parseJson` (1 MiB / depth-32 bounds, strict UTF-8, duplicate keys rejected, ±inf for overflow tokens — documented) + `serializeJson` (canonical ASCII; shortest-round-trip numbers; `std::to_chars` avoided for AppleClang 15 compatibility); all failures → `MalformedInput` (3); `laige-fuzz` minimal deterministic runner (Prng-seeded, `--runs`/`--seed`, 19-document corpus) + `json_parse` fuzz target; `config_json` CTest entry (25 cases) + `fuzz_json_parse` instrumented entry (ASan tree); API contract in `docs/api/json.md` (board/changelog row reconstructed 2026-09-11 from the step record) | | 2026-09-11 | M0-CORE-08 | `810c251` | Budget harness: `laige::Histogram` (fixed-capacity rolling window; O(1) allocation-free `record()`; exact min/mean/p50/p95/p99/max over the stored window via nearest-rank percentiles; allocation-free O(n log n) `stats()`) + `laige::TimeIt` (`steady_clock` ms scope timer) + `loadBudgets`/`budgetCheck` (strict `budgets.json` schema v1 via the bounded JSON parser — ARCH-007; loud `NO_SAMPLES` failure on empty histograms; `target == 0` = hard-zero budget, not "not set"; AGENTS §12 report: stable 4-line text, before/after pair, caller context; `formatStatsLine` the single source of the stats text) + repo-root `budgets.json` (all 15 PRD §8.1 entries; `measured: 0` = not yet measured) + `laige-bench` tool (canonical command per `building.md`: `--suite=synthetic` deterministic 4096-step LCG+double stand-in workload, `--runs`/`--warmup`, `--budget=` check, exit code 2 on budget failure, `--report` append); `budget_harness` CTest entry (22 cases) + `laige_bench_smoke` CTest entry; **bug fix (M0-CORE-07)** found by this step's Verify run: `json.cpp` `parseObjectMembers` missing `skipWhitespace` before the member key — object documents with `", "` between members (the hand-formatted `budgets.json`) were rejected; regression test `ConfigJsonValid.ObjectMemberWhitespace` (fails pre-fix); API contract in `docs/api/budget_harness.md`; local Verify: `ctest` 16/16, zero warnings on GCC static/shared/ASan/TSan + Clang trees; MSVC/AppleClang compile proof lands in CI | | 2026-09-12 | M0-TOOL-01 | `937ac7c` | API manifest (NFR-13.1, PRD §9.4): `laige-api` target + `tools/api/laige-api-scanner` (line-oriented state machine — no regex pass; loud failure on every unsupported construct; doc association from consecutive `//` blocks with `@budget`/`@experimental` tags; `--check FILE` byte compare + symbol-level diff via `laige::parseJson`; exit 0 OK / 1 stale / 2 error) + checked-in `laige-api.json` (version 1, deterministic, no timestamps — byte-identical regeneration is the drift check) + `tests/api` CTest entries (fixture tree with exact manifest bytes, fresh/stale, unsupported-construct failure, real-tree `--check`) + the `api-manifest` CI job (every PR and merge, fails on drift); (board/changelog row retroactively added 2026-09-12 — the step merged as `937ac7c`/PR #10 without updating this board or log) | -| 2026-09-12 | M0-TOOL-02 | `fe4460c` | Determinism checker skeleton (FR-11.5, AGENTS ARCH-010, TEST-004): `laige-detcheck` (`tools/detcheck`) runs a named scenario in two build configurations and compares per-tick hash streams; scenario contract (normative in the tool header, mirrored in `docs/api/detcheck.md`): one stdout line per tick ` ` — 16 lowercase hex hash digits (algorithm NOT part of the contract — lines compare byte-for-byte), tick starts at 0 step 1 no padding, trailing newline optional / trailing `\r` tolerated, stderr ignored, exit 0 — enforced strictly and bounded (65536 ticks, 64 bytes/line; a violation is a loud exit 2, CORE-008); modes: `--scenario=synthetic\|synthetic-perturbed` (built-in 32-body `fpx16_16`+Prng workload, two in-process runs — pure integer arithmetic, bit-exact per ADR 0002; perturbation fixture: +1 unit to body 3's x at tick 7) and `--run-a= --run-b= [-- scenario-args...]` (the M1-DET-04 mode; `--` separator keeps scenario args unambiguous); stable report `detcheck scenario= result=OK\|DIVERGED [first_diff_tick=]` + run-a/run-b lines (LOG-001); exit 0 match / 1 divergence / 2 error; POSIX fork/exec + pipe capture, Windows CreateProcessW + PeekNamedPipe (no shell, bounded memory, scenario stderr stays on the CI log); 9 CTest entries (`tests/detcheck`, `ctest -R detcheck`): self-check, built-in perturbation, identical/diverged/malformed/failure/short-stream cross-binary pairs — one fixture source, five compiled variants — each a generated `cmake -P` script asserting exit code + output fragments (tests/api pattern); CI `detcheck` job in ci-pull.yml/ci.yml (every PR and merge, no ci:* condition — tooling check, not a P0 OS build): runs the synthetic self-check and SKIPS the real-scenario step (two build configurations of M1-SAMPLE-01's hello) until it lands — M1-DET-04 activates it; local Verify: ctest 31/31, zero warnings on g++ static/shared, clang++, ASan+UBSan, TSan trees; Windows path compile-verified by the CI MSVC job; CI (observed 2026-09-12 via the GitHub API): `ci-pull.yml` run 34697638239 on `82c548d` green - the default-Linux lane's four P0/sanitizer jobs (linux-gcc, linux-clang, linux-asan+UBSan, linux-tsan) plus all three tooling jobs (include-lint, api-manifest, detcheck) passed; Windows/macOS jobs label-skipped as expected; the new `detcheck` job's real-scenario step correctly reported `skipped: no real scenario yet (M1-SAMPLE-01)` | -| 2026-09-12 | M0-TEST-01 | `a292aef` | Test infrastructure conventions (docs/testing.md, source of truth, linked from docs/README.md, tests/README.md, building.md, README.md): tests/support/laige_test_seed.h (TestSeed()/TestPrng() — fixed default 0x1F055EED, identical to laige-fuzz's kDefaultSeed, one documented default seed repo-wide; LAIGE_TEST_SEED env override, 0x-hex/decimal, read at call time; set-but-unparseable value records a loud test failure and falls back to the default — CORE-008; per-test substream ids so streams never share position) + tests/testing (test_infra_tests, CTest entry `test_infra`, SeededRandom suite, 6 cases: 65536-draw FNV-1a KATs under the default seed 0x7EA4049545656830 and override seed 0x535D2CA741B61CBF, first-8-draw KAT, default/fuzz seed identity, loud invalid-env path via EXPECT_NONFATAL_FAILURE (gtest-spi.h), seed parser, substream isolation; machine-greppable `test-seed-check` line per KAT before asserting — the byte-identical-across-two-CI-runs identity check, M0-TEST-01 Verify) + first regress_ test (M0-CORE-08's ConfigJsonValid.ObjectMemberWhitespace renamed regress_json_object_member_ws; suite unchanged so `ctest -R config_json` still covers it; historical M0-CORE-08 record unchanged) + fuzz lane semantics (no new CI job — bounded --runs=1000 is the existing fuzz_json_parse ctest entry inside every P0 job's ctest, PRD §14 "every commit (bounded)"; nightly long form --runs=1000000 documented in docs/testing.md §3 + canonical command table; scheduled nightly lane lands with the first M1 fuzz target); CI workflow headers note the fuzz lane; local Verify: 32/32 ctest, zero warnings under NFR-8.10, on g++ static/shared, ASan+UBSan, TSan, and Clang trees; seeded KAT line identical on g++ and clang++ locally (cross-CI-run comparison recorded in the follow-up CI-observation commit) | +| 2026-09-12 | M0-TOOL-02 | `fe4460c` | Determinism checker skeleton (FR-11.5, AGENTS ARCH-010, TEST-004): `laige-detcheck` (`tools/detcheck`) runs a named scenario in two build configurations and compares per-tick hash streams; scenario contract (normative in the tool header, mirrored in `docs/api/detcheck.md`): one stdout line per tick ` ` — 16 lowercase hex hash digits (algorithm NOT part of the contract — lines compare byte-for-byte), tick starts at 0 step 1 no padding, trailing newline optional / trailing `\r` tolerated, stderr ignored, exit 0 — enforced strictly and bounded (65536 ticks, 64 bytes/line; a violation is a loud exit 2, CORE-008); modes: `--scenario=synthetic\|synthetic-perturbed` (built-in 32-body `fpx16_16`+Prng workload, two in-process runs — pure integer arithmetic, bit-exact per ADR 0002; perturbation fixture: +1 unit to body 3's x at tick 7) and `--run-a= --run-b= [-- scenario-args...]` (the M1-DET-04 mode; `--` separator keeps scenario args unambiguous); stable report `detcheck scenario= result=OK\|DIVERGED [first_diff_tick=]` + run-a/run-b lines (LOG-001); exit 0 match / 1 divergence / 2 error; POSIX fork/exec + pipe capture, Windows CreateProcessW + PeekNamedPipe (no shell, bounded memory, scenario stderr stays on the CI log); 9 CTest entries (`tests/detcheck`, `ctest -R detcheck`): self-check, built-in perturbation, identical/diverged/malformed/failure/short-stream cross-binary pairs — one fixture source, five compiled variants — each a generated `cmake -P` script asserting exit code + output fragments (tests/api pattern); CI `detcheck` job in ci-pull.yml/ci.yml (every PR and merge, no ci:* condition — tooling check, not a P0 OS build): runs the synthetic self-check and SKIPS the real-scenario step (two build configurations of M1-SAMPLE-01's hello) until it lands — M1-DET-04 activates it; local Verify: ctest 31/31, zero warnings on g++ static/shared, clang++, ASan+UBSan, TSan trees; Windows path compile-verified by the CI MSVC job — but NOT runtime-verified (stale as of 2026-09-13: the mode-2 capture was broken on the Windows CI runner and the mode-2 fragment assertions were dead, so the Windows failures were silent; both were found and fixed inside M0-TEST-01 / PR #13 — see the 2026-09-13 row); CI (observed 2026-09-12 via the GitHub API): `ci-pull.yml` run 34697638239 on `82c548d` green - the default-Linux lane's four P0/sanitizer jobs (linux-gcc, linux-clang, linux-asan+UBSan, linux-tsan) plus all three tooling jobs (include-lint, api-manifest, detcheck) passed; Windows/macOS jobs label-skipped as expected; the new `detcheck` job's real-scenario step correctly reported `skipped: no real scenario yet (M1-SAMPLE-01)` | +| 2026-09-12 | M0-TEST-01 | `a292aef` | Test infrastructure conventions (docs/testing.md, source of truth, linked from docs/README.md, tests/README.md, building.md, README.md): tests/support/laige_test_seed.h (TestSeed()/TestPrng() — fixed default 0x1F055EED, identical to laige-fuzz's kDefaultSeed, one documented default seed repo-wide; LAIGE_TEST_SEED env override, 0x-hex/decimal, read at call time; set-but-unparseable value records a loud test failure and falls back to the default — CORE-008; per-test substream ids so streams never share position) + tests/testing (test_infra_tests, CTest entry `test_infra`, SeededRandom suite, 6 cases: 65536-draw FNV-1a KATs under the default seed 0x7EA4049545656830 and override seed 0x535D2CA741B61CBF, first-8-draw KAT, default/fuzz seed identity, loud invalid-env path via EXPECT_NONFATAL_FAILURE (gtest-spi.h), seed parser, substream isolation; machine-greppable `test-seed-check` line per KAT before asserting — the byte-identical-across-two-CI-runs identity check, M0-TEST-01 Verify) + first regress_ test (M0-CORE-08's ConfigJsonValid.ObjectMemberWhitespace renamed regress_json_object_member_ws; suite unchanged so `ctest -R config_json` still covers it; historical M0-CORE-08 record unchanged) + fuzz lane semantics (no new CI job — bounded --runs=1000 is the existing fuzz_json_parse ctest entry inside every P0 job's ctest, PRD §14 "every commit (bounded)"; nightly long form --runs=1000000 documented in docs/testing.md §3 + canonical command table; scheduled nightly lane lands with the first M1 fuzz target); CI workflow headers note the fuzz lane; local Verify: 32/32 ctest, zero warnings under NFR-8.10, on g++ static/shared, ASan+UBSan, TSan, and Clang trees; seeded KAT line identical on g++ and clang++ locally; cross-CI-run Verify: byte-identical `test-seed-check` lines in the archived Linux ASan `LastTest.log` across runs 34713354925/34714039135 (same-commit pair) and again across commits e2abce5→c8b8221 (runs 34728782624/34746755055); Windows CI fix chain landed inside this PR (M0-TOOL-02's Windows path, provenance per the established pattern — see the corrected M0-TOOL-02 row): MSVC portability (NOMINMAX, `getenv_s`/`fopen_s` C4996, C2664/C2440/C4457/C2660), `WaitForSingleObject` before `GetExitCodeProcess` (STILL_ACTIVE), then the root cause — the CI Windows runner (windows-2022) never delivers handles the process creates itself (pipes or files, INHERIT bit confirmed set, even duplicated) to children through `STARTUPINFO`; only parent-inherited (kernel-assigned) handles are delivered (measured runs 34728950395/34729337292) — and NULL `STARTUPINFO` is rejected by its CreateProcess machinery (run 34732057356); fixed by two-stage marker capture on all platforms (phase 1 `--run-a/--run-b` spawns with plain stdout inheritance + `@@DETCHK-RUN-A/B-BEGIN/END@@` markers; phase 2 `--compare-combined` splits/validates/compares) plus the zeroed-STARTUPINFO spawn; dead fragment assertions fixed in `7928379` (`@CHECKS@` variable-name misspelling + CMake single-backslash stripping → double-escaped regexes); final CI run 34746755055 (c8b8221): all 10 jobs green, Windows 32/32 | --- diff --git a/tests/detcheck/CMakeLists.txt b/tests/detcheck/CMakeLists.txt index 26dffeb..549bc75 100644 --- a/tests/detcheck/CMakeLists.txt +++ b/tests/detcheck/CMakeLists.txt @@ -68,14 +68,6 @@ string(APPEND LAIGE_DETCHECK_TEST_ENV "LAIGE_DETCHECK_FIX_FAIL=$;") string(APPEND LAIGE_DETCHECK_TEST_ENV "LAIGE_DETCHECK_FIX_SHORT=$") -# M0-TEST-01 Windows CI diagnosis: tag every check-script detcheck -# invocation. The tool's env dump logs it, so a per-test diag file reveals -# whether its writer was the check script's own invocation. (The cmake -# versions some CI runners provide give execute_process no environment -# options, so the tag rides in the test's ENVIRONMENT property and is -# inherited by the spawned tool.) -string(APPEND LAIGE_DETCHECK_TEST_ENV - ";LAIGE_DETCHECK_CALLER=check-script") # --- The tests --------------------------------------------------------------- # Same shape as the tests/api helper: a generated cmake -P check script @@ -130,16 +122,6 @@ function(laige_add_detcheck_test name expect_exit cmd) # The quoted expansion keeps the ';'-separated pairs in ONE argument. set_tests_properties(${name} PROPERTIES ENVIRONMENT "${LAIGE_DETCHECK_TEST_ENV}") - # Windows CI investigation (M0-TEST-01): the tool flushes its spawn - # diagnostics to a per-test file; the Windows CI job cats the files - # after ctest (ctest hides passing-test output). POSIX ignores the - # variable. - if(WIN32) - file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/diag") - set_tests_properties(${name} PROPERTIES - ENVIRONMENT - "${LAIGE_DETCHECK_TEST_ENV};LAIGE_DETCHECK_DIAG_FILE=${CMAKE_CURRENT_BINARY_DIR}/diag/${name}.txt") - endif() endfunction() # (name, expected exit, quoted command list, required output fragments...) diff --git a/tests/detcheck/expect-detcheck-result.cmake.in b/tests/detcheck/expect-detcheck-result.cmake.in index 4c0e354..52abde5 100644 --- a/tests/detcheck/expect-detcheck-result.cmake.in +++ b/tests/detcheck/expect-detcheck-result.cmake.in @@ -93,13 +93,6 @@ foreach(_tok IN LISTS _cmd) list(APPEND _args "${_tok}") endforeach() -# LAIGE_DETCHECK_CALLER tags THIS detcheck invocation (M0-TEST-01 -# Windows CI diagnosis): it arrives in the test's ENVIRONMENT property -# (tests/detcheck/CMakeLists.txt) and is inherited by execute_process, -# which has no environment options on the cmake versions some CI -# runners provide. The tool's env dump logs the marker, so the per-test -# diag file reveals which process wrote it. - # --- Phase 1: spawn (mode 2) / single shot (mode 1) ----------------------- # Mode 1 (--scenario) needs no second phase: the tool runs both runs # in-process and reports in one invocation. @@ -136,22 +129,6 @@ elseif(NOT _rc1 EQUAL 0) set(_out "${_out1}") set(_err "${_err1}") else() - # Preserve phase 1's diagnostics before phase 2's invocation overwrites - # the per-test diag file (TEMP, M0-TEST-01 Windows CI diagnosis). - if(DEFINED ENV{LAIGE_DETCHECK_DIAG_FILE} AND EXISTS "$ENV{LAIGE_DETCHECK_DIAG_FILE}") - # Basename and directory from the (absolute, CMake-built) path via - # string(REGEX) - get_filename_component's argument order and - # component set differ between the cmake versions the CI lanes ship - # (measured on CMake 4.4.3: ). - string(REGEX MATCH "[^/\\\\]+$" _diagfile - "$ENV{LAIGE_DETCHECK_DIAG_FILE}") - string(REPLACE ".txt" "" _diagbase "${_diagfile}") - string(REGEX REPLACE "[^/\\\\]+$" "" _diagdir - "$ENV{LAIGE_DETCHECK_DIAG_FILE}") - file(READ "$ENV{LAIGE_DETCHECK_DIAG_FILE}" _diag1) - file(WRITE "${_diagdir}/${_diagbase}.phase1" "${_diag1}") - endif() - # --- Phase 2: compare the combined stream ------------------------------- # The combined stream (detcheck's phase-1 stdout: markers + both tick # streams, plus any pre-marker probe output) goes back to detcheck, @@ -175,16 +152,6 @@ endif() set(_text "${_out} ${_err}") -# TEMP (M0-TEST-01 Windows CI diagnosis): persist this check script's own -# captures (written by the cmake process, not by detcheck), so they can -# be compared against detcheck's own diag files, which a different -# detcheck invocation could have overwritten. -if(DEFINED ENV{LAIGE_DETCHECK_DIAG_FILE}) - file(WRITE "$ENV{LAIGE_DETCHECK_DIAG_FILE}.check" - "check-script capture phase1 rc=${_rc1}\n${_out1}\n--- err1 ---\n${_err1}\n" - "phase2 rc=${_rc}\n${_out}\n--- err2 ---\n${_err}\n") -endif() - set(_problems "") if(NOT _rc EQUAL @EXPECT_EXIT@) set(_problems "exit code ${_rc} (expected @EXPECT_EXIT@)") @@ -196,9 +163,3 @@ if(NOT _problems STREQUAL "") "--- laige-detcheck output ---\n${_text}\n" "--- end of laige-detcheck output ---") endif() -message("laige-detcheck check OK: exit @EXPECT_EXIT@, required output present") -# TEMP (M0-TEST-01 Windows CI diagnosis): print the tool output on success -# too, so passing and failing process instances can be compared in the CI -# log (the tool's spawn diagnostics go to stderr). -message("--- laige-detcheck output (passing) ---\n${_text}\n" - "--- end of laige-detcheck output ---") diff --git a/tools/detcheck/CMakeLists.txt b/tools/detcheck/CMakeLists.txt index 9ae57e3..df82e13 100644 --- a/tools/detcheck/CMakeLists.txt +++ b/tools/detcheck/CMakeLists.txt @@ -28,12 +28,3 @@ target_link_libraries(laige-detcheck PRIVATE laige-core) # CMake stamps it so the binary does not have to guess. target_compile_definitions(laige-detcheck PRIVATE LAIGE_DETCHECK_BUILD_TYPE="${CMAKE_BUILD_TYPE}") - -# Diagnostic child for the M0-TEST-01 Windows CI investigation: reports -# the std handles the kernel actually assigned to a spawned child, so -# laige-detcheck can compare the STARTUPINFO request against the -# delivery. Windows-only; the POSIX path never spawns it. -if(WIN32) - add_executable(laige-detcheck-probe probe-helper.cpp) - laige_apply_engine_policy(laige-detcheck-probe) -endif() diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp index 9900410..c3a3fa3 100644 --- a/tools/detcheck/laige-detcheck.cpp +++ b/tools/detcheck/laige-detcheck.cpp @@ -142,12 +142,9 @@ #include #include -#include -#include #include #include #include -#include #include #include #include @@ -169,65 +166,6 @@ namespace { -// --- Diagnostic sink (M0-TEST-01 Windows CI investigation) --------------- -// Every diagnostic line goes to stderr AND is accumulated in g_diag. The -// test harness sets LAIGE_DETCHECK_DIAG_FILE; main() flushes g_diag there -// on every exit path (RAII). ctest hides the output of passing tests, so -// this file is how a passing instance's diagnostics reach the CI log (the -// Windows job cats the files after ctest). -std::string g_diag; - -void diagf(const char* fmt, ...) { - char buf[4096]; - va_list ap; - va_start(ap, fmt); - const int n = std::vsnprintf(buf, sizeof buf, fmt, ap); - va_end(ap); - if (n > 0) { - const std::size_t len = static_cast( - n < static_cast(sizeof buf) ? n : static_cast(sizeof buf) - 1); - std::fprintf(stderr, "%s", buf); - g_diag.append(buf, len); - } -} - -// Read an environment variable into a fixed buffer (portable: MSVC -// degrades getenv to C4996, so use getenv_s there). -bool readEnvVar(const char* name, char* buf, std::size_t size) { -#ifdef _WIN32 - std::size_t len = 0; - return getenv_s(&len, buf, static_cast(size), name) == 0 && - len > 0; -#else - const char* v = std::getenv(name); - if (v == nullptr) return false; - const std::size_t n = std::strlen(v); - if (n >= size) return false; - std::memcpy(buf, v, n + 1); - return true; -#endif -} - -// Flush g_diag to $LAIGE_DETCHECK_DIAG_FILE (truncating stale content) on -// every exit path from main(). -struct DiagFlush { - ~DiagFlush() { - char path[1024] = {}; - if (readEnvVar("LAIGE_DETCHECK_DIAG_FILE", path, sizeof path)) { - std::FILE* f = nullptr; -#ifdef _WIN32 - if (fopen_s(&f, path, "w") != 0) f = nullptr; -#else - f = std::fopen(path, "w"); -#endif - if (f != nullptr) { - std::fwrite(g_diag.data(), 1, g_diag.size(), f); - std::fclose(f); - } - } - } -}; - // --- Named constants (CORE-005) ------------------------------------------ constexpr int kDefaultTicks = 256; // built-in scenario default run length @@ -392,444 +330,7 @@ std::wstring quoteArg(std::string_view arg) { return q; } -// Environment diagnostic (CI comparison between failing and passing -// process instances): the CWD, every environment variable name, and the -// full values of the test-wiring variables (LAIGE_/CTEST_ prefixes) and -// PATH. GetEnvironmentStringsW returns a double-NUL-terminated block of -// "NAME=VALUE" entries. -void dumpEnvironmentDiagnostics() { - wchar_t cwd[1024] = {}; - const DWORD cwdLen = GetCurrentDirectoryW(1024, cwd); - std::string cwdUtf8; - if (cwdLen > 0) { - const int n = WideCharToMultiByte(CP_UTF8, 0, cwd, -1, nullptr, 0, - nullptr, nullptr); - if (n > 0) { - cwdUtf8.resize(static_cast(n - 1)); - WideCharToMultiByte(CP_UTF8, 0, cwd, -1, cwdUtf8.data(), n, nullptr, - nullptr); - } - } - diagf("laige-detcheck: env cwd=%s\n", cwdUtf8.c_str()); - LPWCH env = GetEnvironmentStringsW(); - if (env == nullptr) { - diagf("laige-detcheck: env: unavailable\n"); - return; - } - // Only the test-wiring variables (compact; the full name dump was log - // noise and has served its purpose — the wiring is intact). - int testVars = 0; - for (const wchar_t* block = env; *block != L'\0';) { - const size_t len = wcslen(block); - const wchar_t* eq = wcschr(block, L'='); - const std::wstring name(block, eq ? static_cast(eq - block) - : len); - const std::wstring value(eq ? eq + 1 : L""); - const bool isTestVar = name.rfind(L"LAIGE_", 0) == 0 || - name.rfind(L"CTEST_", 0) == 0 || - name == L"PATH"; - if (isTestVar) { - std::string nameUtf8, valueUtf8; - { - const int n = WideCharToMultiByte(CP_UTF8, 0, name.data(), - static_cast(name.size()), - nullptr, 0, nullptr, nullptr); - nameUtf8.resize(static_cast(n)); - WideCharToMultiByte(CP_UTF8, 0, name.data(), - static_cast(name.size()), nameUtf8.data(), n, - nullptr, nullptr); - const int m = WideCharToMultiByte(CP_UTF8, 0, value.data(), - static_cast(value.size()), - nullptr, 0, nullptr, nullptr); - valueUtf8.resize(static_cast(m)); - WideCharToMultiByte(CP_UTF8, 0, value.data(), - static_cast(value.size()), valueUtf8.data(), - m, nullptr, nullptr); - } - diagf("laige-detcheck: env %s=%s\n", nameUtf8.c_str(), - valueUtf8.c_str()); - ++testVars; - } - block += len + 1; - } - FreeEnvironmentStringsW(env); - diagf("laige-detcheck: env: %d test-wiring variables present\n", testVars); -} - -// Handle flag bits for the diagnostics. GetHandleInformation uses -// HANDLE_FLAG_INHERIT = 0x1 and HANDLE_FLAG_PROTECT_FROM_CLOSE = 0x2 -// (NOT 0x80/0x100 - those values belong to no handle API). -std::string handleBits(HANDLE h) { - DWORD info = 0; - GetHandleInformation(h, &info); - char b[96]; - std::snprintf(b, sizeof b, "inherit=%d protect=%d (raw=0x%lx)", - (info & HANDLE_FLAG_INHERIT) ? 1 : 0, - (info & HANDLE_FLAG_PROTECT_FROM_CLOSE) ? 1 : 0, info); - return std::string(b); -} - -// One probe launch. With redirectStdio the child's stdout (and usually -// stderr) are set through STARTUPINFO; outMode selects the stdout source: -// 0 = a fresh CreatePipe write end we drain (baseline); -// 1 = detcheck's OWN stdout/stderr handles (kernel-assigned at process -// start) - the child's marker then lands in detcheck's own streams -// (harness-captured), so capturedOut/capturedErr stay empty; -// 2 = a fresh CreatePipe whose write end is first re-created through -// DuplicateHandle(dwInheritable=TRUE) before use - tests whether a -// freshly duplicated inheritable handle is delivered where the -// original pipe handle is not; -// 3 = a self-created FILE handle (a temp file) as the child's stdout - -// run 24 showed STARTUPINFO delivers detcheck's kernel-assigned -// handles but NOT self-created pipes (even duplicated); if a -// self-created FILE handle delivers, file capture is the scenario -// path. The file is read back after the child exits (capturedOut -// carries the marker; capturedErr still comes from a fresh pipe). -// Without redirectStdio the child inherits detcheck's own standard -// handles (the plain-inheritance control). Returns false only when the -// spawn itself failed (errOut). -bool probeAttempt(const wchar_t* appname, std::wstring cmd, - bool redirectStdio, int outMode, DWORD& exitCode, - std::string& capturedOut, std::string& capturedErr, - std::string& errOut) { - SECURITY_ATTRIBUTES sa{}; - sa.nLength = sizeof sa; - sa.bInheritHandle = TRUE; - HANDLE outR = INVALID_HANDLE_VALUE, outW = INVALID_HANDLE_VALUE; - HANDLE errR = INVALID_HANDLE_VALUE, errW = INVALID_HANDLE_VALUE; - HANDLE dupOut = INVALID_HANDLE_VALUE; - HANDLE outTarget = INVALID_HANDLE_VALUE; - std::wstring outFile; // mode 3 only: the child's stdout file - bool outDrained = false, errDrained = false; - auto cleanup = [&]() { - if (outR != INVALID_HANDLE_VALUE) CloseHandle(outR); - if (outW != INVALID_HANDLE_VALUE) CloseHandle(outW); - if (dupOut != INVALID_HANDLE_VALUE) CloseHandle(dupOut); - if (errR != INVALID_HANDLE_VALUE) CloseHandle(errR); - if (errW != INVALID_HANDLE_VALUE) CloseHandle(errW); - }; - if (redirectStdio) { - if (outMode == 1) { - // Kernel-assigned: detcheck's own stdout; the child's stderr goes - // to detcheck's own stderr (both harness-captured). - outTarget = GetStdHandle(STD_OUTPUT_HANDLE); - } else if (outMode == 3) { - // Self-created FILE handle (temp file) as the child's stdout: - // if this delivers where self-created pipes do not, file capture - // is the scenario path. Read back after the child exits. - wchar_t tmp[512] = {}; - const DWORD tmpLen = GetTempPathW(512, tmp); - if (tmpLen == 0 || tmpLen >= 512) { - errOut = "GetTempPathW failed (lastError=" + - std::to_string(GetLastError()) + ")"; - cleanup(); - return false; - } - outFile = std::wstring(tmp, static_cast(tmpLen)) + - L"laige-detcheck-probe-" + - std::to_wstring(GetCurrentProcessId()) + L".tmp"; - const HANDLE f = CreateFileW(outFile.c_str(), GENERIC_WRITE, - FILE_SHARE_READ, &sa, CREATE_ALWAYS, - FILE_ATTRIBUTE_NORMAL, nullptr); - if (f == INVALID_HANDLE_VALUE) { - errOut = "CreateFileW failed (lastError=" + - std::to_string(GetLastError()) + ")"; - cleanup(); - return false; - } - outTarget = f; - } else { - if (!CreatePipe(&outR, &outW, &sa, 0)) { - errOut = "CreatePipe failed (lastError=" + - std::to_string(GetLastError()) + ")"; - cleanup(); - return false; - } - outDrained = true; - if (outMode == 2) { - // Re-create the write end as a fresh inheritable duplicate. - if (!DuplicateHandle(GetCurrentProcess(), outW, GetCurrentProcess(), - &dupOut, 0, TRUE, DUPLICATE_SAME_ACCESS)) { - errOut = "DuplicateHandle failed (lastError=" + - std::to_string(GetLastError()) + ")"; - cleanup(); - return false; - } - outTarget = dupOut; - } else { - outTarget = outW; - } - } - if (outMode != 1 && !CreatePipe(&errR, &errW, &sa, 0)) { - errOut = "CreatePipe(err) failed (lastError=" + - std::to_string(GetLastError()) + ")"; - cleanup(); - return false; - } - errDrained = (outMode != 1); - if (outDrained) { - SetHandleInformation(outW, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); - } - if (errDrained) { - SetHandleInformation(errW, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); - } - if (outMode == 1) { - diagf("laige-detcheck: probe handles out=kernel-fd1 (0x%p %s) " - "err=kernel-fd2 (0x%p %s)\n", - static_cast(outTarget), handleBits(outTarget).c_str(), - static_cast(GetStdHandle(STD_ERROR_HANDLE)), - handleBits(GetStdHandle(STD_ERROR_HANDLE)).c_str()); - } else if (outMode == 3) { - diagf("laige-detcheck: probe handles out=file 0x%p %s" - " | err r=0x%p w=0x%p type=%lu %s\n", - static_cast(outTarget), handleBits(outTarget).c_str(), - static_cast(errR), static_cast(errW), - GetFileType(errW), handleBits(errW).c_str()); - } else { - std::string dupNote; - if (outMode == 2) { - char nb[128]; - std::snprintf(nb, sizeof nb, " | wdup=0x%p %s", - static_cast(dupOut), - handleBits(dupOut).c_str()); - dupNote = nb; - } - diagf("laige-detcheck: probe handles out r=0x%p w=0x%p type=%lu %s%s" - "| err r=0x%p w=0x%p type=%lu %s\n", - static_cast(outR), static_cast(outW), - GetFileType(outW), handleBits(outTarget).c_str(), - dupNote.c_str(), static_cast(errR), - static_cast(errW), GetFileType(errW), - handleBits(errW).c_str()); - } - } - STARTUPINFOW si{}; - si.cb = sizeof si; - si.dwFlags = redirectStdio ? STARTF_USESTDHANDLES : 0; - if (redirectStdio) { - si.hStdOutput = outTarget; - si.hStdError = outMode == 1 ? GetStdHandle(STD_ERROR_HANDLE) : errW; - } - si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); - PROCESS_INFORMATION pi{}; - if (!CreateProcessW(appname, cmd.data(), nullptr, nullptr, TRUE, 0, - nullptr, nullptr, &si, &pi)) { - errOut = "CreateProcessW failed (lastError=" + - std::to_string(GetLastError()) + ")"; - cleanup(); - return false; - } - // The child now owns the write ends; drop our copies. - if (outW != INVALID_HANDLE_VALUE) CloseHandle(outW); - if (dupOut != INVALID_HANDLE_VALUE) CloseHandle(dupOut); - if (errW != INVALID_HANDLE_VALUE) CloseHandle(errW); - if (redirectStdio && (outDrained || errDrained)) { - auto drain = [](HANDLE r, std::string& out) { - char buf[256]; - for (;;) { - if (WaitForSingleObject(r, INFINITE) != WAIT_OBJECT_0) break; - DWORD n = 0; - if (!PeekNamedPipe(r, buf, sizeof buf, &n, nullptr, nullptr)) break; - if (n == 0) break; - DWORD got = 0; - if (!ReadFile(r, buf, n, &got, nullptr)) break; - out.append(buf, got); - } - CloseHandle(r); - }; - if (outDrained) drain(outR, capturedOut); - if (errDrained) drain(errR, capturedErr); - } - WaitForSingleObject(pi.hProcess, INFINITE); - exitCode = 0; - GetExitCodeProcess(pi.hProcess, &exitCode); - // Mode 3: the child's stdout landed in a file; read it back now that - // the child has exited (all of its writes are complete). - if (!outFile.empty()) { - const HANDLE rf = CreateFileW(outFile.c_str(), GENERIC_READ, - FILE_SHARE_READ, nullptr, OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL, nullptr); - if (rf != INVALID_HANDLE_VALUE) { - char buf[4096]; - for (;;) { - DWORD n = 0; - if (!ReadFile(rf, buf, sizeof buf, &n, nullptr) || n == 0) break; - capturedOut.append(buf, n); - } - CloseHandle(rf); - } - DeleteFileW(outFile.c_str()); - } - CloseHandle(pi.hThread); - CloseHandle(pi.hProcess); - return true; -} - -// Control probe (diagnostic): in this process instance, launch known-good -// children through the exact machinery used for scenario runs and record -// what each one received: -// -// attempt 1: laige-detcheck-probe (the diagnostic helper from our own -// module directory) — its stderr self-reports the std -// handles the kernel assigned to it (value/type/flags), -// captured in capturedErr; its stdout marker is captured in -// capturedOut. -// attempt 2: same helper, explicit lpApplicationName. -// attempt 3: cmd.exe /c echo marker — the classic control. -// attempt 4: same helper, NO STARTUPINFO redirection — plain handle -// inheritance only (stdout follows detcheck's own fd1). -// attempt 5: same helper, STARTUPINFO with detcheck's OWN (kernel- -// assigned) fd1/fd2 — if this delivers while 1-3 do not, -// the failure is specific to handles this process created. -// (Success shows up as an extra PROBE_HELPER_OK line in -// detcheck's own stdout and an extra self-report line in -// its stderr; capturedOut/capturedErr stay empty by design.) -// attempt 6: same helper, STARTUPINFO with a fresh pipe whose write -// end was re-created via DuplicateHandle(dwInheritable) — -// tests whether a freshly duplicated inheritable handle is -// delivered where the original pipe handle is not. -// attempt 7: same helper, STARTUPINFO with a self-created FILE handle -// (temp file) as the child's stdout — if this delivers -// where self-created pipes do not (run 24: kernel-assigned -// handles delivered, self-created pipes not, even when -// duplicated), file capture is the scenario path. Success -// shows up as PROBE_HELPER_OK inside capturedOut. -void probeControlSpawn() { - // The helper lives next to us in the build bin directory. - wchar_t mod[1024] = {}; - const DWORD modLen = GetModuleFileNameW(nullptr, mod, 1024); - std::wstring helper; - if (modLen > 0 && modLen < 1024) { - const std::wstring m(mod, modLen); - const std::size_t slash = m.find_last_of(L'\\'); - if (slash != std::wstring::npos) { - helper = m.substr(0, slash + 1) + L"laige-detcheck-probe.exe"; - } - } - // Non-const so .data() yields wchar_t* for CreateProcessW (C++20). - const std::wstring helperCmd = L"\"" + helper + L"\""; - const std::wstring controlCmd = L"cmd.exe /c echo LAIGE_DETCHECK_CONTROL_OK"; - auto runAttempt = [&](int n, const wchar_t* app, const std::wstring& cmd, - bool redirect, int outMode, const char* desc) { - DWORD code = 0; - std::string out, err, why; - const bool ok = probeAttempt(app, cmd, redirect, outMode, code, out, err, - why); - diagf("laige-detcheck: control probe attempt %d (%s): spawned=%d " - "exit=%lu out='%s' err='%s' why=%s\n", - n, desc, ok ? 1 : 0, static_cast(code), out.c_str(), - err.c_str(), why.c_str()); - }; - if (!helper.empty()) { - runAttempt(1, nullptr, helperCmd, true, 0, "helper, cmdline path"); - runAttempt(2, helper.c_str(), helperCmd, true, 0, "helper, explicit app"); - // Kernel-assigned handles through STARTUPINFO: if this one delivers - // while the fresh-pipe ones do not, the failure is specific to - // handles this process created itself. - runAttempt(5, nullptr, helperCmd, true, 1, - "helper, kernel fd1/fd2 via STARTUPINFO"); - // Fresh pipe whose write end is re-created as an inheritable - // duplicate: if this delivers where the original did not, use the - // DuplicateHandle path for scenario capture. - runAttempt(6, nullptr, helperCmd, true, 2, - "helper, dup handle via STARTUPINFO"); - // Self-created FILE handle as the child's stdout (temp file, read - // back after the child exits): if this delivers where self-created - // pipes do not, file capture is the scenario path. - runAttempt(7, nullptr, helperCmd, true, 3, - "helper, file via STARTUPINFO"); - } - runAttempt(3, nullptr, controlCmd, true, 0, "cmd marker"); - runAttempt(4, nullptr, helperCmd, false, 0, "helper, no redirect (control)"); -} - -// Best-effort parent-process identification (M0-TEST-01 Windows CI -// diagnosis): a diag file can be written by a detcheck invocation OTHER -// than the check script's; the parent's pid and command line say who -// launched this process. NtQueryInformationProcess is resolved through -// GetProcAddress (no new import; both process-info classes are stable on -// x64). Every step is best-effort: any failure yields (unavailable). -std::string parentProcessInfo() { - struct Pbi { - long exitStatus; - void* peb; - unsigned long long affinity; - unsigned char priority; - unsigned long pid; - unsigned long long inheritedFrom; - } pbi = {}; - typedef long(* NtQIP_t)(HANDLE, int, void*, unsigned long, unsigned long*); - HMODULE ntdll = GetModuleHandleW(L"ntdll.dll"); - if (!ntdll) return "parent=(unavailable)"; - const NtQIP_t ntq = reinterpret_cast( - GetProcAddress(ntdll, "NtQueryInformationProcess")); - if (!ntq) return "parent=(unavailable)"; - if (ntq(GetCurrentProcess(), 0, &pbi, sizeof pbi, nullptr) != 0) { - return "parent=(unavailable)"; - } - std::string s = "parent=" + - std::to_string(static_cast(pbi.inheritedFrom)); - std::string cmd; - HANDLE ph = OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, - static_cast(pbi.inheritedFrom)); - if (ph) { - struct Us { - unsigned short length; - unsigned short maximumLength; - wchar_t* buffer; - } us = {}; - unsigned long need = 0; - if (ntq(ph, 60, &us, sizeof us, &need) != 0 && need > sizeof us) { - std::vector buf(need / 2 + 1, 0); - us.buffer = buf.data(); - us.maximumLength = static_cast(need); - if (ntq(ph, 60, &us, sizeof us, nullptr) == 0 && us.length > 0) { - const int n = WideCharToMultiByte(CP_UTF8, 0, buf.data(), - static_cast(us.length / 2), - nullptr, 0, nullptr, nullptr); - if (n > 0) { - cmd.resize(static_cast(n)); - WideCharToMultiByte(CP_UTF8, 0, buf.data(), - static_cast(us.length / 2), cmd.data(), n, - nullptr, nullptr); - } - } - } - CloseHandle(ph); - } - s += cmd.empty() ? " parent-cmd=(unavailable)" - : " parent-cmd=\"" + cmd + "\""; - return s; -} - -#else // POSIX (Linux, macOS) - -// POSIX counterpart of the Windows parent-process identification: the -// parent pid plus its /proc command line (Linux; macOS has no /proc, so -// only the pid is reported there). -std::string parentProcessInfo() { - const int pp = static_cast(::getppid()); - std::string s = "parent=" + std::to_string(pp); - char path[128]; - std::snprintf(path, sizeof path, "/proc/%d/cmdline", pp); - std::FILE* f = std::fopen(path, "rb"); - if (!f) { - return s + " parent-cmd=(unavailable)"; - } - char buf[1024]; - const std::size_t n = std::fread(buf, 1, sizeof buf, f); - std::fclose(f); - std::string cmd(buf, n); - for (char& c : cmd) { - if (c == '\0') c = ' '; - } - while (!cmd.empty() && cmd.back() == ' ') cmd.pop_back(); - s += cmd.empty() ? " parent-cmd=(unavailable)" - : " parent-cmd=\"" + cmd + "\""; - return s; -} - -#endif // _WIN32 +#endif // defined(_WIN32) // --- Scenario spawn with markers (mode 2, phase 1) ------------------------- // @@ -859,18 +360,6 @@ std::string parentProcessInfo() { int spawnScenarioWithMarkers(const std::string& exe, const std::vector& args, const std::string& label, std::string& error) { - // The env dump and control probes describe THIS process instance; run - // them once per process and BEFORE the first BEGIN marker, so their - // (probe) stdout, if any, precedes the marker windows and never lands - // inside an extracted run stream. - static bool probed = false; - if (!probed) { -#ifdef _WIN32 - dumpEnvironmentDiagnostics(); - probeControlSpawn(); -#endif - probed = true; - } // The BEGIN marker must reach the capture BEFORE the child is born, so // the child's ticks (on the same stream) cannot precede it. std::printf("@@DETCHK-RUN-%s-BEGIN@@\n", label.c_str()); @@ -889,10 +378,10 @@ int spawnScenarioWithMarkers(const std::string& exe, // Zeroed STARTUPINFO (no dwFlags): the child inherits this process's // standard handles (the harness's capture). No self-created handle // is involved - the kind the CI Windows runner never delivers (see - // above). A NULL STARTUPINFO is NOT used: the runner's CreateProcess - // machinery fails on it (measured in the M0-TEST-01 CI, run - // 34732057356: the probe's no-redirect control passes a zeroed - // STARTUPINFO and spawns fine, while the NULL form fails). + // above). A zeroed STARTUPINFO is passed rather than NULL: on the + // CI Windows runner the NULL form makes CreateProcessW fail while + // the zeroed form spawns fine (measured in the M0-TEST-01 CI, run + // 34732057356) - do not "simplify" this to NULL. STARTUPINFOW si{}; si.cb = sizeof si; PROCESS_INFORMATION pi{}; @@ -901,8 +390,6 @@ int spawnScenarioWithMarkers(const std::string& exe, error = "CreateProcessW failed (lastError=" + std::to_string(GetLastError()) + "): " + exe; } else { - diagf("laige-detcheck: spawned run-%s child pid=%lu (inherit stdio)\n", - label.c_str(), static_cast(pi.dwProcessId)); // Wait for termination BEFORE reading the exit code // (GetExitCodeProcess on a live process returns STILL_ACTIVE). if (WaitForSingleObject(pi.hProcess, INFINITE) != WAIT_OBJECT_0) { @@ -1154,11 +641,7 @@ CompareResult compareStreams(const std::vector& a, void report(std::string_view scenarioName, const CompareResult& c, std::size_t ticks, std::string_view labelA, std::string_view labelB) { - auto emit = [](const char* line) { - std::printf("%s\n", line); - g_diag.append(line); - g_diag += "\n"; - }; + auto emit = [](const char* line) { std::printf("%s\n", line); }; if (c.ok) { char buf[256]; std::snprintf(buf, sizeof buf, @@ -1304,29 +787,6 @@ bool parseArgs(int argc, char** argv, Args& a) { } // namespace int main(int argc, char** argv) { - // Flush the accumulated diagnostics to $LAIGE_DETCHECK_DIAG_FILE on - // every exit path (ctest hides passing-test output; the file is how - // those diagnostics reach the CI log). - DiagFlush diagFlush; - // Diagnostic begin marker (identifies the run and its writer in the - // flushed file: a diag file can be overwritten by a later detcheck - // invocation with the same LAIGE_DETCHECK_DIAG_FILE). - { - std::string cmdLine; - for (int i = 0; i < argc; ++i) { - if (i > 0) cmdLine += " "; - cmdLine += argv[i]; - } -#ifdef _WIN32 - diagf("laige-detcheck: diag begin pid=%lu %s argv=%s\n", - static_cast(GetCurrentProcessId()), - parentProcessInfo().c_str(), cmdLine.c_str()); -#else - diagf("laige-detcheck: diag begin pid=%d %s argv=%s\n", - static_cast(::getpid()), parentProcessInfo().c_str(), - cmdLine.c_str()); -#endif - } Args a; if (!parseArgs(argc, argv, a)) { printUsage(stderr); @@ -1413,12 +873,12 @@ int main(int argc, char** argv) { } const std::string errA = validateStream(linesA, kMaxTicks); if (!errA.empty()) { - diagf("laige-detcheck: scenario run-a: %s\n", errA.c_str()); + std::fprintf(stderr, "laige-detcheck: scenario run-a: %s\n", errA.c_str()); return 2; } const std::string errB = validateStream(linesB, kMaxTicks); if (!errB.empty()) { - diagf("laige-detcheck: scenario run-b: %s\n", errB.c_str()); + std::fprintf(stderr, "laige-detcheck: scenario run-b: %s\n", errB.c_str()); return 2; } const CompareResult c = compareStreams(linesA, linesB); @@ -1437,11 +897,11 @@ int main(int argc, char** argv) { const int codeA = spawnScenarioWithMarkers(a.runA, a.positionals, "A", errA); if (codeA < 0) { - diagf("laige-detcheck: scenario run-a: %s\n", errA.c_str()); + std::fprintf(stderr, "laige-detcheck: scenario run-a: %s\n", errA.c_str()); return 2; } if (codeA != 0) { - diagf("laige-detcheck: scenario run-a: scenario process exited with " + std::fprintf(stderr, "laige-detcheck: scenario run-a: scenario process exited with " "code %d (command: %s)\n", codeA, a.runA.c_str()); return 2; @@ -1450,11 +910,11 @@ int main(int argc, char** argv) { const int codeB = spawnScenarioWithMarkers(a.runB, a.positionals, "B", errB); if (codeB < 0) { - diagf("laige-detcheck: scenario run-b: %s\n", errB.c_str()); + std::fprintf(stderr, "laige-detcheck: scenario run-b: %s\n", errB.c_str()); return 2; } if (codeB != 0) { - diagf("laige-detcheck: scenario run-b: scenario process exited with " + std::fprintf(stderr, "laige-detcheck: scenario run-b: scenario process exited with " "code %d (command: %s)\n", codeB, a.runB.c_str()); return 2; diff --git a/tools/detcheck/probe-helper.cpp b/tools/detcheck/probe-helper.cpp deleted file mode 100644 index 1bb19af..0000000 --- a/tools/detcheck/probe-helper.cpp +++ /dev/null @@ -1,35 +0,0 @@ -// laige-detcheck-probe (Windows only) — diagnostic child for -// laige-detcheck (M0-TEST-01 Windows CI investigation). -// -// laige-detcheck spawns this with its stdout and stderr each connected -// to a capture pipe (or with no redirection at all, as the control -// attempt). The helper reports the standard handles the kernel actually -// assigned to it — value, type, flags — and writes a marker line to -// stdout. The parent compares that report against the handles it asked -// for in its STARTUPINFO, which shows exactly what CreateProcessW -// delivered in the working versus the broken launch instances. -// -// fd1 types: 1 = character device (console/NUL), 2 = disk file, -// 3 = pipe, 0 = unknown (e.g. INVALID_HANDLE_VALUE). - -#include - -#include - -static void report(const char* name, HANDLE h) { - DWORD flags = 0; - GetHandleInformation(h, &flags); - const unsigned long long hv = reinterpret_cast(h); - std::fprintf(stderr, " %s=0x%llx type=%lu flags=0x%lx", name, hv, - GetFileType(h), flags); -} - -int main() { - std::fprintf(stderr, "probe-helper:"); - report("fd0", GetStdHandle(STD_INPUT_HANDLE)); - report("fd1", GetStdHandle(STD_OUTPUT_HANDLE)); - report("fd2", GetStdHandle(STD_ERROR_HANDLE)); - std::fprintf(stderr, "\n"); - std::printf("PROBE_HELPER_OK\n"); - return 0; -}