From 3028f6d0217022aae455ac2cd3fa5b228ef6a9ee Mon Sep 17 00:00:00 2001 From: Peter Pathirana Date: Fri, 21 Aug 2026 22:27:27 +0000 Subject: [PATCH 1/3] fix(vscode): lower tsserver heap ceiling, pin more UI-only extensions typescript.tsserver.maxTsServerMemory (dotfiles#773) is a deprecated key -- still honored as a fallback in the installed extension (verified against its compiled dist/extension.js) but replaced here with the current js/ts.tsserver.maxMemory, same window scope. Its value was also wrong for the goal: TypeScript spawns two tsserver processes that each read this same setting independently, so the previous 2048 let tsserver alone claim up to 4096 MiB against the operator's 2048 MiB budget for the whole remote VS Code tree. 768 sits well above both processes' measured resting PSS on a live workspace (~142/~112 MiB) while actually bounding the pair. Adds hashicorp.hcl and samuelcolvin.jinjahtml to remote.extensionKind alongside the existing vscode-icons/markdown-mermaid entries -- both are grammar-only (no LSP client, no fs/child_process use in their source), confirmed against the extensions actually installed under ~/.vscode-server/extensions on a live workspace. davidanson.vscode-markdownlint was considered and left on the remote: its markdownlint.lintWorkspace command does real work against the workspace filesystem. Most of the tree still has no heap-ceiling knob at all (extension host, file watcher, pty host, the JSON/Markdown/TOML language servers), so this does not by itself guarantee the tree fits 2048 MiB -- see the comment above js/ts.tsserver.maxMemory and dotfiles#772. Ref: ppat/dotfiles#772 Co-Authored-By: Claude Opus 5 (1M context) --- .../private_Code/User/settings.json.tmpl | 49 +++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/private_Library/private_Application Support/private_Code/User/settings.json.tmpl b/private_Library/private_Application Support/private_Code/User/settings.json.tmpl index 107c69f7..42e3e650 100644 --- a/private_Library/private_Application Support/private_Code/User/settings.json.tmpl +++ b/private_Library/private_Application Support/private_Code/User/settings.json.tmpl @@ -89,8 +89,29 @@ }, // Homebrew/mise symlink farms make plain (non-excluded) symlink traversal expensive. "search.followSymlinks": false, - // Cap the TypeScript server's heap instead of leaving it unbounded. - "typescript.tsserver.maxTsServerMemory": 2048, + // Cap the TypeScript server's heap instead of leaving it unbounded (default 3072 MiB). + // `js/ts.tsserver.maxMemory` replaces the deprecated `typescript.tsserver.maxTsServerMemory` + // (still honored as a fallback in the installed extension, verified against its compiled + // dist/extension.js, but this stops relying on that and the Settings UI deprecation nag). + // Both are `scope: "window"` in the extension's own configuration.json -- not MACHINE or + // MACHINE_OVERRIDABLE -- so, like everything else in this section, this file is the only + // place it can live; the remote Machine settings file would silently drop it. + // + // Value derivation: the operator wants the whole remote VS Code tree (extension host + + // every helper) under 2048 MiB. TypeScript spawns *two* processes that each read this same + // setting independently -- a full semantic server and a lighter "partialSemantic" one -- so + // this is a per-process ceiling, not a combined one; a pathological workspace can drive both + // toward it at once. 768 sits well above both processes' measured resting PSS on a live + // workspace (~142 MiB / ~112 MiB), leaving real headroom for a genuinely large project, while + // no longer leaving tsserver free to claim most of the 2048 MiB envelope by itself the way the + // previous 2048 setting effectively could (2 x 2048 = 4096, already over budget alone). Cost: + // on a large enough TypeScript project this trades an unbounded tsserver for a V8 heap OOM + // that the extension host catches and restarts tsserver from -- a visible but recoverable + // failure, not silent data loss. It is not a guarantee the whole tree fits 2048 MiB: most of + // that tree (extension host, file watcher, pty host, the JSON/Markdown/TOML language servers) + // exposes no heap-ceiling setting at all -- see remote.extensionKind below for the lever that + // actually shrinks the extension host, and dotfiles#772 for the rest of that story. + "js/ts.tsserver.maxMemory": 768, "typescript.disableAutomaticTypeAcquisition": true, // Reduce the git extension's background repository scanning. "git.autoRepositoryDetection": "openEditors", @@ -114,12 +135,32 @@ "remote.SSH.remotePlatform": { "coder-vscode.coder.{{ (bitwardenSecrets "288eeda0-d57f-4a91-8651-b2090163ecc0" .bwsAccessToken).value }}--{{ .coderUsername }}--{{ .coderUsername }}.main": "linux" }, - // APPLICATION/WINDOW-scoped in VS Code's own configuration registry (never MACHINE or + // APPLICATION-scoped in VS Code's own configuration registry (never MACHINE or // MACHINE_OVERRIDABLE), so it must live in this file -- the remote Machine settings file // parses only [MACHINE, MACHINE_OVERRIDABLE] scopes and silently drops anything else. + // + // Pins extensions that do no workspace-filesystem or child-process work to the client (UI) + // extension host instead of the remote one, so their code never runs on the remote at all -- + // the extension host is the tree's biggest, most volatile process (685-738 MiB PSS observed, + // climbing over time) precisely because of what it loads, so this is the "load less" lever + // rather than a heap ceiling. Each entry below was checked against the extension actually + // installed under ~/.vscode-server/extensions on a live workspace: no `main`-side use of + // node's `fs`/`child_process`, no language-server client, no commands operating on files the + // user hasn't opened. + // - vscode-icons-team.vscode-icons, bierner.markdown-mermaid: pre-existing, unchanged. + // - hashicorp.hcl, samuelcolvin.jinjahtml: grammar/syntax-highlighting only (`contributes` + // is just `languages`/`grammars`[/`breakpoints` metadata for jinjahtml, not an actual + // debug adapter]); confirmed no LSP client or fs/child_process usage in either's source. + // Considered and left on the remote (default, unset here): davidanson.vscode-markdownlint -- + // it contributes a `markdownlint.lintWorkspace` command that walks the whole workspace tree, + // which is exactly the "real work against the workspace filesystem" case this lever is not + // for. tamasfe.even-better-toml already declares its own `extensionKind: ["workspace"]` for + // the same reason (it validates TOML against the real workspace, e.g. Cargo.lock/uv.lock). "remote.extensionKind": { "vscode-icons-team.vscode-icons": ["ui"], - "bierner.markdown-mermaid": ["ui"] + "bierner.markdown-mermaid": ["ui"], + "hashicorp.hcl": ["ui"], + "samuelcolvin.jinjahtml": ["ui"] }, "remote.SSH.defaultExtensions": [ "bierner.markdown-mermaid", From c70e822c2a811aca105f3e71d15380e326f84bb6 Mon Sep 17 00:00:00 2001 From: Peter Pathirana Date: Sat, 22 Aug 2026 15:53:17 +0000 Subject: [PATCH 2/3] docs(vscode): state why the heap ceiling is not ordered ahead of the watchdog No setting value changes. This corrects the reasoning around the two settings this branch already carries, after establishing two things that the previous description got wrong. First, the ordering. A heap ceiling of 768 against the template watchdog's 320 MiB tsserver share never binds first, which looks like a defect and is not. A ceiling bounds V8's old space; 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 the two relate by addition - peak PSS is about resting PSS plus the ceiling - and V8 would have to be capped at about 179 MiB to fail first. Three reasons that is refused, now recorded above the setting: it is within 50 MiB of the 128 MiB floor the extension clamps to (Math.max(n, 128) in readMaxTsServerMemory); the setting is window-scoped, so it necessarily applies to every window including local macOS projects where 179 MiB is a crash rather than a bound; and it would not buy a more recoverable failure anyway, because a V8 fatal heap error and an external SIGKILL reach the same handler, 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. Second, NODE_OPTIONS. The previous description said it would reach the rest of the tree but could only be set from the template. It would not reach them at all: server-main.js deletes NODE_OPTIONS from the child environment immediately before every fork it performs, on both the extension-host path and the shared path covering the file watcher, pty host and agent host. Setting it would cap the parent and every unrelated mise-node process in the pod while leaving the extension host at its default. The only channel that propagates is the parent's own process.execArgv, injectable only inside the versioned cli/servers/Stable-/ directory VS Code replaces on every server upgrade. So remote.extensionKind is not the better lever for the extension host, it is the only one, and the comment now says so with the checks that establish it. Ref: ppat/dotfiles#772 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0197hA8JPwGMX8wufiQiy6oz --- .../private_Code/User/settings.json.tmpl | 98 ++++++++++++++----- 1 file changed, 76 insertions(+), 22 deletions(-) diff --git a/private_Library/private_Application Support/private_Code/User/settings.json.tmpl b/private_Library/private_Application Support/private_Code/User/settings.json.tmpl index 42e3e650..cf864081 100644 --- a/private_Library/private_Application Support/private_Code/User/settings.json.tmpl +++ b/private_Library/private_Application Support/private_Code/User/settings.json.tmpl @@ -91,26 +91,67 @@ "search.followSymlinks": false, // Cap the TypeScript server's heap instead of leaving it unbounded (default 3072 MiB). // `js/ts.tsserver.maxMemory` replaces the deprecated `typescript.tsserver.maxTsServerMemory` - // (still honored as a fallback in the installed extension, verified against its compiled - // dist/extension.js, but this stops relying on that and the Settings UI deprecation nag). - // Both are `scope: "window"` in the extension's own configuration.json -- not MACHINE or - // MACHINE_OVERRIDABLE -- so, like everything else in this section, this file is the only - // place it can live; the remote Machine settings file would silently drop it. + // (still honored as a fallback -- `readMaxTsServerMemory()` in the installed extension's + // compiled dist/extension.js reads `tsserver.maxMemory` with the old key as + // `fallbackSubSectionNameOverride` -- but this stops relying on that and on the Settings UI + // deprecation nag). That same function clamps whatever it reads with `Math.max(n, 128)`, so + // 128 MiB is the floor no value here can go below. // - // Value derivation: the operator wants the whole remote VS Code tree (extension host + - // every helper) under 2048 MiB. TypeScript spawns *two* processes that each read this same - // setting independently -- a full semantic server and a lighter "partialSemantic" one -- so - // this is a per-process ceiling, not a combined one; a pathological workspace can drive both - // toward it at once. 768 sits well above both processes' measured resting PSS on a live - // workspace (~142 MiB / ~112 MiB), leaving real headroom for a genuinely large project, while - // no longer leaving tsserver free to claim most of the 2048 MiB envelope by itself the way the - // previous 2048 setting effectively could (2 x 2048 = 4096, already over budget alone). Cost: - // on a large enough TypeScript project this trades an unbounded tsserver for a V8 heap OOM - // that the extension host catches and restarts tsserver from -- a visible but recoverable - // failure, not silent data loss. It is not a guarantee the whole tree fits 2048 MiB: most of - // that tree (extension host, file watcher, pty host, the JSON/Markdown/TOML language servers) - // exposes no heap-ceiling setting at all -- see remote.extensionKind below for the lever that - // actually shrinks the extension host, and dotfiles#772 for the rest of that story. + // Scope, and why this file: both keys are `scope: "window"` in the extension's own + // package.json (verified against the copy installed under the remote server's + // extensions/typescript-language-features). The remote Machine settings file parses only + // [MACHINE, MACHINE_OVERRIDABLE] and silently drops everything else, so this file -- the + // client-side User settings -- is the only place it can live. The consequence is worth + // stating because it constrains the value: this applies to *every* VS Code window, local + // macOS projects included, not just the remote workspace. + // + // Value. There is exactly one process class in the remote tree with a heap knob, and this is + // it; the extension host, file watcher, pty host and the JSON/Markdown/TOML language servers + // have none at any value (see remote.extensionKind below, which is the only lever that + // shrinks the extension host). TypeScript spawns *two* processes that each read this setting + // independently -- a full semantic server and a lighter "partialSemantic" one -- so it is a + // per-process ceiling, not a combined one. + // + // 768 is deliberately NOT low enough to fire before the workspace template's memory watchdog, + // and that ordering is a decision rather than an oversight. The watchdog gives tsserver a + // 320 MiB share of a 2048 MiB envelope, compared in PSS. A heap ceiling bounds V8's old space + // only, while PSS also carries the node binary, 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: + // + // peak PSS ~= resting PSS + ceiling + // + // and for V8 to fail first the ceiling would have to be <= share - resting, which on measured + // resting PSS of 141/111 MiB is about 179 MiB. Three reasons not to set it there: + // 1. It is within spitting distance of the 128 MiB clamp above -- no room left to be wrong. + // 2. Window scope (above) means it would cap tsserver on real TypeScript projects opened + // locally on the Mac, where 179 MiB is a crash rather than a bound. A ceiling set too + // low turns a working feature into a crash loop; a ceiling set too high is merely inert. + // That asymmetry decides it. + // 3. It would not buy the recoverable failure it appears to. A V8 fatal heap error and an + // external SIGKILL arrive at the same handler in the extension, which shows "The JS/TS + // language service crashed." and, after five crashes in five minutes, "The service will + // not be restarted." The watchdog's ten-minute dwell cannot reach that rate. A binding + // heap ceiling on a project that legitimately needs the memory reaches it immediately. + // + // So the watchdog stays first for tsserver, and this ceiling keeps its original and only job: + // stop a runaway tsserver from claiming the whole pod. 768 sits well above both processes' + // measured resting PSS while bounding the pair at ~1.5 GiB of heap instead of the 6 GiB two + // 3072-defaults sanction. Cost on a large TypeScript project: a project whose server needs + // more than 768 MiB of heap will crash and restart. That is a real cost and it is why this is + // not lower. + // + // Nothing here is load-bearing for the template: the watchdog is coherent with these settings + // absent, which is the state it is actually in most of the time (see below). + // + // Not yet verified in force. Live evidence from a remote session started after dotfiles#773 + // set the old key: both tsserver processes still run with --max-old-space-size=3072, VS Code's + // default. Since the extension does honor the old key as a fallback, that means these settings + // are not reaching the window at all -- most likely simply not applied on the Mac. To check: + // in a reconnected remote window open Settings, search "tsserver.maxMemory", and confirm both + // the value AND its provenance ("Modified elsewhere"/User). Then, in a terminal on the remote, + // `pgrep -af tsserver.js` must show --max-old-space-size=768. If the Settings UI shows 768 but + // the process shows 3072, the window has not reloaded since the change. "js/ts.tsserver.maxMemory": 768, "typescript.disableAutomaticTypeAcquisition": true, // Reduce the git extension's background repository scanning. @@ -141,9 +182,22 @@ // // Pins extensions that do no workspace-filesystem or child-process work to the client (UI) // extension host instead of the remote one, so their code never runs on the remote at all -- - // the extension host is the tree's biggest, most volatile process (685-738 MiB PSS observed, - // climbing over time) precisely because of what it loads, so this is the "load less" lever - // rather than a heap ceiling. Each entry below was checked against the extension actually + // the extension host is the tree's biggest, most volatile process (528 -> 843 MiB PSS over + // three days on one live workspace, the hourly minimum climbing throughout) precisely because + // of what it loads. + // + // This is the "load less" lever, and it is the ONLY lever: unlike tsserver above, the + // extension host has no heap ceiling at any value. Established by reading the server bundle + // on disk rather than assumed -- it registers no heap-sizing setting, `server-main.js --help` + // exposes no memory option, no `remote.*` configuration property exists in the server bundle, + // and this build has no `server-env-setup` support. NODE_OPTIONS in particular does not work + // and would be actively bad: `server-main.js` deletes NODE_OPTIONS from the child environment + // immediately before every fork it performs, so setting it would cap the parent and every + // unrelated mise-node process in the pod while leaving the extension host at its default. + // (The one channel that does propagate is the parent's own `process.execArgv`, which the + // extension host inherits verbatim -- but the only injection point is a shell script inside + // the versioned `cli/servers/Stable-/` directory that VS Code replaces wholesale on + // every server upgrade, and the role rests above any share it could be given anyway.) Each entry below was checked against the extension actually // installed under ~/.vscode-server/extensions on a live workspace: no `main`-side use of // node's `fs`/`child_process`, no language-server client, no commands operating on files the // user hasn't opened. From 4a65879bf7f9320bb11d3e45130e23ba3c22a96a Mon Sep 17 00:00:00 2001 From: Peter Pathirana Date: Sat, 22 Aug 2026 19:08:11 +0000 Subject: [PATCH 3/3] docs(vscode): correct the claim that the TOML server has no heap lever No setting value changes. The rendered template is byte-identical to the previous commit's once comments are stripped (verified with `chezmoi execute-template`): 66 keys, js/ts.tsserver.maxMemory 768, four remote.extensionKind entries. The comment above js/ts.tsserver.maxMemory said the extension host, file watcher, pty host and the JSON/Markdown/TOML language servers "have none at any value". That is wrong for TOML, and wrong in the direction that stops someone looking again: tamasfe.even-better-toml exposes `evenBetterToml.taplo.environment` (an object setting passed as `options.env` to the forked bundled server, so a route to NODE_OPTIONS scoped to exactly one process) and `taplo.bundled: false` + `taplo.path` + `taplo.extraArgs` (binary and argv substitution outright). Both were read from the extension's own package.json and dist/extension.js on a live workspace, not from docs. The levers exist and are still not worth using, which is the part now recorded rather than assumed. Measured on the live TOML server process: 274 MiB PSS, of which ~99 MiB is native malloc `[heap]` and ~14 MiB is rwxp JIT code -- 41% that no V8 flag reaches at any value -- with the ~160 MiB remainder split between V8's managed heap and WASM linear memory in proportions smaps cannot separate. taplo is Rust compiled to WASM and its linear memory is an ArrayBuffer backing store outside the old space, so `--max-old-space-size` cannot bound this process below its 320 MiB share however low it is set. The claim is deliberately the weak one that smaps supports rather than a split it does not. Also records `js/ts.tsserver.useSyntaxServer: "never"` as the one setting that would make the tsserver ceiling combined rather than per-process, and why it is deliberately not set: the pair rests at 112/141 MiB against a 320 MiB share, so there is nothing to buy, and collapsing them queues syntax-only work (folding, outline, in-file rename) behind semantic work. Rewraps one over-long comment line introduced by the previous commit. Ref: ppat/dotfiles#772 --- .../private_Code/User/settings.json.tmpl | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/private_Library/private_Application Support/private_Code/User/settings.json.tmpl b/private_Library/private_Application Support/private_Code/User/settings.json.tmpl index cf864081..0537f8ac 100644 --- a/private_Library/private_Application Support/private_Code/User/settings.json.tmpl +++ b/private_Library/private_Application Support/private_Code/User/settings.json.tmpl @@ -106,11 +106,29 @@ // macOS projects included, not just the remote workspace. // // Value. There is exactly one process class in the remote tree with a heap knob, and this is - // it; the extension host, file watcher, pty host and the JSON/Markdown/TOML language servers - // have none at any value (see remote.extensionKind below, which is the only lever that - // shrinks the extension host). TypeScript spawns *two* processes that each read this setting - // independently -- a full semantic server and a lighter "partialSemantic" one -- so it is a - // per-process ceiling, not a combined one. + // it. The extension host, file watcher, pty host and the JSON/Markdown language servers expose + // nothing at any value (see remote.extensionKind below, which is the only lever that shrinks + // the extension host). The TOML server is the one exception to that, and it is named here + // because the exception turns out to be useless rather than absent: tamasfe.even-better-toml + // does expose levers -- `evenBetterToml.taplo.environment` injects arbitrary env into the + // forked bundled server, and `taplo.bundled: false` + `taplo.path` substitutes the binary + // outright -- but nothing reachable through them bounds what actually grows there. Measured + // on a live workspace, that process's 274 MiB PSS is ~99 MiB of native malloc `[heap]` plus + // ~14 MiB of rwxp JIT code -- 41% of the process that no V8 flag can reach at any value -- + // with the ~160 MiB remainder split between V8's managed heap and WASM linear memory in + // proportions smaps cannot separate (taplo is Rust compiled to WASM, whose linear memory is + // an ArrayBuffer backing store outside the old space). Since `--max-old-space-size` bounds + // only the old space, a ceiling there cannot bound this process below its share however low + // it is set: NODE_OPTIONS by that route is inert, not merely leaky. Recorded so this does + // not have to be re-derived from a blanket "no lever exists" claim. + // + // TypeScript spawns *two* processes that each read this setting independently -- a full + // semantic server and a lighter "partialSemantic" one -- so it is a per-process ceiling, not + // a combined one. `js/ts.tsserver.useSyntaxServer: "never"` would collapse the pair into a + // single process and is the only way to make the ceiling combined; deliberately not set, + // because the two rest at 112/141 MiB against a 320 MiB share so there is nothing to buy, + // and the cost is that syntax-only work (folding, outline, in-file rename) then queues behind + // semantic work. // // 768 is deliberately NOT low enough to fire before the workspace template's memory watchdog, // and that ordering is a decision rather than an oversight. The watchdog gives tsserver a @@ -197,8 +215,10 @@ // (The one channel that does propagate is the parent's own `process.execArgv`, which the // extension host inherits verbatim -- but the only injection point is a shell script inside // the versioned `cli/servers/Stable-/` directory that VS Code replaces wholesale on - // every server upgrade, and the role rests above any share it could be given anyway.) Each entry below was checked against the extension actually - // installed under ~/.vscode-server/extensions on a live workspace: no `main`-side use of + // every server upgrade, and the role rests above any share it could be given anyway.) + // + // Each entry below was checked against the extension actually installed under + // ~/.vscode-server/extensions on a live workspace: no `main`-side use of // node's `fs`/`child_process`, no language-server client, no commands operating on files the // user hasn't opened. // - vscode-icons-team.vscode-icons, bierner.markdown-mermaid: pre-existing, unchanged.