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>
111 lines
4.4 KiB
PowerShell
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)
|
|
}
|