726 lines
25 KiB
C#
726 lines
25 KiB
C#
using System.Collections.Concurrent;
|
||
using System.Diagnostics;
|
||
using Microsoft.Extensions.Logging;
|
||
using Microsoft.Extensions.Options;
|
||
using PuppeteerSharp;
|
||
using HtmlToPdfService.Core.Options;
|
||
|
||
namespace HtmlToPdfService.Core.Pool;
|
||
|
||
/// <summary>
|
||
/// 增强版浏览器池实现
|
||
/// 支持自愈、健康检查、内存阈值重启
|
||
/// </summary>
|
||
public class BrowserPool : IBrowserPool
|
||
{
|
||
private readonly BrowserPoolOptions _options;
|
||
private readonly ILogger<BrowserPool> _logger;
|
||
private readonly ConcurrentBag<BrowserInstance> _availableBrowsers = new();
|
||
private readonly ConcurrentDictionary<string, BrowserInstance> _allBrowsers = new();
|
||
private readonly SemaphoreSlim _semaphore;
|
||
private readonly SemaphoreSlim _createLock = new(1, 1);
|
||
private readonly Timer _healthCheckTimer;
|
||
private int _totalInstances = 0;
|
||
private int _restartCount = 0;
|
||
private bool _disposed = false;
|
||
|
||
public BrowserPool(IOptions<PdfServiceOptions> options, ILogger<BrowserPool> logger)
|
||
{
|
||
_options = options.Value.BrowserPool;
|
||
_logger = logger;
|
||
_semaphore = new SemaphoreSlim(_options.MaxConcurrent, _options.MaxConcurrent);
|
||
|
||
// 启动健康检查定时器
|
||
_healthCheckTimer = new Timer(
|
||
async _ => await PerformHealthCheckAsync(),
|
||
null,
|
||
TimeSpan.FromMilliseconds(_options.HealthCheckIntervalMs),
|
||
TimeSpan.FromMilliseconds(_options.HealthCheckIntervalMs));
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<IBrowser> AcquireAsync(CancellationToken cancellationToken = default)
|
||
{
|
||
if (_disposed)
|
||
throw new ObjectDisposedException(nameof(BrowserPool));
|
||
|
||
// 等待信号量(控制并发数)
|
||
var acquired = await _semaphore.WaitAsync(_options.AcquireTimeout, cancellationToken);
|
||
if (!acquired)
|
||
{
|
||
_logger.LogWarning("获取浏览器实例超时,当前并发数已达上限");
|
||
throw new TimeoutException($"无法在 {_options.AcquireTimeout}ms 内获取浏览器实例");
|
||
}
|
||
|
||
try
|
||
{
|
||
// 尝试从池中获取可用实例
|
||
while (_availableBrowsers.TryTake(out var instance))
|
||
{
|
||
if (await ValidateBrowserAsync(instance))
|
||
{
|
||
instance.LastUsedAt = DateTime.UtcNow;
|
||
instance.TaskCount++;
|
||
_logger.LogDebug("复用浏览器实例: {BrowserId}, 已处理任务数: {TaskCount}",
|
||
instance.Id, instance.TaskCount);
|
||
return instance.Browser;
|
||
}
|
||
else
|
||
{
|
||
// 实例已失效,清理
|
||
_logger.LogWarning("检测到无效的浏览器实例,正在清理: {BrowserId}", instance.Id);
|
||
await DisposeBrowserAsync(instance);
|
||
}
|
||
}
|
||
|
||
// 池中没有可用实例,创建新实例
|
||
if (_totalInstances < _options.MaxInstances)
|
||
{
|
||
var newInstance = await CreateBrowserAsync(cancellationToken);
|
||
newInstance.TaskCount = 1;
|
||
_logger.LogInformation("创建新浏览器实例: {BrowserId}, 当前总实例数: {Total}/{Max}",
|
||
newInstance.Id, _totalInstances, _options.MaxInstances);
|
||
return newInstance.Browser;
|
||
}
|
||
|
||
// 达到最大实例数
|
||
_logger.LogWarning("浏览器实例数已达上限 ({Max}),等待其他任务释放", _options.MaxInstances);
|
||
throw new InvalidOperationException($"浏览器实例数已达上限: {_options.MaxInstances}");
|
||
}
|
||
catch
|
||
{
|
||
_semaphore.Release();
|
||
throw;
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public void Release(IBrowser browser)
|
||
{
|
||
if (browser == null)
|
||
throw new ArgumentNullException(nameof(browser));
|
||
|
||
try
|
||
{
|
||
var instance = _allBrowsers.Values.FirstOrDefault(b => b.Browser == browser);
|
||
if (instance != null)
|
||
{
|
||
instance.LastUsedAt = DateTime.UtcNow;
|
||
|
||
// 检查是否需要重启(任务数超限或内存超限)
|
||
if (ShouldRestartBrowser(instance))
|
||
{
|
||
_logger.LogInformation("浏览器实例需要重启: {BrowserId}, 原因: {Reason}",
|
||
instance.Id, GetRestartReason(instance));
|
||
_ = RestartBrowserAsync(instance);
|
||
}
|
||
else if (browser.IsConnected && !_disposed)
|
||
{
|
||
_availableBrowsers.Add(instance);
|
||
_logger.LogDebug("浏览器实例已归还到池中: {BrowserId}", instance.Id);
|
||
}
|
||
else
|
||
{
|
||
_logger.LogWarning("释放的浏览器实例已断开或池已销毁: {BrowserId}", instance.Id);
|
||
_ = DisposeBrowserAsync(instance);
|
||
}
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
_semaphore.Release();
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task WarmUpAsync(CancellationToken cancellationToken = default)
|
||
{
|
||
_logger.LogInformation("开始预热浏览器池,目标实例数: {Min}", _options.MinInstances);
|
||
|
||
var tasks = new List<Task>();
|
||
for (int i = 0; i < _options.MinInstances; i++)
|
||
{
|
||
tasks.Add(Task.Run(async () =>
|
||
{
|
||
try
|
||
{
|
||
var instance = await CreateBrowserAsync(cancellationToken);
|
||
_availableBrowsers.Add(instance);
|
||
_logger.LogDebug("预热:创建浏览器实例 {BrowserId}", instance.Id);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "预热失败:创建浏览器实例时出错");
|
||
}
|
||
}, cancellationToken));
|
||
}
|
||
|
||
await Task.WhenAll(tasks);
|
||
_logger.LogInformation("浏览器池预热完成,当前实例数: {Total}", _totalInstances);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public BrowserPoolStatus GetStatus()
|
||
{
|
||
var healthyCount = _allBrowsers.Values.Count(b => b.Browser.IsConnected);
|
||
var totalMemory = _allBrowsers.Values.Sum(b => b.MemoryUsageMb);
|
||
var availableIds = _availableBrowsers.Select(b => b.Id).ToHashSet();
|
||
|
||
var instances = _allBrowsers.Values.Select(b => new BrowserInstanceInfo
|
||
{
|
||
Id = b.Id,
|
||
Status = availableIds.Contains(b.Id) ? "available" : "in-use",
|
||
TaskCount = b.TaskCount,
|
||
MemoryUsage = b.MemoryUsageMb * 1024 * 1024, // 转换为字节
|
||
CreatedAt = b.CreatedAt,
|
||
LastUsedAt = b.LastUsedAt
|
||
}).ToList();
|
||
|
||
return new BrowserPoolStatus
|
||
{
|
||
TotalInstances = _totalInstances,
|
||
AvailableInstances = _availableBrowsers.Count,
|
||
InUseInstances = _totalInstances - _availableBrowsers.Count,
|
||
MaxInstances = _options.MaxInstances,
|
||
HealthyInstances = healthyCount,
|
||
RestartCount = _restartCount,
|
||
TotalMemoryMb = totalMemory,
|
||
Instances = instances,
|
||
Config = new BrowserPoolConfigInfo
|
||
{
|
||
MaxInstances = _options.MaxInstances,
|
||
MinInstances = _options.MinInstances,
|
||
MaxConcurrent = _options.MaxConcurrent,
|
||
MaxTasksPerInstance = _options.MaxTasksPerBrowserInstance,
|
||
RestartMemoryMb = _options.BrowserRestartMemoryMb,
|
||
HealthCheckIntervalMs = _options.HealthCheckIntervalMs
|
||
}
|
||
};
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<bool> HealthCheckAsync(CancellationToken cancellationToken = default)
|
||
{
|
||
try
|
||
{
|
||
var unhealthyCount = 0;
|
||
foreach (var instance in _allBrowsers.Values.ToList())
|
||
{
|
||
if (!await ValidateBrowserAsync(instance))
|
||
{
|
||
unhealthyCount++;
|
||
}
|
||
}
|
||
|
||
return unhealthyCount == 0;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "健康检查失败");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 执行健康检查
|
||
/// </summary>
|
||
private async Task PerformHealthCheckAsync()
|
||
{
|
||
if (_disposed) return;
|
||
|
||
try
|
||
{
|
||
var instancesToRestart = new List<BrowserInstance>();
|
||
|
||
foreach (var instance in _allBrowsers.Values.ToList())
|
||
{
|
||
// 检查连接状态
|
||
if (!instance.Browser.IsConnected)
|
||
{
|
||
_logger.LogWarning("检测到断开的浏览器实例: {BrowserId}", instance.Id);
|
||
instancesToRestart.Add(instance);
|
||
continue;
|
||
}
|
||
|
||
// 检查内存使用
|
||
await UpdateMemoryUsageAsync(instance);
|
||
if (instance.MemoryUsageMb > _options.BrowserRestartMemoryMb)
|
||
{
|
||
_logger.LogWarning("浏览器实例内存过高: {BrowserId}, 内存: {Memory}MB",
|
||
instance.Id, instance.MemoryUsageMb);
|
||
instancesToRestart.Add(instance);
|
||
continue;
|
||
}
|
||
|
||
// 执行简单的健康探测
|
||
if (!await PerformLivenessProbeAsync(instance))
|
||
{
|
||
_logger.LogWarning("浏览器实例健康探测失败: {BrowserId}", instance.Id);
|
||
instancesToRestart.Add(instance);
|
||
}
|
||
}
|
||
|
||
// 重启不健康的实例
|
||
foreach (var instance in instancesToRestart)
|
||
{
|
||
await RestartBrowserAsync(instance);
|
||
}
|
||
|
||
// 补充实例到最小数量
|
||
while (_totalInstances < _options.MinInstances && !_disposed)
|
||
{
|
||
try
|
||
{
|
||
var newInstance = await CreateBrowserAsync(CancellationToken.None);
|
||
_availableBrowsers.Add(newInstance);
|
||
_logger.LogDebug("补充浏览器实例: {BrowserId}", newInstance.Id);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "补充浏览器实例失败");
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "健康检查过程出错");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 验证浏览器实例是否可用
|
||
/// </summary>
|
||
private async Task<bool> ValidateBrowserAsync(BrowserInstance instance)
|
||
{
|
||
try
|
||
{
|
||
if (!instance.Browser.IsConnected)
|
||
return false;
|
||
|
||
// 简单验证:尝试获取页面列表
|
||
var pages = await instance.Browser.PagesAsync();
|
||
return true;
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 执行存活探测
|
||
/// </summary>
|
||
private async Task<bool> PerformLivenessProbeAsync(BrowserInstance instance)
|
||
{
|
||
IPage? page = null;
|
||
try
|
||
{
|
||
page = await instance.Browser.NewPageAsync();
|
||
await page.GoToAsync("about:blank", new NavigationOptions { Timeout = 5000 });
|
||
return true;
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
finally
|
||
{
|
||
if (page != null)
|
||
{
|
||
try { await page.CloseAsync(); } catch { }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 更新内存使用信息
|
||
/// </summary>
|
||
private async Task UpdateMemoryUsageAsync(BrowserInstance instance)
|
||
{
|
||
try
|
||
{
|
||
var process = instance.Browser.Process;
|
||
if (process != null && !process.HasExited)
|
||
{
|
||
process.Refresh();
|
||
instance.MemoryUsageMb = process.WorkingSet64 / 1024 / 1024;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// 忽略进程信息获取失败
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 判断是否需要重启浏览器
|
||
/// </summary>
|
||
private bool ShouldRestartBrowser(BrowserInstance instance)
|
||
{
|
||
// 任务数超限
|
||
if (instance.TaskCount >= _options.MaxTasksPerBrowserInstance)
|
||
return true;
|
||
|
||
// 内存超限
|
||
if (instance.MemoryUsageMb > _options.BrowserRestartMemoryMb)
|
||
return true;
|
||
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取重启原因
|
||
/// </summary>
|
||
private string GetRestartReason(BrowserInstance instance)
|
||
{
|
||
if (instance.TaskCount >= _options.MaxTasksPerBrowserInstance)
|
||
return $"任务数超限 ({instance.TaskCount}/{_options.MaxTasksPerBrowserInstance})";
|
||
if (instance.MemoryUsageMb > _options.BrowserRestartMemoryMb)
|
||
return $"内存超限 ({instance.MemoryUsageMb}MB/{_options.BrowserRestartMemoryMb}MB)";
|
||
return "未知";
|
||
}
|
||
|
||
/// <summary>
|
||
/// 重启浏览器实例
|
||
/// </summary>
|
||
private async Task RestartBrowserAsync(BrowserInstance instance)
|
||
{
|
||
try
|
||
{
|
||
Interlocked.Increment(ref _restartCount);
|
||
|
||
// 从池中移除
|
||
_allBrowsers.TryRemove(instance.Id, out _);
|
||
Interlocked.Decrement(ref _totalInstances);
|
||
|
||
// 关闭旧实例
|
||
try
|
||
{
|
||
await instance.Browser.CloseAsync();
|
||
}
|
||
catch { }
|
||
|
||
// 创建新实例
|
||
if (!_disposed)
|
||
{
|
||
var newInstance = await CreateBrowserAsync(CancellationToken.None);
|
||
_availableBrowsers.Add(newInstance);
|
||
_logger.LogInformation("浏览器实例已重启: {OldId} -> {NewId}", instance.Id, newInstance.Id);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "重启浏览器实例失败: {BrowserId}", instance.Id);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 创建浏览器实例
|
||
/// </summary>
|
||
private async Task<BrowserInstance> CreateBrowserAsync(CancellationToken cancellationToken)
|
||
{
|
||
await _createLock.WaitAsync(cancellationToken);
|
||
try
|
||
{
|
||
if (_totalInstances >= _options.MaxInstances)
|
||
{
|
||
throw new InvalidOperationException($"浏览器实例数已达上限: {_options.MaxInstances}");
|
||
}
|
||
|
||
// 确保 Chromium 已下载
|
||
var chromiumPath = await EnsureChromiumDownloadedAsync();
|
||
var executablePath = string.IsNullOrWhiteSpace(_options.ExecutablePath)
|
||
? chromiumPath
|
||
: _options.ExecutablePath;
|
||
|
||
_logger.LogDebug("使用的 Chromium 路径: {Path}", executablePath);
|
||
|
||
var launchOptions = new LaunchOptions
|
||
{
|
||
Headless = true,
|
||
Args = _options.BrowserArgs,
|
||
ExecutablePath = executablePath
|
||
};
|
||
|
||
var browser = await Puppeteer.LaunchAsync(launchOptions);
|
||
|
||
var instance = new BrowserInstance
|
||
{
|
||
Id = Guid.NewGuid().ToString(),
|
||
Browser = browser,
|
||
CreatedAt = DateTime.UtcNow,
|
||
LastUsedAt = DateTime.UtcNow
|
||
};
|
||
|
||
_allBrowsers.TryAdd(instance.Id, instance);
|
||
Interlocked.Increment(ref _totalInstances);
|
||
|
||
_logger.LogDebug("成功创建浏览器实例: {BrowserId}", instance.Id);
|
||
|
||
return instance;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "创建浏览器实例失败");
|
||
throw;
|
||
}
|
||
finally
|
||
{
|
||
_createLock.Release();
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<string> EnsureChromiumReadyAsync(Action<string>? progress = null, CancellationToken cancellationToken = default)
|
||
{
|
||
return await EnsureChromiumDownloadedAsync(progress, cancellationToken);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 确保 Chromium 已下载
|
||
/// </summary>
|
||
private async Task<string> EnsureChromiumDownloadedAsync(Action<string>? progress = null, CancellationToken cancellationToken = default)
|
||
{
|
||
try
|
||
{
|
||
var message = "检查 Chromium 是否已下载...";
|
||
_logger.LogInformation(message);
|
||
progress?.Invoke(message);
|
||
|
||
// 使用固定缓存目录,方便 Docker volume 映射持久化
|
||
var cacheDir = Environment.GetEnvironmentVariable("PUPPETEER_CACHE_DIR")
|
||
?? Path.Combine(AppContext.BaseDirectory, "chromium");
|
||
Directory.CreateDirectory(cacheDir);
|
||
|
||
var fetcherOptions = new BrowserFetcherOptions { Path = cacheDir };
|
||
|
||
// 支持通过环境变量配置 Chromium 下载代理
|
||
var proxyUrl = Environment.GetEnvironmentVariable("CHROMIUM_DOWNLOAD_PROXY");
|
||
if (!string.IsNullOrWhiteSpace(proxyUrl))
|
||
{
|
||
_logger.LogInformation("使用代理下载 Chromium: {Proxy}", proxyUrl);
|
||
fetcherOptions.CustomFileDownload = async (url, destinationPath) =>
|
||
{
|
||
var handler = new System.Net.Http.HttpClientHandler
|
||
{
|
||
Proxy = new System.Net.WebProxy(proxyUrl),
|
||
UseProxy = true
|
||
};
|
||
using var httpClient = new System.Net.Http.HttpClient(handler);
|
||
httpClient.Timeout = TimeSpan.FromMinutes(10);
|
||
|
||
using var response = await httpClient.GetAsync(url);
|
||
response.EnsureSuccessStatusCode();
|
||
|
||
using var fileStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||
await response.Content.CopyToAsync(fileStream);
|
||
};
|
||
}
|
||
|
||
var browserFetcher = new BrowserFetcher(fetcherOptions);
|
||
var installedBrowsers = browserFetcher.GetInstalledBrowsers();
|
||
|
||
if (installedBrowsers.Any())
|
||
{
|
||
var installedBrowser = installedBrowsers.First();
|
||
var executablePath = installedBrowser.GetExecutablePath();
|
||
message = $"Chromium 已存在,版本: {installedBrowser.BuildId}";
|
||
_logger.LogInformation("Chromium 已存在,版本: {Version},路径: {Path}",
|
||
installedBrowser.BuildId, executablePath);
|
||
progress?.Invoke(message);
|
||
return executablePath;
|
||
}
|
||
|
||
message = "Chromium 未安装,开始下载(约 150MB,请耐心等待)...";
|
||
_logger.LogInformation(message);
|
||
progress?.Invoke(message);
|
||
|
||
// PuppeteerSharp 20.x 不再支持下载进度回调,直接下载
|
||
var installed = await browserFetcher.DownloadAsync();
|
||
var path = installed.GetExecutablePath();
|
||
|
||
message = "Chromium 下载完成!";
|
||
_logger.LogInformation("Chromium 下载完成,路径: {Path}", path);
|
||
progress?.Invoke(message);
|
||
|
||
return path;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "下载 Chromium 失败");
|
||
throw new InvalidOperationException("无法下载 Chromium,请检查网络连接或手动下载", ex);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 释放浏览器实例
|
||
/// </summary>
|
||
private async Task DisposeBrowserAsync(BrowserInstance instance)
|
||
{
|
||
try
|
||
{
|
||
_allBrowsers.TryRemove(instance.Id, out _);
|
||
Interlocked.Decrement(ref _totalInstances);
|
||
|
||
await instance.Browser.CloseAsync();
|
||
_logger.LogDebug("浏览器实例已清理: {BrowserId}", instance.Id);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "清理浏览器实例时出错: {BrowserId}", instance.Id);
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task UpdateConfigurationAsync(int? maxInstances = null, int? minInstances = null, int? maxConcurrent = null)
|
||
{
|
||
if (_disposed)
|
||
throw new ObjectDisposedException(nameof(BrowserPool));
|
||
|
||
var updated = false;
|
||
var oldMax = _options.MaxInstances;
|
||
var oldMin = _options.MinInstances;
|
||
|
||
if (maxInstances.HasValue && maxInstances.Value != _options.MaxInstances)
|
||
{
|
||
_options.MaxInstances = maxInstances.Value;
|
||
updated = true;
|
||
_logger.LogInformation("浏览器池最大实例数已更新: {Old} -> {New}", oldMax, maxInstances.Value);
|
||
}
|
||
|
||
if (minInstances.HasValue && minInstances.Value != _options.MinInstances)
|
||
{
|
||
_options.MinInstances = minInstances.Value;
|
||
updated = true;
|
||
_logger.LogInformation("浏览器池最小实例数已更新: {Old} -> {New}", oldMin, minInstances.Value);
|
||
}
|
||
|
||
// 注意:MaxConcurrent 由信号量控制,运行时修改较复杂,这里只记录日志
|
||
if (maxConcurrent.HasValue && maxConcurrent.Value != _options.MaxConcurrent)
|
||
{
|
||
_logger.LogWarning("最大并发数修改需要重启服务才能生效: {Old} -> {New}",
|
||
_options.MaxConcurrent, maxConcurrent.Value);
|
||
}
|
||
|
||
if (updated)
|
||
{
|
||
// 如果最小实例数增加,需要补充实例
|
||
if (_totalInstances < _options.MinInstances)
|
||
{
|
||
var toCreate = _options.MinInstances - _totalInstances;
|
||
_logger.LogInformation("补充浏览器实例: {Count}", toCreate);
|
||
|
||
for (int i = 0; i < toCreate && _totalInstances < _options.MaxInstances; i++)
|
||
{
|
||
try
|
||
{
|
||
var instance = await CreateBrowserAsync(CancellationToken.None);
|
||
_availableBrowsers.Add(instance);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "补充浏览器实例失败");
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果最大实例数减少且当前实例数超过新上限,需要移除多余实例
|
||
// 这里采用惰性策略:不主动移除,等实例自然回收
|
||
if (_totalInstances > _options.MaxInstances)
|
||
{
|
||
_logger.LogInformation("当前实例数 {Current} 超过新的最大值 {Max},多余实例将在使用完毕后自然回收",
|
||
_totalInstances, _options.MaxInstances);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async ValueTask DisposeAsync()
|
||
{
|
||
if (_disposed) return;
|
||
|
||
_disposed = true;
|
||
_logger.LogInformation("开始清理浏览器池...");
|
||
|
||
// 停止健康检查
|
||
try
|
||
{
|
||
await _healthCheckTimer.DisposeAsync();
|
||
}
|
||
catch { }
|
||
|
||
// 清理所有浏览器实例
|
||
var disposeTasks = _allBrowsers.Values.Select(async instance =>
|
||
{
|
||
try
|
||
{
|
||
// 先尝试优雅关闭
|
||
var closeTask = instance.Browser.CloseAsync();
|
||
var completed = await Task.WhenAny(closeTask, Task.Delay(3000)) == closeTask;
|
||
|
||
if (!completed)
|
||
{
|
||
_logger.LogWarning("浏览器实例关闭超时,强制终止进程: {BrowserId}", instance.Id);
|
||
}
|
||
|
||
// 确保进程被终止
|
||
ForceKillBrowserProcess(instance);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "清理浏览器实例时出错: {BrowserId}", instance.Id);
|
||
// 即使 CloseAsync 失败,也尝试强制终止进程
|
||
ForceKillBrowserProcess(instance);
|
||
}
|
||
});
|
||
|
||
await Task.WhenAll(disposeTasks);
|
||
|
||
_allBrowsers.Clear();
|
||
_availableBrowsers.Clear();
|
||
_totalInstances = 0;
|
||
|
||
_semaphore.Dispose();
|
||
_createLock.Dispose();
|
||
|
||
_logger.LogInformation("浏览器池清理完成");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 强制终止浏览器进程
|
||
/// </summary>
|
||
private void ForceKillBrowserProcess(BrowserInstance instance)
|
||
{
|
||
try
|
||
{
|
||
var process = instance.Browser.Process;
|
||
if (process != null && !process.HasExited)
|
||
{
|
||
_logger.LogDebug("强制终止浏览器进程: {BrowserId}, PID: {Pid}", instance.Id, process.Id);
|
||
process.Kill(entireProcessTree: true);
|
||
process.WaitForExit(2000); // 等待最多 2 秒
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "强制终止浏览器进程失败: {BrowserId}", instance.Id);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 浏览器实例
|
||
/// </summary>
|
||
internal class BrowserInstance
|
||
{
|
||
public string Id { get; set; } = string.Empty;
|
||
public IBrowser Browser { get; set; } = null!;
|
||
public DateTime CreatedAt { get; set; }
|
||
public DateTime LastUsedAt { get; set; }
|
||
public int TaskCount { get; set; }
|
||
public long MemoryUsageMb { get; set; }
|
||
}
|
||
|