CancellablePromise constructor unconditionally captures new Error().stack — heap leak at ~6 MB/hr on systems with active tray icons
Component: promiseUtils.js line 25
Versions: Ubuntu 26.04, GNOME Shell 50.1, ubuntu-appindicators@ubuntu.com (system extension)
Severity: Medium — silent memory leak in long-running sessions.
Symptom
gnome-shell [heap] grows at approximately 6 MB/hr on a session with active SNI tray icons (Discord, Signal, Dropbox, etc.). After 8 days uptime, heap is ~1.7 GB. Heap monitor shows steady growth in GObject_Object, Function, Call, and INVALID counts at ~7,000/hr each.
Root cause
promiseUtils.js line 25 in CancellablePromise.constructor:
const {stack: promiseStack} = new Error();
this._promiseStack = promiseStack;
The captured stack is only read on the cancellation path (promiseUtils.js:119):
error.stack += `## Promise created at:\n${this._promiseStack}`;
That is — the constructor allocates an ~800-byte stack string on every CancellablePromise instance, and the value is read only when the promise is cancelled. For the steady state where signals are processed normally and promises resolve, the stack is created but never consumed.
Why this is hot
appIndicator.js:370 creates a fresh PromiseUtils.TimeoutPromise per signal batch:
this._propertiesEmitTimeout = new PromiseUtils.TimeoutPromise(
MAX_UPDATE_FREQUENCY * 2, GLib.PRIORITY_DEFAULT_IDLE, params.cancellable);
Each TimeoutPromise wraps a GSourcePromise which wraps a CancellablePromise. With active SNI clients emitting property changes, this chain is allocated continuously. Each allocation creates an Error object (the call frame objects) and a stringified stack trace.
Stack trace from a real heap dump:
CancellablePromise@file:///usr/share/gnome-shell/extensions/ubuntu-appindicators@ubuntu.com/promiseUtils.js:25:39
GSourcePromise@file:///usr/share/gnome-shell/extensions/ubuntu-appindicators@ubuntu.com/promiseUtils.js:208:9
TimeoutPromise@file:///usr/share/gnome-shell/extensions/ubuntu-appindicators@ubuntu.com/promiseUtils.js:263:9
_queuePropertyUpdate@file:///usr/share/gnome-shell/extensions/ubuntu-appindicators@ubuntu.com/appIndicator.js:370:43
_onSignalAsync@file:///usr/share/gnome-shell/extensions/ubuntu-appindicators@ubuntu.com/appIndicator.js:241:28
_onSignal@file:///usr/share/gnome-shell/extensions/ubuntu-appindicators@ubuntu.com/appIndicator.js:218:14
_init/<@file:///usr/share/gnome-shell/extensions/ubuntu-appindicators@ubuntu.com/dbusProxy.js:40:43
@resource:///org/gnome/shell/ui/init.js:20:20
Suggested fix
Make the stack capture lazy or opt-in. Two options:
Option A — env-gated (matches the existing public API):
// PromiseUtils.js line 25
const _enableStack = process.env.APPINDICATOR_DEBUG_PROMISES === '1';
const {stack: promiseStack} = _enableStack ? new Error() : { stack: '' };
this._promiseStack = promiseStack;
Option B — lazy via getter, only allocated when the cancellation path actually reads it:
// remove line 25-26 entirely
get _promiseStack() {
if (!this._cachedStack) {
this._cachedStack = (new Error()).stack;
}
return this._cachedStack;
}
Option B is preferable because the stack is genuinely only needed on cancellation, and Error() creation is cheap. Even a fresh allocation per cancellation is acceptable since cancellations are rare.
Patch already applied locally
I have patched this on my machine at /home/julian/.local/share/gnome-shell/extensions/ubuntu-appindicators@ubuntu.com/promiseUtils.js and /usr/share/gnome-shell/extensions/ubuntu-appindicators@ubuntu.com/promiseUtils.js with Option A. Verified after re-login that the heap growth rate dropped to normal churn.
Verification
Heap dumps via js::DumpHeap over 12 minutes, five samples, 3-min intervals:
| Type |
Δ in 12 min |
Rate per hour |
| GObject_Object |
+1391 |
6955/hr |
| Function |
+1555 |
7775/hr |
| Call |
+1360 |
6800/hr |
| INVALID |
+1190 |
5950/hr |
The lockstep growth is the SpiderMonkey signature of a closure capturing GObject wrappers per allocation. The proposing patch removes the always-on allocation.
Related
Both look like the same underlying pattern: always-on allocation in a hot path.
CancellablePromise constructor unconditionally captures
new Error().stack— heap leak at ~6 MB/hr on systems with active tray iconsComponent:
promiseUtils.jsline 25Versions: Ubuntu 26.04, GNOME Shell 50.1,
ubuntu-appindicators@ubuntu.com(system extension)Severity: Medium — silent memory leak in long-running sessions.
Symptom
gnome-shell[heap]grows at approximately 6 MB/hr on a session with active SNI tray icons (Discord, Signal, Dropbox, etc.). After 8 days uptime, heap is ~1.7 GB. Heap monitor shows steady growth inGObject_Object,Function,Call, andINVALIDcounts at ~7,000/hr each.Root cause
promiseUtils.jsline 25 inCancellablePromise.constructor:The captured stack is only read on the cancellation path (
promiseUtils.js:119):That is — the constructor allocates an ~800-byte stack string on every
CancellablePromiseinstance, and the value is read only when the promise is cancelled. For the steady state where signals are processed normally and promises resolve, the stack is created but never consumed.Why this is hot
appIndicator.js:370creates a freshPromiseUtils.TimeoutPromiseper signal batch:Each
TimeoutPromisewraps aGSourcePromisewhich wraps aCancellablePromise. With active SNI clients emitting property changes, this chain is allocated continuously. Each allocation creates anErrorobject (the call frame objects) and a stringified stack trace.Stack trace from a real heap dump:
Suggested fix
Make the stack capture lazy or opt-in. Two options:
Option A — env-gated (matches the existing public API):
Option B — lazy via getter, only allocated when the cancellation path actually reads it:
Option B is preferable because the stack is genuinely only needed on cancellation, and
Error()creation is cheap. Even a fresh allocation per cancellation is acceptable since cancellations are rare.Patch already applied locally
I have patched this on my machine at
/home/julian/.local/share/gnome-shell/extensions/ubuntu-appindicators@ubuntu.com/promiseUtils.jsand/usr/share/gnome-shell/extensions/ubuntu-appindicators@ubuntu.com/promiseUtils.jswith Option A. Verified after re-login that the heap growth rate dropped to normal churn.Verification
Heap dumps via
js::DumpHeapover 12 minutes, five samples, 3-min intervals:The lockstep growth is the SpiderMonkey signature of a closure capturing GObject wrappers per allocation. The proposing patch removes the always-on allocation.
Related
addIconToPanelre-registers its ownchanged::tray-poshandler on every call — handler count doubles per settings change, wedging gnome-shell #641 —addIconToPanelre-registers handler, similar leak patterndbusMenu.jslog storm causing severe shell lagBoth look like the same underlying pattern: always-on allocation in a hot path.