|
24 | 24 | - R-LIFE-18: Subprocess without cancellation in disable/destroy |
25 | 25 | - R-LIFE-20: Bus name ownership without release |
26 | 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) |
27 | 29 | - R-SEC-16: Clipboard + keybinding cross-reference |
28 | 30 | - R-FILE-07: Missing export default class |
29 | 31 |
|
@@ -1010,6 +1012,137 @@ def check_widget_lifecycle(ext_dir): |
1010 | 1012 | f"All {len(created_widgets)} widget(s) properly cleaned up") |
1011 | 1013 |
|
1012 | 1014 |
|
| 1015 | +def check_stage_actor_lifecycle(ext_dir): |
| 1016 | + """R-LIFE-22: Stage actor add without matching remove in disable()/destroy().""" |
| 1017 | + js_files = find_js_files(ext_dir, exclude_prefs=True) |
| 1018 | + if not js_files: |
| 1019 | + return |
| 1020 | + |
| 1021 | + # Patterns that add actors to stage/chrome |
| 1022 | + add_patterns = [ |
| 1023 | + (r'global\.stage\.add_child\s*\(\s*this\.(_\w+)', 'global.stage.remove_child'), |
| 1024 | + (r'Main\.layoutManager\.addTopChrome\s*\(\s*this\.(_\w+)', 'removeTopChrome'), |
| 1025 | + (r'Main\.layoutManager\.addChrome\s*\(\s*this\.(_\w+)', 'removeChrome'), |
| 1026 | + ] |
| 1027 | + |
| 1028 | + added_actors = [] # (var_name, file_rel, lineno, remove_method) |
| 1029 | + |
| 1030 | + for filepath in js_files: |
| 1031 | + content = strip_comments(read_file(filepath)) |
| 1032 | + rel = os.path.relpath(filepath, ext_dir) |
| 1033 | + for lineno, line in enumerate(content.splitlines(), 1): |
| 1034 | + for add_re, remove_method in add_patterns: |
| 1035 | + m = re.search(add_re, line) |
| 1036 | + if m: |
| 1037 | + added_actors.append((m.group(1), rel, lineno, remove_method)) |
| 1038 | + |
| 1039 | + if not added_actors: |
| 1040 | + return |
| 1041 | + |
| 1042 | + # Extract cleanup method bodies across all files |
| 1043 | + cleanup_re = re.compile( |
| 1044 | + r'\b(?:disable|destroy|_destroy\w*|onDestroy)\s*\(') |
| 1045 | + |
| 1046 | + cleanup_text = '' |
| 1047 | + for filepath in js_files: |
| 1048 | + content = strip_comments(read_file(filepath)) |
| 1049 | + lines = content.splitlines() |
| 1050 | + i = 0 |
| 1051 | + while i < len(lines): |
| 1052 | + if cleanup_re.search(lines[i]): |
| 1053 | + depth = 0 |
| 1054 | + started = False |
| 1055 | + for j in range(i, len(lines)): |
| 1056 | + depth += lines[j].count('{') - lines[j].count('}') |
| 1057 | + if '{' in lines[j]: |
| 1058 | + started = True |
| 1059 | + cleanup_text += lines[j] + '\n' |
| 1060 | + if started and depth <= 0: |
| 1061 | + break |
| 1062 | + i = j + 1 if started else i + 1 |
| 1063 | + else: |
| 1064 | + i += 1 |
| 1065 | + |
| 1066 | + leaked = [] |
| 1067 | + for var_name, rel, lineno, remove_method in added_actors: |
| 1068 | + escaped = re.escape(var_name) |
| 1069 | + has_remove = bool(re.search( |
| 1070 | + rf'{re.escape(remove_method)}\s*\(\s*this\.{escaped}', cleanup_text)) |
| 1071 | + has_destroy = bool(re.search( |
| 1072 | + rf'this\.{escaped}\.destroy\s*\(', cleanup_text)) |
| 1073 | + if not has_remove and not has_destroy: |
| 1074 | + leaked.append(f"this.{var_name} ({rel}:{lineno})") |
| 1075 | + |
| 1076 | + if leaked: |
| 1077 | + result("FAIL", "lifecycle/stage-actor-leak", |
| 1078 | + f"Actor(s) added to stage/chrome but not removed in " |
| 1079 | + f"disable()/destroy(): {', '.join(leaked[:5])}") |
| 1080 | + else: |
| 1081 | + result("PASS", "lifecycle/stage-actor-leak", |
| 1082 | + f"All {len(added_actors)} stage/chrome actor(s) properly cleaned up") |
| 1083 | + |
| 1084 | + |
| 1085 | +def check_message_tray_lifecycle(ext_dir): |
| 1086 | + """R-LIFE-24: MessageTray source added without destroy in disable()/destroy().""" |
| 1087 | + js_files = find_js_files(ext_dir, exclude_prefs=True) |
| 1088 | + if not js_files: |
| 1089 | + return |
| 1090 | + |
| 1091 | + add_re = re.compile(r'Main\.messageTray\.add\s*\(\s*this\.(_\w+)') |
| 1092 | + added_sources = [] # (var_name, file_rel, lineno) |
| 1093 | + |
| 1094 | + for filepath in js_files: |
| 1095 | + content = strip_comments(read_file(filepath)) |
| 1096 | + rel = os.path.relpath(filepath, ext_dir) |
| 1097 | + for lineno, line in enumerate(content.splitlines(), 1): |
| 1098 | + m = add_re.search(line) |
| 1099 | + if m: |
| 1100 | + added_sources.append((m.group(1), rel, lineno)) |
| 1101 | + |
| 1102 | + if not added_sources: |
| 1103 | + return |
| 1104 | + |
| 1105 | + # Extract cleanup method bodies across all files |
| 1106 | + cleanup_re = re.compile( |
| 1107 | + r'\b(?:disable|destroy|_destroy\w*|onDestroy)\s*\(') |
| 1108 | + |
| 1109 | + cleanup_text = '' |
| 1110 | + for filepath in js_files: |
| 1111 | + content = strip_comments(read_file(filepath)) |
| 1112 | + lines = content.splitlines() |
| 1113 | + i = 0 |
| 1114 | + while i < len(lines): |
| 1115 | + if cleanup_re.search(lines[i]): |
| 1116 | + depth = 0 |
| 1117 | + started = False |
| 1118 | + for j in range(i, len(lines)): |
| 1119 | + depth += lines[j].count('{') - lines[j].count('}') |
| 1120 | + if '{' in lines[j]: |
| 1121 | + started = True |
| 1122 | + cleanup_text += lines[j] + '\n' |
| 1123 | + if started and depth <= 0: |
| 1124 | + break |
| 1125 | + i = j + 1 if started else i + 1 |
| 1126 | + else: |
| 1127 | + i += 1 |
| 1128 | + |
| 1129 | + leaked = [] |
| 1130 | + for var_name, rel, lineno in added_sources: |
| 1131 | + escaped = re.escape(var_name) |
| 1132 | + has_destroy = bool(re.search( |
| 1133 | + rf'this\.{escaped}\.destroy\s*\(', cleanup_text)) |
| 1134 | + if not has_destroy: |
| 1135 | + leaked.append(f"this.{var_name} ({rel}:{lineno})") |
| 1136 | + |
| 1137 | + if leaked: |
| 1138 | + result("WARN", "lifecycle/messagetray-source-leak", |
| 1139 | + f"MessageTray source(s) added but not destroyed in " |
| 1140 | + f"disable()/destroy(): {', '.join(leaked[:5])}") |
| 1141 | + else: |
| 1142 | + result("PASS", "lifecycle/messagetray-source-leak", |
| 1143 | + f"All {len(added_sources)} messageTray source(s) properly cleaned up") |
| 1144 | + |
| 1145 | + |
1013 | 1146 | def check_gsettings_signal_leak(ext_dir): |
1014 | 1147 | """R-LIFE-21: Bare settings.connect() without stored ID is a guaranteed leak.""" |
1015 | 1148 | js_files = find_js_files(ext_dir, exclude_prefs=True) |
@@ -1123,6 +1256,8 @@ def main(): |
1123 | 1256 | check_soup_session_abort(ext_dir) |
1124 | 1257 | check_destroy_then_null(ext_dir) |
1125 | 1258 | check_widget_lifecycle(ext_dir) |
| 1259 | + check_stage_actor_lifecycle(ext_dir) |
| 1260 | + check_message_tray_lifecycle(ext_dir) |
1126 | 1261 | check_gsettings_signal_leak(ext_dir) |
1127 | 1262 | check_settings_cleanup(ext_dir) |
1128 | 1263 |
|
|
0 commit comments