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:
2026-07-31 02:34:41 +03:00
parent f0fc85de03
commit 0ed211fe16
2 changed files with 175 additions and 8 deletions
+59 -8
View File
@@ -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()
}
}
+116
View File
@@ -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 }
}
}