Skip to content

Commit 21f7390

Browse files
ZviBaratzclaude
andauthored
fix(ego-lint): enhance GSettings signal leak detection for helper classes (#87)
## Summary - Change R-LIFE-21 auto-cleanup detection from extension-wide to **per-file** - Previously, `connectObject`/`disconnectObject` in any file (e.g., for widget signals) gave a free pass to bare `.connect('changed::...')` calls in all other files - Now auto-cleanup in one file does not suppress findings in other files, because `connectObject`/`disconnectObject` manage signals per-object-per-owner **False negatives fixed:** - media-controls: 28 bare GSettings handlers now detected (were missed because `MenuSlider.js` used `connectObject` for widget signals) - just-perfection: 1 bare handler in `SupportNotifier.js` now detected (was missed because `Manager.js` used `settings.connectObject()`) Closes #78 ## Test plan - [x] New fixture `gsettings-cross-file-leak@test` — bare connects in `extension.js` with `connectObject` only in `helper.js` → FAIL - [x] Existing fixtures unchanged: `gsettings-bare-connect@test` (FAIL), `gsettings-auto-cleanup@test` (PASS), `gsettings-array-storage@test` (no FP) - [x] All 610 test assertions pass - [x] Verified against field test cache: media-controls (28 hits), just-perfection (1 hit), dash-to-panel (no regression) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 30bbeac commit 21f7390

File tree

7 files changed

+69
-13
lines changed

7 files changed

+69
-13
lines changed

skills/ego-lint/references/rules-reference.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2113,12 +2113,13 @@ Rules for APIs removed or changed in specific GNOME Shell versions. These rules
21132113
### R-LIFE-21: GSettings signal leak (bare connect without disconnect)
21142114
- **Severity**: blocking
21152115
- **Checked by**: check-lifecycle.py
2116-
- **Rule**: `settings.connect('changed::...')` calls where the return value is not stored (no `=` or `.push()` before `.connect` on the line) and no auto-cleanup mechanism (`disconnectObject`, `connectObject`, `SignalTracker`, `connectSmart`) is present in the extension.
2116+
- **Rule**: `settings.connect('changed::...')` calls where the return value is not stored (no `=` or `.push()` before `.connect` on the line) and no auto-cleanup mechanism (`disconnectObject`, `connectObject`, `SignalTracker`, `connectSmart`) is present in the **same file**.
21172117
- **Rationale**: Without a stored signal ID, there is no way to call `.disconnect(id)` later. These handlers accumulate across enable/disable cycles, causing duplicate callbacks, memory leaks, and potential crashes. This is the highest-impact gap found in field testing — 4 of 10 extensions had this issue.
21182118
- **Fix**: Use `connectObject()` (recommended — auto-disconnects via `disconnectObject(this)` in disable) or store the return value and call `disconnect(id)` in `disable()`.
21192119
- **Scope exclusions**: prefs.js (manages own lifecycle), `service/` directory (daemon lifecycle).
2120+
- **Auto-cleanup detection**: Per-file (not extension-wide). `connectObject`/`disconnectObject` in a helper file does not suppress bare `.connect()` findings in other files, because signal ownership is per-object-per-owner.
21202121
- **Known limitations**: Multi-line `.connect(\n'changed::...'` calls are not detected (per-line scanning).
2121-
- **Tested by**: `tests/fixtures/gsettings-bare-connect@test/`, `tests/fixtures/gsettings-auto-cleanup@test/`, `tests/fixtures/gsettings-array-storage@test/`
2122+
- **Tested by**: `tests/fixtures/gsettings-bare-connect@test/`, `tests/fixtures/gsettings-auto-cleanup@test/`, `tests/fixtures/gsettings-array-storage@test/`, `tests/fixtures/gsettings-cross-file-leak@test/`
21222123
21232124
### R-LIFE-23: .destroy without parentheses
21242125
- **Severity**: advisory

skills/ego-lint/scripts/check-lifecycle.py

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1229,20 +1229,21 @@ def check_gsettings_signal_leak(ext_dir):
12291229
return
12301230

12311231
bare_connects = []
1232-
has_auto_cleanup = False
1232+
has_auto_cleanup_bare = False
12331233

12341234
for filepath in js_files:
12351235
content = strip_comments(read_file(filepath))
12361236
rel = os.path.relpath(filepath, ext_dir)
12371237

1238-
# Extension-wide (not per-file) auto-cleanup detection: connectObject/
1239-
# disconnectObject typically operate on `this`, so a central disable()
1240-
# calling disconnectObject(this) cleans signals registered from any file.
1241-
if (re.search(r'\.disconnectObject\s*\(', content) or
1242-
re.search(r'\.connectObject\s*\(', content) or
1243-
re.search(r'\b(SignalTracker|SignalManager|connectSmart|disconnectSmart)\b', content)):
1244-
has_auto_cleanup = True
1238+
# Per-file auto-cleanup detection: connectObject/disconnectObject
1239+
# manage signals per-object-per-owner, so auto-cleanup in one file
1240+
# does NOT protect bare .connect() calls in a different file.
1241+
file_has_auto_cleanup = bool(
1242+
re.search(r'\.disconnectObject\s*\(', content) or
1243+
re.search(r'\.connectObject\s*\(', content) or
1244+
re.search(r'\b(SignalTracker|SignalManager|connectSmart|disconnectSmart)\b', content))
12451245

1246+
file_bare_connects = []
12461247
prev_stripped = ''
12471248
for lineno, line in enumerate(content.splitlines(), 1):
12481249
stripped = line.strip()
@@ -1267,17 +1268,23 @@ def check_gsettings_signal_leak(ext_dir):
12671268
if re.search(r'\.(connectObject|connectSmart)\s*\(', stripped):
12681269
prev_stripped = stripped
12691270
continue
1270-
bare_connects.append(f"{rel}:{lineno}")
1271+
file_bare_connects.append(f"{rel}:{lineno}")
12711272
prev_stripped = stripped
12721273

1273-
if bare_connects and not has_auto_cleanup:
1274+
if file_bare_connects:
1275+
if file_has_auto_cleanup:
1276+
has_auto_cleanup_bare = True
1277+
else:
1278+
bare_connects.extend(file_bare_connects)
1279+
1280+
if bare_connects:
12741281
count = len(bare_connects)
12751282
locs = ', '.join(bare_connects[:5])
12761283
suffix = f' (and {count - 5} more)' if count > 5 else ''
12771284
result("FAIL", "lifecycle/gsettings-signal-leak",
12781285
f"{count} bare settings.connect('changed::...') without stored ID "
12791286
f"and no disconnectObject/connectObject cleanup: {locs}{suffix}")
1280-
elif bare_connects:
1287+
elif has_auto_cleanup_bare:
12811288
result("PASS", "lifecycle/gsettings-signal-leak",
12821289
"Bare settings.connect() found but auto-cleanup mechanism present")
12831290
# If no bare connects, skip silently

tests/assertions/gsettings-signal-leak.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,9 @@ run_lint "gsettings-array-storage@test"
1919
assert_exit_code "exits with 0 (array storage is not bare connect)" 0
2020
assert_output_not_contains "no gsettings-signal-leak FP on array storage" "\[FAIL\].*lifecycle/gsettings-signal-leak"
2121
echo ""
22+
23+
echo "=== gsettings-cross-file-leak ==="
24+
run_lint "gsettings-cross-file-leak@test"
25+
assert_exit_code "exits with 1 (cross-file leak detected)" 1
26+
assert_output_contains "fails on cross-file bare GSettings connect" "\[FAIL\].*lifecycle/gsettings-signal-leak"
27+
echo ""
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
SPDX-License-Identifier: GPL-2.0-or-later
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
2+
3+
export default class CrossFileLeakExtension extends Extension {
4+
enable() {
5+
this._settings = this.getSettings();
6+
this._settings.connect('changed::interval', () => this._onChanged());
7+
this._settings.connect('changed::mode', () => this._onMode());
8+
}
9+
10+
_onChanged() {}
11+
_onMode() {}
12+
13+
disable() {
14+
this._settings = null;
15+
}
16+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import St from 'gi://St';
2+
3+
export class SliderHelper {
4+
constructor() {
5+
this._slider = new St.Slider();
6+
this._slider.connectObject(
7+
'notify::value', () => this._onValueChanged(),
8+
this
9+
);
10+
}
11+
12+
_onValueChanged() {}
13+
14+
destroy() {
15+
this._slider.disconnectObject(this);
16+
this._slider = null;
17+
}
18+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"uuid": "gsettings-cross-file-leak@test",
3+
"name": "GSettings Cross-File Leak Test",
4+
"description": "Tests that auto-cleanup in one file does not suppress bare connect in another",
5+
"shell-version": ["47"],
6+
"url": ""
7+
}

0 commit comments

Comments
 (0)