fix(ida-reverse): deadlock on last-healthy, not process age

Keep-alive Force only after tools/list stays unhealthy >180s.
open.ps1 holds opening.lock so in-flight idb_open is never replaced.
GUI and never-healthy stay busy/reuse. recover.ps1 remains the manual -Force path.
This commit is contained in:
Harry
2026-08-27 16:08:57 +08:00
parent ef79ff6616
commit 7196aeb666
8 changed files with 140 additions and 54 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ Versioning follows [Semantic Versioning](https://semver.org/).
- **CI runs remaining unwired suites** — `test-p0-friction.ps1` on the Windows leg of `routing-tests` (Windows PowerShell 5.1); `case-review/tests/test_review_case.py` in the Linux `case-contract` job. `test-workflow-title-safety.ps1` was already wired.
### Fixed
- **IDA MCP HTTP stall** — `run-supervisor.py` patches stock `idalib_supervisor` onto `ThreadingHTTPServer` and accepts Streamable HTTP GET `/mcp` (patch failure is skipped, supervisor still starts). `watchdog.ps1` / `start.ps1` replace a *managed* supervisor if `tools/list` stays dead for more than 3 minutes; GUI and in-flight `idb_open` stay busy/reuse. New `recover.ps1` is an immediate `-Force` path. Never `taskkill`s `ida.exe`.
- **IDA MCP HTTP stall** — `run-supervisor.py` patches stock `idalib_supervisor` onto `ThreadingHTTPServer` and accepts Streamable HTTP GET `/mcp` (patch failure is skipped, supervisor still starts). Keep-alive deadlock is **time since last healthy `tools/list`**, not process `CreationDate`; `open.ps1` holds `opening.lock` so in-flight opens are never `-Force`d. New `recover.ps1` is an immediate `-Force` path. Never `taskkill`s `ida.exe`.
- **Windows PowerShell 5.1 encoding** — added a UTF-8 BOM to five non-ASCII `.ps1` scripts (`skills/scripts/verify-doc-facts.ps1`, `apk-reverse/scripts/frida-run.ps1`, `apk-reverse/scripts/rebuild-sign-install.ps1`, `ida-reverse/scripts/start.ps1`, `radare2/scripts/recon.ps1`). Without a BOM, PS 5.1 parses these files as the system ANSI codepage and garbles their Chinese / em-dash string literals; `verify-doc-facts.ps1` was failing four checks under 5.1 (CI only ran it under `pwsh`, which defaults to UTF-8). CI now guards every non-ASCII `.ps1` for a BOM.
### Changed
+2 -2
View File
@@ -35,8 +35,8 @@ python -m ida_pro_mcp --install --transport streamable-http --scope global
| 脚本 | 作用 |
|------|------|
| `scripts/start.ps1` | 健康则 `OK:<n>:reuse`;端口在听但 RPC 超时视为忙,不杀;只在无人监听或缺 `py_eval` 时替换 managed supervisor;永不杀 `ida.exe` |
| `scripts/watchdog.ps1` | 每分钟巡检;健康 reuse;GUI/新开库 busy reuse;**managed supervisor 超过 3 分钟仍答不上 tools/list 则 `-Force` 拉起** |
| `scripts/start.ps1` | 健康则 `OK:<n>:reuse` 并刷新 last-healthy;端口在听但 RPC 超时视为忙,不杀;只在无人监听、缺 `py_eval`、或 tools/list 连续失败超过 3 分钟(且无 `opening.lock`)时替换 managed supervisor;永不杀 `ida.exe` |
| `scripts/watchdog.ps1` | 每分钟巡检;健康 reuse;GUI / `open.ps1` 开库锁 / last-healthy 未满 3 分钟 → reuse;**tools/list 连续失败超过 3 分钟才 `-Force`** |
| `scripts/recover.ps1` | 立刻 `-Force` 重启 supervisor(不杀 `ida.exe`)。HTTP 客户端把 `idapro` 标成 error 时用这个 |
| `scripts/install-autostart.ps1` | 注册计划任务 `reverse-skill-ida-mcp`(登录 + 每分钟) |
| `scripts/start-gui.ps1` | idalib license 失败时开 GUI 插件 |
+4 -4
View File
@@ -56,8 +56,8 @@ description: |
- 第一次 `open.ps1` 超时后,idalib 的 python worker 子进程可能变成孤儿,咬着 `.id0`/`.id1`/`.nam` 不放
- 后续任何工具或手动拖入 IDA GUI 都会报"权限不足"
- **禁止** `taskkill /F /T` 杀进程树——`/T` 会把 GUI `ida.exe` 子进程一起干掉
- **解决办法**:`start.ps1` 只在端口无人监听、或 `tools/list` 快速返回但缺 `py_eval`(旧 supervisor)时替换 managed supervisor;RPC 超时且 13337 仍在听、且进程是 GUI 或开库未满 3 分钟,视为忙,不杀
- **死锁例外**:managed supervisor 超过 3 分钟仍答不上 `tools/list` 时,`watchdog.ps1` / `start.ps1` 用 `-Force` 只替换 supervisor,仍不杀 `ida.exe`
- **解决办法**:`start.ps1` 只在端口无人监听、或 `tools/list` 快速返回但缺 `py_eval`(旧 supervisor)时替换 managed supervisor;RPC 超时且 13337 仍在听视为忙,不杀。开库时 `open.ps1` 写 `opening.lock`,watchdog 不得 `-Force`
- **死锁例外**:`tools/list` **连续失败超过 3 分钟**(按 last-healthy 时间戳,不是进程创建时间),且没有 in-flight `opening.lock`、不是 GUI 占端口时,才 `-Force` 替换 supervisor,仍不杀 `ida.exe`
- **兜底**:`open.ps1` 检测到旧库被锁自动复制到 Temp 并加 GUID 前缀
8. **带自动分析打开看起来像卡死**
@@ -96,7 +96,7 @@ description: |
- 优先用 IDA 自带 `Python314\python.exe -m ida_pro_mcp.idalib_supervisor`
- 默认先探测 `http://127.0.0.1:13337/mcp`,健康则输出 `OK:<n>:reuse` 并退出
- 13337 在听但 `tools/list` 超时 → `WARN:busy` / `OK:busy:reuse`,**不杀**(开库或 GUI 占用时无法回包)
- managed supervisor **超过 3 分钟**仍答不上 `tools/list` → 视为死锁,输出 `INFO:deadlock` 并 `-Force` 替换 supervisor
- `tools/list` **连续失败超过 3 分钟**(last-healthy 时间戳)且无 `opening.lock` → 视为死锁,输出 `INFO:deadlock` 并 `-Force` 替换 supervisor。进行中的 `idb_open` 和 GUI 不会走这条路径
- 仅在端口无人监听、缺 `py_eval`、或上述死锁时替换 managed supervisor;**永不杀 `ida.exe`,不用 `taskkill /T`**
- GUI 占用 13337 时输出 `WARN:gui_busy` 并退出,不另起 supervisor
- 成功输出 `OK:<工具数>`(当前约 66),失败输出 `ERR:timeout`
@@ -110,7 +110,7 @@ powershell -File "<skill-root>\ida-reverse\scripts\start.ps1"
### watchdog.ps1 / recover.ps1 / install-autostart.ps1 — 保活
- `watchdog.ps1`:探测 13337;健康 reuse;GUI/新开库 busy reuse;managed supervisor 超过 3 分钟仍无 `tools/list` 则 `start.ps1 -Force`
- `watchdog.ps1`:探测 13337;健康 reuse(并刷新 last-healthy);GUI / `open.ps1` 开库锁 / last-healthy 未满 3 分钟的 busy → reuse;只有 `tools/list` 连续失败超过 3 分钟才 `start.ps1 -Force`
- `recover.ps1`:立刻 `start.ps1 -Force`(不杀 `ida.exe`)。HTTP 客户端把 `idapro` 标成 error 时用这个
- `install-autostart.ps1`:注册计划任务 `reverse-skill-ida-mcp`(登录 + 每分钟)
- 日志:`%LOCALAPPDATA%\reverse-skill\ida-mcp\watchdog.log`
@@ -26,3 +26,75 @@ function Get-IdaOpenLockPlan {
PreferTempCopy = $hasLocked
}
}
function Get-IdaMcpKeepaliveDir {
$dir = Join-Path $env:LOCALAPPDATA 'reverse-skill\ida-mcp'
if (-not (Test-Path -LiteralPath $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
return $dir
}
function Get-IdaMcpLastHealthyPath {
return Join-Path (Get-IdaMcpKeepaliveDir) 'last-healthy.txt'
}
function Get-IdaMcpOpeningLockPath {
return Join-Path (Get-IdaMcpKeepaliveDir) 'opening.lock'
}
function Write-IdaMcpLastHealthy {
Set-Content -LiteralPath (Get-IdaMcpLastHealthyPath) -Value 'ok' -Encoding ASCII
}
function Read-IdaMcpLastHealthy {
$path = Get-IdaMcpLastHealthyPath
if (-not (Test-Path -LiteralPath $path)) { return $null }
return (Get-Item -LiteralPath $path).LastWriteTime
}
function Set-IdaMcpOpeningLock {
param([int]$TtlSeconds = 600)
$ttl = [Math]::Max(1, $TtlSeconds)
$until = (Get-Date).AddSeconds($ttl).ToString('o')
Set-Content -LiteralPath (Get-IdaMcpOpeningLockPath) -Value $until -Encoding ASCII
}
function Clear-IdaMcpOpeningLock {
$path = Get-IdaMcpOpeningLockPath
if (Test-Path -LiteralPath $path) {
Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
}
}
function Test-IdaMcpOpeningInFlight {
param([datetime]$Now = (Get-Date))
$path = Get-IdaMcpOpeningLockPath
if (-not (Test-Path -LiteralPath $path)) { return $false }
$raw = (Get-Content -LiteralPath $path -Raw -ErrorAction SilentlyContinue)
if ([string]::IsNullOrWhiteSpace($raw)) { return $true }
try {
$until = [datetime]::Parse($raw.Trim(), $null, [System.Globalization.DateTimeStyles]::RoundtripKind)
} catch {
return $true
}
if ($Now -gt $until) {
Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
return $false
}
return $true
}
function Test-IdaMcpKeepaliveDeadlockFromFacts {
param(
[bool]$GuiOwnsPort = $false,
[bool]$OpeningInFlight = $false,
$LastHealthy = $null,
[datetime]$Now = (Get-Date),
[int]$MaxUnhealthySeconds = 180
)
if ($GuiOwnsPort) { return $false }
if ($OpeningInFlight) { return $false }
if ($null -eq $LastHealthy) { return $false }
return ($Now - [datetime]$LastHealthy).TotalSeconds -ge $MaxUnhealthySeconds
}
+3
View File
@@ -230,6 +230,8 @@ $bodyObj = @{
}
$body = $bodyObj | ConvertTo-Json -Depth 12 -Compress
Set-IdaMcpOpeningLock -TtlSeconds ($TimeoutSeconds + 60)
$openJob = Start-Job -ScriptBlock {
param($RequestBody, $RequestPort)
try {
@@ -368,6 +370,7 @@ try {
Write-Output "ERR:$err"
exit 1
} finally {
Clear-IdaMcpOpeningLock
if ($openJob) {
Stop-Job -Job $openJob -ErrorAction SilentlyContinue
Remove-Job -Job $openJob -Force -ErrorAction SilentlyContinue
+10 -12
View File
@@ -23,6 +23,8 @@ param(
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'IdaOpenHelpers.ps1')
function Get-IdaMcpLogDir {
$dir = Join-Path $env:LOCALAPPDATA 'reverse-skill\ida-mcp'
if (-not (Test-Path -LiteralPath $dir)) {
@@ -347,27 +349,22 @@ if ($guiOwners.Count -gt 0) {
if (-not $Force) {
if ($probe.Status -eq 'healthy') {
Write-IdaMcpLastHealthy
Write-Output "OK:$($probe.Count)`:reuse"
exit 0
}
if ($probe.Status -eq 'busy') {
$deadlock = $false
$now = Get-Date
foreach ($ownerPid in @($probe.Owners)) {
if (Test-IdaGuiProcess -ProcessId $ownerPid) { continue }
if (-not (Test-ManagedSupervisorProcess -ProcessId $ownerPid)) { continue }
$proc = Get-IdaMcpProcessInfo -ProcessId $ownerPid
if ($proc -and $proc.CreationDate -and (($now - $proc.CreationDate).TotalSeconds -ge 180)) {
$deadlock = $true
break
}
}
$deadlock = Test-IdaMcpKeepaliveDeadlockFromFacts `
-GuiOwnsPort $false `
-OpeningInFlight (Test-IdaMcpOpeningInFlight) `
-LastHealthy (Read-IdaMcpLastHealthy) `
-Now (Get-Date)
if (-not $deadlock) {
Write-Output ("WARN:busy:port={0} pid={1}" -f $Port, ($probe.Owners -join ','))
Write-Output 'HINT: 13337 is listening but tools/list timed out (likely idb_open). Not killing supervisor.'
exit 0
}
Write-Output ("INFO:deadlock:port={0} pid={1} (tools/list dead >180s, replacing)" -f $Port, ($probe.Owners -join ','))
Write-Output ("INFO:deadlock:port={0} pid={1} (tools/list unhealthy >180s, replacing)" -f $Port, ($probe.Owners -join ','))
}
}
@@ -508,6 +505,7 @@ for ($i = 0; $i -lt $WaitSeconds; $i++) {
Start-Sleep -Seconds 1
$toolCount = Test-IdaMcpHealth -Port $Port
if ($toolCount -gt 0) {
Write-IdaMcpLastHealthy
Write-Output "OK:$toolCount"
$ready = $true
break
+13 -29
View File
@@ -4,8 +4,8 @@ Ensure IDA Pro MCP HTTP is healthy; start or replace it when needed.
.DESCRIPTION
One-shot health check used by the scheduled task. Safe to run every minute:
a live server is reused; GUI / fresh idb_open busy is reused; a managed
supervisor older than 3 minutes that cannot answer tools/list is replaced
a live server is reused; GUI / open.ps1 in-flight / last-healthy younger
than 3 minutes is reused; tools/list unhealthy for 3 minutes is replaced
via start.ps1 -Force; a dead listener is started via start.ps1.
Usage:
@@ -18,6 +18,8 @@ param(
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'IdaOpenHelpers.ps1')
$logDir = Join-Path $env:LOCALAPPDATA 'reverse-skill\ida-mcp'
if (-not (Test-Path -LiteralPath $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
@@ -66,9 +68,8 @@ function Probe-IdaMcp {
return @{ Status = 'stale'; Count = $count }
}
} catch {}
# Listen + RPC timeout: GUI or a fresh supervisor during idb_open = busy.
# A managed supervisor older than 3 minutes that cannot answer tools/list
# is a deadlock (Streamable HTTP GET /mcp holding single-thread HTTPServer).
# Listen + RPC timeout: GUI or in-flight idb_open = busy.
# Deadlock is consecutive tools/list failures >180s (last-healthy), not process age.
if ($owners.Count -gt 0) {
return @{ Status = 'busy'; Count = 0; Owners = $owners }
}
@@ -87,37 +88,20 @@ function Test-IdaGuiProcess {
return [string]$proc.Name -match '(?i)^(ida|ida64|idaq|idaq64)\.exe$'
}
function Test-ManagedSupervisorProcess {
param([int]$ProcessId)
$proc = Get-IdaMcpProcessInfo -ProcessId $ProcessId
if (-not $proc) { return $false }
$name = [string]$proc.Name
$cmd = [string]$proc.CommandLine
if ($name -match '(?i)^(ida|ida64|idaq|idaq64)\.exe$') { return $false }
if ($name -match '(?i)^(python|pythonw|cmd)\.exe$') {
return $cmd -match '(?i)(run-supervisor\.py|idalib_supervisor|idalib-mcp|ida-pro-mcp)'
}
return $name -match '(?i)^(idalib-mcp|ida-pro-mcp|idalib_supervisor)'
}
function Test-DeadlockedSupervisor {
param([int[]]$Owners, [int]$MaxAgeSeconds = 180)
param([int[]]$Owners)
$gui = @($Owners | Where-Object { Test-IdaGuiProcess -ProcessId $_ })
if ($gui.Count -gt 0) { return $false }
$now = Get-Date
$managed = @($Owners | Where-Object { Test-ManagedSupervisorProcess -ProcessId $_ })
if ($managed.Count -eq 0) { return $false }
foreach ($pid in $managed) {
$proc = Get-IdaMcpProcessInfo -ProcessId $pid
if (-not $proc -or -not $proc.CreationDate) { continue }
$age = ($now - $proc.CreationDate).TotalSeconds
if ($age -ge $MaxAgeSeconds) { return $true }
}
return $false
return Test-IdaMcpKeepaliveDeadlockFromFacts `
-GuiOwnsPort $false `
-OpeningInFlight (Test-IdaMcpOpeningInFlight) `
-LastHealthy (Read-IdaMcpLastHealthy) `
-Now (Get-Date)
}
$probe = Probe-IdaMcp -Port $Port
if ($probe.Status -eq 'healthy') {
Write-IdaMcpLastHealthy
Write-WatchLog "OK:$($probe.Count)`:reuse"
exit 0
}
+35 -6
View File
@@ -67,6 +67,23 @@ foreach ($name in @($idaFiles.Keys)) {
}
}
$now = Get-Date
if (Test-IdaMcpKeepaliveDeadlockFromFacts -GuiOwnsPort $true -OpeningInFlight $false -LastHealthy $now.AddMinutes(-10) -Now $now) {
Bad 'GUI owner must not be deadlock'
} else { Ok 'GUI owner is not deadlock' }
if (Test-IdaMcpKeepaliveDeadlockFromFacts -GuiOwnsPort $false -OpeningInFlight $true -LastHealthy $now.AddMinutes(-10) -Now $now) {
Bad 'in-flight open must not be deadlock'
} else { Ok 'in-flight open is not deadlock' }
if (Test-IdaMcpKeepaliveDeadlockFromFacts -GuiOwnsPort $false -OpeningInFlight $false -LastHealthy $null -Now $now) {
Bad 'never-healthy must not Force'
} else { Ok 'never-healthy is not deadlock' }
if (Test-IdaMcpKeepaliveDeadlockFromFacts -GuiOwnsPort $false -OpeningInFlight $false -LastHealthy $now.AddSeconds(-10) -Now $now) {
Bad 'recent last-healthy must not be deadlock'
} else { Ok 'recent last-healthy is not deadlock' }
if (Test-IdaMcpKeepaliveDeadlockFromFacts -GuiOwnsPort $false -OpeningInFlight $false -LastHealthy $now.AddSeconds(-181) -Now $now) {
Ok 'stale last-healthy is deadlock'
} else { Bad 'tools/list unhealthy >180s should be deadlock' }
$recover = Get-Content -LiteralPath $idaFiles['recover.ps1'] -Raw -ErrorAction SilentlyContinue
if ($recover -match '(?i)start\.ps1' -and $recover -match '-Force') {
Ok 'recover.ps1 calls start.ps1 -Force'
@@ -80,10 +97,10 @@ if ($recover -match '(?im)^\s*(&\s*)?taskkill(\.exe)?\s+[^\n]*/T\b') {
}
$start = Get-Content -LiteralPath $idaFiles['start.ps1'] -Raw
if ($start -match 'TotalSeconds\s+-ge\s+180' -and $start -match 'INFO:deadlock') {
Ok 'start.ps1 deadlock replace after 180s'
if ($start -match 'Test-IdaMcpKeepaliveDeadlockFromFacts' -and $start -match 'Write-IdaMcpLastHealthy' -and $start -notmatch 'CreationDate') {
Ok 'start.ps1 deadlock uses last-healthy, not CreationDate'
} else {
Bad 'start.ps1 missing 180s deadlock replace'
Bad 'start.ps1 must use last-healthy deadlock, not process CreationDate'
}
if ($start -match '(?im)^\s*(&\s*)?taskkill(\.exe)?\s+[^\n]*/T\b') {
Bad 'start.ps1 must not taskkill /T'
@@ -92,10 +109,22 @@ if ($start -match '(?im)^\s*(&\s*)?taskkill(\.exe)?\s+[^\n]*/T\b') {
}
$watch = Get-Content -LiteralPath $idaFiles['watchdog.ps1'] -Raw
if ($watch -match 'Test-DeadlockedSupervisor' -and $watch -match '-Force') {
Ok 'watchdog.ps1 Force-replaces deadlocked supervisor'
if ($watch -match 'Test-IdaMcpKeepaliveDeadlockFromFacts' -and $watch -match 'Test-IdaMcpOpeningInFlight' -and $watch -match '-Force') {
Ok 'watchdog.ps1 Force-replaces only last-healthy deadlock'
} else {
Bad 'watchdog.ps1 missing deadlock -Force path'
Bad 'watchdog.ps1 missing last-healthy deadlock -Force path'
}
if ($watch -match 'CreationDate') {
Bad 'watchdog.ps1 must not use process CreationDate'
} else {
Ok 'watchdog.ps1 has no CreationDate deadlock'
}
$open = Get-Content -LiteralPath (Join-Path $idaDir 'open.ps1') -Raw
if ($open -match 'Set-IdaMcpOpeningLock' -and $open -match 'Clear-IdaMcpOpeningLock') {
Ok 'open.ps1 holds opening.lock during idb_open'
} else {
Bad 'open.ps1 must set and clear opening.lock'
}
$py = Get-Content -LiteralPath $idaFiles['run-supervisor.py'] -Raw