dotnet / runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.
https://docs.microsoft.com/dotnet/core/
MIT License
14.98k stars 4.66k forks source link

Proposal: Device notifications in System.ServiceProcess.ServiceBase #53963

Open hexawyz opened 3 years ago

hexawyz commented 3 years ago

Background and Motivation

Windows services can be used to react to device notifications, such as arrival or removal of a device. In order to write a Windows Service that would properly interact with devices, one needs to register the service for device notifications using RegisterDeviceNotification, then listen to notifications in the service handler function.

Use cases of a service listening to device notifications would typically be to implement user-mode support for a specific device or set of devices, some of them being removable. e.g. a relatively lightweight background service for automating the control of RGB devices such as mouses, keyboards.

If one wanted to do this in .NET with the ServiceBase class, the ServiceHandle is already accessible and can be used to register for notifications. However there is no proper way to handle the device notifications themselves: OnCustomCommand is not enough because it lacks all the metadata associated with device notifications, and because it is executed ansychronously. (Event metadata needs to be processed synchronously in all cases, or the data could be deallocated before we access it)

(See the documentation for LPHANDLER_FUNCTION_EX's dwEventType argument.) (See also DBT_DEVICEQUERYREMOVE which needs to run synchronously)

This lead to people resorting to various tricks to get something to work. But in essence, the best solution would be to get limited, but direct support for device notifications from System.ServiceProcess.

I'm stuck on this part in one of my side projects, and I realise this is quite a niche requirement, but this has useful real world uses.

I already started implementing something here as a proof of concept to identify problems and see how feasible things would be: https://github.com/GoldenCrystal/runtime/commit/22494ffd12ea1fd91c3480eefc535a59b442172f

This proposal is based on the above (WIP) commit.

Proposed API

A few methods would be introduced to register for device notifications on:

Together with this, a few virtual methods to handle notifications:

namespace System.ServiceProcess
{
    public class ServiceBase : Component
    {
        // …

        // Register for device-specific notifications (uses a handle obtained from CreateFile)
        [EditorBrowsable(EditorBrowsableState.Advanced)]
        public unsafe IDisposable RegisterDeviceNotifications(SafeFileHandle handle, object? userToken);

        // Register for notifications on a device interface class (e.g. GUID_DEVINTERFACE_HID)
        [EditorBrowsable(EditorBrowsableState.Advanced)]
        public IDisposable RegisterDeviceNotifications(Guid interfaceClassGuid);

        // Register for notifications on all device interface classes
        [EditorBrowsable(EditorBrowsableState.Advanced)]
        public IDisposable RegisterDeviceNotifications();

        // Process most device-specific notifications
        [EditorBrowsable(EditorBrowsableState.Advanced)]
        protected virtual void OnDeviceNotification(DeviceBroadcastType eventType, SafeFileHandle handle, object? userToken);

        // Process most device interface class notifications
        [EditorBrowsable(EditorBrowsableState.Advanced)]
        protected virtual void OnDeviceNotification(DeviceBroadcastType eventType, Guid interfaceClassGuid, string? deviceName);

        // Decide if a specific device can be removed
        [EditorBrowsable(EditorBrowsableState.Advanced)]
        protected virtual bool OnDeviceQueryRemove(SafeFileHandle handle, object? userToken);

        // Decide if a specific device can be removed
        [EditorBrowsable(EditorBrowsableState.Advanced)]
        protected virtual bool OnDeviceQueryRemove(Guid interfaceClassGuid, string? deviceName);

        // …
    }

    public enum DeviceBroadcastType
    {
        Arrival = Interop.User32.DBT_DEVICEARRIVAL,
        //QueryRemove = Interop.User32.DBT_DEVICEQUERYREMOVE,
        QueryRemoveFailed = Interop.User32.DBT_DEVICEQUERYREMOVEFAILED,
        RemovePending = Interop.User32.DBT_DEVICEREMOVEPENDING,
        RemoveComplete = Interop.User32.DBT_DEVICEREMOVECOMPLETE,
        //CustomEvent = Interop.User32.DBT_CUSTOMEVENT,
    }
}

NB: A device name is the name of a device file that can be opened with CreateFile, such as: \\?\HID#VID_05AC&PID_0221&MI_00#a&974a620&0&0000#{884b96c3-56ef-11d1-bc8c-00a0c91405dd}

The goal of this proposal is not to cover extensively the device-related APIs, which would be far too wide, but to enable those who wish to work with such APIs within services to be able to do so. As such, the APIs above would have limited use without interop calls to CreateFile and other Win32 functions to interact with devices.

Usage Examples

This how a typical usage could look like:

Click to expand! ````csharp public class MyService : ServiceBase { private static readonly Guid Hid = new Guid(0x4D1E55B2, 0xF16F, 0x11CF, 0x88, 0xCB, 0x00, 0x11, 0x11, 0x00, 0x00, 0x30); private sealed class DeviceInfo : IDisposable { public SafeFileHandle DeviceHandle { get; } public IDisposable Registration { get; set; } public DeviceInfo(SafeFileHandle deviceHandle) => DeviceHandle = deviceHandle; public void Dispose() { DeviceHandle.Dispose(); Registration?.Dispose(); } } private readonly object newDeviceLock = new(); private readonly ConcurrentDictionary _deviceNotificationRegistrations = new(); private IDisposable _deviceInterfaceClassNotificationRegistration; protected override OnStart(string[] args) { RegisterNewDevices(); _deviceInterfaceClassNotificationRegistration = RegisterDeviceNotifications(Hid); } private void RegisterNewDevices() { lock (newDeviceLock) { foreach (var deviceName in FindDevices()) { RegisterNewDeviceCore(deviceName); } } } private DeviceInfo RegisterNewDevice(string deviceName) { lock (newDeviceLock) { return RegisterNewDeviceCore(deviceName); } } private DeviceInfo RegisterNewDeviceCore(string deviceName) { if (_deviceNotificationRegistrations.ContainsKey(deviceName)) return; var deviceInfo = new DeviceInfo(OpenDeviceFile(deviceName, DeviceAccess.ReadWrite)); try { deviceInfo.Add(deviceName, deviceInfo); // See the Risks section at the bottom deviceInfo.Registration(RegisterDeviceNotifications(fileHandle, deviceName)); return deviceInfo; } catch { deviceInfo.DeviceHandle.Dispose(); throw; } } protected override void OnDeviceNotification(DeviceBroadcastType eventType, SafeFileHandle handle, object? userToken) { switch (eventType) { case DeviceBroadcastType.QueryRemoveFailed: // OnDeviceQueryRemove was likely called previously to this call, the device needs to be initialized again. InitializeDevice(handle); break; case DeviceBroadcastType.RemoveComplete: if (_deviceNotificationRegistrations.Remove((string)userToken, out var info)) { info.Dispose(); } break; } } protected override void OnDeviceNotification(DeviceBroadcastType eventType, Guid interfaceClassGuid, string? deviceName) { switch (eventType) { case DeviceBroadcastType.Arrival: var info = RegisterNewDevice(deviceName); InitializeDevice(info.DeviceHandle); break; } } protected override bool OnDeviceQueryRemove(SafeFileHandle handle, object? userToken) { // This notification is a good place to unitilialize the device before it is actually removed. UninitializeDevice(handle); return true; } protected override bool OnDeviceQueryRemove(Guid interfaceClassGuid, string? deviceName) { return true; } // Call Win32 Interop to enumerate devices private static string[] FindDevices() { // … } // Call Win32 Interop to do something with the device, e.g. send a HID report private static void InitializeDevice(SafeFileHandle deviceHandle) { // … } // Call Win32 Interop to do something with the device, e.g. send a HID report private static void UninitializeDevice(SafeFileHandle deviceHandle) { // … } public static SafeFileHandle OpenDeviceFile(string deviceName, DeviceAccess access) { var handle = NativeMethods.CreateFile ( deviceName, access switch { DeviceAccess.None => 0, DeviceAccess.Read => NativeMethods.FileAccessMask.GenericRead, DeviceAccess.Write => NativeMethods.FileAccessMask.GenericWrite, DeviceAccess.ReadWrite => NativeMethods.FileAccessMask.GenericRead | NativeMethods.FileAccessMask.GenericWrite, _ => throw new ArgumentOutOfRangeException(nameof(access)) }, FileShare.ReadWrite, IntPtr.Zero, FileMode.Open, 0, IntPtr.Zero ); if (handle.IsInvalid) { throw new Win32Exception(Marshal.GetLastWin32Error()); } return handle; } public enum DeviceAccess { None = 0, Read = 1, Write = 2, ReadWrite = 3, } } ````

Alternative Designs

An alternate, admittedly simpler design, would be to allow more extensibility on the ServiceBase class, such as allowing to override the ServiceCommandCallbackEx method. This would probably be done by exposing a new synchronous and parameterized version of OnCustomCommand.

Risks

  1. This feature seems difficult to test. It would likely require running the test service within a VM and plugging / unplugging virtual devices to validate the various behaviors. I don't know how/if this can be realistically done.
  2. The documentaiton on device notifications can be a bit lacking at times, and because of this, I mostly left out specific handling custom device events for now, as I am unsure how the custom extra data should be marshalled to users. This should not be difficult to tackle later on, though. If we're okay with it, we could just ignore device custom events in ServiceBase. (I've already commented this in the API proposal)
  3. There is kind of a chicken-egg problem with the HDEVNOTIFY handle allocation. While not explicitly documented, we should probably expect device notifications to be triggered before we actually get our hands on the HDEVNOTIFY in some rare cases. I tried to address this in my WIP commit, but a similar problem can still occur on the API consumer side. (As you can see in the example I provided, with the introduction of a mutable DeviceInfo class.)
  4. If possible, I would appreciate if I could get the opinion of a windows (kernel?) expert here, to ensure I'm not missing something too obvious from their point of view. (I wrote most of this from my understanding of the documentation. Although I made sure to not include the things that were not "obvious" to me, I may still have overlooked some details)
dotnet-issue-labeler[bot] commented 3 years ago

I couldn't figure out the best area label to add to this issue. If you have write-permissions please help me learn by adding exactly one area label.