Skip to content

Commit fa02ce0

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 fa02ce0

7 files changed

Lines changed: 311 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: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ 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

3535
#if NET7_0_OR_GREATER
3636
[LibraryImport(ProjFSLib)]
@@ -51,7 +51,7 @@ internal static partial int PrjWritePlaceholderInfo(
5151
[DllImport(ProjFSLib, CharSet = CharSet.Unicode, ExactSpelling = true)]
5252
internal static extern int PrjWritePlaceholderInfo(
5353
#endif
54-
IntPtr namespaceVirtualizationContext,
54+
SafeProjFsHandle namespaceVirtualizationContext,
5555
string destinationFileName,
5656
ref PRJ_PLACEHOLDER_INFO placeholderInfo,
5757
uint length);
@@ -63,7 +63,7 @@ internal static partial int PrjWritePlaceholderInfo2(
6363
[DllImport(ProjFSLib, CharSet = CharSet.Unicode, ExactSpelling = true)]
6464
internal static extern int PrjWritePlaceholderInfo2(
6565
#endif
66-
IntPtr namespaceVirtualizationContext,
66+
SafeProjFsHandle namespaceVirtualizationContext,
6767
string destinationFileName,
6868
ref PRJ_PLACEHOLDER_INFO placeholderInfo,
6969
uint placeholderInfoSize,
@@ -76,7 +76,7 @@ internal static partial int PrjWritePlaceholderInfo2Raw(
7676
[DllImport(ProjFSLib, CharSet = CharSet.Unicode, ExactSpelling = true, EntryPoint = "PrjWritePlaceholderInfo2")]
7777
internal static extern int PrjWritePlaceholderInfo2Raw(
7878
#endif
79-
IntPtr namespaceVirtualizationContext,
79+
SafeProjFsHandle namespaceVirtualizationContext,
8080
IntPtr destinationFileName,
8181
IntPtr placeholderInfo,
8282
uint placeholderInfoSize,
@@ -89,7 +89,7 @@ internal static partial int PrjUpdateFileIfNeeded(
8989
[DllImport(ProjFSLib, CharSet = CharSet.Unicode, ExactSpelling = true)]
9090
internal static extern int PrjUpdateFileIfNeeded(
9191
#endif
92-
IntPtr namespaceVirtualizationContext,
92+
SafeProjFsHandle namespaceVirtualizationContext,
9393
string destinationFileName,
9494
ref PRJ_PLACEHOLDER_INFO placeholderInfo,
9595
uint length,
@@ -103,7 +103,7 @@ internal static partial int PrjDeleteFile(
103103
[DllImport(ProjFSLib, CharSet = CharSet.Unicode, ExactSpelling = true)]
104104
internal static extern int PrjDeleteFile(
105105
#endif
106-
IntPtr namespaceVirtualizationContext,
106+
SafeProjFsHandle namespaceVirtualizationContext,
107107
string destinationFileName,
108108
uint updateFlags,
109109
out uint failureReason);
@@ -146,7 +146,7 @@ internal static partial int PrjWriteFileData(
146146
[DllImport(ProjFSLib, ExactSpelling = true)]
147147
internal static extern int PrjWriteFileData(
148148
#endif
149-
IntPtr namespaceVirtualizationContext,
149+
SafeProjFsHandle namespaceVirtualizationContext,
150150
ref Guid dataStreamId,
151151
IntPtr buffer,
152152
ulong byteOffset,
@@ -159,7 +159,7 @@ internal static partial IntPtr PrjAllocateAlignedBuffer(
159159
[DllImport(ProjFSLib, ExactSpelling = true)]
160160
internal static extern IntPtr PrjAllocateAlignedBuffer(
161161
#endif
162-
IntPtr namespaceVirtualizationContext,
162+
SafeProjFsHandle namespaceVirtualizationContext,
163163
UIntPtr size);
164164

165165
#if NET7_0_OR_GREATER
@@ -181,7 +181,7 @@ internal static partial int PrjCompleteCommand(
181181
[DllImport(ProjFSLib, ExactSpelling = true)]
182182
internal static extern int PrjCompleteCommand(
183183
#endif
184-
IntPtr namespaceVirtualizationContext,
184+
SafeProjFsHandle namespaceVirtualizationContext,
185185
int commandId,
186186
int completionResult,
187187
IntPtr extendedParameters);
@@ -193,7 +193,7 @@ internal static partial int PrjCompleteCommandWithNotification(
193193
[DllImport(ProjFSLib, ExactSpelling = true, EntryPoint = "PrjCompleteCommand")]
194194
internal static extern int PrjCompleteCommandWithNotification(
195195
#endif
196-
IntPtr namespaceVirtualizationContext,
196+
SafeProjFsHandle namespaceVirtualizationContext,
197197
int commandId,
198198
int completionResult,
199199
ref PRJ_COMPLETE_COMMAND_EXTENDED_PARAMETERS extendedParameters);
@@ -209,7 +209,7 @@ internal static partial int PrjClearNegativePathCache(
209209
[DllImport(ProjFSLib, ExactSpelling = true)]
210210
internal static extern int PrjClearNegativePathCache(
211211
#endif
212-
IntPtr namespaceVirtualizationContext,
212+
SafeProjFsHandle namespaceVirtualizationContext,
213213
out uint totalEntryNumber);
214214

215215
// ============================
@@ -306,7 +306,7 @@ internal static partial int PrjGetVirtualizationInstanceInfo(
306306
[DllImport(ProjFSLib, ExactSpelling = true)]
307307
internal static extern int PrjGetVirtualizationInstanceInfo(
308308
#endif
309-
IntPtr namespaceVirtualizationContext,
309+
SafeProjFsHandle namespaceVirtualizationContext,
310310
ref PRJ_VIRTUALIZATION_INSTANCE_INFO virtualizationInstanceInfo);
311311

312312
// ============================
@@ -318,7 +318,7 @@ internal struct PRJ_CALLBACK_DATA
318318
{
319319
public uint Size;
320320
public uint Flags;
321-
public IntPtr NamespaceVirtualizationContext;
321+
public SafeProjFsHandle namespaceVirtualizationContext;
322322
public int CommandId;
323323
public Guid FileId;
324324
public Guid DataStreamId;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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+
ProjFSNative.PrjStopVirtualizing(handle);
30+
return true;
31+
}
32+
}
33+
}

0 commit comments

Comments
 (0)