Skip to content

[LSP] Memory of configured projects is never released, even after didClose of all files — ~70 MB retained per project (7.0.2 and 7.1.0-dev.20260926.1) #64463

Description

Versions: reproduced on both typescript@7.1.0-dev.20260926.1 (typescript@next) and typescript@7.0.2, native tsc --lsp -stdio (@typescript/typescript-darwin-arm64), on macOS 27 (Darwin 27.0.0), Apple Silicon, 18 GB RAM.

What happens

Opening one .ts file in each of N copies of a project creates N configured projects, one per tsconfig.json. Memory grows by about 70 MB per project, and none of it is released:

  • not after idling;
  • not after textDocument/didClose of every file.

In a real 37-hour editing session the server reached a 7060 MB footprint. The client had opened files in ~70 copies of the same repository (clones used by parallel agents). The machine went into heavy swap. Restarting the server brought it back to 258 MB.

Reproduction (standalone client, no editor)

  1. Take a mid-size TS repo with node_modules installed and make 12 copies side by side (cp -cR). Each copy has its own tsconfig.json.
  2. Run the script below: node lsp-probe.mjs <path to tsc> <copies dir> 12.
    • The script sends initialize, then for each copy didOpen of one file plus a hover, recording the server's phys_footprint (macOS footprint <pid>) after each.
    • It then idles 60 s, sends didClose for every file, idles 60 s again, and measures.
step 7.1.0-dev.20260926.1 7.0.2
after initialize 9 MB 9 MB
1 project open 110 MB 118 MB
6 projects open 442 MB 480 MB
12 projects open 853 MB 887 MB
60 s idle, all open 856 MB 889 MB
60 s after didClose of all 12 files 857 MB 890 MB

Note for anyone re-measuring on macOS: RSS (ps -o rss) is misleading here. Under memory pressure the kernel compresses the server's pages, and RSS stops counting them, which makes the server look like it released memory when it did not. Use footprint <pid> or the MEM column of top.

Expected

Once no file of a configured project is open (after didClose), the project and its program are released and the memory goes back. Alternatively, retained projects are capped by an LRU or similar.

Script

// lsp-probe.mjs — usage: node lsp-probe.mjs <tscBinary> <copiesDir> <N>
// (copies are <copiesDir>/c1..cN, each with pipeline/cards.ts — adjust the file path)
// Minimal LSP client: open one .ts file in each project copy, hover, measure server RSS;
// then didClose all and measure again after idle. Usage: node lsp-probe.mjs <tscBinary> <copiesDir> <N>
import { spawn, execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
const [bin, dir, nStr] = process.argv.slice(2)
const N = Number(nStr)
const srv = spawn(bin, ['--lsp', '-stdio'], { stdio: ['pipe', 'pipe', 'ignore'] })
let buf = Buffer.alloc(0); const waiters = new Map(); let id = 0
srv.stdout.on('data', (d) => {
  buf = Buffer.concat([buf, d])
  for (;;) {
    const h = buf.indexOf('\r\n\r\n'); if (h < 0) return
    const len = Number(/Content-Length: (\d+)/i.exec(buf.slice(0, h).toString())[1])
    if (buf.length < h + 4 + len) return
    const msg = JSON.parse(buf.slice(h + 4, h + 4 + len).toString()); buf = buf.slice(h + 4 + len)
    if (msg.id !== undefined && waiters.has(msg.id) && msg.method === undefined) { waiters.get(msg.id)(msg); waiters.delete(msg.id) }
    else if (msg.id !== undefined && msg.method) send({ jsonrpc: '2.0', id: msg.id, result: null })
  }
})
const send = (m) => { const s = JSON.stringify(m); srv.stdin.write(`Content-Length: ${Buffer.byteLength(s)}\r\n\r\n${s}`) }
const req = (method, params) => new Promise((r) => { const i = ++id; waiters.set(i, r); send({ jsonrpc: '2.0', id: i, method, params }) })
const note = (method, params) => send({ jsonrpc: '2.0', method, params })
// phys_footprint, not RSS: under memory pressure macOS compresses pages and RSS stops counting them.
const rss = () => { const t = execFileSync('footprint', [String(srv.pid)]).toString(); const m = /Footprint: ([\d.]+) (KB|MB|GB)/.exec(t); const v = Number(m[1]); return Math.round(m[2] === 'GB' ? v * 1024 : m[2] === 'KB' ? v / 1024 : v) }
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
await req('initialize', { processId: process.pid, rootUri: null, capabilities: {}, workspaceFolders: null })
note('initialized', {})
const out = [`start ${rss()} MB`]
const uris = []
for (let i = 1; i <= N; i++) {
  const file = `${dir}/c${i}/pipeline/cards.ts`; const uri = pathToFileURL(file).href; uris.push(uri)
  note('textDocument/didOpen', { textDocument: { uri, languageId: 'typescript', version: 1, text: readFileSync(file, 'utf8') } })
  const r = await req('textDocument/hover', { textDocument: { uri }, position: { line: 227, character: 15 } })
  out.push(`open ${i}: ${rss()} MB${r.result ? '' : ' (no hover)'}`)
}
await sleep(60000); out.push(`idle 60 s with all open: ${rss()} MB`)
for (const uri of uris) note('textDocument/didClose', { textDocument: { uri } })
await sleep(60000); out.push(`60 s after didClose of all: ${rss()} MB`)
console.log(out.join('\n')); srv.kill(); process.exit(0)

Notes

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions