< Summary

Information
Class: SwitchBlade.Services.UiaWorkerClient
Assembly: SwitchBlade
File(s): D:\a\switchblade\switchblade\Services\UiaWorkerClient.cs
Tag: 203_23722840422
Line coverage
100%
Covered lines: 239
Uncovered lines: 0
Coverable lines: 239
Total lines: 386
Line coverage: 100%
Branch coverage
100%
Covered branches: 110
Total branches: 110
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%1010100%
.cctor()100%11100%
ScanStreamingAsync()100%6666100%
ScanAsync()100%1414100%
ConvertToWindowItems(...)100%66100%
Dispose()100%1010100%

File(s)

D:\a\switchblade\switchblade\Services\UiaWorkerClient.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Diagnostics;
 4using System.IO;
 5using System.Text.Json;
 6using System.Threading;
 7using System.Runtime.CompilerServices;
 8using System.Threading.Tasks;
 9using SwitchBlade.Contracts;
 10using SwitchBlade.Core;
 11
 12namespace SwitchBlade.Services
 13{
 14    /// <summary>
 15    /// Client that spawns the UIA Worker process for out-of-process UI Automation scanning.
 16    ///
 17    /// This eliminates UIA memory leaks by running all UIA scans in a separate process that
 18    /// terminates after each scan. When the process exits, Windows releases all UIA COM objects.
 19    /// </summary>
 20    public class UiaWorkerClient : IUiaWorkerClient
 21    {
 22        private readonly string _workerPath;
 23        private readonly ILogger? _logger;
 24        private readonly TimeSpan _timeout;
 25        private readonly IProcessFactory _processFactory;
 26        private readonly IFileSystem _fileSystem;
 27        private bool _disposed;
 28
 29        // Concurrency management
 30        private IProcess? _activeProcess;
 7731        private readonly Lock _processLock = new();
 7732        private readonly CancellationTokenSource _disposeCts = new();
 33
 134        private static readonly JsonSerializerOptions JsonOptions = new()
 135        {
 136            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
 137            WriteIndented = false
 138        };
 39
 40        /// <summary>
 41        /// Creates a new UIA Worker Client.
 42        /// </summary>
 43        /// <param name="logger">Logger for diagnostics.</param>
 44        /// <param name="timeout">Timeout for worker process execution. Default 10 seconds.</param>
 45        /// <param name="processFactory">Process factory for spawning workers.</param>
 46        /// <param name="fileSystem">File system abstraction.</param>
 7747        public UiaWorkerClient(
 7748            ILogger? logger = null,
 7749            TimeSpan? timeout = null,
 7750            IProcessFactory? processFactory = null,
 7751            IFileSystem? fileSystem = null)
 7752        {
 7753            _logger = logger;
 7754            _timeout = timeout ?? TimeSpan.FromSeconds(10);
 7755            _processFactory = processFactory ?? new ProcessFactory(new SystemProcessProvider());
 7756            _fileSystem = fileSystem ?? new FileSystemWrapper();
 57
 58            // Find the worker executable relative to the main app
 7759            var appDir = AppContext.BaseDirectory;
 7760            _workerPath = Path.Combine(appDir, "SwitchBlade.UiaWorker.exe");
 61
 7762            if (!_fileSystem.FileExists(_workerPath))
 1063            {
 1064                _logger?.Log($"[UiaWorkerClient] WARNING: Worker not found at {_workerPath}");
 1065            }
 7766        }
 67
 68        /// <summary>
 69        /// Runs a UIA scan in the worker process with STREAMING results.
 70        /// Each plugin's results are yielded immediately as they complete.
 71        /// </summary>
 72        /// <param name="disabledPlugins">Set of disabled plugin names to skip.</param>
 73        /// <param name="excludedProcesses">Set of process names to exclude from scanning.</param>
 74        /// <param name="cancellationToken">Cancellation token.</param>
 75        /// <returns>Async stream of plugin results as they arrive.</returns>
 76        public async IAsyncEnumerable<UiaPluginResult> ScanStreamingAsync(
 77            IEnumerable<string>? disabledPlugins = null,
 78            IEnumerable<string>? excludedProcesses = null,
 79            [EnumeratorCancellation] CancellationToken cancellationToken = default)
 6480        {
 6481            ObjectDisposedException.ThrowIf(_disposed, this);
 82
 6383            if (!_fileSystem.FileExists(_workerPath))
 284            {
 285                _logger?.Log($"[UiaWorkerClient] Worker executable not found: {_workerPath}");
 286                yield break;
 87            }
 88
 6189            var request = new UiaRequest
 6190            {
 6191                Command = "scan",
 6192                DisabledPlugins = disabledPlugins != null ? [.. disabledPlugins] : null,
 6193                ExcludedProcesses = excludedProcesses != null ? [.. excludedProcesses] : null
 6194            };
 95
 6196            var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposeCts.Token);
 6197            combinedCts.CancelAfter(_timeout);
 98
 99            // Pass Parent PID for watchdog
 61100            var currentProcess = _processFactory.GetCurrentProcess();
 60101            int currentPid = currentProcess.Id;
 60102            var args = SwitchBlade.Core.Logger.IsDebugEnabled
 60103                ? $"/debug --parent {currentPid}"
 60104                : $"--parent {currentPid}";
 105
 60106            var psi = new ProcessStartInfo
 60107            {
 60108                FileName = _workerPath,
 60109                Arguments = args,
 60110                UseShellExecute = false,
 60111                CreateNoWindow = true,
 60112                RedirectStandardInput = true,
 60113                RedirectStandardOutput = true,
 60114                RedirectStandardError = true
 60115            };
 116
 117            IProcess? process;
 118            try
 60119            {
 60120                process = _processFactory.Start(psi);
 55121                if (process == null)
 2122                {
 2123                    _logger?.Log("[UiaWorkerClient] Worker process failed to start (null return)");
 2124                    yield break;
 125                }
 53126            }
 3127            catch (OperationCanceledException)
 3128            {
 3129                throw;
 130            }
 2131            catch (Exception ex)
 2132            {
 2133                _logger?.LogError($"[UiaWorkerClient] Failed to start worker process", ex);
 2134                yield break;
 135            }
 136
 137            lock (_processLock)
 53138            {
 53139                if (_disposed)
 1140                {
 3141                    try { process.Kill(entireProcessTree: true); } catch { }
 1142                    process.Dispose();
 1143                    throw new ObjectDisposedException(nameof(UiaWorkerClient));
 144                }
 52145                _activeProcess = process;
 52146            }
 147
 52148            _logger?.Log($"[UiaWorkerClient] Starting streaming worker: {_workerPath} (ParentPID={currentPid})");
 52149            var startTime = Stopwatch.GetTimestamp();
 150
 151            // Send request via stdin
 152            try
 52153            {
 52154                string requestJson = JsonSerializer.Serialize(request, JsonOptions);
 52155                await process.StandardInput.WriteLineAsync(requestJson);
 50156                await process.StandardInput.FlushAsync(cancellationToken);
 50157                process.StandardInput.Close();
 50158            }
 2159            catch (Exception ex)
 2160            {
 2161                _logger?.LogError($"[UiaWorkerClient] Failed to send request to worker", ex);
 162                // Continue to cleanup
 2163            }
 164
 52165            process.ErrorDataReceived += (s, e) =>
 4166            {
 4167                if (!string.IsNullOrEmpty(e.Data))
 2168                {
 52169                    // Log worker stderr to main log with a prefix
 2170                    _logger?.Log($"[UiaWorker STDERR] {e.Data}");
 2171                }
 56172            };
 52173            process.BeginErrorReadLine();
 174
 175            // Read streaming responses line by line (STDOUT)
 176            try
 52177            {
 69178                while (!combinedCts.Token.IsCancellationRequested)
 68179                {
 180                    string? line;
 181                    try
 68182                    {
 68183                        line = await process.StandardOutput.ReadLineAsync(combinedCts.Token);
 56184                    }
 4185                    catch (OperationCanceledException)
 4186                    {
 187                        // Explicitly caught - this is the expected path for timeouts
 4188                        _logger?.Log("[UiaWorkerClient] Streaming read cancelled/timed out.");
 16189                        try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch { }
 4190                        yield break;
 191                    }
 192
 56193                    if (line == null)
 22194                    {
 195                        // Process ended or closed stdout
 22196                        break;
 197                    }
 198
 199                    // Final check before yielding: if we were cancelled during the read, don't return partial garbage
 34200                    if (combinedCts.Token.IsCancellationRequested)
 2201                    {
 2202                        _logger?.Log("[UiaWorkerClient] Streaming read cancelled/timed out.");
 8203                        try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch { }
 2204                        yield break;
 205                    }
 206
 207                    UiaPluginResult? result;
 208                    try
 32209                    {
 32210                        result = JsonSerializer.Deserialize<UiaPluginResult>(line, JsonOptions);
 28211                    }
 4212                    catch (JsonException ex)
 4213                    {
 4214                        _logger?.Log($"[UiaWorkerClient] Failed to parse streaming line: {ex.Message}");
 4215                        continue;
 216                    }
 217
 28218                    if (result == null)
 2219                        continue;
 220
 26221                    if (result.IsFinal)
 15222                    {
 15223                        _logger?.Log("[UiaWorkerClient] Received final marker.");
 15224                        break;
 225                    }
 226
 11227                    _logger?.Log($"[UiaWorkerClient] Received {result.Windows?.Count ?? 0} windows from {result.PluginNa
 11228                    yield return result;
 11229                }
 230
 231                // If we exited the loop naturally but cancellation was requested, log it.
 232                // This handles cases where ReadLineAsync might return a cached line or finish just as the token is canc
 38233                if (combinedCts.Token.IsCancellationRequested)
 3234                {
 3235                    _logger?.Log("[UiaWorkerClient] Streaming read cancelled/timed out.");
 3236                }
 38237            }
 238            finally
 46239            {
 240                // Ensure active process is cleared
 241                lock (_processLock)
 46242                {
 46243                    _activeProcess = null;
 46244                }
 245
 246                // Wait for process to exit or kill if needed
 247                try
 46248                {
 46249                    if (!process.HasExited)
 43250                    {
 251                        try
 43252                        {
 43253                            if (combinedCts.Token.IsCancellationRequested)
 9254                            {
 9255                                process.Kill(entireProcessTree: true);
 9256                            }
 257                            else
 34258                            {
 34259                                await process.WaitForExitAsync(combinedCts.Token);
 28260                            }
 37261                        }
 6262                        catch
 6263                        {
 12264                            if (!process.HasExited) process.Kill(entireProcessTree: true);
 2265                        }
 39266                    }
 41267                }
 5268                catch (Exception ex)
 5269                {
 5270                    _logger?.Log($"[UiaWorkerClient] Error during process cleanup: {ex.Message}");
 23271                    try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch { }
 5272                }
 273
 46274                process.Dispose();
 46275                combinedCts.Dispose();
 276
 46277                var elapsed = Stopwatch.GetElapsedTime(startTime);
 46278                _logger?.Log($"[UiaWorkerClient] Streaming worker completed in {elapsed.TotalMilliseconds:F0}ms");
 46279            }
 50280        }
 281
 282        /// <summary>
 283        /// Runs a UIA scan in the worker process.
 284        /// Convenience wrapper that collects all streaming results into a single list.
 285        /// </summary>
 286        /// <param name="disabledPlugins">Set of disabled plugin names to skip.</param>
 287        /// <param name="excludedProcesses">Set of process names to exclude from scanning.</param>
 288        /// <param name="cancellationToken">Cancellation token.</param>
 289        /// <returns>List of discovered windows, or empty list on failure.</returns>
 290        public async Task<List<WindowItem>> ScanAsync(
 291            IEnumerable<string>? disabledPlugins = null,
 292            IEnumerable<string>? excludedProcesses = null,
 293            CancellationToken cancellationToken = default)
 23294        {
 23295            ObjectDisposedException.ThrowIf(_disposed, this);
 296
 22297            if (!_fileSystem.FileExists(_workerPath))
 3298            {
 3299                _logger?.Log($"[UiaWorkerClient] Worker executable not found: {_workerPath}");
 3300                return [];
 301            }
 302
 19303            var allWindows = new List<WindowItem>();
 304
 305            try
 19306            {
 73307                await foreach (var result in ScanStreamingAsync(disabledPlugins, excludedProcesses, cancellationToken))
 8308                {
 8309                    if (result.Error != null)
 3310                    {
 3311                        _logger?.Log($"[UiaWorkerClient] Plugin {result.PluginName} error: {result.Error}");
 3312                    }
 313
 8314                    allWindows.AddRange(ConvertToWindowItems(result.Windows));
 8315                }
 14316                return allWindows;
 317            }
 3318            catch (OperationCanceledException)
 3319            {
 3320                _logger?.Log("[UiaWorkerClient] Scan cancelled.");
 3321                return allWindows;
 322            }
 2323            catch (Exception ex)
 2324            {
 2325                _logger?.LogError("[UiaWorkerClient] ScanAsync failed mid-stream", ex);
 2326                return allWindows;
 327            }
 22328        }
 329
 330        private static List<WindowItem> ConvertToWindowItems(List<UiaWindowResult>? results)
 12331        {
 12332            if (results == null || results.Count == 0)
 7333                return [];
 334
 5335            var items = new List<WindowItem>(results.Count);
 25336            foreach (var r in results)
 5337            {
 5338                items.Add(new WindowItem
 5339                {
 5340                    Hwnd = new IntPtr(r.Hwnd),
 5341                    Title = r.Title,
 5342                    ProcessName = r.ProcessName,
 5343                    ExecutablePath = r.ExecutablePath,
 5344                    IsFallback = r.IsFallback,
 5345                    Source = null
 5346                });
 5347            }
 5348            return items;
 12349        }
 350
 351        public void Dispose()
 15352        {
 353            lock (_processLock)
 15354            {
 17355                if (_disposed) return;
 13356                _disposed = true;
 357
 13358                _disposeCts.Cancel();
 359
 13360                if (_activeProcess != null)
 8361                {
 362                    try
 8363                    {
 8364                        if (!_activeProcess.HasExited)
 7365                        {
 7366                            _logger?.Log($"[UiaWorkerClient] Dispose called - killing active worker PID {_activeProcess.
 7367                            _activeProcess.Kill(entireProcessTree: true);
 4368                        }
 5369                    }
 3370                    catch (Exception ex)
 3371                    {
 3372                        _logger?.Log($"[UiaWorkerClient] Failed to kill active process on Dispose: {ex.Message}");
 3373                    }
 374                    finally
 8375                    {
 8376                        _activeProcess = null;
 8377                    }
 8378                }
 13379            }
 380
 13381            _disposeCts.Dispose();
 13382            GC.SuppressFinalize(this);
 15383        }
 384    }
 385
 386}