Skip to content

CancellablePromise constructor unconditionally captures new Error().stack — heap leak at ~6 MB/hr #645

Description

@KatsuJinCode

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.

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