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>
This commit is contained in:
commit
57b5e43d0e
199 changed files with 1648333 additions and 0 deletions
5
bin/_uac_probe.ps1
Normal file
5
bin/_uac_probe.ps1
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
$elev = ([Security.Principal.WindowsPrincipal]::new(
|
||||
[Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole(
|
||||
[Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
"elevated=$elev`r`npid=$PID`r`nuser=$env:USERNAME" |
|
||||
Out-File -FilePath 'C:\Users\acbot\leakhunt\artifacts\soak\uac_test.txt' -Encoding utf8
|
||||
7
bin/_user32.ps1
Normal file
7
bin/_user32.ps1
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Tiny P/Invoke shim so phase1_idle_baseline.ps1 can read GDI / USER handle counts.
|
||||
# Dot-source this before invoking the sampler.
|
||||
|
||||
Add-Type -Namespace LeakHunt -Name User32 -MemberDefinition @'
|
||||
[System.Runtime.InteropServices.DllImport("User32.dll")]
|
||||
public static extern int GetGuiResources(System.IntPtr hProcess, int uiFlags);
|
||||
'@
|
||||
57
bin/admin_hklm_only.ps1
Normal file
57
bin/admin_hklm_only.ps1
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
#requires -Version 5.1
|
||||
<#
|
||||
admin_hklm_only.ps1 — minimal admin script for the two HKLM writes.
|
||||
SDK Debuggers are already extracted as flat files; this script only
|
||||
handles the things gflags + WER need that touch HKLM:
|
||||
|
||||
1. Configure WER LocalDumps for acclient.exe (auto-dumps on crash).
|
||||
2. gflags +ust on acclient.exe (heap-allocation stack tagging on
|
||||
FUTURE acclient spawns; current ones won't pick it up).
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$log = 'C:\Users\acbot\leakhunt\artifacts\soak\admin_hklm.log'
|
||||
Start-Transcript -Path $log -Force | Out-Null
|
||||
|
||||
try {
|
||||
|
||||
if (-not ([Security.Principal.WindowsPrincipal]::new(
|
||||
[Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole(
|
||||
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
Write-Host 'ERROR: not elevated. Aborting.' -ForegroundColor Red
|
||||
Stop-Transcript | Out-Null
|
||||
Read-Host 'press enter to close'
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "=== admin_hklm_only.ps1 started @ $(Get-Date -Format o) ===" -ForegroundColor Cyan
|
||||
|
||||
# [1/2] WER LocalDumps
|
||||
Write-Host '[1/2] Configuring WER LocalDumps for acclient.exe...' -ForegroundColor Cyan
|
||||
$dumpDir = 'C:\Users\acbot\leakhunt\artifacts\crashdumps'
|
||||
New-Item -ItemType Directory -Path $dumpDir -Force | Out-Null
|
||||
$werKey = 'HKLM:\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\acclient.exe'
|
||||
New-Item -Path $werKey -Force | Out-Null
|
||||
New-ItemProperty -Path $werKey -Name 'DumpFolder' -Value $dumpDir -PropertyType ExpandString -Force | Out-Null
|
||||
New-ItemProperty -Path $werKey -Name 'DumpType' -Value 2 -PropertyType DWord -Force | Out-Null # 2 = Full
|
||||
New-ItemProperty -Path $werKey -Name 'DumpCount' -Value 25 -PropertyType DWord -Force | Out-Null
|
||||
Get-ItemProperty -Path $werKey | Format-List DumpFolder, DumpType, DumpCount
|
||||
|
||||
# [2/2] gflags +ust
|
||||
$gflags = 'C:\Users\acbot\Tools\WindowsKits\Windows Kits\10\Debuggers\x86\gflags.exe'
|
||||
Write-Host '[2/2] Enabling gflags +ust on acclient.exe...' -ForegroundColor Cyan
|
||||
if (Test-Path $gflags) {
|
||||
& $gflags /i acclient.exe +ust
|
||||
" current image-file flags:"
|
||||
& $gflags /i acclient.exe
|
||||
} else {
|
||||
Write-Warning "gflags.exe not found at $gflags"
|
||||
}
|
||||
|
||||
Write-Host "=== admin_hklm_only.ps1 finished @ $(Get-Date -Format o) ===" -ForegroundColor Green
|
||||
|
||||
} catch {
|
||||
Write-Host "FATAL: $($_ | Out-String)" -ForegroundColor Red
|
||||
}
|
||||
Stop-Transcript | Out-Null
|
||||
Read-Host 'press enter to close'
|
||||
91
bin/admin_setup.ps1
Normal file
91
bin/admin_setup.ps1
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
#requires -Version 5.1
|
||||
<#
|
||||
admin_setup.ps1 — one-time, ADMIN-ELEVATED setup for the leak hunt.
|
||||
|
||||
RUN THIS FROM AN ELEVATED POWERSHELL. It does three things:
|
||||
|
||||
1. Installs the Windows SDK "Windows Desktop Debuggers" feature
|
||||
(~250 MB), giving us standalone cdb.exe, umdh.exe, gflags.exe
|
||||
under C:\Program Files (x86)\Windows Kits\10\Debuggers\.
|
||||
2. Configures WER LocalDumps for acclient.exe so any future crash
|
||||
(OOM/AV/heap-corruption) auto-saves a full-memory dump to
|
||||
artifacts\crashdumps\.
|
||||
3. Sets gflags +ust on acclient.exe (per-image-file flag in HKLM)
|
||||
so future acclient spawns tag every heap allocation with its
|
||||
call stack — required for Phase 5 attribution.
|
||||
|
||||
NOTE: currently-running acclient processes will NOT pick up the
|
||||
gflags +ust setting. Only processes started after this runs will
|
||||
have stack tagging. Pre-existing leakers continue to leak, just
|
||||
without UST tags on their heap entries.
|
||||
|
||||
After this finishes, you can close the elevated shell. The
|
||||
non-elevated session keeps running.
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = 'Continue' # don't crash window on first error
|
||||
$log = 'C:\Users\acbot\leakhunt\artifacts\soak\admin_setup.log'
|
||||
Start-Transcript -Path $log -Force | Out-Null
|
||||
|
||||
try {
|
||||
|
||||
if (-not ([Security.Principal.WindowsPrincipal]::new(
|
||||
[Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole(
|
||||
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
Write-Host "ERROR: not elevated. Aborting." -ForegroundColor Red
|
||||
Stop-Transcript | Out-Null
|
||||
Read-Host 'press enter to close'
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "=== admin_setup.ps1 started @ $(Get-Date -Format o) ===" -ForegroundColor Cyan
|
||||
|
||||
# ---- 1. Install Windows SDK Debuggers feature ----------------------------
|
||||
$sdk = "$env:TEMP\winsdk\winsdksetup.exe"
|
||||
if (-not (Test-Path $sdk)) {
|
||||
Write-Host "SDK installer not present; downloading..." -ForegroundColor Yellow
|
||||
New-Item -ItemType Directory -Path "$env:TEMP\winsdk" -Force | Out-Null
|
||||
Invoke-WebRequest -Uri "https://go.microsoft.com/fwlink/?linkid=2272610" `
|
||||
-OutFile $sdk -UseBasicParsing
|
||||
}
|
||||
Write-Host "[1/3] Installing Windows SDK Debuggers feature (silent)..." -ForegroundColor Cyan
|
||||
$args = @('/features','OptionId.WindowsDesktopDebuggers','/quiet','/norestart')
|
||||
$p = Start-Process -FilePath $sdk -ArgumentList $args -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) {
|
||||
Write-Warning "SDK setup exit code: $($p.ExitCode) (non-zero — see %TEMP%\winsdk\ logs)"
|
||||
}
|
||||
$cdb = 'C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe'
|
||||
$umdh = 'C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\umdh.exe'
|
||||
$gflags = 'C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\gflags.exe'
|
||||
foreach ($t in @($cdb, $umdh, $gflags)) {
|
||||
if (Test-Path $t) { Write-Host " OK $t" -ForegroundColor Green }
|
||||
else { Write-Host " MISSING $t" -ForegroundColor Red }
|
||||
}
|
||||
|
||||
# ---- 2. WER LocalDumps for acclient.exe ----------------------------------
|
||||
Write-Host "[2/3] Configuring WER LocalDumps for acclient.exe..." -ForegroundColor Cyan
|
||||
$dumpDir = 'C:\Users\acbot\leakhunt\artifacts\crashdumps'
|
||||
New-Item -ItemType Directory -Path $dumpDir -Force | Out-Null
|
||||
$werKey = 'HKLM:\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\acclient.exe'
|
||||
New-Item -Path $werKey -Force | Out-Null
|
||||
New-ItemProperty -Path $werKey -Name 'DumpFolder' -Value $dumpDir -PropertyType ExpandString -Force | Out-Null
|
||||
New-ItemProperty -Path $werKey -Name 'DumpType' -Value 2 -PropertyType DWord -Force | Out-Null # 2 = Full
|
||||
New-ItemProperty -Path $werKey -Name 'DumpCount' -Value 25 -PropertyType DWord -Force | Out-Null
|
||||
Get-ItemProperty -Path $werKey | Format-List DumpFolder, DumpType, DumpCount
|
||||
|
||||
# ---- 3. gflags +ust on acclient.exe --------------------------------------
|
||||
Write-Host "[3/3] Enabling gflags +ust on acclient.exe (FUTURE spawns only)..." -ForegroundColor Cyan
|
||||
if (Test-Path $gflags) {
|
||||
& $gflags /i acclient.exe +ust
|
||||
} else {
|
||||
Write-Warning "gflags.exe not found at $gflags — SDK install may have failed. Skipping +ust."
|
||||
}
|
||||
|
||||
Write-Host "=== admin_setup.ps1 finished @ $(Get-Date -Format o) ===" -ForegroundColor Cyan
|
||||
Write-Host "You can close this elevated window now." -ForegroundColor Green
|
||||
|
||||
} catch {
|
||||
Write-Host "FATAL: $($_ | Out-String)" -ForegroundColor Red
|
||||
}
|
||||
Stop-Transcript | Out-Null
|
||||
Read-Host 'press enter to close'
|
||||
27
bin/admin_uac_silent.ps1
Normal file
27
bin/admin_uac_silent.ps1
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
#requires -Version 5.1
|
||||
<#
|
||||
admin_uac_silent.ps1 — sets ConsentPromptBehaviorAdmin = 0 so future
|
||||
elevations from this admin user happen silently. Reversible via the
|
||||
same key (default value = 5).
|
||||
#>
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$log = 'C:\Users\acbot\leakhunt\artifacts\soak\admin_uac.log'
|
||||
Start-Transcript -Path $log -Force | Out-Null
|
||||
try {
|
||||
if (-not ([Security.Principal.WindowsPrincipal]::new(
|
||||
[Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole(
|
||||
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
Write-Host 'ERROR: not elevated.' -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$k = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Policies\System'
|
||||
$before = (Get-ItemProperty -Path $k -Name ConsentPromptBehaviorAdmin -ErrorAction SilentlyContinue).ConsentPromptBehaviorAdmin
|
||||
Write-Host ("ConsentPromptBehaviorAdmin BEFORE = {0}" -f $before)
|
||||
Set-ItemProperty -Path $k -Name ConsentPromptBehaviorAdmin -Value 0 -Type DWord -Force
|
||||
$after = (Get-ItemProperty -Path $k -Name ConsentPromptBehaviorAdmin).ConsentPromptBehaviorAdmin
|
||||
Write-Host ("ConsentPromptBehaviorAdmin AFTER = {0}" -f $after) -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "FATAL: $($_ | Out-String)" -ForegroundColor Red
|
||||
}
|
||||
Stop-Transcript | Out-Null
|
||||
Read-Host 'press enter to close'
|
||||
71
bin/bench_roundtrip.ahk
Normal file
71
bin/bench_roundtrip.ahk
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
; bench_roundtrip.ahk
|
||||
; Phase 0 step 4 — drive the AC client through char-select → in-world → clean quit,
|
||||
; without a human watching. Logs to artifacts/phase0/ahk_roundtrip.log.
|
||||
;
|
||||
; Run with: AutoHotkey64.exe bench_roundtrip.ahk
|
||||
|
||||
#Requires AutoHotkey v2.0
|
||||
#SingleInstance Force
|
||||
|
||||
WinTitle := "Asheron's Call"
|
||||
LogFile := "C:\Users\acbot\leakhunt\artifacts\phase0\ahk_roundtrip.log"
|
||||
|
||||
Log(msg) {
|
||||
global LogFile
|
||||
FileAppend Format("[{1}] {2}`n", FormatTime(A_Now, "yyyy-MM-dd HH:mm:ss"), msg), LogFile
|
||||
}
|
||||
|
||||
Log("script start")
|
||||
|
||||
if not WinWait(WinTitle, , 30) {
|
||||
Log("FATAL: AC window not found within 30s")
|
||||
ExitApp 1
|
||||
}
|
||||
Log("AC window found")
|
||||
|
||||
WinActivate WinTitle
|
||||
Sleep 1500
|
||||
if not WinActive(WinTitle) {
|
||||
Log("WARN: WinActivate did not bring AC to foreground; trying ControlSend fallback")
|
||||
}
|
||||
|
||||
; Phase A: if at char-select, Enter selects the default (only) character and enters world.
|
||||
; If we're already in-world (memory >800 MB suggested by the supervisor probe), Enter opens
|
||||
; chat which we'll close immediately.
|
||||
SendInput "{Enter}"
|
||||
Log("sent Enter #1 (char-select confirm or chat-open)")
|
||||
Sleep 2000
|
||||
|
||||
; Phase B: send Escape to dismiss any modal/chat that may have opened. Harmless if no modal.
|
||||
SendInput "{Esc}"
|
||||
Log("sent Escape (dismiss modal/chat)")
|
||||
Sleep 800
|
||||
|
||||
; Phase C: small movement step to satisfy "walk one step" requirement (Phase 0 §4).
|
||||
; W is the AC default forward-walk bind in most layouts. Hold 250 ms.
|
||||
SendInput "{w down}"
|
||||
Sleep 250
|
||||
SendInput "{w up}"
|
||||
Log("sent W pulse (walk one step)")
|
||||
Sleep 1000
|
||||
|
||||
; Phase D: clean quit. AC supports @quit chat command to log out to character select.
|
||||
; Then we send /quit again from char-select to terminate the client process gracefully.
|
||||
SendInput "{Enter}" ; open chat
|
||||
Sleep 200
|
||||
SendInput "@quit"
|
||||
Sleep 200
|
||||
SendInput "{Enter}"
|
||||
Log("sent @quit (logout-to-char-select)")
|
||||
Sleep 4000
|
||||
|
||||
; If still alive at char-select: ask the launcher menu to exit the client.
|
||||
; AC's exit-to-desktop on char-select is typically Escape -> Yes-to-exit confirmation.
|
||||
SendInput "{Esc}"
|
||||
Sleep 600
|
||||
SendInput "{Enter}" ; default focus on "Quit" or "Yes"
|
||||
Log("sent Escape + Enter (exit from char-select)")
|
||||
|
||||
Sleep 1500
|
||||
Log("script end")
|
||||
ExitApp 0
|
||||
51
bin/cdb_probe.ps1
Normal file
51
bin/cdb_probe.ps1
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
#requires -Version 5.1
|
||||
<#
|
||||
cdb_probe.ps1 <dump.dmp>
|
||||
Standard analysis probe for an acclient minidump. Writes structured
|
||||
output next to the dump as <dump>.probe.txt.
|
||||
|
||||
Runs (in order):
|
||||
vertarget — image version + uptime
|
||||
.lastevent — last debug event captured
|
||||
!peb — process env block
|
||||
lm vM — modules with versions
|
||||
!address -summary — VA usage summary
|
||||
!address /f:Heap — list heap regions and sizes
|
||||
!runaway 7 — thread CPU usage (kernel+user time)
|
||||
~* k 12 — short stack of every thread (no symbols, just RVAs)
|
||||
#>
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $Dump
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (-not (Test-Path $Dump)) { throw "Dump file not found: $Dump" }
|
||||
|
||||
$cdb = 'C:\Users\acbot\Tools\WindowsKits\Windows Kits\10\Debuggers\x86\cdb.exe'
|
||||
$out = "$Dump.probe.txt"
|
||||
$env:_NT_SYMBOL_PATH = 'C:\Users\acbot\leakhunt\pdb'
|
||||
|
||||
$script = @(
|
||||
'.echo === vertarget ==='
|
||||
'vertarget'
|
||||
'.echo === lastevent ==='
|
||||
'.lastevent'
|
||||
'.echo === peb ==='
|
||||
'!peb'
|
||||
'.echo === modules ==='
|
||||
'lm vM'
|
||||
'.echo === address summary ==='
|
||||
'!address -summary'
|
||||
'.echo === heap regions ==='
|
||||
'!address /f:Heap'
|
||||
'.echo === runaway ==='
|
||||
'!runaway 7'
|
||||
'.echo === threads top frames ==='
|
||||
'~* k 12'
|
||||
'q'
|
||||
) -join ';'
|
||||
|
||||
& $cdb -z $Dump -y 'C:\Users\acbot\leakhunt\pdb' -c $script 2>&1 |
|
||||
Out-File -FilePath $out -Encoding utf8
|
||||
Write-Output "probe written: $out size=$([math]::Round((Get-Item $out).Length/1KB,1)) KB"
|
||||
18
bin/heap_summary.cdb
Normal file
18
bin/heap_summary.cdb
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
$$ heap_summary.cdb — run via: cdb -z <dump.dmp> -cf bin\heap_summary.cdb
|
||||
$$
|
||||
$$ One command per line. .sympath eats the rest of its line, so the path goes
|
||||
$$ on its own line. This avoids the -c semicolon-ambiguity hazard.
|
||||
|
||||
.sympath C:\Users\acbot\leakhunt\pdb
|
||||
.symopt+ 0x40
|
||||
.reload /f acclient.exe
|
||||
|
||||
$$ Heap summary — top-level overview, lightweight.
|
||||
.echo === !heap -s ===
|
||||
!heap -s
|
||||
|
||||
$$ Per-heap allocation-size histogram, useful for tracking growth.
|
||||
.echo === !heap -stat -h 0 ===
|
||||
!heap -stat -h 0
|
||||
|
||||
q
|
||||
48
bin/keepalive_f5.ahk
Normal file
48
bin/keepalive_f5.ahk
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
; keepalive_f5.ahk
|
||||
; Sends F5 to the AC window every 60 s via SendInput so DirectInput sees it.
|
||||
; Brings AC to foreground briefly each pulse (focus-stealing is unavoidable
|
||||
; for SendInput).
|
||||
;
|
||||
; Run with: AutoHotkey64.exe bin\keepalive_f5.ahk
|
||||
|
||||
#Requires AutoHotkey v2.0
|
||||
#SingleInstance Force
|
||||
SetTitleMatchMode 2
|
||||
|
||||
WinTitle := "ahk_class Turbine Device Class"
|
||||
LogFile := "C:\Users\acbot\leakhunt\artifacts\phase1\keepalive.log"
|
||||
|
||||
LogPulse(msg) {
|
||||
global LogFile
|
||||
FileAppend("[" FormatTime(A_Now, "yyyy-MM-dd HH:mm:ss") "] " msg "`n", LogFile)
|
||||
}
|
||||
|
||||
LogPulse("AHK keepalive starting, alternating z/c (2s hold) every 600s (10 min)")
|
||||
|
||||
i := 0
|
||||
Loop {
|
||||
if not WinExist(WinTitle) {
|
||||
LogPulse("AC window not present - waiting 10s")
|
||||
Sleep 10000
|
||||
continue
|
||||
}
|
||||
try {
|
||||
WinActivate(WinTitle)
|
||||
Sleep 200
|
||||
if (Mod(i, 2) = 0) {
|
||||
Send("{z down}")
|
||||
Sleep 2000
|
||||
Send("{z up}")
|
||||
LogPulse("pulse " i ": z held 2s")
|
||||
} else {
|
||||
Send("{c down}")
|
||||
Sleep 2000
|
||||
Send("{c up}")
|
||||
LogPulse("pulse " i ": c held 2s")
|
||||
}
|
||||
i := i + 1
|
||||
} catch as e {
|
||||
LogPulse("ERROR: " e.Message)
|
||||
}
|
||||
Sleep 600000 ; 10 min production interval
|
||||
}
|
||||
72
bin/keepalive_f5.ps1
Normal file
72
bin/keepalive_f5.ps1
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# keepalive_f5.ps1
|
||||
# Pulses F5 to the AC client window every N seconds to defeat Coldeve's
|
||||
# idle-kick. Finds the window by class "Turbine Device Class" so it's
|
||||
# robust to title-string encoding quirks.
|
||||
|
||||
param(
|
||||
[int]$IntervalSec = 60,
|
||||
[string]$LogFile = "C:\Users\acbot\leakhunt\artifacts\phase1\keepalive.log"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
public class LhKA {
|
||||
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
||||
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr hWnd, StringBuilder text, int count);
|
||||
[DllImport("user32.dll")] public static extern int GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
|
||||
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
|
||||
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
|
||||
}
|
||||
"@ -ErrorAction SilentlyContinue
|
||||
|
||||
function W([string]$msg) {
|
||||
$t = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
|
||||
"[$t] $msg" | Out-File -Append -Encoding utf8 -FilePath $LogFile
|
||||
}
|
||||
|
||||
function Find-AcWindow {
|
||||
param([int]$ProcessId)
|
||||
$found = $null
|
||||
$cb = [LhKA+EnumWindowsProc]{
|
||||
param($h, $l)
|
||||
$pid_ = 0
|
||||
[void][LhKA]::GetWindowThreadProcessId($h, [ref]$pid_)
|
||||
if ($pid_ -eq $ProcessId -and [LhKA]::IsWindowVisible($h)) {
|
||||
$cls = New-Object System.Text.StringBuilder 256
|
||||
[void][LhKA]::GetClassName($h, $cls, $cls.Capacity)
|
||||
if ($cls.ToString() -eq "Turbine Device Class") {
|
||||
$script:found = $h
|
||||
return $false
|
||||
}
|
||||
}
|
||||
return $true
|
||||
}
|
||||
[void][LhKA]::EnumWindows($cb, [IntPtr]::Zero)
|
||||
return $script:found
|
||||
}
|
||||
|
||||
$WM_KEYDOWN = 0x0100
|
||||
$WM_KEYUP = 0x0101
|
||||
$VK_F5 = 0x74
|
||||
|
||||
W "keepalive_f5 starting, pulse every $IntervalSec s"
|
||||
|
||||
while ($true) {
|
||||
$ac = Get-Process -Name acclient -ErrorAction SilentlyContinue | Sort-Object StartTime -Descending | Select-Object -First 1
|
||||
if (-not $ac) { W "acclient gone - exiting"; break }
|
||||
|
||||
$hwnd = Find-AcWindow -ProcessId $ac.Id
|
||||
if (-not $hwnd) { W "AC window not found for PID $($ac.Id); waiting"; Start-Sleep -Seconds $IntervalSec; continue }
|
||||
|
||||
$ok1 = [LhKA]::PostMessage($hwnd, $WM_KEYDOWN, [IntPtr]$VK_F5, [IntPtr]0)
|
||||
Start-Sleep -Milliseconds 50
|
||||
$ok2 = [LhKA]::PostMessage($hwnd, $WM_KEYUP, [IntPtr]$VK_F5, [IntPtr]0)
|
||||
W "F5 pulse -> hwnd=0x$($hwnd.ToInt64().ToString('X')) pid=$($ac.Id) PM=$([math]::Round($ac.PrivateMemorySize64/1MB,1))MB ok=$ok1,$ok2"
|
||||
|
||||
Start-Sleep -Seconds $IntervalSec
|
||||
}
|
||||
111
bin/phase1_idle_baseline.ps1
Normal file
111
bin/phase1_idle_baseline.ps1
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# 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)
|
||||
}
|
||||
33
bin/sample_fleet.ps1
Normal file
33
bin/sample_fleet.ps1
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
#requires -Version 5.1
|
||||
<#
|
||||
sample_fleet.ps1
|
||||
One-shot working-set + private-bytes + virtual-bytes sample for every
|
||||
running acclient.exe. Appends to artifacts/soak/memtrace_fleet.csv.
|
||||
|
||||
Usage:
|
||||
powershell -ExecutionPolicy Bypass -File bin\sample_fleet.ps1
|
||||
or schedule via ScheduleWakeup with a recurring 1500-1800s cadence.
|
||||
#>
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$logDir = Join-Path $root 'artifacts\soak'
|
||||
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
|
||||
$csv = Join-Path $logDir 'memtrace_fleet.csv'
|
||||
if (-not (Test-Path $csv)) {
|
||||
'ts_utc,pid,start,uptime_h,ws_mb,private_mb,virtual_mb,handle_count,thread_count' |
|
||||
Out-File -FilePath $csv -Encoding utf8
|
||||
}
|
||||
|
||||
$now = [DateTime]::UtcNow
|
||||
$procs = Get-Process -Name acclient -ErrorAction SilentlyContinue
|
||||
foreach ($p in $procs) {
|
||||
$up = [math]::Round(($now - $p.StartTime.ToUniversalTime()).TotalHours, 2)
|
||||
$line = '{0:o},{1},{2:o},{3},{4},{5},{6},{7},{8}' -f $now, $p.Id,
|
||||
$p.StartTime.ToUniversalTime(), $up,
|
||||
[math]::Round($p.WorkingSet64 / 1MB, 1),
|
||||
[math]::Round($p.PrivateMemorySize64 / 1MB, 1),
|
||||
[math]::Round($p.VirtualMemorySize64 / 1MB, 1),
|
||||
$p.HandleCount, $p.Threads.Count
|
||||
Add-Content -LiteralPath $csv -Value $line
|
||||
}
|
||||
Write-Output ("samples={0} csv={1}" -f $procs.Count, $csv)
|
||||
105
bin/snapshot_leakers.ps1
Normal file
105
bin/snapshot_leakers.ps1
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
#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
|
||||
63
bin/take_snapshot.ps1
Normal file
63
bin/take_snapshot.ps1
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# take_snapshot.ps1
|
||||
# One-shot umdh snapshot helper. Auto-finds acclient PID and the next snap_NNN filename.
|
||||
#
|
||||
# Usage:
|
||||
# take_snapshot.ps1 -PhaseDir artifacts\phase1
|
||||
#
|
||||
# Requires: gflags +ust on acclient.exe (one-time, see Phase 1 step 1),
|
||||
# _NT_SYMBOL_PATH set to the directory containing acclient.pdb.
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$PhaseDir,
|
||||
[int]$ProcessId = 0, # 0 = auto-discover via Get-Process
|
||||
[string]$UmdhExe = "C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\umdh.exe",
|
||||
[int]$TimeoutSec = 600 # umdh on a fat 32-bit AC client can take 60-200s
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (-not (Test-Path $PhaseDir)) { New-Item -ItemType Directory -Path $PhaseDir -Force | Out-Null }
|
||||
if (-not (Test-Path $UmdhExe)) { throw "umdh.exe not found at $UmdhExe" }
|
||||
if (-not $env:_NT_SYMBOL_PATH) { $env:_NT_SYMBOL_PATH = "C:\Users\acbot\leakhunt\pdb" }
|
||||
|
||||
if ($ProcessId -eq 0) {
|
||||
$procs = Get-Process -Name acclient -ErrorAction SilentlyContinue
|
||||
if (-not $procs) { throw "acclient.exe not running" }
|
||||
if ($procs.Count -gt 1) { throw "multiple acclient.exe instances; specify -ProcessId" }
|
||||
$ProcessId = $procs[0].Id
|
||||
}
|
||||
|
||||
# Find next snap_NNN filename
|
||||
$existing = Get-ChildItem -Path $PhaseDir -Filter "snap_*.txt" -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { [int]([regex]::Match($_.BaseName, '\d+').Value) }
|
||||
$next = if ($existing) { ($existing | Measure-Object -Maximum).Maximum + 1 } else { 1 }
|
||||
$Out = Join-Path $PhaseDir ("snap_{0:D3}.txt" -f $next)
|
||||
|
||||
Write-Host "[$(Get-Date -Format HH:mm:ss)] umdh -p:$ProcessId -f:$Out"
|
||||
|
||||
$argList = @("-p:$ProcessId", "-f:$Out")
|
||||
$p = Start-Process -FilePath $UmdhExe -ArgumentList $argList -NoNewWindow -PassThru -RedirectStandardError "$Out.err"
|
||||
if (-not $p.WaitForExit($TimeoutSec * 1000)) {
|
||||
Stop-Process -Id $p.Id -Force
|
||||
throw "umdh timed out after $TimeoutSec s"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $Out)) { throw "umdh produced no output (exit $($p.ExitCode))" }
|
||||
$size = (Get-Item $Out).Length
|
||||
|
||||
# Pull the leading metadata + a quick stats summary
|
||||
$head = (Get-Content $Out -TotalCount 6) -join "`n"
|
||||
$totalAllocs = (Select-String -Path $Out -Pattern '^\d+ bytes \+' -AllMatches).Matches.Count
|
||||
|
||||
Write-Host ("[$(Get-Date -Format HH:mm:ss)] OK: {0} ({1:N0} bytes; {2} alloc-stack records)" -f $Out, $size, $totalAllocs)
|
||||
Write-Host "--- head ---"
|
||||
Write-Host $head
|
||||
|
||||
# Emit metadata for the orchestrator
|
||||
[pscustomobject]@{
|
||||
Path = $Out
|
||||
SizeBytes = $size
|
||||
Records = $totalAllocs
|
||||
Pid = $ProcessId
|
||||
Timestamp = (Get-Date).ToString("o")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue