Using SafeHandles in Structs via Stateful Marshalling

SafeHandles provide reliable and secure means of managing the lifetime of unmanaged resources. It’s commonly recommended to use SafeHandle instead of IntPtr whenever we need to acquire and deal with unmanaged system handles.

The .NET runtime makes this recommendation easy to follow, as the SafeHandle class is integrated with platform invoke, allowing for the correct type of SafeHandle to be used interchangeably with IntPtr as parameters in P/Invoke method signatures.

Unmanaged handles aren’t only used as method parameters, however, as they are also present as members of the many kinds of C-style structs that unmanaged functions accept as parameters. Unfortunately, with structs, the use of SafeHandle is not interchangeable with IntPtr.

This means that we’re still stuck using dangerous IntPtr handles when dealing with structs. However, with .NET 7’s introduction of source generation for platform invokes, we now have a way to safely make use of SafeHandle types in our structs. This article will demonstrate how.

A Sample Handle

We’re going to take a look at an example SafeHandle and its current limitations, and then look at how we can overcome them as far structs are concerned.

One kind of unmanaged resource we encounter frequently is a handle to a window. It is created when a window is created, and it is used to interact with said window throughout its lifetime.

WindowHandle.cs
/// <summary>
/// Provides a level-0 type for window handles.
/// </summary>
/// <suppressions>
/// ReSharper disable UnusedMember.Local
/// </suppressions>
public sealed class WindowHandle : SafeHandle
{
    /// <summary>
    /// Initializes a new instance of the <see cref="WindowHandle"/> class.
    /// </summary>
    /// <param name="handle">The handle to the window.</param>
    /// <param name="ownsHandle">
    /// Value indicating if this safe handle is responsible for releasing the provided handle.
    /// </param>
    public WindowHandle(IntPtr handle, bool ownsHandle)
        : this(ownsHandle)
    {
        SetHandle(handle);
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="WindowHandle"/> class.
    /// </summary>
    public WindowHandle()
        : this(true)
    { }

    /// <summary>
    /// Initializes a new instance of the <see cref="WindowHandle"/> class.
    /// </summary>
    /// <param name="ownsHandle">
    /// Value indicating if this safe handle is responsible for releasing the provided handle.
    /// </param>
    private WindowHandle(bool ownsHandle)
        : base(IntPtr.Zero, ownsHandle)
    { }

    /// <summary>
    /// Gets a default invalid instance of the <see cref="WindowHandle"/> class.
    /// </summary>
    public static WindowHandle InvalidHandle
        => new(false);

    /// <inheritdoc/>
    public override bool IsInvalid
        => handle == IntPtr.Zero;
    
    /// <inheritdoc/>
    protected override bool ReleaseHandle()
        => User32.DestroyWindow(handle);
}

The WindowHandle class provides SafeHandle-like functoinality for unmanaged window handles.

It’ll be initialized via the default constructor by P/Invoke whenever it’s used as a return value for a function that normally returns a window handle.

P/Invoke Function Returning WindowHandle
[LibraryImport(LIBRARY_NAME, 
               EntryPoint = "CreateWindowExW", 
               StringMarshalling = StringMarshalling.Utf16, 
               SetLastError = true)]
public static unsafe partial WindowHandle CreateWindowEx(int dwExStyle,
                                                         string lpClassName,
                                                         string lpWindowName,
                                                         int style,
                                                         int x,
                                                         int y,
                                                         int width,
                                                         int height,
                                                         IntPtr hWndParent,
                                                         IntPtr hMenu,
                                                         IntPtr hInstance,
                                                         void* lpParam);

Normally, the signature for CreateWindowEx would be written to return an IntPtr handle to the window. But, as you can see, we can easily swap that with our custom SafeHandle and it all works like magic.

Our custom SafeHandle can also be used as a parameter for methods that expect a window handle and are normally written to accept an IntPtr.

P/Invoke Function Accepting WindowHandle as Parameter
[LibraryImport(LIBRARY_NAME, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool GetWindowRect(WindowHandle hWnd, out RECT lpRect);

And everything works just perfectly, with the added security and reliability that using a SafeHandle grants us.

Unfortunately, that’s where the magic just about stops.

Structs and SafeHandles Don’t Mix Well

If we continue to expand our repertoire of window-related P/Invoke functions, we’ll eventually run into some methods expecting window handles to be provided as members of a C-style struct.

An example is the Shell_NotifyIcon function, used to register an icon with the taskbar’s status area. This function accepts a NOTIFYICONDATA struct as one of its parameters, which has a member that gets set to the handle of the window meant to receive taskbar notifications.

If we try to define this structure so that it uses a WindowHandle, we will run into problems.

A Problematic NOTIFYICONDATA
/// <summary>
/// Represents information that the system needs to display notifications in the notification area.
/// </summary>
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal unsafe struct NOTIFYICONDATAW
{
    /// <summary>
    /// A handle to the window that receives notifications associated with an icon i
    /// n the notification area.
    /// </summary>
    public WindowHandle hWnd;

    // Insert the rest of the members..
}

The presence of a WindowHandle in this struct will immediately result in compile-time errors similar to the following if we try to use the struct as a parameter to Shell_NotifyIcon:

SYSLIB10: The type 'BadEcho.Interop.NOTIFYICONDATAW' is not supported by source-generated P/Invokes.
The generated source will not handle marshalling of parameter 'lpData'.

This is an error you’d get when using the new source generator system à la the LibraryImportAttribute. If you’re using the older DllImportAttribute, then your program may compile, however it isn’t going to work at runtime.

So what can we do? Well, let’s start with the more obvious, less-than-perfect, workaround.

The Bad Way: Getting a Handle Out of a SafeHandle

We can revert our struct definition to use IntPtr instead of our WindowHandle. This will resolve the compile-time errors.

All we need to do then is squeeze the SafeHandle‘s internal handle out from it, and use that when populating the struct’s members.

A Dangerous Squeeze
var iconData = new NOTIFYICONDATAW
               {
                   cbSize = (uint) sizeof(NOTIFYICONDATAW),
                   uCallbackMessage = (uint) _TrayEvent,
                   uFlags = NotifyIconFlags.Message | NotifyIconFlags.Guid,
                   guidItem = _id,
                   hWnd = windowHandle.DangerousGetHandle()
                   // Everything else...
               };

Shell32.Shell_NotifyIcon(NotifyIconMessage.Add, ref iconData);

Getting the internal handle out from the SafeHandle requires the calling of DangerousGetHandle, a method with an appropriately ominous name.

There are numerous articles out there which go into why using this method is, for lack of a better word, “dangerous”. It opens ourselves up to runtime errors due to stale handles, as well as potential security issues via handle recycling security attacks.

To use this method safely, we need to use the DangerousAddRef and DangerousRelease methods — and the best place to use these is right in the middle of the whole marshalling procedure, somewhere that’s been out of reach to us until .NET 7’s release.

This leads us to the better way to “handle” our SafeHandle-in-structs conundrum: by implementing a custom stateful marshaller for this struct.

The Good Way: A Stateful Marshaller

We’re going to leverage some of .NET 7’s new P/Invoke source generation technology so that we can keep our WindowHandle, and a few other SafeHandle instances that we require, where we want them in the call to Shell_NotifyIcon.

A custom marshaller allows for fine-grained control over how a type is marshalled (transformed when crossing between managed and native code).

Most (this is a bit of an assumption) marshallers are ‘stateless’, meaning they are static classes that maintain no state regarding the object being marshalled. You can see an example of one here.

Custom Marshaller Components

We’ll be making a marshaller that keeps track of the object and its constituent parts during the marshalling process. Maintaining state is required so that we can correctly update the reference counters on our SafeHandle instances.

Custom marshalling requires the following components:

  • A managed data type
  • An unmanaged data type
  • The custom marshaller type

When we’re done, we’ll be able to define a P/Invoke signature that receives the managed data class in place of the C-style struct, and it will all work fabulously.

The Managed Data Type

The managed data type will be the class our consuming code will be directly creating and interacting with when calling our P/Invoke function. Consuming code will never interact with the unmanaged data type.

Despite how we are referring to it, the “unmanaged” data type, defined using C# code, will be managed code itself. Our managed data type will differ from the “unmanaged” type in that it will contain all the member types not supported by source-generated P/Invokes, such as our WindowHandle.

Also, while the managed type could (and, in practice, sometimes is) defined as a struct, I tend to define them as classes; this is because, being purely managed representations of the data, I find it more appropriate to define them as how one defines the majority of types which don’t require value type semantics in a .NET assembly: as classes with properties.

NotifyIconData.cs
/// <summary>
/// Provides information that the system needs to display notifications in the
/// notification area.
/// </summary>
[NativeMarshalling(typeof(NotifyIconDataMarshaller))]
internal sealed class NotifyIconData
{
    /// <summary>
    /// Initializes a new instance of the <see cref="NotifyIconData"/> class.
    /// </summary>
    /// <param name="window">
    /// A handle to the window that receives notifications associated with an icon in the
    /// notification area.
    /// </param>
    /// <param name="id">The unique identifier of the taskbar icon.</param>
    /// <param name="flags">
    /// Flags that either indicate which of the other members of the structure contain valid
    /// data or provide additional information to the tooltip as to how it should display.
    /// </param>
    public NotifyIconData(WindowHandle window, Guid id, NotifyIconFlags flags)
    {
        Require.NotNull(window, nameof(window));

        Window = window;
        Id = id;
        // It is implied that the Guid identifier member is valid given our constructor's
        // parameters.
        Flags = flags | NotifyIconFlags.Guid;
    }

    /// <summary>
    /// Gets a handle to the window that receives notifications associated with an icon
    /// in the notification area.
    /// </summary>
    public WindowHandle Window
    { get; }

    /// <summary>
    /// Gets the unique identifier of the taskbar icon.
    /// </summary>
    public Guid Id
    { get; }

    /// <summary>
    /// Gets flags that either indicate which of the other members of the structure contain
    /// valid data or provide additional information to the tooltip as to how it should display.
    /// </summary>
    public NotifyIconFlags Flags
    { get; }

    /// <summary>
    /// Gets or sets an application-defined message identifier.
    /// </summary>
    public uint CallbackMessage
    { get; set; }

    /// <summary>
    /// Gets or sets a handle to the icon to be added, modified, or deleted.
    /// </summary>
    public IconHandle? Icon
    { get; set; }

    /// <summary>
    /// Gets or sets the state of the icon.
    /// </summary>
    public uint State
    { get; set; }

    /// <summary>
    /// Gets or sets a value that specifies which bits of the <see cref="State"/> member
    /// are retrieved or modified.
    /// </summary>
    public uint StateMask
    { get; set; }

    /// <summary>
    /// Gets or sets either the timeout value, in milliseconds, for the notification, or a
    /// specification of which version of the Shell notification icon interface should be used.
    /// </summary>
    public uint TimeoutOrVersion
    { get; set; }

    /// <summary>
    /// Gets or sets Flags that can be set to modify the behavior and appearance of a
    /// balloon notification.
    /// </summary>
    public NotifyIconInfoFlags InfoFlags
    { get; set; }

    /// <summary>
    /// Gets or sets A handle to a customized notification icon that should be used independently
    /// of the notification area icon.
    /// </summary>
    public IconHandle? BalloonIcon
    { get; set; }

    /// <summary>
    /// Gets or sets a string that specifies the text for a standard tooltip.
    /// </summary>
    public string? Tip
    { get; set; }

    /// <summary>
    /// Gets or sets a string that specifies the text to display in a balloon notification.
    /// </summary>
    public string? Info
    { get; set; }

    /// <summary>
    /// Gets or sets a string that specifies a title for a balloon notification.
    /// </summary>
    public string? InfoTitle
    { get; set; }
}

We see above the managed data representation of the NOTIFYICONDATA struct; it is a pretty typical looking POCO.

One thing a bit out of the ordinary is the NativeMarshallingAttribute at the top of the class. The presence of this attribute will cause the specified marshaller to be automatically used when this managed type is present in a P/Invoke signature.

The marshaller type specified, NotifyIconDataMarshaller, is our custom marshaller which we’ll be defining later.

The Unmanaged Data Type

The next step is to define the unmanaged data type. As was mentioned earlier, this “unmanaged” data type’s code will be as managed as everything else we’re writing.

What will make it “unmanaged” is that it will be a C-style struct that contains only member types supported by source-generated P/Invokes.

That means every member must be a built-in value type. So, no SafeHandle types are allowed here; instead, IntPtr must be used for handles. Delegate types will need to be replaced with function pointers, strings will need to be replaced with ushort or byte pointers, etc.

Proper struct authoring is outside the scope of this article; suffice it to say: the unmanaged data type will be the struct we would’ve been directly feeding to the P/Invoke function if we weren’t planning on doing any custom marshalling.

NOTIFYICONDATAW Struct
/// <summary>
/// Represents information that the system needs to display notifications in the notification area.
/// </summary>
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct NOTIFYICONDATAW
{
    /// <summary>
    /// The size of this structure in bytes.
    /// </summary>
    public uint cbSize;
    /// <summary>
    /// A handle to the window that receives notifications associated with an icon in
    /// the notification area.
    /// </summary>
    public IntPtr hWnd;
    /// <summary>
    /// The application-defined identifier of the taskbar icon.
    /// </summary>
    public uint uID;
    /// <summary>
    /// Flags that either indicate which of the other members of the structure contain valid data
    /// or provide additional information to the tooltip as to how it should display.
    /// </summary>
    public NotifyIconFlags uFlags;
    /// <summary>
    /// An application-defined message identifier.
    /// </summary>
    public uint uCallbackMessage;
    /// <summary>
    /// A handle to the icon to be added, modified, or deleted.
    /// </summary>
    public IntPtr hIcon;
    /// <summary>
    /// A null-terminated string that specifies the text for a standard tooltip.
    /// </summary>
    public fixed char szTip[128];
    /// <summary>
    /// The state of the icon.
    /// </summary>
    public uint dwState;
    /// <summary>
    /// A value that specifies which bits of the <see cref="dwState"/> member are retrieved
    /// or modified.
    /// </summary>
    public uint dwStateMask;
    /// <summary>
    /// A null-terminated string that specifies the text to display in a balloon notification.
    /// </summary>
    public fixed char szInfo[256];
    /// <summary>
    /// Either the timeout value, in milliseconds, for the notification, or a specification of which
    /// version of the Shell notification icon interface should be used.
    /// </summary>
    public uint uTimeoutOrVersion;
    /// <summary>
    /// A null-terminated string that specifies the title for a balloon notification.
    /// </summary>
    public fixed char szInfoTitle[64];
    /// <summary>
    /// Flags that can be set to modify the behavior and appearance of a balloon notification.
    /// </summary>
    public NotifyIconInfoFlags dwInfoFlags;
    /// <summary>
    /// A registered <see cref="GUID"/> that identifies the icon. This is the preferred way to
    /// identify an icon in modern OS versions.
    /// </summary>
    public GUID guidItem;
    /// <summary>
    /// A handle to a customized notification icon that should be used independently of the
    /// notification area icon.
    /// </summary>
    public IntPtr hBalloonIcon;

    /// <summary>
    /// Gets or sets a string that specifies the text for a standard tooltip.
    /// </summary>
    public ReadOnlySpan<char> Tip
    {
        get => SzTip.SliceAtFirstNull();
        set => value.CopyToAndTerminate(SzTip);
    }

    /// <summary>
    /// Gets or sets a string that specifies the text to display in a balloon notification.
    /// </summary>
    public ReadOnlySpan<char> Info
    {
        get => SzInfo.SliceAtFirstNull();
        set => value.CopyToAndTerminate(SzInfo);
    }

    /// <summary>
    /// Gets or sets a string that specifies a title for a balloon notification.
    /// </summary>
    public ReadOnlySpan<char> InfoTitle
    {
        get => SzInfoTitle.SliceAtFirstNull();
        set => value.CopyToAndTerminate(SzInfoTitle);
    }

    /// <summary>
    /// Gets a null-terminated string that specifies the text for a standard tooltip.
    /// </summary>
    private Span<char> SzTip
    {
        get
        {
            fixed (char* c = szTip)
            {
                return new Span<char>(c, 128);
            }
        }
    }

    /// <summary>
    /// Gets a null-terminated string that specifies the text to display in a balloon notification.
    /// </summary>
    private Span<char> SzInfo
    {
        get
        {
            fixed (char* c = szInfo)
            {
                return new Span<char>(c, 256);
            }
        }
    }

    /// <summary>
    /// Gets a null-terminated string that specifies a title for a balloon notification.
    /// </summary>
    private Span<char> SzInfoTitle
    {
        get
        {
            fixed (char* c = szInfoTitle)
            {
                return new Span<char>(c, 64);
            }
        }
    }
}

The only other thing to add here is that it is common practice to include the unmanaged data type as a nested type inside the custom marshaller (which we will be covering next). This discourages its use outside of the marshaller.

Now, let’s write that marshaller!

Our Custom Stateful Marshaller

On to the main show. We will be writing a custom stateful marshaller that will marshal notification area information containing a number of custom SafeHandle types.

Most of the time, a custom marshaller will be stateless and consist only of a static class with a few required static methods.

For a stateful marshaller, we need a static class (which will be referenced by the NativeMarshallingAttribute adorning our managed data type) and then, inside of that, an inner struct that will hold state information and provide the marshalling methods.

NotifyIconDataMarshaller.cs
/// <summary>
/// Provides a custom marshaller for notification area information.
/// </summary>
[CustomMarshaller(
    typeof(NotifyIconData), MarshalMode.ManagedToUnmanagedRef, typeof(ManagedToUnmanagedRef))]
internal static unsafe class NotifyIconDataMarshaller
{
    /// <summary>
    /// Represents a stateful marshaller for notification area information.
    /// </summary>
    public ref struct ManagedToUnmanagedRef
    {
        private NOTIFYICONDATAW _unmanaged;
        private WindowHandle _windowHandle;
        private IconHandle? _iconHandle;
        private IconHandle? _balloonIconHandle;
        private bool _windowHandleAddRefd;
        private bool _iconHandleAddRefd;
        private bool _balloonIconHandleAddRefd;
        private IntPtr _originalWindowHandleValue;
        private IntPtr _originalIconHandleValue;
        private IntPtr _originalBalloonIconHandleValue;

        /// <summary>
        /// Converts a managed <see cref="NotifyIconData"/> instance to its unmanaged counterpart,
        /// loading the result into the marshaller.
        /// </summary>
        /// <param name="iconData">A managed instance of notification area information.</param>
        public void FromManaged(NotifyIconData iconData)
        {
            Require.NotNull(iconData, nameof(iconData));

            _windowHandleAddRefd = false;
            _iconHandleAddRefd = false;
            _balloonIconHandleAddRefd = false;

            _windowHandle = iconData.Window;
            _iconHandle = iconData.Icon;
            _balloonIconHandle = iconData.BalloonIcon;

            _unmanaged.cbSize = (uint) sizeof(NOTIFYICONDATAW);
            _windowHandle.DangerousAddRef(ref _windowHandleAddRefd);
            _unmanaged.hWnd = _originalWindowHandleValue = _windowHandle.DangerousGetHandle();
            _unmanaged.guidItem = GuidMarshaller.ConvertToUnmanaged(iconData.Id);
            _unmanaged.uFlags = iconData.Flags;
            _unmanaged.uCallbackMessage = iconData.CallbackMessage;

            if (_iconHandle != null)
            {
                _iconHandle.DangerousAddRef(ref _iconHandleAddRefd);
                _unmanaged.hIcon = _originalIconHandleValue = _iconHandle.DangerousGetHandle();
            }

            _unmanaged.dwState = iconData.State;
            _unmanaged.dwStateMask = iconData.StateMask;
            _unmanaged.uTimeoutOrVersion = iconData.TimeoutOrVersion;
            _unmanaged.dwInfoFlags = iconData.InfoFlags;

            if (_balloonIconHandle != null)
            {
                _balloonIconHandle.DangerousAddRef(ref _balloonIconHandleAddRefd);
                _unmanaged.hBalloonIcon 
                    = _originalBalloonIconHandleValue = _balloonIconHandle.DangerousGetHandle();
            }

            _unmanaged.Tip = iconData.Tip;
            _unmanaged.Info = iconData.Info;
            _unmanaged.InfoTitle = iconData.InfoTitle;
        }

        /// <summary>
        /// Provides the unmanaged notification area information currently loaded into the
        /// marshaller.
        /// </summary>
        /// <returns>The converted <see cref="NOTIFYICONDATAW"/> value.</returns>
        public NOTIFYICONDATAW ToUnmanaged()
            => _unmanaged;

        /// <summary>
        /// Loads the provided unmanaged notification area information into the
        /// marshaller.
        /// </summary>
        /// <param name="unmanaged">The unmanaged instance of notification area information.</param>
        public void FromUnmanaged(NOTIFYICONDATAW unmanaged)
            => _unmanaged = unmanaged;

        /// <summary>
        /// Converts the unmanaged <see cref="NOTIFYICONDATAW"/> instance currently loaded into
        /// the marshaller into its managed counterpart, returning the result.
        /// </summary>
        /// <returns>The converted <see cref="NotifyIconData"/> instance.</returns>
        /// <exception cref="NotSupportedException">
        /// A handle originating from a <see cref="SafeHandle"/> was changed.
        /// </exception>
        public NotifyIconData ToManaged()
        {
            // SafeHandle fields must match the underlying handle value during marshalling.
            // They cannot change.
            if (_unmanaged.hWnd != _originalWindowHandleValue)
                throw new NotSupportedException(Strings.HandleCannotChangeDuringMarshalling);

            if (_unmanaged.hIcon != _originalIconHandleValue)
                throw new NotSupportedException(Strings.HandleCannotChangeDuringMarshalling);

            if (_unmanaged.hBalloonIcon != _originalBalloonIconHandleValue)
                throw new NotSupportedException(Strings.HandleCannotChangeDuringMarshalling);

            Guid managedId = GuidMarshaller.ConvertToManaged(_unmanaged.guidItem);
            
            return new NotifyIconData(_windowHandle, managedId, _unmanaged.uFlags)
                   {
                       CallbackMessage = _unmanaged.uCallbackMessage,
                       Icon = _iconHandle,
                       State = _unmanaged.dwState,
                       StateMask = _unmanaged.dwStateMask,
                       TimeoutOrVersion = _unmanaged.uTimeoutOrVersion,
                       InfoFlags = _unmanaged.dwInfoFlags,
                       BalloonIcon = _balloonIconHandle,
                       Tip = new string(_unmanaged.Tip),
                       Info = new string(_unmanaged.Info),
                       InfoTitle = new string(_unmanaged.InfoTitle)
                   };
        }

        /// <summary>
        /// Releases all resources in use by the marshaller.
        /// </summary>
        public void Free()
        {
            if (_windowHandleAddRefd)
                _windowHandle.DangerousRelease();

            if (_iconHandle != null && _iconHandleAddRefd)
                _iconHandle.DangerousRelease();

            if (_balloonIconHandle != null && _balloonIconHandleAddRefd)
                _balloonIconHandle.DangerousRelease();
        }
    }
}

During managed-to-unmanaged conversion in the FromManaged method, we call DangerousAddRef on all the provided SafeHandle instances and store the bool value indicating whether or not the reference counter was successfully incremented.

After that, we call DangerousGetHandle for each SafeHandle present and store the resulting system handles both in our marshalling struct as well as the NOTIFYICONDATAW struct which we’re populating.

We keep a backup of the original handles in order to see if their values have changed at all when the ToManaged method executes; something which we cannot support given that they originated from SafeHandle types.

Finally, in the Free method, we make good on our attempt to keep our SafeHandle instances “safe” and call the DangerousRelease method for each handle whose reference counter was previously incremented.

SafeHandles and Structs Living in Harmony

We’re done! Our P/Invoke signature for Shell_NotifyIcon should now be error-free.

Shell_NotifyIcon P/Invoke Signature
/// <summary>
/// Sends a message to the taskbar's status area.
/// </summary>
/// <param name="dwMessage">A value that specifies the action to be taken by this function.</param>
/// <param name="lpData">
/// A <see cref="NotifyIconData"/> instance containing notification area information.
/// </param>
/// <returns>True if successful; otherwise, false.</returns>
[LibraryImport(LIBRARY_NAME, EntryPoint = "Shell_NotifyIconW")]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool Shell_NotifyIcon(NotifyIconMessage dwMessage, ref NotifyIconData lpData);

Let’s try it out. The following code was tested and executed successfully.

Shell_NotifyIcon Usage with SafeHandles
var iconData
    = new NotifyIconData(windowHandle,
                         _id,
                         NotifyIconFlags.Message | NotifyIconFlags.Icon | NotifyIconFlags.Tip)
      {
          CallbackMessage = (uint) _TrayEvent,
          Icon = iconHandle,
          Tip = _tip
      };

Shell32.Shell_NotifyIcon(NotifyIconMessage.Add, ref iconData);

Mission accomplished! We can all rest easier at night with our SafeHandle instances now being able to be passed into those pesky P/Invoke methods requiring C-style structs.