Skip to content

Commit 05523f9

Browse files
ZviBaratzclaude
andauthored
fix(ego-lint): make R-LIFE-25 bare connectSignal always FAIL regardless of disconnectSignal elsewhere (#91)
## Summary - **Bare `connectSignal()` calls now always FAIL** — a global `has_dbus_disconnect` flag was masking bare (un-stored) connects whenever *any* `disconnectSignal()` existed elsewhere. Bare connects are structurally un-cleanable (can't call `disconnectSignal(id)` without storing `id`), so they should never be suppressed. - **Stored connects without `disconnectSignal()` in the same file → WARN** (per-file scoping replaces global flag) - **`.disconnectSignal.bind()` recognized** as valid cleanup pattern ## Test plan - [x] 615 test assertions pass (`bash tests/run-tests.sh`) - [x] `dbus-signal-auto-cleanup@test` — exits 1 with FAIL (was exit 0 with PASS) - [x] `dbus-signal-stored-no-disconnect@test` — exits 0 with WARN (new fixture) - [x] `dbus-signal-bind-cleanup@test` — exits 0, clean (new fixture) - [ ] Field test: `bash scripts/field-test-runner.sh --no-fetch` — media-controls shows FAIL for bare connects Closes #90 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 14d0ca3 commit 05523f9

File tree

9 files changed

+119
-27
lines changed

9 files changed

+119
-27
lines changed

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2074,14 +2074,16 @@ Rules for APIs removed or changed in specific GNOME Shell versions. These rules
20742074
- **Tested by**: `tests/fixtures/messagetray-source-leak@test/`
20752075
20762076
### R-LIFE-25: D-Bus proxy signal leak (connectSignal without disconnectSignal)
2077-
- **Severity**: blocking
2077+
- **Severity**: blocking (bare connects), advisory (stored IDs without disconnect)
20782078
- **Checked by**: check-lifecycle.py
2079-
- **Rule**: `proxy.connectSignal('...')` calls where the return value is not stored (no `=` or `.push()` before `.connectSignal` on the line) and no `disconnectSignal()` call is present in the extension. GObject cleanup mechanisms (`connectObject`, `disconnectObject`, `SignalTracker`, `connectSmart`) do NOT clean up D-Bus proxy signals and are not considered valid cleanup.
2079+
- **Rule**: Two tiers of detection:
2080+
- **Bare connects** (FAIL): `proxy.connectSignal('...')` where the return value is not stored (no `=` or `.push()` before `.connectSignal` on the line). These are always leaks — you cannot call `disconnectSignal(id)` without having stored `id`. Not masked by `disconnectSignal()` elsewhere.
2081+
- **Stored without disconnect** (WARN): `connectSignal()` return values are stored but no `disconnectSignal()` call exists in the same file (per-file scoping). GObject cleanup mechanisms (`connectObject`, `disconnectObject`, `SignalTracker`, `connectSmart`) do NOT clean up D-Bus proxy signals and are not considered valid cleanup. `.disconnectSignal.bind()` is recognized as valid cleanup.
20802082
- **Rationale**: Without a stored signal ID, there is no way to call `proxy.disconnectSignal(id)` later. These D-Bus signal handlers accumulate across enable/disable cycles, causing duplicate callbacks and memory leaks. This is distinct from GObject `.connect()` — `connectSignal()` is specific to `Gio.DBusProxy` for subscribing to D-Bus signals (e.g., `PropertiesChanged`, `NameOwnerChanged`).
20812083
- **Fix**: Store the return value and call `proxy.disconnectSignal(id)` in `disable()`.
20822084
- **Scope exclusions**: prefs.js (manages own lifecycle), `service/` directory (daemon lifecycle).
20832085
- **Known limitations**: Multi-line `.connectSignal(\n'...'` calls are not detected (per-line scanning).
2084-
- **Tested by**: `tests/fixtures/dbus-signal-leak@test/`, `tests/fixtures/dbus-signal-auto-cleanup@test/`, `tests/fixtures/dbus-signal-array-storage@test/`, `tests/fixtures/dbus-signal-mixed-cleanup@test/`
2086+
- **Tested by**: `tests/fixtures/dbus-signal-leak@test/`, `tests/fixtures/dbus-signal-auto-cleanup@test/`, `tests/fixtures/dbus-signal-array-storage@test/`, `tests/fixtures/dbus-signal-mixed-cleanup@test/`, `tests/fixtures/dbus-signal-stored-no-disconnect@test/`, `tests/fixtures/dbus-signal-bind-cleanup@test/`
20852087
20862088
### R-LIFE-26: Module-scope mutable state not cleared in disable()
20872089
- **Severity**: advisory

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

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
- R-LIFE-22: Stage actor leak (add_child/addChrome without remove)
2828
- R-LIFE-23: .destroy without parentheses (property access, not method call)
2929
- R-LIFE-24: MessageTray source leak (add without destroy)
30-
- R-LIFE-25: D-Bus proxy signal leak (connectSignal without disconnectSignal)
30+
- R-LIFE-25: D-Bus proxy signal leak (bare connectSignal always fails; stored without disconnect warns)
3131
- R-LIFE-26: Module-scope mutable state (Map/Set) not cleared in disable()
3232
- R-LIFE-27: Module-scope prototype mutation
3333
- R-SEC-16: Clipboard + keybinding cross-reference
@@ -1291,7 +1291,13 @@ def check_gsettings_signal_leak(ext_dir):
12911291

12921292

12931293
def check_dbus_signal_leak(ext_dir):
1294-
"""R-LIFE-25: Bare proxy.connectSignal() without stored ID is a guaranteed leak."""
1294+
"""R-LIFE-25: D-Bus proxy signal leak detection.
1295+
1296+
Bare proxy.connectSignal() without stored ID is a guaranteed leak (FAIL).
1297+
Stored connectSignal() IDs without disconnectSignal() in same file are
1298+
advisory (WARN) — per-file scoping avoids cross-file false positives.
1299+
Recognizes .disconnectSignal.bind() as valid cleanup pattern.
1300+
"""
12951301
js_files = find_js_files(ext_dir, exclude_prefs=True)
12961302
if not js_files:
12971303
return
@@ -1303,17 +1309,18 @@ def check_dbus_signal_leak(ext_dir):
13031309
return
13041310

13051311
bare_connects = []
1306-
has_dbus_disconnect = False
1312+
stored_no_disconnect_files = []
13071313

13081314
for filepath in js_files:
13091315
content = strip_comments(read_file(filepath))
13101316
rel = os.path.relpath(filepath, ext_dir)
13111317

1312-
# D-Bus-specific cleanup detection: only disconnectSignal counts
1318+
# Per-file D-Bus cleanup detection: disconnectSignal() or .disconnectSignal.bind(
13131319
# (connectObject/disconnectObject manage GObject signals, not D-Bus signals)
1314-
if re.search(r'\.disconnectSignal\s*\(', content):
1315-
has_dbus_disconnect = True
1320+
file_has_disconnect = bool(
1321+
re.search(r'\.disconnectSignal\s*[\(.]', content))
13161322

1323+
file_stored_connects = []
13171324
prev_stripped = ''
13181325
for lineno, line in enumerate(content.splitlines(), 1):
13191326
stripped = line.strip()
@@ -1322,36 +1329,52 @@ def check_dbus_signal_leak(ext_dir):
13221329
if stripped:
13231330
prev_stripped = stripped
13241331
continue
1325-
# Skip if return value is stored (has = before .connectSignal on this line)
1332+
# Skip connectObject/connectSmart variants
1333+
if re.search(r'\.(connectObject|connectSmart)\s*\(', stripped):
1334+
prev_stripped = stripped
1335+
continue
1336+
# Check if return value is stored (has = before .connectSignal on this line)
13261337
if re.search(r'=\s*\S+\.connectSignal\s*\(', stripped):
1338+
file_stored_connects.append(f"{rel}:{lineno}")
13271339
prev_stripped = stripped
13281340
continue
1329-
# Skip if pushed to array (.push(proxy.connectSignal(...)))
1341+
# Check if pushed to array (.push(proxy.connectSignal(...)))
13301342
if re.search(r'\.push\s*\(\s*\S+\.connectSignal\s*\(', stripped):
1343+
file_stored_connects.append(f"{rel}:{lineno}")
13311344
prev_stripped = stripped
13321345
continue
1333-
# Skip if this line is inside a .push() call (multi-line pattern)
1346+
# Check if this line is inside a .push() call (multi-line pattern)
13341347
if re.search(r'\.push\s*\(\s*$', prev_stripped):
1335-
prev_stripped = stripped
1336-
continue
1337-
# Skip connectObject/connectSmart variants
1338-
if re.search(r'\.(connectObject|connectSmart)\s*\(', stripped):
1348+
file_stored_connects.append(f"{rel}:{lineno}")
13391349
prev_stripped = stripped
13401350
continue
13411351
bare_connects.append(f"{rel}:{lineno}")
13421352
prev_stripped = stripped
13431353

1344-
if bare_connects and not has_dbus_disconnect:
1354+
# Track files with stored connects but no disconnect
1355+
if file_stored_connects and not file_has_disconnect:
1356+
stored_no_disconnect_files.append((rel, file_stored_connects))
1357+
1358+
# Bare connects always FAIL — can't disconnect what you never stored
1359+
if bare_connects:
13451360
count = len(bare_connects)
13461361
locs = ', '.join(bare_connects[:5])
13471362
suffix = f' (and {count - 5} more)' if count > 5 else ''
13481363
result("FAIL", "lifecycle/dbus-signal-leak",
1349-
f"{count} bare proxy.connectSignal() without stored ID "
1350-
f"and no disconnectSignal() cleanup: {locs}{suffix}")
1351-
elif bare_connects:
1352-
result("PASS", "lifecycle/dbus-signal-leak",
1353-
"Bare connectSignal() found but disconnectSignal() cleanup present")
1354-
# If no bare connectSignal calls, skip silently
1364+
f"{count} bare proxy.connectSignal() without stored ID: "
1365+
f"{locs}{suffix}")
1366+
1367+
# Stored connects in files without any disconnectSignal → advisory
1368+
if stored_no_disconnect_files:
1369+
all_locs = []
1370+
for _rel, locs in stored_no_disconnect_files:
1371+
all_locs.extend(locs)
1372+
count = len(all_locs)
1373+
loc_str = ', '.join(all_locs[:5])
1374+
suffix = f' (and {count - 5} more)' if count > 5 else ''
1375+
result("WARN", "lifecycle/dbus-signal-leak",
1376+
f"{count} stored connectSignal() ID(s) without "
1377+
f"disconnectSignal() in same file: {loc_str}{suffix}")
13551378

13561379

13571380
def check_settings_cleanup(ext_dir):

tests/assertions/dbus-signal-leak.sh

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,34 @@ echo ""
1010

1111
echo "=== dbus-signal-auto-cleanup ==="
1212
run_lint "dbus-signal-auto-cleanup@test"
13-
assert_exit_code "exits with 0 (no failures)" 0
14-
assert_output_contains "passes with auto-cleanup" "\[PASS\].*lifecycle/dbus-signal-leak"
15-
assert_output_not_contains "no dbus-signal-leak FP" "\[FAIL\].*lifecycle/dbus-signal-leak"
13+
assert_exit_code "exits with 1 (bare connect always fails)" 1
14+
assert_output_contains "fails on bare connectSignal despite other cleanup" "\[FAIL\].*lifecycle/dbus-signal-leak"
15+
assert_output_not_contains "no WARN when file has disconnectSignal" "\[WARN\].*lifecycle/dbus-signal-leak"
1616
echo ""
1717

1818
echo "=== dbus-signal-array-storage ==="
1919
run_lint "dbus-signal-array-storage@test"
2020
assert_exit_code "exits with 0 (array storage is not bare connect)" 0
2121
assert_output_not_contains "no dbus-signal-leak FP on array storage" "\[FAIL\].*lifecycle/dbus-signal-leak"
22+
assert_output_not_contains "no WARN when file has disconnectSignal" "\[WARN\].*lifecycle/dbus-signal-leak"
2223
echo ""
2324

2425
echo "=== dbus-signal-mixed-cleanup ==="
2526
run_lint "dbus-signal-mixed-cleanup@test"
2627
assert_exit_code "exits with 1 (has failures)" 1
2728
assert_output_contains "fails despite GObject cleanup" "\[FAIL\].*lifecycle/dbus-signal-leak"
28-
assert_output_not_contains "no false auto-cleanup pass" "\[PASS\].*lifecycle/dbus-signal-leak"
29+
echo ""
30+
31+
echo "=== dbus-signal-stored-no-disconnect ==="
32+
run_lint "dbus-signal-stored-no-disconnect@test"
33+
assert_exit_code "exits with 0 (advisory only)" 0
34+
assert_output_contains "warns on stored connect without disconnect" "\[WARN\].*lifecycle/dbus-signal-leak"
35+
assert_output_not_contains "no FAIL for stored connect" "\[FAIL\].*lifecycle/dbus-signal-leak"
36+
echo ""
37+
38+
echo "=== dbus-signal-bind-cleanup ==="
39+
run_lint "dbus-signal-bind-cleanup@test"
40+
assert_exit_code "exits with 0 (bind cleanup recognized)" 0
41+
assert_output_not_contains "no FAIL with bind cleanup" "\[FAIL\].*lifecycle/dbus-signal-leak"
42+
assert_output_not_contains "no WARN with bind cleanup" "\[WARN\].*lifecycle/dbus-signal-leak"
2943
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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
2+
import Gio from 'gi://Gio';
3+
4+
export default class extends Extension {
5+
enable() {
6+
this._proxy = Gio.DBusProxy.new_for_bus_sync(
7+
Gio.BusType.SESSION, 0, null,
8+
'org.mpris.MediaPlayer2', '/org/mpris/MediaPlayer2',
9+
'org.mpris.MediaPlayer2.Player', null);
10+
this._seekedId = this._proxy.connectSignal('Seeked', () => {});
11+
this._cleanups = [
12+
this._proxy.disconnectSignal.bind(this._proxy, this._seekedId),
13+
];
14+
}
15+
16+
disable() {
17+
this._cleanups.forEach(cb => cb());
18+
this._cleanups = null;
19+
this._proxy = null;
20+
}
21+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"uuid": "dbus-signal-bind-cleanup@test",
3+
"name": "D-Bus Signal Bind Cleanup Test",
4+
"shell-version": ["45"],
5+
"description": "Fixture for R-LIFE-25: .disconnectSignal.bind() recognized as valid cleanup",
6+
"url": "https://example.com"
7+
}
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+
import Gio from 'gi://Gio';
3+
4+
export default class extends Extension {
5+
enable() {
6+
this._proxy = Gio.DBusProxy.new_for_bus_sync(
7+
Gio.BusType.SESSION, 0, null,
8+
'org.freedesktop.DBus', '/org/freedesktop/DBus',
9+
'org.freedesktop.DBus', null);
10+
this._signalId = this._proxy.connectSignal('NameOwnerChanged', () => {});
11+
}
12+
13+
disable() {
14+
this._proxy = null;
15+
}
16+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"uuid": "dbus-signal-stored-no-disconnect@test",
3+
"name": "D-Bus Signal Stored No Disconnect Test",
4+
"shell-version": ["45"],
5+
"description": "Fixture for R-LIFE-25: stored connectSignal ID without disconnectSignal",
6+
"url": "https://example.com"
7+
}

0 commit comments

Comments
 (0)