#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__.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