Harden GVFS.Mount against native ProjFS failures under memory pressure (0xC000CE01)#2042
Open
tyrielv wants to merge 1 commit into
Open
Harden GVFS.Mount against native ProjFS failures under memory pressure (0xC000CE01)#2042tyrielv wants to merge 1 commit into
tyrielv wants to merge 1 commit into
Conversation
GVFS.Mount crashes are being reported that manifest downstream as STATUS_FILE_SYSTEM_VIRTUALIZATION_UNAVAILABLE (0xC000CE01): once the mount process dies, the ProjFS virtualization root is orphaned and every subsequent placeholder access fails. Watson attributes the crashes to radar_high_memory projectedfslib.dll!prjcompletecommand, with the dominant managed signatures being NullReferenceException surfaced from the native ProjFS command-completion (PrjCompleteCommand) and delete (PrjDeleteFile) paths under memory pressure. VFS for Git is already on the latest Microsoft.Windows.ProjFS package, so the native projectedfslib.dll fault cannot be fixed here directly; the only managed-side levers are (A) reducing the memory pressure that triggers the native fault, and (B) not converting a single failed ProjFS call into a whole-mount crash. This is not a v2.0 regression: the unguarded call sites and the activeEnumerations leak both date to the 2018 .NET Framework era. The v2.0 pure-C# ProjFS rewrite made the same latent native fault legible as a managed NRE in GVFS telemetry, which is why reports increased recently. A. Bound the activeEnumerations leak. ProjFS does not always deliver EndDirectoryEnumeration for a StartDirectoryEnumeration, which leaks the corresponding ActiveEnumeration - and the projected item list it pins - in this.activeEnumerations. ActiveEnumeration now tracks a monotonic LastActivityTickCount (Environment.TickCount64), set on creation and on every GetDirectoryEnumeration read. Once activeEnumerations grows past a threshold far larger than any realistic count of concurrent live enumerations, a throttled sweep evicts entries idle for longer than a timeout. Evicting a live-but-idle enumeration only fails that one directory listing with InternalError - never a crash - and eviction is reported via telemetry. Monotonic clocks are used for both the throttle and the staleness check so wall-clock adjustments cannot disturb them. B. Do not crash the mount on a native ProjFS failure. A single boundary helper, TryInvokeProjFS, wraps each GVFS-initiated native ProjFS call: it catches the exception, emits high-signal *_NativeFailure telemetry (including live enumeration/command counts and GC memory), and returns HResult.InternalError so the caller maps it to a failure result and keeps serving the mount. Routed through it: CompleteCommand (via TryCompleteCommand, covering the completion leg of all async callbacks), DeleteFile, WritePlaceholderFile, WritePlaceholderDirectory, UpdatePlaceholderIfNeeded, and ClearNegativePathCache. MarkDirectoryAsPlaceholder mutates projection state, so swallowing is unsafe; it uses a sibling helper InvokeProjFSOrThrow that emits the same telemetry and then rethrows, preserving fail-fast. GetFileStreamHandlerAsyncHandler already fails a single hydration on any exception, so it is left as-is. Tests: DeleteFile and the other outbound operations return IOError when the virtualization instance throws; ActiveEnumeration records activity time. Full unit suite passes. Assisted-by: Claude Opus 4.8 Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
5b892a4 to
59ad816
Compare
ShiningMassXAcc
approved these changes
Jul 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
GVFS.Mountcrashes are being reported that surface downstream asGetOfficialBranch.exe(and other tools) exiting with-1073689087=0xC000CE01=STATUS_FILE_SYSTEM_VIRTUALIZATION_UNAVAILABLE. The chain is: GVFS.Mount crashes → the ProjFS virtualization root is orphaned → every subsequent placeholder access on that enlistment returns0xC000CE01until remount.Watson attributes the crashes to
radar_high_memory projectedfslib.dll!prjcompletecommand, alongsideSystem.OutOfMemoryExceptionbuckets and GC access violations. The dominant managed signatures areNullReferenceExceptionsurfaced from the native ProjFS command-completion (PrjCompleteCommand, viaStartDirectoryEnumerationAsyncHandler) and delete (PrjDeleteFile) paths — the native code faults under memory exhaustion and the CLR raises a (catchable)NullReferenceExceptionon the worker thread, which today reachesExecuteFileOrNetworkRequestand callsEnvironment.Exit.VFS for Git is already on the latest
Microsoft.Windows.ProjFS(2.1.0), so the nativeprojectedfslib.dllfault cannot be fixed from here. The only managed-side levers are (A) reduce the memory pressure that triggers the native fault, and (B) stop converting a single failed native ProjFS call into a whole-mount crash. This PR does both. It does not claim to eliminate the native OOM fault itself.A — Bound the
activeEnumerationsleakProjFS does not always deliver
EndDirectoryEnumerationfor aStartDirectoryEnumeration(the long-standing// TODO: Need ProjFS 13150199note). The correspondingActiveEnumeration— and theList<ProjectedFileInfo>it pins — then leaks for the life of the mount.ActiveEnumerationtracks a monotonicLastActivityTickCount(Environment.TickCount64), set on creation and everyGetDirectoryEnumerationread.activeEnumerationsgrows past a threshold far larger than any realistic count of concurrent live enumerations (1024), a throttled sweep (≤ once/min) evicts entries idle longer than a timeout (5 min).InternalError— never a crash — and every eviction is reported via telemetry.B — Don’t crash the mount on a native ProjFS failure (generalized)
A single boundary helper,
TryInvokeProjFS(Func<HResult>, operationName, …), wraps each GVFS-initiated native ProjFS call: it catches the exception, emits high-signal telemetry (*_NativeFailure, withactiveEnumerations/activeCommandscounts andGC.GetTotalMemory), and returnsHResult.InternalErrorso the caller maps it to a failure result and keeps serving.Routed through the swallow-and-continue helper:
CompleteCommand(viaTryCompleteCommand— covers the completion leg of all async callbacks: enumeration, placeholder-info, file-data),DeleteFile,WritePlaceholderFile,WritePlaceholderDirectory,UpdatePlaceholderIfNeeded,ClearNegativePathCache.MarkDirectoryAsPlaceholdermutates projection state, so swallowing is unsafe. It uses a sibling helperInvokeProjFSOrThrowthat emits the same*_NativeFailuretelemetry (so the memory diagnostics are captured) and then rethrows — the exception still reachesLogUnhandledExceptionAndExit, preserving fail-fast. Both helpers share oneLogProjFSNativeFailure.Left as-is (already non-fatal):
GetFileStreamHandlerAsyncHandleralready fails a single hydration on any exception (completes the command withFileNotAvailable, no exit), soCreateWriteBuffer/WriteFileDataare already non-fatal.Local repro attempt (empirical) — informs A and B
I built a B-only variant (guards + telemetry, no A eviction), installed it, cloned a large enlistment, and hammered it with cancelled directory enumerations (
FindFirstFileExW+CancelSynchronousIomid-flight) to try to reproduce an enumeration leak → OOM → native crash.Controlled A/B, each touching 15k cold directories exactly once:
The cancel run grew less than the no-cancel control. A sustained 180 s / 869k-enumeration / 110k-abort run added only ~37 MB of slow creep dominated by legitimate cold→warm projection cache, stayed flat on idle, and produced zero
*_NativeFailure/ OOM / crash. Telemetry showed 883 async-path cancellations, all withactiveEnumerationsUpdated:true— i.e. the existing async-cancel cleanup (StartDirectoryEnumerationAsyncHandler→TryCompleteCommandreturns false →TryRemove) already removes every cancelled enumeration.Takeaways:
EndDirectoryEnumeration-never-arrives case, but this repro shows it is not the driver of the observed OOM.Start-without-End, abnormal process termination, or genuine kernel-memory exhaustion unrelated to this leak). That is exactly what B’s*_NativeFailure+activeEnumerations/memory telemetry is there to identify once rolled out.Tests
DeleteFileReturnsIOErrorWhenVirtualizationInstanceThrows,OutboundProjFSOperationsReturnFailureWhenVirtualizationInstanceThrows(ClearNegativePathCache, UpdatePlaceholderIfNeeded),WritePlaceholderOperationsReturnFailureWhenVirtualizationInstanceThrows(tester-based) — a throwing native call yieldsIOError, not a propagated exception.RecordActivityUpdatesLastActivityTime— covers the A monotonic-timestamp building block.InvokeProjFSOrThrow’s rethrow path ends inEnvironment.Exit, so it isn’t safely unit-testable in-process; it’s symmetric with the testedTryInvokeProjFS.)Open questions for review
Two design questions remain genuinely open; I'd appreciate reviewer input on both.
Eviction heuristic (Part A). The eviction trigger is a pair of conservative
constants: a size threshold (
activeEnumerations.Count > 1024) and astaleness timeout (no activity for 5 min), swept at most once per minute. These
are deliberately loose guesses chosen to sit far above any plausible count of
genuinely-concurrent live enumerations, so that normal operation never evicts.
Open points:
hard-coded, so they can be tuned per-fleet without a redeploy?
working set or
GC.GetTotalMemorycrossing a bound) instead of a raw count,since the count is only a proxy for the memory we actually care about?
genuinely-live-but-idle enumeration; that surfaces to the user as a single
failed directory listing (
InternalError), which the caller can retry — nevera crash. I believe that tradeoff is fine given it only triggers well outside
the normal operating envelope, but I'd like a second opinion.
Swallow-and-continue vs. controlled remount (Part B). When a native ProjFS
call faults, B logs
*_NativeFailureand fails just that one operation, keepingthe mount alive. That is strictly better than today's behavior (crash → orphaned
root →
0xC000CE01for everything). But if the native virtualization context isgenuinely dead — not merely a transient allocation failure — then every
subsequent operation will also fault, and the mount is effectively limping while
still reporting itself as up. In that case a deliberate, clean unmount +
remount on repeated
*_NativeFailure(e.g. N failures within a window) mightbe better recovery than indefinitely serving errors. Open points:
escalate to a controlled remount / self-restart of the mount process?
dead"? A simple failure-rate threshold, or something more specific?
via cancelled enumerations (see the repro section — cancellation is
self-healed), so I have no local scenario that reaches the dead-context state
to test remount-vs-limp against. This needs either a heavier memory-pressure
harness or production data.
Summary / conclusion
The core value of this PR is turning an opaque, fatal crash into a survivable,
observable event. Today the native ProjFS fault kills GVFS.Mount and all we get
is a
radar_high_memoryWatson bucket with no GVFS-side context. After thischange the mount stays alive (Part B), the documented enumeration leak is bounded
as cheap insurance (Part A), and — most importantly — every native failure and
every eviction emits high-signal telemetry (
activeEnumerations/activeCommandscounts and
GC.GetTotalMemoryat the exact failure point).That telemetry is precisely what will let us answer both open questions above
with real data instead of guesses. The local repro already narrowed the search:
enumeration cancellation is self-healed and is not the OOM driver, so the real
memory source lies elsewhere (sync-path
Start-without-End, abnormal processtermination, or genuine kernel-memory exhaustion). Once this is rolled out, the
*_NativeFailureevents will show the actualactiveEnumerations/memory state atthe moment of failure, which will tell us (1) whether A's eviction is ever even
triggered in the field and where to set its thresholds, and (2) how often the
native context is transiently vs. permanently dead — i.e. whether escalating to a
controlled remount is warranted. In short: land this to stop the bleeding and
start collecting the data needed to fix the root cause.
Assisted-by: Claude Opus 4.8