Skip to content

Commit 10f6bbd

Browse files
ZviBaratzclaude
andauthored
feat(ego-lint): add lifecycle leak detection (R-LIFE-21, R-LIFE-22, R-LIFE-24) (#48)
## Summary - **R-LIFE-21** (blocking): Detect bare `settings.connect('changed::...')` without stored signal ID and no auto-cleanup mechanism (connectObject/SignalTracker). 4 of 10 field-tested extensions had this issue. - **R-LIFE-22** (blocking): Detect `global.stage.add_child()`, `addTopChrome()`, `addChrome()` without matching removal or `.destroy()` in disable/destroy. Supersedes R-LIFE-19 (advisory pattern rule) with smarter Tier 2 check that verifies cleanup actually exists. - **R-LIFE-24** (advisory): Detect `Main.messageTray.add()` without matching `.destroy()` in disable/destroy. ### Key design decisions - Only tracks `this._xxx` patterns (local variables are rare and impractical to track cross-scope) - `= null` is NOT valid cleanup for R-LIFE-22 (doesn't remove actor from stage) - `.destroy()` IS valid cleanup for R-LIFE-22 (destroying a Clutter actor removes it from parent) - FAIL for R-LIFE-22 (visible artifacts every cycle), WARN for R-LIFE-24 (lower impact) - Removes R-LIFE-19 pattern rule from patterns.yaml (superseded by R-LIFE-22) ### Field test results Clipboard Indicator now correctly triggers both R-LIFE-22 (FAIL for `_historyLabel` on global.stage) and R-LIFE-24 (WARN for `_notifSource` in messageTray). Closes #35, closes #36 ## Test plan - [x] `bash tests/run-tests.sh` — 553 assertions pass (was 549) - [x] `gsettings-bare-connect@test` triggers FAIL for R-LIFE-21 - [x] `stage-add-child@test` exits 1 with `[FAIL] lifecycle/stage-actor-leak` - [x] `stage-add-child-clean@test` exits 0 with `[PASS] lifecycle/stage-actor-leak` - [x] `messagetray-source-leak@test` exits 0 with `[WARN] lifecycle/messagetray-source-leak` - [x] Direct ego-lint on clipboard-indicator: R-LIFE-22 FAIL + R-LIFE-24 WARN, R-LIFE-19 absent 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6d906d6 commit 10f6bbd

File tree

22 files changed

+337
-25
lines changed

22 files changed

+337
-25
lines changed

field-tests/annotations/clipboard-indicator.yaml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,10 @@ findings:
7676
- id: "resource-tracking/ownership::orphan detected"
7777
classification: tp
7878
notes: "1 orphan detected — correct"
79-
# ego-review blocking issues
80-
- id: "lifecycle::_historyLabel on global.stage never removed"
79+
# stage/messageTray actor leaks (now detected by ego-lint)
80+
- id: "lifecycle/stage-actor-leak::this._historyLabel"
8181
classification: tp
82-
notes: "B1: Actor leak on every enable/disable cycle — ego-review finding"
83-
- id: "lifecycle::_notifSource never destroyed in disable()"
82+
notes: "B1: Actor leak on every enable/disable cycle — now detected by R-LIFE-22"
83+
- id: "lifecycle/messagetray-source-leak::this._notifSource"
8484
classification: tp
85-
notes: "B2: Notification source persists — ego-review finding"
85+
notes: "B2: Notification source persists — now detected by R-LIFE-24"

rules/patterns.yaml

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -790,14 +790,6 @@
790790
# --- Lifecycle (advisory) ---
791791
# Pattern-based lifecycle checks (complement script-based checks in check-lifecycle.py).
792792

793-
- id: R-LIFE-19
794-
pattern: "global\\.stage\\.add_child\\s*\\("
795-
scope: ["*.js"]
796-
severity: advisory
797-
message: "global.stage.add_child() — ensure matching remove_child() in disable()/destroy()"
798-
category: lifecycle
799-
fix: "In disable(), call global.stage.remove_child(this._actor); this._actor = null;"
800-
801793
# --- GNOME 44-46 migration (version-gated) ---
802794
# APIs removed or renamed in GNOME 44-46 transition to ESM.
803795

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

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2049,13 +2049,22 @@ Rules for APIs removed or changed in specific GNOME Shell versions. These rules
20492049
- **Fix**: Add `this._session.abort()` before nullifying the session reference in `disable()`.
20502050
- **Tested by**: `tests/fixtures/soup-session-no-abort@test/`
20512051

2052-
### R-LIFE-19: global.stage.add_child() without matching remove_child()
2052+
### R-LIFE-22: Stage/chrome actor leak (add without remove)
2053+
- **Severity**: blocking
2054+
- **Checked by**: check-lifecycle.py
2055+
- **Rule**: `global.stage.add_child()`, `Main.layoutManager.addTopChrome()`, and `Main.layoutManager.addChrome()` must have a matching removal (`remove_child`/`removeTopChrome`/`removeChrome`) or `.destroy()` in `disable()`/`destroy()`. Only tracks `this._xxx` patterns.
2056+
- **Rationale**: Actors added to `global.stage` or the layout manager chrome persist on the compositor stage across enable/disable cycles. Unlike container widgets that are destroyed with their parent, stage children must be explicitly removed or they leak — causing visible artifacts and memory growth. Nulling the reference (`this._actor = null`) does NOT remove the actor from the stage.
2057+
- **Fix**: In `disable()`, call `global.stage.remove_child(this._actor); this._actor.destroy(); this._actor = null;` or just `this._actor.destroy()` (destroying a Clutter actor removes it from its parent).
2058+
- **Tested by**: `tests/fixtures/stage-add-child@test/`, `tests/fixtures/stage-add-child-clean@test/` (negative)
2059+
- **Supersedes**: R-LIFE-19 (advisory pattern rule)
2060+
2061+
### R-LIFE-24: MessageTray source leak (add without destroy)
20532062
- **Severity**: advisory
2054-
- **Checked by**: apply-patterns.py
2055-
- **Rule**: `global.stage.add_child()` must have a matching `remove_child()` in `disable()` or `destroy()`.
2056-
- **Rationale**: Actors added to `global.stage` persist on the compositor stage across enable/disable cycles. Unlike container widgets that are destroyed with their parent, stage children must be explicitly removed or they leak — causing visual artifacts and memory growth.
2057-
- **Fix**: In `disable()`, call `global.stage.remove_child(this._actor); this._actor = null;`.
2058-
- **Tested by**: `tests/fixtures/stage-add-child@test/`
2063+
- **Checked by**: check-lifecycle.py
2064+
- **Rule**: `Main.messageTray.add(this._source)` must have a matching `this._source.destroy()` in `disable()`/`destroy()`. Only tracks `this._xxx` patterns.
2065+
- **Rationale**: Notification sources added to the message tray persist if not explicitly destroyed. While lower impact than stage actor leaks (notifications expire and sources may be GC'd), they can accumulate across enable/disable cycles.
2066+
- **Fix**: In `disable()`, call `this._source.destroy(); this._source = null;`.
2067+
- **Tested by**: `tests/fixtures/messagetray-source-leak@test/`
20592068
20602069
### R-LIFE-20: Bus name ownership without release
20612070
- **Severity**: advisory
@@ -2065,6 +2074,16 @@ Rules for APIs removed or changed in specific GNOME Shell versions. These rules
20652074
- **Fix**: In `disable()`, call `Gio.bus_unown_name(this._ownerId)`.
20662075
- **Tested by**: `tests/fixtures/bus-name-leak@test/`
20672076
2077+
### R-LIFE-21: GSettings signal leak (bare connect without disconnect)
2078+
- **Severity**: blocking
2079+
- **Checked by**: check-lifecycle.py
2080+
- **Rule**: `settings.connect('changed::...')` calls where the return value is not stored (no `=` before `.connect` on the line) and no auto-cleanup mechanism (`disconnectObject`, `connectObject`, `SignalTracker`, `connectSmart`) is present in the extension.
2081+
- **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.
2082+
- **Fix**: Use `connectObject()` (recommended — auto-disconnects via `disconnectObject(this)` in disable) or store the return value and call `disconnect(id)` in `disable()`.
2083+
- **Scope exclusions**: prefs.js (manages own lifecycle), `service/` directory (daemon lifecycle).
2084+
- **Known limitations**: Multi-line `.connect(\n'changed::...'` calls are not detected (per-line scanning). Signal IDs stored via `.push()` rather than direct assignment may false-positive.
2085+
- **Tested by**: `tests/fixtures/gsettings-bare-connect@test/`, `tests/fixtures/gsettings-auto-cleanup@test/`
2086+
20682087
---
20692088
20702089
## Security (R-SEC) — continued

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

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
- R-LIFE-17: Timeout ID reassignment without prior Source.remove()
2424
- R-LIFE-18: Subprocess without cancellation in disable/destroy
2525
- R-LIFE-20: Bus name ownership without release
26+
- R-LIFE-21: GSettings signal leak (bare connect without disconnect)
27+
- R-LIFE-22: Stage actor leak (add_child/addChrome without remove)
28+
- R-LIFE-24: MessageTray source leak (add without destroy)
2629
- R-SEC-16: Clipboard + keybinding cross-reference
2730
- R-FILE-07: Missing export default class
2831
@@ -1009,6 +1012,172 @@ def check_widget_lifecycle(ext_dir):
10091012
f"All {len(created_widgets)} widget(s) properly cleaned up")
10101013

10111014

1015+
def extract_cleanup_bodies(js_files):
1016+
"""Extract concatenated text of disable/destroy/onDestroy method bodies."""
1017+
cleanup_re = re.compile(
1018+
r'\b(?:disable|destroy|_destroy\w*|onDestroy)\s*\(')
1019+
cleanup_text = ''
1020+
for filepath in js_files:
1021+
content = strip_comments(read_file(filepath))
1022+
lines = content.splitlines()
1023+
i = 0
1024+
while i < len(lines):
1025+
if cleanup_re.search(lines[i]):
1026+
depth = 0
1027+
started = False
1028+
for j in range(i, len(lines)):
1029+
depth += lines[j].count('{') - lines[j].count('}')
1030+
if '{' in lines[j]:
1031+
started = True
1032+
cleanup_text += lines[j] + '\n'
1033+
if started and depth <= 0:
1034+
break
1035+
i = j + 1 if started else i + 1
1036+
else:
1037+
i += 1
1038+
return cleanup_text
1039+
1040+
1041+
def check_stage_actor_lifecycle(ext_dir):
1042+
"""R-LIFE-22: Stage actor add without matching remove in disable()/destroy()."""
1043+
js_files = find_js_files(ext_dir, exclude_prefs=True)
1044+
if not js_files:
1045+
return
1046+
1047+
# Patterns that add actors to stage/chrome
1048+
add_patterns = [
1049+
(r'global\.stage\.add_child\s*\(\s*this\.(_\w+)', 'global.stage.remove_child'),
1050+
(r'Main\.layoutManager\.addTopChrome\s*\(\s*this\.(_\w+)', 'removeTopChrome'),
1051+
(r'Main\.layoutManager\.addChrome\s*\(\s*this\.(_\w+)', 'removeChrome'),
1052+
]
1053+
1054+
added_actors = [] # (var_name, file_rel, lineno, remove_method)
1055+
1056+
for filepath in js_files:
1057+
content = strip_comments(read_file(filepath))
1058+
rel = os.path.relpath(filepath, ext_dir)
1059+
for lineno, line in enumerate(content.splitlines(), 1):
1060+
for add_re, remove_method in add_patterns:
1061+
m = re.search(add_re, line)
1062+
if m:
1063+
added_actors.append((m.group(1), rel, lineno, remove_method))
1064+
1065+
if not added_actors:
1066+
return
1067+
1068+
cleanup_text = extract_cleanup_bodies(js_files)
1069+
1070+
leaked = []
1071+
for var_name, rel, lineno, remove_method in added_actors:
1072+
escaped = re.escape(var_name)
1073+
has_remove = bool(re.search(
1074+
rf'{re.escape(remove_method)}\s*\(\s*this\.{escaped}', cleanup_text))
1075+
has_destroy = bool(re.search(
1076+
rf'this\.{escaped}\.destroy\s*\(', cleanup_text))
1077+
if not has_remove and not has_destroy:
1078+
leaked.append(f"this.{var_name} ({rel}:{lineno})")
1079+
1080+
if leaked:
1081+
result("FAIL", "lifecycle/stage-actor-leak",
1082+
f"Actor(s) added to stage/chrome but not removed in "
1083+
f"disable()/destroy(): {', '.join(leaked[:5])}")
1084+
else:
1085+
result("PASS", "lifecycle/stage-actor-leak",
1086+
f"All {len(added_actors)} stage/chrome actor(s) properly cleaned up")
1087+
1088+
1089+
def check_message_tray_lifecycle(ext_dir):
1090+
"""R-LIFE-24: MessageTray source added without destroy in disable()/destroy()."""
1091+
js_files = find_js_files(ext_dir, exclude_prefs=True)
1092+
if not js_files:
1093+
return
1094+
1095+
add_re = re.compile(r'Main\.messageTray\.add\s*\(\s*this\.(_\w+)')
1096+
added_sources = [] # (var_name, file_rel, lineno)
1097+
1098+
for filepath in js_files:
1099+
content = strip_comments(read_file(filepath))
1100+
rel = os.path.relpath(filepath, ext_dir)
1101+
for lineno, line in enumerate(content.splitlines(), 1):
1102+
m = add_re.search(line)
1103+
if m:
1104+
added_sources.append((m.group(1), rel, lineno))
1105+
1106+
if not added_sources:
1107+
return
1108+
1109+
cleanup_text = extract_cleanup_bodies(js_files)
1110+
1111+
leaked = []
1112+
for var_name, rel, lineno in added_sources:
1113+
escaped = re.escape(var_name)
1114+
has_destroy = bool(re.search(
1115+
rf'this\.{escaped}\.destroy\s*\(', cleanup_text))
1116+
if not has_destroy:
1117+
leaked.append(f"this.{var_name} ({rel}:{lineno})")
1118+
1119+
if leaked:
1120+
result("WARN", "lifecycle/messagetray-source-leak",
1121+
f"MessageTray source(s) added but not destroyed in "
1122+
f"disable()/destroy(): {', '.join(leaked[:5])}")
1123+
else:
1124+
result("PASS", "lifecycle/messagetray-source-leak",
1125+
f"All {len(added_sources)} messageTray source(s) properly cleaned up")
1126+
1127+
1128+
def check_gsettings_signal_leak(ext_dir):
1129+
"""R-LIFE-21: Bare settings.connect() without stored ID is a guaranteed leak."""
1130+
js_files = find_js_files(ext_dir, exclude_prefs=True)
1131+
if not js_files:
1132+
return
1133+
1134+
# Skip service/ directory (different lifecycle)
1135+
js_files = [f for f in js_files
1136+
if '/service/' not in f.replace(os.sep, '/')]
1137+
if not js_files:
1138+
return
1139+
1140+
bare_connects = []
1141+
has_auto_cleanup = False
1142+
1143+
for filepath in js_files:
1144+
content = strip_comments(read_file(filepath))
1145+
rel = os.path.relpath(filepath, ext_dir)
1146+
1147+
# Extension-wide (not per-file) auto-cleanup detection: connectObject/
1148+
# disconnectObject typically operate on `this`, so a central disable()
1149+
# calling disconnectObject(this) cleans signals registered from any file.
1150+
if (re.search(r'\.disconnectObject\s*\(', content) or
1151+
re.search(r'\.connectObject\s*\(', content) or
1152+
re.search(r'\b(SignalTracker|SignalManager|connectSmart|disconnectSmart)\b', content)):
1153+
has_auto_cleanup = True
1154+
1155+
for lineno, line in enumerate(content.splitlines(), 1):
1156+
stripped = line.strip()
1157+
# Match settings.connect('changed...') without assignment
1158+
if not re.search(r"\.connect\s*\(\s*['\"]changed", stripped):
1159+
continue
1160+
# Skip if return value is stored (has = before .connect on this line)
1161+
if re.search(r'=\s*\S+\.connect\s*\(', stripped):
1162+
continue
1163+
# Skip connectObject/connectSmart variants
1164+
if re.search(r'\.(connectObject|connectSmart)\s*\(', stripped):
1165+
continue
1166+
bare_connects.append(f"{rel}:{lineno}")
1167+
1168+
if bare_connects and not has_auto_cleanup:
1169+
count = len(bare_connects)
1170+
locs = ', '.join(bare_connects[:5])
1171+
suffix = f' (and {count - 5} more)' if count > 5 else ''
1172+
result("FAIL", "lifecycle/gsettings-signal-leak",
1173+
f"{count} bare settings.connect('changed::...') without stored ID "
1174+
f"and no disconnectObject/connectObject cleanup: {locs}{suffix}")
1175+
elif bare_connects:
1176+
result("PASS", "lifecycle/gsettings-signal-leak",
1177+
"Bare settings.connect() found but auto-cleanup mechanism present")
1178+
# If no bare connects, skip silently
1179+
1180+
10121181
def check_settings_cleanup(ext_dir):
10131182
"""Detect getSettings() without cleanup in disable()."""
10141183
ext_file = os.path.join(ext_dir, 'extension.js')
@@ -1071,6 +1240,9 @@ def main():
10711240
check_soup_session_abort(ext_dir)
10721241
check_destroy_then_null(ext_dir)
10731242
check_widget_lifecycle(ext_dir)
1243+
check_stage_actor_lifecycle(ext_dir)
1244+
check_message_tray_lifecycle(ext_dir)
1245+
check_gsettings_signal_leak(ext_dir)
10741246
check_settings_cleanup(ext_dir)
10751247

10761248

tests/assertions/field-test-improvements.sh

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,25 @@ assert_exit_code "exits with 0 (no FAIL)" 0
4343
assert_output_not_contains "no constructor-resources WARN for prefs.js" "\[WARN\].*quality/constructor-resources"
4444
echo ""
4545

46-
# --- stage-add-child (R-LIFE-19) ---
46+
# --- stage-add-child (R-LIFE-22) ---
4747
echo "=== stage-add-child ==="
4848
run_lint "stage-add-child@test"
49-
assert_exit_code "exits with 0 (advisory only)" 0
50-
assert_output_contains "warns on global.stage.add_child" "\[WARN\].*R-LIFE-19"
49+
assert_exit_code "exits with 1 (FAIL — stage actor leak)" 1
50+
assert_output_contains "fails on stage actor leak" "\[FAIL\].*lifecycle/stage-actor-leak"
51+
echo ""
52+
53+
# --- messagetray-source-leak (R-LIFE-24) ---
54+
echo "=== messagetray-source-leak ==="
55+
run_lint "messagetray-source-leak@test"
56+
assert_exit_code "exits with 0 (WARN only)" 0
57+
assert_output_contains "warns on messageTray source leak" "\[WARN\].*lifecycle/messagetray-source-leak"
58+
echo ""
59+
60+
# --- stage-add-child-clean (R-LIFE-22 negative) ---
61+
echo "=== stage-add-child-clean ==="
62+
run_lint "stage-add-child-clean@test"
63+
assert_exit_code "exits with 0 (clean)" 0
64+
assert_output_not_contains "no stage actor leak" "lifecycle/stage-actor-leak.*FAIL"
5165
echo ""
5266

5367
# --- shell-keybinding-mode (R-DEPR-11) ---
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# R-LIFE-21: GSettings signal leak detection
2+
# Sourced by run-tests.sh
3+
4+
echo "=== gsettings-bare-connect ==="
5+
run_lint "gsettings-bare-connect@test"
6+
assert_exit_code "exits with 1 (has failures)" 1
7+
assert_output_contains "fails on bare GSettings connect" "\[FAIL\].*lifecycle/gsettings-signal-leak"
8+
echo ""
9+
10+
echo "=== gsettings-auto-cleanup ==="
11+
run_lint "gsettings-auto-cleanup@test"
12+
assert_exit_code "exits with 0 (no failures)" 0
13+
assert_output_contains "passes with auto-cleanup" "\[PASS\].*lifecycle/gsettings-signal-leak"
14+
assert_output_not_contains "no gsettings-signal-leak FP" "\[FAIL\].*lifecycle/gsettings-signal-leak"
15+
echo ""

tests/assertions/signal-balance-fp.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ run_lint "signal-non-gobject@test"
44
assert_exit_code "exits with 0 (no failures)" 0
55
assert_output_contains "signal balance OK" "\[PASS\].*lifecycle/signal-balance"
66
assert_output_not_contains "no signal imbalance warning" "\[WARN\].*lifecycle/signal-balance"
7+
assert_output_not_contains "no gsettings-signal-leak FP" "\[FAIL\].*lifecycle/gsettings-signal-leak"
78
echo ""
89

910
# --- signal-destroy-connect (Sub-issue B: destroy signal not counted) ---
@@ -12,6 +13,7 @@ run_lint "signal-destroy-connect@test"
1213
assert_exit_code "exits with 0 (no failures)" 0
1314
assert_output_contains "signal balance OK" "\[PASS\].*lifecycle/signal-balance"
1415
assert_output_not_contains "no signal imbalance warning" "\[WARN\].*lifecycle/signal-balance"
16+
assert_output_not_contains "no gsettings-signal-leak FP" "\[FAIL\].*lifecycle/gsettings-signal-leak"
1517
echo ""
1618

1719
# --- signal-local-variable (Sub-issue C: local-variable signal not counted) ---
@@ -20,4 +22,5 @@ run_lint "signal-local-variable@test"
2022
assert_exit_code "exits with 0 (no failures)" 0
2123
assert_output_contains "signal balance OK" "\[PASS\].*lifecycle/signal-balance"
2224
assert_output_not_contains "no signal imbalance warning" "\[WARN\].*lifecycle/signal-balance"
25+
assert_output_not_contains "no gsettings-signal-leak FP" "\[FAIL\].*lifecycle/gsettings-signal-leak"
2326
echo ""

tests/assertions/widget-lifecycle.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ assert_output_contains "widget not destroyed in disable()" "\[WARN\].*lifecycle/
88
echo "=== settings-no-null ==="
99
run_lint "settings-no-null@test"
1010
assert_output_contains "settings not nulled in disable()" "\[WARN\].*lifecycle/settings-cleanup"
11+
assert_output_not_contains "no gsettings-signal-leak FP (stored ID)" "\[FAIL\].*lifecycle/gsettings-signal-leak"
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: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
2+
3+
export default class AutoCleanupExtension extends Extension {
4+
enable() {
5+
this._settings = this.getSettings();
6+
this._settings.connect('changed::interval', () => this._onChanged());
7+
}
8+
9+
_onChanged() {}
10+
11+
disable() {
12+
this._settings.disconnectObject(this);
13+
this._settings = null;
14+
}
15+
}

0 commit comments

Comments
 (0)