leakhunt/bin/snapshot_leakers.ps1
acbot 57b5e43d0e Initial commit — leak-hunt project complete
Five bugs identified and patched in retail Asheron's Call client:
- v3b: palette refcount over-increment (3-byte NOP at two sites)
- v5: RenderSurface PurgeResource no-op stub (vtable slot 2 thunk)
- v11: two dangling-pointer crash guards (NULL-check + reorder)
- v14: CEnvCell::Destroy ClipPlaneList leak (18-byte JMP to cleanup thunk)
- v22: unpacker stale-pointer SEH guard (whole-function __try/__except)

All five ship in leakfix.dll (117 KB, SHA d282f23c…) which is loaded
by acclient.exe at process start via PE import table patching by
tools/install_leakfix.py.

Controlled 15-client fleet soak: unpatched control died at 26h with
palette exhaustion; all 14 patched clients survived past that point
and reached ≥5-day uptime.

Residual ~15 MB/h growth traced to d3d9.dll's internal slab allocator
(260KB surface backing buffers retained after Release). See REPORT.md
§10 for the full investigation; conclusion is that it's unfixable from
outside d3d9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:07:58 +02:00

105 lines
4 KiB
PowerShell

#requires -Version 5.1
<#
snapshot_leakers.ps1
Takes a `procdump -r 1 -ma` clone-snapshot of the top-N leakiest
acclient processes that haven't been dumped recently. Designed to
be called every 30-60 min from the wakeup loop.
Decision rules:
* Skip a PID if there is already a procdump -e watcher attached
(it would block our snapshot attach; the watcher will catch OOM).
* Skip a PID if we already wrote a dump for it in the last
`$staleAfterMin` minutes.
* Rank by VirtualMemorySize64 descending. Take top $TopN.
* Output dir: artifacts/soak/ filename: dump_<pid>_<NNN>.dmp.
Tracks state in artifacts/soak/snapshot_state.json.
#>
param(
[int] $TopN = 2,
[int] $StaleAfterMin = 60,
[int] $MinVirtualMB = 1200,
[switch] $DryRun
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$soakDir = Join-Path $root 'artifacts\soak'
$procdump = 'C:\Users\acbot\Tools\Procdump\procdump.exe'
$state = Join-Path $soakDir 'snapshot_state.json'
if (-not (Test-Path $soakDir)) { New-Item -ItemType Directory -Path $soakDir -Force | Out-Null }
# Load state
if (Test-Path $state) {
$st = Get-Content $state -Raw | ConvertFrom-Json
} else {
$st = @{ snapshots = @() }
}
# What PIDs already have a procdump -e watcher attached?
$attachedTo = @()
$watcherCsv = Join-Path $soakDir 'watchers.csv'
if (Test-Path $watcherCsv) {
Import-Csv $watcherCsv | ForEach-Object {
$wp = [int]$_.watcher_pid
if (Get-Process -Id $wp -ErrorAction SilentlyContinue) {
$attachedTo += [int]$_.target_pid
}
}
}
# Rank live acclient processes by virtual mem
$candidates = Get-Process -Name acclient -ErrorAction SilentlyContinue |
Where-Object { [math]::Round($_.VirtualMemorySize64/1MB,0) -ge $MinVirtualMB -and $_.Id -notin $attachedTo } |
Sort-Object VirtualMemorySize64 -Descending |
Select-Object -First $TopN
$now = [DateTime]::UtcNow
foreach ($p in $candidates) {
# Skip if dumped recently
$existing = @($st.snapshots) | Where-Object { $_.pid -eq $p.Id } | Sort-Object ts_utc -Descending | Select-Object -First 1
if ($existing) {
$age = ($now - [DateTime]::Parse($existing.ts_utc)).TotalMinutes
if ($age -lt $StaleAfterMin) {
Write-Output ("skip pid={0} dumped {1:N1} min ago" -f $p.Id, $age)
continue
}
}
# Compute filename — increment N
$n = 1
while (Test-Path (Join-Path $soakDir ("dump_{0}_{1:000}.dmp" -f $p.Id, $n))) { $n++ }
$out = Join-Path $soakDir ("dump_{0}_{1:000}.dmp" -f $p.Id, $n)
$vMB = [math]::Round($p.VirtualMemorySize64/1MB,1)
$pMB = [math]::Round($p.PrivateMemorySize64/1MB,1)
if ($DryRun) {
Write-Output ("would dump pid={0} virtual={1}MB private={2}MB -> {3}" -f $p.Id, $vMB, $pMB, $out)
continue
}
Write-Output ("dump pid={0} virtual={1}MB private={2}MB" -f $p.Id, $vMB, $pMB)
$sw = [Diagnostics.Stopwatch]::StartNew()
& $procdump -r 1 -ma -accepteula $p.Id $out *> $null
$sw.Stop()
if (Test-Path $out) {
$sz = [math]::Round((Get-Item $out).Length/1MB,1)
Write-Output (" wrote {0} MB in {1:N1}s" -f $sz, $sw.Elapsed.TotalSeconds)
$entry = [PSCustomObject]@{
ts_utc = $now.ToString('o')
pid = $p.Id
start = $p.StartTime.ToUniversalTime().ToString('o')
uptime_h = [math]::Round(($now - $p.StartTime.ToUniversalTime()).TotalHours, 2)
ws_mb = [math]::Round($p.WorkingSet64/1MB,1)
private_mb = $pMB
virtual_mb = $vMB
path = $out
size_mb = $sz
elapsed_s = [math]::Round($sw.Elapsed.TotalSeconds, 1)
ust_likely = ($p.StartTime.ToUniversalTime() -gt [DateTime]::Parse('2026-05-13T06:07:43Z'))
}
$st.snapshots = @($st.snapshots) + $entry
} else {
Write-Output " DUMP FAILED — file not present"
}
}
$st | ConvertTo-Json -Depth 5 | Out-File -FilePath $state -Encoding utf8