[5.8.2][UHT] Non-deterministic package body hash (NetCore) from a race between UhtHeaderFile.Reset() and the same-module Referenced hack — spurious engine module rebuilds

Summary

UHT produces two different package body hashes for NetCore from identical inputs, because a parallel-parse race decides whether PushModel.h counts toward the hash. Every whole-target UHT re-run can flip NetCore.init.gen.cpp, which makes UBT rebuild UnrealEditor-NetCore.dll and bump the BuildId with no source change.

What Type of Bug are you experiencing?

Foundation (C++ Tools, Profiling, & Pipeline)

Steps to Reproduce

RunUBT.bat -Mode=UnrealHeaderTool .uproject .uhtmanifest -WriteRef -NoOutput [-IncludeDebugOutput]

Run it repeatedly on the same manifest and compare Engine/Programs/UnrealHeaderTool/Saved/ReferenceExports/CodeGen/NetCore.init.gen.cpp:

Mode Result
default (GoWide) 0x7A7616C1 or 0xFC36040C, random
-NoGoWide always 0x7A7616C1
-EnableInputCacheRead always 0xFC36040C
All ten per-header NetCore/*.gen.cpp outputs are byte-identical between the two results. With -IncludeDebugOutput the header list printed in NetCore.init.gen.cpp differs by exactly one line: Runtime/Net/Core/Public/Net/Core/PushModel/PushModel.h.

Root cause

The package body hash combines the BodyHash of every header with ShouldExport == true (UhtPackageCodeGeneratorCppFile.Generate).
PushModel.h has no reflected types (its USTRUCT/UCLASS are inside an #if 0 example, but enough for UBT to hand it to UHT), so ShouldExport depends only on UhtHeaderFileExportFlags.Referenced.
Referenced is set by the same-module compatibility hack in UhtHeaderFile.AddReferencedHeader when Classes/Net/Serialization/FastArraySerializer.h parses include “Net/Core/PushModel/PushModel.h”. That write goes to the other header’s flags.
UhtSession.StepParseHeaders processes headers in parallel; each header first calls ReadFromCache()/Read(), which calls Reset() → HeaderFileExportFlags = None on itself.
If FastArraySerializer.h finishes parsing before PushModel.h starts, PushModel.h’s own Reset() wipes the flag → excluded from the hash. Otherwise it stays → included.
The re-propagation in UhtHeaderFile.Resolve (UhtResolvePhase.InvalidCheck) would repair this, but it only runs when Session.EnableInputCacheRead is true (off by default, bEnableUHTInputCache = false).
-NoGoWide is deterministic because the sequential order always parses Classes/…FastArraySerializer.h before Public/…PushModel.h, so the flag is always wiped.

Suggested fix (either)

Run the Resolve(InvalidCheck) propagation of the same-module Referenced hack unconditionally (drop the Session.EnableInputCacheRead && condition), or
Call Reset() for all headers in a sequential step before the parallel parse, so a header never clears flags that other headers already set on it.
Workaround

BuildConfiguration.xml → true. The hash is then stable (0xFC36040C) across forced UHT re-runs.

Expected Result

identical inputs produce an identical NetCore.init.gen.cpp; no engine module is rebuilt.

Observed Result

Whenever UHT re-runs for the whole target (makefile invalidated: .Build.cs changed, files added/removed), Engine/Intermediate/Build/Win64/UnrealEditor/Inc/NetCore/UHT/NetCore.init.gen.cpp is sometimes rewritten with a different package body hash (0x7A7616C1 ↔ 0xFC36040C; declarations hash 0x12F0F921 unchanged). No engine source changed. UBT then wants to rebuild UnrealEditor-NetCore.dll and bump the BuildId; with -NoEngineChanges the build fails with FailedDueToEngineChange. In our project this happened in 5 of 11 builds that touched project headers.

Affects Versions

5.8

Platform(s)

Windows

I I’ve run into the same issue while having an LLM port the UE editor to Linux on an NVIDIA DGX Spark (AArch64). Below is some additional information gathered while troubleshooting and fixing the race. It was written by Claude Code.

A few additions that may help triage:

  • Not Windows-specific. It reproduces on a Linux source build of 5.8.2, with different values (Linux: -NoGoWide gives 0x858D5FE0, -EnableInputCacheRead gives 0x015E0A5C). The values differ by host because a few generated lines in the hashed body come from AppendLine and multi-line raw strings, which use the host’s or the source checkout’s line endings. With LF-checked-out UHT sources on Windows the pair becomes 0x6C2D6518 / 0xF26BCE42. Your 0x7A7616C1 / 0xFC36040C is the CRLF pair.
  • Epic’s shipped build has the flag-kept value. The launcher’s 5.8.2 UnrealEditor-NetCore.dll (CL 56702186) contains 0xFC36040C, not 0x7A7616C1. UBT enables the UHT input cache on build machines (bEnableUHTInputCache || Unreal.IsBuildMachine() in UEBuildTarget.cs), so build machines take the path that re-applies the flag. Local source builds mostly don’t, which is probably why this hasn’t shown up internally.
  • Still present in 5.8.3 and ue5-main (checked 2026-10-03). On ue5-main the propagation moved to the new Pairings resolve phase, still behind Session.EnableInputCacheRead &&, so the same one-condition fix applies there.
  • Your first proposed fix (propagate unconditionally) tested on 5.8.0–5.8.2:
    • 50 of 50 multithreaded runs, all single-threaded runs and shuffled header orders gave one value;
    • on the full 1,352-module editor manifest, multithreaded and single-threaded output is byte-identical, and only NetCore.init.gen.cpp changes from before;
    • engine builds, protected (-NoEngineChanges) project builds and project tests passed;
    • existing builds recompile NetCore once.
  • Other projects hitting it: [CI] CI editor builds re-stamp the shared engine BuildId and break contributor editor projects · Issue #238 · ShayShimoni/aetheln-online · GitHub (Windows; their alternating pair is exactly the LF pair above) and M2: Tuning and GAS foundation (ADR-006) by blooprocket-create · Pull Request #13 · blooprocket-create/MOBAVeyra · GitHub (5.8.3).

Investigated with an AI coding assistant (Anthropic’s Claude); results checked on my own builds.

Follow-up: a Windows project that hit this (CI and contributors sharing one source engine) has confirmed Bille’s workaround in real builds. With `bEnableUHTInputCache` set to true (`UEBuildConfiguration` section of `BuildConfiguration.xml`), NetCore recompiled once, settled on the flag-kept value `0xF26BCE42`, and stayed there across local editor builds and a CI build running full UHT. Details: [CI] CI editor builds re-stamp the shared engine BuildId and break contributor editor projects · Issue #238 · ShayShimoni/aetheln-online · GitHub

One caveat: builds with `-ForceHeaderGeneration` skip input-cache reads, so they still take the racy path.

(Written with Claude Code.)