FileInfoTimeout::Infinite + cache-manager paging-IO retention: delete-class IRPs fail until cache release; consider splitting DirInfoTimeout from FileInfoTimeout
Summary
I'm building a content-addressed dedup filesystem on top of WinFsp. FileInfoTimeout = INFINITE is the only switch I've found that activates the FSD's DirInfoCache, and on my workload that's the difference between 105 s and ~20 s cold project-open (5×). Under INFINITE the NT cache manager attaches and retains paging-IO references on the FileNode that survive my cb_close. The FSD then refuses delete-class IRPs against the same path with STATUS_INVALID_DEVICE_REQUEST (Win32 ERROR_INVALID_FUNCTION / "Incorrect function") until the cache manager releases its reference (~500 ms later). I'd love guidance on how to coordinate with cache-manager retention from user-mode at cleanup time, and to ask whether DirInfoTimeout could become an independent knob from FileInfoTimeout.
Profile evidence
My workload is copying a 3,105-file Unreal Engine project. From a probe-instrumented run:
intra-copy-ue-project: 104.4 s warm cold-open, 30 files/s.cb_read_directory= 89.6% of the per-file budget (30,123 µs of 33,640 µs). OneCopyFileExfans out to ~81cb_read_directoryIRPs because no dir cache is attached under finiteFileInfoTimeout.get_file_info_total = 4over the whole bench matrix confirms the FileInfoCache is also unattached.- Projected under
INFINITE: ~6 ms/file, ~20 s total.
What I tried first (so you know I did the work)
I rebuilt my open/handle model on memfs's shape: one Arc<PathContext> per path, refcounted across opens, stored as the FSD UserContext (UmFileContextIsUserContext2 = 0). All 12 of my callbacks dispatch through that pointer.
Along the way I found several mistakes that were my own, but it's been a slow process. These were the issues I found in my own code:
- Stat-after-cleanup-pre-COMMIT staleness window — added synchronous write-through to a per-path
cached_metamirror at every size-mutation site. cb_flushwas returning a hardcoded zero-sizeFILE_INFO—FlushFileBufferswas reverting FileSize underINFINITE.cb_set_file_sizewas treatingFileAllocationInformationas a truncate — .NET'sFileStream.SetLength()issues aFileEndOfFileInformationfollowed byFileAllocationInformation, and my code was treating both as the same op, so the second IRP undid the first. Persisted the wrong size on disk under any timeout.- A
cb_close-only finalize race — I was releasing the writer lock before the SQL commit landed; a concurrent open within that window returned Busy → user-visible "Incorrect function". - Duplicate
cb_cleanupunderflow — underINFINITE, the cache manager dispatches a secondcb_cleanupon the sameFileContext(flags0xa0=FspCleanupSetAllocationSize | FspCleanupSetArchiveBit) when its paging handle releases. Mywrite_opensdecrement underflowed0 → u64::MAXand leaked the PathContext forever. Fixed by making the cleanup arm idempotent at the call site.
After all five fixes, my code is honoring the contract as I understand it. The remaining failure (below) seems to be in a corner I can't reach from user mode.
The remaining blocker — minimal reproducer
I isolated the failure to a small standalone PowerShell script (no benchmark harness needed):
# Mount my user-space file system at H:\ with FileInfoTimeout = INFINITE.
$dir = "H:\test-a"
$file = Join-Path $dir "seqwrite.bin"
$buf = New-Object byte[] 1MB
(New-Object System.Random 42).NextBytes($buf)
foreach ($run in 1..2) {
if (Test-Path $dir) {
Remove-Item -Recurse -Force $dir # <-- run 2: throws "Incorrect function"
}
New-Item -ItemType Directory -Path $dir | Out-Null
$fs = [System.IO.File]::Create($file, 1MB)
try {
$written = 0L
while ($written -lt 512MB) { $fs.Write($buf, 0, 1MB); $written += 1MB }
$fs.Flush($true)
} finally { $fs.Dispose() }
}Timing matrix (sleep inserted between Run 1 dispose and Run 2 Remove-Item):
| Sleep | Outcome |
|---|---|
| 0 ms | FAIL — Remove-Item throws Incorrect function |
| 100 ms | FAIL |
| 500 ms | PASS |
| 1000 ms | PASS |
The 500 ms cadence aligns with the cache manager's lazy-writer / paging-IO release period. Under Ms(999_999) the same script passes at all sleep values, but then I don't get DirInfoCache.
My cb_cleanup returns STATUS_SUCCESS, my cb_close runs, and the FileContext is released cleanly (verified via probe trace — no underflows or leaks after the 1c.8 fix). User-mode CloseHandle returns to the script. Then Remove-Item calls DeleteFileW, which goes through cb_can_delete and cb_cleanup(FspCleanupDelete). Under INFINITE, that delete-class IRP returns STATUS_INVALID_DEVICE_REQUEST for ~500 ms after the prior close, then succeeds. Under finite timeout, no delay.
I couldn't find a user-mode hook to either (a) wait for the cache manager to release its paging-IO references at cleanup time, or (b) discover whether such references are present so I could surface a retryable error rather than fail. That's where I'm stuck.
Asks
Primary — cache-manager paging-IO coordination at cleanup. Under
FileInfoTimeout = INFINITE, what's the user-mode FS's contract for handling delete-class IRPs that arrive while the cache manager still holds paging-IO references on the FileNode? Is there a user-mode helper I should be calling to drain the cache (aFspFileSystemFlushCache-shaped thing?), or a way to learn that paging-IO references are outstanding so I could return a retryable error instead of failing? I'd assume memfs runs underINFINITEwithout hitting this — is there a contract or pattern I'm missing?Secondary — split
DirInfoTimeoutfromFileInfoTimeout. If the primary can't be addressed in the current ABI, couldDirInfoTimeoutbecome independently configurable? The DirInfoCache is the only lever I need for my cold project-open speedup; the FileInfoCache + cache-manager attach are what introduces the retention timing. I'd happily accept FSD-side dir caching while letting per-file metadata pass through every IRP.
Test environment
- Windows 11 22H2 dev VM (Hyper-V); WinFsp 2.1.25156 (per installed
fsctl.h); hand-rolled Rust FFI (nowinfsp-sys/winfsp-rs— wrote my own bindings). - The filesystem is content-addressed dedup over local NTFS (NT-handle pass-through reads), SQLite adjacency-list index, per-path
Arc<PathContext>shared viaUserContext, all 12 callbacks Arc-shared per path. - Targeting game-dev workstations.
Happy to put together a minimal repro repo, write a memfs patch that adds the INFINITE + back-to-back-write-and-delete test, or both. I can test candidate patches on my VM and report bench numbers from the UE-project workload — just let me know what would be most useful. Thanks for taking a look.
Source: winfsp/winfsp