Memory limits and parking idle tabs
The v-code.service unit runs inside a cgroup with a hard memory ceiling. The
tabs share a group of their own below it, tabs/, which carries the soft limit
and a hard stop 1 GB under the unit's; the server sits outside that group, so
tabs under memory pressure are throttled or killed without it. Each tab gets a
cgroup of its own inside tabs/, with its own ceiling: 8 GB, or the tabs'
soft limit (2 GB under the unit's MemoryMax) when that is not above 8 GB. Near the
unit's ceiling the server refuses to open another tab, and under real pressure it
stops idle claude processes so the kernel does not have to.
Why you would use it
Every agent and its children run inside this one unit's cgroup. A test run with 27 workers inside a tab can take the whole desktop down. The unit's cap keeps the kill inside VCode, and the tab's cap keeps it inside the tab that ran the command: the other tabs, and the server, carry on.
How to use it
- Check what is set:
systemctl --user show v-code -p MemoryHigh -p MemoryMax -p MemorySwapMax.MemoryHighshould readinfinity. - Raise the ceiling persistently on a machine with room:
systemctl --user set-property v-code MemoryMax=18G - The ceiling applies immediately, and the server re-reads the cgroup per
request, so the refusal to open a tab near the ceiling follows it at once. The tabs' group's
limits are written at boot: restart the service from a terminal outside
VCode,
systemctl --user restart v-code, for them to follow. - To change the per-tab cap, set
VCODE_TAB_MEMORY_MAXin~/.config/v-code/env(for exampleVCODE_TAB_MEMORY_MAX=12G, or0for no per-tab cap) and restart the same way. A per-tab cap at or above the tabs' soft limit (2 GB under the unit'sMemoryMax) is lowered at boot to that, and the boot line says so. If that would leave a tab under 1 GB, the tabs get no cgroups of their own and the boot saystab cgroups off: unit limit <bytes> leaves under 1G per tab.
Do not set MemoryHigh on the unit. It throttles every process in the unit,
the server included: with the tabs past it the server stops answering and the
app shows the box offline. One set by hand before is removed in two steps,
because it can live in two places: run
systemctl --user set-property v-code MemoryHigh=infinity, which clears the one
the step 2 of an older version of this page wrote, then delete any
MemoryHigh= line from the files in ~/.config/systemd/user/v-code.service.d/
(a drop-in you wrote yourself, which outranks the first step at the next
reload) and run systemctl --user daemon-reload. While one stands, the journal says
unit memory.high <bytes> is set: it throttles the server with the tabs; remove MemoryHigh from the unit's drop-ins
at every start, and the tabs' limits sit under it instead. The soft limit lives
on the tabs' group, 2 GB under the unit's ceiling, and needs no setting.
What you see
At boot the journal says whether the tabs got their own cgroups:
tab cgroups on: /sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/v-code.service, memory.max 8589934592 per tab, memory.swap.max 536870912, tabs memory.high 10737418240, memory.max 11811160064
(; VCODE_TAB_MEMORY_MAX 8589934592 lowered to the tabs' memory.high 7516192768 (the unit's memory.max 9663676416 less 2G)
at the end when the cap was lowered; no swap accounting in place
of memory.swap.max 536870912 on a kernel booted without it; tabs no limit
for a unit with no MemoryMax), or
tab cgroups off: <reason>. For example
tab cgroups off: v-code.service is not delegated (Delegate=yes) on a unit
rendered before this change, until install.sh update re-renders it, or
tab cgroups off: v-code.service: systemctl show failed: ENOENT when
systemctl could not be asked (ETIMEDOUT, or exit <code>, for the other
ways it fails).
When a command in a tab passes the tab's cap, the kernel kills the largest process in that tab, and nothing outside the tab. Which process that is depends on what is running:
- A big worker or a compiler. The command fails the way it would after any
SIGKILL: a test runner reports a worker that died, and a!command's card ends with its non-zeroexitcode. The agent reads the failure and goes on. - The agent itself. A suite of many small workers leaves the agent's CLI
(about 300 MB for claude) as the largest process, and the kernel kills it.
The turn stops, and the tab shows
Claude stopped after N tool calls.with the detailclaude exited code=null signal=SIGKILLand a Retry button. Any subagents it had running are marked failed. Retry, or the next message you send, starts the CLI again on the same session; the conversation is kept. The commands it started went with the tab's memory, so check what finished before running them again.
The kernel log names the kill:
journalctl -k | grep -i 'memory cgroup out of memory'.
Under pressure the journal also names the three tabs holding the most, at most
once every 10 minutes. A tab's figure leaves out page cache nothing is using:
memory pressure: largest tabs <thread-id> 6.5 GB, <thread-id> 2.0 GB, <thread-id> 1.0 GB.
The journal logs one line per parked tab: parked <thread-id> after 4 min idle,
and parked idle claude process from the runner. The 503 a user meets when the
cap is close, and what a parked tab feels like from the phone, are in
memory-headroom-and-idle-parking.
Options and settings
| Option | Default | What it changes |
|---|---|---|
MemoryMax |
12G |
The hard ceiling for the whole instance |
MemoryHigh |
not set | Leave it unset: on the unit it throttles the server with the tabs |
| Tabs' soft limit | MemoryMax minus 2 GB |
memory.high on tabs/: where reclaim and throttling begin, for the tabs alone. Written at boot |
| Tabs' hard stop | MemoryMax minus 1 GB |
memory.max on tabs/: the OOM kill lands inside the tabs, not on a server under 1 GB. Written at boot |
MemorySwapMax |
2G |
How much swap the unit may use |
OOMPolicy |
continue |
An agent child killed by the OOM killer does not take VCode with it |
Delegate |
yes |
Lets the server make a cgroup per tab below the unit |
DelegateSubgroup |
server |
systemd 254 and later start the server in server/ below the unit. Older systemd warns about an unknown setting and ignores it; the server moves itself there |
VCODE_TAB_MEMORY_MAX |
8G |
Each tab's own ceiling: bytes, or a whole number with K, M or G, at least 256M. 0 is no per-tab cap. Anything else fails the boot. Lowered to the tabs' soft limit, 2G under the unit's MemoryMax, when it is not below it; off if that leaves under 1G |
| Tab swap | 512M |
Each tab's memory.swap.max, so a tab at its cap cannot take the unit's whole MemorySwapMax before the kill. A constant (TAB_SWAP_MAX in lib/tab-cgroup.js) |
The guard's own thresholds (new-tab headroom, parking headroom, idle time,
reaper interval, CPU mark) are constants in lib/memory-guard.js and are listed
in
memory-headroom-and-idle-parking.
Limits and known gaps
- Neither guard stops one tab from allocating its whole cap. The tab's cap is
what stops that: the kill lands on the biggest process inside the tab, which
is the runaway. With the per-tab cap off, or on a box where the tabs have no
cgroups of their own, the unit's
MemoryMaxis the only stop and the kill lands anywhere in the unit. Keep the unit's cap set. - The tabs' caps add up to more than the unit's: three tabs at 8 GB each fit under a 12 GB unit only while they do not all fill up at once. The unit's cap still applies above them.
- The tabs get their own cgroups only under systemd with cgroup v2,
Delegate=yeson the unit, and a unit limit that leaves at least 1 GB per tab after the 2 GB margin. On a Mac, in a container, or for a clone run withnpm run dev, every process stays in one cgroup as before and the boot saystab cgroups off: <reason>. - A process is moved into its tab right after it starts. Anything it forks in that first moment stays with the server and counts against the unit only.
- The tmux shell panel keeps its own scope; it is not in any tab's cgroup.
- Which tabs are eligible to park, and why a tab is left alone, are in memory-headroom-and-idle-parking.
- Outside a cgroup with a cap (a bare
node server.js), both guards are off.readCgroupMemory()returnsnulland nothing is refused or parked. - A unit that masks
/sys/fs/cgroupalso getsnull, which fails open rather than 500-ing every new tab.
Related
- install-as-systemd-units: where the caps are set
- logs-and-troubleshooting: reading the park lines
- thread-state-on-disk: the disk side of the same question
- memory-headroom-and-idle-parking: the 503 and the parked tab, from the user's seat
- tab-memory-cap: what a tab at its own cap shows, and the TMPDIR on disk