fix(diagnostics): sampler can no longer crash the run it measures; worm skip default off

Harness round. Every launched run is in Saved/HarnessFixAcceptance_20260914.ledger.txt.

- FVoxelStackSampler: two sampler-on runs had died in EndPlay (logs stop after "workers drained",
  before the latency summary). The lifecycle teardown is now serialised, registration TLS is
  tagged by session, stale worker registrations are cleared safely, and in-process DbgHelp
  initialisation/symbolisation is REMOVED. The measured process now writes raw PCs only; symbol
  resolution is deferred to after process exit. (An offline symboliser is still needed for
  readable tables; it is queued first in the next round.)
- voxel.WormBlockSkip default 1 -> 0: it measured neutral (26b3476). The code, the proven bound
  and the soundness test are kept for a tighter skip later.
- Harness (Saved/*.ps1, not tracked): a writable DDC under the plugin's Saved\DDC with
  -ddc=NoZenLocalFallback, fixing the DerivedDataBackends.cpp:208 startup fatal. UE 5.7 Windows
  was verified NOT to honour -nocrashreports for its startup monitor, so the harness runs
  unattended with dialogs forced off plus a PID-scoped CrashReportClient watchdog. Failures
  still exit non-zero, and a run without its final summary line is rejected.

Acceptance: 10 consecutive mixed runs, 7 of them sampler-on, all PASS; no new crash folders, dumps,
dialogs or lingering processes. Other runs: one smoke run failed on a PowerShell
HashSet.ToArray() compatibility bug in the harness (Unreal itself exited cleanly); its retries and
the export passed. Export OBJ unchanged (b3e5f4c3..9b377).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-09-14 05:17:27 +02:00
1 parent 26b34765d5
commit 11f0c1335b
4 files changed
+127 -24

No files matched your search

+2 -2
View File
@@ -87,8 +87,8 @@ namespace
// A proved N1-above-threshold block skips both worm noise calls. The switch is parsed once
// on the same scalar entry point as the other generation A/B switches; 0 is the exact legacy
// path, and 1 is the default after the proof/soundness test passes.
int32 GVoxelForgeWormBlockSkip = 1;
// path and remains the default until a tighter skip is justified by a future measurement.
int32 GVoxelForgeWormBlockSkip = 0;
FAutoConsoleVariableRef CVarVoxelForgeWormBlockSkip(
TEXT("voxel.WormBlockSkip"),
GVoxelForgeWormBlockSkip,
+115 -14
View File
@@ -1,5 +1,5 @@
// VoxelStackSampler.cpp
// Fixed-storage sampling and post-run symbolization for VoxelForge generation workers.
// Fixed-storage raw stack sampling for VoxelForge generation workers.
#include "VoxelStackSampler.h"
@@ -18,6 +18,7 @@
#include "Containers/StringConv.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "Misc/ScopeLock.h"
#include "Serialization/Archive.h"
#include "Templates/UniquePtr.h"
@@ -49,6 +50,20 @@ thread_local int32 GTlsRegisteredSlot = INDEX_NONE;
thread_local uint64 GTlsRegistrationGeneration = 0;
thread_local int32 GTlsRegistrationDepth = 0;
thread_local int32 GTlsRegisteredLODLevel = INDEX_NONE;
thread_local void* GTlsRegisteredImpl = nullptr;
thread_local uint64 GTlsSamplerSessionGeneration = 0;
std::atomic<uint64> GNextSamplerSessionGeneration(1);
void ResetRegistrationTls()
{
GTlsRegisteredSlot = INDEX_NONE;
GTlsRegistrationGeneration = 0;
GTlsRegistrationDepth = 0;
GTlsRegisteredLODLevel = INDEX_NONE;
GTlsRegisteredImpl = nullptr;
GTlsSamplerSessionGeneration = 0;
}
FString SanitizeLabel(const FString& InLabel)
{
@@ -293,7 +308,7 @@ void AppendRows(
struct FVoxelStackSampler::FImpl final : public FRunnable
{
struct FStoredSample
struct FStoredSample
{
uint64 ElapsedUs = 0;
uint64 ThreadId = 0;
@@ -310,6 +325,7 @@ struct FStoredSample
FString SummaryPath;
double StartSeconds = 0.0;
std::atomic<bool> bStop{false};
uint64 SamplerSessionGeneration = 0;
std::atomic<uint64> NextRegistrationGeneration{1};
FRegisteredThreadSlot Slots[MaxRegisteredThreads];
TUniquePtr<FStoredSample[]> Samples;
@@ -337,6 +353,8 @@ struct FStoredSample
, RunLabel(InRunLabel)
, OutputDirectory(InOutputDirectory)
{
SamplerSessionGeneration = GNextSamplerSessionGeneration.fetch_add(
1, std::memory_order_relaxed);
const FString SafeLabel = SanitizeLabel(RunLabel);
const uint32 ProcessId = FPlatformProcess::GetCurrentProcessId();
RawSamplesPath = FPaths::Combine(
@@ -390,7 +408,9 @@ struct FStoredSample
bool StartThread()
{
#if PLATFORM_WINDOWS
bStackWalkingInitialized = FPlatformStackWalk::InitStackWalking();
// CaptureThreadStackBackTrace uses the same-process context path and deliberately does
// not require DbgHelp. Initializing DbgHelp here made the measured process depend on
// global symbol-engine state before shutdown; symbolization is an offline concern.
StartSeconds = FPlatformTime::Seconds();
#endif
Thread = FRunnableThread::Create(this, TEXT("VoxelStackSampler"), 0, TPri_Lowest);
@@ -414,8 +434,16 @@ struct FStoredSample
const uint32 CurrentThreadId = FPlatformTLS::GetCurrentThreadId();
if (GTlsRegistrationDepth > 0)
{
++GTlsRegistrationDepth;
return true;
if (GTlsRegisteredImpl == this
&& GTlsSamplerSessionGeneration == SamplerSessionGeneration)
{
++GTlsRegistrationDepth;
return true;
}
// A worker can outlive a session's owner-side shutdown. Never let stale TLS from
// that session masquerade as a nested registration in a later session.
ResetRegistrationTls();
}
const uint64 Generation = NextRegistrationGeneration.fetch_add(
@@ -439,6 +467,8 @@ struct FStoredSample
GTlsRegistrationGeneration = Generation;
GTlsRegistrationDepth = 1;
GTlsRegisteredLODLevel = LODLevel;
GTlsRegisteredImpl = this;
GTlsSamplerSessionGeneration = SamplerSessionGeneration;
return true;
}
return false;
@@ -467,9 +497,7 @@ struct FStoredSample
Slot.LODLevel.store(INDEX_NONE, std::memory_order_relaxed);
}
}
GTlsRegisteredSlot = INDEX_NONE;
GTlsRegistrationGeneration = 0;
GTlsRegisteredLODLevel = INDEX_NONE;
ResetRegistrationTls();
}
void CaptureRegisteredThreads(double NowSeconds)
@@ -598,7 +626,10 @@ struct FStoredSample
Result.RunSeconds = FPlatformTime::Seconds() - StartSeconds;
WriteRawSamples(Result);
SymbolizeAndWriteSummary(Result);
// Do not call the DbgHelp-backed symbolizer from the measured process. DbgHelp is a
// process-global state machine and can fault during shutdown even after the sampler
// thread has joined. The raw PCs remain available for a separate offline pass.
WriteRawOnlySummary(Result);
return Result;
}
@@ -641,6 +672,61 @@ struct FStoredSample
Result.bOutputWritten = true;
}
void WriteRawOnlySummary(FSummary& Result)
{
uint64 SamplesWithStack = 0;
for (uint32 SampleIndex = 0; SampleIndex < StoredSampleCount; ++SampleIndex)
{
if (Samples[SampleIndex].Depth > 0)
{
++SamplesWithStack;
}
}
FString Summary;
Summary += TEXT("VoxelForge raw stack sampler\n");
Summary += TEXT("============================\n");
Summary.Appendf(TEXT("run_label: %s\n"), *RunLabel);
Summary.Appendf(TEXT("supported: %s\n"), Result.bSupported ? TEXT("yes") : TEXT("no"));
Summary += TEXT("raw_only: yes\n");
Summary += TEXT("symbolization: deferred\n");
Summary += TEXT("dbghelp_in_measured_process: no\n");
Summary.Appendf(TEXT("interval_us: %u\n"), IntervalUs);
Summary.Appendf(TEXT("max_stack_depth: %u\n"), MaxStackDepth);
Summary.Appendf(TEXT("max_stored_samples: %u\n"), MaxStoredSamples);
Summary.Appendf(TEXT("sampler_ticks: %llu\n"),
static_cast<unsigned long long>(SamplerTicks));
Summary.Appendf(TEXT("capture_attempts: %llu\n"),
static_cast<unsigned long long>(CaptureAttempts));
Summary.Appendf(TEXT("registered_thread_samples: %llu\n"),
static_cast<unsigned long long>(RegisteredThreadSamples));
Summary.Appendf(TEXT("registration_race_drops: %llu\n"),
static_cast<unsigned long long>(RegistrationRaceDrops));
Summary.Appendf(TEXT("capture_failures: %llu\n"),
static_cast<unsigned long long>(CaptureFailures));
Summary.Appendf(TEXT("retained_samples: %llu\n"),
static_cast<unsigned long long>(StoredSampleCount));
Summary.Appendf(TEXT("dropped_samples_after_bound: %llu\n"),
static_cast<unsigned long long>(DroppedSamples));
Summary.Appendf(TEXT("samples_with_stack: %llu\n"),
static_cast<unsigned long long>(SamplesWithStack));
Summary.Appendf(TEXT("total_frames: %llu\n"),
static_cast<unsigned long long>(TotalFrames));
Summary.Appendf(TEXT("max_active_registered_threads: %llu\n"),
static_cast<unsigned long long>(MaxActiveRegisteredThreads));
Summary.Appendf(TEXT("run_seconds: %.6f\n"), Result.RunSeconds);
Summary.Appendf(TEXT("raw_samples_path: %s\n"), *RawSamplesPath);
Summary += TEXT("\nThe target process records bounded raw PCs only; resolve symbols after the process exits.\n");
if (FFileHelper::SaveStringToFile(
Summary,
*SummaryPath,
FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
{
Result.bOutputWritten = true;
}
}
void ResolveProgramCounter(
uint64 ProgramCounter,
TMap<uint64, FResolvedProgramCounter>& SymbolCache,
@@ -948,6 +1034,7 @@ bool FVoxelStackSampler::Start(
const FString& OutputDirectory,
const FString& RunLabel)
{
FScopeLock LifecycleLock(&LifecycleMutex);
if (Impl != nullptr)
{
return false;
@@ -982,6 +1069,7 @@ bool FVoxelStackSampler::Start(
FVoxelStackSampler::FSummary FVoxelStackSampler::StopAndWrite()
{
GVoxelStackSamplerEnabled.store(false, std::memory_order_release);
FScopeLock LifecycleLock(&LifecycleMutex);
FSummary Result;
if (Impl == nullptr)
{
@@ -1011,14 +1099,12 @@ FVoxelStackSampler::FSummary FVoxelStackSampler::StopAndWrite()
void FVoxelStackSampler::Shutdown()
{
if (Impl != nullptr)
{
StopAndWrite();
}
StopAndWrite();
}
bool FVoxelStackSampler::IsRunning() const
{
FScopeLock LifecycleLock(&LifecycleMutex);
return Impl != nullptr;
}
@@ -1045,13 +1131,28 @@ bool FVoxelStackSampler::IsEnabled()
bool FVoxelStackSampler::RegisterThreadInternal(int32 LODLevel)
{
FScopeLock LifecycleLock(&LifecycleMutex);
return Impl != nullptr && Impl->RegisterThread(LODLevel);
}
void FVoxelStackSampler::DeregisterThreadInternal()
{
if (Impl != nullptr)
FScopeLock LifecycleLock(&LifecycleMutex);
if (GTlsRegistrationDepth <= 0)
{
ResetRegistrationTls();
return;
}
if (Impl != nullptr
&& GTlsRegisteredImpl == Impl
&& GTlsSamplerSessionGeneration == Impl->SamplerSessionGeneration)
{
Impl->DeregisterThread();
return;
}
// StopAndWrite may have detached and destroyed the session before a worker's scope guard
// runs. Clear only this thread's stale registration; never dereference the old FImpl.
ResetRegistrationTls();
}
+8 -6
View File
@@ -1,21 +1,22 @@
// VoxelStackSampler.h
// Opt-in, out-of-process-symbolized stack sampling for active VoxelForge generation workers.
// Opt-in raw stack sampling for active VoxelForge generation workers; symbols are resolved offline.
#pragma once
#include "CoreTypes.h"
#include "Containers/UnrealString.h"
#include "HAL/CriticalSection.h"
/**
* A diagnostic-only in-process stack sampler.
* A diagnostic-only raw stack sampler.
*
* The generation path only constructs FScopedVoxelStackRegistration at task entry. When the
* sampler is disabled that constructor performs one relaxed load of the global enable flag and
* returns; it does not touch the registry, allocate, lock, or read a console variable.
*
* The sampler thread owns a bounded preallocated array of raw program counters. Symbolization
* and aggregation happen after the sampler thread has stopped, so DbgHelp and FString activity are
* kept out of both generation workers and the sampling loop.
* The sampler thread owns a bounded preallocated array of raw program counters. The measured
* process never initializes or calls DbgHelp: the raw CSV is the durable sampling artifact and
* symbolization is explicitly deferred to an offline consumer.
*/
class VOXELFORGE_API FVoxelStackSampler
{
@@ -66,7 +67,7 @@ public:
/** Start one sampling session. Returns false when another session is active or setup fails. */
bool Start(uint32 IntervalUs, const FString& OutputDirectory, const FString& RunLabel);
/** Stop, symbolize, aggregate, and write the bounded raw CSV plus readable summary. */
/** Stop and write the bounded raw CSV plus a symbolization-free readable summary. */
FSummary StopAndWrite();
/** Used by the module shutdown path as a final lifecycle backstop. */
@@ -87,6 +88,7 @@ private:
struct FImpl;
FImpl* Impl = nullptr;
mutable FCriticalSection LifecycleMutex;
bool RegisterThreadInternal(int32 LODLevel);
void DeregisterThreadInternal();