feat(install): lock destinations during sync
Create <destination>/.ai-config.lock exclusively while syncing so parallel installs or updates fail instead of interleaving writes. A lock left by an exited process of the same shell on the same host is replaced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d4143ae2ac
commit
96313f4056
4 files changed
+97
-1
No files matched your search
@@ -86,6 +86,26 @@ function Write-ManagedManifest {
|
|||||||
Write-ManagedFileAtomically -Path $ManifestPath -Bytes ((New-Object System.Text.UTF8Encoding($false)).GetBytes($content))
|
Write-ManagedFileAtomically -Path $ManifestPath -Bytes ((New-Object System.Text.UTF8Encoding($false)).GetBytes($content))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Enter-ManagedLock {
|
||||||
|
param([Parameter(Mandatory=$true)][string]$Destination)
|
||||||
|
New-Item $Destination -ItemType Directory -Force | Out-Null
|
||||||
|
$lockPath = Join-Path $Destination '.ai-config.lock'
|
||||||
|
$owner = "powershell $([Environment]::MachineName) $PID"
|
||||||
|
foreach ($attempt in 1, 2) {
|
||||||
|
try {
|
||||||
|
$stream = [System.IO.File]::Open($lockPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
|
||||||
|
try { $bytes = [System.Text.Encoding]::UTF8.GetBytes("$owner`n"); $stream.Write($bytes, 0, $bytes.Length) } finally { $stream.Dispose() }
|
||||||
|
return $lockPath
|
||||||
|
} catch [System.IO.IOException] {
|
||||||
|
$holder = @(([System.IO.File]::ReadAllText($lockPath)).Trim() -split ' ')
|
||||||
|
# ponytail: only same-shell, same-host owners are checked for staleness; other stale locks need manual removal.
|
||||||
|
$stale = $attempt -eq 1 -and $holder.Count -eq 3 -and $holder[0] -eq 'powershell' -and $holder[1] -eq [Environment]::MachineName -and -not (Get-Process -Id ([int]$holder[2]) -ErrorAction SilentlyContinue)
|
||||||
|
if (-not $stale) { throw "Another installation or update holds $lockPath; wait for it to finish, or remove the file if no update is running." }
|
||||||
|
[System.IO.File]::Delete($lockPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Invoke-InstallOptions {
|
function Invoke-InstallOptions {
|
||||||
param([Parameter(Mandatory=$true)][string[]]$Arguments, [byte[]]$Managed, [Parameter(Mandatory=$true)][string]$ErrorMessage)
|
param([Parameter(Mandatory=$true)][string[]]$Arguments, [byte[]]$Managed, [Parameter(Mandatory=$true)][string]$ErrorMessage)
|
||||||
# Exchange content through UTF-8 files: Windows PowerShell pipes re-encode text by console code page.
|
# Exchange content through UTF-8 files: Windows PowerShell pipes re-encode text by console code page.
|
||||||
@@ -107,6 +127,30 @@ function Invoke-InstallOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Sync-ManagedDestination {
|
function Sync-ManagedDestination {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Runs Invoke-ManagedSync while holding the destination lock, so parallel
|
||||||
|
installs or updates cannot interleave writes to the same destination.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)][string]$Source,
|
||||||
|
[Parameter(Mandatory=$true)][string]$Destination,
|
||||||
|
[Parameter(Mandatory=$true)][string]$Stamp,
|
||||||
|
[string]$AiConfigRoot,
|
||||||
|
[string]$ShellCommand,
|
||||||
|
[string]$PowerShellCommand,
|
||||||
|
[string]$ClaudeConcurrency = '5',
|
||||||
|
[bool]$FlashbangEnabled = $true,
|
||||||
|
[bool]$StatusLineEnabled = $true,
|
||||||
|
[switch]$DryRun,
|
||||||
|
[switch]$Summary
|
||||||
|
)
|
||||||
|
if ($DryRun) { return Invoke-ManagedSync @PSBoundParameters }
|
||||||
|
$lockPath = Enter-ManagedLock $Destination
|
||||||
|
try { Invoke-ManagedSync @PSBoundParameters } finally { [System.IO.File]::Delete($lockPath) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-ManagedSync {
|
||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Installs one generated source tree into a destination directory, tracking
|
Installs one generated source tree into a destination directory, tracking
|
||||||
|
|||||||
+27
-1
@@ -94,8 +94,34 @@ write_managed_manifest() {
|
|||||||
return "$status"
|
return "$status"
|
||||||
}
|
}
|
||||||
|
|
||||||
# sync_managed_destination <source-dir> <destination-dir> <stamp> <ai-config-root> <shell-command> <powershell-command> <dry-run: true|false> [summary: true|false] [flashbang] [claude-concurrency] [statusline]
|
# acquire_managed_lock <destination>: create <destination>/.ai-config.lock exclusively.
|
||||||
|
acquire_managed_lock() {
|
||||||
|
local lock="$1/.ai-config.lock" owner holder_shell holder_host holder_pid
|
||||||
|
mkdir -p -- "$1"
|
||||||
|
owner="bash $(uname -n) $$"
|
||||||
|
if (set -o noclobber; printf '%s\n' "$owner" > "$lock") 2>/dev/null; then return 0; fi
|
||||||
|
read -r holder_shell holder_host holder_pid < "$lock" 2>/dev/null || true
|
||||||
|
# ponytail: only same-shell, same-host owners are checked for staleness; other stale locks need manual removal.
|
||||||
|
if [ "$holder_shell" = bash ] && [ "$holder_host" = "$(uname -n)" ] && [[ "$holder_pid" =~ ^[0-9]+$ ]] && ! kill -0 "$holder_pid" 2>/dev/null; then
|
||||||
|
rm -f -- "$lock"
|
||||||
|
if (set -o noclobber; printf '%s\n' "$owner" > "$lock") 2>/dev/null; then return 0; fi
|
||||||
|
fi
|
||||||
|
printf 'Another installation or update holds %s; wait for it to finish, or remove the file if no update is running.\n' "$lock" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# sync_managed_destination: same arguments as run_managed_sync; holds the destination lock unless dry-running.
|
||||||
sync_managed_destination() {
|
sync_managed_destination() {
|
||||||
|
[ "$7" != true ] || { run_managed_sync "$@"; return; }
|
||||||
|
acquire_managed_lock "$2" || return 1
|
||||||
|
local status=0
|
||||||
|
run_managed_sync "$@" || status=$?
|
||||||
|
rm -f -- "$2/.ai-config.lock"
|
||||||
|
return "$status"
|
||||||
|
}
|
||||||
|
|
||||||
|
# run_managed_sync <source-dir> <destination-dir> <stamp> <ai-config-root> <shell-command> <powershell-command> <dry-run: true|false> [summary: true|false] [flashbang] [claude-concurrency] [statusline]
|
||||||
|
run_managed_sync() {
|
||||||
local source=$1 destination=$2 stamp=$3 ai_config_root=$4 shell_command=$5 powershell_command=$6 dry_run=$7 summary=${8:-false} flashbang_enabled=${9:-true} claude_concurrency=${10:-5} statusline_enabled=${11:-true}
|
local source=$1 destination=$2 stamp=$3 ai_config_root=$4 shell_command=$5 powershell_command=$6 dry_run=$7 summary=${8:-false} flashbang_enabled=${9:-true} claude_concurrency=${10:-5} statusline_enabled=${11:-true}
|
||||||
[ "$summary" = true ] || printf 'SOURCE %s -> %s\n' "$source" "$destination"
|
[ "$summary" = true ] || printf 'SOURCE %s -> %s\n' "$source" "$destination"
|
||||||
local manifest
|
local manifest
|
||||||
|
|||||||
@@ -119,6 +119,21 @@ try {
|
|||||||
Set-Content (Join-Path $source 'concurrency.txt') '__CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY__' -NoNewline
|
Set-Content (Join-Path $source 'concurrency.txt') '__CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY__' -NoNewline
|
||||||
$null = Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'concurrency' -ClaudeConcurrency '7'
|
$null = Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'concurrency' -ClaudeConcurrency '7'
|
||||||
if ((Get-Content (Join-Path $destination 'concurrency.txt') -Raw) -ne '7') { throw 'Standalone concurrency placeholder must be resolved.' }
|
if ((Get-Content (Join-Path $destination 'concurrency.txt') -Raw) -ne '7') { throw 'Standalone concurrency placeholder must be resolved.' }
|
||||||
|
if (@(Get-ChildItem -LiteralPath $destination -Filter '.ai-config-write-*' -File -Recurse).Count) { throw 'Atomic writes left a temporary file.' }
|
||||||
|
if (Test-Path -LiteralPath (Join-Path $destination '.ai-config.lock')) { throw 'Sync must release its destination lock.' }
|
||||||
|
|
||||||
|
$lockPath = Join-Path $destination '.ai-config.lock'
|
||||||
|
[System.IO.File]::WriteAllText($lockPath, "bash other-host 1`n")
|
||||||
|
$manifestBefore = [System.IO.File]::ReadAllText((Get-ManagedManifestPath $destination))
|
||||||
|
$blocked = $false
|
||||||
|
try { $null = Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'locked' } catch { $blocked = $_.Exception.Message -like 'Another installation or update holds*' }
|
||||||
|
if (-not $blocked) { throw 'A held destination lock must block a parallel sync.' }
|
||||||
|
if ([System.IO.File]::ReadAllText((Get-ManagedManifestPath $destination)) -ne $manifestBefore) { throw 'A blocked sync must not write the manifest.' }
|
||||||
|
$exited = Start-Process -FilePath (Get-Process -Id $PID).Path -ArgumentList '-NoProfile', '-Command', 'exit 0' -PassThru -WindowStyle Hidden
|
||||||
|
$exited.WaitForExit()
|
||||||
|
[System.IO.File]::WriteAllText($lockPath, "powershell $([Environment]::MachineName) $($exited.Id)`n")
|
||||||
|
$null = Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'stale-lock'
|
||||||
|
if (Test-Path -LiteralPath $lockPath) { throw 'A stale lock from an exited process must be replaced and released.' }
|
||||||
|
|
||||||
if ($Summary) { Write-Output 'Tests: PASS | managed install' } else { Write-Output 'PASS managed manifest: idempotent, stale removal, modified-file protection, foreign files preserved, dry run side-effect free, skill migration' }
|
if ($Summary) { Write-Output 'Tests: PASS | managed install' } else { Write-Output 'PASS managed manifest: idempotent, stale removal, modified-file protection, foreign files preserved, dry run side-effect free, skill migration' }
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -135,5 +135,16 @@ sync_managed_destination "${targets[0]%%|*}" "${targets[0]#*|}" skill-remove ""
|
|||||||
printf '__CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY__' > "$source_dir/concurrency.txt"
|
printf '__CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY__' > "$source_dir/concurrency.txt"
|
||||||
sync_managed_destination "$source_dir" "$destination" concurrency "" "" "" false false true 7 > /dev/null
|
sync_managed_destination "$source_dir" "$destination" concurrency "" "" "" false false true 7 > /dev/null
|
||||||
[ "$(cat "$destination/concurrency.txt")" = 7 ] || { printf 'Standalone concurrency placeholder must be resolved.\n' >&2; exit 1; }
|
[ "$(cat "$destination/concurrency.txt")" = 7 ] || { printf 'Standalone concurrency placeholder must be resolved.\n' >&2; exit 1; }
|
||||||
|
[ -z "$(find "$destination" -name '.ai-config-write.*' -print -quit)" ] || { printf 'Atomic writes left a temporary file.\n' >&2; exit 1; }
|
||||||
|
[ ! -e "$destination/.ai-config.lock" ] || { printf 'Sync must release its destination lock.\n' >&2; exit 1; }
|
||||||
|
printf 'powershell other-host 1\n' > "$destination/.ai-config.lock"
|
||||||
|
manifest_before=$(cat "$destination/.ai-config-manifest.tsv")
|
||||||
|
if sync_managed_destination "$source_dir" "$destination" locked "" "" "" false > /dev/null 2>&1; then printf 'A held destination lock must block a parallel sync.\n' >&2; exit 1; fi
|
||||||
|
[ "$(cat "$destination/.ai-config-manifest.tsv")" = "$manifest_before" ] || { printf 'A blocked sync must not write the manifest.\n' >&2; exit 1; }
|
||||||
|
bash -c 'exit 0' & exited_pid=$!
|
||||||
|
wait "$exited_pid"
|
||||||
|
printf 'bash %s %s\n' "$(uname -n)" "$exited_pid" > "$destination/.ai-config.lock"
|
||||||
|
sync_managed_destination "$source_dir" "$destination" stale-lock "" "" "" false > /dev/null
|
||||||
|
[ ! -e "$destination/.ai-config.lock" ] || { printf 'A stale lock from an exited process must be replaced and released.\n' >&2; exit 1; }
|
||||||
|
|
||||||
if [ "$summary" = true ]; then printf 'Tests: PASS | managed install\n'; else printf 'PASS managed manifest: idempotent, stale removal, modified-file protection, foreign files preserved, dry run side-effect free, skill migration\n'; fi
|
if [ "$summary" = true ]; then printf 'Tests: PASS | managed install\n'; else printf 'PASS managed manifest: idempotent, stale removal, modified-file protection, foreign files preserved, dry run side-effect free, skill migration\n'; fi
|
||||||
Reference in new issue
Block a user