From 2869bd6839335ef3f744f1b71fdc671297fe30ac Mon Sep 17 00:00:00 2001 From: John Lambert Date: Tue, 29 Sep 2026 21:29:27 -0400 Subject: [PATCH 1/2] LT-22816: Stop regenerating CellarConstants.h on every build GenerateCellarConstants declared its Outputs through a top-level item built from $(dir-fwoutputCommon). That property is set by the Setup target at run time, so the item was evaluated as "/CellarConstants.h". MSBuild never found the output, ran the target on every build, and rewrote the header with identical bytes and a new timestamp. That was harmless while nothing tracked headers. With header dependencies it would rebuild every object that includes the file (43 of 46 in Views) on every build. Inputs and Outputs now sit on the target, where properties expand at run time, as CopyCellarBaseConstants already does. Co-Authored-By: Claude Opus 5.5 --- Build/mkall.targets | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/Build/mkall.targets b/Build/mkall.targets index 46cfc58576..78d1390a93 100644 --- a/Build/mkall.targets +++ b/Build/mkall.targets @@ -193,16 +193,13 @@ TaskName="LcmGenerate" AssemblyFile="$(LcmBuildTasksDir)/SIL.LCModel.Build.Tasks.dll" /> - - - - - + Date: Tue, 29 Sep 2026 21:29:27 -0400 Subject: [PATCH 2/2] LT-22816: Rebuild native objects when an included header changes The nmake inference rules in Bld/_rule.mak make each object depend on its own source file only. Editing a header rebuilt nothing, the build reported success, and Views.dll stayed stale. A class-layout change rebuilt only some objects and mixed layouts, which corrupts memory. Every compile rule now passes cl /sourceDependencies, which writes a JSON list of the headers the translation unit read. At the end of _targ.mak, Bld/Write-NmakeHeaderDependencies.ps1 turns one product's lists into plain "object : header" lines and _targ.mak includes them, so nmake makes every up-to-date decision itself. - /sourceDependencies rather than /showIncludes: structured output, no parsing of localized compiler text. Needs VS 2019 16.7+. - Full edges, evaluated by nmake at make time, so a header generated earlier in the same build still triggers rebuilds. - The include comes last because nmake's default goal is the first target it reads. - Only repository headers are tracked, and missing ones are dropped, so a deleted header cannot break the build. - An object with no sidecar predates this change and is rebuilt once. A missing PCH forces its producer and consumers the same way. - The include is written to a temporary file and then replaced. - A path containing # fails the build: nmake cannot quote it. Co-Authored-By: Claude Opus 5.5 --- Bld/Write-NmakeHeaderDependencies.ps1 | 173 ++++++++++++++++++++++++++ Bld/_rule.mak | 62 ++++----- Bld/_targ.mak | 13 ++ 3 files changed, 218 insertions(+), 30 deletions(-) create mode 100644 Bld/Write-NmakeHeaderDependencies.ps1 diff --git a/Bld/Write-NmakeHeaderDependencies.ps1 b/Bld/Write-NmakeHeaderDependencies.ps1 new file mode 100644 index 0000000000..0d516cb5aa --- /dev/null +++ b/Bld/Write-NmakeHeaderDependencies.ps1 @@ -0,0 +1,173 @@ +<# +.SYNOPSIS + Writes the nmake header dependencies for one native build product. + +.DESCRIPTION + Every compile rule in Bld/_rule.mak writes a cl /sourceDependencies sidecar + (.source-dependencies.json) next to its object file. This script reads + those sidecars for one product and writes header-dependencies.mak, which the + end of Bld/_targ.mak includes, so nmake rebuilds an object whenever a header it + includes changes. nmake still makes every up-to-date decision; this script only + supplies the edges. + + An object with no sidecar (compiled before header tracking existed, or its + sidecar was deleted) depends on a target that is always out of date, so it is + rebuilt once and gains a sidecar. A missing precompiled header does the same + for the objects that produce and use it. + +.PARAMETER RepositoryRoot + Repository root. Headers outside it (SDK, toolset) are not tracked. + +.PARAMETER IntermediateDirectory + The product's intermediate directory, $(INT_DIR). The include file is written + here. +#> +param( + [Parameter(Mandatory = $true)] + [string] $RepositoryRoot, + [Parameter(Mandatory = $true)] + [string] $IntermediateDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$sidecarSuffix = '.source-dependencies.json' +$forceTarget = 'header-dependencies-force' +$repositoryPrefix = [IO.Path]::GetFullPath($RepositoryRoot).TrimEnd('\', '/') + '\' +$intermediateDirectory = [IO.Path]::GetFullPath($IntermediateDirectory) + +if (-not [IO.Directory]::Exists($intermediateDirectory)) { + exit 0 +} + +# nmake expands $ as a macro even inside quotes, so it is doubled. A # cannot be +# escaped inside a quoted name, so such a path fails the build rather than +# producing an edge to a file that does not exist. +function ConvertTo-NmakePath([string] $path) { + if ($path.Contains('#')) { + throw "Header dependency path contains '#', which nmake cannot quote: $path" + } + return '"' + $path.Replace('$', '$$') + '"' +} + +# Returns the repository headers and PCH the sidecar records, and whether its PCH +# is missing. Deleted or renamed headers are dropped so they never become targets +# nmake cannot make. +function Get-TrackedFile([string] $sidecarPath) { + $result = @{ + Paths = New-Object 'System.Collections.Generic.List[string]' + MissingPch = $false + } + $data = ([IO.File]::ReadAllText($sidecarPath) | ConvertFrom-Json).Data + $candidates = New-Object 'System.Collections.Generic.List[object]' + $includes = $data.PSObject.Properties['Includes'] + if ($null -ne $includes) { + foreach ($include in @($includes.Value)) { + $candidates.Add(@{ Value = $include; IsPch = $false }) + } + } + $pch = $data.PSObject.Properties['PCH'] + if ($null -ne $pch -and $null -ne $pch.Value) { + $candidates.Add(@{ Value = $pch.Value; IsPch = $true }) + } + foreach ($candidate in $candidates) { + $value = $candidate.Value + $path = $null + if ($value -is [string]) { + $path = $value + } + elseif ($null -ne $value -and $null -ne $value.PSObject.Properties['Path']) { + $path = [string]$value.Path + } + if ([string]::IsNullOrWhiteSpace($path)) { + continue + } + $path = [IO.Path]::GetFullPath($path) + if (-not $path.StartsWith($repositoryPrefix, [StringComparison]::OrdinalIgnoreCase)) { + continue + } + if ([IO.File]::Exists($path)) { + $result.Paths.Add($path) + } + elseif ($candidate.IsPch) { + $result.MissingPch = $true + } + } + return $result +} + +$edges = New-Object 'System.Collections.Generic.List[string]' +$forced = New-Object 'System.Collections.Generic.SortedSet[string]' ([StringComparer]::OrdinalIgnoreCase) +$pchMissing = $false +$objects = [IO.Directory]::GetFiles($intermediateDirectory, '*.obj', [IO.SearchOption]::AllDirectories) +[Array]::Sort($objects, [StringComparer]::OrdinalIgnoreCase) +foreach ($object in $objects) { + $sidecar = $object + $sidecarSuffix + if (-not [IO.File]::Exists($sidecar)) { + [void]$forced.Add($object) + continue + } + $tracked = Get-TrackedFile $sidecar + if ($tracked.MissingPch) { + [void]$forced.Add($object) + $pchMissing = $true + } + $tracked.Paths.Sort([StringComparer]::OrdinalIgnoreCase) + $target = ConvertTo-NmakePath $object + foreach ($path in $tracked.Paths) { + $edges.Add($target + ' : ' + (ConvertTo-NmakePath $path)) + } +} +# A missing PCH is only recreated when its /Yc producer compiles again. +if ($pchMissing) { + foreach ($object in $objects) { + if ([IO.Path]::GetFileName([IO.Path]::GetDirectoryName($object)) -ieq 'genpch') { + [void]$forced.Add($object) + } + } +} + +$lines = New-Object 'System.Collections.Generic.List[string]' +$lines.AddRange($edges) +foreach ($object in $forced) { + $lines.Add((ConvertTo-NmakePath $object) + ' : ' + $forceTarget) +} +if ($forced.Count -gt 0) { + $lines.Add($forceTarget + ' :') + $lines.Add("`t@rem") +} + +$content = [string]::Join("`r`n", $lines.ToArray()) +if ($content.Length -gt 0) { + $content += "`r`n" +} +$makeFile = Join-Path $intermediateDirectory 'header-dependencies.mak' +if ([IO.File]::Exists($makeFile) -and [IO.File]::ReadAllText($makeFile) -ceq $content) { + exit 0 +} + +# Write beside the target, then replace it, so nmake never reads a partial file. +$temporaryFile = $makeFile + '.' + [Diagnostics.Process]::GetCurrentProcess().Id + '.tmp' +try { + [IO.File]::WriteAllText($temporaryFile, $content, (New-Object System.Text.UTF8Encoding($false))) + $moved = $false + if (-not [IO.File]::Exists($makeFile)) { + try { + [IO.File]::Move($temporaryFile, $makeFile) + $moved = $true + } + catch [IO.IOException] { + # Another build created the file first; replace it below instead. + } + } + if (-not $moved) { + # Windows PowerShell passes $null to a string parameter as "", which Replace rejects. + [IO.File]::Replace($temporaryFile, $makeFile, [NullString]::Value) + } +} +finally { + if ([IO.File]::Exists($temporaryFile)) { + [IO.File]::Delete($temporaryFile) + } +} diff --git a/Bld/_rule.mak b/Bld/_rule.mak index 696e9ca66f..bb96a5fd75 100644 --- a/Bld/_rule.mak +++ b/Bld/_rule.mak @@ -1,40 +1,42 @@ # === C Source === +# Every compile also writes a /sourceDependencies sidecar; the end of _targ.mak turns this +# product's sidecars into object-to-header edges. {$(ARG_SRCDIR)}.cpp{$(INT_DIR)}.obj: $(DISPLAY) Compiling $< $(CL) @<< -/c /Fo"$(INT_DIR)/" $(CL_OPTS) $(DEFS) $< +/c /Fo"$(INT_DIR)/" /sourceDependencies "$@.source-dependencies.json" $(CL_OPTS) $(DEFS) $< <