caproom 0.3.1 → 0.5.0
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/README.md +119 -15
- package/bin/caproom +406 -32
- package/bin/caproom-mcp.js +264 -0
- package/bin/caproom.js +21 -0
- package/bin/caproom.ps1 +471 -0
- package/package.json +10 -5
package/bin/caproom.ps1
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
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
|
+
|
|
25
|
+
--limit <mb> memory cap in MB (default: 4096). On Windows this caps
|
|
26
|
+
committed virtual memory (Job Object ProcessMemoryLimit);
|
|
27
|
+
on macOS/Linux it caps RSS. Same flag, different quantity.
|
|
28
|
+
--interval <sec> poll interval for the fallback watchdog (default: 0.2)
|
|
29
|
+
--force-watchdog use the polling watchdog instead of the Job Object backend
|
|
30
|
+
|
|
31
|
+
Windows differences from macOS/Linux:
|
|
32
|
+
* No SIGTERM grace period. Windows console apps have no signal equivalent,
|
|
33
|
+
so a watchdog breach is a hard kill. The Job Object backend does not kill
|
|
34
|
+
at all -- the allocation simply fails inside the process.
|
|
35
|
+
* park <pid> uses EmptyWorkingSet: memory is trimmed to the pagefile
|
|
36
|
+
immediately, on demand, and the process KEEPS RUNNING. There is no
|
|
37
|
+
suspension, so it cannot hang a process that something is waiting on.
|
|
38
|
+
* wake <pid> is a no-op -- nothing was suspended. Trimmed pages fault back
|
|
39
|
+
in by themselves on next access.
|
|
40
|
+
|
|
41
|
+
guard watches SYSTEM-WIDE free memory (not any single process) and auto-parks
|
|
42
|
+
tracked pids (EmptyWorkingSet) once free mem drops below --threshold percent,
|
|
43
|
+
before the OS has to fail an allocation itself. Use it when unrelated heavy
|
|
44
|
+
processes (e.g. a GPU inference job and a TTS job in separate terminals)
|
|
45
|
+
share a box and neither individually breaches any --limit cap. Foreground,
|
|
46
|
+
blocking; exits once all watched pids have exited. There is no unpark step --
|
|
47
|
+
park just trims the working set, pages fault back in on next access.
|
|
48
|
+
|
|
49
|
+
env vars (override flags): CAPROOM_LIMIT_MB, CAPROOM_INTERVAL
|
|
50
|
+
|
|
51
|
+
examples:
|
|
52
|
+
caproom --limit 2048 -- npm run build
|
|
53
|
+
caproom park 12345
|
|
54
|
+
caproom guard --threshold 10 --interval 5 -- 12345 12346
|
|
55
|
+
caproom init claude --limit 6144
|
|
56
|
+
'@
|
|
57
|
+
if ($AsHelp) { Write-Output $text; exit 0 }
|
|
58
|
+
[Console]::Error.WriteLine($text)
|
|
59
|
+
exit 1
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
$NativeMethods = @'
|
|
63
|
+
using System;
|
|
64
|
+
using System.Runtime.InteropServices;
|
|
65
|
+
|
|
66
|
+
public static class Caproom {
|
|
67
|
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
68
|
+
public static extern IntPtr CreateJobObject(IntPtr a, string lpName);
|
|
69
|
+
|
|
70
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
71
|
+
public static extern bool SetInformationJobObject(IntPtr hJob, int infoClass, IntPtr lpInfo, uint cbInfo);
|
|
72
|
+
|
|
73
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
74
|
+
public static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
|
|
75
|
+
|
|
76
|
+
[DllImport("psapi.dll", SetLastError = true)]
|
|
77
|
+
public static extern bool EmptyWorkingSet(IntPtr hProcess);
|
|
78
|
+
|
|
79
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
80
|
+
public struct JOBOBJECT_BASIC_LIMIT_INFORMATION {
|
|
81
|
+
public Int64 PerProcessUserTimeLimit;
|
|
82
|
+
public Int64 PerJobUserTimeLimit;
|
|
83
|
+
public UInt32 LimitFlags;
|
|
84
|
+
public UIntPtr MinimumWorkingSetSize;
|
|
85
|
+
public UIntPtr MaximumWorkingSetSize;
|
|
86
|
+
public UInt32 ActiveProcessLimit;
|
|
87
|
+
public UIntPtr Affinity;
|
|
88
|
+
public UInt32 PriorityClass;
|
|
89
|
+
public UInt32 SchedulingClass;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
93
|
+
public struct IO_COUNTERS {
|
|
94
|
+
public UInt64 ReadOperationCount;
|
|
95
|
+
public UInt64 WriteOperationCount;
|
|
96
|
+
public UInt64 OtherOperationCount;
|
|
97
|
+
public UInt64 ReadTransferCount;
|
|
98
|
+
public UInt64 WriteTransferCount;
|
|
99
|
+
public UInt64 OtherTransferCount;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
103
|
+
public struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
|
|
104
|
+
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
|
|
105
|
+
public IO_COUNTERS IoInfo;
|
|
106
|
+
public UIntPtr ProcessMemoryLimit;
|
|
107
|
+
public UIntPtr JobMemoryLimit;
|
|
108
|
+
public UIntPtr PeakProcessMemoryUsed;
|
|
109
|
+
public UIntPtr PeakJobMemoryUsed;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
public const int ExtendedLimitInformation = 9;
|
|
113
|
+
public const uint LIMIT_PROCESS_MEMORY = 0x00000100;
|
|
114
|
+
public const uint LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
|
|
115
|
+
}
|
|
116
|
+
'@
|
|
117
|
+
|
|
118
|
+
function Import-Native {
|
|
119
|
+
if (-not ('Caproom' -as [type])) { Add-Type -TypeDefinition $script:NativeMethods }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function Invoke-Park {
|
|
123
|
+
param([int]$TargetPid)
|
|
124
|
+
Import-Native
|
|
125
|
+
$proc = Get-Process -Id $TargetPid -ErrorAction SilentlyContinue
|
|
126
|
+
if (-not $proc) { [Console]::Error.WriteLine("caproom: no such pid $TargetPid"); exit 1 }
|
|
127
|
+
$before = $proc.WorkingSet64
|
|
128
|
+
if (-not [Caproom]::EmptyWorkingSet($proc.Handle)) {
|
|
129
|
+
[Console]::Error.WriteLine("caproom: EmptyWorkingSet failed for pid $TargetPid (error $([Runtime.InteropServices.Marshal]::GetLastWin32Error()))")
|
|
130
|
+
exit 1
|
|
131
|
+
}
|
|
132
|
+
$after = (Get-Process -Id $TargetPid).WorkingSet64
|
|
133
|
+
[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.")
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function Invoke-Wake {
|
|
137
|
+
param([int]$TargetPid)
|
|
138
|
+
if (-not (Get-Process -Id $TargetPid -ErrorAction SilentlyContinue)) {
|
|
139
|
+
[Console]::Error.WriteLine("caproom: no such pid $TargetPid"); exit 1
|
|
140
|
+
}
|
|
141
|
+
[Console]::Error.WriteLine("caproom: pid $TargetPid -- nothing to wake. On Windows park trims the working set without suspending, so the process never stopped running.")
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function Invoke-Status {
|
|
145
|
+
param([int]$TargetPid)
|
|
146
|
+
$proc = Get-Process -Id $TargetPid -ErrorAction SilentlyContinue
|
|
147
|
+
if (-not $proc) { [Console]::Error.WriteLine("caproom: no such pid $TargetPid"); exit 1 }
|
|
148
|
+
[PSCustomObject]@{
|
|
149
|
+
Pid = $proc.Id
|
|
150
|
+
WorkingSetMB = [math]::Round($proc.WorkingSet64 / 1MB)
|
|
151
|
+
CommittedMB = [math]::Round($proc.PagedMemorySize64 / 1MB)
|
|
152
|
+
Elapsed = (Get-Date) - $proc.StartTime
|
|
153
|
+
Command = $proc.ProcessName
|
|
154
|
+
} | Format-List
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function Get-FreeMemPercent {
|
|
158
|
+
$os = Get-CimInstance Win32_OperatingSystem
|
|
159
|
+
return [math]::Floor(($os.FreePhysicalMemory * 100) / $os.TotalVisibleMemorySize)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function Invoke-Guard {
|
|
163
|
+
param([int]$Threshold, [double]$Interval, [int[]]$TargetPids)
|
|
164
|
+
Import-Native
|
|
165
|
+
[Console]::Error.WriteLine("caproom: guarding $($TargetPids.Count) pid(s), park when system free mem < ${Threshold}% (poll ${Interval}s)")
|
|
166
|
+
$parked = @{}
|
|
167
|
+
while ($true) {
|
|
168
|
+
$alive = @($TargetPids | Where-Object { Get-Process -Id $_ -ErrorAction SilentlyContinue })
|
|
169
|
+
if ($alive.Count -eq 0) {
|
|
170
|
+
[Console]::Error.WriteLine("caproom: guard: all watched pids exited")
|
|
171
|
+
exit 0
|
|
172
|
+
}
|
|
173
|
+
$TargetPids = $alive
|
|
174
|
+
$pct = Get-FreeMemPercent
|
|
175
|
+
if ($pct -lt $Threshold) {
|
|
176
|
+
foreach ($p in $TargetPids) {
|
|
177
|
+
if (-not $parked.ContainsKey($p)) {
|
|
178
|
+
$proc = Get-Process -Id $p -ErrorAction SilentlyContinue
|
|
179
|
+
if ($proc) {
|
|
180
|
+
[Console]::Error.WriteLine("caproom: system free mem ${pct}% < ${Threshold}% threshold -- about to blow, parking pid $p (EmptyWorkingSet)")
|
|
181
|
+
[void][Caproom]::EmptyWorkingSet($proc.Handle)
|
|
182
|
+
$parked[$p] = $true
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
Start-Sleep -Seconds $Interval
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function Invoke-Init {
|
|
192
|
+
param([string]$Target, [int]$LimitMb)
|
|
193
|
+
@"
|
|
194
|
+
# caproom: auto-cap '$Target' -- added by 'caproom init $Target'
|
|
195
|
+
# override per-shell: `$env:CAPROOM_LIMIT_MB = 8192
|
|
196
|
+
function ${Target}_capped {
|
|
197
|
+
`$limit = if (`$env:CAPROOM_LIMIT_MB) { `$env:CAPROOM_LIMIT_MB } else { $LimitMb }
|
|
198
|
+
caproom --force-watchdog --limit `$limit -- $Target @args
|
|
199
|
+
}
|
|
200
|
+
Set-Alias -Name $Target -Value ${Target}_capped -Force
|
|
201
|
+
"@
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
# Start-Process -ArgumentList joins an array with spaces and does no quoting,
|
|
205
|
+
# so an argument containing whitespace gets re-split into several arguments by
|
|
206
|
+
# the callee. Build one command line with CommandLineToArgvW quoting instead.
|
|
207
|
+
function ConvertTo-ArgString {
|
|
208
|
+
param([string[]]$Arguments)
|
|
209
|
+
$quoted = foreach ($a in $Arguments) {
|
|
210
|
+
if ($a -eq '') { '""' }
|
|
211
|
+
elseif ($a -notmatch '[\s"]') { $a }
|
|
212
|
+
else {
|
|
213
|
+
# Double any backslashes preceding a quote (and at end of string),
|
|
214
|
+
# then escape the quotes themselves.
|
|
215
|
+
$s = $a -replace '(\\*)"', '$1$1\"'
|
|
216
|
+
$s = $s -replace '(\\+)$', '$1$1'
|
|
217
|
+
'"' + $s + '"'
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
$quoted -join ' '
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function New-CappedProcess {
|
|
224
|
+
# Every pipe-based capture (Process class + ReadToEndAsync, Process class
|
|
225
|
+
# + raw BaseStream, with and without stripping std-handle inheritance)
|
|
226
|
+
# returned zero bytes in CI despite a clean exit 0 -- caproom is invoked
|
|
227
|
+
# as powershell.exe -File caproom.ps1 from the Node shim, itself invoked
|
|
228
|
+
# from a pwsh.EXE step that captures via a pipe (`| Out-String`), and
|
|
229
|
+
# something in that nesting swallows anonymous-pipe output every time.
|
|
230
|
+
# File-based redirection (Start-Process -RedirectStandardOutput <file>)
|
|
231
|
+
# was the one capture method that survived an isolated repro under the
|
|
232
|
+
# exact same nesting in the same CI job, so route through temp files
|
|
233
|
+
# instead of pipes entirely.
|
|
234
|
+
param([string]$Exe, [string]$ArgLine)
|
|
235
|
+
$resolvedExe = $Exe
|
|
236
|
+
$cmd = Get-Command $Exe -ErrorAction SilentlyContinue
|
|
237
|
+
if ($cmd) { $resolvedExe = $cmd.Source }
|
|
238
|
+
|
|
239
|
+
$outFile = [IO.Path]::GetTempFileName()
|
|
240
|
+
$errFile = [IO.Path]::GetTempFileName()
|
|
241
|
+
$proc = Start-Process -FilePath $resolvedExe -ArgumentList $ArgLine -NoNewWindow `
|
|
242
|
+
-RedirectStandardOutput $outFile -RedirectStandardError $errFile -PassThru
|
|
243
|
+
|
|
244
|
+
# Start-Process's PassThru object opens a limited-rights handle lazily --
|
|
245
|
+
# if .Handle is never touched while the process is still alive, .ExitCode
|
|
246
|
+
# silently reads back 0 for an already-exited process instead of the real
|
|
247
|
+
# code. Force the full-access handle open now, before it can exit.
|
|
248
|
+
$null = $proc.Handle
|
|
249
|
+
|
|
250
|
+
$proc | Add-Member -NotePropertyName StdoutFile -NotePropertyValue $outFile
|
|
251
|
+
$proc | Add-Member -NotePropertyName StderrFile -NotePropertyValue $errFile
|
|
252
|
+
return $proc
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function Read-NewOutput {
|
|
256
|
+
# Tail-follow one capture file from its recorded byte offset, writing new
|
|
257
|
+
# bytes to the given console stream as they land so output streams live.
|
|
258
|
+
# Byte-level writes pass the child's bytes through un-re-encoded.
|
|
259
|
+
param([string]$Path, $Offsets, $Stream)
|
|
260
|
+
if (-not (Test-Path -LiteralPath $Path)) { return }
|
|
261
|
+
$fs = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite)
|
|
262
|
+
try {
|
|
263
|
+
if ($fs.Length -lt $Offsets[$Path]) { $Offsets[$Path] = 0 } # file truncated/recreated under us
|
|
264
|
+
if ($fs.Length -gt $Offsets[$Path]) {
|
|
265
|
+
$fs.Position = $Offsets[$Path]
|
|
266
|
+
$len = [int]($fs.Length - $fs.Position)
|
|
267
|
+
$buf = New-Object byte[] $len
|
|
268
|
+
$read = 0
|
|
269
|
+
while ($read -lt $len) {
|
|
270
|
+
$n = $fs.Read($buf, $read, $len - $read)
|
|
271
|
+
if ($n -le 0) { break }
|
|
272
|
+
$read += $n
|
|
273
|
+
}
|
|
274
|
+
if ($read -gt 0) {
|
|
275
|
+
$Offsets[$Path] += $read
|
|
276
|
+
$Stream.Write($buf, 0, $read)
|
|
277
|
+
$Stream.Flush()
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
} finally { $fs.Close() }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function Wait-CappedProcess {
|
|
284
|
+
# Drains remaining output, cleans up the temp capture files, returns the
|
|
285
|
+
# exit code. If the caller streamed while polling (watchdog path), pass
|
|
286
|
+
# the SAME offsets table so only the unread tail is relayed here; the
|
|
287
|
+
# job-object path streams internally on a 50ms cadence.
|
|
288
|
+
param($Proc, $Offsets = @{ ($Proc.StdoutFile) = 0; ($Proc.StderrFile) = 0 })
|
|
289
|
+
try {
|
|
290
|
+
while (-not $Proc.HasExited) {
|
|
291
|
+
Read-NewOutput -Path $Proc.StdoutFile -Offsets $Offsets -Stream ([Console]::Out)
|
|
292
|
+
Read-NewOutput -Path $Proc.StderrFile -Offsets $Offsets -Stream ([Console]::Error)
|
|
293
|
+
Start-Sleep -Milliseconds 50
|
|
294
|
+
}
|
|
295
|
+
Read-NewOutput -Path $Proc.StdoutFile -Offsets $Offsets -Stream ([Console]::Out)
|
|
296
|
+
Read-NewOutput -Path $Proc.StderrFile -Offsets $Offsets -Stream ([Console]::Error)
|
|
297
|
+
return $Proc.ExitCode
|
|
298
|
+
} finally {
|
|
299
|
+
Remove-Item -LiteralPath $Proc.StdoutFile, $Proc.StderrFile -ErrorAction SilentlyContinue
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
# The watchdog must see the WHOLE tree, not just the top pid: coding agents
|
|
304
|
+
# keep their memory in children (MCP servers, bundler daemons, headless
|
|
305
|
+
# browsers) while the parent's own working set stays flat. Walk the
|
|
306
|
+
# parent->child edges of one Win32_Process snapshot and sum working sets.
|
|
307
|
+
function Get-TreeWorkingSetBytes {
|
|
308
|
+
param([int]$RootPid)
|
|
309
|
+
$ws = @{}
|
|
310
|
+
$kids = @{}
|
|
311
|
+
foreach ($p in Get-CimInstance -ClassName Win32_Process -Property ProcessId, ParentProcessId, WorkingSetSize) {
|
|
312
|
+
$pidInt = [int]$p.ProcessId
|
|
313
|
+
$ppidInt = [int]$p.ParentProcessId
|
|
314
|
+
$ws[$pidInt] = [uint64]$p.WorkingSetSize
|
|
315
|
+
if (-not $kids.ContainsKey($ppidInt)) { $kids[$ppidInt] = @() }
|
|
316
|
+
$kids[$ppidInt] += $pidInt
|
|
317
|
+
}
|
|
318
|
+
if (-not $ws.ContainsKey($RootPid)) { return [uint64]0 }
|
|
319
|
+
$total = [uint64]0
|
|
320
|
+
$queue = New-Object System.Collections.Queue
|
|
321
|
+
$visited = @{}
|
|
322
|
+
$queue.Enqueue($RootPid)
|
|
323
|
+
while ($queue.Count -gt 0) {
|
|
324
|
+
$cur = [int]$queue.Dequeue()
|
|
325
|
+
if ($visited.ContainsKey($cur)) { continue } # pid-reuse / cycle guard
|
|
326
|
+
$visited[$cur] = $true
|
|
327
|
+
$total += $ws[$cur]
|
|
328
|
+
if ($kids.ContainsKey($cur)) { foreach ($c in $kids[$cur]) { [void]$queue.Enqueue($c) } }
|
|
329
|
+
}
|
|
330
|
+
return $total
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function Invoke-Capped {
|
|
334
|
+
param([int]$LimitMb, [double]$Interval, [bool]$ForceWatchdog, [string[]]$Command)
|
|
335
|
+
|
|
336
|
+
$exe = $Command[0]
|
|
337
|
+
$rest = if ($Command.Length -gt 1) { ConvertTo-ArgString $Command[1..($Command.Length - 1)] } else { '' }
|
|
338
|
+
|
|
339
|
+
if (-not $ForceWatchdog) {
|
|
340
|
+
try {
|
|
341
|
+
Import-Native
|
|
342
|
+
$job = [Caproom]::CreateJobObject([IntPtr]::Zero, $null)
|
|
343
|
+
if ($job -eq [IntPtr]::Zero) { throw 'CreateJobObject returned NULL' }
|
|
344
|
+
|
|
345
|
+
$info = New-Object Caproom+JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
|
346
|
+
$info.BasicLimitInformation.LimitFlags = [Caproom]::LIMIT_PROCESS_MEMORY -bor [Caproom]::LIMIT_KILL_ON_JOB_CLOSE
|
|
347
|
+
$info.ProcessMemoryLimit = [UIntPtr]::new([uint64]$LimitMb * 1MB)
|
|
348
|
+
|
|
349
|
+
$size = [Runtime.InteropServices.Marshal]::SizeOf($info)
|
|
350
|
+
$ptr = [Runtime.InteropServices.Marshal]::AllocHGlobal($size)
|
|
351
|
+
try {
|
|
352
|
+
[Runtime.InteropServices.Marshal]::StructureToPtr($info, $ptr, $false)
|
|
353
|
+
if (-not [Caproom]::SetInformationJobObject($job, [Caproom]::ExtendedLimitInformation, $ptr, $size)) {
|
|
354
|
+
throw "SetInformationJobObject failed (error $([Runtime.InteropServices.Marshal]::GetLastWin32Error()))"
|
|
355
|
+
}
|
|
356
|
+
} finally {
|
|
357
|
+
[Runtime.InteropServices.Marshal]::FreeHGlobal($ptr)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
# Assign ONLY the child to the job, immediately after spawn --
|
|
361
|
+
# never caproom's own process. Putting the PowerShell runtime
|
|
362
|
+
# inside the job made its ~100MB+ commit eat the user's budget,
|
|
363
|
+
# and a PS spike could fail allocations inside THEIR command.
|
|
364
|
+
# Policy: prefer under-counting over impeding. Cost is a
|
|
365
|
+
# millisecond-scale window before assignment lands; the child's
|
|
366
|
+
# own descendants are still covered automatically (they inherit
|
|
367
|
+
# the association at CreateProcess).
|
|
368
|
+
[Console]::Error.WriteLine("caproom: job object backend, limit=${LimitMb}m (committed memory, kernel-enforced, covers the command and its descendants)")
|
|
369
|
+
$proc = New-CappedProcess -Exe $exe -ArgLine $rest
|
|
370
|
+
if (-not [Caproom]::AssignProcessToJobObject($job, $proc.Handle)) {
|
|
371
|
+
# Child is already running -- kill it before falling back,
|
|
372
|
+
# or the watchdog path below would launch a second instance.
|
|
373
|
+
& taskkill.exe /PID $proc.Id /T /F 2>$null | Out-Null
|
|
374
|
+
throw "AssignProcessToJobObject failed (error $([Runtime.InteropServices.Marshal]::GetLastWin32Error()))"
|
|
375
|
+
}
|
|
376
|
+
exit (Wait-CappedProcess $proc)
|
|
377
|
+
} catch {
|
|
378
|
+
[Console]::Error.WriteLine("caproom: job object backend unavailable ($($_.Exception.Message)) -- falling back to watchdog")
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
[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)")
|
|
383
|
+
$limitBytes = [uint64]$LimitMb * 1MB
|
|
384
|
+
$proc = New-CappedProcess -Exe $exe -ArgLine $rest
|
|
385
|
+
# Stream output WHILE the breach-poll loop runs -- polling must not sit
|
|
386
|
+
# on the whole runtime and leave the tail-follow to drain everything at
|
|
387
|
+
# exit. Same offsets table flows into Wait-CappedProcess for the final
|
|
388
|
+
# drain so nothing is relayed twice.
|
|
389
|
+
$offsets = @{ ($proc.StdoutFile) = 0; ($proc.StderrFile) = 0 }
|
|
390
|
+
while (-not $proc.HasExited) {
|
|
391
|
+
Read-NewOutput -Path $proc.StdoutFile -Offsets $offsets -Stream ([Console]::Out)
|
|
392
|
+
Read-NewOutput -Path $proc.StderrFile -Offsets $offsets -Stream ([Console]::Error)
|
|
393
|
+
Start-Sleep -Seconds $Interval
|
|
394
|
+
if ($proc.HasExited) { break }
|
|
395
|
+
$treeBytes = Get-TreeWorkingSetBytes -RootPid $proc.Id
|
|
396
|
+
if ($treeBytes -gt $limitBytes) {
|
|
397
|
+
[Console]::Error.WriteLine("caproom: process tree of pid $($proc.Id) using $([math]::Round($treeBytes/1MB))MB exceeded ${LimitMb}MB cap -- killing tree")
|
|
398
|
+
& taskkill.exe /PID $proc.Id /T /F 2>$null | Out-Null
|
|
399
|
+
exit 137
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
exit (Wait-CappedProcess $proc -Offsets $offsets)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
# ---- argument parsing ----
|
|
406
|
+
|
|
407
|
+
if ($args.Count -eq 0) { Show-Usage }
|
|
408
|
+
|
|
409
|
+
switch ($args[0]) {
|
|
410
|
+
'help' { Show-Usage -AsHelp }
|
|
411
|
+
'-h' { Show-Usage -AsHelp }
|
|
412
|
+
'--help' { Show-Usage -AsHelp }
|
|
413
|
+
'park' {
|
|
414
|
+
if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom park <pid>'); exit 1 }
|
|
415
|
+
Invoke-Park -TargetPid ([int]$args[1]); exit 0
|
|
416
|
+
}
|
|
417
|
+
'wake' {
|
|
418
|
+
if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom wake <pid>'); exit 1 }
|
|
419
|
+
Invoke-Wake -TargetPid ([int]$args[1]); exit 0
|
|
420
|
+
}
|
|
421
|
+
'status' {
|
|
422
|
+
if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom status <pid>'); exit 1 }
|
|
423
|
+
Invoke-Status -TargetPid ([int]$args[1]); exit 0
|
|
424
|
+
}
|
|
425
|
+
'guard' {
|
|
426
|
+
if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom guard [--threshold <pct>] [--interval <sec>] <pid...>'); exit 1 }
|
|
427
|
+
$threshold = 10
|
|
428
|
+
$gInterval = 5
|
|
429
|
+
$gPids = @()
|
|
430
|
+
for ($i = 1; $i -lt $args.Count; $i++) {
|
|
431
|
+
if ($args[$i] -eq '--threshold') { $threshold = [int]$args[$i + 1]; $i++ }
|
|
432
|
+
elseif ($args[$i] -eq '--interval') { $gInterval = [double]$args[$i + 1]; $i++ }
|
|
433
|
+
elseif ($args[$i] -eq '--') { continue }
|
|
434
|
+
else { $gPids += [int]$args[$i] }
|
|
435
|
+
}
|
|
436
|
+
if ($gPids.Count -eq 0) { [Console]::Error.WriteLine('usage: caproom guard [--threshold <pct>] [--interval <sec>] <pid...>'); exit 1 }
|
|
437
|
+
Invoke-Guard -Threshold $threshold -Interval $gInterval -TargetPids $gPids
|
|
438
|
+
exit 0
|
|
439
|
+
}
|
|
440
|
+
'init' {
|
|
441
|
+
if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom init <command> [--limit <mb>]'); exit 1 }
|
|
442
|
+
$target = $args[1]
|
|
443
|
+
$limit = 4096
|
|
444
|
+
for ($i = 2; $i -lt $args.Count; $i++) {
|
|
445
|
+
if ($args[$i] -eq '--limit') { $limit = [int]$args[$i + 1]; $i++ }
|
|
446
|
+
else { [Console]::Error.WriteLine("caproom: unknown init flag $($args[$i])"); exit 1 }
|
|
447
|
+
}
|
|
448
|
+
Invoke-Init -Target $target -LimitMb $limit
|
|
449
|
+
exit 0
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
$limitMb = if ($env:CAPROOM_LIMIT_MB) { [int]$env:CAPROOM_LIMIT_MB } else { 4096 }
|
|
454
|
+
$interval = if ($env:CAPROOM_INTERVAL) { [double]$env:CAPROOM_INTERVAL } else { 0.2 }
|
|
455
|
+
$forceWatchdog = $false
|
|
456
|
+
$i = 0
|
|
457
|
+
$parsing = $true
|
|
458
|
+
while ($parsing -and $i -lt $args.Count) {
|
|
459
|
+
$a = $args[$i]
|
|
460
|
+
if ($a -eq '--limit') { $limitMb = [int]$args[$i + 1]; $i += 2 }
|
|
461
|
+
elseif ($a -eq '--interval') { $interval = [double]$args[$i + 1]; $i += 2 }
|
|
462
|
+
elseif ($a -eq '--force-watchdog') { $forceWatchdog = $true; $i++ }
|
|
463
|
+
elseif ($a -eq '-h' -or $a -eq '--help') { Show-Usage -AsHelp }
|
|
464
|
+
elseif ($a -eq '--') { $i++; $parsing = $false }
|
|
465
|
+
else { $parsing = $false }
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if ($i -ge $args.Count) { Show-Usage }
|
|
469
|
+
$command = @($args[$i..($args.Count - 1)])
|
|
470
|
+
|
|
471
|
+
Invoke-Capped -LimitMb $limitMb -Interval $interval -ForceWatchdog $forceWatchdog -Command $command
|
package/package.json
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "caproom",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Memory-cap any command (AI coding agents, builds, background jobs) on macOS
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Memory-cap any command (AI coding agents, builds, background jobs) on macOS, Linux, and Windows — real enforcement via Docker cgroups, Windows Job Objects, or a polling watchdog, plus park/wake to reclaim idle process memory without killing.",
|
|
5
5
|
"bin": {
|
|
6
|
-
"caproom": "bin/caproom"
|
|
6
|
+
"caproom": "bin/caproom.js",
|
|
7
|
+
"caproom-mcp": "bin/caproom-mcp.js"
|
|
7
8
|
},
|
|
8
9
|
"files": [
|
|
9
|
-
"bin/caproom"
|
|
10
|
+
"bin/caproom",
|
|
11
|
+
"bin/caproom.js",
|
|
12
|
+
"bin/caproom.ps1",
|
|
13
|
+
"bin/caproom-mcp.js"
|
|
10
14
|
],
|
|
11
15
|
"keywords": [
|
|
12
16
|
"memory",
|
|
@@ -21,7 +25,8 @@
|
|
|
21
25
|
],
|
|
22
26
|
"os": [
|
|
23
27
|
"darwin",
|
|
24
|
-
"linux"
|
|
28
|
+
"linux",
|
|
29
|
+
"win32"
|
|
25
30
|
],
|
|
26
31
|
"license": "MIT",
|
|
27
32
|
"repository": {
|