Skip to content

Commit b738d41

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

7 files changed

Lines changed: 367 additions & 17 deletions

File tree

.github/instructions/build.instructions.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,23 @@ 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 `auto`.
56+
- In `auto` mode, MSBuild node reuse is enabled when the repository has a single local worktree and disabled when multiple local worktrees are detected, reducing cross-worktree lock contention from shared worker nodes.
57+
- You can still override the policy explicitly with:
58+
59+
```powershell
60+
.\build.ps1 -NodeReuse $true
61+
.\build.ps1 -NodeReuse $false
62+
```
63+
4764
## Troubleshooting (common)
4865

4966
### “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.

.vscode/settings.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@
4040
"/^(&\\s+)?git\\s+(worktree\\s+list|remote(\\s+(-v|show))?|config\\s+--get)(?!.*[;&|<>])/": true,
4141
"/^(&\\s+)?git\\s+stash(\\s+(push|apply|pop|list|show|drop|branch)(\\s+[^;&|<>]+)*)?(?!.*[;&|<>])/": true,
4242

43+
// GitHub CLI read-only operations only
44+
// Explicitly allow read verbs (view/list/status/search) and block chained/redirection patterns.
45+
"/^(&\\s+)?gh\\s+(pr|issue|repo|run|release|workflow|label|milestone|project|discussion|gist)\\s+(view|list|status)\\b(?!.*[;&|<>])/i": true,
46+
"/^(&\\s+)?gh\\s+search\\s+(prs|issues|repos|commits|code)\\b(?!.*[;&|<>])/i": true,
47+
// gh api is approved only for non-GraphQL calls when no explicit mutating method is specified.
48+
// gh api graphql always uses POST, so require manual approval for any graphql invocation.
49+
"/^(&\\s+)?gh\\s+api\\b(?!.*\\bgraphql\\b)(?!.*\\s(-X|--method)\\s*(POST|PUT|PATCH|DELETE)\\b)(?!.*[;&|<>])/i": true,
50+
4351
// Python/scripting (consolidated)
4452
"/^(&\\s+)?python\\s+(scripts\\/|-m|\\.github\\/)(?!.*[;&|<>])/": true,
4553
"/^(&\\s+)?\\.[\\\\/]scripts[\\\\/](?!.*[;&|<>])/": true,

Build/Agent/FwBuildHelpers.psm1

Lines changed: 189 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ function Stop-ConflictingProcesses {
8181
# Filter by RepoRoot (Smart Kill) - only kill processes locking files in this repo
8282
if ($RepoRoot) {
8383
$processesToKill = @()
84-
$RepoRoot = $RepoRoot.TrimEnd('\').TrimEnd('/')
84+
$RepoRoot = $RepoRoot.TrimEnd([char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar))
8585

8686
foreach ($p in $processes) {
8787
if ($p.Id -eq $PID) { continue } # Don't kill self
@@ -125,6 +125,173 @@ function Stop-ConflictingProcesses {
125125
}
126126
}
127127

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

@@ -156,10 +323,28 @@ function Test-IsFileLockError {
156323
}
157324

158325
function Invoke-WithFileLockRetry {
326+
<#
327+
.SYNOPSIS
328+
Runs an action and retries once when a file lock conflict is detected.
329+
.DESCRIPTION
330+
On lock-related failures, performs the same process cleanup used by build/test scripts
331+
before retrying. When RepoRoot is provided, cleanup remains worktree-aware.
332+
.PARAMETER Action
333+
Script block to execute.
334+
.PARAMETER Context
335+
Friendly text used in retry warnings.
336+
.PARAMETER IncludeOmniSharp
337+
Includes OmniSharp processes in cleanup candidates.
338+
.PARAMETER RepoRoot
339+
Optional repository root used to scope cleanup to the current worktree.
340+
.PARAMETER MaxAttempts
341+
Number of attempts (default 2).
342+
#>
159343
param(
160344
[Parameter(Mandatory)][ScriptBlock]$Action,
161345
[Parameter(Mandatory)][string]$Context,
162346
[switch]$IncludeOmniSharp,
347+
[string]$RepoRoot,
163348
[int]$MaxAttempts = 2
164349
)
165350

@@ -173,7 +358,7 @@ function Invoke-WithFileLockRetry {
173358
if ($attempt -lt $MaxAttempts -and (Test-IsFileLockError -ErrorRecord $_)) {
174359
$nextAttempt = $attempt + 1
175360
Write-Host "[WARN] $Context hit a file lock. Cleaning and retrying (attempt $nextAttempt of $MaxAttempts)..." -ForegroundColor Yellow
176-
Stop-ConflictingProcesses -IncludeOmniSharp:$IncludeOmniSharp
361+
Stop-ConflictingProcesses -IncludeOmniSharp:$IncludeOmniSharp -RepoRoot $RepoRoot
177362
Start-Sleep -Seconds 2
178363
$retry = $true
179364
}
@@ -321,6 +506,8 @@ Export-ModuleMember -Function @(
321506
'Get-CvtresDiagnostics',
322507
# Local functions
323508
'Stop-ConflictingProcesses',
509+
'Enter-WorktreeLock',
510+
'Exit-WorktreeLock',
324511
'Remove-StaleObjFolders',
325512
'Test-IsFileLockError',
326513
'Invoke-WithFileLockRetry'

ReadMe.md

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

3737
For detailed build instructions, see [.github/instructions/build.instructions.md](.github/instructions/build.instructions.md).
3838

39+
### Concurrent worktree builds/tests
40+
41+
`build.ps1` and `test.ps1` use worktree-aware process cleanup, so running scripted builds/tests in different git worktrees does not kill each other.
42+
43+
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.
44+
45+
You can tag lock ownership for diagnostics with `FW_BUILD_STARTED_BY=user|agent` (or `-StartedBy user|agent`).
46+
3947
## Building Installers (WiX 3 default, WiX 6 opt-in)
4048

4149
Installer builds default to **WiX 3** (legacy batch pipeline) using inputs in `FLExInstaller/` and `PatchableInstaller/`. The **Visual Studio WiX Toolset v3 extension** is required so `Wix.CA.targets` is available under the MSBuild extensions path. Use `-InstallerToolset Wix6` to opt into the WiX 6 SDK-style path (restored via NuGet).

0 commit comments

Comments
 (0)