Skip to content

[M0-TEST-01] Test infrastructure conventions: docs/testing.md + seed handling + SeededRandom KAT suite + fuzz lane semantics - #13

Merged
offdev merged 27 commits into
masterfrom
m0-test-01-test-infra
Sep 13, 2026
Merged

offdev merged 27 commits into
masterfrom
m0-test-01-test-infra

Conversation

@offdev

@offdev offdev commented Sep 12, 2026 •

Copy link
Copy Markdown
Owner

Implements roadmap/M0-foundations.md step M0-TEST-01 (test infrastructure conventions), and — via the established provenance pattern — the Windows CI runtime fix for M0-TOOL-02's laige-detcheck that this step's verification exposed.

What (M0-TEST-01)

  • docs/testing.md (new) — the normative conventions doc, linked from docs/README.md, tests/README.md, building.md, README.md:
    • GTest integration: one test dir per src/ module, <module>_tests executables, per-suite CTest entries via unquoted --gtest_filter, GoogleTest dev-only (ADR 0004), non-module dirs (tests/tools, tests/api, tests/detcheck, tests/support, tests/testing).
    • Regression tests: every bug-fix test named regress_<short-id>, must fail before the fix (AGENTS TEST-003).
    • laige-fuzz: target registration, CI lane semantics (bounded --runs=1000 in every P0 job's ctest = PRD §14 "every commit (bounded)"), nightly long-run form (--runs=1000000; scheduled lane lands with the first M1 fuzz target).
    • Seed handling for randomized tests: fixed default seed, overridable, CI-deterministic.
  • tests/support/laige_test_seed.h — TestSeed() returns the fixed default 0x1F055EED (identical to laige-fuzz's kDefaultSeed — one documented default seed repo-wide) unless LAIGE_TEST_SEED is set (0x-hex/decimal); a set-but-unparseable value records a loud test failure (CORE-008) and falls back to the default; TestPrng(id) derives a per-test substream from a stable named id.
  • tests/testing/ — test_infra_tests + CTest entry test_infra, suite SeededRandom (6 cases): 65536-draw FNV-1a KATs under the default and override seeds, first-8-draw KAT, default/fuzz seed identity, the loud invalid-env path (EXPECT_NONFATAL_FAILURE from gtest-spi.h), seed parser, substream isolation. Each KAT prints a machine-greppable test-seed-check line before asserting — the byte-identical-across-two-CI-runs identity check (the step's Verify clause).
  • First regress_ test: M0-CORE-08's ConfigJsonValid.ObjectMemberWhitespace renamed to ConfigJsonValid.regress_json_object_member_ws (suite unchanged; ctest -R config_json still covers it; historical M0-CORE-08 record unchanged).
  • CI workflow headers note the fuzz lane (no new job — the bounded run is the existing fuzz_json_parse ctest entry in every P0 job).

Windows CI fix chain (M0-TOOL-02 runtime, fixed in this PR)

The step's cross-run verification required the Windows CI lane to be trustworthy; observing it exposed two M0-TOOL-02 defects (the step record's "Windows path was compile-verified" claim was stale — corrected in roadmap/README.md):

  1. Dead fragment assertions: the generated check scripts never generated their output-fragment checks (@CHECKS@ variable-name misspelling + CMake single-backslash string stripping eating the regex escapes) — fixed in 7928379.
  2. Broken mode-2 capture on the Windows CI runner: the windows-2022 runner 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). Additionally, NULL STARTUPINFO is rejected by the runner's CreateProcess machinery (run 34732057356). Fixed by two-stage marker capture on all platforms (--run-a/--run-b phase 1 spawns each scenario child with plain stdout inheritance, delimited by @@DETCHK-RUN-A/B-BEGIN/END@@ markers; --compare-combined=<file> phase 2 splits/validates/compares) + the zeroed-STARTUPINFO spawn (c8b8221). All diagnostics used for the diagnosis were stripped after the fix verified (d57bb79).

Verified

  • Local (2026-09-12/13): 32/32 ctest, zero warnings under the NFR-8.10 policy, on g++ 16.2.1 (build, build-shared, build-asan, build-tsan) and Clang 22.1.8 (build-clang); ctest -R test_infra, ctest -R fuzz_json_parse green in every tree; laige-fuzz json_parse --runs=1000 clean.
  • Cross-CI-run seed identity (the step's Verify clause): byte-identical test-seed-check lines in the archived Linux ASan Testing/Temporary/LastTest.log across runs 34713354925/34714039135 (same-commit pair) and again across commits e2abce5→c8b8221 (runs 34728782624/34746755055):
    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 on the final commit d57bb79: dispatch run 34747610136 — all 10 jobs green, Windows 32/32; the push-triggered ci-pull run 34747612216 — green (macOS/Windows label-gated, skipped as designed).

Notes

  • The repo was made public at the owner's direction during this step: GitHub Actions on the private repo stopped scheduling jobs mid-session (private-repo Actions minutes/entitlement), and public visibility is the unblocking change the owner selected.
  • No ENGINE-RULE-EXCEPTION added or retained.
  • Untested path: the Windows CreateProcessW branch is CI-verified only (no MSVC/mingw locally); the two-stage flow is verified end-to-end on POSIX locally and on Windows in CI.

…andom KAT suite + fuzz lane docs

- docs/testing.md: normative test conventions (layout mirroring src/,
  <module>_tests executables, regress_<short-id> 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
…angelog

- 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)
…portability

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).
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.
…failures

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).
…dows 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).
…xit code

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).
…cause)

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).
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.
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).
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.
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.
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.
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.
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.
…, 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 <name>.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.
- 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).
…ouble-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.
…agnosis

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).
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.
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-<A|B>-BEGIN@@ / @@DETCHK-RUN-<A|B>-END <exitcode>@@.
    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=<file>): 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 <diag>.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).
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).
…ified

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 (<cstdarg>/<cerrno>/<cstring>).
- 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).
@offdev
offdev merged commit 7b8719e into master Sep 13, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant