[File Locksmith] Isolate handle enumeration

Run blockable native handle enumeration in a disposable CLI worker with a finite timeout, explicit UI errors, and cleanup coverage.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Clint Rutkas
2026-08-28 13:21:38 -07:00
parent 428044b477
commit 1ea9303f50
18 changed files with 680 additions and 132 deletions

View File

@@ -495,6 +495,10 @@
</Folder>
<Folder Name="/modules/FileLocksmith/Tests/">
<Project Path="src/modules/FileLocksmith/FileLocksmithCLI/tests/FileLocksmithCLIUnitTests.vcxproj" Id="a1b2c3d4-e5f6-7890-1234-567890abcdef" />
<Project Path="src/modules/FileLocksmith/Tests/FileLocksmithUI.UnitTests/FileLocksmithUI.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/FileLocksmith/Tests/FileLocksmith.UITests/FileLocksmith.UITests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
@@ -1284,4 +1288,3 @@
<Project Path="tools/CliShim/CliShim.vcxproj" Id="8a7fb7fa-65ea-4004-ba73-1b237435a57b" />
<Project Path="tools/CliShim.UnitTests/CliShim.UnitTests.vcxproj" Id="d4b0ed68-867d-46b4-a03f-ec52b9fbebe5" />
</Solution>

View File

@@ -46,8 +46,10 @@ The module adds "Unlock with File Locksmith" to the context menu in File Explore
3. The shell extension writes the selected file path to a temporary file (file-based IPC)
4. The shell extension launches `PowerToys.FileLocksmithUI.exe`
5. The UI reads the file path from the temporary file
6. The UI uses `FileLocksmithLibInterop` to scan for processes with handles to the file
7. Results are displayed in the UI, showing process information and allowing user action
6. The UI starts `FileLocksmithCLI.exe` in a hidden worker mode and sends the selected paths over redirected standard input
7. The worker uses `FileLocksmithLib` to scan for processes with handles to the file and returns JSON over redirected standard output
8. The UI terminates the worker process tree if the scan exceeds 30 seconds or is canceled, and reports the failure instead of displaying an empty result
9. Results are displayed in the UI, showing process information and allowing user action
### Core Functionality
@@ -57,6 +59,7 @@ The core functionality to find processes locking files is implemented in [FileLo
- Examines all running processes to find handles to the specified files
- Maps process IDs to the files they're locking
- Retrieves process information such as name, user context, and file paths
- Runs outside the UI process because `NtQueryObject` and `GetFileType` can block indefinitely for individual handles
### User Interface

View File

@@ -79,6 +79,13 @@ std::wstring get_json(const std::vector<ProcessResult>& results)
return root.Stringify().c_str();
}
CommandResult run_worker_query(const std::vector<std::wstring>& paths, IProcessFinder& finder)
{
auto results = finder.find(paths);
Logger::info("Worker enumeration found {} processes", results.size());
return { 0, get_json(results), L"worker-query" };
}
std::wstring get_text(const std::vector<ProcessResult>& results, IStringProvider& strings)
{
std::wstringstream ss;

View File

@@ -30,3 +30,4 @@ struct IStringProvider
};
CommandResult run_command(int argc, wchar_t* argv[], IProcessFinder& finder, IProcessTerminator& terminator, IStringProvider& strings);
CommandResult run_worker_query(const std::vector<std::wstring>& paths, IProcessFinder& finder);

View File

@@ -2,7 +2,11 @@
#include "CLILogic.h"
#include "FileLocksmithLib/FileLocksmith.h"
#include "FileLocksmithLib/Trace.h"
#include <common/utils/json.h>
#include <chrono>
#include <iostream>
#include <iterator>
#include <optional>
#include "resource.h"
#include <common/logger/logger.h>
#include <common/utils/logger_helper.h>
@@ -44,6 +48,53 @@ struct RealStringProvider : IStringProvider
}
};
namespace
{
constexpr std::wstring_view WorkerArgument = L"--worker-json";
std::optional<std::vector<std::wstring>> read_worker_paths()
{
const std::string input{
std::istreambuf_iterator<char>{ std::cin },
std::istreambuf_iterator<char>{}
};
json::JsonObject request;
if (!json::JsonObject::TryParse(winrt::to_hstring(input), request) || !request.HasKey(L"paths"))
{
return std::nullopt;
}
try
{
std::vector<std::wstring> paths;
const auto json_paths = request.GetNamedArray(L"paths");
paths.reserve(json_paths.Size());
for (const auto& path : json_paths)
{
if (path.ValueType() != json::JsonValueType::String)
{
return std::nullopt;
}
paths.emplace_back(path.GetString());
}
if (paths.empty())
{
return std::nullopt;
}
return paths;
}
catch (const winrt::hresult_error&)
{
return std::nullopt;
}
}
}
#ifndef UNIT_TEST
int wmain(int argc, wchar_t* argv[])
{
@@ -56,6 +107,29 @@ int wmain(int argc, wchar_t* argv[])
RealProcessTerminator terminator;
RealStringProvider strings;
if (argc == 2 && argv[1] == WorkerArgument)
{
const auto paths = read_worker_paths();
if (!paths)
{
Logger::error("Worker input was malformed");
Trace::CLICommand(L"worker-query", false);
Trace::UnregisterProvider();
return 2;
}
Logger::info("Worker query started with {} paths", paths->size());
const auto started = std::chrono::steady_clock::now();
const auto result = run_worker_query(*paths, finder);
const auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - started);
Logger::info("Worker query completed in {} ms with exit code {}", duration.count(), result.exit_code);
std::cout << winrt::to_string(result.output);
Trace::CLICommand(result.command_name.c_str(), result.exit_code == 0);
Trace::UnregisterProvider();
return result.exit_code;
}
auto result = run_command(argc, argv, finder, terminator, strings);
if (result.exit_code != 0)

View File

@@ -117,6 +117,10 @@ constexpr size_t LongMaxPathSize = 65536;
std::wstring pid_to_full_path(DWORD pid)
{
HANDLE process = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, pid);
if (!process)
{
return {};
}
std::wstring result(LongMaxPathSize, L'\0');

View File

@@ -1,8 +1,6 @@
#include "pch.h"
#include "NtdllExtensions.h"
#include <thread>
#include <atomic>
#define STATUS_INFO_LENGTH_MISMATCH ((LONG)0xC0000004)
@@ -12,11 +10,21 @@ namespace
{
std::wstring_view unicode_to_view(UNICODE_STRING unicode_str)
{
if (!unicode_str.Buffer || unicode_str.Length == 0)
{
return {};
}
return std::wstring_view(unicode_str.Buffer, unicode_str.Length / sizeof(WCHAR));
}
std::wstring unicode_to_str(UNICODE_STRING unicode_str)
{
if (!unicode_str.Buffer || unicode_str.Length == 0)
{
return {};
}
return std::wstring(unicode_str.Buffer, unicode_str.Length / sizeof(WCHAR));
}
@@ -164,119 +172,66 @@ std::vector<NtdllExtensions::HandleInfo> NtdllExtensions::handles() noexcept
std::map<ULONG_PTR, HANDLE> pid_to_handle;
std::vector<HandleInfo> result;
std::vector<BYTE> object_info_buffer(DefaultResultBufferSize);
std::atomic<ULONG> i = 0;
std::atomic<ULONG_PTR> handle_count = info_ptr->NumberOfHandles;
std::atomic<HANDLE> process_handle = NULL;
std::atomic<HANDLE> handle_copy = NULL;
ULONG previous_i;
while (i < handle_count)
const ULONG_PTR handle_count = info_ptr->NumberOfHandles;
for (ULONG_PTR i = 0; i < handle_count; i++)
{
previous_i = i;
const auto* handle_info = info_ptr->Handles + i;
const auto pid = handle_info->UniqueProcessId;
// The system calls we use in this block were reported to hang on some machines.
// We need to offload the cycle to another thread and keep track of progress to terminate and resume when needed.
// Unfortunately, there are no alternative APIs to what we're using that accept timeouts. (NtQueryObject and GetFileType)
auto offload_function = std::thread([&] {
for (; i < handle_count; i++)
{
process_handle = NULL;
handle_copy = NULL;
auto handle_info = info_ptr->Handles + i;
auto pid = handle_info->UniqueProcessId;
auto iter = pid_to_handle.find(pid);
if (iter != pid_to_handle.end())
{
process_handle = iter->second;
}
else
{
process_handle = OpenProcess(PROCESS_DUP_HANDLE, FALSE, static_cast<DWORD>(pid));
if (!process_handle)
{
continue;
}
pid_to_handle[pid] = process_handle;
}
// According to this:
// https://stackoverflow.com/questions/46384048/enumerate-handles
// NtQueryObject could hang
// TODO uncomment and investigate
// if (handle_info->GrantedAccess == 0x0012019f) {
// continue;
// }
HANDLE local_handle_copy;
auto dh_result = DuplicateHandle(process_handle, reinterpret_cast<HANDLE>(handle_info->HandleValue), GetCurrentProcess(), &local_handle_copy, 0, 0, DUPLICATE_SAME_ACCESS);
if (dh_result == 0)
{
// Ignore this handle.
continue;
}
handle_copy = local_handle_copy;
ULONG return_length;
auto status = NtQueryObject(handle_copy, ObjectTypeInformation, object_info_buffer.data(), static_cast<ULONG>(object_info_buffer.size()), &return_length);
if (NT_ERROR(status))
{
// Ignore this handle.
CloseHandle(handle_copy);
handle_copy = NULL;
continue;
}
auto object_type_info = reinterpret_cast<OBJECT_TYPE_INFORMATION*>(object_info_buffer.data());
auto type_name = unicode_to_str(object_type_info->Name);
std::wstring file_name;
if (type_name == L"File")
{
file_name = file_handle_to_kernel_name(handle_copy, object_info_buffer);
result.push_back(HandleInfo{ pid, handle_info->HandleValue, type_name, file_name });
}
CloseHandle(handle_copy);
handle_copy = NULL;
}
});
offload_function.detach();
do
HANDLE process_handle = NULL;
if (auto iter = pid_to_handle.find(pid); iter != pid_to_handle.end())
{
Sleep(200); // Timeout in milliseconds for detecting that the system hang on getting information for a handle.
if (i >= handle_count)
process_handle = iter->second;
}
else
{
process_handle = OpenProcess(PROCESS_DUP_HANDLE, FALSE, static_cast<DWORD>(pid));
if (!process_handle)
{
// We're done.
break;
continue;
}
if (previous_i >= i)
{
// The thread looks like it's hanging on some handle. Let's kill it and resume.
pid_to_handle[pid] = process_handle;
}
// HACK: This is unsafe and may leak something, but looks like there's no way to properly clean up a thread when it's hanging on a system call.
TerminateThread(offload_function.native_handle(), 1);
HANDLE handle_copy = NULL;
if (!DuplicateHandle(
process_handle,
reinterpret_cast<HANDLE>(handle_info->HandleValue),
GetCurrentProcess(),
&handle_copy,
0,
FALSE,
DUPLICATE_SAME_ACCESS))
{
continue;
}
// Close Handles that might be lingering.
if (handle_copy!=NULL)
{
CloseHandle(handle_copy);
}
i++;
break;
}
previous_i = i;
} while (1);
ULONG return_length = 0;
const auto status = NtQueryObject(
handle_copy,
ObjectTypeInformation,
object_info_buffer.data(),
static_cast<ULONG>(object_info_buffer.size()),
&return_length);
if (NT_ERROR(status))
{
CloseHandle(handle_copy);
continue;
}
auto object_type_info = reinterpret_cast<OBJECT_TYPE_INFORMATION*>(object_info_buffer.data());
auto type_name = unicode_to_str(object_type_info->Name);
if (type_name == L"File")
{
auto file_name = file_handle_to_kernel_name(handle_copy, object_info_buffer);
result.push_back(HandleInfo{ pid, handle_info->HandleValue, std::move(type_name), std::move(file_name) });
}
CloseHandle(handle_copy);
}
for (auto [pid, handle] : pid_to_handle)

View File

@@ -84,6 +84,10 @@
<ProjectReference Include="..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\FileLocksmithCLI\FileLocksmithCLI.vcxproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<BuildProject>true</BuildProject>
</ProjectReference>
<ProjectReference Include="..\FileLocksmithLibInterop\FileLocksmithLibInterop.vcxproj" />
</ItemGroup>
</Project>

View File

@@ -36,10 +36,18 @@ namespace FileLocksmithUI
Title = title;
titleBar.Title = title;
Closed += MainWindow_Closed;
}
public void Dispose()
{
Closed -= MainWindow_Closed;
mainPage.ViewModel.Dispose();
}
private void MainWindow_Closed(object sender, WindowEventArgs args)
{
Dispose();
}
}
}

View File

@@ -38,6 +38,7 @@
<Grid RowSpacing="8">
<Grid.RowDefinitions>
<RowDefinition x:Name="ButtonsRow" Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
@@ -84,7 +85,15 @@
</StackPanel>
</Grid>
<Grid Grid.Row="1">
<InfoBar
Grid.Row="1"
Margin="16,0,16,8"
IsClosable="False"
IsOpen="{x:Bind ViewModel.HasQueryError, Mode=OneWay}"
Message="{x:Bind ViewModel.QueryErrorMessage, Mode=OneWay}"
Severity="Error" />
<Grid Grid.Row="2">
<Grid Visibility="{x:Bind ViewModel.IsLoading, Converter={StaticResource boolToVisibilityConverter}, Mode=OneWay}">
<ListView
x:Name="ProcessesListView"
@@ -175,7 +184,10 @@
<TextBlock x:Uid="Reload" />
</ToolTipService.ToolTip>
</Button>
<TextBlock x:Uid="EmptyListDescription" Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
<TextBlock
x:Uid="EmptyListDescription"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Visibility="{x:Bind ViewModel.HasQueryError, Converter={StaticResource boolToVisibilityConverter}, Mode=OneWay}" />
</StackPanel>
</Grid>
<ProgressRing

View File

@@ -0,0 +1,8 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerToys.FileLocksmithUI.Services
{
internal sealed record FileLocksmithProcessInfo(string Name, uint Pid, string User, string[] Files);
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
namespace PowerToys.FileLocksmithUI.Services
{
internal sealed record FileLocksmithQueryResult(
FileLocksmithQueryStatus Status,
IReadOnlyList<FileLocksmithProcessInfo> Processes,
int? ExitCode = null);
}

View File

@@ -0,0 +1,253 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
#nullable enable
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
namespace PowerToys.FileLocksmithUI.Services
{
internal sealed class FileLocksmithQueryService
{
internal static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
private const string WorkerExecutableName = "FileLocksmithCLI.exe";
private const string WorkerArgument = "--worker-json";
private readonly Func<ProcessStartInfo> _startInfoFactory;
private readonly TimeSpan _timeout;
private readonly Action<int>? _processStarted;
internal FileLocksmithQueryService()
: this(CreateWorkerStartInfo, DefaultTimeout, null)
{
}
internal FileLocksmithQueryService(
Func<ProcessStartInfo> startInfoFactory,
TimeSpan timeout,
Action<int>? processStarted)
{
ArgumentNullException.ThrowIfNull(startInfoFactory);
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(timeout, TimeSpan.Zero);
_startInfoFactory = startInfoFactory;
_timeout = timeout;
_processStarted = processStarted;
}
internal async Task<FileLocksmithQueryResult> FindProcessesAsync(
IReadOnlyCollection<string> paths,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(paths);
if (paths.Count == 0)
{
return new FileLocksmithQueryResult(FileLocksmithQueryStatus.Success, Array.Empty<FileLocksmithProcessInfo>());
}
using var process = new Process
{
StartInfo = _startInfoFactory(),
};
try
{
if (!process.Start())
{
return FailedToStart();
}
}
catch (Win32Exception)
{
return FailedToStart();
}
catch (InvalidOperationException)
{
return FailedToStart();
}
_processStarted?.Invoke(process.Id);
#pragma warning disable CA2016 // These reads must drain the redirected pipes after timeout cancellation.
var outputTask = process.StandardOutput.ReadToEndAsync(CancellationToken.None);
var errorTask = process.StandardError.ReadToEndAsync(CancellationToken.None);
#pragma warning restore CA2016
var inputClosed = false;
try
{
using var timeoutCancellation = new CancellationTokenSource(_timeout);
using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(
timeoutCancellation.Token,
cancellationToken);
try
{
var request = JsonSerializer.Serialize(new WorkerRequest(paths));
await process.StandardInput.WriteAsync(request.AsMemory(), linkedCancellation.Token);
process.StandardInput.Close();
inputClosed = true;
await process.WaitForExitAsync(linkedCancellation.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
await TerminateAsync(process);
await Task.WhenAll(outputTask, errorTask);
return new FileLocksmithQueryResult(
FileLocksmithQueryStatus.TimedOut,
Array.Empty<FileLocksmithProcessInfo>());
}
catch (IOException)
{
return Failed(process.HasExited ? process.ExitCode : null);
}
catch (InvalidOperationException)
{
return Failed(process.HasExited ? process.ExitCode : null);
}
if (process.ExitCode != 0)
{
await Task.WhenAll(outputTask, errorTask);
return Failed(process.ExitCode);
}
var output = await outputTask;
await errorTask;
try
{
var response = JsonSerializer.Deserialize<WorkerResponse>(output);
if (response?.Processes is null)
{
return MalformedOutput();
}
var processes = new List<FileLocksmithProcessInfo>(response.Processes.Length);
foreach (var processInfo in response.Processes)
{
if (processInfo.Name is null ||
processInfo.User is null ||
processInfo.Files is null)
{
return MalformedOutput();
}
processes.Add(new FileLocksmithProcessInfo(
processInfo.Name,
processInfo.Pid,
processInfo.User,
processInfo.Files));
}
return new FileLocksmithQueryResult(
FileLocksmithQueryStatus.Success,
processes);
}
catch (JsonException)
{
return MalformedOutput();
}
}
finally
{
if (!process.HasExited)
{
await TerminateAsync(process);
}
if (!inputClosed)
{
process.StandardInput.BaseStream.Dispose();
}
await Task.WhenAll(outputTask, errorTask);
}
}
private static ProcessStartInfo CreateWorkerStartInfo()
{
var installedPath = Path.Combine(AppContext.BaseDirectory, WorkerExecutableName);
var buildOutputPath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", WorkerExecutableName));
var workerPath = File.Exists(installedPath) ? installedPath : buildOutputPath;
var startInfo = new ProcessStartInfo(workerPath)
{
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardInput = true,
RedirectStandardOutput = true,
StandardErrorEncoding = Encoding.UTF8,
StandardInputEncoding = Encoding.UTF8,
StandardOutputEncoding = Encoding.UTF8,
UseShellExecute = false,
};
startInfo.ArgumentList.Add(WorkerArgument);
return startInfo;
}
private static async Task TerminateAsync(Process process)
{
if (process.HasExited)
{
return;
}
try
{
process.Kill(entireProcessTree: true);
}
catch (InvalidOperationException) when (process.HasExited)
{
}
await process.WaitForExitAsync(CancellationToken.None);
}
private static FileLocksmithQueryResult FailedToStart() =>
Failed(null);
private static FileLocksmithQueryResult Failed(int? exitCode) =>
new(FileLocksmithQueryStatus.Failed, Array.Empty<FileLocksmithProcessInfo>(), exitCode);
private static FileLocksmithQueryResult MalformedOutput() =>
new(FileLocksmithQueryStatus.MalformedOutput, Array.Empty<FileLocksmithProcessInfo>());
private sealed record WorkerRequest([property: JsonPropertyName("paths")] IReadOnlyCollection<string> Paths);
private sealed class WorkerResponse
{
[JsonPropertyName("processes")]
public WorkerProcessInfo[]? Processes { get; init; }
}
private sealed class WorkerProcessInfo
{
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("pid")]
public uint Pid { get; init; }
[JsonPropertyName("user")]
public string? User { get; init; }
[JsonPropertyName("files")]
public string[]? Files { get; init; }
}
}
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerToys.FileLocksmithUI.Services
{
internal enum FileLocksmithQueryStatus
{
Success,
TimedOut,
Failed,
MalformedOutput,
}
}

View File

@@ -124,6 +124,14 @@
<data name="EmptyListDescription.Text" xml:space="preserve">
<value>No results</value>
</data>
<data name="QueryFailedError" xml:space="preserve">
<value>File Locksmith couldn't scan the selected files. Select Reload to try again.</value>
<comment>Error shown when the isolated process used to scan files fails or returns invalid data.</comment>
</data>
<data name="QueryTimeoutError" xml:space="preserve">
<value>File Locksmith couldn't finish scanning within 30 seconds. Select Reload to try again.</value>
<comment>Error shown when the isolated process used to scan files exceeds its time limit.</comment>
</data>
<data name="EndTask.Text" xml:space="preserve">
<value>End task</value>
</data>

View File

@@ -3,10 +3,9 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Linq;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
@@ -14,6 +13,8 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ManagedCommon;
using PowerToys.FileLocksmithLib.Interop;
using PowerToys.FileLocksmithUI.Helpers;
using PowerToys.FileLocksmithUI.Services;
namespace PowerToys.FileLocksmithUI.ViewModels
{
@@ -23,14 +24,30 @@ namespace PowerToys.FileLocksmithUI.ViewModels
{
public IAsyncRelayCommand LoadProcessesCommand { get; }
private readonly FileLocksmithQueryService _queryService = new();
private bool _isLoading;
private bool _isElevated;
private string[] paths;
private bool _disposed;
private CancellationTokenSource _cancelProcessWatching;
private CancellationTokenSource _cancelQuery;
private string _queryErrorMessage;
public ObservableCollection<ProcessResult> Processes { get; } = new();
public string QueryErrorMessage
{
get => _queryErrorMessage;
private set
{
_queryErrorMessage = value;
OnPropertyChanged(nameof(QueryErrorMessage));
OnPropertyChanged(nameof(HasQueryError));
}
}
public bool HasQueryError => !string.IsNullOrEmpty(QueryErrorMessage);
public bool IsLoading
{
get
@@ -87,36 +104,51 @@ namespace PowerToys.FileLocksmithUI.ViewModels
private async Task LoadProcessesAsync()
{
IsLoading = true;
QueryErrorMessage = null;
Processes.Clear();
if (_cancelProcessWatching is not null)
{
_cancelProcessWatching.Cancel();
}
_cancelProcessWatching?.Cancel();
_cancelProcessWatching?.Dispose();
_cancelProcessWatching = new CancellationTokenSource();
var processes_found = await FindProcesses(paths);
if (processes_found is not null)
_cancelQuery?.Cancel();
_cancelQuery?.Dispose();
_cancelQuery = new CancellationTokenSource();
var cancellationToken = _cancelQuery.Token;
var stopwatch = Stopwatch.StartNew();
try
{
foreach (ProcessResult p in processes_found)
var queryResult = await _queryService.FindProcessesAsync(paths, cancellationToken);
stopwatch.Stop();
if (queryResult.Status == FileLocksmithQueryStatus.Success)
{
Processes.Add(p);
WatchProcess(p, _cancelProcessWatching.Token);
Logger.LogInfo($"File Locksmith worker query completed in {stopwatch.ElapsedMilliseconds} ms with exit code 0 and {queryResult.Processes.Count} processes.");
foreach (var processInfo in queryResult.Processes)
{
var process = new ProcessResult(processInfo.Name, processInfo.Pid, processInfo.User, processInfo.Files);
Processes.Add(process);
WatchProcess(process, _cancelProcessWatching.Token);
}
}
else
{
var exitCode = queryResult.ExitCode?.ToString(CultureInfo.InvariantCulture) ?? "unavailable";
Logger.LogError($"File Locksmith worker query failed at stage enumeration after {stopwatch.ElapsedMilliseconds} ms with status {queryResult.Status} and exit code {exitCode}.");
QueryErrorMessage = queryResult.Status == FileLocksmithQueryStatus.TimedOut
? ResourceLoaderInstance.ResourceLoader.GetString("QueryTimeoutError")
: ResourceLoaderInstance.ResourceLoader.GetString("QueryFailedError");
}
}
IsLoading = false;
}
private async Task<List<ProcessResult>> FindProcesses(string[] paths)
{
var results = new List<ProcessResult>();
await Task.Run(() =>
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
results = NativeMethods.FindProcessesRecursive(paths)?.ToList();
});
return results;
Logger.LogInfo($"File Locksmith worker query canceled at stage enumeration after {stopwatch.ElapsedMilliseconds} ms.");
}
finally
{
IsLoading = false;
}
}
private async void WatchProcess(ProcessResult process, CancellationToken token)
@@ -194,6 +226,10 @@ namespace PowerToys.FileLocksmithUI.ViewModels
{
if (disposing)
{
_cancelQuery?.Cancel();
_cancelQuery?.Dispose();
_cancelProcessWatching?.Cancel();
_cancelProcessWatching?.Dispose();
_disposed = true;
}
}

View File

@@ -0,0 +1,115 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using PowerToys.FileLocksmithUI.Services;
namespace PowerToys.FileLocksmithUI.UnitTests
{
[TestClass]
public sealed class FileLocksmithQueryServiceTests
{
private static readonly string[] SelectedPaths = [@"C:\file.txt"];
[TestMethod]
public async Task FindProcessesAsyncReturnsWorkerResults()
{
const string output = """
{"processes":[{"pid":123,"name":"process.exe","user":"user","files":["C:\\file.txt"]}]}
""";
var service = CreateService($"[Console]::Out.Write('{output}')");
var result = await service.FindProcessesAsync(SelectedPaths, CancellationToken.None);
Assert.AreEqual(FileLocksmithQueryStatus.Success, result.Status);
Assert.HasCount(1, result.Processes);
Assert.AreEqual(123U, result.Processes[0].Pid);
Assert.AreEqual("process.exe", result.Processes[0].Name);
Assert.AreEqual(@"C:\file.txt", result.Processes[0].Files[0]);
}
[TestMethod]
public async Task FindProcessesAsyncReportsMalformedWorkerOutput()
{
var service = CreateService("[Console]::Out.Write('not-json')");
var result = await service.FindProcessesAsync(SelectedPaths, CancellationToken.None);
Assert.AreEqual(FileLocksmithQueryStatus.MalformedOutput, result.Status);
Assert.IsEmpty(result.Processes);
}
[TestMethod]
public async Task FindProcessesAsyncReportsFailedWorkerExitCode()
{
var service = CreateService("exit 17");
var result = await service.FindProcessesAsync(SelectedPaths, CancellationToken.None);
Assert.AreEqual(FileLocksmithQueryStatus.Failed, result.Status);
Assert.AreEqual(17, result.ExitCode);
Assert.IsEmpty(result.Processes);
}
[TestMethod]
public async Task FindProcessesAsyncTimeoutTerminatesWorker()
{
var workerPid = 0;
var service = CreateService(
"Start-Sleep -Seconds 30",
TimeSpan.FromMilliseconds(500),
pid => workerPid = pid);
var result = await service.FindProcessesAsync(SelectedPaths, CancellationToken.None);
Assert.AreEqual(FileLocksmithQueryStatus.TimedOut, result.Status);
Assert.AreNotEqual(0, workerPid);
Assert.IsFalse(IsProcessRunning(workerPid), "The timed-out worker process was left running.");
}
private static FileLocksmithQueryService CreateService(
string script,
TimeSpan? timeout = null,
Action<int>? processStarted = null)
{
return new FileLocksmithQueryService(
() =>
{
var startInfo = new ProcessStartInfo("powershell.exe")
{
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardInput = true,
RedirectStandardOutput = true,
UseShellExecute = false,
};
startInfo.ArgumentList.Add("-NoLogo");
startInfo.ArgumentList.Add("-NoProfile");
startInfo.ArgumentList.Add("-NonInteractive");
startInfo.ArgumentList.Add("-Command");
startInfo.ArgumentList.Add($"$null = [Console]::In.ReadToEnd(); {script}");
return startInfo;
},
timeout ?? TimeSpan.FromSeconds(10),
processStarted);
}
private static bool IsProcessRunning(int pid)
{
try
{
using var process = Process.GetProcessById(pid);
return !process.HasExited;
}
catch (ArgumentException)
{
return false;
}
}
}
}

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<IsTestProject>true</IsTestProject>
<RootNamespace>PowerToys.FileLocksmithUI.UnitTests</RootNamespace>
<SelfContained>true</SelfContained>
<RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier>
<RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\FileLocksmithUI.UnitTests\</OutputPath>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\..\FileLocksmithUI\Services\FileLocksmithProcessInfo.cs" Link="FileLocksmithProcessInfo.cs" />
<Compile Include="..\..\FileLocksmithUI\Services\FileLocksmithQueryResult.cs" Link="FileLocksmithQueryResult.cs" />
<Compile Include="..\..\FileLocksmithUI\Services\FileLocksmithQueryService.cs" Link="FileLocksmithQueryService.cs" />
<Compile Include="..\..\FileLocksmithUI\Services\FileLocksmithQueryStatus.cs" Link="FileLocksmithQueryStatus.cs" />
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" />
</ItemGroup>
</Project>