fix(plugin): mutex AbandonedMutexException, валидация server.lock.json, тесты гонки
Три предметных замечания ревью качества: - Resolve-KompasMcpExecutable: WaitOne() перенесён в try/finally, отдельно ловится AbandonedMutexException (владелец умер посреди установки — владение переходит к нам, это не ошибка); ReleaseMutex вызывается только если mutex реально захвачен, иначе он бросает SynchronizationLockException и маскирует первопричину. Добавлен -Downloader passthrough для тестируемости. - Read-KompasMcpLock: разбор JSON обёрнут в try/catch с сообщением, называющим файл; наличие полей version/url/sha256 проверяется через PSObject.Properties.Name (обращение к отсутствующему свойству под Set-StrictMode -Version Latest бросает PropertyNotFoundException); sha256 валидируется на 64 hex-символа. - Добавлены тесты на конкурентную логику: Resolve-KompasMcpExecutable (повторный вызов не скачивает), гонка "сосед выиграл" (целевой каталог уже содержит годную/негодную установку) в Install-KompasMcpServer, счастливый путь и все новые ветки валидации в Read-KompasMcpLock. Мелочи: & $Downloader ... | Out-Null (внедряемый загрузчик не должен писать в success stream); уточнён комментарий Test-KompasMcpInstall — оно намеренно трактует любую ошибку чтения маркера (не только битый JSON, но и блокировку антивирусом) как "установка негодная". AbandonedMutexException-ветка вручную воспроизведена и проверена: процесс- владелец mutex убит, пока другой процесс уже блокирован в WaitOne() — тот получает AbandonedMutexException и корректно продолжает работу.
This commit is contained in:
@@ -7,11 +7,37 @@ function Read-KompasMcpLock {
|
||||
param([Parameter(Mandatory)] [string] $Path)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path)) { throw "нет файла пина версии: $Path" }
|
||||
$lock = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
|
||||
|
||||
if ($lock.version -eq '0.0.0' -or [string]::IsNullOrWhiteSpace($lock.url)) {
|
||||
$raw = Get-Content -LiteralPath $Path -Raw
|
||||
try {
|
||||
$lock = $raw | ConvertFrom-Json
|
||||
}
|
||||
catch {
|
||||
throw "не удалось разобрать server.lock.json ($Path): $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Под Set-StrictMode -Version Latest обращение к отсутствующему свойству
|
||||
# PSCustomObject бросает PropertyNotFoundException — поэтому сначала проверяем
|
||||
# наличие ключа через PSObject.Properties.Name и только потом читаем значение.
|
||||
$propNames = @($lock.PSObject.Properties.Name)
|
||||
|
||||
if (-not ($propNames -contains 'version') -or [string]::IsNullOrWhiteSpace([string]$lock.version)) {
|
||||
throw "server.lock.json ($Path) повреждён: нет поля version"
|
||||
}
|
||||
$version = [string]$lock.version
|
||||
|
||||
if ($version -eq '0.0.0') {
|
||||
throw "релиз сервера ещё не опубликован (server.lock.json пуст). Соберите сервер локально и укажите путь в KOMPAS_MCP_EXE."
|
||||
}
|
||||
|
||||
if (-not ($propNames -contains 'url') -or [string]::IsNullOrWhiteSpace([string]$lock.url)) {
|
||||
throw "server.lock.json ($Path) указывает опубликованную версию $version, но не содержит url для загрузки"
|
||||
}
|
||||
|
||||
if (-not ($propNames -contains 'sha256') -or [string]::IsNullOrWhiteSpace([string]$lock.sha256) -or ([string]$lock.sha256 -notmatch '^[0-9a-fA-F]{64}$')) {
|
||||
throw "server.lock.json ($Path) указывает опубликованную версию $version, но sha256 отсутствует или некорректен (ожидались 64 hex-символа)"
|
||||
}
|
||||
|
||||
return $lock
|
||||
}
|
||||
|
||||
@@ -31,7 +57,11 @@ function Test-KompasMcpInstall {
|
||||
$installed = Get-Content -LiteralPath $marker -Raw | ConvertFrom-Json
|
||||
}
|
||||
catch {
|
||||
# Битый/чужой маркер — считаем установку негодной, а не падаем.
|
||||
# Намеренно ловим ЛЮБУЮ ошибку чтения/разбора маркера — не только битый
|
||||
# JSON, но и, например, файл, временно заблокированный антивирусом.
|
||||
# В любом из этих случаев правильный ответ один: считать установку
|
||||
# негодной и позволить Install-KompasMcpServer перекачать заново,
|
||||
# а не падать здесь с сырым исключением.
|
||||
return $false
|
||||
}
|
||||
|
||||
@@ -64,7 +94,10 @@ function Install-KompasMcpServer {
|
||||
|
||||
try {
|
||||
$zip = Join-Path $work 'server.zip'
|
||||
& $Downloader $Lock.url $zip
|
||||
# Downloader внедряемый (в проде — Invoke-WebRequest, в тестах — фейк);
|
||||
# | Out-Null на случай, если чужая реализация случайно вернёт объект
|
||||
# в success stream — stdout занят JSON-RPC, лишний вывод недопустим.
|
||||
& $Downloader $Lock.url $zip | Out-Null
|
||||
if (-not (Test-Path -LiteralPath $zip)) { throw "загрузка не создала файл: $($Lock.url)" }
|
||||
|
||||
$actual = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
@@ -116,7 +149,11 @@ function Resolve-KompasMcpExecutable {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] $Lock,
|
||||
[string] $InstallRoot
|
||||
[string] $InstallRoot,
|
||||
# Пробрасывается в Install-KompasMcpServer — прод по умолчанию использует
|
||||
# Invoke-WebRequest; параметр существует в первую очередь для тестов,
|
||||
# чтобы прогонять конкурентную/mutex-логику без реальной сети.
|
||||
[scriptblock] $Downloader
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($InstallRoot)) {
|
||||
@@ -133,13 +170,27 @@ function Resolve-KompasMcpExecutable {
|
||||
# защищают проверка целевого каталога, сверка SHA256 и обработка IOException
|
||||
# при переносе каталога.
|
||||
$mutex = New-Object System.Threading.Mutex($false, "kompas-mcp-install-$($Lock.version)")
|
||||
[void]$mutex.WaitOne()
|
||||
$acquired = $false
|
||||
try {
|
||||
$dir = Install-KompasMcpServer -Lock $Lock -InstallRoot $InstallRoot
|
||||
try {
|
||||
$acquired = $mutex.WaitOne()
|
||||
}
|
||||
catch [System.Threading.AbandonedMutexException] {
|
||||
# Предыдущий владелец умер, не освободив mutex посреди установки
|
||||
# (оборванная закачка, таймаут MCP-хоста, антивирус остановил процесс) —
|
||||
# ровно тот сценарий, ради которого mutex и заведён. .NET в этом случае
|
||||
# всё равно передаёт владение текущему потоку, поэтому продолжаем
|
||||
# работу как обычно, а не считаем это ошибкой.
|
||||
$acquired = $true
|
||||
}
|
||||
|
||||
$dir = Install-KompasMcpServer -Lock $Lock -InstallRoot $InstallRoot -Downloader $Downloader
|
||||
return (Join-Path $dir 'kompas-mcp.exe')
|
||||
}
|
||||
finally {
|
||||
$mutex.ReleaseMutex()
|
||||
# ReleaseMutex зовём только если реально владеем mutex'ом — иначе он
|
||||
# бросает SynchronizationLockException и маскирует настоящую ошибку.
|
||||
if ($acquired) { $mutex.ReleaseMutex() }
|
||||
$mutex.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,66 @@ Describe 'Install-KompasMcpServer' {
|
||||
Test-KompasMcpInstall -Directory $dir -Lock $rel.Lock | Should -BeFalse
|
||||
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
It 'гонка: если целевой каталог версии уже содержит годную установку, не скачивает и возвращает его путь' {
|
||||
$root = New-TempRoot
|
||||
try {
|
||||
$rel = New-FakeRelease -Root $root -Version '1.0.0'
|
||||
$installRoot = Join-Path $root 'install'
|
||||
$target = Join-Path $installRoot '1.0.0'
|
||||
|
||||
# Имитируем "соседа", который уже успешно установил версию раньше нас.
|
||||
New-Item -ItemType Directory -Path $target -Force | Out-Null
|
||||
Set-Content -LiteralPath (Join-Path $target 'kompas-mcp.exe') -Value 'not-a-real-exe' -Encoding ascii
|
||||
$neighborMarker = [ordered]@{ version = $rel.Lock.version; sha256 = $rel.Lock.sha256 }
|
||||
Set-Content -LiteralPath (Join-Path $target 'install.json') -Value ($neighborMarker | ConvertTo-Json) -Encoding utf8
|
||||
|
||||
$script:calls = 0
|
||||
$downloader = { param($Url, $Destination) $script:calls++; Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force }
|
||||
|
||||
$dir = Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader $downloader
|
||||
|
||||
$dir | Should -Be $target
|
||||
$script:calls | Should -Be 0
|
||||
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
It 'гонка: если целевой каталог версии существует, но установка в нём негодная, бросает внятную ошибку' {
|
||||
$root = New-TempRoot
|
||||
try {
|
||||
$rel = New-FakeRelease -Root $root -Version '1.0.0'
|
||||
$installRoot = Join-Path $root 'install'
|
||||
$target = Join-Path $installRoot '1.0.0'
|
||||
|
||||
# Каталог версии существует (например, остался от оборванной установки),
|
||||
# но без валидного маркера/exe — установка в нём негодная.
|
||||
New-Item -ItemType Directory -Path $target -Force | Out-Null
|
||||
|
||||
{ Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader {
|
||||
param($Url, $Destination) Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force
|
||||
} } | Should -Throw '*негодная*'
|
||||
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Resolve-KompasMcpExecutable' {
|
||||
It 'возвращает путь к exe и не скачивает повторно при втором вызове' {
|
||||
$root = New-TempRoot
|
||||
try {
|
||||
$rel = New-FakeRelease -Root $root -Version '1.0.0'
|
||||
$installRoot = Join-Path $root 'install'
|
||||
$script:calls = 0
|
||||
$downloader = { param($Url, $Destination) $script:calls++; Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force }
|
||||
|
||||
$exe1 = Resolve-KompasMcpExecutable -Lock $rel.Lock -InstallRoot $installRoot -Downloader $downloader
|
||||
$exe2 = Resolve-KompasMcpExecutable -Lock $rel.Lock -InstallRoot $installRoot -Downloader $downloader
|
||||
|
||||
$exe1 | Should -Be (Join-Path $installRoot '1.0.0' 'kompas-mcp.exe')
|
||||
$exe2 | Should -Be $exe1
|
||||
Test-Path -LiteralPath $exe1 | Should -BeTrue
|
||||
$script:calls | Should -Be 1
|
||||
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Read-KompasMcpLock' {
|
||||
@@ -98,4 +158,60 @@ Describe 'Read-KompasMcpLock' {
|
||||
{ Read-KompasMcpLock -Path $path } | Should -Throw '*релиз*'
|
||||
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
It 'счастливый путь: валидный опубликованный лок разбирается и возвращает поля' {
|
||||
$root = New-TempRoot
|
||||
try {
|
||||
$path = Join-Path $root 'server.lock.json'
|
||||
$sha = '1' * 64
|
||||
Set-Content -LiteralPath $path -Value "{`"version`":`"1.2.3`",`"sourceSha`":`"deadbeef`",`"url`":`"https://example.invalid/release.zip`",`"sha256`":`"$sha`"}" -Encoding utf8
|
||||
|
||||
$lock = Read-KompasMcpLock -Path $path
|
||||
|
||||
$lock.version | Should -Be '1.2.3'
|
||||
$lock.url | Should -Be 'https://example.invalid/release.zip'
|
||||
$lock.sha256 | Should -Be $sha
|
||||
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
It 'падает внятно на битом JSON' {
|
||||
$root = New-TempRoot
|
||||
try {
|
||||
$path = Join-Path $root 'server.lock.json'
|
||||
Set-Content -LiteralPath $path -Value '{ не json вообще' -Encoding utf8
|
||||
|
||||
{ Read-KompasMcpLock -Path $path } | Should -Throw '*server.lock.json*'
|
||||
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
It 'падает внятно, если в валидном JSON нет поля version' {
|
||||
$root = New-TempRoot
|
||||
try {
|
||||
$path = Join-Path $root 'server.lock.json'
|
||||
Set-Content -LiteralPath $path -Value '{"sourceSha":"deadbeef","url":"https://example.invalid/release.zip","sha256":"1111111111111111111111111111111111111111111111111111111111111111"}' -Encoding utf8
|
||||
|
||||
{ Read-KompasMcpLock -Path $path } | Should -Throw '*version*'
|
||||
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
It 'падает внятно, если у опубликованной версии нет поля url' {
|
||||
$root = New-TempRoot
|
||||
try {
|
||||
$path = Join-Path $root 'server.lock.json'
|
||||
$sha = '1' * 64
|
||||
Set-Content -LiteralPath $path -Value "{`"version`":`"1.2.3`",`"sourceSha`":`"deadbeef`",`"sha256`":`"$sha`"}" -Encoding utf8
|
||||
|
||||
{ Read-KompasMcpLock -Path $path } | Should -Throw '*url*'
|
||||
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
It 'падает внятно, если у опубликованной версии sha256 пустой или некорректной длины' {
|
||||
$root = New-TempRoot
|
||||
try {
|
||||
$path = Join-Path $root 'server.lock.json'
|
||||
Set-Content -LiteralPath $path -Value '{"version":"1.2.3","sourceSha":"deadbeef","url":"https://example.invalid/release.zip","sha256":"beef"}' -Encoding utf8
|
||||
|
||||
{ Read-KompasMcpLock -Path $path } | Should -Throw '*sha256*'
|
||||
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user