Уборка брошенных временных каталогов установки сервера
ci / build (push) Failing after 27s

finally в Install-KompasMcpServer спасает только от штатного throw/return.
После принудительного убийства процесса посреди установки каталог
.tmp\<guid> оставался навсегда — по ~70 МБ на оборванный запуск, сервер
публикуется self-contained.

Remove-KompasMcpStaleTemp удаляет подкаталоги .tmp старше суток. Возраст
считается как максимум LastWriteTimeUtc по всему поддереву: пока сосед
льёт байты в один и тот же server.zip, время записи самого каталога не
меняется, и по нему можно было бы снести живую установку.

Вызов стоит в начале Install-KompasMcpServer, до раннего выхода по
Test-KompasMcpInstall — иначе мусор от оборванных попыток лежал бы до
следующего релиза. Если каталога .tmp нет, всё сводится к одному
Test-Path.

Функция не бросает исключений и не пишет в success stream: запуск
сервера важнее мусора, а stdout занят JSON-RPC.
This commit is contained in:
2026-07-31 08:24:16 +03:00
parent 5b1af4ab39
commit e56132de33
2 changed files with 172 additions and 1 deletions
+48 -1
View File
@@ -79,6 +79,46 @@ function Test-KompasMcpInstall {
return ($installed.version -eq $Lock.version) -and ($installed.sha256 -eq $Lock.sha256)
}
function Remove-KompasMcpStaleTemp {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $TempRoot,
[timespan] $MaxAge = ([timespan]::FromDays(1))
)
# Уборка мусора не имеет права мешать запуску сервера: любая ошибка (каталог
# держит соседний процесс, отказано в доступе, гонка на файловой системе)
# гасится здесь. Диагностика уходит в verbose stream — stdout занят JSON-RPC.
try {
if (-not (Test-Path -LiteralPath $TempRoot)) { return }
$deadline = (Get-Date).ToUniversalTime() - $MaxAge
foreach ($dir in (Get-ChildItem -LiteralPath $TempRoot -Directory -Force -ErrorAction SilentlyContinue)) {
try {
# Время записи самого каталога не обновляется, пока сосед льёт в
# него один и тот же server.zip (меняется файл, а не список
# содержимого), поэтому за «живость» отвечает максимум по всему
# поддереву — иначе можно снести чужую активную установку.
$touched = $dir.LastWriteTimeUtc
foreach ($item in (Get-ChildItem -LiteralPath $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue)) {
if ($item.LastWriteTimeUtc -gt $touched) { $touched = $item.LastWriteTimeUtc }
}
if ($touched -lt $deadline) {
Remove-Item -LiteralPath $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue
}
}
catch {
Write-Verbose "не удалось убрать временный каталог $($dir.FullName): $($_.Exception.Message)"
}
}
}
catch {
Write-Verbose "не удалось обойти временный каталог ${TempRoot}: $($_.Exception.Message)"
}
}
function Install-KompasMcpServer {
[CmdletBinding()]
param(
@@ -87,6 +127,13 @@ function Install-KompasMcpServer {
[scriptblock] $Downloader
)
# Подметаем брошенные .tmp\<guid> ДО проверки уже установленной версии: finally
# ниже спасает только от штатного throw/return, а после принудительного убийства
# процесса (kill, перезагрузка) посреди установки каталог остаётся навсегда —
# по ~70 МБ на оборванный запуск, сервер публикуется self-contained. Место в
# горячем пути дешёвое: без каталога .tmp всё сводится к одному Test-Path.
Remove-KompasMcpStaleTemp -TempRoot (Join-Path $InstallRoot '.tmp')
$target = Join-Path $InstallRoot $Lock.version
if (Test-KompasMcpInstall -Directory $target -Lock $Lock) { return $target }
@@ -206,4 +253,4 @@ function Resolve-KompasMcpExecutable {
}
}
Export-ModuleMember -Function Read-KompasMcpLock, Test-KompasMcpInstall, Install-KompasMcpServer, Resolve-KompasMcpExecutable
Export-ModuleMember -Function Read-KompasMcpLock, Test-KompasMcpInstall, Remove-KompasMcpStaleTemp, Install-KompasMcpServer, Resolve-KompasMcpExecutable
+124
View File
@@ -23,6 +23,19 @@ BeforeAll {
Lock = [pscustomobject]@{ version = $Version; sourceSha = 'deadbeef'; url = "file:///$zip"; sha256 = $sha }
}
}
# Имитирует брошенный временный каталог установки: с файлом внутри и заданным
# «возрастом» (сдвигаем время записи и каталога, и содержимого).
function New-TempWorkDir([string] $TempRoot, [string] $Name, [timespan] $Age) {
$dir = Join-Path $TempRoot $Name
New-Item -ItemType Directory -Path $dir -Force | Out-Null
Set-Content -LiteralPath (Join-Path $dir 'server.zip') -Value 'недокачанный архив' -Encoding ascii
$stamp = (Get-Date).ToUniversalTime() - $Age
Get-ChildItem -LiteralPath $dir -Recurse -Force | ForEach-Object { $_.LastWriteTimeUtc = $stamp }
(Get-Item -LiteralPath $dir).LastWriteTimeUtc = $stamp
return $dir
}
}
Describe 'Install-KompasMcpServer' {
@@ -126,6 +139,117 @@ Describe 'Install-KompasMcpServer' {
} } | Should -Throw '*негодная*'
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
# Без угловых скобок в имени: Pester трактует <...> как подстановку переменной.
It 'убирает брошенный временный каталог от оборванной прошлой установки' {
$root = New-TempRoot
try {
$rel = New-FakeRelease -Root $root -Version '1.0.0'
$installRoot = Join-Path $root 'install'
$tempRoot = Join-Path $installRoot '.tmp'
New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null
$abandoned = New-TempWorkDir -TempRoot $tempRoot -Name 'abandoned' -Age ([timespan]::FromDays(3))
$dir = Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader {
param($Url, $Destination) Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force
}
Test-Path -LiteralPath $abandoned | Should -BeFalse
Test-Path -LiteralPath (Join-Path $dir 'kompas-mcp.exe') | Should -BeTrue
} 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'
$downloader = { param($Url, $Destination) Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force }
Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader $downloader | Out-Null
$abandoned = New-TempWorkDir -TempRoot (Join-Path $installRoot '.tmp') -Name 'abandoned' -Age ([timespan]::FromDays(3))
Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader $downloader | Out-Null
Test-Path -LiteralPath $abandoned | Should -BeFalse
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
}
Describe 'Remove-KompasMcpStaleTemp' {
It 'удаляет каталог старше порога и не трогает свежий' {
$root = New-TempRoot
try {
$tempRoot = Join-Path $root '.tmp'
New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null
$stale = New-TempWorkDir -TempRoot $tempRoot -Name 'stale' -Age ([timespan]::FromDays(2))
$fresh = New-TempWorkDir -TempRoot $tempRoot -Name 'fresh' -Age ([timespan]::FromMinutes(5))
Remove-KompasMcpStaleTemp -TempRoot $tempRoot -MaxAge ([timespan]::FromDays(1))
Test-Path -LiteralPath $stale | Should -BeFalse
Test-Path -LiteralPath $fresh | Should -BeTrue
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'не трогает чужую активную установку: каталог старый, но внутрь только что писали' {
$root = New-TempRoot
try {
$tempRoot = Join-Path $root '.tmp'
New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null
# Сосед начал качать давно и всё ещё льёт байты в тот же server.zip:
# время записи самого каталога с тех пор не менялось, время файла — да.
$active = New-TempWorkDir -TempRoot $tempRoot -Name 'active' -Age ([timespan]::FromDays(2))
(Get-Item -LiteralPath (Join-Path $active 'server.zip')).LastWriteTimeUtc = (Get-Date).ToUniversalTime()
Remove-KompasMcpStaleTemp -TempRoot $tempRoot -MaxAge ([timespan]::FromDays(1))
Test-Path -LiteralPath $active | Should -BeTrue
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'молча переживает отсутствующий .tmp' {
$root = New-TempRoot
try {
{ Remove-KompasMcpStaleTemp -TempRoot (Join-Path $root '.tmp') } | Should -Not -Throw
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'не падает на неудаляемом каталоге и всё равно убирает остальные' {
$root = New-TempRoot
$stream = $null
try {
$tempRoot = Join-Path $root '.tmp'
New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null
$locked = New-TempWorkDir -TempRoot $tempRoot -Name 'locked' -Age ([timespan]::FromDays(2))
$stale = New-TempWorkDir -TempRoot $tempRoot -Name 'stale' -Age ([timespan]::FromDays(2))
# Открытый дескриптор не даёт Windows удалить файл — Remove-Item обязан
# отработать вхолостую, а не сорвать уборку остального мусора.
$stream = [System.IO.File]::Open((Join-Path $locked 'server.zip'), 'Open', 'Read', 'None')
{ Remove-KompasMcpStaleTemp -TempRoot $tempRoot -MaxAge ([timespan]::FromDays(1)) } | Should -Not -Throw
Test-Path -LiteralPath (Join-Path $locked 'server.zip') | Should -BeTrue
Test-Path -LiteralPath $stale | Should -BeFalse
} finally {
if ($null -ne $stream) { $stream.Dispose() }
Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
}
}
It 'ничего не пишет в success stream' {
$root = New-TempRoot
try {
$tempRoot = Join-Path $root '.tmp'
New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null
New-TempWorkDir -TempRoot $tempRoot -Name 'stale' -Age ([timespan]::FromDays(2)) | Out-Null
$out = Remove-KompasMcpStaleTemp -TempRoot $tempRoot -MaxAge ([timespan]::FromDays(1))
$out | Should -BeNullOrEmpty
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
}
Describe 'Resolve-KompasMcpExecutable' {