leakhunt/bin/phase1_idle_baseline.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

111 lines
4.4 KiB
PowerShell

# phase1_idle_baseline.ps1
# Runs unattended for ~4 hours. Each cycle:
# - samples acclient memory metrics → CSV
# - every Nth cycle, takes a procdump (full memory) for later analysis
# - exits if acclient dies (so we know exactly when)
#
# Output:
# artifacts/phase1/memtrace.csv (one row per sample)
# artifacts/phase1/dump_NNN.dmp (every 30 min)
# artifacts/phase1/phase1.log (operator log)
#
# Run with:
# powershell -ExecutionPolicy Bypass -File bin\phase1_idle_baseline.ps1
param(
[string]$PhaseDir = "C:\Users\acbot\leakhunt\artifacts\soak",
[int]$SampleEvery = 300, # 5 min
[int]$DumpEvery = 6, # every 6th sample = every 30 min
[int]$DurationSec = 28800, # 8 h (extend if no crash)
[string]$ProcDumpExe = "C:\Tools\Sysinternals\procdump.exe"
)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
Add-Type -Namespace LeakHunt -Name User32 -MemberDefinition @'
[System.Runtime.InteropServices.DllImport("User32.dll")]
public static extern int GetGuiResources(System.IntPtr hProcess, int uiFlags);
'@ -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $PhaseDir -Force | Out-Null
$csv = Join-Path $PhaseDir "memtrace.csv"
$log = Join-Path $PhaseDir "phase1.log"
function W([string]$msg) {
$t = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
"[$t] $msg" | Out-File -Append -Encoding utf8 -FilePath $log
Write-Host "[$t] $msg"
}
if (-not (Test-Path $csv)) {
"iso,elapsed_s,pid,ws_mb,pm_mb,virtual_mb,handles,threads,gdi_handles,user_handles,cpu_s,dump_path" |
Out-File -Encoding utf8 -FilePath $csv
}
$ac = Get-Process -Name acclient -ErrorAction SilentlyContinue
if (-not $ac) { W "FATAL: acclient.exe not running"; exit 1 }
$acPid = $ac.Id
$start = Get-Date
W "Phase 1 idle baseline started. acclient PID=$acPid, $($DurationSec)s budget, sample every $($SampleEvery)s, dump every $($DumpEvery * $SampleEvery)s ($($DumpEvery) samples)."
$sampleIdx = 0
while ($true) {
$now = Get-Date
$elapsed = [int]($now - $start).TotalSeconds
if ($elapsed -ge $DurationSec) { W "Duration budget reached. Stopping."; break }
$ac = Get-Process -Id $acPid -ErrorAction SilentlyContinue
if (-not $ac) { W "acclient.exe EXITED unexpectedly at elapsed=$elapsed s"; break }
$sampleIdx++
$ws = [math]::Round($ac.WorkingSet64 / 1MB, 2)
$pm = [math]::Round($ac.PrivateMemorySize64 / 1MB, 2)
$vm = [math]::Round($ac.VirtualMemorySize64 / 1MB, 2)
$cpu = [math]::Round($ac.CPU, 2)
# GDI/USER handle counts via Win32 API
$gdi = [LeakHunt.User32]::GetGuiResources($ac.Handle, 0)
$usr = [LeakHunt.User32]::GetGuiResources($ac.Handle, 1)
# Optional dump
$dumpPath = ""
if ($DumpEvery -gt 0 -and (($sampleIdx - 1) % $DumpEvery) -eq 0) {
$dumpNum = (($sampleIdx - 1) / $DumpEvery) + 1
$dumpPath = Join-Path $PhaseDir ("dump_{0:D3}.dmp" -f $dumpNum)
W "Taking dump_$('{0:D3}' -f $dumpNum) ..."
$pdOut = & $ProcDumpExe -accepteula -ma $acPid $dumpPath 2>&1 | Out-String
if (-not (Test-Path $dumpPath)) {
W "procdump FAILED for sample $sampleIdx -- output: $pdOut"
$dumpPath = "FAILED"
} else {
$mb = [math]::Round((Get-Item $dumpPath).Length / 1MB, 1)
W "dump_$('{0:D3}' -f $dumpNum).dmp written ($mb MB)"
}
}
$row = "{0},{1},{2},{3},{4},{5},{6},{7},{8},{9},{10},{11}" -f `
$now.ToString("o"), $elapsed, $acPid, $ws, $pm, $vm, $ac.HandleCount, $ac.Threads.Count, $gdi, $usr, $cpu, $dumpPath
$row | Out-File -Append -Encoding utf8 -FilePath $csv
W ("sample {0,3}: WS={1,8} MB PM={2,8} MB VM={3,8} MB H={4,5} T={5,3} G={6,5} U={7,5} CPU={8,7}s" -f `
$sampleIdx, $ws, $pm, $vm, $ac.HandleCount, $ac.Threads.Count, $gdi, $usr, $cpu)
Start-Sleep -Seconds $SampleEvery
}
W "Phase 1 baseline complete."
# Print growth rate summary
$rows = Import-Csv $csv
if ($rows.Count -ge 2) {
$first = $rows[0]
$last = $rows[-1]
$dWs = [double]$last.ws_mb - [double]$first.ws_mb
$dPm = [double]$last.pm_mb - [double]$first.pm_mb
$hrs = ([double]$last.elapsed_s - [double]$first.elapsed_s) / 3600.0
$rateWs = if ($hrs -gt 0) { $dWs / $hrs } else { 0 }
$ratePm = if ($hrs -gt 0) { $dPm / $hrs } else { 0 }
W ("Growth over {0:N2} h: WS +{1:N2} MB ({2:N2} MB/h), PM +{3:N2} MB ({4:N2} MB/h)" -f $hrs, $dWs, $rateWs, $dPm, $ratePm)
}