Skip to content

Commit e2a6cfe

Browse files
committed
Make build/test worktree-aware with same-worktree lock
1 parent fe415b8 commit e2a6cfe

6 files changed

Lines changed: 258 additions & 9 deletions

File tree

.github/instructions/build.instructions.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,21 @@ Key ordering constraint:
4444
## Worktrees and concurrent builds
4545
This repo supports multiple concurrent builds across git worktrees. Prefer the scripts because they handle environment setup and avoid cross-worktree process conflicts.
4646

47+
### Worktree-aware process cleanup
48+
- `build.ps1` now scopes process cleanup to the current repository root (`$PSScriptRoot`).
49+
- This avoids killing active `msbuild`/`dotnet`/test processes started from other worktrees.
50+
- The same worktree is intentionally exclusive: `build.ps1`/`test.ps1` acquire a named lock and fail fast if another build/test workflow is already running in that worktree.
51+
- Lock metadata is written to `Output/WorktreeRun.lock.json` while the workflow is active.
52+
- Optional actor tagging: set `FW_BUILD_STARTED_BY=user|agent` (or pass `-StartedBy`) to record who owns the lock.
53+
54+
### MSBuild node reuse default
55+
- `build.ps1` defaults `-NodeReuse` to `false` to reduce cross-worktree lock contention from shared MSBuild worker nodes.
56+
- If you prefer faster local inner-loop builds and are not running concurrent worktrees, you can opt back in with:
57+
58+
```powershell
59+
.\build.ps1 -NodeReuse $true
60+
```
61+
4762
## Troubleshooting (common)
4863

4964
### “Native artifacts missing” / code-generation failures

.github/instructions/testing.instructions.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ The testing infrastructure relies on shared PowerShell modules for consistency:
6565
- **`Build/scripts/Invoke-CppTest.ps1`**: Backend for native C++ tests (MSBuild/NMake).
6666
- **`Build/Agent/FwBuildHelpers.psm1`**: Shared logic for VS environment, and process cleanup.
6767

68+
## Worktree-aware behavior
69+
- `test.ps1` scopes process cleanup to the current repository root (`$PSScriptRoot`) so concurrent runs in other worktrees are not terminated.
70+
- `build.ps1` and `test.ps1` in the same worktree both target `Output/<Configuration>/`; a same-worktree lock prevents concurrent runs and fails fast with owner details.
71+
- `build.ps1 -RunTests` is supported in the same worktree: the child `test.ps1` run reuses the parent workflow lock.
72+
- If you need concurrency, use separate git worktrees and run one scripted workflow per worktree.
73+
- Optional lock ownership tagging: set `FW_BUILD_STARTED_BY=user|agent` (or pass `-StartedBy`) before running scripts.
74+
6875
## Debugging & Logs
6976
- **Logs**: Build logs are written to `Output/Build.log` (if configured) or standard output.
7077
- **Verbosity**: Use `-Verbosity detailed` with `test.ps1` for more output.

Build/Agent/FwBuildHelpers.psm1

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,159 @@ function Stop-ConflictingProcesses {
125125
}
126126
}
127127

128+
function Get-WorktreeMutexName {
129+
param([Parameter(Mandatory)][string]$RepoRoot)
130+
131+
$normalizedRepoRoot = [System.IO.Path]::GetFullPath($RepoRoot).TrimEnd('\\', '/')
132+
$normalizedRepoRoot = $normalizedRepoRoot.ToLowerInvariant()
133+
134+
$bytes = [System.Text.Encoding]::UTF8.GetBytes($normalizedRepoRoot)
135+
$sha = [System.Security.Cryptography.SHA256]::Create()
136+
try {
137+
$hashBytes = $sha.ComputeHash($bytes)
138+
}
139+
finally {
140+
$sha.Dispose()
141+
}
142+
143+
$hash = [System.BitConverter]::ToString($hashBytes).Replace('-', '')
144+
$shortHash = $hash.Substring(0, 16)
145+
return "Global\\FieldWorks.Worktree.$shortHash"
146+
}
147+
148+
function Enter-WorktreeLock {
149+
<#
150+
.SYNOPSIS
151+
Acquires an exclusive same-worktree lock for build/test workflows.
152+
.DESCRIPTION
153+
Uses a named Windows mutex keyed by RepoRoot to prevent concurrent build/test
154+
runs inside the same worktree. Also writes lock metadata to Output/ so users
155+
can see who currently owns the lock.
156+
.PARAMETER RepoRoot
157+
Repository root path for this worktree.
158+
.PARAMETER Context
159+
Friendly text describing the operation (e.g. "FieldWorks build").
160+
.PARAMETER StartedBy
161+
Optional actor tag for diagnostics (for example: user, agent).
162+
#>
163+
param(
164+
[Parameter(Mandatory)][string]$RepoRoot,
165+
[Parameter(Mandatory)][string]$Context,
166+
[string]$StartedBy = 'unknown'
167+
)
168+
169+
$mutexName = Get-WorktreeMutexName -RepoRoot $RepoRoot
170+
$mutex = New-Object System.Threading.Mutex($false, $mutexName)
171+
$hasHandle = $false
172+
173+
try {
174+
try {
175+
$hasHandle = $mutex.WaitOne(0)
176+
}
177+
catch [System.Threading.AbandonedMutexException] {
178+
$hasHandle = $true
179+
}
180+
181+
$lockPath = Join-Path $RepoRoot "Output\\WorktreeRun.lock.json"
182+
183+
if (-not $hasHandle) {
184+
$ownerDetails = $null
185+
if (Test-Path -LiteralPath $lockPath -PathType Leaf) {
186+
try {
187+
$ownerDetails = Get-Content -LiteralPath $lockPath -Raw | ConvertFrom-Json
188+
}
189+
catch {
190+
$ownerDetails = $null
191+
}
192+
}
193+
194+
if ($ownerDetails) {
195+
throw "$Context is already running in this worktree (ownerPid=$($ownerDetails.Pid), startedBy=$($ownerDetails.StartedBy), startedAtUtc=$($ownerDetails.StartedAtUtc), context=$($ownerDetails.Context)). Run one build/test workflow at a time per worktree."
196+
}
197+
198+
throw "$Context is already running in this worktree. Run one build/test workflow at a time per worktree."
199+
}
200+
201+
$outputDir = Split-Path -Parent $lockPath
202+
if (-not (Test-Path -LiteralPath $outputDir -PathType Container)) {
203+
New-Item -Path $outputDir -ItemType Directory -Force | Out-Null
204+
}
205+
206+
$metadata = [pscustomobject]@{
207+
Context = $Context
208+
StartedBy = $StartedBy
209+
Pid = $PID
210+
ProcessName = (Get-Process -Id $PID).ProcessName
211+
RepoRoot = [System.IO.Path]::GetFullPath($RepoRoot)
212+
MutexName = $mutexName
213+
StartedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
214+
}
215+
216+
$metadata | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $lockPath -Encoding UTF8
217+
218+
return [pscustomobject]@{
219+
Mutex = $mutex
220+
MutexName = $mutexName
221+
LockPath = $lockPath
222+
HasHandle = $true
223+
OwnerPid = $PID
224+
}
225+
}
226+
catch {
227+
if (-not $hasHandle -and $mutex) {
228+
$mutex.Dispose()
229+
}
230+
throw
231+
}
232+
}
233+
234+
function Exit-WorktreeLock {
235+
<#
236+
.SYNOPSIS
237+
Releases a same-worktree lock acquired by Enter-WorktreeLock.
238+
#>
239+
param([Parameter(Mandatory)]$LockHandle)
240+
241+
if (-not $LockHandle) {
242+
return
243+
}
244+
245+
$mutex = $LockHandle.Mutex
246+
$lockPath = $LockHandle.LockPath
247+
248+
try {
249+
if ($lockPath -and (Test-Path -LiteralPath $lockPath -PathType Leaf)) {
250+
try {
251+
$owner = $null
252+
try {
253+
$owner = Get-Content -LiteralPath $lockPath -Raw | ConvertFrom-Json
254+
}
255+
catch {
256+
$owner = $null
257+
}
258+
259+
if (-not $owner -or ($owner.Pid -eq $PID)) {
260+
Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue
261+
}
262+
}
263+
catch {
264+
# Best effort only
265+
}
266+
}
267+
}
268+
finally {
269+
if ($mutex) {
270+
try {
271+
$mutex.ReleaseMutex()
272+
}
273+
catch {
274+
# Ignore release failures
275+
}
276+
$mutex.Dispose()
277+
}
278+
}
279+
}
280+
128281
function Test-IsFileLockError {
129282
param([Parameter(Mandatory)][System.Management.Automation.ErrorRecord]$ErrorRecord)
130283

@@ -156,10 +309,28 @@ function Test-IsFileLockError {
156309
}
157310

158311
function Invoke-WithFileLockRetry {
312+
<#
313+
.SYNOPSIS
314+
Runs an action and retries once when a file lock conflict is detected.
315+
.DESCRIPTION
316+
On lock-related failures, performs the same process cleanup used by build/test scripts
317+
before retrying. When RepoRoot is provided, cleanup remains worktree-aware.
318+
.PARAMETER Action
319+
Script block to execute.
320+
.PARAMETER Context
321+
Friendly text used in retry warnings.
322+
.PARAMETER IncludeOmniSharp
323+
Includes OmniSharp processes in cleanup candidates.
324+
.PARAMETER RepoRoot
325+
Optional repository root used to scope cleanup to the current worktree.
326+
.PARAMETER MaxAttempts
327+
Number of attempts (default 2).
328+
#>
159329
param(
160330
[Parameter(Mandatory)][ScriptBlock]$Action,
161331
[Parameter(Mandatory)][string]$Context,
162332
[switch]$IncludeOmniSharp,
333+
[string]$RepoRoot,
163334
[int]$MaxAttempts = 2
164335
)
165336

@@ -173,7 +344,7 @@ function Invoke-WithFileLockRetry {
173344
if ($attempt -lt $MaxAttempts -and (Test-IsFileLockError -ErrorRecord $_)) {
174345
$nextAttempt = $attempt + 1
175346
Write-Host "[WARN] $Context hit a file lock. Cleaning and retrying (attempt $nextAttempt of $MaxAttempts)..." -ForegroundColor Yellow
176-
Stop-ConflictingProcesses -IncludeOmniSharp:$IncludeOmniSharp
347+
Stop-ConflictingProcesses -IncludeOmniSharp:$IncludeOmniSharp -RepoRoot $RepoRoot
177348
Start-Sleep -Seconds 2
178349
$retry = $true
179350
}
@@ -321,6 +492,8 @@ Export-ModuleMember -Function @(
321492
'Get-CvtresDiagnostics',
322493
# Local functions
323494
'Stop-ConflictingProcesses',
495+
'Enter-WorktreeLock',
496+
'Exit-WorktreeLock',
324497
'Remove-StaleObjFolders',
325498
'Test-IsFileLockError',
326499
'Invoke-WithFileLockRetry'

ReadMe.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ FieldWorks uses the **MSBuild Traversal SDK** for declarative, dependency-ordere
4949

5050
For detailed build instructions, see [.github/instructions/build.instructions.md](.github/instructions/build.instructions.md).
5151

52+
### Concurrent worktree builds/tests
53+
54+
`build.ps1` and `test.ps1` use worktree-aware process cleanup, so running scripted builds/tests in different git worktrees does not kill each other.
55+
56+
Within a single worktree, builds and tests run one at a time: scripts acquire a worktree lock and fail fast if another scripted workflow is active.
57+
58+
You can tag lock ownership for diagnostics with `FW_BUILD_STARTED_BY=user|agent` (or `-StartedBy user|agent`).
59+
5260
## Building Installers (WiX 3 default, WiX 6 opt-in)
5361

5462
Installer builds include the additional utilities (UnicodeCharEditor, LCMBrowser, MigrateSqlDbs, etc.).

build.ps1

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
is written next to the built executable. Useful for crash investigation.
4343
4444
.PARAMETER NodeReuse
45-
Enables or disables MSBuild node reuse (/nr). Default is true.
45+
Enables or disables MSBuild node reuse (/nr). Default is false to improve
46+
worktree isolation for concurrent local builds. Set to true to favor speed over isolation.
4647
4748
.PARAMETER MsBuildArgs
4849
Additional arguments to pass directly to MSBuild.
@@ -80,6 +81,10 @@
8081
.PARAMETER LogFile
8182
Path to a file where the build output should be logged.
8283
84+
.PARAMETER StartedBy
85+
Optional actor label written to the worktree lock metadata (for example: user or agent).
86+
Defaults to the FW_BUILD_STARTED_BY environment variable when set, otherwise 'unknown'.
87+
8388
.PARAMETER TailLines
8489
If specified, only displays the last N lines of output after the build completes.
8590
Useful for CI/agent scenarios where you want to see recent output without piping.
@@ -120,7 +125,7 @@ param(
120125
[switch]$BuildAdditionalApps,
121126
[string]$Project = "FieldWorks.proj",
122127
[string]$Verbosity = "minimal",
123-
[bool]$NodeReuse = $true,
128+
[bool]$NodeReuse = $false,
124129
[string[]]$MsBuildArgs = @(),
125130
[string]$LogFile,
126131
[int]$TailLines,
@@ -134,11 +139,20 @@ param(
134139
[switch]$SignInstaller,
135140
[switch]$TraceCrashes,
136141
[switch]$UseLocalLcm,
137-
[string]$LocalLcmPath
142+
[string]$LocalLcmPath,
143+
[ValidateSet('user', 'agent', 'unknown')]
144+
[string]$StartedBy = 'unknown'
138145
)
139146

140147
$ErrorActionPreference = "Stop"
141148

149+
if (-not $PSBoundParameters.ContainsKey('StartedBy') -and -not [string]::IsNullOrWhiteSpace($env:FW_BUILD_STARTED_BY)) {
150+
$startedByFromEnv = $env:FW_BUILD_STARTED_BY.ToLowerInvariant()
151+
if ($startedByFromEnv -in @('user', 'agent', 'unknown')) {
152+
$StartedBy = $startedByFromEnv
153+
}
154+
}
155+
142156
if ($Configuration -like "--*") {
143157
if ($Configuration -eq "--TraceCrashes" -and -not $TraceCrashes) {
144158
$TraceCrashes = $true
@@ -190,7 +204,10 @@ if (-not (Test-Path $helpersPath)) {
190204
}
191205
Import-Module $helpersPath -Force
192206

193-
Stop-ConflictingProcesses -IncludeOmniSharp
207+
$worktreeLock = Enter-WorktreeLock -RepoRoot $PSScriptRoot -Context "FieldWorks build" -StartedBy $StartedBy
208+
209+
# Worktree-aware cleanup: only stop conflicting processes related to this repo root.
210+
Stop-ConflictingProcesses -IncludeOmniSharp -RepoRoot $PSScriptRoot
194211

195212
$fwTasksSourcePath = Join-Path $PSScriptRoot "BuildTools/FwBuildTasks/$Configuration/FwBuildTasks.dll"
196213
$fwTasksDropPath = Join-Path $PSScriptRoot "BuildTools/FwBuildTasks/$Configuration/FwBuildTasks.dll"
@@ -268,7 +285,7 @@ function Get-BuildStampPath {
268285
}
269286

270287
try {
271-
Invoke-WithFileLockRetry -Context "FieldWorks build" -IncludeOmniSharp -Action {
288+
Invoke-WithFileLockRetry -Context "FieldWorks build" -IncludeOmniSharp -RepoRoot $PSScriptRoot -Action {
272289
# Initialize Visual Studio Developer environment
273290
Initialize-VsDevEnvironment
274291
Test-CvtresCompatibility
@@ -528,6 +545,7 @@ try {
528545
Write-Host "Running tests..." -ForegroundColor Cyan
529546

530547
$testArgs = @("-Configuration", $Configuration, "-NoBuild")
548+
$testArgs += "-SkipWorktreeLock"
531549
if ($TestFilter) {
532550
$testArgs += @("-TestFilter", $TestFilter)
533551
}
@@ -543,6 +561,7 @@ try {
543561
finally {
544562
# Kill any lingering build processes that might hold file locks
545563
Stop-ConflictingProcesses @cleanupArgs
564+
Exit-WorktreeLock -LockHandle $worktreeLock
546565
}
547566

548567
if ($testExitCode -ne 0) {

0 commit comments

Comments
 (0)