Skip to content

docs: state why the heap ceiling is not ordered ahead of the share - #886

Open
ppat wants to merge 1 commit into
mainfrom
heap-ceiling-ordering
Open

docs: state why the heap ceiling is not ordered ahead of the share#886
ppat wants to merge 1 commit into
mainfrom
heap-ceiling-ordering

Conversation

@ppat

@ppat ppat commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Summary

Comments and DESIGN.md only. No share, no envelope, no budget and no watchdog behaviour changes. Follow-up to #884, paired with ppat/dotfiles#789.

The premise under investigation was that the watchdog's per-role PSS share and the launcher heap ceiling in the operator's dotfiles are in the wrong order — that the ceiling should bind first so a process fails its own allocation and raises a recoverable V8 fatal heap error, leaving the watchdog as backstop. Two comments in this repo implied that ordering was achievable or already intended. It is not, and both are corrected against evidence read off the live pod and out of the VS Code server bundle on disk.

The rule, in one line

A heap ceiling bounds V8's old space. PSS additionally carries the node binary's share of file-backed pages, native allocations, external ArrayBuffers and V8's new/code/large-object spaces — a remainder that is near-constant per role rather than proportional to the heap. So the two relate by addition, not by a ratio, and one line covers every role instead of a seven-row table of PSS-to-heap factors:

peak PSS ≲ resting PSS + ceiling      ⇒      ordering needs   ceiling ≤ share − resting PSS

Applied to this envelope, no role clears it

Role Share Resting PSS Ceiling the share permits Knob?
serverMain 512 MiB 146 MiB 366 MiB none
extensionHost 480 MiB 747 MiB negative none
tsserver 320 MiB 141 / 111 MiB 179 MiB yes
treeHelper 320 MiB 279 MiB 41 MiB none
fileWatcher 160 MiB 117 MiB 43 MiB none
languageServer 128 MiB 56 MiB 72 MiB none
extensionHelper 128 MiB 31 MiB 97 MiB none

Six of seven have no ceiling to set at any value. The seventh is arithmetically reachable and is refused — see ppat/dotfiles#789, which keeps the ceiling at 768 on purpose.

What each comment claimed, and what replaced it

[extensionHost] claimed "capped launcher heaps" as one of the trims that would make it fit. There is no such cap available to it. Established by reading the server bundle rather than assumed: no heap-sizing setting is registered, server-main.js --help exposes no memory option, no remote.* configuration property exists in the bundle, and this build has no server-env-setup support. NODE_OPTIONS in particular does not workserver-main.js deletes it from the child environment immediately before every fork it performs, on the extension-host path and on the shared path covering the file watcher, pty host and agent host. Setting it from env.tf would cap the parent and every unrelated mise-node process in the pod while leaving the extension host at its default: maximum blast radius, zero effect on the target. The only channel that propagates is the parent's own process.execArgv, injectable only inside the versioned cli/servers/Stable-<commit>/ directory VS Code replaces wholesale on every server upgrade — and no share of 2048 MiB is above where the role already rests anyway. remote.extensionKind (loading less) is the only lever, and this template still cannot depend on it having been applied.

[tsserver] claimed the dotfiles cap would make its share bind. It does not, and the honest reading is worse than the one it replaced: unlike the extension host, tsserver fits at rest, so the oversize rule never covers it, and on a project large enough to hold it above 320 MB for ten minutes the share means "kill a healthy tsserver" rather than "report" one. That kill is recoverable (the extension restarts the service) and the ten-minute dwell rate-limits it well under the five-crashes-in-five-minutes at which the extension gives up permanently — which is why it is accepted rather than designed away.

Coupling to dotfiles (unchanged, and now stated)

The watchdog's behaviour with the dotfiles caps absent is exactly what it was before this PR, which is the state it is actually in today: live tsserver processes still run --max-old-space-size=3072 on a session started after dotfiles#773. Nothing here becomes load-bearing on dotfiles having been applied.

Test plan

  • pre-commit run --all-files clean (baseline clean at f8cc458, no unrelated findings touched)
  • ./script-memory-watchdog-test.sh — 355 passed, 0 failed
  • bash -n script-memory-watchdog.sh

Ref: #884, ppat/dotfiles#789

🤖 Generated with Claude Code

https://claude.ai/code/session_0197hA8JPwGMX8wufiQiy6oz

The two mechanisms that bound this tree - the watchdog's per-role PSS share
here, and the launcher heap ceiling in the operator's dotfiles - are not
ordered so that a process fails its own allocation first, and two comments
here implied they were or could be. Both are corrected against evidence read
off the live pod and out of the server bundle on disk; no share, no envelope
and no behaviour changes.

The rule relating the two is one line, not a table: a heap ceiling bounds
V8's old space while PSS also carries the binary, native allocations,
external buffers and V8's other spaces, and that remainder is near-constant
per role rather than proportional, so peak PSS is about resting PSS plus the
ceiling and the ordering needs ceiling <= share - resting. Applied to this
envelope no role clears it. Six of the seven have no ceiling to set at any
value; tsserver's is reachable at 179 MiB and refused, because the setting is
window-scoped and so applies to every window the operator opens, and because
a V8 fatal heap error and a SIGKILL reach the same handler in the TypeScript
extension - which stops restarting the service after five crashes in five
minutes, a rate a ten-minute dwell cannot reach and a binding ceiling reaches
immediately.

The extension host comment claimed "capped launcher heaps" as one of the
trims that would make it fit. There is no such cap available to it: the
server registers no heap setting, exposes no CLI flag, and deletes
NODE_OPTIONS from the child environment before every fork it performs, so a
variable set in env.tf would cap every mise-node process in the pod and not
the extension host. remote.extensionKind - loading less - is the only lever,
and this template still cannot depend on it having been applied.

The tsserver comment claimed the dotfiles cap would make its share bind. It
does not, and the honest reading is worse than the one it replaced: unlike
the extension host, tsserver fits at rest, so the oversize rule never covers
it and the share means "kill a healthy tsserver" on a large enough project.
That kill is recoverable and dwell-limited, which is why it is accepted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197hA8JPwGMX8wufiQiy6oz
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant