Skip to content

Commit 2c6cc77

Browse files
Reduce foreground and background runtime overhead (#35)
* perf: cache persistent rule snapshots * perf: reduce process-start enrichment and logging * fix: dispose process handles and clear pid caches * test: cover low-overhead runtime policies
1 parent 58c8990 commit 2c6cc77

9 files changed

Lines changed: 382 additions & 114 deletions

‎Services/PersistentProcessRuleJsonStore.cs‎

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ public sealed class PersistentProcessRuleJsonStore : IPersistentProcessRuleStore
1717

1818
private readonly Func<string> filePathProvider;
1919
private readonly ILogger<PersistentProcessRuleJsonStore>? logger;
20+
private readonly SemaphoreSlim cacheLock = new(1, 1);
21+
private volatile IReadOnlyList<PersistentProcessRule>? cachedRules;
2022

2123
public PersistentProcessRuleJsonStore(ILogger<PersistentProcessRuleJsonStore>? logger = null)
2224
: this(() => StoragePaths.PersistentRulesFilePath, logger)
@@ -33,44 +35,71 @@ internal PersistentProcessRuleJsonStore(
3335

3436
public async Task<IReadOnlyList<PersistentProcessRule>> LoadAsync()
3537
{
36-
var filePath = this.filePathProvider();
37-
this.logger?.LogDebug("Loading persistent process rules from {FilePath}", filePath);
38-
if (!File.Exists(filePath))
38+
if (this.cachedRules != null)
3939
{
40-
this.logger?.LogDebug("Persistent process rules file does not exist at {FilePath}", filePath);
41-
return [];
40+
return this.cachedRules;
4241
}
4342

43+
await this.cacheLock.WaitAsync().ConfigureAwait(false);
4444
try
4545
{
46-
var json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false);
47-
var rules = JsonSerializer.Deserialize<List<PersistentProcessRule>>(json, JsonOptions) ?? [];
48-
this.logger?.LogDebug("Loaded {RuleCount} persistent process rules from {FilePath}", rules.Count, filePath);
49-
return rules;
46+
if (this.cachedRules != null)
47+
{
48+
return this.cachedRules;
49+
}
50+
51+
var filePath = this.filePathProvider();
52+
this.logger?.LogDebug("Loading persistent process rules from {FilePath}", filePath);
53+
if (!File.Exists(filePath))
54+
{
55+
this.logger?.LogDebug("Persistent process rules file does not exist at {FilePath}", filePath);
56+
return this.cachedRules = [];
57+
}
58+
59+
try
60+
{
61+
var json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false);
62+
var rules = JsonSerializer.Deserialize<List<PersistentProcessRule>>(json, JsonOptions) ?? [];
63+
this.logger?.LogDebug("Loaded {RuleCount} persistent process rules from {FilePath}", rules.Count, filePath);
64+
return this.cachedRules = rules.ToArray();
65+
}
66+
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
67+
{
68+
this.logger?.LogWarning(ex, "Could not load persistent process rules from {FilePath}", filePath);
69+
return this.cachedRules = [];
70+
}
5071
}
51-
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
72+
finally
5273
{
53-
this.logger?.LogWarning(ex, "Could not load persistent process rules from {FilePath}", filePath);
54-
return [];
74+
this.cacheLock.Release();
5575
}
5676
}
5777

5878
public async Task SaveAsync(IReadOnlyList<PersistentProcessRule> rules)
5979
{
6080
ArgumentNullException.ThrowIfNull(rules);
6181

62-
var filePath = this.filePathProvider();
63-
this.logger?.LogDebug("Saving {RuleCount} persistent process rules to {FilePath}", rules.Count, filePath);
82+
await this.cacheLock.WaitAsync().ConfigureAwait(false);
6483
try
6584
{
66-
var json = JsonSerializer.Serialize(rules, JsonOptions);
67-
await AtomicFileWriter.WriteAllTextAsync(filePath, json).ConfigureAwait(false);
68-
this.logger?.LogDebug("Saved {RuleCount} persistent process rules to {FilePath}", rules.Count, filePath);
85+
var filePath = this.filePathProvider();
86+
this.logger?.LogDebug("Saving {RuleCount} persistent process rules to {FilePath}", rules.Count, filePath);
87+
try
88+
{
89+
var json = JsonSerializer.Serialize(rules, JsonOptions);
90+
await AtomicFileWriter.WriteAllTextAsync(filePath, json).ConfigureAwait(false);
91+
this.cachedRules = rules.ToArray();
92+
this.logger?.LogDebug("Saved {RuleCount} persistent process rules to {FilePath}", rules.Count, filePath);
93+
}
94+
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
95+
{
96+
this.logger?.LogError(ex, "Could not save {RuleCount} persistent process rules to {FilePath}", rules.Count, filePath);
97+
throw;
98+
}
6999
}
70-
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
100+
finally
71101
{
72-
this.logger?.LogError(ex, "Could not save {RuleCount} persistent process rules to {FilePath}", rules.Count, filePath);
73-
throw;
102+
this.cacheLock.Release();
74103
}
75104
}
76105
}

‎Services/PersistentRuleAutoApplyService.cs‎

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
namespace ThreadPilot.Services
55
{
66
using System.Collections.Concurrent;
7+
using System.IO;
78
using Microsoft.Extensions.Logging;
89
using ThreadPilot.Models;
910

@@ -86,6 +87,7 @@ public sealed class PersistentRuleAutoApplyService : IPersistentRuleAutoApplySer
8687
private readonly ILogger<PersistentRuleAutoApplyService> logger;
8788
private readonly IActivityAuditService? activityAuditService;
8889
private readonly IPersistentRulePriorityVerificationService? priorityVerificationService;
90+
private readonly IProcessService? processService;
8991
private readonly Func<DateTimeOffset> nowProvider;
9092
private readonly TimeSpan cooldown;
9193
private readonly ConcurrentDictionary<RuleAttemptKey, DateTimeOffset> recentAttempts = new();
@@ -97,8 +99,9 @@ public PersistentRuleAutoApplyService(
9799
IApplicationSettingsService settingsService,
98100
ILogger<PersistentRuleAutoApplyService> logger,
99101
IActivityAuditService? activityAuditService = null,
100-
IPersistentRulePriorityVerificationService? priorityVerificationService = null)
101-
: this(ruleStore, matcher, rulesEngine, settingsService, logger, () => DateTimeOffset.UtcNow, DefaultCooldown, activityAuditService, priorityVerificationService)
102+
IPersistentRulePriorityVerificationService? priorityVerificationService = null,
103+
IProcessService? processService = null)
104+
: this(ruleStore, matcher, rulesEngine, settingsService, logger, () => DateTimeOffset.UtcNow, DefaultCooldown, activityAuditService, priorityVerificationService, processService)
102105
{
103106
}
104107

@@ -111,7 +114,8 @@ public PersistentRuleAutoApplyService(
111114
Func<DateTimeOffset> nowProvider,
112115
TimeSpan cooldown,
113116
IActivityAuditService? activityAuditService = null,
114-
IPersistentRulePriorityVerificationService? priorityVerificationService = null)
117+
IPersistentRulePriorityVerificationService? priorityVerificationService = null,
118+
IProcessService? processService = null)
115119
{
116120
this.ruleStore = ruleStore ?? throw new ArgumentNullException(nameof(ruleStore));
117121
this.matcher = matcher ?? throw new ArgumentNullException(nameof(matcher));
@@ -122,6 +126,7 @@ public PersistentRuleAutoApplyService(
122126
this.cooldown = cooldown <= TimeSpan.Zero ? DefaultCooldown : cooldown;
123127
this.activityAuditService = activityAuditService;
124128
this.priorityVerificationService = priorityVerificationService;
129+
this.processService = processService;
125130
}
126131

127132
public async Task<IReadOnlyList<PersistentRuleAutoApplyResult>> ApplyForDiscoveredProcessesAsync(
@@ -190,8 +195,35 @@ private async Task<IReadOnlyList<PersistentRuleAutoApplyResult>> ApplyForProcess
190195
CancellationToken cancellationToken)
191196
{
192197
var now = this.nowProvider();
193-
var candidates = rules
194-
.Where(rule => rule.IsEnabled && this.matcher.IsMatch(rule, process))
198+
var potentialRules = rules
199+
.Where(rule => rule.IsEnabled && IsPotentialNameMatch(rule, process.Name))
200+
.ToList();
201+
202+
if (potentialRules.Count == 0)
203+
{
204+
return Array.Empty<PersistentRuleAutoApplyResult>();
205+
}
206+
207+
if (this.processService != null &&
208+
string.IsNullOrWhiteSpace(process.ExecutablePath) &&
209+
potentialRules.Any(rule => !string.IsNullOrWhiteSpace(rule.ExecutablePath)))
210+
{
211+
try
212+
{
213+
await this.processService.RefreshProcessInfo(process).ConfigureAwait(false);
214+
}
215+
catch (Exception ex)
216+
{
217+
this.logger.LogDebug(
218+
ex,
219+
"Could not enrich process {ProcessName} (PID: {ProcessId}) for path-based persistent rule matching",
220+
process.Name,
221+
process.ProcessId);
222+
}
223+
}
224+
225+
var candidates = potentialRules
226+
.Where(rule => this.matcher.IsMatch(rule, process))
195227
.ToList();
196228

197229
if (candidates.Count == 0)
@@ -393,6 +425,18 @@ private static string ResolveActivityMessage(PersistentRuleAutoApplyResult resul
393425
private bool IsEnabled() =>
394426
this.settingsService.Settings.ApplyPersistentRulesOnProcessStart;
395427

428+
private static bool IsPotentialNameMatch(PersistentProcessRule rule, string processName)
429+
{
430+
var candidateName = !string.IsNullOrWhiteSpace(rule.ProcessName)
431+
? rule.ProcessName
432+
: Path.GetFileNameWithoutExtension(rule.ExecutablePath);
433+
return !string.IsNullOrWhiteSpace(candidateName) &&
434+
string.Equals(
435+
Path.GetFileNameWithoutExtension(candidateName.Trim()),
436+
Path.GetFileNameWithoutExtension(processName.Trim()),
437+
StringComparison.OrdinalIgnoreCase);
438+
}
439+
396440
private static bool IsProcessEligible(ProcessModel process) =>
397441
process.ProcessId > 0 && !string.IsNullOrWhiteSpace(process.Name);
398442

‎Services/ProcessMonitorManagerService.cs‎

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -346,13 +346,34 @@ private async Task OnProcessStartedAsync(ProcessEventArgs e)
346346

347347
try
348348
{
349-
await this.enhancedLogger.LogProcessMonitoringEventAsync(
350-
LogEventTypes.ProcessMonitoring.Started,
351-
e.Process.Name, e.Process.ProcessId, "Process started and detected by monitoring");
349+
this.logger.LogDebug(
350+
"Process started: {ProcessName} (PID: {ProcessId})",
351+
e.Process.Name,
352+
e.Process.ProcessId);
352353

353354
await this.ApplyPersistentRulesForProcessStartAsync(e.Process);
354355

355-
var association = this.configuration.FindMatchingAssociation(e.Process);
356+
var associationCandidates = this.configuration
357+
.GetEnabledAssociations()
358+
.Where(association => association.MatchesExecutable(e.Process.Name))
359+
.ToList();
360+
if (associationCandidates.Count > 0)
361+
{
362+
try
363+
{
364+
await this.processService.RefreshProcessInfo(e.Process);
365+
}
366+
catch (Exception ex)
367+
{
368+
this.logger.LogDebug(
369+
ex,
370+
"Could not enrich process {ProcessName} (PID: {ProcessId}) for association matching",
371+
e.Process.Name,
372+
e.Process.ProcessId);
373+
}
374+
}
375+
376+
var association = associationCandidates.FirstOrDefault(candidate => candidate.MatchesProcess(e.Process));
356377
if (association != null)
357378
{
358379
this.runningAssociatedProcesses[e.Process.ProcessId] = e.Process;
@@ -411,12 +432,11 @@ private async Task OnProcessStoppedAsync(ProcessEventArgs e)
411432
try
412433
{
413434
this.persistentRuleAutoApplyService.MarkProcessExited(e.Process.ProcessId);
435+
this.coreMaskService.UnregisterMaskApplication(e.Process.ProcessId);
436+
this.processService.UntrackProcess(e.Process.ProcessId);
414437

415438
if (this.runningAssociatedProcesses.TryRemove(e.Process.ProcessId, out _))
416439
{
417-
this.coreMaskService.UnregisterMaskApplication(e.Process.ProcessId);
418-
this.processService.UntrackProcess(e.Process.ProcessId);
419-
420440
// Check if there are any other associated processes still running
421441
var remainingProcesses = this.runningAssociatedProcesses.Values.ToList();
422442
await this.DeterminePowerPlanAsync(remainingProcesses);

‎Services/ProcessMonitorService.cs‎

Lines changed: 12 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ namespace ThreadPilot.Services
33
using System;
44
using System.Collections.Concurrent;
55
using System.Collections.Generic;
6-
using System.Diagnostics;
76
using System.IO;
87
using System.Linq;
98
using System.Management;
@@ -371,23 +370,26 @@ private void OnProcessStarted(object sender, EventArrivedEventArgs e)
371370
});
372371
}
373372

374-
private async Task HandleProcessStartedAsync(EventArrivedEventArgs e)
373+
private Task HandleProcessStartedAsync(EventArrivedEventArgs e)
375374
{
376375
if (!this.isMonitoring || this.IsDisposed)
377376
{
378-
return;
377+
return Task.CompletedTask;
379378
}
380379

381380
try
382381
{
383382
var processId = Convert.ToInt32(e.NewEvent["ProcessID"]);
384383
var processName = e.NewEvent["ProcessName"]?.ToString() ?? string.Empty;
385384

386-
// Get detailed process information
387-
var process = await this.CreateProcessModelFromId(processId, processName).ConfigureAwait(false)
388-
?? (!string.IsNullOrWhiteSpace(processName)
389-
? new ProcessModel { ProcessId = processId, Name = NormalizeProcessName(processName) }
390-
: null);
385+
var process = !string.IsNullOrWhiteSpace(processName)
386+
? new ProcessModel
387+
{
388+
ProcessId = processId,
389+
Name = NormalizeProcessName(processName),
390+
Classification = ProcessClassification.Unknown,
391+
}
392+
: null;
391393

392394
if (process != null)
393395
{
@@ -399,6 +401,8 @@ private async Task HandleProcessStartedAsync(EventArrivedEventArgs e)
399401
{
400402
this.OnMonitoringStatusChanged($"Error handling process start event: {ex.Message}", ex);
401403
}
404+
405+
return Task.CompletedTask;
402406
}
403407

404408
private void OnProcessStopped(object sender, EventArrivedEventArgs e)
@@ -581,36 +585,6 @@ private async Task TryRecoverWmiMonitoringAsync()
581585
}
582586
}
583587

584-
private async Task<ProcessModel?> CreateProcessModelFromId(int processId, string processName)
585-
{
586-
try
587-
{
588-
using var process = Process.GetProcessById(processId);
589-
var normalizedName = !string.IsNullOrWhiteSpace(processName)
590-
? NormalizeProcessName(processName)
591-
: NormalizeProcessName(process.ProcessName);
592-
593-
return new ProcessModel
594-
{
595-
ProcessId = process.Id,
596-
Name = normalizedName,
597-
CpuUsage = 0,
598-
MemoryUsage = process.PrivateMemorySize64,
599-
Priority = process.PriorityClass,
600-
ProcessorAffinity = (long)process.ProcessorAffinity,
601-
MainWindowHandle = process.MainWindowHandle,
602-
MainWindowTitle = process.MainWindowTitle ?? string.Empty,
603-
HasVisibleWindow = process.MainWindowHandle != IntPtr.Zero && !string.IsNullOrWhiteSpace(process.MainWindowTitle),
604-
ExecutablePath = process.MainModule?.FileName ?? string.Empty,
605-
};
606-
}
607-
catch (Exception ex)
608-
{
609-
this.logger?.LogDebug(ex, "Process {ProcessId} terminated before access", processId);
610-
return null;
611-
}
612-
}
613-
614588
private void OnMonitoringStatusChanged(string? message = null, Exception? error = null)
615589
{
616590
this.MonitoringStatusChanged?.Invoke(this, new MonitoringStatusEventArgs(

0 commit comments

Comments
 (0)