From 08480710ac2634af6c617376dcd48c89a8fbfd23 Mon Sep 17 00:00:00 2001 From: Sagiri Rium <2761125079@qq.com> Date: Wed, 9 Sep 2026 16:48:52 +0800 Subject: [PATCH 1/2] test: correlate original MSVC invocation results with calibrated PDB events Add explicit invocation-only QPC/owner begin/end records and read-only outcome correlation using the accepted ETW recorder. Preserve concurrent child ambiguity and shared-service RPC limits. Run a predeclared eight-case PCH Release/Modules Debug study with original workloads and RM queries, retaining the four-case recorder regression and negative readiness controls. Original errors and recovery outcomes remain separate; a complete record never makes a failed compiler control pass. Product source, scheduler, benchmark, VERSION5.5.0 and historical held PRs unchanged. --- .github/workflows/pdb-file-trace.yml | 44 ++- .../windows/msvc_service_ownership_probe.cpp | 70 +++- tests/native/collect_pdb_file_trace.ps1 | 52 ++- tests/native/verify_pdb_invocations.py | 335 ++++++++++++++++++ 4 files changed, 476 insertions(+), 25 deletions(-) create mode 100644 tests/native/verify_pdb_invocations.py diff --git a/.github/workflows/pdb-file-trace.yml b/.github/workflows/pdb-file-trace.yml index bb1a7757..4ee7df35 100644 --- a/.github/workflows/pdb-file-trace.yml +++ b/.github/workflows/pdb-file-trace.yml @@ -4,6 +4,8 @@ on: pull_request: paths: - 'cpp/tests/platform/windows/pdb_file_event_probe.cpp' + - 'cpp/tests/platform/windows/msvc_service_ownership_probe.cpp' + - 'tests/native/verify_pdb_invocations.py' - 'tests/native/collect_pdb_file_trace.ps1' - 'tests/native/verify_pdb_file_trace.py' - '.github/workflows/pdb-file-trace.yml' @@ -27,6 +29,8 @@ jobs: run: | python tests/native/verify_pdb_file_trace.py --self-test --output .mqb/file-trace-contracts.json if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python tests/native/verify_pdb_invocations.py --self-test --output .mqb/invocation-contracts.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $tokens=$null; $errors=$null [void][Management.Automation.Language.Parser]::ParseFile( (Join-Path $PWD 'tests/native/collect_pdb_file_trace.ps1'),[ref]$tokens,[ref]$errors) @@ -35,7 +39,9 @@ jobs: if: always() with: name: file-trace-contracts - path: .mqb/file-trace-contracts.json + path: | + .mqb/file-trace-contracts.json + .mqb/invocation-contracts.json include-hidden-files: true retention-days: 30 build: @@ -120,3 +126,39 @@ jobs: include-hidden-files: true if-no-files-found: warn retention-days: 30 + + investigate: + name: Eight fixed historical-profile invocation traces + needs: [build, contract] + # Different disposable VM; never compile tools on the observation host. + runs-on: windows-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: actions/download-artifact@v8 + with: + name: file-trace-input + path: native-input + - shell: pwsh + env: + MQB_OWNERSHIP_DISPOSABLE_HOST: '1' + run: | + & ./tests/native/collect_pdb_file_trace.ps1 -InputRoot (Join-Path $PWD 'native-input') ` + -OutputRoot (Join-Path $PWD '.mqb/pdb-invocation-trace') -RepoRoot $PWD -Study invocations + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Preserve raw ETL, decoded events and original diagnostics on every outcome + uses: actions/upload-artifact@v7 + if: always() + with: + name: pdb-invocation-evidence + path: | + .mqb/pdb-invocation-trace/** + !.mqb/pdb-invocation-trace/**/*.obj + !.mqb/pdb-invocation-trace/**/*.pdb + !.mqb/pdb-invocation-trace/**/*.pch + !.mqb/pdb-invocation-trace/**/*.exe + include-hidden-files: true + if-no-files-found: warn + retention-days: 30 diff --git a/cpp/tests/platform/windows/msvc_service_ownership_probe.cpp b/cpp/tests/platform/windows/msvc_service_ownership_probe.cpp index a7ed2fc9..3a455ccc 100644 --- a/cpp/tests/platform/windows/msvc_service_ownership_probe.cpp +++ b/cpp/tests/platform/windows/msvc_service_ownership_probe.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -388,6 +389,45 @@ void save_result(const fs::path& dir, const std::string& label, const RunResult& + std::to_string(result.error().native_code) + "}\n"); } } +// Set only by explicit investigation entry points, before launching any worker. +// Each helper is a separate process. Normal matrices do not take new timestamps. +bool record_invocation_spans = false; +std::uint64_t invocation_tick() { + LARGE_INTEGER value{}; + require(::QueryPerformanceCounter(&value) && value.QuadPart >= 0, "invocation QPC failed"); + return static_cast(value.QuadPart); +} +struct InvocationSpan { + fs::path directory; + std::string label; + bool enabled{record_invocation_spans}; + std::uint64_t begin{}; + InvocationSpan(const fs::path& dir, const std::string& name, const fs::path& executable) + : directory(dir), label(name) { + if (!enabled) return; + LARGE_INTEGER frequency{}; + FILETIME created{}, exited{}, kernel{}, user{}; + require(::QueryPerformanceFrequency(&frequency) && frequency.QuadPart > 0, "invocation QPF failed"); + require(::GetProcessTimes(::GetCurrentProcess(), &created, &exited, &kernel, &user), "invocation owner identity failed"); + begin = invocation_tick(); + write(directory / (label + ".invocation-begin.json"), + "{\"schema\":1,\"clock\":\"QPC\",\"label\":" + (quoted)(label) + + ",\"executable\":" + (quoted)(path_text(executable)) + + ",\"owner_pid\":" + std::to_string(::GetCurrentProcessId()) + + ",\"owner_created_filetime\":" + std::to_string(ticks(created)) + + ",\"frequency\":" + std::to_string(frequency.QuadPart) + + ",\"before_run_qpc\":" + std::to_string(begin) + "}\n"); + } + void finish(std::uint64_t returned, const RunResult& result) const { + if (!enabled) return; + write(directory / (label + ".invocation-end.json"), + "{\"schema\":1,\"before_run_qpc\":" + std::to_string(begin) + + ",\"after_run_qpc\":" + std::to_string(returned) + + ",\"infrastructure_error\":" + (result ? "false" : "true") + + ",\"exit_code\":" + (result ? std::to_string(result->exit_code) : "null") + + ",\"safe_to_transfer_write_lease\":false}\n"); + } +}; RunResult invoke(const MsvcToolchain& toolchain, const fs::path& executable, const fs::path& dir, const std::string& label, std::vector arguments) { ProcessSpec spec; @@ -399,8 +439,11 @@ RunResult invoke(const MsvcToolchain& toolchain, const fs::path& executable, for (const auto& arg : spec.arguments) command += arg + '\n'; write(dir / (label + ".argv.txt"), command); WindowsProcessRunner runner; + InvocationSpan span{dir, label, executable}; auto result = runner.run(spec); // Deliberately unmanaged real compiler / linker. - save_result(dir, label, result); + save_result(dir, label, result); // Original diagnostics survive a later timestamp/span-write failure. + const auto returned = span.enabled ? invocation_tick() : 0; // Includes diagnostic serialization. + span.finish(returned, result); return result; } int exit_code(const RunResult& r) { return r ? r->exit_code : -1; } @@ -508,13 +551,18 @@ int root(int argc, wchar_t** argv, bool drain) { return stopped && exit_code(first) == 0 ? 0 : 1; } -int measure(int argc, wchar_t** argv, bool default_endpoint, bool pdb_study = false) { +int measure(int argc, wchar_t** argv, bool default_endpoint, bool pdb_study = false, bool failure_study = false) { require(argc == (pdb_study ? 8 : 7), "measure arguments"); if (default_endpoint) require_default_host(); const fs::path dir = fs::absolute(argv[2]); const std::string profile = utf8(argv[3]), origin = utf8(argv[4]), ending = utf8(argv[5]); const std::wstring fixture_id = argv[6]; const bool drain = ending == "drain"; + if (failure_study) { + require(default_endpoint && !pdb_study && origin == "A-started" && drain && + (profile == "pch-release" || profile == "modules-debug"), "unsupported invocation study case"); + record_invocation_spans = true; + } const bool query_enabled = !pdb_study || std::wstring{argv[7]} == L"rm-on"; if (pdb_study) { require(default_endpoint && profile == "pch-release" && origin == "A-started" && drain, @@ -556,7 +604,7 @@ int measure(int argc, wchar_t** argv, bool default_endpoint, bool pdb_study = fa std::stop_source stop; ProcessSpec a; a.executable = self(); - a.arguments = {drain ? "--drain-root" : "--root", path_text(tc.identity.compiler), path_text(dir / "A"), profile, + a.arguments = {failure_study ? "--invocation-drain-root" : (drain ? "--drain-root" : "--root"), path_text(tc.identity.compiler), path_text(dir / "A"), profile, utf8(ready_name), utf8(release_name)}; if (drain) a.arguments.push_back(utf8(cancel_name)); a.environment = tc.environment; @@ -603,7 +651,7 @@ int measure(int argc, wchar_t** argv, bool default_endpoint, bool pdb_study = fa return result; }; auto warm_owners = resource_owners(dir / "B/compiler.pdb"); - if (pdb_study) pdb_snapshot(dir, "before-A"); + if (pdb_study || failure_study) pdb_snapshot(dir, "before-A"); workload(dir / "B", profile); std::vector active_a; if (drain) { @@ -644,7 +692,7 @@ int measure(int argc, wchar_t** argv, bool default_endpoint, bool pdb_study = fa else require(::SetEvent(release.value) != FALSE, "release A failed"); auto a_result = a_future.get(); const auto settled = std::chrono::steady_clock::now(); - if (pdb_study) pdb_snapshot(dir, "after-A"); + if (pdb_study || failure_study) pdb_snapshot(dir, "after-A"); const bool service_survived = alive(server.handle.value); // The surviving service can still own PDB resources after the A client exits. // An empty snapshot is not a durable no-writer certificate either. @@ -652,7 +700,7 @@ int measure(int argc, wchar_t** argv, bool default_endpoint, bool pdb_study = fa const bool a_handles_signaled = std::all_of(active_a.begin(), active_a.end(), [](const auto& p) { return !alive(p.handle.value); }); const bool pending_dispatched = fs::exists(dir / "A/work1.argv.txt"); auto b0_result = b0.get(), b1_result = b1.get(); - if (pdb_study) pdb_snapshot(dir, "after-B"); + if (pdb_study || failure_study) pdb_snapshot(dir, "after-B"); int linked = -2, executed = -2; if (exit_code(b0_result) == 0 && exit_code(b1_result) == 0) { std::vector args{"/NOLOGO", "/DEBUG", "/INCREMENTAL:NO", "/OUT:" + path_text(dir / "B/result.exe"), @@ -723,11 +771,17 @@ int wmain(int argc, wchar_t** argv) { require(argc == 3, "identity test arguments"); return pdb_identity_self_test(fs::absolute(argv[2])); } if (mode == L"--evidence-contract-self-test") return evidence_contract_self_test(); + if (mode == L"--invocation-drain-root") { + record_invocation_spans = true; + return root(argc, argv, true); + } + if (mode == L"--measure-invocations") return measure(argc, argv, true, false, true); if (mode == L"--root" || mode == L"--drain-root") return root(argc, argv, mode == L"--drain-root"); if (mode == L"--measure-pdb") return measure(argc, argv, true, true); if (mode == L"--measure" || mode == L"--measure-default") return measure(argc, argv, mode == L"--measure-default"); const bool pdb_study = mode == L"--pdb-case"; - const bool default_endpoint = mode == L"--default-case" || pdb_study; + const bool failure_study = mode == L"--invocation-case"; + const bool default_endpoint = mode == L"--default-case" || pdb_study || failure_study; require((mode == L"--case" || default_endpoint) && argc == (pdb_study ? 8 : 7), "case arguments"); const fs::path dir = fs::absolute(argv[2]); fs::create_directories(dir); @@ -743,7 +797,7 @@ int wmain(int argc, wchar_t** argv) { // No global service termination, PID-authorized kill, or product changes. ProcessSpec spec; spec.executable = self(); - spec.arguments = {pdb_study ? "--measure-pdb" : (default_endpoint ? "--measure-default" : "--measure")}; + spec.arguments = {failure_study ? "--measure-invocations" : (pdb_study ? "--measure-pdb" : (default_endpoint ? "--measure-default" : "--measure"))}; for (int i = 2; i < argc; ++i) spec.arguments.push_back(utf8(argv[i])); std::stop_source lifetime; spec.cancellation = lifetime.get_token(); diff --git a/tests/native/collect_pdb_file_trace.ps1 b/tests/native/collect_pdb_file_trace.ps1 index 4380e421..6ada00c6 100644 --- a/tests/native/collect_pdb_file_trace.ps1 +++ b/tests/native/collect_pdb_file_trace.ps1 @@ -2,7 +2,8 @@ param( [Parameter(Mandatory)][string]$InputRoot, [Parameter(Mandatory)][string]$OutputRoot, - [string]$RepoRoot = (Join-Path $PSScriptRoot '../..') + [string]$RepoRoot = (Join-Path $PSScriptRoot '../..'), + [ValidateSet('calibration', 'invocations')][string]$Study = 'calibration' ) $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $false @@ -41,8 +42,13 @@ Write-Json (Join-Path $OutputRoot 'identity.json') @{ # Complete plan before the first native capture. Neither outcomes nor runtime # affect this budget. Boundary calibration files are not compiler PDB failures. $plan = @('rm-on','rm-off','rm-off','rm-on') +if ($Study -eq 'invocations') { + $plan = @('pch-release','modules-debug','modules-debug','pch-release', + 'pch-release','modules-debug','modules-debug','pch-release') +} +$expected = $plan.Count Write-Json (Join-Path $OutputRoot 'plan.json') @{ - modes=$plan; cases=4; pairs=2; controlled_conflicts_per_trace=2; adaptive_retries=$false + study=$Study; modes=$plan; cases=$expected; pairs=($expected/2); controlled_conflicts_per_trace=2; adaptive_retries=$false readiness_wait_limit_ms=10000; negative_readiness_cases=1 note='Positive native sharing conflicts are separate from original MSVC outcomes; no forced C1041.' } @@ -54,7 +60,7 @@ New-Item -ItemType Directory -Path $negative | Out-Null $negativeTrace = Join-Path $negative 'trace' $sentinel = Join-Path $negative 'child-was-admitted.txt' Write-Json (Join-Path $OutputRoot 'summary.json') @{ - expected=4; completed=0; cases=@(); not_run=4; negative_readiness_verified=$false + expected=$expected; completed=0; cases=@(); not_run=$expected; negative_readiness_verified=$false historical_cause_resolved=$false; authorizes_held_pr_merge=$false; safe_to_transfer_write_lease=$false } $negativeArguments = @('--test-withhold-file-provider',$negativeTrace,$tracer,'--test-child-sentinel',$sentinel) @@ -87,48 +93,62 @@ foreach ($definition in @( } } $rows = [Collections.Generic.List[object]]::new() -foreach ($index in 0..3) { +foreach ($index in 0..($expected-1)) { $mode = $plan[$index]; $name = ('{0:D2}-{1}' -f ($index+1),$mode) $slot = Join-Path $OutputRoot $name $fixture = Join-Path $slot 'fixture'; $trace = Join-Path $slot 'trace' New-Item -ItemType Directory -Path $slot | Out-Null - $arguments = @($trace,$probe,'--pdb-case',$fixture,'pch-release','A-started','drain',('trace-'+[guid]::NewGuid().ToString('N')),$mode) + $profile = if ($Study -eq 'invocations') { $mode } else { 'pch-release' } + $probeMode = if ($Study -eq 'invocations') { '--invocation-case' } else { '--pdb-case' } + $arguments = @($trace,$probe,$probeMode,$fixture,$profile,'A-started','drain',('trace-'+[guid]::NewGuid().ToString('N'))) + if ($Study -eq 'calibration') { $arguments += $mode } Write-Json (Join-Path $slot 'arguments.json') $arguments $output = @(& $tracer @arguments 2>&1); $code = $LASTEXITCODE $output | Set-Content -LiteralPath (Join-Path $slot 'output.txt') -Encoding utf8 $errors = [Collections.Generic.List[string]]::new(); $originalOk=$null; $cleanup=$null + $outcomeExit=$null + if ($Study -eq 'invocations') { + # Always preserve original outcome diagnostics, even when the recorder + # failed. An independently complete trace is not compiler success. + & python (Join-Path $PSScriptRoot 'verify_pdb_invocations.py') --trace $trace --fixture $fixture ` + --native-root $fixture --profile $profile --output (Join-Path $slot 'invocation-audit.json') + $outcomeExit=$LASTEXITCODE + } try { if ($code -ne 0) { throw "Native trace capture failed with $code; no retry." } + if ($Study -eq 'invocations' -and $outcomeExit -ne 0) { throw 'Invocation evidence or original compiler control failed.' } & python (Join-Path $PSScriptRoot 'verify_pdb_file_trace.py') --trace $trace --case-root $fixture --require-readiness --output (Join-Path $slot 'trace-audit.json') if ($LASTEXITCODE -ne 0) { throw 'Trace integrity/calibration gate failed.' } $envelope=Get-Content -LiteralPath (Join-Path $fixture 'default-envelope.json') -Raw | ConvertFrom-Json $cleanup=Test-OwnershipCleanupEnvelope $envelope if (-not $cleanup) { throw 'Original fixture cleanup unproven.' } $observation=Get-Content -LiteralPath (Join-Path $fixture 'observation.json') -Raw | ConvertFrom-Json - if ($observation.profile -cne 'pch-release' -or $observation.origin -cne 'A-started' -or $observation.ending -cne 'drain' -or + if ($observation.profile -cne $profile -or $observation.origin -cne 'A-started' -or $observation.ending -cne 'drain' -or $observation.endpoint_mode -cne 'default' -or $observation.safe_to_transfer_write_lease -isnot [bool] -or $observation.safe_to_transfer_write_lease -or $observation.safe_to_integrate_cancellation -isnot [bool] -or $observation.safe_to_integrate_cancellation) { throw 'Wrong original fixture identity/safety fields.' } - $diagnostics=@(Get-OwnershipDiagnosticErrors $fixture 'pch-release' 'A-started' 'drain' $observation) + $diagnostics=@(Get-OwnershipDiagnosticErrors $fixture $profile 'A-started' 'drain' $observation) # This prior helper is strict about successful drain controls. Preserve # failures as failures; do not relabel an A compiler failure as calibration. if ($diagnostics.Count) { throw ($diagnostics -join '; ') } - foreach ($i in 0..3) { - $query=Get-Content -LiteralPath (Join-Path $fixture "rm-query-$i.json") -Raw | ConvertFrom-Json - Assert-PdbQuery $query ($mode -eq 'rm-on') - } - foreach ($phase in @('before-A','after-A','after-B')) { - $snapshot=Get-Content -LiteralPath (Join-Path $fixture "pdb-$phase.json") -Raw | ConvertFrom-Json - Assert-PdbPair $snapshot.pdb; Assert-PdbPair $snapshot.pch + if ($Study -eq 'calibration') { + foreach ($i in 0..3) { + $query=Get-Content -LiteralPath (Join-Path $fixture "rm-query-$i.json") -Raw | ConvertFrom-Json + Assert-PdbQuery $query ($mode -eq 'rm-on') + } + foreach ($phase in @('before-A','after-A','after-B')) { + $snapshot=Get-Content -LiteralPath (Join-Path $fixture "pdb-$phase.json") -Raw | ConvertFrom-Json + Assert-PdbPair $snapshot.pdb; Assert-PdbPair $snapshot.pch + } } $capture=Get-Content -LiteralPath (Join-Path $trace 'capture.json') -Raw | ConvertFrom-Json $originalOk=$capture.child.exit_code -eq 0 -and $observation.lifecycle_ok -eq $true -and $observation.drain_control_ok -eq $true if (-not $originalOk) { throw 'Original natural-drain control failed; traced failure is not a passing control.' } } catch { $errors.Add($_.Exception.Message) } - $rows.Add([pscustomobject]@{ name=$name; mode=$mode; capture_exit=$code; cleanup_verified=$cleanup + $rows.Add([pscustomobject]@{ name=$name; mode=$mode; capture_exit=$code; invocation_audit_exit=$outcomeExit; cleanup_verified=$cleanup original_control_ok=$originalOk; accepted=($errors.Count -eq 0); errors=@($errors.ToArray()) }) Write-Json (Join-Path $OutputRoot 'summary.json') @{ - expected=4; completed=$rows.Count; cases=@($rows.ToArray()); not_run=(4-$rows.Count) + study=$Study; expected=$expected; completed=$rows.Count; cases=@($rows.ToArray()); not_run=($expected-$rows.Count) negative_readiness_verified=$negativeOk historical_cause_resolved=$false; authorizes_held_pr_merge=$false; safe_to_transfer_write_lease=$false } diff --git a/tests/native/verify_pdb_invocations.py b/tests/native/verify_pdb_invocations.py new file mode 100644 index 00000000..ca969071 --- /dev/null +++ b/tests/native/verify_pdb_invocations.py @@ -0,0 +1,335 @@ +"""Bind original tool outcomes to calibrated ETW intervals, without inferring RPC ownership. + +A failed compiler control can have complete evidence. Those are separate results. +Overlapping B invocations retain multiple candidate children; never choose by order. +""" +from __future__ import annotations +import argparse +import copy +import json +from pathlib import Path +import re +import tempfile +from typing import Any +import verify_pdb_file_trace as trace + + +def signed_code(value: Any) -> int: + trace.need(type(value) is int and -(1 << 31) <= value < (1 << 31), "invalid signed tool exit") + return value + + +def control_ok(observation: dict, drain: dict) -> bool: + """No recovery field is used to decide original control success.""" + return (all(type(observation.get(f)) is int and observation[f] == 0 for f in + ("A_exit", "B0_exit", "B1_exit", "B_link_exit", "B_run_exit")) + and all(observation.get(f) is True for f in ("lifecycle_ok", "drain_control_ok", "server_survived_A", + "A_compiler_overlap_observed", "B_compiler_overlap_observed", "A_observed_compilers_signaled")) + and observation.get("A_pending_compile_dispatched") is False + and drain.get("stop_observed") is True and drain.get("work_compiles_dispatched") == 1 + and drain.get("first_compile_exit") == 0 and drain.get("pending_compile_exit") == -2) + + +def bind_span(begin: dict, end: dict, result: dict, capture: dict, events: list[dict], + executable: str, label: str) -> list[dict]: + for row in (begin, end): + trace.need(type(row.get("schema")) is int and row["schema"] == 1, "invalid invocation schema") + trace.need(begin.get("clock") == "QPC" and begin.get("label") == label, "wrong span clock/label") + trace.need(trace.canonical(begin["executable"], capture) == trace.canonical(executable, capture), "span executable mismatch") + trace.need(trace.integer(begin.get("frequency"), "frequency") == capture["qpc_frequency"], "span clock frequency mismatch") + low = trace.integer(begin.get("before_run_qpc"), "span begin") + high = trace.integer(end.get("after_run_qpc"), "span end") + trace.need(type(end.get("before_run_qpc")) is int and end["before_run_qpc"] == low, "span pairing mismatch") + trace.need(capture["child"]["before_launch_qpc"] <= low <= high <= capture["child"]["after_wait_qpc"], "span outside child lifetime") + trace.need(end.get("infrastructure_error") is False and end.get("safe_to_transfer_write_lease") is False, + "infrastructure failure or invalid lease authority") + trace.need(signed_code(end.get("exit_code")) == signed_code(result.get("exit_code")) and + result.get("cancelled") is False and "infrastructure_error" not in result, "original outcome/span mismatch") + owner = trace.integer(begin.get("owner_pid"), "owner pid") + created = trace.integer(begin.get("owner_created_filetime"), "owner creation") + starts = [e for e in events if e["provider"] == "process" and e["id"] == 1] + owners = [e for e in starts if e["data"].get("ProcessID") == owner and e["data"].get("CreateTime") == created + and e["qpc"] <= low] + trace.need(created > 0 and len(owners) == 1, "native invocation owner not uniquely matched to ETW creation") + # A new same-PID lifetime inside this interval must not inherit parent identity. + trace.need(not any(e["data"].get("ProcessID") == owner and e["data"].get("CreateTime") != created + and owners[0]["qpc"] <= e["qpc"] <= high for e in starts), "invocation owner PID reused") + exits = [e for e in events if e["provider"] == "process" and e["id"] == 2 and + e["data"].get("ProcessID") == owner and owners[0]["qpc"] <= e["qpc"] < high] + trace.need(not exits, "invocation outlives observed owner") + candidates = [] + for event in starts: + data = event["data"] + if data.get("ParentProcessID") != owner or not low <= event["qpc"] <= high: + continue + image = data.get("ImageName") + if isinstance(image, str) and trace.canonical(image, capture) == trace.canonical(executable, capture): + candidates.append({"pid": trace.integer(data.get("ProcessID"), "child pid"), + "created_filetime": trace.integer(data.get("CreateTime"), "child creation"), + "start_qpc": event["qpc"], "image": image}) + trace.need(bool(candidates) and all(c["created_filetime"] > 0 for c in candidates), "tool child lifetime absent") + return candidates + + +def analyse(root: Path, native_root: str, profile: str, capture: dict, + events: list[dict], event_audit: dict) -> dict: + observation = trace.load(root / "observation.json") + trace.need(observation.get("profile") == profile and profile in ("pch-release", "modules-debug") and + observation.get("origin") == "A-started" and observation.get("ending") == "drain" and + observation.get("endpoint_mode") == "default", "wrong original case identity") + trace.need(observation.get("safe_to_transfer_write_lease") is False and + observation.get("safe_to_integrate_cancellation") is False, "invalid original safety fields") + envelope = trace.load(root / "default-envelope.json") + trace.need(all(envelope.get(f) is True for f in ("cleanup_verified", "endpoint_override_absent", "outer_lifecycle_verified")) + and type(envelope.get("remaining_servers")) is list and not envelope["remaining_servers"], "cleanup unproven") + for f in ("A_exit", "B0_exit", "B1_exit", "B_link_exit", "B_run_exit", "recovery_compile_exit"): + signed_code(observation.get(f)) + expected = ["A/warm", "A/work0", "B/warm", "B/work0", "B/work1", "B/recovery"] + expected += [f"{side}/{'prefix' if profile == 'pch-release' else 'provider'}" for side in ("A", "B")] + if observation["B0_exit"] == observation["B1_exit"] == 0: + expected.append("B/link") + if observation["B_link_exit"] == 0: + expected.append("B/run") + else: + trace.need(observation["B_run_exit"] == -2, "failed link unexpectedly ran") + else: + trace.need(observation["B_link_exit"] == observation["B_run_exit"] == -2, "failed compile unexpectedly linked/ran") + actual = {p.relative_to(root).as_posix().removesuffix(".argv.txt") for p in root.rglob("*.argv.txt")} + trace.need(actual == set(expected), "missing or unexpected original tool invocation") + for suffix in ("invocation-begin.json", "invocation-end.json", "result.json"): + inventory = {p.relative_to(root).as_posix().removesuffix("." + suffix) for p in root.rglob("*." + suffix)} + trace.need(inventory == set(expected) | ({"A"} if suffix == "result.json" else set()), "missing/orphan invocation evidence") + root_result = trace.load(root / "A.result.json") + trace.need(signed_code(root_result.get("exit_code")) == observation["A_exit"] and root_result.get("cancelled") is False, + "A root result mismatch") + trace.need(capture["child"]["exit_code"] in (0, 1), "unexpected fixture exit") + for suffix in ("stdout.txt", "stderr.txt"): + (root / f"A.{suffix}").read_bytes() + snapshots = [] + for phase in ("before-A", "after-A", "after-B"): + snapshot = trace.load(root / ("pdb-" + phase + ".json")) + trace.need(snapshot.get("phase") == phase and snapshot.get("safe_to_transfer_write_lease") is False, "wrong snapshot phase/authority") + pair = snapshot["pdb"] + for side in ("A", "B"): + info = pair[side] + trace.need(type(info.get("open_error")) is int and info["open_error"] == 0 and + type(info.get("identity_error")) is int and info["identity_error"] == 0 and + isinstance(info.get("volume_serial"), str) and info["volume_serial"].isdigit() and + isinstance(info.get("file_id"), str) and re.fullmatch(r"[0-9a-f]{32}", info["file_id"]) is not None, + "PDB physical identity unavailable") + trace.need(trace.canonical(info["path"], capture) == trace.canonical(native_root + "\\" + side + "\\compiler.pdb", capture), "PDB identity path mismatch") + trace.need(pair.get("same_file") is False and + (pair["A"]["volume_serial"], pair["A"]["file_id"]) != (pair["B"]["volume_serial"], pair["B"]["file_id"]), "A/B PDB alias") + snapshots.append(snapshot) # Finite metadata observation, NOT event-time identity or a lock. + fields = {"B/work0": "B0_exit", "B/work1": "B1_exit", "B/link": "B_link_exit", "B/run": "B_run_exit", + "B/recovery": "recovery_compile_exit"} + rows = [] + for stem in expected: + result = trace.load(root / (stem + ".result.json")) + begin = trace.load(root / (stem + ".invocation-begin.json")) + end = trace.load(root / (stem + ".invocation-end.json")) + arguments = (root / (stem + ".argv.txt")).read_text(encoding="utf-8-sig").splitlines() + trace.need(bool(arguments), "empty original argv") + children = bind_span(begin, end, result, capture, events, arguments[0], stem.rsplit("/", 1)[1]) + if stem in fields: + trace.need(result["exit_code"] == observation[fields[stem]], "observation changed original tool result") + outputs = [(root / (stem + "." + kind + ".txt")).read_text(encoding="utf-8-sig") for kind in ("stdout", "stderr")] + if Path(stem).name not in ("link", "run"): + required = ["/FS", "/Zi", "/O2", "/MT"] if profile == "pch-release" else ["/FS", "/Zi", "/Od", "/MTd"] + trace.need(all(arg in arguments for arg in required), "original compiler flags changed") + trace.need(("/bigobj" in arguments) == (stem == "A/work0"), "large-A object flag changed") + target = trace.canonical(native_root.rstrip("\\/") + "\\" + stem.split("/")[0] + "\\compiler.pdb", capture) + overlapping = [p for p in event_audit["target_open_pairs"] if p["path"] == target and + begin["before_run_qpc"] <= p["begin_qpc"] <= p["end_qpc"] <= end["after_run_qpc"]] + failures = [p for p in overlapping if p["ntstatus"] != 0] + # Even one direct child does not assign a shared server's RPCs to it. + rows.append({"stem": stem, "exit_code": result["exit_code"], "phase": "recovery" if stem == "B/recovery" else "original", + "diagnostic_codes": sorted(set(re.findall(r"\bC\d{4}\b", "\n".join(outputs)))), + "before_run_qpc": begin["before_run_qpc"], "after_run_qpc": end["after_run_qpc"], + "owner_pid": begin["owner_pid"], "owner_created_filetime": begin["owner_created_filetime"], + "candidate_tool_processes": children, "unique_direct_child": len(children) == 1, + "pdb_open_pairs_in_interval": len(overlapping), "nonzero_pdb_events_in_interval": failures, + "shared_service_request_attribution_proven": False}) + drain = trace.load(root / "A/drain.json") + for field in ("work_compiles_dispatched", "first_compile_exit", "pending_compile_exit"): + signed_code(drain.get(field)) + trace.need(drain.get("safe_to_transfer_write_lease") is False and type(drain.get("stop_observed")) is bool, "invalid drain state") + first = next(r for r in rows if r["stem"] == "A/work0") + trace.need(first["exit_code"] == drain["first_compile_exit"], "original A failure replaced by drain outcome") + passed = control_ok(observation, drain) and all(r["exit_code"] == 0 for r in rows if r["phase"] == "original") + trace.need(capture["child"]["exit_code"] == (0 if passed else 1), "fixture outcome disagrees with original control") + return {"schema": 1, "evidence_complete": True, "original_control_ok": passed, "profile": profile, + "physical_snapshots": snapshots, "invocations": rows, "original_failed_invocations": [r["stem"] for r in rows if r["phase"] == "original" and r["exit_code"] != 0], + "recovery_failed": any(r["exit_code"] != 0 for r in rows if r["phase"] == "recovery"), + "all_recorded_real_failed_opens": event_audit["real_failed_opens"], + "historical_cause_resolved": False, "causal_attribution_proven": False, "safe_to_transfer_write_lease": False} + + +def self_test() -> dict: + capture = {"clock": "QPC", "qpc_frequency": 1000, "dos_root": "C:", "device_root": "\\Device\\V", + "child": {"before_launch_qpc": 1, "after_wait_qpc": 1000}} + begin = {"schema": 1, "clock": "QPC", "label": "work0", "executable": "C:\\cl.exe", "owner_pid": 10, + "owner_created_filetime": 20, "frequency": 1000, "before_run_qpc": 100} + end = {"schema": 1, "before_run_qpc": 100, "after_run_qpc": 200, "infrastructure_error": False, "exit_code": 0, + "safe_to_transfer_write_lease": False} + result = {"exit_code": 0, "cancelled": False} + events = [{"provider": "process", "id": 1, "qpc": 50, "data": {"ProcessID": 10, "CreateTime": 20}}, + {"provider": "process", "id": 1, "qpc": 120, "data": {"ProcessID": 11, "CreateTime": 21, "ParentProcessID": 10, "ImageName": "\\Device\\V\\cl.exe"}}] + rows = [] + tests = [ + ("single-child", True, lambda b, e, r, v: None), + ("original-nonzero-preserved", True, lambda b, e, r, v: (e.update(exit_code=2), r.update(exit_code=2))), + ("two-B-candidates-not-guessed", True, lambda b, e, r, v: v.append(dict(v[1], data=dict(v[1]["data"], ProcessID=12, CreateTime=22)))), + ("wrong-schema", False, lambda b, e, r, v: b.update(schema=True)), + ("wrong-label", False, lambda b, e, r, v: b.update(label="work1")), + ("wrong-frequency", False, lambda b, e, r, v: b.update(frequency=999)), + ("wrong-executable", False, lambda b, e, r, v: b.update(executable="C:\\other.exe")), + ("wrong-paired-begin", False, lambda b, e, r, v: e.update(before_run_qpc=99)), + ("reversed-interval", False, lambda b, e, r, v: e.update(after_run_qpc=90)), + ("outside-outer-span", False, lambda b, e, r, v: e.update(after_run_qpc=1001)), + ("result-mismatch", False, lambda b, e, r, v: r.update(exit_code=2)), + ("string-code", False, lambda b, e, r, v: r.update(exit_code="0")), + ("unexpected-cancellation", False, lambda b, e, r, v: r.update(cancelled=True)), + ("infrastructure-error", False, lambda b, e, r, v: e.update(infrastructure_error=True)), + ("lease-authorized", False, lambda b, e, r, v: e.update(safe_to_transfer_write_lease=True)), + ("owner-creation-mismatch", False, lambda b, e, r, v: b.update(owner_created_filetime=19)), + ("owner-PID-reused", False, lambda b, e, r, v: v.append(dict(v[0], qpc=130, data=dict(v[0]["data"], CreateTime=99)))), + ("owner-exited-during-run", False, lambda b, e, r, v: v.append(dict(v[0], id=2, qpc=130))), + ("missing-child-start", False, lambda b, e, r, v: v.pop()), + ] + for name, expected, change in tests: + b, e, r, v = copy.deepcopy((begin, end, result, events)); change(b, e, r, v) + error = None + try: + candidates = bind_span(b, e, r, capture, v, "C:\\cl.exe", "work0") + trace.need(len(candidates) == (2 if name.startswith("two-B") else 1), "candidate ambiguity erased") + except (ValueError, KeyError, TypeError) as exc: + error = str(exc) + rows.append({"name": name, "expected_accepted": expected, "accepted": error is None, "passed": (error is None) == expected, "error": error}) + original = {f: 0 for f in ("A_exit", "B0_exit", "B1_exit", "B_link_exit", "B_run_exit")} + original.update({f: True for f in ("lifecycle_ok", "drain_control_ok", "server_survived_A", "A_compiler_overlap_observed", "B_compiler_overlap_observed", "A_observed_compilers_signaled")}) + original.update(A_pending_compile_dispatched=False, recovery_compile_exit=0) + drain = dict(stop_observed=True, work_compiles_dispatched=1, first_compile_exit=0, pending_compile_exit=-2) + for name, change, expected in [ + ("original-success", {}, True), ("recovery-cannot-hide-A-failure", {"A_exit": 1}, False), + ("recovery-cannot-hide-B-failure", {"B0_exit": 2}, False), + ("string-success-not-accepted", {"A_exit": "0"}, False), + ("recovery-not-an-original-outcome", {"recovery_compile_exit": 2}, True), + ]: + actual = control_ok(dict(original, **change), drain) + rows.append(dict(name=name, expected_accepted=expected, accepted=actual, passed=actual == expected)) + # Full on-disk result/span/diagnostic contracts, not just pure field tests. + for name, profile, mutation, accepted, original_ok in [ + ("disk-pch-intact", "pch-release", "none", True, True), + ("disk-module-intact", "modules-debug", "none", True, True), + ("disk-A-failure-retained", "pch-release", "A-fail", True, False), + ("disk-B-failure-retained", "modules-debug", "B-fail", True, False), + ("disk-recovery-separate", "pch-release", "recovery-fail", True, True), + ("disk-missing-end", "pch-release", "missing-end", False, False), + ("disk-orphan-begin", "pch-release", "orphan", False, False), + ("disk-missing-stderr", "pch-release", "missing-diagnostic", False, False), + ("disk-A-result-masked", "pch-release", "masked", False, False), + ("disk-argv-exe-mismatch", "pch-release", "argv", False, False), + ("disk-PDB-alias", "pch-release", "alias", False, False), + ("disk-PDB-identity-missing", "pch-release", "identity", False, False), + ]: + failure = None + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + c = dict(capture, child=dict(capture["child"], exit_code=0, after_wait_qpc=10000)) + o = dict(original, profile=profile, origin="A-started", ending="drain", endpoint_mode="default", + safe_to_transfer_write_lease=False, safe_to_integrate_cancellation=False) + dr = dict(drain, safe_to_transfer_write_lease=False) + records = ["A/warm", "A/work0", "B/warm", "B/work0", "B/work1", "B/recovery", "B/link", "B/run"] + records += [f"{side}/{'prefix' if profile == 'pch-release' else 'provider'}" for side in ("A", "B")] + def store(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value), encoding="utf-8") + event_rows = [{"provider": "process", "id": 1, "qpc": 5, + "data": {"ProcessID": pid, "CreateTime": pid * 100}} for pid in (10, 20)] + for i, stem in enumerate(records): + owner = 10 if stem.startswith("A/") else 20 + code = 2 if ((mutation in ("A-fail", "masked") and stem == "A/work0") or + (mutation == "B-fail" and stem == "B/work0") or + (mutation == "recovery-fail" and stem == "B/recovery")) else 0 + b = dict(begin, label=stem.split("/")[-1], owner_pid=owner, owner_created_filetime=owner*100, + before_run_qpc=100+i*60) + e = dict(end, before_run_qpc=b["before_run_qpc"], after_run_qpc=b["before_run_qpc"]+40, exit_code=code) + r = dict(result, exit_code=code) + for suffix, data in (("invocation-begin.json", b), ("invocation-end.json", e), ("result.json", r)): + store(root / (stem + "." + suffix), data) + flags = ["/FS", "/Zi", "/O2", "/MT"] if profile == "pch-release" else ["/FS", "/Zi", "/Od", "/MTd"] + if stem == "A/work0": flags.append("/bigobj") + (root / (stem+".argv.txt")).write_text("C:\\cl.exe\n" + "\n".join(flags), encoding="utf-8") + for suffix in ("stdout.txt", "stderr.txt"): + (root / (stem+"."+suffix)).write_text("synthetic C1041" if code else "", encoding="utf-8") + event_rows.append({"provider": "process", "id": 1, "qpc": b["before_run_qpc"]+5, + "data": {"ProcessID": 100+i, "ParentProcessID": owner, "CreateTime": 10000+i, "ImageName": "C:\\cl.exe"}}) + if mutation == "A-fail": + o.update(A_exit=1, lifecycle_ok=False, drain_control_ok=False); dr["first_compile_exit"] = 2; c["child"]["exit_code"] = 1 + if mutation == "B-fail": + o.update(B0_exit=2, B_link_exit=-2, B_run_exit=-2, drain_control_ok=False); c["child"]["exit_code"] = 1 + for stem in ("B/link", "B/run"): + for f in root.glob(stem+".*"): f.unlink() + if mutation == "recovery-fail": o["recovery_compile_exit"] = 2 + for phase in ("before-A", "after-A", "after-B"): + pair = {side: dict(path=f"C:\\case\\{side}\\compiler.pdb", open_error=0, identity_error=0, + volume_serial="1", file_id=("a" if side == "A" else "b")*32) for side in ("A", "B")} + pair["same_file"] = False + if mutation == "alias": pair["B"]["file_id"] = pair["A"]["file_id"] + if mutation == "identity": pair["A"]["open_error"] = 32 + store(root / ("pdb-"+phase+".json"), dict(phase=phase, pdb=pair, safe_to_transfer_write_lease=False)) + store(root / "observation.json", o); store(root / "A/drain.json", dr) + store(root / "default-envelope.json", dict(cleanup_verified=True, endpoint_override_absent=True, outer_lifecycle_verified=True, remaining_servers=[])) + store(root / "A.result.json", dict(exit_code=o["A_exit"], cancelled=False)) + for suffix in ("stdout.txt", "stderr.txt"): (root / ("A."+suffix)).write_text("") + if mutation == "missing-end": (root / "B/work0.invocation-end.json").unlink() + if mutation == "orphan": store(root / "B/extra.invocation-begin.json", begin) + if mutation == "missing-diagnostic": (root / "B/work0.stderr.txt").unlink() + if mutation == "argv": (root / "B/work0.argv.txt").write_text("C:\\wrong.exe\n") + try: + observed = analyse(root, "C:\\case", profile, c, event_rows, dict(target_open_pairs=[], real_failed_opens=[])) + trace.need(observed["original_control_ok"] == original_ok, "original failure hidden by analysis") + if mutation == "recovery-fail": trace.need(observed["recovery_failed"], "recovery outcome omitted") + except (ValueError, KeyError, TypeError, OSError) as exc: failure = str(exc) + rows.append(dict(name=name, expected_accepted=accepted, accepted=failure is None, passed=(failure is None)==accepted, error=failure)) + return {"synthetic_only": True, "cases": rows, "passed": all(r["passed"] for r in rows)} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--self-test", action="store_true") + parser.add_argument("--trace", type=Path); parser.add_argument("--fixture", type=Path) + parser.add_argument("--native-root"); parser.add_argument("--profile", choices=("pch-release", "modules-debug")) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + trace.need(not args.output.exists(), "refuse to overwrite audit") + report = {"schema": 1, "evidence_complete": False, "original_control_ok": False, + "historical_cause_resolved": False, "safe_to_transfer_write_lease": False} + try: + if args.self_test: + report = self_test(); accepted = report["passed"] + else: + trace.need(args.trace is not None and args.fixture is not None and args.profile is not None, "missing input paths/profile") + # Diagnostic inventory survives a trace failure; never hide an original C1041 behind a tracing error. + report["diagnostic_inventory"] = [{"path": p.relative_to(args.fixture).as_posix(), + "bytes": p.stat().st_size, "compiler_codes": sorted(set(re.findall(r"\bC\d{4}\b", p.read_text(encoding="utf-8-sig"))))} + for p in sorted(args.fixture.rglob("*.txt")) if p.name.endswith(("stdout.txt", "stderr.txt", "error.txt"))] + capture = trace.load(args.trace / "capture.json"); decode = trace.load(args.trace / "decode.json") + trace.need(trace.load(args.trace / "readiness.json") == capture["readiness"], "readiness copy mismatch") + trace.need((args.trace / "events.etl").stat().st_size == decode["etl_bytes"], "ETL size mismatch") + events = [json.loads(line) for line in (args.trace / "events.jsonl").read_text(encoding="utf-8-sig").splitlines()] + native = args.native_root or str(args.fixture) + checked = trace.audit(capture, decode, events, native, require_readiness=True) + report.update(analyse(args.fixture, native, args.profile, capture, events, checked)) + accepted = report["original_control_ok"] and not report["recovery_failed"] + except (ValueError, TypeError, KeyError, OSError) as exc: + report["error"] = str(exc); accepted = False + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps({"accepted": accepted, "output": str(args.output)})) + return 0 if accepted else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From 7ce1422b591366c5d4445c8cebd060ab73b01b0b Mon Sep 17 00:00:00 2001 From: Sagiri Rium <2761125079@qq.com> Date: Wed, 9 Sep 2026 16:59:38 +0800 Subject: [PATCH 2/2] fix(test): retain executable volume mapping for ETW process correlation Prior real trace shows compiler on C: and fixture on D:; trace-root-only normalization falsely reports absent cl.exe lifetimes. Capture the executable's current QueryDosDeviceW mapping in its invocation record and use it for process-image comparison. Add four positive/negative mapping contracts, preserving the original 36 tests. No basename/volume guesses, no compiler behavior, study budget or gate change. Earlier source results retained; corrected source requires separate CI and ABBA. VERSION5.5.0 unchanged. --- .../windows/msvc_service_ownership_probe.cpp | 9 +++++++++ tests/native/verify_pdb_invocations.py | 16 ++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/cpp/tests/platform/windows/msvc_service_ownership_probe.cpp b/cpp/tests/platform/windows/msvc_service_ownership_probe.cpp index 3a455ccc..8a77eb59 100644 --- a/cpp/tests/platform/windows/msvc_service_ownership_probe.cpp +++ b/cpp/tests/platform/windows/msvc_service_ownership_probe.cpp @@ -409,10 +409,19 @@ struct InvocationSpan { FILETIME created{}, exited{}, kernel{}, user{}; require(::QueryPerformanceFrequency(&frequency) && frequency.QuadPart > 0, "invocation QPF failed"); require(::GetProcessTimes(::GetCurrentProcess(), &created, &exited, &kernel, &user), "invocation owner identity failed"); + // The compiler and fixture may reside on different volumes. Preserve + // the executable's native DOS-device mapping; do not guess from the ETL directory. + const auto executable_root = executable.root_name().wstring(); + wchar_t executable_device[32768]{}; + require(executable_root.size() == 2 && executable_root[1] == L':' && + ::QueryDosDeviceW(executable_root.c_str(), executable_device, 32768) != 0, + "invocation executable device mapping unavailable"); begin = invocation_tick(); write(directory / (label + ".invocation-begin.json"), "{\"schema\":1,\"clock\":\"QPC\",\"label\":" + (quoted)(label) + ",\"executable\":" + (quoted)(path_text(executable)) + + ",\"executable_dos_root\":" + (quoted)(utf8(executable_root)) + + ",\"executable_device_root\":" + (quoted)(utf8(executable_device)) + ",\"owner_pid\":" + std::to_string(::GetCurrentProcessId()) + ",\"owner_created_filetime\":" + std::to_string(ticks(created)) + ",\"frequency\":" + std::to_string(frequency.QuadPart) diff --git a/tests/native/verify_pdb_invocations.py b/tests/native/verify_pdb_invocations.py index ca969071..20eb688d 100644 --- a/tests/native/verify_pdb_invocations.py +++ b/tests/native/verify_pdb_invocations.py @@ -37,6 +37,13 @@ def bind_span(begin: dict, end: dict, result: dict, capture: dict, events: list[ trace.need(begin.get("clock") == "QPC" and begin.get("label") == label, "wrong span clock/label") trace.need(trace.canonical(begin["executable"], capture) == trace.canonical(executable, capture), "span executable mismatch") trace.need(trace.integer(begin.get("frequency"), "frequency") == capture["qpc_frequency"], "span clock frequency mismatch") + dos, device = begin.get("executable_dos_root"), begin.get("executable_device_root") + trace.need(isinstance(dos, str) and re.fullmatch(r"[A-Za-z]:", dos) is not None and + isinstance(device, str) and device.casefold().startswith("\\device\\") and + len(device) > 8 and not device.endswith("\\"), "missing/invalid executable device mapping") + executable_mapping = dict(dos_root=dos, device_root=device) + image_key = trace.canonical(executable, executable_mapping) + trace.need(image_key.startswith(dos.casefold() + "\\"), "executable drive differs from captured mapping") low = trace.integer(begin.get("before_run_qpc"), "span begin") high = trace.integer(end.get("after_run_qpc"), "span end") trace.need(type(end.get("before_run_qpc")) is int and end["before_run_qpc"] == low, "span pairing mismatch") @@ -63,7 +70,7 @@ def bind_span(begin: dict, end: dict, result: dict, capture: dict, events: list[ if data.get("ParentProcessID") != owner or not low <= event["qpc"] <= high: continue image = data.get("ImageName") - if isinstance(image, str) and trace.canonical(image, capture) == trace.canonical(executable, capture): + if isinstance(image, str) and trace.canonical(image, executable_mapping) == image_key: candidates.append({"pid": trace.integer(data.get("ProcessID"), "child pid"), "created_filetime": trace.integer(data.get("CreateTime"), "child creation"), "start_qpc": event["qpc"], "image": image}) @@ -169,7 +176,8 @@ def self_test() -> dict: capture = {"clock": "QPC", "qpc_frequency": 1000, "dos_root": "C:", "device_root": "\\Device\\V", "child": {"before_launch_qpc": 1, "after_wait_qpc": 1000}} begin = {"schema": 1, "clock": "QPC", "label": "work0", "executable": "C:\\cl.exe", "owner_pid": 10, - "owner_created_filetime": 20, "frequency": 1000, "before_run_qpc": 100} + "owner_created_filetime": 20, "frequency": 1000, "before_run_qpc": 100, + "executable_dos_root": "C:", "executable_device_root": "\\Device\\V"} end = {"schema": 1, "before_run_qpc": 100, "after_run_qpc": 200, "infrastructure_error": False, "exit_code": 0, "safe_to_transfer_write_lease": False} result = {"exit_code": 0, "cancelled": False} @@ -180,6 +188,10 @@ def self_test() -> dict: ("single-child", True, lambda b, e, r, v: None), ("original-nonzero-preserved", True, lambda b, e, r, v: (e.update(exit_code=2), r.update(exit_code=2))), ("two-B-candidates-not-guessed", True, lambda b, e, r, v: v.append(dict(v[1], data=dict(v[1]["data"], ProcessID=12, CreateTime=22)))), + ("cross-volume-compiler", True, lambda b, e, r, v: (b.update(executable_device_root="\\Device\\ToolVolume"), v[1]["data"].update(ImageName="\\Device\\ToolVolume\\cl.exe"))), + ("device-map-missing", False, lambda b, e, r, v: b.pop("executable_device_root")), + ("device-map-wrong", False, lambda b, e, r, v: b.update(executable_device_root="\\Device\\Wrong")), + ("dos-map-wrong", False, lambda b, e, r, v: b.update(executable_dos_root="Z:")), ("wrong-schema", False, lambda b, e, r, v: b.update(schema=True)), ("wrong-label", False, lambda b, e, r, v: b.update(label="work1")), ("wrong-frequency", False, lambda b, e, r, v: b.update(frequency=999)),