caproom 0.2.0 → 0.4.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 +76 -14
- package/bin/caproom +178 -25
- package/bin/caproom.js +21 -0
- package/bin/caproom.ps1 +471 -0
- package/package.json +8 -5
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/caproom)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
|
|
6
|
-
Prevent RAM OOM for long-running terminal coding agents, builds, and background jobs — memory caps plus idle-process parking, for macOS
|
|
6
|
+
Prevent RAM OOM for long-running terminal coding agents, builds, and background jobs — memory caps plus idle-process parking, for macOS, Linux, and Windows.
|
|
7
7
|
|
|
8
8
|
## Why
|
|
9
9
|
|
|
@@ -15,10 +15,10 @@ macOS has no reliable way to cap a process's memory from userspace. A runaway pr
|
|
|
15
15
|
caproom --limit <mb> -- <command> [args...]
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Two backends
|
|
18
|
+
Two backends:
|
|
19
19
|
|
|
20
|
-
1. **
|
|
21
|
-
2. **
|
|
20
|
+
1. **Host-native polling watchdog** (`ps` RSS + `SIGKILL`) — the **default** on macOS/Linux. Runs in your real environment: same PATH, auth, native binaries, tty. Measures the **whole process tree** each poll (agents keep their memory in children — MCP servers, bundler daemons, headless browsers — while the parent's own RSS stays flat). Has a small race window bounded by `--interval` (default 200ms).
|
|
21
|
+
2. **Docker cgroup** (`--memory`) — opt-in with `--docker`. Hard cap, real kernel enforcement, zero race window — at the cost of running inside a Linux container (see caveats below). Fails loudly if the daemon isn't reachable rather than silently switching backends.
|
|
22
22
|
|
|
23
23
|
## Install
|
|
24
24
|
|
|
@@ -31,17 +31,14 @@ or clone and symlink `bin/caproom` onto your `PATH`.
|
|
|
31
31
|
## Usage
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
-
# cap a build at 2GB
|
|
34
|
+
# cap a build at 2GB (host-native watchdog, the default)
|
|
35
35
|
caproom --limit 2048 -- npm run build
|
|
36
36
|
|
|
37
37
|
# cap an AI coding agent run at 512MB
|
|
38
38
|
caproom --limit 512 -- claude -p "refactor this module"
|
|
39
39
|
|
|
40
|
-
#
|
|
41
|
-
caproom --limit
|
|
42
|
-
|
|
43
|
-
# use a different docker image for the docker backend (default: node:22-slim)
|
|
44
|
-
caproom --limit 4096 --image python:3.12-slim -- python train.py
|
|
40
|
+
# opt in to the Docker cgroup backend for a zero-race hard cap
|
|
41
|
+
caproom --limit 4096 --docker --image python:3.12-slim -- python train.py
|
|
45
42
|
```
|
|
46
43
|
|
|
47
44
|
### Flags
|
|
@@ -49,15 +46,52 @@ caproom --limit 4096 --image python:3.12-slim -- python train.py
|
|
|
49
46
|
| Flag | Default | Meaning |
|
|
50
47
|
|---|---|---|
|
|
51
48
|
| `--limit <mb>` | `4096` | memory cap in MB |
|
|
52
|
-
| `--image <name>` | `node:22-slim` | docker image used by the docker backend |
|
|
53
49
|
| `--interval <sec>` | `0.2` | watchdog poll interval |
|
|
54
50
|
| `--grace <sec>` | `5` | seconds to wait after `SIGTERM` before `SIGKILL`, watchdog backend only — gives the process a chance to flush/save state before a hard kill |
|
|
55
|
-
| `--
|
|
51
|
+
| `--docker` | off | opt in to the Docker cgroup backend instead of the default host-native watchdog |
|
|
52
|
+
| `--image <name>` | `node:22-slim` | docker image used by the `--docker` backend |
|
|
53
|
+
| `--force-watchdog` | — | legacy no-op; the watchdog IS the default. Accepted so existing scripts and `init` snippets keep working |
|
|
56
54
|
|
|
57
55
|
Env var overrides: `CAPROOM_LIMIT_MB`, `CAPROOM_IMAGE`, `CAPROOM_INTERVAL`, `CAPROOM_GRACE`.
|
|
58
56
|
|
|
59
57
|
On cap breach, the watchdog backend sends `SIGTERM` first and waits `--grace` seconds before `SIGKILL`. If the process exits cleanly during the grace window, `caproom` propagates its real exit code; only a hard `SIGKILL` (process ignored `SIGTERM`, or grace ran out) reports `137` (same convention as Docker's own OOM-kill exit code, which the docker backend always uses on breach since Docker itself sends the kill).
|
|
60
58
|
|
|
59
|
+
## Backends compared
|
|
60
|
+
|
|
61
|
+
The three enforcement mechanisms measure different quantities and cover children differently. Read this before reusing a `--limit` number across platforms or backends. The watchdog is the POSIX default; Docker is opt-in — caproom prefers to run your command unmodified in its real environment and *miss* an exotic memory spike over breaking a working workflow with container drift:
|
|
62
|
+
|
|
63
|
+
| | Docker cgroup | Windows Job Object | Watchdog (POSIX) | Watchdog (Windows) |
|
|
64
|
+
|---|---|---|---|---|
|
|
65
|
+
| Measures | cgroup memory | **committed virtual memory** | RSS of the process tree | working set of the process tree |
|
|
66
|
+
| Children counted | yes — whole container | yes — auto-inherited at spawn | yes — tree walked each poll | yes — tree walked each poll |
|
|
67
|
+
| Enforcement | kernel OOM-kill | allocation fails in-process | TERM → grace → KILL | hard kill (`taskkill /T /F`) |
|
|
68
|
+
| Race window | none | none | bounded by `--interval` | bounded by `--interval` |
|
|
69
|
+
| Interactive/streaming output | degraded — no TTY (`-i` only) | full — streamed live | full — child inherits the tty | streamed live via temp-file tail-follow (~50ms cadence) |
|
|
70
|
+
|
|
71
|
+
**Committed vs RSS**: Node/V8 runtimes commit far more virtual memory than they touch, so a limit tuned against RSS on macOS will bite much earlier under the Job Object backend. Tune per platform.
|
|
72
|
+
|
|
73
|
+
### Docker backend caveats
|
|
74
|
+
|
|
75
|
+
Opt in with `--docker`. The command then runs inside `node:22-slim` with `$PWD` mounted at `/work` — a Linux container, not your host shell:
|
|
76
|
+
|
|
77
|
+
- Native modules built for macOS (`esbuild`, `swc`, `sharp`) fail with exec-format errors inside the container.
|
|
78
|
+
- Host toolchain, env vars, git credentials, and `~/.ssh` are not present.
|
|
79
|
+
- The image pins Node 22 regardless of your project's version (`--image` to override).
|
|
80
|
+
- No TTY is allocated, so interactive/TUI programs degrade; Docker Desktop's file-share layer slows large builds on macOS.
|
|
81
|
+
|
|
82
|
+
For capping an AI agent session you want to *interact* with, use the default watchdog: same host environment, streaming output, no container drift. Reach for `--docker` when you need the zero-race kernel guarantee and the command is container-safe.
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
## init — auto-cap a command on every launch
|
|
86
|
+
|
|
87
|
+
For a command you always want capped (e.g. an AI coding agent), don't type the wrapper every time — bake it into your shell so a new terminal tab is capped automatically:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
caproom init claude --limit 6144 --grace 10 >> ~/.zshrc && source ~/.zshrc
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
This appends a shell function that wraps `claude` through the watchdog backend (host-native — no Docker isolation, so the wrapped command keeps its normal filesystem/auth/PATH access) and an alias so plain `claude` picks it up. Per-shell override without editing the rc file: `CAPROOM_LIMIT_MB=8192 claude ...`. Works for any command, not just `claude` — `caproom init npm --limit 2048` wraps `npm` the same way.
|
|
94
|
+
|
|
61
95
|
## park / wake — reclaim idle memory without killing
|
|
62
96
|
|
|
63
97
|
Long-running agent sessions accumulate subprocesses that go idle but stay resident — old file watchers, finished tool-call children, stale servers. Killing them loses state; leaving them wastes RAM. `caproom park` freezes instead:
|
|
@@ -77,11 +111,39 @@ No daemon, no tracking file, no dependency — just `SIGSTOP`/`SIGCONT` wrapped
|
|
|
77
111
|
|
|
78
112
|
**Caveat**: a parked process does zero work while stopped — no CPU, no I/O, no timers firing. Only park something actually idle (a background watcher, a finished subprocess kept around for reuse) — never park the process an agent is actively waiting on a response from, or you'll hang the agent, not save it memory.
|
|
79
113
|
|
|
114
|
+
## What it never touches
|
|
115
|
+
|
|
116
|
+
caproom only watches OS-level RSS and sends signals (`SIGTERM`/`SIGKILL`/`SIGSTOP`/`SIGCONT`). The watchdog backend runs the wrapped command as a direct child with stdin/stdout/stderr passed straight through — no pipe, no buffering, no interception. The Docker backend passes stdio through the same way (`docker run -i`). caproom never reads, modifies, or truncates anything the wrapped process reads or writes — including an AI agent's own conversation/context stream. It manages RAM headroom only, nothing else.
|
|
117
|
+
|
|
118
|
+
## Windows
|
|
119
|
+
|
|
120
|
+
Windows uses a separate PowerShell backend, selected automatically. Same commands, but the semantics differ in three ways worth knowing before you reuse a `--limit` number across platforms.
|
|
121
|
+
|
|
122
|
+
**The cap is a Job Object** (`JOB_OBJECT_LIMIT_PROCESS_MEMORY`), enforced by the kernel at allocation time. Two things it does better than the POSIX watchdog: there is no poll-interval race window, and child processes are covered automatically — a process associated with a job passes that association to anything it spawns, so the whole tree is capped, not just the direct child.
|
|
123
|
+
|
|
124
|
+
**`--limit` means committed memory on Windows, RSS on macOS/Linux.** These are different quantities. The same number will bite at a different point, so tune it per platform rather than assuming it transfers.
|
|
125
|
+
|
|
126
|
+
**No grace period.** Windows console apps have no `SIGTERM` equivalent. Under the Job Object backend nothing is killed at all — the allocation just fails inside the process. Under the watchdog fallback, a breach kills the whole tree (`taskkill /T /F`) with no chance to flush state. `--grace` is accepted and ignored.
|
|
127
|
+
|
|
128
|
+
**Watchdog output streams, but through temp files.** stdout/stderr are captured to files and tail-followed (~50ms cadence) so logs and CI steps show progress live. Full-screen TUI redraws are not pixel-perfect over this path; plain streaming output (agents in non-interactive mode, builds) works normally.
|
|
129
|
+
|
|
130
|
+
**`park` does not suspend on Windows.** It calls `EmptyWorkingSet`, which trims the process's working set to the pagefile immediately and on demand — no waiting for system memory pressure, and **the process keeps running**. The macOS caveat about never parking a process an agent is waiting on does not apply here. `caproom wake` is therefore a no-op on Windows; trimmed pages fault back in on next access.
|
|
131
|
+
|
|
132
|
+
`init` emits a PowerShell function plus `Set-Alias` for your `$PROFILE`:
|
|
133
|
+
|
|
134
|
+
```powershell
|
|
135
|
+
caproom init claude --limit 6144 >> $PROFILE
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Docker backend is not wired up on Windows — the Job Object path already gives kernel enforcement, so there is nothing for it to add.
|
|
139
|
+
|
|
80
140
|
## Limitations
|
|
81
141
|
|
|
82
142
|
- Docker backend mounts `$PWD` into the container at `/work` and runs there — paths outside `$PWD` aren't visible to the command.
|
|
83
|
-
- Watchdog
|
|
84
|
-
-
|
|
143
|
+
- Watchdog backends have a real (if small) race window; for a hard guarantee, opt into the Docker backend (`--docker`) on POSIX, or use the Job Object backend on Windows.
|
|
144
|
+
- The watchdog's tree walk follows live parent→child edges. A child that *daemonizes* (double-fork, reparented to init/launchd) leaves the tree and escapes the cap — as does any process spawned after its parent chain broke. The Windows Job Object backend does not have this gap. This is a deliberate trade: caproom prefers to **miss** memory outside the tracked lineage rather than risk interfering with processes the user didn't ask it to manage.
|
|
145
|
+
- On Windows, `Get-CimInstance` per poll makes the watchdog heavier than a plain RSS read; keep `--interval` at 0.2s or above there.
|
|
146
|
+
- On Windows, the Job Object holds only the wrapped command and its descendants — never caproom itself — so the full `--limit` reaches your workload. (Cost: a millisecond-scale window after spawn before assignment lands, where the child is not yet counted.)
|
|
85
147
|
|
|
86
148
|
## Contributing
|
|
87
149
|
|
package/bin/caproom
CHANGED
|
@@ -3,28 +3,37 @@
|
|
|
3
3
|
# on macOS/Linux. macOS has no working RLIMIT_AS/DATA/RSS or launchd RSS
|
|
4
4
|
# enforcement (verified empirically — both are no-ops on modern macOS), so
|
|
5
5
|
# this uses whichever real enforcement mechanism is available:
|
|
6
|
-
# 1.
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
6
|
+
# 1. Host-native polling watchdog (process-tree RSS + SIGKILL) — the
|
|
7
|
+
# DEFAULT. Runs in your real environment: same PATH, auth, native
|
|
8
|
+
# binaries, tty. Small race window bounded by --interval.
|
|
9
|
+
# 2. Docker cgroup (--memory) — opt-in via --docker. Hard cap, zero race
|
|
10
|
+
# window, but runs the command inside a Linux container (native-module
|
|
11
|
+
# and toolchain drift; see README). Fails loudly if the daemon is down.
|
|
10
12
|
set -euo pipefail
|
|
11
13
|
|
|
12
14
|
usage() {
|
|
13
|
-
|
|
15
|
+
local stream=/dev/stderr
|
|
16
|
+
local code=1
|
|
17
|
+
if [[ "${1:-}" == "help" ]]; then stream=/dev/stdout; code=0; fi
|
|
18
|
+
cat >"$stream" << 'EOF'
|
|
14
19
|
usage: caproom [--limit <mb>] [--image <docker-image>] [--interval <sec>] -- <command> [args...]
|
|
15
20
|
caproom park <pid>
|
|
16
21
|
caproom wake <pid>
|
|
17
22
|
caproom status <pid>
|
|
23
|
+
caproom guard [--threshold <pct>] [--interval <sec>] <pid...>
|
|
24
|
+
caproom init <command> [--limit <mb>] [--grace <sec>]
|
|
18
25
|
|
|
19
26
|
--limit <mb> memory cap in MB (default: 4096)
|
|
20
|
-
--
|
|
21
|
-
backend (default: node:22-slim)
|
|
22
|
-
--interval <sec> watchdog poll interval in seconds, fallback backend only
|
|
23
|
-
(default: 0.2)
|
|
27
|
+
--interval <sec> watchdog poll interval in seconds (default: 0.2)
|
|
24
28
|
--grace <sec> seconds to wait after SIGTERM before SIGKILL, watchdog
|
|
25
29
|
backend only (default: 5) — gives the process a chance
|
|
26
30
|
to flush/save state before a hard kill
|
|
27
|
-
--
|
|
31
|
+
--docker opt in to the Docker cgroup backend instead of the default
|
|
32
|
+
host-native watchdog (needs the daemon running)
|
|
33
|
+
--image <name> docker image used by the --docker backend
|
|
34
|
+
(default: node:22-slim)
|
|
35
|
+
--force-watchdog no-op; the host-native watchdog IS the default — kept so
|
|
36
|
+
existing scripts and 'init' snippets keep working
|
|
28
37
|
|
|
29
38
|
park / wake — freeze an idle process so the kernel can reclaim/compress its
|
|
30
39
|
memory without killing it. For a long-running agent sitting on stale
|
|
@@ -35,15 +44,57 @@ becomes eligible for compression under system memory pressure. `caproom wake
|
|
|
35
44
|
Any agent can call these directly — they're just SIGSTOP/SIGCONT, no daemon,
|
|
36
45
|
no tracking file required.
|
|
37
46
|
|
|
47
|
+
guard — watch SYSTEM-WIDE free memory (not any single process) and auto-park
|
|
48
|
+
tracked pids (SIGSTOP) when free mem drops below --threshold percent, before
|
|
49
|
+
the kernel OOM-killer has to pick a victim. Use it when unrelated heavy
|
|
50
|
+
processes (e.g. a GPU inference job in one terminal, a TTS job in another)
|
|
51
|
+
share a box and neither is individually over any --limit cap. Foreground,
|
|
52
|
+
blocking; exits once all watched pids have exited. Does not auto-wake —
|
|
53
|
+
`caproom wake <pid>` when memory pressure clears:
|
|
54
|
+
|
|
55
|
+
caproom guard --threshold 10 --interval 5 -- 12345 12346
|
|
56
|
+
|
|
57
|
+
init <command> — print a shell snippet that auto-caps <command> on every
|
|
58
|
+
invocation, so a new terminal tab is capped with no extra typing. Append the
|
|
59
|
+
output to your shell rc (~/.zshrc, ~/.bashrc):
|
|
60
|
+
|
|
61
|
+
caproom init claude >> ~/.zshrc && source ~/.zshrc
|
|
62
|
+
|
|
38
63
|
env vars (override flags): CAPROOM_LIMIT_MB, CAPROOM_IMAGE, CAPROOM_INTERVAL, CAPROOM_GRACE
|
|
39
64
|
|
|
40
65
|
examples:
|
|
41
66
|
caproom --limit 2048 -- npm run build
|
|
42
67
|
caproom --limit 512 -- claude --dangerously-skip-permissions -p "task"
|
|
68
|
+
caproom --limit 4096 --docker --image python:3.12-slim -- python train.py
|
|
43
69
|
caproom park 12345
|
|
44
70
|
caproom wake 12345
|
|
71
|
+
caproom init claude --limit 6144 --grace 10
|
|
72
|
+
EOF
|
|
73
|
+
exit "$code"
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
cmd_init() {
|
|
77
|
+
local target="${1:-}"
|
|
78
|
+
[[ -z "$target" ]] && { echo "usage: caproom init <command> [--limit <mb>] [--grace <sec>]" >&2; exit 1; }
|
|
79
|
+
shift
|
|
80
|
+
local limit=4096
|
|
81
|
+
local grace=5
|
|
82
|
+
while [[ $# -gt 0 ]]; do
|
|
83
|
+
case "$1" in
|
|
84
|
+
--limit) limit="$2"; shift 2 ;;
|
|
85
|
+
--grace) grace="$2"; shift 2 ;;
|
|
86
|
+
*) echo "caproom: unknown init flag $1" >&2; exit 1 ;;
|
|
87
|
+
esac
|
|
88
|
+
done
|
|
89
|
+
local fn="${target}_capped"
|
|
90
|
+
cat << EOF
|
|
91
|
+
# caproom: auto-cap '$target' — added by 'caproom init $target'
|
|
92
|
+
# override per-shell: CAPROOM_LIMIT_MB=8192 $target ...
|
|
93
|
+
$fn() {
|
|
94
|
+
command caproom --limit "\${CAPROOM_LIMIT_MB:-$limit}" --force-watchdog --grace "\${CAPROOM_GRACE:-$grace}" -- command $target "\$@"
|
|
95
|
+
}
|
|
96
|
+
alias $target=$fn
|
|
45
97
|
EOF
|
|
46
|
-
exit 1
|
|
47
98
|
}
|
|
48
99
|
|
|
49
100
|
cmd_park() {
|
|
@@ -68,17 +119,77 @@ cmd_status() {
|
|
|
68
119
|
ps -o pid,stat,rss,etime,command -p "$pid" 2>/dev/null || { echo "caproom: no such pid $pid" >&2; exit 1; }
|
|
69
120
|
}
|
|
70
121
|
|
|
122
|
+
mem_free_pct() {
|
|
123
|
+
if [[ "$(uname)" == "Darwin" ]]; then
|
|
124
|
+
local page_size free inactive total_bytes avail_bytes
|
|
125
|
+
page_size=$(vm_stat | awk '/page size of/ {print $8}')
|
|
126
|
+
free=$(vm_stat | awk '/Pages free/ {gsub("\\.","",$3); print $3}')
|
|
127
|
+
inactive=$(vm_stat | awk '/Pages inactive/ {gsub("\\.","",$3); print $3}')
|
|
128
|
+
total_bytes=$(sysctl -n hw.memsize)
|
|
129
|
+
avail_bytes=$(( (free + inactive) * page_size ))
|
|
130
|
+
echo $(( avail_bytes * 100 / total_bytes ))
|
|
131
|
+
else
|
|
132
|
+
local avail_kb total_kb
|
|
133
|
+
avail_kb=$(awk '/MemAvailable/ {print $2}' /proc/meminfo)
|
|
134
|
+
total_kb=$(awk '/MemTotal/ {print $2}' /proc/meminfo)
|
|
135
|
+
echo $(( avail_kb * 100 / total_kb ))
|
|
136
|
+
fi
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
cmd_guard() {
|
|
140
|
+
local threshold=10
|
|
141
|
+
local interval=5
|
|
142
|
+
local pids=()
|
|
143
|
+
while [[ $# -gt 0 ]]; do
|
|
144
|
+
case "$1" in
|
|
145
|
+
--threshold) threshold="$2"; shift 2 ;;
|
|
146
|
+
--interval) interval="$2"; shift 2 ;;
|
|
147
|
+
--) shift ;;
|
|
148
|
+
*) pids+=("$1"); shift ;;
|
|
149
|
+
esac
|
|
150
|
+
done
|
|
151
|
+
[[ ${#pids[@]} -eq 0 ]] && { echo "usage: caproom guard [--threshold <pct>] [--interval <sec>] <pid...>" >&2; exit 1; }
|
|
152
|
+
echo "caproom: guarding ${#pids[@]} pid(s), park when system free mem < ${threshold}% (poll ${interval}s)" >&2
|
|
153
|
+
local parked=()
|
|
154
|
+
while :; do
|
|
155
|
+
local alive=()
|
|
156
|
+
local pid
|
|
157
|
+
for pid in "${pids[@]}"; do
|
|
158
|
+
kill -0 "$pid" 2>/dev/null && alive+=("$pid")
|
|
159
|
+
done
|
|
160
|
+
if [[ ${#alive[@]} -eq 0 ]]; then
|
|
161
|
+
echo "caproom: guard: all watched pids exited" >&2
|
|
162
|
+
exit 0
|
|
163
|
+
fi
|
|
164
|
+
pids=("${alive[@]}")
|
|
165
|
+
local pct
|
|
166
|
+
pct=$(mem_free_pct)
|
|
167
|
+
if [[ "$pct" -lt "$threshold" ]]; then
|
|
168
|
+
for pid in "${pids[@]}"; do
|
|
169
|
+
if [[ ! " ${parked[*]:-} " == *" $pid "* ]]; then
|
|
170
|
+
echo "caproom: system free mem ${pct}% < ${threshold}% threshold — about to blow, parking pid $pid (SIGSTOP)" >&2
|
|
171
|
+
kill -STOP "$pid" 2>/dev/null && parked+=("$pid")
|
|
172
|
+
fi
|
|
173
|
+
done
|
|
174
|
+
fi
|
|
175
|
+
sleep "$interval"
|
|
176
|
+
done
|
|
177
|
+
}
|
|
178
|
+
|
|
71
179
|
case "${1:-}" in
|
|
72
180
|
park) shift; cmd_park "$@"; exit 0 ;;
|
|
73
181
|
wake) shift; cmd_wake "$@"; exit 0 ;;
|
|
74
182
|
status) shift; cmd_status "$@"; exit 0 ;;
|
|
183
|
+
guard) shift; cmd_guard "$@"; exit 0 ;;
|
|
184
|
+
init) shift; cmd_init "$@"; exit 0 ;;
|
|
185
|
+
help|-h|--help) usage help ;;
|
|
75
186
|
esac
|
|
76
187
|
|
|
77
188
|
LIMIT_MB="${CAPROOM_LIMIT_MB:-4096}"
|
|
78
189
|
IMAGE="${CAPROOM_IMAGE:-node:22-slim}"
|
|
79
190
|
INTERVAL="${CAPROOM_INTERVAL:-0.2}"
|
|
80
191
|
GRACE="${CAPROOM_GRACE:-5}"
|
|
81
|
-
|
|
192
|
+
USE_DOCKER=0
|
|
82
193
|
|
|
83
194
|
while [[ $# -gt 0 ]]; do
|
|
84
195
|
case "$1" in
|
|
@@ -86,19 +197,18 @@ while [[ $# -gt 0 ]]; do
|
|
|
86
197
|
--image) IMAGE="$2"; shift 2 ;;
|
|
87
198
|
--interval) INTERVAL="$2"; shift 2 ;;
|
|
88
199
|
--grace) GRACE="$2"; shift 2 ;;
|
|
89
|
-
--
|
|
200
|
+
--docker) USE_DOCKER=1; shift ;;
|
|
201
|
+
# legacy no-op: the watchdog IS the default now; accepted so old
|
|
202
|
+
# scripts and init snippets keep working
|
|
203
|
+
--force-watchdog) shift ;;
|
|
90
204
|
--) shift; break ;;
|
|
91
|
-
-h|--help) usage ;;
|
|
205
|
+
-h|--help) usage help ;;
|
|
92
206
|
*) break ;;
|
|
93
207
|
esac
|
|
94
208
|
done
|
|
95
209
|
|
|
96
210
|
[[ $# -eq 0 ]] && usage
|
|
97
211
|
|
|
98
|
-
docker_available() {
|
|
99
|
-
[[ "$FORCE_WATCHDOG" -eq 0 ]] && command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1
|
|
100
|
-
}
|
|
101
|
-
|
|
102
212
|
run_docker() {
|
|
103
213
|
echo "caproom: docker cgroup backend, limit=${LIMIT_MB}m image=${IMAGE}" >&2
|
|
104
214
|
exec docker run --rm -i \
|
|
@@ -106,17 +216,52 @@ run_docker() {
|
|
|
106
216
|
-v "$PWD:/work" -w /work "$IMAGE" "$@"
|
|
107
217
|
}
|
|
108
218
|
|
|
219
|
+
sum_tree_rss_kb() {
|
|
220
|
+
# Sum RSS across the whole descendant tree of $1. Agents keep their memory
|
|
221
|
+
# in children (MCP servers, bundler daemons, headless browsers) while the
|
|
222
|
+
# parent's own RSS stays flat — a top-pid-only check never fires on them.
|
|
223
|
+
# One ps snapshot per poll; BFS over pid->ppid edges. Plain indexed arrays
|
|
224
|
+
# only (no declare -A) so macOS's stock bash 3.2 works.
|
|
225
|
+
local root="$1"
|
|
226
|
+
local -a pids=() ppids=() kb=() queue=()
|
|
227
|
+
local pid ppid k total=0 cur i j
|
|
228
|
+
while read -r pid ppid k; do
|
|
229
|
+
[[ -n "$pid" ]] || continue
|
|
230
|
+
pids+=("$pid"); ppids+=("$ppid"); kb+=("$k")
|
|
231
|
+
done < <(ps -eo pid=,ppid=,rss=)
|
|
232
|
+
|
|
233
|
+
queue=("$root")
|
|
234
|
+
while [[ ${#queue[@]} -gt 0 ]]; do
|
|
235
|
+
cur="${queue[0]}"
|
|
236
|
+
if [[ ${#queue[@]} -gt 1 ]]; then queue=("${queue[@]:1}"); else queue=(); fi
|
|
237
|
+
for ((i = 0; i < ${#pids[@]}; i++)); do
|
|
238
|
+
if [[ "${pids[$i]}" == "$cur" && -n "${kb[$i]}" ]]; then
|
|
239
|
+
total=$(( total + kb[$i] ))
|
|
240
|
+
kb[$i]=""
|
|
241
|
+
for ((j = 0; j < ${#ppids[@]}; j++)); do
|
|
242
|
+
if [[ "${ppids[$j]}" == "$cur" ]]; then
|
|
243
|
+
queue+=("${pids[$j]}")
|
|
244
|
+
ppids[$j]=""
|
|
245
|
+
fi
|
|
246
|
+
done
|
|
247
|
+
break
|
|
248
|
+
fi
|
|
249
|
+
done
|
|
250
|
+
done
|
|
251
|
+
echo "$total"
|
|
252
|
+
}
|
|
253
|
+
|
|
109
254
|
run_watchdog() {
|
|
110
|
-
echo "caproom: watchdog backend (
|
|
255
|
+
echo "caproom: watchdog backend (host-native), limit=${LIMIT_MB}m poll=${INTERVAL}s (process-tree RSS)" >&2
|
|
111
256
|
local limit_kb=$(( LIMIT_MB * 1024 ))
|
|
112
257
|
"$@" &
|
|
113
258
|
local pid=$!
|
|
114
259
|
local exit_code=0
|
|
115
260
|
while kill -0 "$pid" 2>/dev/null; do
|
|
116
|
-
local
|
|
117
|
-
|
|
118
|
-
if [[ -n "$
|
|
119
|
-
echo "caproom: pid $pid RSS ${
|
|
261
|
+
local tree_kb
|
|
262
|
+
tree_kb=$(sum_tree_rss_kb "$pid")
|
|
263
|
+
if [[ -n "$tree_kb" && "$tree_kb" -gt "$limit_kb" ]]; then
|
|
264
|
+
echo "caproom: pid $pid tree RSS ${tree_kb}KB exceeded ${limit_kb}KB cap — sending SIGTERM (grace ${GRACE}s)" >&2
|
|
120
265
|
kill -TERM "$pid" 2>/dev/null || true
|
|
121
266
|
local waited=0
|
|
122
267
|
while kill -0 "$pid" 2>/dev/null && [[ "$waited" -lt "$GRACE" ]]; do
|
|
@@ -138,8 +283,16 @@ run_watchdog() {
|
|
|
138
283
|
exit "$exit_code"
|
|
139
284
|
}
|
|
140
285
|
|
|
141
|
-
if
|
|
142
|
-
|
|
286
|
+
if [[ "$USE_DOCKER" -eq 1 ]]; then
|
|
287
|
+
# Explicit opt-in must fail loudly rather than silently downgrade —
|
|
288
|
+
# the caller asked for a hard cap, a silent watchdog switch would
|
|
289
|
+
# quietly change the guarantee they asked for.
|
|
290
|
+
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
|
291
|
+
run_docker "$@"
|
|
292
|
+
else
|
|
293
|
+
echo "caproom: --docker requested but the docker daemon is not reachable" >&2
|
|
294
|
+
exit 1
|
|
295
|
+
fi
|
|
143
296
|
else
|
|
144
297
|
run_watchdog "$@"
|
|
145
298
|
fi
|
package/bin/caproom.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Platform dispatch: bash script on POSIX, PowerShell script on Windows.
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
const { join } = require('path');
|
|
5
|
+
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
const isWin = process.platform === 'win32';
|
|
8
|
+
|
|
9
|
+
const result = isWin
|
|
10
|
+
? spawnSync(
|
|
11
|
+
'powershell.exe',
|
|
12
|
+
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(__dirname, 'caproom.ps1'), ...args],
|
|
13
|
+
{ stdio: 'inherit' }
|
|
14
|
+
)
|
|
15
|
+
: spawnSync('bash', [join(__dirname, 'caproom'), ...args], { stdio: 'inherit' });
|
|
16
|
+
|
|
17
|
+
if (result.error) {
|
|
18
|
+
console.error(`caproom: failed to launch backend — ${result.error.message}`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
process.exit(result.status === null ? 1 : result.status);
|
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,14 @@
|
|
|
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.4.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
7
|
},
|
|
8
8
|
"files": [
|
|
9
|
-
"bin/caproom"
|
|
9
|
+
"bin/caproom",
|
|
10
|
+
"bin/caproom.js",
|
|
11
|
+
"bin/caproom.ps1"
|
|
10
12
|
],
|
|
11
13
|
"keywords": [
|
|
12
14
|
"memory",
|
|
@@ -21,7 +23,8 @@
|
|
|
21
23
|
],
|
|
22
24
|
"os": [
|
|
23
25
|
"darwin",
|
|
24
|
-
"linux"
|
|
26
|
+
"linux",
|
|
27
|
+
"win32"
|
|
25
28
|
],
|
|
26
29
|
"license": "MIT",
|
|
27
30
|
"repository": {
|