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

orion-ecs / keen-eye / 30142648908

25 Jul 2026 03:39AM UTC coverage: 63.875% (-0.02%) from 63.89%
30142648908

Pull #1257

github

web-flow
Merge 74a30678c into db3e44307
Pull Request #1257: docs: Align docs with current engine state (blend modes, scene tooling, TestBridge client, NOVAFALL)

8870 of 13117 branches covered (67.62%)

Branch coverage included in aggregate %.

52559 of 83053 relevant lines covered (63.28%)

1.0 hits per line

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

69.68
/src/KeenEyes.TestBridge/Process/ManagedProcess.cs
1
using System.Diagnostics;
2
using System.Text;
3
using KeenEyes.TestBridge.Process;
4

5
namespace KeenEyes.TestBridge.ProcessManagement;
6

7
/// <summary>
8
/// Internal wrapper for System.Diagnostics.Process that manages output buffering
9
/// and provides thread-safe access to process state.
10
/// </summary>
11
internal sealed class ManagedProcess : IDisposable
12
{
13
    private readonly System.Diagnostics.Process process;
14
    private readonly ProcessStartOptions options;
15
    private readonly StringBuilder stdoutBuffer = new();
1✔
16
    private readonly StringBuilder stderrBuffer = new();
1✔
17
    private readonly StringBuilder stdoutFullHistory = new();
1✔
18
    private readonly StringBuilder stderrFullHistory = new();
1✔
19
    private readonly Lock bufferLock = new();
1✔
20
    private readonly TaskCompletionSource<int> exitTcs = new();
1✔
21
    private readonly DateTime startTime;
22
    private readonly Task? stdoutReadTask;
23
    private readonly Task? stderrReadTask;
24

25
    private bool disposed;
26

27
    public ManagedProcess(System.Diagnostics.Process process, ProcessStartOptions options)
1✔
28
    {
29
        this.process = process;
1✔
30
        this.options = options;
1✔
31
        startTime = DateTime.UtcNow;
1✔
32

33
        if (options.RedirectStdout)
1✔
34
        {
35
            stdoutReadTask = ReadOutputAsync(process.StandardOutput, stdoutBuffer, stdoutFullHistory, options.MaxStdoutBuffer);
1✔
36
        }
37

38
        if (options.RedirectStderr)
1✔
39
        {
40
            stderrReadTask = ReadOutputAsync(process.StandardError, stderrBuffer, stderrFullHistory, options.MaxStderrBuffer);
1✔
41
        }
42

43
        // Monitor for exit
44
        _ = MonitorExitAsync();
1✔
45
    }
1✔
46

47
    public int ProcessId => process.Id;
1✔
48

49
    public string Executable => options.Executable;
1✔
50

51
    public string? Arguments => options.Arguments;
1✔
52

53
    public string? WorkingDirectory => options.WorkingDirectory;
1✔
54

55
    public bool HasExited
56
    {
57
        get
58
        {
59
            try
60
            {
61
                return process.HasExited;
1✔
62
            }
63
            catch (InvalidOperationException)
×
64
            {
65
                return true;
×
66
            }
67
        }
1✔
68
    }
69

70
    public int? ExitCode
71
    {
72
        get
73
        {
74
            try
75
            {
76
                return HasExited ? process.ExitCode : null;
1✔
77
            }
78
            catch (InvalidOperationException)
×
79
            {
80
                return null;
×
81
            }
82
        }
1✔
83
    }
84

85
    public DateTime StartTime => startTime;
1✔
86

87
    public DateTime? ExitTime => HasExited ? DateTime.UtcNow : null;
1✔
88

89
    public ProcessInfo GetInfo()
90
    {
91
        return new ProcessInfo
1✔
92
        {
1✔
93
            ProcessId = ProcessId,
1✔
94
            Executable = Executable,
1✔
95
            Arguments = Arguments,
1✔
96
            WorkingDirectory = WorkingDirectory,
1✔
97
            HasExited = HasExited,
1✔
98
            ExitCode = ExitCode,
1✔
99
            StartTime = StartTime,
1✔
100
            ExitTime = ExitTime
1✔
101
        };
1✔
102
    }
103

104
    public async Task WriteLineAsync(string line, CancellationToken cancellationToken)
105
    {
106
        if (!options.RedirectStdin)
×
107
        {
108
            throw new InvalidOperationException("Standard input is not redirected for this process.");
×
109
        }
110

111
        if (HasExited)
×
112
        {
113
            throw new InvalidOperationException("Cannot write to a process that has exited.");
×
114
        }
115

116
        await process.StandardInput.WriteLineAsync(line.AsMemory(), cancellationToken);
×
117
        await process.StandardInput.FlushAsync(cancellationToken);
×
118
    }
×
119

120
    public string ReadStdout()
121
    {
1✔
122
        lock (bufferLock)
123
        {
124
            var result = stdoutBuffer.ToString();
1✔
125
            stdoutBuffer.Clear();
1✔
126
            return result;
1✔
127
        }
128
    }
1✔
129

130
    public string ReadStderr()
131
    {
×
132
        lock (bufferLock)
133
        {
134
            var result = stderrBuffer.ToString();
×
135
            stderrBuffer.Clear();
×
136
            return result;
×
137
        }
138
    }
×
139

140
    public string PeekStdout()
141
    {
1✔
142
        lock (bufferLock)
143
        {
144
            return stdoutBuffer.ToString();
1✔
145
        }
146
    }
1✔
147

148
    public string PeekStderr()
149
    {
×
150
        lock (bufferLock)
151
        {
152
            return stderrBuffer.ToString();
×
153
        }
154
    }
×
155

156
    public string GetFullStdout()
157
    {
1✔
158
        lock (bufferLock)
159
        {
160
            return stdoutFullHistory.ToString();
1✔
161
        }
162
    }
1✔
163

164
    public string GetFullStderr()
165
    {
1✔
166
        lock (bufferLock)
167
        {
168
            return stderrFullHistory.ToString();
1✔
169
        }
170
    }
1✔
171

172
    public async Task<ProcessExitResult> WaitForExitAsync(TimeSpan? timeout, CancellationToken cancellationToken)
173
    {
174
        if (timeout.HasValue)
1✔
175
        {
176
            using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
1✔
177
            cts.CancelAfter(timeout.Value);
1✔
178

179
            try
180
            {
181
                await exitTcs.Task.WaitAsync(cts.Token);
1✔
182
            }
1✔
183
            catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
×
184
            {
185
                // Timeout occurred
186
                return new ProcessExitResult
×
187
                {
×
188
                    Completed = false,
×
189
                    ExitCode = null,
×
190
                    Stdout = GetFullStdout(),
×
191
                    Stderr = GetFullStderr(),
×
192
                    Duration = DateTime.UtcNow - startTime
×
193
                };
×
194
            }
195
        }
1✔
196
        else
197
        {
198
            await exitTcs.Task.WaitAsync(cancellationToken);
×
199
        }
200

201
        // Wait for output reading to complete
202
        if (stdoutReadTask != null)
1✔
203
        {
204
            await stdoutReadTask;
1✔
205
        }
206

207
        if (stderrReadTask != null)
1✔
208
        {
209
            await stderrReadTask;
1✔
210
        }
211

212
        return new ProcessExitResult
1✔
213
        {
1✔
214
            Completed = true,
1✔
215
            ExitCode = ExitCode,
1✔
216
            Stdout = GetFullStdout(),
1✔
217
            Stderr = GetFullStderr(),
1✔
218
            Duration = DateTime.UtcNow - startTime
1✔
219
        };
1✔
220
    }
1✔
221

222
    public async Task<bool> WaitForOutputAsync(string text, TimeSpan timeout, CancellationToken cancellationToken)
223
    {
224
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
1✔
225
        cts.CancelAfter(timeout);
1✔
226

227
        try
228
        {
229
            while (!cts.Token.IsCancellationRequested)
1✔
230
            {
1✔
231
                lock (bufferLock)
232
                {
233
                    if (stdoutFullHistory.ToString().Contains(text, StringComparison.Ordinal))
1✔
234
                    {
235
                        return true;
1✔
236
                    }
237
                }
1✔
238

239
                if (HasExited)
1✔
240
                {
×
241
                    // Check one more time after exit
242
                    lock (bufferLock)
243
                    {
244
                        return stdoutFullHistory.ToString().Contains(text, StringComparison.Ordinal);
×
245
                    }
246
                }
247

248
                await Task.Delay(50, cts.Token);
1✔
249
            }
250
        }
×
251
        catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
1✔
252
        {
1✔
253
            // Timeout - check one final time
254
            lock (bufferLock)
255
            {
256
                return stdoutFullHistory.ToString().Contains(text, StringComparison.Ordinal);
1✔
257
            }
258
        }
259

260
        return false;
×
261
    }
1✔
262

263
    public Task TerminateAsync()
264
    {
265
        if (HasExited)
1✔
266
        {
267
            return Task.CompletedTask;
×
268
        }
269

270
        // On Windows, we try to close the main window first for graceful shutdown
271
        // On Unix, Process.Kill() sends SIGTERM by default in .NET
272
        try
273
        {
274
            if (OperatingSystem.IsWindows())
1✔
275
            {
276
                // Try graceful shutdown first
277
                process.CloseMainWindow();
×
278
            }
279
            else
280
            {
281
                // On Unix, Kill() sends SIGTERM
282
                process.Kill();
1✔
283
            }
284
        }
1✔
285
        catch (InvalidOperationException)
×
286
        {
287
            // Process already exited
288
        }
×
289

290
        return Task.CompletedTask;
1✔
291
    }
292

293
    public Task KillAsync()
294
    {
295
        if (HasExited)
1✔
296
        {
297
            return Task.CompletedTask;
1✔
298
        }
299

300
        try
301
        {
302
            process.Kill(entireProcessTree: true);
1✔
303
        }
1✔
304
        catch (InvalidOperationException)
×
305
        {
306
            // Process already exited
307
        }
×
308

309
        return Task.CompletedTask;
1✔
310
    }
311

312
    public void Dispose()
313
    {
314
        if (disposed)
1✔
315
        {
316
            return;
×
317
        }
318

319
        disposed = true;
1✔
320

321
        try
322
        {
323
            if (!HasExited)
1✔
324
            {
325
                process.Kill(entireProcessTree: true);
1✔
326
            }
327
        }
1✔
328
        catch (InvalidOperationException)
×
329
        {
330
            // Process already exited, which is fine during cleanup
331
        }
×
332
        catch (SystemException)
×
333
        {
334
            // Other system errors during kill are acceptable during cleanup
335
        }
×
336

337
        process.Dispose();
1✔
338
    }
1✔
339

340
    private async Task MonitorExitAsync()
341
    {
342
        try
343
        {
344
            await process.WaitForExitAsync();
1✔
345
            exitTcs.TrySetResult(process.ExitCode);
1✔
346
        }
1✔
347
        catch (Exception ex)
×
348
        {
349
            exitTcs.TrySetException(ex);
×
350
        }
×
351
    }
1✔
352

353
    private async Task ReadOutputAsync(StreamReader reader, StringBuilder buffer, StringBuilder fullHistory, int maxBuffer)
354
    {
355
        var charBuffer = new char[4096];
1✔
356

357
        try
358
        {
359
            int charsRead;
360
            while ((charsRead = await reader.ReadAsync(charBuffer, CancellationToken.None)) > 0)
1✔
361
            {
362
                var text = new string(charBuffer, 0, charsRead);
1✔
363

364
                lock (bufferLock)
365
                {
366
                    buffer.Append(text);
1✔
367
                    fullHistory.Append(text);
1✔
368

369
                    // Trim buffer if exceeds max size (FIFO - remove oldest)
370
                    if (buffer.Length > maxBuffer)
1✔
371
                    {
372
                        var excess = buffer.Length - maxBuffer;
×
373
                        buffer.Remove(0, excess);
×
374
                    }
375

376
                    // Trim full history too to prevent unbounded memory growth
377
                    if (fullHistory.Length > maxBuffer * 2)
1✔
378
                    {
379
                        var excess = fullHistory.Length - maxBuffer;
×
380
                        fullHistory.Remove(0, excess);
×
381
                    }
382
                }
1✔
383
            }
384
        }
1✔
385
        catch (ObjectDisposedException)
×
386
        {
387
            // Stream was closed, normal during shutdown
388
        }
×
389
        catch (IOException)
×
390
        {
391
            // IO error, process likely terminated
392
        }
×
393
    }
1✔
394
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc