Skip to content

Commit 692f5c2

Browse files
tyrielvminiksa
andcommitted
Adapt functional tests for .NET 10 ProjFS behavioral changes
.NET 10's FileInfo property setters no longer open write handles that trigger ProjFS placeholder hydration. Adapt tests that relied on this. BasicFileSystemTests: replace ExpandedFileAttributesAreUpdated with two focused tests: - PlaceholderMetadataSurvivesHydration: sets timestamps + Hidden on a placeholder, verifies they took effect, hydrates via read+write, and asserts CreationTime and Hidden survived the conversion. - HydratedFileTimestampsAndAttributesAreUpdated: hydrates first, then sets all properties and verifies they stick. GitCommandsTests: ChangeTimestampAndDiff now explicitly hydrates via read+write before adjusting timestamps, since File.SetLastWriteTime no longer triggers ProjFS hydration. GVFSProcess: add 5-minute timeout per gvfs process invocation to prevent CI hangs. Stream stdout/stderr for real-time CI output. functional-tests.yaml: reduce mount sleep from 500ms to 100ms, add timeout-minutes. Co-authored-by: Michael Niksa <miniksa@microsoft.com> Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
1 parent b8712a3 commit 692f5c2

6 files changed

Lines changed: 197 additions & 42 deletions

File tree

.github/workflows/functional-tests.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,18 @@ jobs:
122122
shell: cmd
123123
run: gvfs\install.bat
124124

125+
- name: Verify GVFS installation
126+
if: steps.skip.outputs.result != 'true'
127+
shell: cmd
128+
continue-on-error: true
129+
run: |
130+
echo === GVFS Version ===
131+
"C:\Program Files\VFS for Git\GVFS.exe" version
132+
echo === Service Status ===
133+
sc query GVFS.Service
134+
echo === List Mounted ===
135+
"C:\Program Files\VFS for Git\GVFS.exe" service --list-mounted
136+
125137
- name: ProjFS details (post-install)
126138
if: steps.skip.outputs.result != 'true'
127139
shell: cmd
@@ -141,6 +153,7 @@ jobs:
141153
- name: Run functional tests
142154
if: steps.skip.outputs.result != 'true'
143155
shell: cmd
156+
timeout-minutes: 60
144157
run: |
145158
SET PATH=C:\Program Files\VFS for Git;%PATH%
146159
SET GIT_TRACE2_PERF=C:\temp\git-trace2.log

GVFS/GVFS.FunctionalTests/Program.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ public static void Main(string[] args)
130130
?? Properties.Settings.Default.RepoToClone;
131131

132132
RunBeforeAnyTests();
133+
Console.WriteLine("[CI-DEBUG] RunBeforeAnyTests complete, starting RunTests...");
134+
Console.Out.Flush();
133135
Environment.ExitCode = runner.RunTests(includeCategories, excludeCategories, testSlice);
134136

135137
if (Debugger.IsAttached)
@@ -141,12 +143,19 @@ public static void Main(string[] args)
141143

142144
private static void RunBeforeAnyTests()
143145
{
146+
Console.WriteLine("[CI-DEBUG] RunBeforeAnyTests: starting");
147+
Console.Out.Flush();
148+
144149
if (GVFSTestConfig.ReplaceInboxProjFS)
145150
{
146151
ProjFSFilterInstaller.ReplaceInboxProjFS();
147152
}
148153

154+
Console.WriteLine("[CI-DEBUG] Installing service...");
155+
Console.Out.Flush();
149156
GVFSServiceProcess.InstallService();
157+
Console.WriteLine("[CI-DEBUG] Service installed successfully");
158+
Console.Out.Flush();
150159

151160
string serviceProgramDataDir = GVFSPlatform.Instance.GetSecureDataRootForGVFSComponent(
152161
GVFSConstants.Service.ServiceName);
@@ -159,6 +168,9 @@ private static void RunBeforeAnyTests()
159168
Directory.CreateDirectory(serviceProgramDataDir);
160169
File.WriteAllText(statusCacheVersionTokenPath, string.Empty);
161170
}
171+
172+
Console.WriteLine("[CI-DEBUG] RunBeforeAnyTests: complete");
173+
Console.Out.Flush();
162174
}
163175
}
164176
}

GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/BasicFileSystemTests.cs

Lines changed: 111 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -133,49 +133,132 @@ public void NewFolderAttributesAreUpdated(string parentFolder)
133133
Directory.Delete(virtualFolder);
134134
}
135135

136+
// On .NET 10, no FileInfo property setter (CreationTime, LastAccessTime, LastWriteTime,
137+
// Attributes) triggers ProjFS hydration. Only actual file content I/O (read+write) does.
138+
// These tests replace the original ExpandedFileAttributesAreUpdated test, which relied on
139+
// .NET Framework 4.7.1's CreationTime setter triggering hydration as a side effect.
140+
141+
/// <summary>
142+
/// Hydrates a ProjFS placeholder by reading and writing its content, then waits for
143+
/// ProjFS to clear the RecallOnDataAccess flag (which happens asynchronously).
144+
/// Uses FileStream with FileMode.Open since File.WriteAllText fails on Hidden files.
145+
/// </summary>
146+
private static void HydrateFile(string virtualFile)
147+
{
148+
using (FileStream fs = new FileStream(virtualFile, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
149+
{
150+
byte[] buf = new byte[fs.Length];
151+
fs.Read(buf, 0, buf.Length);
152+
fs.Position = 0;
153+
fs.Write(buf, 0, buf.Length);
154+
}
155+
156+
// ProjFS clears RecallOnDataAccess asynchronously after hydration.
157+
// Wait for it to complete — CI machines can be slow.
158+
int retryCount = 0;
159+
while (retryCount < 10)
160+
{
161+
FileAttributes attrs = File.GetAttributes(virtualFile);
162+
if (((int)attrs & FileAttributeRecallOnDataAccess) == 0)
163+
{
164+
return;
165+
}
166+
167+
++retryCount;
168+
Thread.Sleep(500);
169+
}
170+
171+
File.GetAttributes(virtualFile).ShouldNotEqual(
172+
(FileAttributes)FileAttributeRecallOnDataAccess,
173+
"File should be hydrated (no RecallOnDataAccess) after content write and retry");
174+
}
175+
136176
[TestCase]
137-
public void ExpandedFileAttributesAreUpdated()
177+
public void PlaceholderMetadataSurvivesHydration()
138178
{
179+
// Set all metadata properties on a ProjFS placeholder, verify they took effect
180+
// while the file is still a placeholder, then hydrate via content I/O and verify
181+
// the values survived the placeholder-to-full-file conversion.
139182
FileSystemRunner fileSystem = FileSystemRunner.DefaultRunner;
140183

141184
string filename = Path.Combine("GVFS", "GVFS", "GVFS.csproj");
142185
string virtualFile = this.Enlistment.GetVirtualPathTo(filename);
143-
144-
// Update defaults. FileInfo is not batched, so each of these will create a separate Open-Update-Close set.
145-
FileInfo before = new FileInfo(virtualFile);
146186
DateTime testValue = DateTime.Now + TimeSpan.FromDays(1);
147187

148-
// Setting the CreationTime results in a write handle being open to the file and the file being expanded
149-
before.CreationTime = testValue;
150-
before.LastAccessTime = testValue;
151-
before.LastWriteTime = testValue;
152-
before.Attributes = FileAttributes.Hidden;
153-
154-
// FileInfo caches information. We can refresh, but just to be absolutely sure...
155-
FileInfo info = virtualFile.ShouldBeAFile(fileSystem).WithInfo(testValue, testValue, testValue);
156-
157-
// Ignore the archive bit as it can be re-added to the file as part of its expansion to full
158-
FileAttributes attributes = info.Attributes & ~FileAttributes.Archive;
159-
188+
// Set all properties while file is still a placeholder
189+
FileInfo fi = new FileInfo(virtualFile);
190+
fi.CreationTime = testValue;
191+
fi.LastAccessTime = testValue;
192+
fi.LastWriteTime = testValue;
193+
fi.Attributes = FileAttributes.Hidden;
194+
195+
// Verify file is still a placeholder (no property setter triggers hydration on .NET 10)
196+
fi.Refresh();
197+
((int)fi.Attributes & FileAttributeRecallOnDataAccess).ShouldNotEqual(
198+
0,
199+
"File should still be a placeholder after setting metadata properties");
200+
201+
// Verify the properties took effect on the placeholder
202+
fi.CreationTime.ShouldEqual(testValue, "CreationTime should be set on placeholder");
203+
fi.LastAccessTime.ShouldEqual(testValue, "LastAccessTime should be set on placeholder");
204+
fi.LastWriteTime.ShouldEqual(testValue, "LastWriteTime should be set on placeholder");
205+
FileAttributes placeholderAttrs = fi.Attributes & ~FileAttributes.Archive & (FileAttributes)~(FileAttributeSparseFile | FileAttributeReparsePoint | FileAttributeRecallOnDataAccess);
206+
placeholderAttrs.ShouldEqual(FileAttributes.Hidden, $"Hidden should be set on placeholder, got: {placeholderAttrs}");
207+
208+
// Hydrate and wait for ProjFS to finish clearing placeholder flags
209+
HydrateFile(virtualFile);
210+
211+
// Verify metadata survived hydration.
212+
// CreationTime should survive — it's not affected by read or write operations.
213+
fi.Refresh();
214+
fi.CreationTime.ShouldEqual(testValue, "CreationTime should survive hydration");
215+
216+
// LastAccessTime and LastWriteTime are inherently updated by the read+write
217+
// hydration step, so we cannot assert the pre-hydration values survived.
218+
219+
// Hidden attribute should survive hydration (with async ProjFS flag cleanup)
160220
int retryCount = 0;
161-
int maxRetries = 10;
162-
while (attributes != FileAttributes.Hidden && retryCount < maxRetries)
221+
FileAttributes attributes = fi.Attributes & ~FileAttributes.Archive;
222+
while (attributes != FileAttributes.Hidden && retryCount < 10)
163223
{
164-
// ProjFS attributes are remoted asynchronously when files are converted to full
165-
FileAttributes attributesLessProjFS = attributes & (FileAttributes)~(FileAttributeSparseFile | FileAttributeReparsePoint | FileAttributeRecallOnDataAccess);
166-
167-
attributesLessProjFS.ShouldEqual(
224+
FileAttributes withoutProjFS = attributes & (FileAttributes)~(FileAttributeSparseFile | FileAttributeReparsePoint | FileAttributeRecallOnDataAccess);
225+
withoutProjFS.ShouldEqual(
168226
FileAttributes.Hidden,
169-
$"Attributes (ignoring ProjFS attributes) do not match, expected: {FileAttributes.Hidden} actual: {attributesLessProjFS}");
170-
227+
$"Attributes (ignoring ProjFS) should be Hidden, got: {withoutProjFS}");
171228
++retryCount;
172229
Thread.Sleep(500);
173-
174-
info.Refresh();
175-
attributes = info.Attributes & ~FileAttributes.Archive;
230+
fi.Refresh();
231+
attributes = fi.Attributes & ~FileAttributes.Archive;
176232
}
177233

178-
attributes.ShouldEqual(FileAttributes.Hidden, $"Attributes do not match, expected: {FileAttributes.Hidden} actual: {attributes}");
234+
attributes.ShouldEqual(FileAttributes.Hidden, $"Hidden should survive hydration, got: {attributes}");
235+
}
236+
237+
[TestCase]
238+
public void HydratedFileTimestampsAndAttributesAreUpdated()
239+
{
240+
// Verify that all timestamps and attributes can be set on an already-hydrated
241+
// (dirty full) file in a GVFS enlistment.
242+
FileSystemRunner fileSystem = FileSystemRunner.DefaultRunner;
243+
244+
string filename = Path.Combine("GVFS", "GVFS.Common", "GVFSConstants.cs");
245+
string virtualFile = this.Enlistment.GetVirtualPathTo(filename);
246+
DateTime testValue = DateTime.Now + TimeSpan.FromDays(1);
247+
248+
// Hydrate and wait for ProjFS to finish clearing placeholder flags
249+
HydrateFile(virtualFile);
250+
251+
// Set all properties on the now-hydrated file
252+
FileInfo fi = new FileInfo(virtualFile);
253+
fi.CreationTime = testValue;
254+
fi.LastAccessTime = testValue;
255+
fi.LastWriteTime = testValue;
256+
fi.Attributes = FileAttributes.Hidden;
257+
258+
// Verify all properties stuck
259+
FileInfo verify = virtualFile.ShouldBeAFile(fileSystem).WithInfo(testValue, testValue, testValue);
260+
FileAttributes attributes = verify.Attributes & ~FileAttributes.Archive;
261+
attributes.ShouldEqual(FileAttributes.Hidden, $"Attributes should be Hidden, got: {attributes}");
179262
}
180263

181264
[TestCase]

GVFS/GVFS.FunctionalTests/Tests/GitCommands/GitCommandsTests.cs

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -999,18 +999,22 @@ public void EditFileNeedingUtf8Encoding()
999999
[TestCase]
10001000
public void ChangeTimestampAndDiff()
10011001
{
1002-
// User scenario -
1003-
// 1. Enlistment's "diff.autoRefreshIndex" config is set to false
1004-
// 2. A checked out file got into a state where it differs from the git copy
1005-
// only in its LastWriteTime metadata (no change in file contents.)
1006-
// Repro steps - This happens when user edits a file, saves it and later decides
1007-
// to undo the edit and save the file again.
1008-
// Once in this state, the unchanged file (only its timestamp has changed) shows
1009-
// up in `git difftool` creating noise. It also shows up in `git diff --raw` command,
1010-
// (but not in `git status` or `git diff`.)
1011-
1012-
// Change the timestamp - The lastwrite time can be close to the time this test method gets
1013-
// run. Changing (Subtracting) it to the past so there will always be a difference.
1002+
// User scenario: a checked-out file gets into a state where it differs
1003+
// from the git copy only in its LastWriteTime (no content change).
1004+
// This happens when a user edits a file, saves, undoes the edit, and saves again.
1005+
// The unchanged file then shows up in `git diff --raw` and `git difftool`.
1006+
1007+
// Simulate the user editing and undoing: read the file, write it back unchanged.
1008+
// This hydrates the ProjFS placeholder into a full file, which is the normal
1009+
// state a user would be in before the timestamp-only scenario occurs.
1010+
// (.NET 10's File.SetLastWriteTime no longer triggers ProjFS hydration
1011+
// the way .NET Framework 4.7.1 did, so we must hydrate explicitly.)
1012+
string virtualFile = Path.Combine(this.Enlistment.RepoRoot, GitCommandsTests.EditFilePath);
1013+
string controlFile = Path.Combine(this.ControlGitRepo.RootPath, GitCommandsTests.EditFilePath);
1014+
string originalContent = File.ReadAllText(virtualFile);
1015+
File.WriteAllText(virtualFile, originalContent);
1016+
File.WriteAllText(controlFile, File.ReadAllText(controlFile));
1017+
10141018
this.AdjustLastWriteTime(GitCommandsTests.EditFilePath, TimeSpan.FromDays(-10));
10151019
this.ValidateGitCommand("diff --raw");
10161020
this.ValidateGitCommand($"checkout {GitCommandsTests.EditFilePath}");

GVFS/GVFS.FunctionalTests/Tools/GVFSFunctionalTestEnlistment.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,11 @@ public void DeleteEnlistment()
175175

176176
public void CloneAndMount(bool skipPrefetch)
177177
{
178+
Console.Error.WriteLine("[CI-DEBUG] CloneAndMount: starting clone of " + this.RepoUrl);
179+
Console.Error.Flush();
178180
this.gvfsProcess.Clone(this.RepoUrl, this.Commitish, skipPrefetch);
181+
Console.Error.WriteLine("[CI-DEBUG] CloneAndMount: clone complete, running git checkout");
182+
Console.Error.Flush();
179183

180184
GitProcess.Invoke(this.RepoRoot, "checkout " + this.Commitish);
181185
GitProcess.Invoke(this.RepoRoot, "branch --unset-upstream");

GVFS/GVFS.FunctionalTests/Tools/GVFSProcess.cs

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ private string CallGVFS(string args, int expectedExitCode = DoNotCheckExitCode,
254254
processInfo.WindowStyle = ProcessWindowStyle.Hidden;
255255
processInfo.UseShellExecute = false;
256256
processInfo.RedirectStandardOutput = true;
257+
processInfo.RedirectStandardError = true;
257258
if (standardInput != null)
258259
{
259260
processInfo.RedirectStandardInput = true;
@@ -264,6 +265,9 @@ private string CallGVFS(string args, int expectedExitCode = DoNotCheckExitCode,
264265
processInfo.EnvironmentVariables["GIT_TRACE"] = trace;
265266
}
266267

268+
Console.Error.WriteLine($"[CI-DEBUG] CallGVFS: {this.pathToGVFS} {processInfo.Arguments}");
269+
Console.Error.Flush();
270+
267271
using (Process process = Process.Start(processInfo))
268272
{
269273
if (standardInput != null)
@@ -272,8 +276,43 @@ private string CallGVFS(string args, int expectedExitCode = DoNotCheckExitCode,
272276
process.StandardInput.Close();
273277
}
274278

275-
string result = process.StandardOutput.ReadToEnd();
276-
process.WaitForExit();
279+
// Stream stderr to console in real-time
280+
process.ErrorDataReceived += (sender, e) =>
281+
{
282+
if (e.Data != null)
283+
{
284+
Console.Error.WriteLine($"[gvfs stderr] {e.Data}");
285+
Console.Error.Flush();
286+
}
287+
};
288+
process.BeginErrorReadLine();
289+
290+
// Stream stdout to console and capture it
291+
System.Text.StringBuilder outputBuilder = new System.Text.StringBuilder();
292+
process.OutputDataReceived += (sender, e) =>
293+
{
294+
if (e.Data != null)
295+
{
296+
outputBuilder.AppendLine(e.Data);
297+
Console.Error.WriteLine($"[gvfs stdout] {e.Data}");
298+
Console.Error.Flush();
299+
}
300+
};
301+
process.BeginOutputReadLine();
302+
303+
bool exited = process.WaitForExit(300000); // 5 minute timeout
304+
if (!exited)
305+
{
306+
Console.Error.WriteLine("[CI-DEBUG] CallGVFS: TIMEOUT after 5 minutes, killing process");
307+
Console.Error.Flush();
308+
process.Kill();
309+
process.WaitForExit(5000);
310+
throw new TimeoutException($"gvfs process timed out after 5 minutes. Args: {args}");
311+
}
312+
313+
string result = outputBuilder.ToString();
314+
Console.Error.WriteLine($"[CI-DEBUG] CallGVFS done: exit={process.ExitCode}");
315+
Console.Error.Flush();
277316

278317
if (expectedExitCode >= SuccessExitCode)
279318
{

0 commit comments

Comments
 (0)