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)
- 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.
- 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
Versions: reproduced on both
typescript@7.1.0-dev.20260926.1(typescript@next) andtypescript@7.0.2, nativetsc --lsp -stdio(@typescript/typescript-darwin-arm64), on macOS 27 (Darwin 27.0.0), Apple Silicon, 18 GB RAM.What happens
Opening one
.tsfile in each of N copies of a project creates N configured projects, one pertsconfig.json. Memory grows by about 70 MB per project, and none of it is released:textDocument/didCloseof 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)
node_modulesinstalled and make 12 copies side by side (cp -cR). Each copy has its owntsconfig.json.node lsp-probe.mjs <path to tsc> <copies dir> 12.initialize, then for each copydidOpenof one file plus ahover, recording the server's phys_footprint (macOSfootprint <pid>) after each.didClosefor every file, idles 60 s again, and measures.initializedidCloseof all 12 filesNote 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. Usefootprint <pid>or theMEMcolumn oftop.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
Notes
typescript-go#4520(long-lived native LSP sessions growing without bound), moved to Bug Report: Severe memory leak triggered by "TypeScript (Native Preview)" extension #63849 and closed on 26.08 with a request for a fresh report on the latest build; this is that report.tsc --lsp.