|
23 | 23 | - R-LIFE-17: Timeout ID reassignment without prior Source.remove() |
24 | 24 | - R-LIFE-18: Subprocess without cancellation in disable/destroy |
25 | 25 | - 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) |
26 | 29 | - R-SEC-16: Clipboard + keybinding cross-reference |
27 | 30 | - R-FILE-07: Missing export default class |
28 | 31 |
|
@@ -1009,6 +1012,172 @@ def check_widget_lifecycle(ext_dir): |
1009 | 1012 | f"All {len(created_widgets)} widget(s) properly cleaned up") |
1010 | 1013 |
|
1011 | 1014 |
|
| 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 | + |
1012 | 1181 | def check_settings_cleanup(ext_dir): |
1013 | 1182 | """Detect getSettings() without cleanup in disable().""" |
1014 | 1183 | ext_file = os.path.join(ext_dir, 'extension.js') |
@@ -1071,6 +1240,9 @@ def main(): |
1071 | 1240 | check_soup_session_abort(ext_dir) |
1072 | 1241 | check_destroy_then_null(ext_dir) |
1073 | 1242 | 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) |
1074 | 1246 | check_settings_cleanup(ext_dir) |
1075 | 1247 |
|
1076 | 1248 |
|
|
0 commit comments