caproom 0.7.5 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/caproom.ps1 DELETED
@@ -1,753 +0,0 @@
1
- #Requires -Version 5.1
2
- # caproom -- Windows backend.
3
- #
4
- # Enforcement uses a Job Object with JOB_OBJECT_LIMIT_PROCESS_MEMORY, which is a
5
- # real kernel-enforced cap (no polling race) and automatically covers child
6
- # processes. Note this limits *committed virtual memory*, not resident set -- the
7
- # POSIX backend caps RSS, so the same --limit value can bite at a different point.
8
- #
9
- # park uses EmptyWorkingSet, which trims a process's working set to the pagefile
10
- # on demand without suspending it -- unlike the POSIX backend's SIGSTOP, the
11
- # process keeps running, so wake is a no-op here.
12
-
13
- $ErrorActionPreference = 'Stop'
14
-
15
- function Show-Usage {
16
- param([switch]$AsHelp)
17
- $text = @'
18
- usage: caproom [--limit <mb>] [--interval <sec>] -- <command> [args...]
19
- caproom park <pid>
20
- caproom wake <pid>
21
- caproom status <pid>
22
- caproom guard [--threshold <pct>] [--interval <sec>] <pid...>
23
- caproom init <command> [--limit <mb>]
24
- caproom top --json [--pid <pid>] [--park-min-mb <mb>]
25
- caproom watch [--threshold-mb <mb>] [--auto-park] [--auto-wake-free-pct <pct>] [--json] <pid...>
26
- caproom setup / freemem
27
-
28
- --limit <mb> memory cap in MB (default: 4096). On Windows this caps
29
- committed virtual memory (Job Object ProcessMemoryLimit);
30
- on macOS/Linux it caps RSS. Same flag, different quantity.
31
- --interval <sec> poll interval for the fallback watchdog (default: 0.2)
32
- --force-watchdog use the polling watchdog instead of the Job Object backend
33
-
34
- Windows differences from macOS/Linux:
35
- * No SIGTERM grace period. Windows console apps have no signal equivalent,
36
- so a watchdog breach is a hard kill. The Job Object backend does not kill
37
- at all -- the allocation simply fails inside the process.
38
- * park <pid> uses EmptyWorkingSet: memory is trimmed to the pagefile
39
- immediately, on demand, and the process KEEPS RUNNING. There is no
40
- suspension, so it cannot hang a process that something is waiting on.
41
- * wake <pid> is a no-op -- nothing was suspended. Trimmed pages fault back
42
- in by themselves on next access.
43
-
44
- guard watches SYSTEM-WIDE free memory (not any single process) and auto-parks
45
- tracked pids (EmptyWorkingSet) once free mem drops below --threshold percent,
46
- before the OS has to fail an allocation itself. Use it when unrelated heavy
47
- processes (e.g. a GPU inference job and a TTS job in separate terminals)
48
- share a box and neither individually breaches any --limit cap. Foreground,
49
- blocking; exits once all watched pids have exited. There is no unpark step --
50
- park just trims the working set, pages fault back in on next access.
51
-
52
- env vars (override flags): CAPROOM_LIMIT_MB, CAPROOM_INTERVAL
53
-
54
- examples:
55
- caproom --limit 2048 -- npm run build
56
- caproom park 12345
57
- caproom guard --threshold 10 --interval 5 -- 12345 12346
58
- caproom init claude --limit 6144
59
- '@
60
- if ($AsHelp) { Write-Output $text; exit 0 }
61
- [Console]::Error.WriteLine($text)
62
- exit 1
63
- }
64
-
65
- $NativeMethods = @'
66
- using System;
67
- using System.Runtime.InteropServices;
68
-
69
- public static class Caproom {
70
- [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
71
- public static extern IntPtr CreateJobObject(IntPtr a, string lpName);
72
-
73
- [DllImport("kernel32.dll", SetLastError = true)]
74
- public static extern bool SetInformationJobObject(IntPtr hJob, int infoClass, IntPtr lpInfo, uint cbInfo);
75
-
76
- [DllImport("kernel32.dll", SetLastError = true)]
77
- public static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
78
-
79
- [DllImport("psapi.dll", SetLastError = true)]
80
- public static extern bool EmptyWorkingSet(IntPtr hProcess);
81
-
82
- [StructLayout(LayoutKind.Sequential)]
83
- public struct JOBOBJECT_BASIC_LIMIT_INFORMATION {
84
- public Int64 PerProcessUserTimeLimit;
85
- public Int64 PerJobUserTimeLimit;
86
- public UInt32 LimitFlags;
87
- public UIntPtr MinimumWorkingSetSize;
88
- public UIntPtr MaximumWorkingSetSize;
89
- public UInt32 ActiveProcessLimit;
90
- public UIntPtr Affinity;
91
- public UInt32 PriorityClass;
92
- public UInt32 SchedulingClass;
93
- }
94
-
95
- [StructLayout(LayoutKind.Sequential)]
96
- public struct IO_COUNTERS {
97
- public UInt64 ReadOperationCount;
98
- public UInt64 WriteOperationCount;
99
- public UInt64 OtherOperationCount;
100
- public UInt64 ReadTransferCount;
101
- public UInt64 WriteTransferCount;
102
- public UInt64 OtherTransferCount;
103
- }
104
-
105
- [StructLayout(LayoutKind.Sequential)]
106
- public struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
107
- public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
108
- public IO_COUNTERS IoInfo;
109
- public UIntPtr ProcessMemoryLimit;
110
- public UIntPtr JobMemoryLimit;
111
- public UIntPtr PeakProcessMemoryUsed;
112
- public UIntPtr PeakJobMemoryUsed;
113
- }
114
-
115
- public const int ExtendedLimitInformation = 9;
116
- public const uint LIMIT_PROCESS_MEMORY = 0x00000100;
117
- public const uint LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
118
- }
119
- '@
120
-
121
- function Import-Native {
122
- if (-not ('Caproom' -as [type])) { Add-Type -TypeDefinition $script:NativeMethods }
123
- }
124
-
125
- function Invoke-Park {
126
- param([int]$TargetPid)
127
- Import-Native
128
- $proc = Get-Process -Id $TargetPid -ErrorAction SilentlyContinue
129
- if (-not $proc) { [Console]::Error.WriteLine("caproom: no such pid $TargetPid"); exit 1 }
130
- $before = $proc.WorkingSet64
131
- if (-not [Caproom]::EmptyWorkingSet($proc.Handle)) {
132
- [Console]::Error.WriteLine("caproom: EmptyWorkingSet failed for pid $TargetPid (error $([Runtime.InteropServices.Marshal]::GetLastWin32Error()))")
133
- exit 1
134
- }
135
- $after = (Get-Process -Id $TargetPid).WorkingSet64
136
- [Console]::Error.WriteLine("caproom: pid $TargetPid parked -- working set trimmed $([math]::Round($before/1MB))MB -> $([math]::Round($after/1MB))MB. Process is STILL RUNNING (no suspension); pages fault back in on access.")
137
- }
138
-
139
- function Invoke-Wake {
140
- param([int]$TargetPid)
141
- if (-not (Get-Process -Id $TargetPid -ErrorAction SilentlyContinue)) {
142
- [Console]::Error.WriteLine("caproom: no such pid $TargetPid"); exit 1
143
- }
144
- [Console]::Error.WriteLine("caproom: pid $TargetPid -- nothing to wake. On Windows park trims the working set without suspending, so the process never stopped running.")
145
- }
146
-
147
- function Invoke-Status {
148
- param([int]$TargetPid)
149
- $proc = Get-Process -Id $TargetPid -ErrorAction SilentlyContinue
150
- if (-not $proc) { [Console]::Error.WriteLine("caproom: no such pid $TargetPid"); exit 1 }
151
- [PSCustomObject]@{
152
- Pid = $proc.Id
153
- WorkingSetMB = [math]::Round($proc.WorkingSet64 / 1MB)
154
- CommittedMB = [math]::Round($proc.PagedMemorySize64 / 1MB)
155
- Elapsed = (Get-Date) - $proc.StartTime
156
- Command = $proc.ProcessName
157
- } | Format-List
158
- }
159
-
160
- function Get-FreeMemPercent {
161
- $os = Get-CimInstance Win32_OperatingSystem
162
- return [math]::Floor(($os.FreePhysicalMemory * 100) / $os.TotalVisibleMemorySize)
163
- }
164
-
165
- function Invoke-Guard {
166
- param([int]$Threshold, [double]$Interval, [int[]]$TargetPids)
167
- Import-Native
168
- [Console]::Error.WriteLine("caproom: guarding $($TargetPids.Count) pid(s), park when system free mem < ${Threshold}% (poll ${Interval}s)")
169
- $parked = @{}
170
- while ($true) {
171
- $alive = @($TargetPids | Where-Object { Get-Process -Id $_ -ErrorAction SilentlyContinue })
172
- if ($alive.Count -eq 0) {
173
- [Console]::Error.WriteLine("caproom: guard: all watched pids exited")
174
- exit 0
175
- }
176
- $TargetPids = $alive
177
- $pct = Get-FreeMemPercent
178
- if ($pct -lt $Threshold) {
179
- foreach ($p in $TargetPids) {
180
- if (-not $parked.ContainsKey($p)) {
181
- $proc = Get-Process -Id $p -ErrorAction SilentlyContinue
182
- if ($proc) {
183
- [Console]::Error.WriteLine("caproom: system free mem ${pct}% < ${Threshold}% threshold -- about to blow, parking pid $p (EmptyWorkingSet)")
184
- [void][Caproom]::EmptyWorkingSet($proc.Handle)
185
- $parked[$p] = $true
186
- }
187
- }
188
- }
189
- }
190
- Start-Sleep -Seconds $Interval
191
- }
192
- }
193
-
194
- function Invoke-Init {
195
- param([string]$Target, [int]$LimitMb)
196
- @"
197
- # caproom: auto-cap '$Target' -- added by 'caproom init $Target'
198
- # override per-shell: `$env:CAPROOM_LIMIT_MB = 8192
199
- function ${Target}_capped {
200
- `$limit = if (`$env:CAPROOM_LIMIT_MB) { `$env:CAPROOM_LIMIT_MB } else { $LimitMb }
201
- caproom --force-watchdog --limit `$limit -- $Target @args
202
- }
203
- Set-Alias -Name $Target -Value ${Target}_capped -Force
204
- "@
205
- }
206
-
207
- # Start-Process -ArgumentList joins an array with spaces and does no quoting,
208
- # so an argument containing whitespace gets re-split into several arguments by
209
- # the callee. Build one command line with CommandLineToArgvW quoting instead.
210
- function ConvertTo-ArgString {
211
- param([string[]]$Arguments)
212
- $quoted = foreach ($a in $Arguments) {
213
- if ($a -eq '') { '""' }
214
- elseif ($a -notmatch '[\s"]') { $a }
215
- else {
216
- # Double any backslashes preceding a quote (and at end of string),
217
- # then escape the quotes themselves.
218
- $s = $a -replace '(\\*)"', '$1$1\"'
219
- $s = $s -replace '(\\+)$', '$1$1'
220
- '"' + $s + '"'
221
- }
222
- }
223
- $quoted -join ' '
224
- }
225
-
226
- function New-CappedProcess {
227
- # Every pipe-based capture (Process class + ReadToEndAsync, Process class
228
- # + raw BaseStream, with and without stripping std-handle inheritance)
229
- # returned zero bytes in CI despite a clean exit 0 -- caproom is invoked
230
- # as powershell.exe -File caproom.ps1 from the Node shim, itself invoked
231
- # from a pwsh.EXE step that captures via a pipe (`| Out-String`), and
232
- # something in that nesting swallows anonymous-pipe output every time.
233
- # File-based redirection (Start-Process -RedirectStandardOutput <file>)
234
- # was the one capture method that survived an isolated repro under the
235
- # exact same nesting in the same CI job, so route through temp files
236
- # instead of pipes entirely.
237
- param([string]$Exe, [string]$ArgLine)
238
- $resolvedExe = $Exe
239
- $cmd = Get-Command $Exe -ErrorAction SilentlyContinue
240
- if ($cmd) { $resolvedExe = $cmd.Source }
241
-
242
- $outFile = [IO.Path]::GetTempFileName()
243
- $errFile = [IO.Path]::GetTempFileName()
244
- $proc = Start-Process -FilePath $resolvedExe -ArgumentList $ArgLine -NoNewWindow `
245
- -RedirectStandardOutput $outFile -RedirectStandardError $errFile -PassThru
246
-
247
- # Start-Process's PassThru object opens a limited-rights handle lazily --
248
- # if .Handle is never touched while the process is still alive, .ExitCode
249
- # silently reads back 0 for an already-exited process instead of the real
250
- # code. Force the full-access handle open now, before it can exit.
251
- $null = $proc.Handle
252
-
253
- $proc | Add-Member -NotePropertyName StdoutFile -NotePropertyValue $outFile
254
- $proc | Add-Member -NotePropertyName StderrFile -NotePropertyValue $errFile
255
- return $proc
256
- }
257
-
258
- function Read-NewOutput {
259
- # Tail-follow one capture file from its recorded byte offset, writing new
260
- # bytes to the given console stream as they land so output streams live.
261
- # Byte-level writes pass the child's bytes through un-re-encoded.
262
- param([string]$Path, $Offsets, $Stream)
263
- if (-not (Test-Path -LiteralPath $Path)) { return }
264
- $fs = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite)
265
- try {
266
- if ($fs.Length -lt $Offsets[$Path]) { $Offsets[$Path] = 0 } # file truncated/recreated under us
267
- if ($fs.Length -gt $Offsets[$Path]) {
268
- $fs.Position = $Offsets[$Path]
269
- $len = [int]($fs.Length - $fs.Position)
270
- $buf = New-Object byte[] $len
271
- $read = 0
272
- while ($read -lt $len) {
273
- $n = $fs.Read($buf, $read, $len - $read)
274
- if ($n -le 0) { break }
275
- $read += $n
276
- }
277
- if ($read -gt 0) {
278
- $Offsets[$Path] += $read
279
- $Stream.Write($buf, 0, $read)
280
- $Stream.Flush()
281
- }
282
- }
283
- } finally { $fs.Close() }
284
- }
285
-
286
- function Wait-CappedProcess {
287
- # Drains remaining output, cleans up the temp capture files, returns the
288
- # exit code. If the caller streamed while polling (watchdog path), pass
289
- # the SAME offsets table so only the unread tail is relayed here; the
290
- # job-object path streams internally on a 50ms cadence.
291
- param($Proc, $Offsets = @{ ($Proc.StdoutFile) = 0; ($Proc.StderrFile) = 0 })
292
- try {
293
- while (-not $Proc.HasExited) {
294
- Read-NewOutput -Path $Proc.StdoutFile -Offsets $Offsets -Stream ([Console]::Out)
295
- Read-NewOutput -Path $Proc.StderrFile -Offsets $Offsets -Stream ([Console]::Error)
296
- Start-Sleep -Milliseconds 50
297
- }
298
- Read-NewOutput -Path $Proc.StdoutFile -Offsets $Offsets -Stream ([Console]::Out)
299
- Read-NewOutput -Path $Proc.StderrFile -Offsets $Offsets -Stream ([Console]::Error)
300
- return $Proc.ExitCode
301
- } finally {
302
- Remove-Item -LiteralPath $Proc.StdoutFile, $Proc.StderrFile -ErrorAction SilentlyContinue
303
- }
304
- }
305
-
306
- # The watchdog must see the WHOLE tree, not just the top pid: coding agents
307
- # keep their memory in children (MCP servers, bundler daemons, headless
308
- # browsers) while the parent's own working set stays flat. Walk the
309
- # parent->child edges of one Win32_Process snapshot and sum working sets.
310
- function Get-TreeWorkingSetBytes {
311
- param([int]$RootPid)
312
- $ws = @{}
313
- $kids = @{}
314
- foreach ($p in Get-CimInstance -ClassName Win32_Process -Property ProcessId, ParentProcessId, WorkingSetSize) {
315
- $pidInt = [int]$p.ProcessId
316
- $ppidInt = [int]$p.ParentProcessId
317
- $ws[$pidInt] = [uint64]$p.WorkingSetSize
318
- if (-not $kids.ContainsKey($ppidInt)) { $kids[$ppidInt] = @() }
319
- $kids[$ppidInt] += $pidInt
320
- }
321
- if (-not $ws.ContainsKey($RootPid)) { return [uint64]0 }
322
- $total = [uint64]0
323
- $queue = New-Object System.Collections.Queue
324
- $visited = @{}
325
- $queue.Enqueue($RootPid)
326
- while ($queue.Count -gt 0) {
327
- $cur = [int]$queue.Dequeue()
328
- if ($visited.ContainsKey($cur)) { continue } # pid-reuse / cycle guard
329
- $visited[$cur] = $true
330
- $total += $ws[$cur]
331
- if ($kids.ContainsKey($cur)) { foreach ($c in $kids[$cur]) { [void]$queue.Enqueue($c) } }
332
- }
333
- return $total
334
- }
335
-
336
- function Invoke-Capped {
337
- param([int]$LimitMb, [double]$Interval, [bool]$ForceWatchdog, [string[]]$Command)
338
-
339
- $exe = $Command[0]
340
- $rest = if ($Command.Length -gt 1) { ConvertTo-ArgString $Command[1..($Command.Length - 1)] } else { '' }
341
-
342
- if (-not $ForceWatchdog) {
343
- try {
344
- Import-Native
345
- $job = [Caproom]::CreateJobObject([IntPtr]::Zero, $null)
346
- if ($job -eq [IntPtr]::Zero) { throw 'CreateJobObject returned NULL' }
347
-
348
- $info = New-Object Caproom+JOBOBJECT_EXTENDED_LIMIT_INFORMATION
349
- $info.BasicLimitInformation.LimitFlags = [Caproom]::LIMIT_PROCESS_MEMORY -bor [Caproom]::LIMIT_KILL_ON_JOB_CLOSE
350
- $info.ProcessMemoryLimit = [UIntPtr]::new([uint64]$LimitMb * 1MB)
351
-
352
- $size = [Runtime.InteropServices.Marshal]::SizeOf($info)
353
- $ptr = [Runtime.InteropServices.Marshal]::AllocHGlobal($size)
354
- try {
355
- [Runtime.InteropServices.Marshal]::StructureToPtr($info, $ptr, $false)
356
- if (-not [Caproom]::SetInformationJobObject($job, [Caproom]::ExtendedLimitInformation, $ptr, $size)) {
357
- throw "SetInformationJobObject failed (error $([Runtime.InteropServices.Marshal]::GetLastWin32Error()))"
358
- }
359
- } finally {
360
- [Runtime.InteropServices.Marshal]::FreeHGlobal($ptr)
361
- }
362
-
363
- # Assign ONLY the child to the job, immediately after spawn --
364
- # never caproom's own process. Putting the PowerShell runtime
365
- # inside the job made its ~100MB+ commit eat the user's budget,
366
- # and a PS spike could fail allocations inside THEIR command.
367
- # Policy: prefer under-counting over impeding. Cost is a
368
- # millisecond-scale window before assignment lands; the child's
369
- # own descendants are still covered automatically (they inherit
370
- # the association at CreateProcess).
371
- [Console]::Error.WriteLine("caproom: job object backend, limit=${LimitMb}m (committed memory, kernel-enforced, covers the command and its descendants)")
372
- $proc = New-CappedProcess -Exe $exe -ArgLine $rest
373
- if (-not [Caproom]::AssignProcessToJobObject($job, $proc.Handle)) {
374
- # Child is already running -- kill it before falling back,
375
- # or the watchdog path below would launch a second instance.
376
- & taskkill.exe /PID $proc.Id /T /F 2>$null | Out-Null
377
- throw "AssignProcessToJobObject failed (error $([Runtime.InteropServices.Marshal]::GetLastWin32Error()))"
378
- }
379
- exit (Wait-CappedProcess $proc)
380
- } catch {
381
- [Console]::Error.WriteLine("caproom: job object backend unavailable ($($_.Exception.Message)) -- falling back to watchdog")
382
- }
383
- }
384
-
385
- [Console]::Error.WriteLine("caproom: watchdog backend, limit=${LimitMb}m poll=${Interval}s (process-tree working set, hard kill on breach -- Windows has no SIGTERM equivalent)")
386
- $limitBytes = [uint64]$LimitMb * 1MB
387
- $proc = New-CappedProcess -Exe $exe -ArgLine $rest
388
- # Stream output WHILE the breach-poll loop runs -- polling must not sit
389
- # on the whole runtime and leave the tail-follow to drain everything at
390
- # exit. Same offsets table flows into Wait-CappedProcess for the final
391
- # drain so nothing is relayed twice.
392
- $offsets = @{ ($proc.StdoutFile) = 0; ($proc.StderrFile) = 0 }
393
- while (-not $proc.HasExited) {
394
- Read-NewOutput -Path $proc.StdoutFile -Offsets $offsets -Stream ([Console]::Out)
395
- Read-NewOutput -Path $proc.StderrFile -Offsets $offsets -Stream ([Console]::Error)
396
- Start-Sleep -Seconds $Interval
397
- if ($proc.HasExited) { break }
398
- $treeBytes = Get-TreeWorkingSetBytes -RootPid $proc.Id
399
- if ($treeBytes -gt $limitBytes) {
400
- [Console]::Error.WriteLine("caproom: process tree of pid $($proc.Id) using $([math]::Round($treeBytes/1MB))MB exceeded ${LimitMb}MB cap -- killing tree")
401
- & taskkill.exe /PID $proc.Id /T /F 2>$null | Out-Null
402
- exit 137
403
- }
404
- }
405
- exit (Wait-CappedProcess $proc -Offsets $offsets)
406
- }
407
-
408
- # ---- argument parsing ----
409
-
410
- if ($args.Count -eq 0) { Show-Usage }
411
-
412
- $script:CaproomNtLoaded = $false
413
- function Ensure-NtSuspend {
414
- # Whole-tree park needs NtSuspendProcess/NtResumeProcess (ntdll) --
415
- # the Windows analogue of kill -STOP/-CONT. Loaded lazily, once.
416
- if ($script:CaproomNtLoaded) { return }
417
- try {
418
- Add-Type -Namespace Caproom -Name Nt -MemberDefinition @'
419
- [DllImport("ntdll.dll")] public static extern int NtSuspendProcess(IntPtr processHandle);
420
- [DllImport("ntdll.dll")] public static extern int NtResumeProcess(IntPtr processHandle);
421
- [DllImport("kernel32.dll", SetLastError=true)] public static extern IntPtr OpenProcess(int desiredAccess, bool inheritHandle, int processId);
422
- [DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr handle);
423
- '@
424
- $script:CaproomNtLoaded = $true
425
- } catch {
426
- [Console]::Error.WriteLine('caproom watch: cannot load ntdll suspend/resume -- --auto-park unavailable')
427
- throw
428
- }
429
- }
430
-
431
- function Get-CaproomSnapshot {
432
- # One CIM query -> ByPid map, Children map (only live parents), Roots
433
- # (pids whose parent is not in the snapshot). Mirrors posix read_snapshot.
434
- $procs = @(Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,Name,CommandLine,WorkingSetSize)
435
- $byPid = @{}
436
- foreach ($p in $procs) { $byPid[[int]$p.ProcessId] = $p }
437
- $children = @{}
438
- foreach ($p in $procs) {
439
- $ppid = [int]$p.ParentProcessId
440
- if ($byPid.ContainsKey($ppid)) {
441
- if (-not $children.ContainsKey($ppid)) { $children[$ppid] = New-Object System.Collections.Generic.List[int] }
442
- $children[$ppid].Add([int]$p.ProcessId)
443
- }
444
- }
445
- $roots = @($procs | Where-Object { -not $byPid.ContainsKey([int]$_.ParentProcessId) } | ForEach-Object { [int]$_.ProcessId })
446
- return @{ ByPid=$byPid; Children=$children; Roots=$roots }
447
- }
448
-
449
- function Get-TreeStats {
450
- param([hashtable]$Snap, [int]$RootPid)
451
- $rss = [long]0
452
- $pids = New-Object System.Collections.Generic.List[int]
453
- $stack = New-Object System.Collections.Generic.Stack[int]
454
- $seen = @{}
455
- $stack.Push($RootPid)
456
- while ($stack.Count -gt 0) {
457
- $cur = $stack.Pop()
458
- if ($seen.ContainsKey($cur)) { continue }
459
- $seen[$cur] = $true
460
- if (-not $Snap.ByPid.ContainsKey($cur)) { continue }
461
- $proc = $Snap.ByPid[$cur]
462
- if ($proc.WorkingSetSize) { $rss += [long]$proc.WorkingSetSize }
463
- $pids.Add($cur)
464
- if ($Snap.Children.ContainsKey($cur)) { foreach ($c in $Snap.Children[$cur]) { $stack.Push($c) } }
465
- }
466
- return @{ RssKb = [long]($rss / 1KB); Pids = $pids }
467
- }
468
-
469
- function Invoke-Top {
470
- # schema:1 rows identical in shape to the POSIX build. One honest
471
- # divergence: Windows exposes no cheap sleep-state, so state is always
472
- # 'running' and park_candidate keys off tree size alone -- the reason
473
- # string says so instead of pretending a sleep check happened.
474
- param([int]$FilterPid = 0, [int]$ParkMinMb = 512)
475
- $snap = Get-CaproomSnapshot
476
- $ts = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
477
- $limitMb = 4096; if ($env:CAPROOM_LIMIT_MB) { $limitMb = [int]$env:CAPROOM_LIMIT_MB }
478
- $parkMinKb = [long]$ParkMinMb * 1024
479
- $rows = New-Object System.Collections.Generic.List[object]
480
- foreach ($root in $snap.Roots) {
481
- if ($FilterPid -ne 0 -and $root -ne $FilterPid) { continue }
482
- $st = Get-TreeStats -Snap $snap -RootPid $root
483
- $cmd = ''
484
- if ($snap.ByPid.ContainsKey($root)) {
485
- $procRow = $snap.ByPid[$root]
486
- if ($procRow.CommandLine) { $cmd = [string]$procRow.CommandLine } else { $cmd = [string]$procRow.Name }
487
- }
488
- $cand = $false; $reason = ''
489
- if ([long]$st.RssKb -ge $parkMinKb) {
490
- $cand = $true
491
- $reason = "tree_rss $($st.RssKb)KB >= ${parkMinKb}KB park threshold (win32: no sleep-state check)"
492
- }
493
- $rows.Add([pscustomobject]@{
494
- pid = $root; cmd = $cmd; tree_rss_kb = $st.RssKb
495
- tree_pids = @($st.Pids.ToArray()); state = 'running'
496
- park_candidate = $cand; reason = $reason
497
- })
498
- }
499
- $envelope = [pscustomobject]@{ schema = 1; ts = $ts; limit_mb_default = $limitMb; processes = @($rows.ToArray()) }
500
- ConvertTo-Json -Compress -Depth 6 -InputObject $envelope
501
- }
502
-
503
- function Invoke-Watch {
504
- # Same NDJSON contract as the POSIX watcher (schema:1 events on stdout
505
- # under --json). Explicit pids only; naming the pid IS the opt-in for
506
- # --auto-park, same rule as POSIX.
507
- # NOTE: args arrive via the named -Rest parameter, NOT $args — splatting
508
- # into $args proved unreliable here (every element read back as null,
509
- # yielding pids [0,0,0,0] and a busy-spinning interval-0 loop).
510
- param([string[]]$Rest)
511
- $thresholdMb = 2048; $intervalSec = 5.0; $auto = $false; $wake = -1.0; $json = $false
512
- $targets = New-Object System.Collections.Generic.List[int]
513
- for ($i = 0; $i -lt $Rest.Count; $i++) {
514
- switch ($Rest[$i]) {
515
- '--threshold-mb' { $thresholdMb = [int]$Rest[$i + 1]; $i++ }
516
- '--interval' { $intervalSec = [double]$Rest[$i + 1]; $i++ }
517
- '--auto-park' { $auto = $true }
518
- '--auto-wake-free-pct' { $wake = [double]$Rest[$i + 1]; $i++ }
519
- '--json' { $json = $true }
520
- '--' { }
521
- default {
522
- try { $targets.Add([int]$Rest[$i]) }
523
- catch { [Console]::Error.WriteLine("caproom: unknown watch arg $($Rest[$i])"); exit 1 }
524
- }
525
- }
526
- }
527
- if ($targets.Count -eq 0) {
528
- [Console]::Error.WriteLine('usage: caproom watch [--threshold-mb <mb>] [--interval <sec>] [--auto-park] [--auto-wake-free-pct <pct>] [--json] <pid...>')
529
- exit 1
530
- }
531
- if ($intervalSec -lt 0.5) { $intervalSec = 0.5 }
532
-
533
- function Emit([object]$Ev) {
534
- [Console]::Out.WriteLine((ConvertTo-Json -Compress -Depth 6 -InputObject $Ev))
535
- }
536
-
537
- $mode = 'watch'; if ($auto) { $mode = 'auto-park' }
538
- $ts0 = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
539
- if ($json) {
540
- Emit ([pscustomobject]@{ schema = 1; event = 'started'; ts = $ts0; mode = $mode; threshold_kb = ($thresholdMb * 1024); pids = @($targets.ToArray()) })
541
- } else {
542
- $armed = ''; if ($auto) { $armed = ', AUTO-PARK ARMED' }
543
- [Console]::Error.WriteLine("caproom: watching $($targets.Count) pid(s), tree threshold ${thresholdMb}MB, poll ${intervalSec}s$armed")
544
- }
545
-
546
- $parkedByUs = New-Object System.Collections.Generic.List[int]
547
- $breaching = @{}
548
- while ($true) {
549
- $liveSet = @{}
550
- foreach ($q in @(Get-CimInstance Win32_Process -Property ProcessId)) { $liveSet[[int]$q.ProcessId] = $true }
551
- $alive = New-Object System.Collections.Generic.List[int]
552
- foreach ($tpid in $targets) { if ($liveSet.ContainsKey($tpid)) { $alive.Add($tpid) } }
553
- if ($alive.Count -eq 0) {
554
- if ($json) { Emit ([pscustomobject]@{ schema = 1; event = 'all-exited'; ts = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() }) }
555
- [Console]::Error.WriteLine('caproom: watch: all watched pids exited')
556
- exit 0
557
- }
558
- $targets = $alive
559
-
560
- if ($wake -ge 0 -and $parkedByUs.Count -gt 0) {
561
- $os = Get-CimInstance Win32_OperatingSystem
562
- $pct = [int]($os.FreePhysicalMemory * 100 / $os.TotalVisibleMemorySize)
563
- if ($pct -ge $wake) {
564
- Ensure-NtSuspend
565
- foreach ($wpid in @($parkedByUs.ToArray())) {
566
- if (-not $liveSet.ContainsKey($wpid)) { continue }
567
- $h = [Caproom.Nt]::OpenProcess(0x0800, $false, $wpid)
568
- if ($h -ne [IntPtr]::Zero) {
569
- [void][Caproom.Nt]::NtResumeProcess($h); [void][Caproom.Nt]::CloseHandle($h)
570
- if ($json) { Emit ([pscustomobject]@{ schema = 1; event = 'woke'; ts = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds(); pid = $wpid; free_pct = $pct }) }
571
- else { [Console]::Error.WriteLine("caproom: watch: free mem ${pct}% >= ${wake}% -- resuming pid $wpid") }
572
- }
573
- }
574
- $parkedByUs.Clear()
575
- }
576
- }
577
-
578
- $snap = Get-CaproomSnapshot
579
- $threshKb = [long]$thresholdMb * 1024
580
- foreach ($tpid in $targets) {
581
- if (-not $snap.ByPid.ContainsKey($tpid)) { continue }
582
- $st = Get-TreeStats -Snap $snap -RootPid $tpid
583
- if ([long]$st.RssKb -ge $threshKb) {
584
- if ($breaching.ContainsKey($tpid)) { continue }
585
- $breaching[$tpid] = $true
586
- $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
587
- if ($auto) {
588
- Ensure-NtSuspend
589
- $stopped = 0
590
- foreach ($cp in $st.Pids) {
591
- $h = [Caproom.Nt]::OpenProcess(0x0800, $false, $cp)
592
- if ($h -ne [IntPtr]::Zero) {
593
- [void][Caproom.Nt]::NtSuspendProcess($h); [void][Caproom.Nt]::CloseHandle($h)
594
- $parkedByUs.Add($cp); $stopped++
595
- }
596
- }
597
- if ($json) { Emit ([pscustomobject]@{ schema = 1; event = 'parked'; ts = $now; pid = $tpid; tree_rss_kb = $st.RssKb; tree_pids = @($st.Pids.ToArray()); stopped = $stopped }) }
598
- else { [Console]::Error.WriteLine("caproom: watch: tree of pid $tpid hit $($st.RssKb)KB (>= $([int]$threshKb)KB) -- PARKED tree ($stopped pids)") }
599
- } else {
600
- if ($json) { Emit ([pscustomobject]@{ schema = 1; event = 'breach'; ts = $now; pid = $tpid; tree_rss_kb = $st.RssKb }) }
601
- else { [Console]::Error.WriteLine("caproom: watch: tree of pid $tpid hit $($st.RssKb)KB (>= $([int]$threshKb)KB) -- no --auto-park, reporting only") }
602
- }
603
- } else {
604
- if ($breaching.ContainsKey($tpid)) {
605
- $breaching.Remove($tpid)
606
- $now2 = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
607
- if ($json) { Emit ([pscustomobject]@{ schema = 1; event = 'recovered'; ts = $now2; pid = $tpid; tree_rss_kb = $st.RssKb }) }
608
- else { [Console]::Error.WriteLine("caproom: watch: pid $tpid back under threshold ($($st.RssKb)KB)") }
609
- }
610
- }
611
- }
612
- Start-Sleep -Seconds $intervalSec
613
- }
614
- }
615
-
616
- function Invoke-Setup {
617
- # Bind headroom management to PowerShell sessions in ANY terminal:
618
- # writes ~/.caproom/shell.ps1 (single source) and marker-patches
619
- # $PROFILE. Idempotent, backs up the profile, reversible via
620
- # `caproom setup --uninstall`. Never runs on npm install.
621
- $dir = Join-Path $HOME '.caproom'
622
- New-Item -ItemType Directory -Force -Path $dir | Out-Null
623
-
624
- @'
625
- # caproom PowerShell integration -- regenerated by `caproom setup`.
626
- function global:caproom_freemem_pct {
627
- $os = Get-CimInstance Win32_OperatingSystem
628
- [int]($os.FreePhysicalMemory * 100 / $os.TotalVisibleMemorySize)
629
- }
630
- $global:__caproomLastWarn = 0
631
- function global:prompt {
632
- try {
633
- $pct = caproom_freemem_pct
634
- $now = [DateTimeOffset]::Now.ToUnixTimeSeconds()
635
- $warn = if ($env:CAPROOM_HEADROOM_WARN) { [int]$env:CAPROOM_HEADROOM_WARN } else { 20 }
636
- if ($pct -lt $warn -and ($now - $script:__caproomLastWarn) -ge 60) {
637
- $script:__caproomLastWarn = $now
638
- Write-Host "caproom: headroom low ($pct% free) - check 'caproom top' before launching heavy work" -ForegroundColor Yellow
639
- }
640
- } catch {}
641
- "PS $($executionContext.SessionState.Path.CurrentLocation)> "
642
- }
643
- '@ | Set-Content -Encoding UTF8 (Join-Path $dir 'shell.ps1')
644
-
645
- $profilePath = $PROFILE.CurrentUserAllHosts
646
- if (-not (Test-Path $profilePath)) { New-Item -ItemType File -Force -Path $profilePath | Out-Null }
647
- $content = Get-Content $profilePath -Raw -ErrorAction SilentlyContinue
648
- if ($content -notmatch '# >>> caproom >>>') {
649
- Copy-Item $profilePath "$profilePath.caproom.bak.$(Get-Date -Format yyyyMMddHHmmss)"
650
- Add-Content $profilePath @'
651
-
652
- # >>> caproom >>>
653
- . "$HOME\.caproom\shell.ps1"
654
- # <<< caproom <<<
655
- '@
656
- [Console]::Error.WriteLine("caproom setup: patched $profilePath (backup alongside)")
657
- } else {
658
- [Console]::Error.WriteLine('caproom setup: profile already bound')
659
- }
660
- [Console]::Error.WriteLine('caproom setup: shell.ps1 written to ' + $dir + ' -- new terminals pick it up automatically')
661
- }
662
-
663
- switch ($args[0]) {
664
- 'help' { Show-Usage -AsHelp }
665
- '-h' { Show-Usage -AsHelp }
666
- '--help' { Show-Usage -AsHelp }
667
- 'setup' {
668
- Invoke-Setup; exit 0
669
- }
670
- 'bind' {
671
- Invoke-Setup; exit 0
672
- }
673
- 'freemem' {
674
- $os = Get-CimInstance Win32_OperatingSystem
675
- Write-Output ([int]($os.FreePhysicalMemory * 100 / $os.TotalVisibleMemorySize))
676
- exit 0
677
- }
678
- 'top' {
679
- $fpid = 0; $parkMin = 512
680
- for ($i = 1; $i -lt $args.Count; $i++) {
681
- switch ($args[$i]) {
682
- '--json' { }
683
- '--pid' { $fpid = [int]$args[$i + 1]; $i++ }
684
- '--park-min-mb' { $parkMin = [int]$args[$i + 1]; $i++ }
685
- default { [Console]::Error.WriteLine("caproom: unknown top flag $($args[$i])"); exit 1 }
686
- }
687
- }
688
- Invoke-Top -FilterPid $fpid -ParkMinMb $parkMin
689
- exit 0
690
- }
691
- 'watch' {
692
- Invoke-Watch -Rest @($args | Select-Object -Skip 1)
693
- exit 0
694
- }
695
- 'park' {
696
- if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom park <pid>'); exit 1 }
697
- Invoke-Park -TargetPid ([int]$args[1]); exit 0
698
- }
699
- 'wake' {
700
- if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom wake <pid>'); exit 1 }
701
- Invoke-Wake -TargetPid ([int]$args[1]); exit 0
702
- }
703
- 'status' {
704
- if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom status <pid>'); exit 1 }
705
- Invoke-Status -TargetPid ([int]$args[1]); exit 0
706
- }
707
- 'guard' {
708
- if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom guard [--threshold <pct>] [--interval <sec>] <pid...>'); exit 1 }
709
- $threshold = 10
710
- $gInterval = 5
711
- $gPids = @()
712
- for ($i = 1; $i -lt $args.Count; $i++) {
713
- if ($args[$i] -eq '--threshold') { $threshold = [int]$args[$i + 1]; $i++ }
714
- elseif ($args[$i] -eq '--interval') { $gInterval = [double]$args[$i + 1]; $i++ }
715
- elseif ($args[$i] -eq '--') { continue }
716
- else { $gPids += [int]$args[$i] }
717
- }
718
- if ($gPids.Count -eq 0) { [Console]::Error.WriteLine('usage: caproom guard [--threshold <pct>] [--interval <sec>] <pid...>'); exit 1 }
719
- Invoke-Guard -Threshold $threshold -Interval $gInterval -TargetPids $gPids
720
- exit 0
721
- }
722
- 'init' {
723
- if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom init <command> [--limit <mb>]'); exit 1 }
724
- $target = $args[1]
725
- $limit = 4096
726
- for ($i = 2; $i -lt $args.Count; $i++) {
727
- if ($args[$i] -eq '--limit') { $limit = [int]$args[$i + 1]; $i++ }
728
- else { [Console]::Error.WriteLine("caproom: unknown init flag $($args[$i])"); exit 1 }
729
- }
730
- Invoke-Init -Target $target -LimitMb $limit
731
- exit 0
732
- }
733
- }
734
-
735
- $limitMb = if ($env:CAPROOM_LIMIT_MB) { [int]$env:CAPROOM_LIMIT_MB } else { 4096 }
736
- $interval = if ($env:CAPROOM_INTERVAL) { [double]$env:CAPROOM_INTERVAL } else { 0.2 }
737
- $forceWatchdog = $false
738
- $i = 0
739
- $parsing = $true
740
- while ($parsing -and $i -lt $args.Count) {
741
- $a = $args[$i]
742
- if ($a -eq '--limit') { $limitMb = [int]$args[$i + 1]; $i += 2 }
743
- elseif ($a -eq '--interval') { $interval = [double]$args[$i + 1]; $i += 2 }
744
- elseif ($a -eq '--force-watchdog') { $forceWatchdog = $true; $i++ }
745
- elseif ($a -eq '-h' -or $a -eq '--help') { Show-Usage -AsHelp }
746
- elseif ($a -eq '--') { $i++; $parsing = $false }
747
- else { $parsing = $false }
748
- }
749
-
750
- if ($i -ge $args.Count) { Show-Usage }
751
- $command = @($args[$i..($args.Count - 1)])
752
-
753
- Invoke-Capped -LimitMb $limitMb -Interval $interval -ForceWatchdog $forceWatchdog -Command $command