ci: run WinFsp's conformance suite against the windows mount (#10555)

* ci: run WinFsp's conformance suite against the windows mount

The FUSE mount is held to pjdfstest with an empty known-failures list;
the Windows mount had 24 hand-written tests. winfsp-tests is what WinFsp
uses to check a filesystem behaves like NTFS, and --fuse-external points
it at ours instead of the bundled memfs, so it is the same bar in the
same shape: anything failing that is not listed is a regression.

It reaches oplocks, security descriptors, POSIX unlink-and-rename and
directory-buffer resumption — the places a Windows filesystem actually
breaks, and none of which the current suite touches.

known_failures.txt starts with the four groups that cannot pass by
construction. The first run will show what else needs listing.

* ci: make the conformance runner fail loudly instead of running empty

The first run reported "0 excluded entries" and then died with
STATUS_DLL_NOT_FOUND, so it never tested anything while looking like a
normal failing run.

winfsp-tests links against winfsp-x64.dll, which the installer puts
somewhere the loader does not search, so the WinFsp bin directory goes on
PATH. A missing or empty known-failures list is now an error rather than
a silent run with nothing excluded, which would read as a clean sweep
with no known failures. ${env:ProgramFiles(x86)} needs the braces, and a
mount point without a trailing separator makes Join-Path build a path
relative to the drive's current directory rather than its root.

* ci: read winfsp-tests failures from its report, and list the real ones

The first run exited zero with 30 of 50 tests reporting KO, and the job
went green: --no-abort keeps the suite going past a failure and the exit
code stops reflecting them, so trusting it meant the check could not fail.
The report is now parsed for KO lines and each one named in the error.

known_failures.txt is populated from that run rather than guessed. The
groups are real gaps, not suite quirks: cached and overlapped IO fails as
a block, delete-while-open has no pending state, Windows file attributes
and creation time are not round-tripped, and directory enumeration does
not resume from a marker.

* ci: stop excluding the extended attribute tests

Forwarding landed, so the group runs instead of being taken on trust —
which is the only coverage it has had.
This commit is contained in:
Chris Lu
2026-08-03 22:26:22 -07:00
committed by GitHub
parent b8cba2982c
commit 89ce6e175d
3 changed files with 301 additions and 0 deletions
@@ -0,0 +1,118 @@
name: "mount: windows conformance"
on:
push:
branches: [ master ]
paths:
- 'weed/mount/**'
- 'weed/command/mount*.go'
- 'test/winfsp-conformance/**'
- '.github/workflows/mount-windows-conformance.yml'
# No base branch filter: this is the only thing that runs the Windows mount,
# so it should cover a pull request stacked on another one too.
pull_request:
paths:
- 'weed/mount/**'
- 'weed/command/mount*.go'
- 'test/winfsp-conformance/**'
- '.github/workflows/mount-windows-conformance.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
conformance:
name: WinFsp conformance
runs-on: windows-latest
timeout-minutes: 60
env:
# The runner ships MinGW, so cgo is on by default and cgofuse picks its
# cgo variant, which wants WinFsp's headers. The nocgo variant loads
# winfsp-x64.dll at run time instead, which is how weed.exe is released.
CGO_ENABLED: 0
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-go@v7
with:
go-version-file: 'go.mod'
# cgofuse loads winfsp-x64.dll at run time, so WinFsp is needed here but
# not to build.
- name: Install WinFsp
run: choco install winfsp -y --no-progress
- name: Build weed.exe
run: go build -o weed.exe ./weed
# The runner tears down a step's process tree when its shell exits, so a
# cluster started in one step is gone by the next. Everything that needs
# the cluster and the mount alive has to share a step.
- name: Mount and run winfsp-tests
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
function Test-Port($port) {
# A plain connect, because Test-NetConnection has reported success
# here for a port nothing was listening on.
$client = New-Object System.Net.Sockets.TcpClient
try { $client.Connect('127.0.0.1', $port); return $client.Connected }
catch { return $false }
finally { $client.Dispose() }
}
function Start-Mount($log) {
Start-Process -FilePath .\weed.exe `
-ArgumentList '-logtostderr','mount','-filer=127.0.0.1:8888','-dir=S:' `
-RedirectStandardOutput "C:\$log.log" -RedirectStandardError "C:\$log.err.log"
$deadline = (Get-Date).AddMinutes(2)
while ((Get-Date) -lt $deadline) {
if (Test-Path S:\) { Write-Host "S: is mounted"; return }
Start-Sleep -Seconds 2
}
Get-Content "C:\$log.log", "C:\$log.err.log" -ErrorAction SilentlyContinue
throw "S: never appeared"
}
New-Item -ItemType Directory -Force -Path C:\seaweed-data | Out-Null
# -ip pins the cluster to loopback; it otherwise advertises and binds
# the runner's LAN address, which 127.0.0.1 cannot reach.
Start-Process -FilePath .\weed.exe `
-ArgumentList '-logtostderr','mini','-dir=C:\seaweed-data','-ip=127.0.0.1' `
-RedirectStandardOutput C:\seaweed-mini.log -RedirectStandardError C:\seaweed-mini.err.log
$deadline = (Get-Date).AddMinutes(3)
while ((Get-Date) -lt $deadline) {
# The mount dials grpc, not http, so both ports have to answer.
if ((Test-Port 8888) -and (Test-Port 18888)) { break }
Start-Sleep -Seconds 3
}
if (-not ((Test-Port 8888) -and (Test-Port 18888))) {
Get-Content C:\seaweed-mini.log, C:\seaweed-mini.err.log -ErrorAction SilentlyContinue
throw "filer never came up"
}
Write-Host "filer is up on http 8888 and grpc 18888"
Start-Mount 'seaweed-mount'
Write-Host "::group::winfsp-tests"
& pwsh -File test/winfsp-conformance/run.ps1 -MountPoint S:\
$code = $LASTEXITCODE
Write-Host "::endgroup::"
if ($code -ne 0) { throw "winfsp-tests failed with exit $code" }
- name: Logs
if: always()
shell: pwsh
run: |
foreach ($f in 'C:\seaweed-mount.log','C:\seaweed-mount.err.log','C:\seaweed-mini.log','C:\seaweed-mini.err.log') {
if (Test-Path $f) { Write-Host "===== $f"; Get-Content $f -Tail 200 }
}
@@ -0,0 +1,92 @@
# Known winfsp-tests failures for the SeaweedFS Windows mount.
#
# One test name per line, as winfsp-tests reports it. A trailing * matches a
# prefix, so "stream_*" covers a whole group. Listed tests are excluded from
# the run; a failure in anything NOT listed fails CI, which is what catches a
# regression.
#
# Populated from the first real run: 20 of 50 passed. Extended attributes are
# forwarded now, so that group runs rather than being excluded. Every entry below is a
# gap in the mount rather than a quirk of the suite, and the list is meant to
# shrink. Keep a reason on each group — an entry with no reason cannot be told
# apart from one nobody has looked at.
# Cannot pass by construction
# ---------------------------
# Alternate data streams: the filer stores one byte range per entry, and the
# mount exposes no second stream.
stream_*
# Reparse points: Symlink is refused, so there is nothing to resolve.
reparse_*
# Byte-range locks live in the WinFsp driver, so they never reach the mount.
lock_*
# Security descriptors
# --------------------
# The mount carries unix mode bits and no ACL, and Chown accepts and discards,
# so a descriptor written here does not read back.
create_sd_test
getsecurity_test
# Cached and overlapped IO
# ------------------------
# The whole rdwr group fails together, so this is one defect rather than nine:
# the mount does not yet satisfy what Windows expects of cached, write-through
# and overlapped IO. The first thing worth fixing.
rdwr_cached_test
rdwr_cached_append_test
rdwr_cached_overlapped_test
rdwr_noncached_test
rdwr_noncached_overlapped_test
rdwr_writethru_test
rdwr_writethru_append_test
rdwr_writethru_overlapped_test
rdwr_mixed_test
flush_test
# Delete semantics
# ----------------
# WinFsp advertises POSIX unlink, so a file can be deleted while handles are
# still open; the mount does not implement the delete-pending state.
delete_access_test
delete_ex_test
delete_mmap_test
delete_pending_test
# Rename semantics
# ----------------
# Rename over an open file, and rename of a directory holding open handles,
# which WinFsp is stricter about than POSIX.
rename_ex_test
rename_mmap_test
rename_open_test
exec_rename_dir_test
# File information
# ----------------
# Windows attributes (hidden, system, readonly) and creation time are not
# round-tripped: attrToStat leaves Flags unset and reports ctime as birthtime.
create_fileattr_test
create_readonlydir_test
getfileinfo_test
setfileinfo_test
# Directory enumeration
# ---------------------
# Marker-based resumption and the buffer-overflow path, neither of which the
# hand-written suite reaches.
querydir_test
querydir_buffer_overflow_test
querydir_namelen_test
# Name length
# -----------
# checkName caps at 255 bytes while WinFsp allows 255 characters, so a CJK
# name that is legal to Windows is rejected here.
create_namelen_test
# Sharing modes
# -------------
# Windows share-access checking on create, which the mount does not enforce.
create_share_test
create_test
+91
View File
@@ -0,0 +1,91 @@
# Runs WinFsp's own conformance suite against a mounted SeaweedFS drive.
#
# winfsp-tests is what WinFsp uses to check a filesystem behaves like NTFS,
# and --fuse-external points it at somebody else's filesystem instead of the
# bundled memfs. It is the Windows counterpart of the pjdfstest run the FUSE
# mount already goes through, and it is scored the same way: anything failing
# that is not in known_failures.txt is a regression.
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$MountPoint,
[string]$KnownFailures = "$PSScriptRoot\known_failures.txt",
[string]$WinFspTestsVersion = '2.1.25156'
)
$ErrorActionPreference = 'Stop'
# "S:" and "S:\" mean different things to Join-Path: without the separator the
# result is relative to the drive's current directory, not its root.
if ($MountPoint -notmatch '[\\/]$') { $MountPoint = $MountPoint + '\' }
# winfsp-tests links against winfsp-x64.dll, which the MSI puts somewhere the
# loader does not look by default.
foreach ($candidate in @("${env:ProgramFiles(x86)}\WinFsp\bin", "$env:ProgramFiles\WinFsp\bin")) {
if (Test-Path $candidate) {
$env:PATH = "$candidate;$env:PATH"
Write-Host "using WinFsp binaries from $candidate"
}
}
$toolDir = Join-Path $env:TEMP 'winfsp-tests'
$exe = Join-Path $toolDir 'winfsp-tests-x64.exe'
if (-not (Test-Path $exe)) {
# Shipped as its own archive rather than in the MSI.
$url = "https://github.com/winfsp/winfsp/releases/download/v2.1/winfsp-tests-$WinFspTestsVersion.zip"
$zip = Join-Path $env:TEMP 'winfsp-tests.zip'
Write-Host "downloading $url"
Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing
Expand-Archive -LiteralPath $zip -DestinationPath $toolDir -Force
}
if (-not (Test-Path $exe)) {
throw "winfsp-tests-x64.exe not found under $toolDir"
}
# A missing or unreadable list would otherwise run with nothing excluded and
# report that as normal, which reads like a pass with no known failures.
if (-not (Test-Path $KnownFailures)) {
throw "known failures list not found at $KnownFailures"
}
$excluded = @(Get-Content $KnownFailures |
ForEach-Object { $_.Trim() } |
Where-Object { $_ -and -not $_.StartsWith('#') })
Write-Host "read $($excluded.Count) exclusions from $KnownFailures"
if ($excluded.Count -eq 0) {
throw "known failures list at $KnownFailures parsed to nothing"
}
# The suite refuses to run anywhere but a drive, and works in the current
# directory, so it has to be driven from inside the mount.
$workDir = Join-Path $MountPoint 'winfsp-conformance'
New-Item -ItemType Directory -Force -Path $workDir | Out-Null
Push-Location $workDir
try {
# --fuse-external: a third-party FUSE filesystem, not the bundled memfs.
# --resilient: tolerate operations this filesystem does not implement.
# --no-abort: report every failure instead of stopping at the first.
$arguments = @('--fuse-external', '--resilient', '--no-abort')
foreach ($name in $excluded) {
$arguments += "-$name"
}
Write-Host "running winfsp-tests with $($excluded.Count) excluded entries"
# --no-abort keeps going past a failure, and the exit code stops reflecting
# them, so the report itself is what has to be read.
$output = & $exe @arguments 2>&1
$output | ForEach-Object { Write-Host $_ }
$failed = @($output |
ForEach-Object { if ($_ -match '^([a-z_0-9]+)\.+\s+KO') { $Matches[1] } })
$code = if ($failed.Count -gt 0) { 1 } else { $LASTEXITCODE }
} finally {
Pop-Location
Remove-Item $workDir -Recurse -Force -ErrorAction SilentlyContinue
}
if ($failed.Count -gt 0) {
Write-Host "::error::winfsp-tests failures outside known_failures.txt: $($failed -join ', ')"
exit 1
}
if ($code -ne 0) {
Write-Host "::error::winfsp-tests exited $code with no failure reported"
exit $code
}
Write-Host 'winfsp-tests passed'