Skip to content

Commit 0db5837

Browse files
committed
Implement IDisposable on VirtualizationInstance to prevent zombie processes
VirtualizationInstance holds native ProjFS handles (PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT) and GCHandles that must be released when the instance is no longer needed. Previously, StopVirtualizing() had to be called explicitly — if the process crashed or exited without calling it, ProjFS kept the virtualization root alive, creating zombie processes. Changes: - IVirtualizationInstance now extends IDisposable - VirtualizationInstance implements full dispose pattern: - Dispose() calls PrjStopVirtualizing, frees GCHandles, frees Marshal.StringToHGlobalUni notification strings - Finalizer ~VirtualizationInstance() as safety net - StopVirtualizing() calls Dispose() for backward compat (Stream.Close pattern) - Thread-safe: _disposed flag prevents double-free - All public methods throw ObjectDisposedException after disposal - Fixed memory leak: _notificationRootStrings and _notificationMappingsHandle were never freed in StopVirtualizing - Enabled .NET analyzers (CA1001/CA2213 would have caught this) - Added 8 unit tests for disposal mechanics (all pass without ProjFS feature) Version bumped to 2.1.0 (breaking: IVirtualizationInstance now requires Dispose)
1 parent c8e7db6 commit 0db5837

7 files changed

Lines changed: 318 additions & 36 deletions

File tree

Directory.Build.props

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
<Project>
22

33
<PropertyGroup>
4-
<ProjFSManagedVersion>2.0.0</ProjFSManagedVersion>
4+
<ProjFSManagedVersion>2.1.0</ProjFSManagedVersion>
55
<LangVersion>latest</LangVersion>
66
<Nullable>enable</Nullable>
7+
<EnableNETAnalyzers>true</EnableNETAnalyzers>
8+
<AnalysisLevel>latest-recommended</AnalysisLevel>
79
</PropertyGroup>
810

911
</Project>
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
using Microsoft.Windows.ProjFS;
5+
using NUnit.Framework;
6+
using System;
7+
8+
namespace ProjectedFSLib.Managed.Test
9+
{
10+
/// <summary>
11+
/// Tests for VirtualizationInstance IDisposable implementation.
12+
/// These tests verify the dispose pattern mechanics without requiring
13+
/// the ProjFS optional feature to be enabled on the machine.
14+
/// </summary>
15+
public class DisposeTests
16+
{
17+
[Test]
18+
public void VirtualizationInstance_ImplementsIDisposable()
19+
{
20+
// VirtualizationInstance must implement IDisposable to prevent zombie processes.
21+
var instance = new VirtualizationInstance(
22+
"C:\\nonexistent",
23+
poolThreadCount: 0,
24+
concurrentThreadCount: 0,
25+
enableNegativePathCache: false,
26+
notificationMappings: new System.Collections.Generic.List<NotificationMapping>());
27+
28+
Assert.That(instance, Is.InstanceOf<IDisposable>());
29+
}
30+
31+
[Test]
32+
public void IVirtualizationInstance_ExtendsIDisposable()
33+
{
34+
// The interface itself must extend IDisposable so all implementations are required
35+
// to support disposal.
36+
Assert.That(typeof(IDisposable).IsAssignableFrom(typeof(IVirtualizationInstance)));
37+
}
38+
39+
[Test]
40+
public void Dispose_CanBeCalledMultipleTimes()
41+
{
42+
var instance = new VirtualizationInstance(
43+
"C:\\nonexistent",
44+
poolThreadCount: 0,
45+
concurrentThreadCount: 0,
46+
enableNegativePathCache: false,
47+
notificationMappings: new System.Collections.Generic.List<NotificationMapping>());
48+
49+
// Should not throw on any call.
50+
instance.Dispose();
51+
instance.Dispose();
52+
instance.Dispose();
53+
}
54+
55+
[Test]
56+
public void StopVirtualizing_CanBeCalledMultipleTimes()
57+
{
58+
var instance = new VirtualizationInstance(
59+
"C:\\nonexistent",
60+
poolThreadCount: 0,
61+
concurrentThreadCount: 0,
62+
enableNegativePathCache: false,
63+
notificationMappings: new System.Collections.Generic.List<NotificationMapping>());
64+
65+
// Should not throw on any call.
66+
instance.StopVirtualizing();
67+
instance.StopVirtualizing();
68+
}
69+
70+
[Test]
71+
public void StopVirtualizing_ThenDispose_DoesNotThrow()
72+
{
73+
var instance = new VirtualizationInstance(
74+
"C:\\nonexistent",
75+
poolThreadCount: 0,
76+
concurrentThreadCount: 0,
77+
enableNegativePathCache: false,
78+
notificationMappings: new System.Collections.Generic.List<NotificationMapping>());
79+
80+
instance.StopVirtualizing();
81+
instance.Dispose();
82+
}
83+
84+
[Test]
85+
public void AfterDispose_MethodsThrowObjectDisposedException()
86+
{
87+
var instance = new VirtualizationInstance(
88+
"C:\\nonexistent",
89+
poolThreadCount: 0,
90+
concurrentThreadCount: 0,
91+
enableNegativePathCache: false,
92+
notificationMappings: new System.Collections.Generic.List<NotificationMapping>());
93+
94+
instance.Dispose();
95+
96+
Assert.Throws<ObjectDisposedException>(() =>
97+
instance.ClearNegativePathCache(out _));
98+
99+
Assert.Throws<ObjectDisposedException>(() =>
100+
instance.DeleteFile("test.txt", UpdateType.AllowDirtyMetadata, out _));
101+
102+
Assert.Throws<ObjectDisposedException>(() =>
103+
instance.WritePlaceholderInfo(
104+
"test.txt", DateTime.Now, DateTime.Now, DateTime.Now, DateTime.Now,
105+
System.IO.FileAttributes.Normal, 0, false, new byte[128], new byte[128]));
106+
107+
Assert.Throws<ObjectDisposedException>(() =>
108+
instance.CreateWriteBuffer(4096));
109+
110+
Assert.Throws<ObjectDisposedException>(() =>
111+
instance.CompleteCommand(0));
112+
113+
Assert.Throws<ObjectDisposedException>(() =>
114+
instance.StartVirtualizing(null!));
115+
}
116+
117+
[Test]
118+
public void AfterStopVirtualizing_MethodsThrowObjectDisposedException()
119+
{
120+
var instance = new VirtualizationInstance(
121+
"C:\\nonexistent",
122+
poolThreadCount: 0,
123+
concurrentThreadCount: 0,
124+
enableNegativePathCache: false,
125+
notificationMappings: new System.Collections.Generic.List<NotificationMapping>());
126+
127+
instance.StopVirtualizing();
128+
129+
// StopVirtualizing should have the same effect as Dispose.
130+
Assert.Throws<ObjectDisposedException>(() =>
131+
instance.ClearNegativePathCache(out _));
132+
133+
Assert.Throws<ObjectDisposedException>(() =>
134+
instance.CreateWriteBuffer(4096));
135+
}
136+
137+
[Test]
138+
public void UsingStatement_DisposesAutomatically()
139+
{
140+
VirtualizationInstance instance;
141+
using (instance = new VirtualizationInstance(
142+
"C:\\nonexistent",
143+
poolThreadCount: 0,
144+
concurrentThreadCount: 0,
145+
enableNegativePathCache: false,
146+
notificationMappings: new System.Collections.Generic.List<NotificationMapping>()))
147+
{
148+
// Instance is alive here.
149+
}
150+
151+
// After using block, instance should be disposed.
152+
Assert.Throws<ObjectDisposedException>(() =>
153+
instance.ClearNegativePathCache(out _));
154+
}
155+
}
156+
}

ProjectedFSLib.Managed/ProjFSLib.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ public string NotificationRoot
128128

129129
private static void ValidateNotificationRoot(string root)
130130
{
131-
if (root == "." || (root != null && root.StartsWith(".\\")))
131+
if (root == "." || (root != null && root.StartsWith(".\\", StringComparison.Ordinal)))
132132
{
133133
throw new ArgumentException(
134134
"notificationRoot cannot be \".\" or begin with \".\\\"");
@@ -216,7 +216,9 @@ public delegate bool NotifyPreCreateHardlinkCallback(
216216
// Interfaces
217217
public interface IWriteBuffer : IDisposable
218218
{
219+
#pragma warning disable CA1720 // Identifier contains type name — established public API, cannot rename
219220
IntPtr Pointer { get; }
221+
#pragma warning restore CA1720
220222
UnmanagedMemoryStream Stream { get; }
221223
long Length { get; }
222224
}
@@ -289,7 +291,7 @@ HResult GetFileDataCallback(
289291
string triggeringProcessImageFileName);
290292
}
291293

292-
public interface IVirtualizationInstance
294+
public interface IVirtualizationInstance : IDisposable
293295
{
294296
/// <summary>Returns the virtualization instance GUID.</summary>
295297
Guid VirtualizationInstanceId { get; }

ProjectedFSLib.Managed/ProjFSNative.cs

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,11 @@ internal static extern int PrjStartVirtualizing(
3030
ref PRJ_CALLBACKS callbacks,
3131
IntPtr instanceContext,
3232
ref PRJ_STARTVIRTUALIZING_OPTIONS options,
33-
out IntPtr namespaceVirtualizationContext);
33+
out SafeProjFsHandle namespaceVirtualizationContext);
3434

35+
// PrjStopVirtualizing takes raw IntPtr (not SafeProjFsHandle) because it is
36+
// called from SafeProjFsHandle.ReleaseHandle(), where the SafeHandle is already
37+
// closed and cannot be marshaled. All other ProjFS APIs use SafeProjFsHandle.
3538
#if NET7_0_OR_GREATER
3639
[LibraryImport(ProjFSLib)]
3740
internal static partial void PrjStopVirtualizing(IntPtr namespaceVirtualizationContext);
@@ -51,7 +54,7 @@ internal static partial int PrjWritePlaceholderInfo(
5154
[DllImport(ProjFSLib, CharSet = CharSet.Unicode, ExactSpelling = true)]
5255
internal static extern int PrjWritePlaceholderInfo(
5356
#endif
54-
IntPtr namespaceVirtualizationContext,
57+
SafeProjFsHandle namespaceVirtualizationContext,
5558
string destinationFileName,
5659
ref PRJ_PLACEHOLDER_INFO placeholderInfo,
5760
uint length);
@@ -63,7 +66,7 @@ internal static partial int PrjWritePlaceholderInfo2(
6366
[DllImport(ProjFSLib, CharSet = CharSet.Unicode, ExactSpelling = true)]
6467
internal static extern int PrjWritePlaceholderInfo2(
6568
#endif
66-
IntPtr namespaceVirtualizationContext,
69+
SafeProjFsHandle namespaceVirtualizationContext,
6770
string destinationFileName,
6871
ref PRJ_PLACEHOLDER_INFO placeholderInfo,
6972
uint placeholderInfoSize,
@@ -76,7 +79,7 @@ internal static partial int PrjWritePlaceholderInfo2Raw(
7679
[DllImport(ProjFSLib, CharSet = CharSet.Unicode, ExactSpelling = true, EntryPoint = "PrjWritePlaceholderInfo2")]
7780
internal static extern int PrjWritePlaceholderInfo2Raw(
7881
#endif
79-
IntPtr namespaceVirtualizationContext,
82+
SafeProjFsHandle namespaceVirtualizationContext,
8083
IntPtr destinationFileName,
8184
IntPtr placeholderInfo,
8285
uint placeholderInfoSize,
@@ -89,7 +92,7 @@ internal static partial int PrjUpdateFileIfNeeded(
8992
[DllImport(ProjFSLib, CharSet = CharSet.Unicode, ExactSpelling = true)]
9093
internal static extern int PrjUpdateFileIfNeeded(
9194
#endif
92-
IntPtr namespaceVirtualizationContext,
95+
SafeProjFsHandle namespaceVirtualizationContext,
9396
string destinationFileName,
9497
ref PRJ_PLACEHOLDER_INFO placeholderInfo,
9598
uint length,
@@ -103,7 +106,7 @@ internal static partial int PrjDeleteFile(
103106
[DllImport(ProjFSLib, CharSet = CharSet.Unicode, ExactSpelling = true)]
104107
internal static extern int PrjDeleteFile(
105108
#endif
106-
IntPtr namespaceVirtualizationContext,
109+
SafeProjFsHandle namespaceVirtualizationContext,
107110
string destinationFileName,
108111
uint updateFlags,
109112
out uint failureReason);
@@ -146,7 +149,7 @@ internal static partial int PrjWriteFileData(
146149
[DllImport(ProjFSLib, ExactSpelling = true)]
147150
internal static extern int PrjWriteFileData(
148151
#endif
149-
IntPtr namespaceVirtualizationContext,
152+
SafeProjFsHandle namespaceVirtualizationContext,
150153
ref Guid dataStreamId,
151154
IntPtr buffer,
152155
ulong byteOffset,
@@ -159,7 +162,7 @@ internal static partial IntPtr PrjAllocateAlignedBuffer(
159162
[DllImport(ProjFSLib, ExactSpelling = true)]
160163
internal static extern IntPtr PrjAllocateAlignedBuffer(
161164
#endif
162-
IntPtr namespaceVirtualizationContext,
165+
SafeProjFsHandle namespaceVirtualizationContext,
163166
UIntPtr size);
164167

165168
#if NET7_0_OR_GREATER
@@ -181,7 +184,7 @@ internal static partial int PrjCompleteCommand(
181184
[DllImport(ProjFSLib, ExactSpelling = true)]
182185
internal static extern int PrjCompleteCommand(
183186
#endif
184-
IntPtr namespaceVirtualizationContext,
187+
SafeProjFsHandle namespaceVirtualizationContext,
185188
int commandId,
186189
int completionResult,
187190
IntPtr extendedParameters);
@@ -193,7 +196,7 @@ internal static partial int PrjCompleteCommandWithNotification(
193196
[DllImport(ProjFSLib, ExactSpelling = true, EntryPoint = "PrjCompleteCommand")]
194197
internal static extern int PrjCompleteCommandWithNotification(
195198
#endif
196-
IntPtr namespaceVirtualizationContext,
199+
SafeProjFsHandle namespaceVirtualizationContext,
197200
int commandId,
198201
int completionResult,
199202
ref PRJ_COMPLETE_COMMAND_EXTENDED_PARAMETERS extendedParameters);
@@ -209,7 +212,7 @@ internal static partial int PrjClearNegativePathCache(
209212
[DllImport(ProjFSLib, ExactSpelling = true)]
210213
internal static extern int PrjClearNegativePathCache(
211214
#endif
212-
IntPtr namespaceVirtualizationContext,
215+
SafeProjFsHandle namespaceVirtualizationContext,
213216
out uint totalEntryNumber);
214217

215218
// ============================
@@ -306,7 +309,7 @@ internal static partial int PrjGetVirtualizationInstanceInfo(
306309
[DllImport(ProjFSLib, ExactSpelling = true)]
307310
internal static extern int PrjGetVirtualizationInstanceInfo(
308311
#endif
309-
IntPtr namespaceVirtualizationContext,
312+
SafeProjFsHandle namespaceVirtualizationContext,
310313
ref PRJ_VIRTUALIZATION_INSTANCE_INFO virtualizationInstanceInfo);
311314

312315
// ============================
@@ -318,7 +321,7 @@ internal struct PRJ_CALLBACK_DATA
318321
{
319322
public uint Size;
320323
public uint Flags;
321-
public IntPtr NamespaceVirtualizationContext;
324+
public SafeProjFsHandle namespaceVirtualizationContext;
322325
public int CommandId;
323326
public Guid FileId;
324327
public Guid DataStreamId;
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using System;
2+
using System.Runtime.InteropServices;
3+
using Microsoft.Win32.SafeHandles;
4+
5+
namespace Microsoft.Windows.ProjFS
6+
{
7+
/// <summary>
8+
/// SafeHandle wrapper for the PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT returned
9+
/// by PrjStartVirtualizing. Guarantees PrjStopVirtualizing is called even
10+
/// during rude app domain unloads, Environment.Exit, or finalizer-only cleanup.
11+
/// </summary>
12+
/// <remarks>
13+
/// <para>
14+
/// SafeHandle is a CriticalFinalizerObject — the CLR guarantees its
15+
/// ReleaseHandle runs after all normal finalizers and during constrained
16+
/// execution regions. This provides the strongest possible guarantee that
17+
/// the ProjFS virtualization root is released, preventing zombie processes.
18+
/// </para>
19+
/// </remarks>
20+
internal class SafeProjFsHandle : SafeHandleZeroOrMinusOneIsInvalid
21+
{
22+
/// <summary>
23+
/// Parameterless constructor required by P/Invoke marshaler for out-parameter usage.
24+
/// </summary>
25+
public SafeProjFsHandle() : base(ownsHandle: true) { }
26+
27+
protected override bool ReleaseHandle()
28+
{
29+
// Must use the raw 'handle' field (IntPtr) here, not 'this'.
30+
// Inside ReleaseHandle, the SafeHandle is already marked as closed —
31+
// passing 'this' to a P/Invoke taking SafeProjFsHandle would fail
32+
// because the marshaler refuses to marshal a closed SafeHandle.
33+
ProjFSNative.PrjStopVirtualizing(handle);
34+
return true;
35+
}
36+
}
37+
}

0 commit comments

Comments
 (0)