• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

aelassas / servy / 24115526031

08 Apr 2026 03:08AM UTC coverage: 98.97% (-1.0%) from 100.0%
24115526031

push

github

aelassas
fix(core): ProcessHelper.CpuTimesStore: ConcurrentDictionary grows unbounded - memory leak on long-running systems

304 of 307 branches covered (99.02%)

Branch coverage included in aggregate %.

2 of 24 new or added lines in 1 file covered. (8.33%)

9 existing lines in 1 file now uncovered.

2963 of 2994 relevant lines covered (98.96%)

69.48 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

75.36
/src/Servy.Core/Helpers/ProcessHelper.cs
1
using System;
2
using System.Collections.Concurrent;
3
using System.Diagnostics;
4
using System.Diagnostics.CodeAnalysis;
5
using System.Globalization;
6
using System.IO;
7
using System.Text;
8
using System.Text.RegularExpressions;
9

10
namespace Servy.Core.Helpers
11
{
12
    /// <summary>
13
    /// Provides helper methods for retrieving and formatting process-related information
14
    /// such as CPU usage and RAM usage.
15
    /// </summary>
16
    public static class ProcessHelper
17
    {
18
        private static DateTime _lastPruneTime = DateTime.MinValue;
1✔
19
        private static readonly TimeSpan PruneInterval = TimeSpan.FromMinutes(5);
1✔
20

21
        /// <summary>
22
        /// Stores the last CPU measurement for a process.
23
        /// </summary>
24
        [ExcludeFromCodeCoverage]
25
        private sealed class CpuSample
26
        {
27
            /// <summary>
28
            /// The date and time of the last CPU measurement.
29
            /// </summary>
30
            public DateTime LastTime;
31

32
            /// <summary>
33
            /// The total processor time used by the process at the last measurement.
34
            /// </summary>
35
            public TimeSpan LastTotalTime;
36
        }
37

38
        /// <summary>
39
        /// Provides a storage container for CPU usage samples.
40
        /// This class holds the last recorded CPU usage values for each process ID
41
        /// and is excluded from code coverage because it only acts as an internal cache.
42
        /// </summary>
43
        [ExcludeFromCodeCoverage]
44
        private static class CpuTimesStore
45
        {
46
            /// <summary>
47
            /// Stores the last recorded CPU usage sample for each process ID.
48
            /// </summary>
49
            public static readonly ConcurrentDictionary<int, CpuSample> PrevCpuTimes = new ConcurrentDictionary<int, CpuSample>();
50
        }
51

52
        /// <summary>
53
        /// Gets the CPU usage percentage of a process over the interval since the last sample.
54
        /// Should be called repeatedly (e.g., by a background timer every 4 seconds).
55
        /// </summary>
56
        /// <param name="pid">The process ID.</param>
57
        /// <returns>The CPU usage percentage rounded to one decimal place, or 0 if not available.</returns>
58
        [ExcludeFromCodeCoverage]
59
        public static double GetCpuUsage(int pid)
60
        {
61
            // 1. Periodically prune dead PIDs to prevent memory leaks and stale calculations
62
            PruneDeadProcesses();
63

64
            try
65
            {
66
                using (var process = Process.GetProcessById(pid))
67
                {
68
                    var now = DateTime.UtcNow;
69
                    var totalTime = process.TotalProcessorTime;
70

71
                    if (!CpuTimesStore.PrevCpuTimes.TryGetValue(pid, out var prev) || prev == null)
72
                    {
73
                        CpuTimesStore.PrevCpuTimes[pid] = new CpuSample
74
                        {
75
                            LastTime = now,
76
                            LastTotalTime = totalTime
77
                        };
78
                        return 0;
79
                    }
80

81
                    var deltaTime = (now - prev.LastTime).TotalMilliseconds;
82
                    var deltaCpu = (totalTime - prev.LastTotalTime).TotalMilliseconds;
83

84
                    // Handle invalid or inconsistent samples
85
                    if (deltaTime <= 0 || deltaCpu < 0) return 0;
86

87
                    double usage = (deltaCpu / (deltaTime * Environment.ProcessorCount)) * 100.0;
88

89
                    // Update stored sample
90
                    CpuTimesStore.PrevCpuTimes[pid] = new CpuSample
91
                    {
92
                        LastTime = now,
93
                        LastTotalTime = totalTime
94
                    };
95

96
                    return Math.Round(usage, 1, MidpointRounding.AwayFromZero);
97
                }
98
            }
99
            catch (ArgumentException)
100
            {
101
                // Process no longer exists -> remove stale entry
102
                CpuTimesStore.PrevCpuTimes.TryRemove(pid, out _);
103
                return 0;
104
            }
105
            catch (Exception)
106
            {
107
                return 0;
108
            }
109
        }
110

111
        /// <summary>
112
        /// Removes entries for processes that are no longer running.
113
        /// Throttled to execute at most once every 5 minutes.
114
        /// </summary>
115
        private static void PruneDeadProcesses()
NEW
116
        {
×
NEW
117
            if (DateTime.UtcNow - _lastPruneTime < PruneInterval) return;
×
118

NEW
119
            foreach (var pid in CpuTimesStore.PrevCpuTimes.Keys)
×
NEW
120
            {
×
NEW
121
                bool isAlive = false;
×
122
                try
NEW
123
                {
×
NEW
124
                    using (var p = Process.GetProcessById(pid))
×
NEW
125
                    {
×
NEW
126
                        isAlive = !p.HasExited;
×
NEW
127
                    }
×
NEW
128
                }
×
NEW
129
                catch (ArgumentException)
×
NEW
130
                {
×
NEW
131
                    isAlive = false;
×
NEW
132
                }
×
133

NEW
134
                if (!isAlive)
×
NEW
135
                {
×
NEW
136
                    CpuTimesStore.PrevCpuTimes.TryRemove(pid, out _);
×
NEW
137
                }
×
NEW
138
            }
×
139

NEW
140
            _lastPruneTime = DateTime.UtcNow;
×
NEW
141
        }
×
142

143
        public static long GetRamUsage(int pid)
UNCOV
144
        {
×
145
            try
UNCOV
146
            {
×
UNCOV
147
                using (var process = Process.GetProcessById(pid))
×
UNCOV
148
                {
×
149
                    // Private bytes (close to Task Manager's "Memory" column)
UNCOV
150
                    return process.PrivateMemorySize64;
×
151
                }
152
            }
UNCOV
153
            catch (Exception)
×
UNCOV
154
            {
×
UNCOV
155
                return 0;
×
156
            }
UNCOV
157
        }
×
158

159
        /// <summary>
160
        /// Formats a CPU usage value as a percentage string.
161
        /// </summary>
162
        /// <param name="cpuUsage">The CPU usage value.</param>
163
        /// <returns>
164
        /// A formatted string with a percent sign.
165
        /// Examples:
166
        /// <list type="bullet">
167
        /// <item><description>0 -> "0%"</description></item>
168
        /// <item><description>0.03 -> "0%"</description></item>
169
        /// <item><description>1 -> "1.0%"</description></item>
170
        /// <item><description>1.04 -> "1.0%"</description></item>
171
        /// <item><description>1.05 -> "1.1%"</description></item>
172
        /// <item><description>1.06 -> "1.1%"</description></item>
173
        /// <item><description>1.1 -> "1.1%"</description></item>
174
        /// <item><description>1.49 -> "1.4%"</description></item>
175
        /// <item><description>1.51 -> "1.5%"</description></item>
176
        /// <item><description>1.57 -> "1.5%"</description></item>
177
        /// <item><description>1.636 -> "1.6%"</description></item>
178
        /// </list>
179
        /// </returns>
180
        public static string FormatCpuUsage(double cpuUsage)
181
        {
11✔
182
            double rounded = Math.Round(cpuUsage, 1, MidpointRounding.AwayFromZero);
11✔
183
            const double epsilon = 0.0001;
184

185
            string formatted = Math.Abs(rounded) < epsilon
11✔
186
                ? "0"
11✔
187
                : rounded.ToString("0.0", CultureInfo.InvariantCulture);
11✔
188

189
            return $"{formatted}%";
11✔
190
        }
11✔
191

192
        /// <summary>
193
        /// Formats a RAM usage value in human-readable units.
194
        /// </summary>
195
        /// <param name="ramUsage">The RAM usage in bytes.</param>
196
        /// <returns>
197
        /// A formatted string with the most appropriate unit:
198
        /// B, KB, MB, GB, or TB.
199
        /// Examples:
200
        /// <list type="bullet">
201
        /// <item><description>512 -> "512.0 B"</description></item>
202
        /// <item><description>2048 -> "2.0 KB"</description></item>
203
        /// <item><description>1048576 -> "1.0 MB"</description></item>
204
        /// <item><description>1073741824 -> "1.0 GB"</description></item>
205
        /// </list>
206
        /// </returns>
207
        public static string FormatRamUsage(long ramUsage)
208
        {
10✔
209
            const double KB = 1024.0;
210
            const double MB = KB * 1024.0;
211
            const double GB = MB * 1024.0;
212
            const double TB = GB * 1024.0;
213

214
            string result;
215
            if (ramUsage < KB)
10✔
216
            {
1✔
217
                result = $"{ramUsage.ToString("0.0", CultureInfo.InvariantCulture)} B";
1✔
218
            }
1✔
219
            else if (ramUsage < MB)
9✔
220
            {
2✔
221
                result = $"{(ramUsage / KB).ToString("0.0", CultureInfo.InvariantCulture)} KB";
2✔
222
            }
2✔
223
            else if (ramUsage < GB)
7✔
224
            {
2✔
225
                result = $"{(ramUsage / MB).ToString("0.0", CultureInfo.InvariantCulture)} MB";
2✔
226
            }
2✔
227
            else if (ramUsage < TB)
5✔
228
            {
3✔
229
                result = $"{(ramUsage / GB).ToString("0.0", CultureInfo.InvariantCulture)} GB";
3✔
230
            }
3✔
231
            else
232
            {
2✔
233
                result = $"{(ramUsage / TB).ToString("0.0", CultureInfo.InvariantCulture)} TB";
2✔
234
            }
2✔
235

236
            return result;
10✔
237
        }
10✔
238

239
        /// <summary>
240
        /// Resolves and validates an absolute filesystem path for use by a Windows service.
241
        /// </summary>
242
        /// <param name="inputPath">
243
        /// The input path, which may contain environment variables (e.g. %ProgramFiles%).
244
        /// Environment variables are expanded using the service account's environment only.
245
        /// </param>
246
        /// <returns>
247
        /// A normalized, absolute path with environment variables expanded.
248
        /// </returns>
249
        /// <exception cref="ArgumentException">
250
        /// Thrown if the path is relative or contains environment variables that could not be expanded.
251
        /// </exception>
252
        /// <remarks>
253
        /// This method is intentionally strict:
254
        /// <list type="bullet">
255
        /// <item>Only absolute paths are allowed.</item>
256
        /// <item>Environment variables must be defined at the system level and visible to the service account.</item>
257
        /// <item>User-level environment variables are not supported.</item>
258
        /// </list>
259
        /// Use <see cref="ValidatePath"/> if you only need a boolean existence check.
260
        /// </remarks>
261
        public static string ResolvePath(string inputPath)
262
        {
17✔
263
            if (string.IsNullOrWhiteSpace(inputPath)) return inputPath;
21✔
264

265
            inputPath = inputPath.Trim();
13✔
266

267
            // 1. Expand variables (Note: only expands variables existing in the SERVICE'S environment)
268
            var expandedPath = Environment.ExpandEnvironmentVariables(inputPath);
13✔
269

270
            // 2. Strict Check: If the path still contains %, expansion likely failed 
271
            // because the variable is not defined for the service account (e.g., LocalSystem).
272
            var match = Regex.Match(expandedPath, @"%[^%]+%");
13✔
273
            if (match.Success)
13✔
274
            {
2✔
275
                var varName = match.Groups[0].Value;
2✔
276
                throw new InvalidOperationException(
2✔
277
                    $"Environment variable '{varName}' could not be expanded. " +
2✔
278
                    "Ensure it is defined as a System variable and visible to the service account.");
2✔
279
            }
280

281
            // 3. Ensure the path is absolute
282
            if (!Path.IsPathRooted(expandedPath))
11✔
283
            {
2✔
284
                throw new InvalidOperationException($"Path '{expandedPath}' is relative. Only absolute paths are allowed.");
2✔
285
            }
286

287
            // 4. Normalize (removes trailing slashes, resolves ..\ segments)
288
            return Path.GetFullPath(expandedPath);
9✔
289
        }
13✔
290

291
        /// <summary>
292
        /// Validates that a file or directory path exists after resolving environment variables
293
        /// and normalizing the path.
294
        /// </summary>
295
        /// <param name="path">
296
        /// The path to validate. May contain environment variables.
297
        /// </param>
298
        /// <param name="isFile">
299
        /// True to validate a file path; false to validate a directory path.
300
        /// </param>
301
        /// <returns>
302
        /// True if the path resolves successfully and exists; otherwise false.
303
        /// </returns>
304
        /// <remarks>
305
        /// This method never throws exceptions.
306
        /// Any failure during resolution (such as unexpanded environment variables,
307
        /// relative paths, or invalid paths) results in a false return value.
308
        /// </remarks>
309
        public static bool ValidatePath(string path, bool isFile = true)
310
        {
10✔
311
            try
312
            {
10✔
313
                var expandedPath = ResolvePath(path);
10✔
314

315
                if (string.IsNullOrWhiteSpace(expandedPath)) return false;
10✔
316

317
                if (isFile)
6✔
318
                {
3✔
319
                    return File.Exists(expandedPath);
3✔
320
                }
321
                else
322
                {
3✔
323
                    return Directory.Exists(expandedPath);
3✔
324
                }
325
            }
326
            catch
2✔
327
            {
2✔
328
                return false; // ResolvePath failed (unexpanded vars or relative path)
2✔
329
            }
330
        }
10✔
331

332
        /// <summary>
333
        /// Encapsulates a string argument in double quotes and escapes internal quotes and backslashes 
334
        /// according to the Win32 'CommandLineToArgvW' rules.
335
        /// </summary>
336
        /// <remarks>
337
        /// This is required in .NET Framework 4.8 and earlier when building <see cref="System.Diagnostics.ProcessStartInfo.Arguments"/> 
338
        /// to prevent argument injection vulnerabilities and ensure paths with spaces or special characters are parsed correctly.
339
        /// 
340
        /// Rule summary:
341
        /// 1. The argument is wrapped in double quotes.
342
        /// 2. Double quotes inside the string are escaped with a backslash (\").
343
        /// 3. Backslashes are treated literally unless they immediately precede a double quote.
344
        /// 4. If backslashes precede a double quote, they must be doubled (2n) so the last one doesn't escape the quote.
345
        /// </remarks>
346
        /// <param name="arg">The raw command-line argument to escape.</param>
347
        /// <returns>A shell-safe, quoted string ready for use in a process start command.</returns>
348
        public static string EscapeProcessArgument(string arg)
349
        {
12✔
350
            if (string.IsNullOrWhiteSpace(arg)) return "\"\"";
15✔
351

352
            // Replace " with \"
353
            // But we must also handle backslashes that precede a "
354
            // because \" is treated as a literal quote, and \\" is a literal backslash + quote.
355
            // The logic: 2n backslashes + " => n backslashes + literal "
356
            // 2n+1 backslashes + " => n backslashes + literal " + escape next... 
357
            // Actually, a simpler way for standard .NET/Windows:
358
            StringBuilder sb = new StringBuilder();
9✔
359
            sb.Append('"');
9✔
360
            for (int i = 0; i < arg.Length; i++)
306✔
361
            {
144✔
362
                int backslashCount = 0;
144✔
363
                while (i < arg.Length && arg[i] == '\\')
158✔
364
                {
14✔
365
                    backslashCount++;
14✔
366
                    i++;
14✔
367
                }
14✔
368

369
                if (i == arg.Length)
144✔
370
                {
3✔
371
                    // Backslashes at the end of the string need to be doubled 
372
                    // so they don't escape the closing quote
373
                    sb.Append('\\', backslashCount * 2);
3✔
374
                }
3✔
375
                else if (arg[i] == '"')
141✔
376
                {
5✔
377
                    // Backslashes before a quote need to be doubled, 
378
                    // and then the quote itself needs a backslash
379
                    sb.Append('\\', backslashCount * 2 + 1);
5✔
380
                    sb.Append('"');
5✔
381
                }
5✔
382
                else
383
                {
136✔
384
                    // Regular character, just add the backslashes and the char
385
                    sb.Append('\\', backslashCount);
136✔
386
                    sb.Append(arg[i]);
136✔
387
                }
136✔
388
            }
144✔
389
            sb.Append('"');
9✔
390
            return sb.ToString();
9✔
391
        }
12✔
392
    }
393
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc