修复所有资源泄漏问题(P0级bug)

修复的Bug:
- Bug #21: Playwright浏览器实例泄漏
- Bug #22: 数据库连接泄漏(已由连接池解决)
- Bug #23: 截图文件句柄泄漏
- Bug #24: 线程资源未清理
- Bug #25: requests.Session对象泄漏

主要改进:
1. PlaywrightAutomation类:
   - 添加atexit注册,确保进程退出时关闭浏览器
   - 添加__enter__/__exit__支持context manager
   - 添加_closed标志防止重复关闭
   - 添加_cleanup_on_exit静默清理方法

2. APIBrowser类:
   - 添加atexit注册,确保Session正确关闭
   - 添加__enter__/__exit__支持context manager
   - 添加_closed标志防止重复关闭

3. 截图功能增强:
   - 使用临时文件机制
   - 添加文件大小验证
   - 失败时自动清理临时文件
   - 确保不产生垃圾文件

4. 应用关闭清理:
   - 添加cleanup_on_exit()函数
   - 注册SIGINT/SIGTERM信号处理器
   - 停止所有运行中的任务
   - 等待线程优雅退出
   - 关闭浏览器线程池
   - 关闭数据库连接池

影响:
- 防止长期运行导致的内存泄漏
- 确保进程异常退出时正确清理资源
- 提升系统稳定性和可靠性

受影响文件:
- playwright_automation.py
- api_browser.py
- app.py

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-11 13:48:06 +08:00
parent 481f4dfbac
commit c1a65debbc
3 changed files with 156 additions and 2 deletions

View File

@@ -9,6 +9,7 @@ import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import re import re
import time import time
import atexit
from typing import Optional, Callable from typing import Optional, Callable
from dataclasses import dataclass from dataclasses import dataclass
@@ -38,6 +39,8 @@ class APIBrowser:
self.logged_in = False self.logged_in = False
self.log_callback = log_callback self.log_callback = log_callback
self.stop_flag = False self.stop_flag = False
self._closed = False # 防止重复关闭
# 设置代理 # 设置代理
if proxy_config and proxy_config.get("server"): if proxy_config and proxy_config.get("server"):
proxy_server = proxy_config["server"] proxy_server = proxy_config["server"]
@@ -49,6 +52,9 @@ class APIBrowser:
else: else:
self.proxy_server = None self.proxy_server = None
# 注册退出清理函数
atexit.register(self._cleanup_on_exit)
def log(self, message: str): def log(self, message: str):
"""记录日志""" """记录日志"""
if self.log_callback: if self.log_callback:
@@ -382,7 +388,29 @@ class APIBrowser:
def close(self): def close(self):
"""关闭会话""" """关闭会话"""
if self._closed:
return
self._closed = True
try: try:
self.session.close() self.session.close()
except: except:
pass pass
def _cleanup_on_exit(self):
"""进程退出时的清理函数由atexit调用"""
if not self._closed:
try:
self.session.close()
self._closed = True
except:
pass
def __enter__(self):
"""Context manager支持 - 进入"""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager支持 - 退出"""
self.close()
return False # 不抑制异常

60
app.py
View File

@@ -3484,7 +3484,67 @@ def batch_stop_accounts():
"stopped": stopped "stopped": stopped
}) })
def cleanup_on_exit():
"""应用退出时的清理函数"""
import signal
import sys
print("\n正在清理资源...")
# 1. 停止所有运行中的任务
print("- 停止运行中的任务...")
for account_id, thread in list(active_tasks.items()):
try:
# 设置停止标志
if account_id in user_accounts:
for user_id in user_accounts:
if account_id in user_accounts[user_id]:
user_accounts[user_id][account_id].should_stop = True
except:
pass
# 2. 等待所有线程完成最多等待5秒
print("- 等待线程退出...")
for account_id, thread in list(active_tasks.items()):
try:
if thread and thread.is_alive():
thread.join(timeout=2)
except:
pass
# 3. 关闭浏览器工作线程池
print("- 关闭浏览器线程池...")
try:
shutdown_browser_worker_pool()
except:
pass
# 4. 关闭数据库连接池
print("- 关闭数据库连接池...")
try:
db_pool._pool.close_all() if db_pool._pool else None
except:
pass
print("✓ 资源清理完成")
if __name__ == '__main__': if __name__ == '__main__':
import signal
import atexit
# 注册退出清理函数
atexit.register(cleanup_on_exit)
# 注册信号处理器
def signal_handler(sig, frame):
print("\n\n收到退出信号,正在关闭...")
cleanup_on_exit()
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
print("=" * 60) print("=" * 60)
print("知识管理平台自动化工具 - 多用户版") print("知识管理平台自动化工具 - 多用户版")
print("=" * 60) print("=" * 60)

View File

@@ -11,6 +11,7 @@ from playwright.sync_api import sync_playwright, Browser, BrowserContext, Page,
import time import time
import json import json
import threading import threading
import atexit
from typing import Optional, Callable from typing import Optional, Callable
from dataclasses import dataclass from dataclasses import dataclass
from app_config import get_config from app_config import get_config
@@ -147,6 +148,10 @@ class PlaywrightAutomation:
self.context: Optional[BrowserContext] = None self.context: Optional[BrowserContext] = None
self.page: Optional[Page] = None self.page: Optional[Page] = None
self.main_page: Optional[Page] = None self.main_page: Optional[Page] = None
self._closed = False # 防止重复关闭
# 注册退出清理函数,确保进程异常退出时也能关闭浏览器
atexit.register(self._cleanup_on_exit)
def log(self, message: str): def log(self, message: str):
"""记录日志""" """记录日志"""
@@ -1269,7 +1274,16 @@ class PlaywrightAutomation:
Returns: Returns:
是否截图成功 是否截图成功
""" """
import os
temp_filepath = None
try: try:
# 确保目录存在
os.makedirs(os.path.dirname(filepath), exist_ok=True)
# 先保存到临时文件
temp_filepath = filepath + '.tmp'
# 使用最高质量设置截图 # 使用最高质量设置截图
# type='jpeg' 指定JPEG格式支持quality参数 # type='jpeg' 指定JPEG格式支持quality参数
# quality=100 表示100%的JPEG质量范围0-100最高质量 # quality=100 表示100%的JPEG质量范围0-100最高质量
@@ -1277,19 +1291,47 @@ class PlaywrightAutomation:
# 视口分辨率 2560x1440 确保高清晰度 # 视口分辨率 2560x1440 确保高清晰度
# 这样可以生成更清晰的截图大小约500KB-1MB左右 # 这样可以生成更清晰的截图大小约500KB-1MB左右
self.main_page.screenshot( self.main_page.screenshot(
path=filepath, path=temp_filepath,
type='jpeg', type='jpeg',
full_page=True, full_page=True,
quality=100 quality=100
) )
self.log(f"截图已保存: {filepath}")
# 验证文件是否成功创建且大小合理
if not os.path.exists(temp_filepath):
raise FileNotFoundError(f"截图文件未创建: {temp_filepath}")
file_size = os.path.getsize(temp_filepath)
if file_size == 0:
raise ValueError(f"截图文件为空: {temp_filepath}")
# 文件验证通过,重命名为最终文件名
if os.path.exists(filepath):
os.remove(filepath) # 删除旧文件
os.rename(temp_filepath, filepath)
self.log(f"截图已保存: {filepath} ({file_size} bytes)")
return True return True
except Exception as e: except Exception as e:
self.log(f"截图失败: {str(e)}") self.log(f"截图失败: {str(e)}")
# 清理临时文件
try:
if temp_filepath and os.path.exists(temp_filepath):
os.remove(temp_filepath)
except Exception as cleanup_error:
self.log(f"清理临时文件失败: {cleanup_error}")
return False return False
def close(self): def close(self):
"""完全关闭浏览器进程(每个账号独立)并确保资源释放""" """完全关闭浏览器进程(每个账号独立)并确保资源释放"""
# 防止重复关闭
if self._closed:
return
self._closed = True
errors = [] errors = []
# 第一步:关闭上下文 # 第一步:关闭上下文
@@ -1339,6 +1381,30 @@ class PlaywrightAutomation:
self.log(f"资源清理完成,但有{len(errors)}个警告") self.log(f"资源清理完成,但有{len(errors)}个警告")
# else部分日志已精简 # else部分日志已精简
def _cleanup_on_exit(self):
"""进程退出时的清理函数由atexit调用"""
if not self._closed:
try:
# 静默关闭,避免在退出时产生过多日志
if self.context:
self.context.close()
if self.browser:
self.browser.close()
if self.playwright:
self.playwright.stop()
self._closed = True
except:
pass # 退出时忽略所有错误
def __enter__(self):
"""Context manager支持 - 进入"""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager支持 - 退出"""
self.close()
return False # 不抑制异常
# 简单的测试函数 # 简单的测试函数
if __name__ == "__main__": if __name__ == "__main__":