Skip to content

Fix main process OOM from unconsumed buffered pty host service events - #323980

Merged
Anthony Kim (anthonykim1) merged 9 commits into
microsoft:mainfrom
jacobjove:fix/pty-host-channel-event-buffer-leak
Sep 14, 2026
Merged

Anthony Kim (anthonykim1) merged 9 commits into
microsoft:mainfrom
jacobjove:fix/pty-host-channel-event-buffer-leak

Conversation

@jacobjove

@jacobjove Jacob T. Jove (jacobjove) commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #234393. The crash dumps, heap snapshots, and retainer chains referenced below are in #307156, which was closed as a duplicate of it.

Problem

The main process accumulates all terminal PTY output in the V8 heap until it hits the ~4 GB heap ceiling and OOM-aborts (Reached heap limit). Heavy ANSI producers in the integrated terminal (tmux status/pane redraws, TUI apps) crash the main process within hours; median ~4.2h across the 99 crash dumps in #307156.

Root cause

ProxyChannel.fromService eagerly wraps every service event in Event.buffer(..., flushAfterTimeout=true), which subscribes to the source immediately and queues each event into an array that is only flushed (and released) when a first listener attaches to the channel event.

The main process registers ILocalPtyService this way (app.ts) as the TerminalIpcChannels.LocalPty channel. On desktop, the renderer consumes terminal data over the dedicated pty host connection (PtyHostWindow MessagePort, see localTerminalBackend.ts); the LocalPty channel itself is only used for two method calls (getLatency, getProfiles) plus a handful of low-frequency pty-host lifecycle events (onPtyHostStart/onPtyHostExit/onPtyHostUnresponsive/onPtyHostResponsive/onPtyHostRequestResolveVariables, consumed by BaseTerminalBackend) — it never gets a listener for the high-volume onProcessData event. So the eager buffer for onProcessData (one entry per PTY data chunk) grows for the lifetime of the process. Retainer chains in heap snapshots (see #307156) show ~99% of the heap held by exactly this array: Emitter._listeners → closure → Array[N] → { event: }.

Fix

Adds a disableEventBuffering option to ProxyChannel.ICreateServiceChannelOptions (naming follows the existing disableMarshalling) that exposes service events on the channel without the eager Event.buffer wrapper, and applies it to the LocalPty channel registration. Events fired before a listener attaches are dropped instead of retained. This is a whole-channel opt-out, so it also stops buffering the pty-host lifecycle events BaseTerminalBackend listens to here; in practice that's low-risk (those fire only after a window's listener has attached, and the one early-fireable case — the initial onPtyHostStart — is a no-op per BaseTerminalBackend's own hasStarted guard, see review discussion below). Per Dmitriy Vasyura (@dmitrivMS)'s plan, this PR ships as the accepted stop-gap; a follow-up telemetry pass will confirm whether any channel actually needs buffering restored before removing it from fromService generally.

Testing

  • New unit tests in ipc.test.ts cover the default (eager subscription, buffered) and the new option (no subscription until listen, events delivered after).
  • Full node unit suite passes (11,908 passing).
  • A/B with the repro from the issue (ANSI-flood script in the integrated terminal, fresh profile, --disable-extensions, macOS arm64), measuring main-process RSS every 15 s on the same dev build with only this change toggled:
Configuration Main-process RSS under flood
Without the fix 191 MB → 1,750 MB in 8.5 min (~180 MB/min, monotonic); at that rate the ~4 GB ceiling lands in under 30 min
With the fix flat at ~250–330 MB over 9+ min

…microsoft#307156)

ProxyChannel.fromService eagerly wraps every service event in
Event.buffer, which subscribes immediately and retains every event
until a first listener attaches. The LocalPty channel's events are
never listened to on desktop (terminal data flows over the dedicated
pty host connection), so every pty data chunk was retained in the main
process heap for the lifetime of the process, eventually reaching V8's
heap ceiling and crashing with OOM.

Adds a disableEventBuffering option to ProxyChannel and applies it to
the LocalPty channel registration.

Co-Authored-By: Claude Fable 5 <[email protected]>
Copilot AI review requested due to automatic review settings July 2, 2026 03:08
@vs-code-engineering

Copy link
Copy Markdown
Contributor

📬 CODENOTIFY

The following users are being notified based on files changed in this PR:

Robo (@deepak1556)

Matched files:

  • src/vs/code/electron-main/app.ts

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses a main-process memory leak/OOM in the desktop terminal IPC path by allowing ProxyChannel.fromService to expose service events without eager Event.buffer(...) wrapping, and applying that behavior to the LocalPty channel where events are never listened to on desktop.

Changes:

  • Add disableEventBuffering to ProxyChannel.ICreateServiceChannelOptions to avoid eager subscription + buffering of service events.
  • Use disableEventBuffering: true when registering the TerminalIpcChannels.LocalPty channel in the electron-main process.
  • Add IPC unit tests covering eager subscription by default vs. the new no-buffering mode.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
src/vs/code/electron-main/app.ts Registers ILocalPtyService channel with event buffering disabled to prevent unbounded heap growth.
src/vs/base/parts/ipc/common/ipc.ts Introduces disableEventBuffering option and routes event creation through it.
src/vs/base/parts/ipc/test/common/ipc.test.ts Adds unit tests for default eager subscription vs. disabled buffering behavior.

Comment thread src/vs/base/parts/ipc/test/common/ipc.test.ts Outdated
Comment thread src/vs/code/electron-main/app.ts Outdated
Comment thread src/vs/base/parts/ipc/common/ipc.ts
@jacobjove

Copy link
Copy Markdown
Contributor Author

@microsoft-github-policy-service agree

… test, tighten comments

Co-Authored-By: Claude Fable 5 <[email protected]>
@deepak1556

Copy link
Copy Markdown
Collaborator

Previous take #285419

cc Anthony Kim (@anthonykim1)

@dmitrivMS

Dmitriy Vasyura (dmitrivMS) commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

From my understanding of the context, the recommendation from Alexandru Dima (@alexdima) and others was to remove the buffering in this place completely since it's not really a reliable mechanism, and add an opt-in mechanism if necessary. The problem as I see it is we don't know with certainty what scenarios rely on this buffering today.

I suggest the following plan:

  1. Let this change go in (as a stop-gap for the OOM)
  2. Add telemetry to track where we see listeners come in and buffer is not empty
  3. Let it run for a week in Stable
  4. With that data, remove buffering in fromService while opting in the places identified by telemetry
  5. The previous step would revert this change and telemetry as obsolete

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread src/vs/code/electron-main/app.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread src/vs/code/electron-main/app.ts Outdated
Co-authored-by: Copilot Autofix powered by AI <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread src/vs/code/electron-main/app.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread src/vs/base/parts/ipc/common/ipc.ts Outdated
@jeremyprz

Copy link
Copy Markdown

A way for others to check this themselves, on any build

Several people in this thread are reporting the crash but can only offer RSS or crash dumps, which is the hard way to tell "fixed" from "quiet afternoon". Here is a zero-dependency check that counts the leak's own signature instead:

https://gist.github.com/jeremyprz/0f4a353efaf8de58fb32fdc8d1ce7f9b

$ vscode-pty-leak-check.py main-A.heapsnapshot main-B.heapsnapshot
snapshot                          pty chunks   retained    strings
main-A.heapsnapshot                      322       4.8M     98,357
main-B.heapsnapshot                   27,219     430.5M    125,468

LEAKING - 27,219 raw pty chunks retained (430.5 MB) in the main process.

  growth across the pair: +26,897 chunks (+425.7 MB)

That is an unpatched 1.137.0 main, ten minutes apart, under ordinary use — +26,897 retained pty chunks, +425.7 MB. The same pair of snapshots taken after unbuffering onProcessData reports 0 and 0.

It counts strings ≥ 4 KB containing an ANSI escape — the raw pty chunks themselves — so an idle-terminal snapshot and a fixed build no longer look alike, and the output says so explicitly when the count is zero. Pure stdlib, takes .heapsnapshot files exported from DevTools, and the header explains how to get one off main (inspector applies at launch only; kill -USR1 terminates Electron rather than enabling it).

One thing I took out rather than shipped, since it bears on how much to trust any of this: it originally also looked for onProcessData among the snapshot's node names to confirm the retainer chain. On real snapshots that count came out identical whether the process was leaking or clean — what matches is the property name, which is present either way. A number that does not move with the thing being measured is worse than no number, so the chunk count is the only verdict it gives.

Might be useful for anyone who wants to confirm the fix on their own machine once this lands, or to tell whether a crash they are seeing is this bug or a different one.

@bcmills

Bryan C. Mills (bcmills) commented Sep 12, 2026

Copy link
Copy Markdown

I don't think adding more AI slop to the discussion on this PR is really helping anything. 😅

AFAICT this is just waiting for someone at MSFT (Anthony Kim (@anthonykim1) or Dmitriy Vasyura (@dmitrivMS)?) to look at it again.

@jeremyprz

Copy link
Copy Markdown

Had anyone supplied a one-click workaround? There is now a one-click workaround for those who are impacted. Call it slop if you want - but it has mitigated my hourly annoyance that has plagued me for 1 year.

https://gist.github.com/jeremyprz/0f4a353efaf8de58fb32fdc8d1ce7f9b

Reuse unbufferedEvents for unused process events while preserving lifecycle notifications. Cover lazy subscriptions, restart notifications, direct replay completion, and ordered output across reload and reattachment.

Co-authored-by: Copilot <[email protected]>
@anthonykim1

Anthony Kim (anthonykim1) commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Updated this in f63bfed7c4f to reuse unbufferedEvents instead of adding a whole-channel opt-out.

For local terminals, the renderer receives output and replay directly from the PTY host through PtyHostWindow. Main was also buffering those process events on LocalPty, but nothing subscribed to them there, so the buffers kept growing.

This only removes buffering for the unused process events. Host lifecycle and variable-resolution events stay buffered, and the renderer’s direct output/replay path is unchanged.

Tests) Added tests for selective buffering, restart notifications, and replay completion after renderer writes. The new reload and detach/attach smoke tests check that all 100 numbered output lines arrive exactly once and in order. Also verified terminal recovery after PTY-host and application restarts locally on macOS.

TLDR:

Uses unbufferedEvents for unused LocalPty process events: onProcessData, onProcessReady, onProcessExit, onProcessReplay, onDidChangeProperty, onProcessOrphanQuestion, and onDidRequestDetach.

Keeps buffering for host lifecycle and variable-resolution events: onPtyHostStart, onPtyHostExit, onPtyHostUnresponsive, onPtyHostResponsive, and onPtyHostRequestResolveVariables.

@dmitrivMS Dmitriy Vasyura (dmitrivMS) added freeze-slow-crash-leak VS Code crashing, performance, freeze and memory leak issues terminal General terminal issues that don't fall under another label labels Sep 14, 2026
@dmitrivMS Dmitriy Vasyura (dmitrivMS) added this to the 1.139.0 milestone Sep 14, 2026
@anthonykim1
Anthony Kim (anthonykim1) merged commit f955049 into microsoft:main Sep 14, 2026
53 of 54 checks passed
@anthonykim1 Anthony Kim (anthonykim1) added terminal-process Problems launching processes, managing ptys, exiting, process leaks, etc. and removed terminal General terminal issues that don't fall under another label labels Sep 14, 2026
@anthonykim1

Anthony Kim (anthonykim1) commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Edit: This should be available in next upcoming stable 1.138.0 and the next upcoming vscode insiders.
Will post here once we have such insiders ready for people to test on, thanks everyone.

@dmitrivMS

Copy link
Copy Markdown
Collaborator

The is now in latest VS Code Insiders.

@dmitrivMS

Copy link
Copy Markdown
Collaborator

FYI - the fix will be included in the VS Code 1.138.0 stable release.

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

Labels

freeze-slow-crash-leak VS Code crashing, performance, freeze and memory leak issues release-cherry-pick Automated cherry-pick between release and main branches terminal-process Problems launching processes, managing ptys, exiting, process leaks, etc.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Codes Crashes after about 15 Minutes