opencode-mem-guard 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """OpenCode Memory Guard - 内存泄漏监控与僵尸进程回收"""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """允许 python -m opencode_mem_guard 启动"""
2
+ from opencode_mem_guard.app import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -0,0 +1,351 @@
1
+ """
2
+ OpenCode MemGuard 主应用编排器
3
+
4
+ 职责:
5
+ - 启动监控线程(collector + leak_detector + reclaimer 循环)
6
+ - 启动 360 式悬浮球(主线程 Win32 Layered Window)
7
+ - 启动系统托盘(后台线程 pystray)
8
+ - 线程安全的共享状态
9
+ """
10
+ import argparse
11
+ import sys
12
+ import time
13
+ import threading
14
+ import logging
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ from .collector import collect_snapshot, MemSnapshot
19
+ from .leak_detector import LeakDetector, LeakAlert
20
+ from .reclaimer import ProcessReclaimer, ReclaimResult
21
+ from .datastore import DataStore
22
+ from .autostart import is_autostart_enabled, enable_autostart, disable_autostart
23
+
24
+
25
+ # ─── 共享状态 ───────────────────────────────────────────────
26
+
27
+ class SharedState:
28
+ """线程安全的共享监控状态"""
29
+
30
+ def __init__(self):
31
+ self._lock = threading.Lock()
32
+ self._data = {
33
+ "ram_pct": 0.0,
34
+ "ram_used_gb": 0.0,
35
+ "ram_total_gb": 0.0,
36
+ "node_count": 0,
37
+ "node_ram_mb": 0.0,
38
+ "node_handles": 0,
39
+ "total_killed": 0,
40
+ "total_freed_mb": 0.0,
41
+ "auto_reclaim": True,
42
+ "status": "normal",
43
+ "uptime_min": 0.0,
44
+ "alerts": [],
45
+ }
46
+ self._start_time = time.time()
47
+
48
+ def update_from_snapshot(self, snap: MemSnapshot):
49
+ with self._lock:
50
+ if snap.system:
51
+ self._data["ram_pct"] = snap.system.usage_pct
52
+ self._data["ram_used_gb"] = snap.system.used_ram_gb
53
+ self._data["ram_total_gb"] = snap.system.total_ram_gb
54
+ self._data["node_count"] = snap.node_process_count
55
+ self._data["node_ram_mb"] = snap.node_total_ram_mb
56
+ self._data["node_handles"] = snap.node_total_handles
57
+ self._data["uptime_min"] = round((time.time() - self._start_time) / 60, 1)
58
+
59
+ def update_alerts(self, alerts: list):
60
+ with self._lock:
61
+ # 确定状态等级
62
+ status = "normal"
63
+ for a in alerts:
64
+ if a.level in ("critical", "emergency"):
65
+ status = "critical"
66
+ break
67
+ elif a.level == "warning":
68
+ status = "warning"
69
+ self._data["status"] = status
70
+ self._data["alerts"] = [
71
+ {"level": a.level, "metric": a.metric, "msg": a.message}
72
+ for a in alerts[-5:] # 只保留最近5条
73
+ ]
74
+
75
+ def update_reclaim(self, reclaimer: ProcessReclaimer):
76
+ with self._lock:
77
+ self._data["total_killed"] = reclaimer.total_killed
78
+ self._data["total_freed_mb"] = round(reclaimer.total_freed_mb, 1)
79
+
80
+ def set_auto_reclaim(self, enabled: bool):
81
+ with self._lock:
82
+ self._data["auto_reclaim"] = enabled
83
+
84
+ def get_auto_reclaim(self) -> bool:
85
+ with self._lock:
86
+ return self._data["auto_reclaim"]
87
+
88
+ def get_status_dict(self) -> dict:
89
+ with self._lock:
90
+ return dict(self._data)
91
+
92
+ def get_tray_status(self) -> str:
93
+ with self._lock:
94
+ return self._data["status"]
95
+
96
+ def get_tray_tooltip(self) -> str:
97
+ with self._lock:
98
+ d = self._data
99
+ return (
100
+ f"MemGuard - RAM: {d['ram_pct']}% | "
101
+ f"Node: {d['node_count']}进程 | "
102
+ f"{d['node_ram_mb']:.0f}MB"
103
+ )
104
+
105
+
106
+ # ─── 监控线程 ───────────────────────────────────────────────
107
+
108
+ class MonitorThread(threading.Thread):
109
+ """后台监控线程"""
110
+
111
+ def __init__(
112
+ self,
113
+ state: SharedState,
114
+ interval: float = 5.0,
115
+ no_reclaim: bool = False,
116
+ dry_run: bool = False,
117
+ data_dir: str = "",
118
+ ):
119
+ super().__init__(daemon=True, name="monitor-loop")
120
+ self.state = state
121
+ self.interval = interval
122
+ self.no_reclaim = no_reclaim
123
+ self._stop_event = threading.Event()
124
+
125
+ # 数据目录
126
+ if not data_dir:
127
+ data_dir = str(Path.home() / ".opencode-mem-guard" / "data")
128
+ self.store = DataStore(data_dir=data_dir)
129
+ self.detector = LeakDetector()
130
+ self.reclaimer = ProcessReclaimer(
131
+ dry_run=dry_run,
132
+ logger=self.store.logger,
133
+ )
134
+ self.store.logger.info("监控线程初始化完成")
135
+
136
+ def run(self):
137
+ self.store.logger.info(
138
+ f"监控启动 interval={self.interval}s "
139
+ f"reclaim={'off' if self.no_reclaim else 'on'}"
140
+ )
141
+ while not self._stop_event.is_set():
142
+ try:
143
+ self._tick()
144
+ except Exception as e:
145
+ self.store.logger.error(f"监控循环异常: {e}")
146
+ self._stop_event.wait(self.interval)
147
+
148
+ self.store.close()
149
+ self.store.logger.info("监控线程已停止")
150
+
151
+ def _tick(self):
152
+ # 1. 采集
153
+ snap = collect_snapshot()
154
+ self.state.update_from_snapshot(snap)
155
+ self.store.save_snapshot(snap)
156
+
157
+ # 2. 泄漏检测
158
+ alerts = self.detector.feed(snap)
159
+ self.state.update_alerts(alerts)
160
+ if alerts:
161
+ self.store.save_alerts(alerts)
162
+
163
+ # 3. 回收
164
+ if not self.no_reclaim and self.state.get_auto_reclaim():
165
+ if self.reclaimer.should_run():
166
+ result = self.reclaimer.reclaim()
167
+ if result and result.actions:
168
+ self.store.save_reclaim(result)
169
+ self.state.update_reclaim(self.reclaimer)
170
+ else:
171
+ # 即使不回收也更新统计
172
+ self.state.update_reclaim(self.reclaimer)
173
+
174
+ def force_reclaim(self) -> Optional[ReclaimResult]:
175
+ """手动触发一次回收(忽略冷却期)"""
176
+ self.reclaimer._last_reclaim_time = 0 # 重置冷却
177
+ result = self.reclaimer.reclaim()
178
+ if result and result.actions:
179
+ self.store.save_reclaim(result)
180
+ self.state.update_reclaim(self.reclaimer)
181
+ return result
182
+
183
+ def stop(self):
184
+ self._stop_event.set()
185
+
186
+
187
+ # ─── 主入口 ─────────────────────────────────────────────────
188
+
189
+
190
+ def main():
191
+ parser = argparse.ArgumentParser(
192
+ prog="opencode-mem-guard",
193
+ description="OpenCode 内存泄漏监控与僵尸进程回收",
194
+ )
195
+ parser.add_argument(
196
+ "--no-reclaim", action="store_true",
197
+ help="禁用自动回收",
198
+ )
199
+ parser.add_argument(
200
+ "--dry-run", action="store_true",
201
+ help="试运行模式(只检测不杀进程)",
202
+ )
203
+ parser.add_argument(
204
+ "--no-tray", action="store_true",
205
+ help="不显示托盘图标(headless 模式)",
206
+ )
207
+ parser.add_argument(
208
+ "--no-ball", action="store_true",
209
+ help="不显示悬浮球(仅托盘模式)",
210
+ )
211
+ parser.add_argument(
212
+ "-i", "--interval", type=float, default=5.0,
213
+ help="采集间隔(秒),默认 5",
214
+ )
215
+ parser.add_argument(
216
+ "--data-dir", type=str, default="",
217
+ help="数据存储目录,默认 ~/.opencode-mem-guard/data/",
218
+ )
219
+ args = parser.parse_args()
220
+
221
+ # 共享状态
222
+ state = SharedState()
223
+ state.set_auto_reclaim(not args.no_reclaim)
224
+
225
+ # 启动监控线程
226
+ monitor = MonitorThread(
227
+ state=state,
228
+ interval=args.interval,
229
+ no_reclaim=args.no_reclaim,
230
+ dry_run=args.dry_run,
231
+ data_dir=args.data_dir,
232
+ )
233
+ monitor.start()
234
+
235
+ if args.no_tray and args.no_ball:
236
+ # headless 模式:直接等待
237
+ print("OpenCode MemGuard 已启动(headless 模式)")
238
+ print(f"数据目录: {monitor.store.data_dir}")
239
+ print("按 Ctrl+C 退出")
240
+ try:
241
+ while True:
242
+ time.sleep(1)
243
+ except KeyboardInterrupt:
244
+ print("\n正在停止...")
245
+ monitor.stop()
246
+ monitor.join(timeout=5)
247
+ return
248
+
249
+ # ── 回调函数 ──
250
+
251
+ ball = None # 浮球引用,后面赋值
252
+
253
+ def on_quit():
254
+ monitor.stop()
255
+ if ball:
256
+ ball.stop()
257
+
258
+ def on_toggle_reclaim():
259
+ state.set_auto_reclaim(not state.get_auto_reclaim())
260
+
261
+ def on_force_reclaim():
262
+ monitor.force_reclaim()
263
+
264
+ def on_toggle_autostart():
265
+ if is_autostart_enabled():
266
+ disable_autostart()
267
+ else:
268
+ enable_autostart()
269
+
270
+ # ── 启动托盘(后台线程) ──
271
+
272
+ tray = None
273
+
274
+ if not args.no_tray:
275
+ from .tray import TrayManager
276
+
277
+ def on_toggle_ball():
278
+ if ball:
279
+ ball.toggle_visibility()
280
+
281
+ tray = TrayManager(
282
+ on_toggle_ball=on_toggle_ball,
283
+ on_toggle_reclaim=on_toggle_reclaim,
284
+ on_force_reclaim=on_force_reclaim,
285
+ on_toggle_autostart=on_toggle_autostart,
286
+ on_quit=on_quit,
287
+ get_reclaim_state=lambda: state.get_auto_reclaim(),
288
+ get_autostart_state=is_autostart_enabled,
289
+ get_ball_visible=lambda: ball._visible if ball else True,
290
+ data_dir=str(monitor.store.data_dir),
291
+ )
292
+
293
+ # 托盘状态更新线程
294
+ def _update_tray():
295
+ while monitor.is_alive():
296
+ try:
297
+ tray.update_status(
298
+ state.get_tray_status(),
299
+ state.get_tray_tooltip(),
300
+ )
301
+ except Exception:
302
+ pass
303
+ time.sleep(3)
304
+
305
+ tray_thread = threading.Thread(
306
+ target=tray.run, daemon=True, name="pystray"
307
+ )
308
+ tray_thread.start()
309
+
310
+ updater = threading.Thread(
311
+ target=_update_tray, daemon=True, name="tray-updater"
312
+ )
313
+ updater.start()
314
+
315
+ # ── 启动悬浮球(主线程 Win32) ──
316
+
317
+ if not args.no_ball:
318
+ from .floating_ball import FloatingBall
319
+
320
+ ball = FloatingBall(
321
+ get_data=lambda: state.get_status_dict(),
322
+ on_force_reclaim=on_force_reclaim,
323
+ on_toggle_reclaim=on_toggle_reclaim,
324
+ on_quit=on_quit,
325
+ get_reclaim_state=lambda: state.get_auto_reclaim(),
326
+ )
327
+
328
+ try:
329
+ ball.run() # 阻塞:tkinter mainloop
330
+ except KeyboardInterrupt:
331
+ pass
332
+ finally:
333
+ monitor.stop()
334
+ monitor.join(timeout=5)
335
+ if tray is not None:
336
+ tray.stop()
337
+ else:
338
+ # 仅托盘模式,主线程等待
339
+ print("OpenCode MemGuard 已启动(仅托盘模式)")
340
+ try:
341
+ while True:
342
+ time.sleep(1)
343
+ except KeyboardInterrupt:
344
+ pass
345
+ finally:
346
+ monitor.stop()
347
+ monitor.join(timeout=5)
348
+
349
+
350
+ if __name__ == "__main__":
351
+ main()
@@ -0,0 +1,129 @@
1
+ """
2
+ Windows 开机自启动管理模块
3
+ 使用注册表实现开机自启动
4
+ """
5
+ import sys
6
+ import winreg
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+
11
+ REG_PATH = r"Software\Microsoft\Windows\CurrentVersion\Run"
12
+ APP_NAME = "OpenCodeMemGuard"
13
+
14
+
15
+ def get_executable_path() -> str:
16
+ """获取当前可执行文件路径"""
17
+ if getattr(sys, 'frozen', False):
18
+ # 打包后的 exe
19
+ return sys.executable
20
+ else:
21
+ # 开发模式:使用 pythonw -m opencode_mem_guard
22
+ python_exe = sys.executable.replace("python.exe", "pythonw.exe")
23
+ if not Path(python_exe).exists():
24
+ python_exe = sys.executable
25
+ return f'"{python_exe}" -m opencode_mem_guard'
26
+
27
+
28
+ def enable_autostart() -> bool:
29
+ """
30
+ 启用开机自启动
31
+
32
+ Returns:
33
+ bool: 成功返回 True,失败返回 False
34
+ """
35
+ try:
36
+ key = winreg.OpenKey(
37
+ winreg.HKEY_CURRENT_USER,
38
+ REG_PATH,
39
+ 0,
40
+ winreg.KEY_SET_VALUE
41
+ )
42
+
43
+ exe_path = get_executable_path()
44
+ winreg.SetValueEx(key, APP_NAME, 0, winreg.REG_SZ, exe_path)
45
+ winreg.CloseKey(key)
46
+ return True
47
+ except Exception as e:
48
+ print(f"启用自启动失败: {e}")
49
+ return False
50
+
51
+
52
+ def disable_autostart() -> bool:
53
+ """
54
+ 禁用开机自启动
55
+
56
+ Returns:
57
+ bool: 成功返回 True,失败返回 False
58
+ """
59
+ try:
60
+ key = winreg.OpenKey(
61
+ winreg.HKEY_CURRENT_USER,
62
+ REG_PATH,
63
+ 0,
64
+ winreg.KEY_SET_VALUE
65
+ )
66
+
67
+ try:
68
+ winreg.DeleteValue(key, APP_NAME)
69
+ except FileNotFoundError:
70
+ # 键不存在,视为成功
71
+ pass
72
+
73
+ winreg.CloseKey(key)
74
+ return True
75
+ except Exception as e:
76
+ print(f"禁用自启动失败: {e}")
77
+ return False
78
+
79
+
80
+ def is_autostart_enabled() -> bool:
81
+ """
82
+ 检查是否已启用开机自启动
83
+
84
+ Returns:
85
+ bool: 已启用返回 True,未启用返回 False
86
+ """
87
+ try:
88
+ key = winreg.OpenKey(
89
+ winreg.HKEY_CURRENT_USER,
90
+ REG_PATH,
91
+ 0,
92
+ winreg.KEY_READ
93
+ )
94
+
95
+ try:
96
+ value, _ = winreg.QueryValueEx(key, APP_NAME)
97
+ winreg.CloseKey(key)
98
+ return bool(value)
99
+ except FileNotFoundError:
100
+ winreg.CloseKey(key)
101
+ return False
102
+ except Exception:
103
+ return False
104
+
105
+
106
+ def get_autostart_path() -> Optional[str]:
107
+ """
108
+ 获取注册表中的自启动路径
109
+
110
+ Returns:
111
+ Optional[str]: 自启动路径,如果未设置则返回 None
112
+ """
113
+ try:
114
+ key = winreg.OpenKey(
115
+ winreg.HKEY_CURRENT_USER,
116
+ REG_PATH,
117
+ 0,
118
+ winreg.KEY_READ
119
+ )
120
+
121
+ try:
122
+ value, _ = winreg.QueryValueEx(key, APP_NAME)
123
+ winreg.CloseKey(key)
124
+ return value
125
+ except FileNotFoundError:
126
+ winreg.CloseKey(key)
127
+ return None
128
+ except Exception:
129
+ return None
@@ -0,0 +1,117 @@
1
+ """
2
+ 内存数据采集模块
3
+ 使用 psutil 采集 node.exe 进程内存、系统内存、句柄数等关键指标
4
+ 比 PowerShell 快 100x+,不会在系统高负载时超时
5
+ """
6
+ import time
7
+ import psutil
8
+ from dataclasses import dataclass, field, asdict
9
+ from typing import List, Optional
10
+
11
+
12
+ @dataclass
13
+ class ProcessMemInfo:
14
+ """单个进程的内存信息"""
15
+ pid: int
16
+ name: str
17
+ ram_mb: float # WorkingSet (物理内存)
18
+ virtual_mb: float # 虚拟内存
19
+ paged_mb: float # 分页内存
20
+ peak_ram_mb: float # 峰值物理内存
21
+ handle_count: int
22
+ thread_count: int
23
+ cpu_seconds: float = 0.0
24
+ cmdline: str = ""
25
+
26
+
27
+ @dataclass
28
+ class SystemMemInfo:
29
+ """系统级内存信息"""
30
+ total_ram_gb: float
31
+ free_ram_gb: float
32
+ used_ram_gb: float
33
+ usage_pct: float
34
+ commit_total_gb: float # 提交总量(虚拟内存)
35
+ commit_used_gb: float
36
+ commit_pct: float
37
+
38
+
39
+ @dataclass
40
+ class MemSnapshot:
41
+ """一次完整的内存快照"""
42
+ timestamp: float
43
+ system: Optional[SystemMemInfo] = None
44
+ node_processes: List[ProcessMemInfo] = field(default_factory=list)
45
+ node_total_ram_mb: float = 0.0
46
+ node_total_virtual_gb: float = 0.0
47
+ node_process_count: int = 0
48
+ node_total_handles: int = 0
49
+ node_total_threads: int = 0
50
+ error: str = ""
51
+
52
+ def to_dict(self) -> dict:
53
+ return asdict(self)
54
+
55
+
56
+ def collect_snapshot() -> MemSnapshot:
57
+ """使用 psutil 执行一次完整的内存快照采集,毫秒级完成"""
58
+ snap = MemSnapshot(timestamp=time.time())
59
+
60
+ try:
61
+ # ── 系统内存 ──
62
+ vm = psutil.virtual_memory()
63
+ swap = psutil.swap_memory()
64
+
65
+ total_gb = vm.total / (1024 ** 3)
66
+ free_gb = vm.available / (1024 ** 3)
67
+ used_gb = (vm.total - vm.available) / (1024 ** 3)
68
+
69
+ # commit = 物理内存 + swap 已用
70
+ commit_total_gb = (vm.total + swap.total) / (1024 ** 3)
71
+ commit_used_gb = (vm.total - vm.available + swap.used) / (1024 ** 3)
72
+ commit_pct = (commit_used_gb / commit_total_gb * 100) if commit_total_gb > 0 else 0
73
+
74
+ snap.system = SystemMemInfo(
75
+ total_ram_gb=round(total_gb, 2),
76
+ free_ram_gb=round(free_gb, 2),
77
+ used_ram_gb=round(used_gb, 2),
78
+ usage_pct=round(vm.percent, 1),
79
+ commit_total_gb=round(commit_total_gb, 2),
80
+ commit_used_gb=round(commit_used_gb, 2),
81
+ commit_pct=round(commit_pct, 1),
82
+ )
83
+ except Exception as e:
84
+ snap.error = f"系统内存采集失败: {e}"
85
+
86
+ # ── Node 进程采集 ──
87
+ try:
88
+ for proc in psutil.process_iter(['pid', 'name']):
89
+ try:
90
+ if proc.info['name'] and proc.info['name'].lower() == 'node.exe':
91
+ mem = proc.memory_info()
92
+ cpu_times = proc.cpu_times()
93
+ snap.node_processes.append(ProcessMemInfo(
94
+ pid=proc.info['pid'],
95
+ name="node.exe",
96
+ ram_mb=round(mem.rss / 1048576, 1),
97
+ virtual_mb=round(mem.vms / 1048576, 1),
98
+ paged_mb=round(getattr(mem, 'paged_pool', 0) / 1048576, 1),
99
+ peak_ram_mb=round(getattr(mem, 'peak_wset', mem.rss) / 1048576, 1),
100
+ handle_count=proc.num_handles() if hasattr(proc, 'num_handles') else 0,
101
+ thread_count=proc.num_threads(),
102
+ cpu_seconds=round(cpu_times.user + cpu_times.system, 2),
103
+ ))
104
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
105
+ continue
106
+ except Exception as e:
107
+ if not snap.error:
108
+ snap.error = f"进程采集失败: {e}"
109
+
110
+ # 汇总
111
+ snap.node_process_count = len(snap.node_processes)
112
+ snap.node_total_ram_mb = round(sum(p.ram_mb for p in snap.node_processes), 1)
113
+ snap.node_total_virtual_gb = round(sum(p.virtual_mb for p in snap.node_processes) / 1024, 2)
114
+ snap.node_total_handles = sum(p.handle_count for p in snap.node_processes)
115
+ snap.node_total_threads = sum(p.thread_count for p in snap.node_processes)
116
+
117
+ return snap