diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index e7de3e1fb9..439057d70b 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -327,12 +327,59 @@ jobs:
name: GVFS_${{ matrix.configuration }}_${{ matrix.architecture }}
path: artifacts\GVFS.Installers
+ coverage_build:
+ runs-on: windows-2025
+ name: Build AOT Coverage Probe
+ needs: validate
+ timeout-minutes: 30
+
+ env:
+ AotMsCodeCoverageInstrumentation: 'true'
+ MsCodeCoverageSessionId: gvfs-ci-${{ github.run_id }}
+
+ steps:
+ - name: Checkout source
+ if: needs.validate.outputs.skip == ''
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ path: src
+ persist-credentials: false
+
+ - name: Install .NET SDK
+ if: needs.validate.outputs.skip == ''
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
+ with:
+ global-json-file: src/global.json
+ dotnet-version: 8.0.x
+
+ - name: Add MSBuild to PATH
+ if: needs.validate.outputs.skip == ''
+ uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 # v3.0.0
+
+ - name: Build instrumented x64 installer
+ if: needs.validate.outputs.skip == ''
+ shell: cmd
+ run: src\scripts\Build.bat Release 0.2.173.2 minimal x64
+
+ - name: Create build artifacts
+ if: needs.validate.outputs.skip == ''
+ shell: cmd
+ run: src\scripts\CreateBuildArtifacts.bat Release artifacts x64
+
+ - name: Upload coverage-probe installer
+ if: needs.validate.outputs.skip == ''
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: GVFS_Coverage_Release_x64
+ path: artifacts\GVFS.Installers
+
functional_tests:
name: Functional Tests
- needs: [validate, build]
+ needs: [validate, build, coverage_build]
uses: ./.github/workflows/functional-tests.yaml
with:
skip: ${{ needs.validate.outputs.skip }}
+ aot_coverage_probe: true
upgrade_tests:
name: Upgrade Tests
diff --git a/.github/workflows/functional-tests.yaml b/.github/workflows/functional-tests.yaml
index 5b05a0f1a0..85a5ed2858 100644
--- a/.github/workflows/functional-tests.yaml
+++ b/.github/workflows/functional-tests.yaml
@@ -38,6 +38,11 @@ on:
required: false
type: string
default: ''
+ aot_coverage_probe:
+ description: 'Use the instrumented installer and collect coverage on x64 shard 0'
+ required: false
+ type: boolean
+ default: false
secrets:
vfs_token:
description: 'Token for downloading VFSForGit artifacts (required for cross-repository downloads; defaults to GITHUB_TOKEN)'
@@ -58,6 +63,8 @@ jobs:
env:
ARTIFACT_PREFIX: ${{ inputs.output_prefix && format('{0}_', inputs.output_prefix) || '' }}
FT_MATRIX_NAME: ${{ format('{0}_{1}-{2}', matrix.configuration, matrix.architecture, matrix.nr) }}
+ AOT_COVERAGE_PROBE: ${{ inputs.aot_coverage_probe && matrix.architecture == 'x64' && matrix.nr == 0 }}
+ COVERAGE_SESSION_ID: gvfs-ci-${{ inputs.vfs_run_id || github.run_id }}
strategy:
matrix:
@@ -104,7 +111,7 @@ jobs:
continue-on-error: true
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
- name: GVFS_${{ matrix.configuration }}_${{ matrix.architecture }}
+ name: ${{ env.AOT_COVERAGE_PROBE == 'true' && 'GVFS_Coverage_Release_x64' || format('GVFS_{0}_{1}', matrix.configuration, matrix.architecture) }}
path: gvfs
repository: ${{ inputs.vfs_repository || github.repository }}
run-id: ${{ inputs.vfs_run_id || github.run_id }}
@@ -114,7 +121,7 @@ jobs:
if: steps.skip.outputs.result != 'true' && steps.download-gvfs.outcome == 'failure'
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
- name: GVFS_${{ matrix.configuration }}_${{ matrix.architecture }}
+ name: ${{ env.AOT_COVERAGE_PROBE == 'true' && 'GVFS_Coverage_Release_x64' || format('GVFS_{0}_{1}', matrix.configuration, matrix.architecture) }}
path: gvfs
repository: ${{ inputs.vfs_repository || github.repository }}
run-id: ${{ inputs.vfs_run_id || github.run_id }}
@@ -153,6 +160,40 @@ jobs:
run-id: ${{ inputs.vfs_run_id || github.run_id }}
github-token: ${{ secrets.vfs_token || github.token }}
+ - name: Checkout coverage configuration and source
+ if: steps.skip.outputs.result != 'true' && env.AOT_COVERAGE_PROBE == 'true'
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ path: src
+ persist-credentials: false
+
+ - name: Install coverage runtime and SDK
+ if: steps.skip.outputs.result != 'true' && env.AOT_COVERAGE_PROBE == 'true'
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
+ with:
+ global-json-file: src/global.json
+ dotnet-version: 8.0.x
+
+ - name: Install coverage tools
+ if: steps.skip.outputs.result != 'true' && env.AOT_COVERAGE_PROBE == 'true'
+ shell: pwsh
+ run: |
+ dotnet tool install dotnet-coverage --version 18.8.0 --tool-path coverage-tools --configfile src\nuget.config
+ if ($LASTEXITCODE -ne 0) { throw 'Coverage collector installation failed.' }
+ dotnet tool install dotnet-reportgenerator-globaltool --version 5.5.11 --tool-path coverage-tools --configfile src\nuget.config
+ if ($LASTEXITCODE -ne 0) { throw 'Coverage renderer installation failed.' }
+ New-Item -ItemType Directory -Path coverage -Force | Out-Null
+
+ - name: Start fixed-session coverage before service installation
+ id: coverage_start
+ if: steps.skip.outputs.result != 'true' && env.AOT_COVERAGE_PROBE == 'true'
+ shell: pwsh
+ run: |
+ .\coverage-tools\dotnet-coverage.exe collect --server-mode --background --session-id $env:COVERAGE_SESSION_ID --settings src\scripts\AotCoverage.config --output coverage\functional.cobertura.xml --output-format cobertura --log-file coverage\collector.log --log-level Verbose
+ if ($LASTEXITCODE -ne 0) { throw 'Coverage collector startup failed.' }
+ .\coverage-tools\dotnet-coverage.exe snapshot $env:COVERAGE_SESSION_ID --output coverage\before-install.coverage
+ if ($LASTEXITCODE -ne 0) { throw 'Coverage collector readiness check failed.' }
+
- name: ProjFS details (pre-install)
if: steps.skip.outputs.result != 'true'
shell: cmd
@@ -197,6 +238,14 @@ jobs:
git\logs
gvfs\logs
+ - name: Record installed service identity
+ if: steps.skip.outputs.result != 'true' && env.AOT_COVERAGE_PROBE == 'true'
+ shell: pwsh
+ run: |
+ Get-CimInstance Win32_Service -Filter "Name = 'GVFS.Service'" |
+ Select-Object Name,StartName,State,ProcessId,PathName |
+ ConvertTo-Json | Set-Content coverage\installed-service.json
+
- name: Run functional tests
if: steps.skip.outputs.result != 'true'
shell: cmd
@@ -207,6 +256,33 @@ jobs:
SET GVFS_TEST_DIAGNOSTICS_DIR=C:\temp\gvfs-ft-diagnostics
ft\GVFS.FunctionalTests.exe /result:TestResult.xml --ci --slice=${{ matrix.nr }},12
+ - name: Stop coverage and verify SCM service capture
+ id: coverage_stop
+ if: always() && env.AOT_COVERAGE_PROBE == 'true' && steps.coverage_start.outcome == 'success'
+ shell: pwsh
+ run: |
+ .\coverage-tools\dotnet-coverage.exe shutdown $env:COVERAGE_SESSION_ID
+ if ($LASTEXITCODE -ne 0) { throw 'Coverage collector shutdown failed.' }
+ src\scripts\Assert-AotCoverage.ps1 -CoveragePath coverage\functional.cobertura.xml |
+ Tee-Object -FilePath coverage\probe.json
+
+ - name: Render single-shard coverage report
+ if: always() && env.AOT_COVERAGE_PROBE == 'true' && steps.coverage_stop.outcome == 'success'
+ shell: pwsh
+ run: |
+ .\coverage-tools\reportgenerator.exe -reports:coverage\functional.cobertura.xml -targetdir:coverage\report "-reporttypes:Html;MarkdownSummaryGithub;Cobertura;JsonSummary" "-filefilters:+$env:GITHUB_WORKSPACE\src\GVFS\*" "-title:Managed NativeAOT product - x64 shard 0 of 12"
+ if ($LASTEXITCODE -ne 0) { throw 'Coverage rendering failed.' }
+ 'One-shard probe only. Native C++ and the other shards are not measured.' >> $env:GITHUB_STEP_SUMMARY
+ Get-Content coverage\report\SummaryGithub.md >> $env:GITHUB_STEP_SUMMARY
+
+ - name: Upload coverage-probe evidence
+ if: always() && steps.skip.outputs.result != 'true' && env.AOT_COVERAGE_PROBE == 'true'
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: ${{ env.ARTIFACT_PREFIX }}AotCoverage_${{ env.FT_MATRIX_NAME }}
+ path: coverage
+ if-no-files-found: error
+
- name: Upload failure diagnostics (mount dumps + logs)
if: always() && steps.skip.outputs.result != 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
diff --git a/Directory.Build.targets b/Directory.Build.targets
index 59c793d2d4..7aa29b1094 100644
--- a/Directory.Build.targets
+++ b/Directory.Build.targets
@@ -12,6 +12,15 @@
+
+
+ true
+
+
+
+
+
+
+
diff --git a/scripts/AotCoverage.config b/scripts/AotCoverage.config
new file mode 100644
index 0000000000..7b3c03e238
--- /dev/null
+++ b/scripts/AotCoverage.config
@@ -0,0 +1,11 @@
+
+
+ False
+
+ False
+ False
+ False
+ False
+ True
+
+
diff --git a/scripts/Assert-AotCoverage.Tests.ps1 b/scripts/Assert-AotCoverage.Tests.ps1
new file mode 100644
index 0000000000..537fb088d7
--- /dev/null
+++ b/scripts/Assert-AotCoverage.Tests.ps1
@@ -0,0 +1,78 @@
+BeforeAll {
+ $assertion = Join-Path $PSScriptRoot 'Assert-AotCoverage.ps1'
+
+ function New-CoverageReport {
+ param(
+ [int]$OnStartHits = 1,
+ [int]$ConsoleHits = 0
+ )
+
+ $modules = @('GVFS', 'GVFS.Hooks', 'GVFS.Mount', 'GVFS.Service', 'GVFS.Common', 'GVFS.Platform.Windows', 'GVFS.Virtualization')
+ $packages = ($modules | ForEach-Object {
+ ""
+ }) -join ''
+ [xml]$document = "$packages"
+ $service = $document.SelectSingleNode("//package[@name='GVFS.Service']/classes")
+ $service.InnerXml = @"
+
+
+
+
+
+
+
+
+"@
+ return ,$document
+ }
+
+ function Save-CoverageReport {
+ param([xml]$Document)
+ $path = Join-Path $TestDrive 'coverage.xml'
+ $Document.Save($path)
+ return $path
+ }
+}
+
+Describe 'NativeAOT SCM coverage evidence' {
+ It 'Accepts product coverage with SCM startup and no console-service entry' {
+ $path = Save-CoverageReport (New-CoverageReport)
+ $result = & $assertion -CoveragePath $path | ConvertFrom-Json
+ $result.ScmServiceStartupCovered | Should -BeTrue
+ $result.CompleteSuiteCoverage | Should -BeFalse
+ }
+
+ It 'Rejects a report without SCM startup execution' {
+ $path = Save-CoverageReport (New-CoverageReport -OnStartHits 0)
+ { & $assertion -CoveragePath $path } | Should -Throw '*no executed GVFSService.OnStart*'
+ }
+
+ It 'Rejects console-service execution even when SCM startup also executes' {
+ $path = Save-CoverageReport (New-CoverageReport -ConsoleHits 1)
+ { & $assertion -CoveragePath $path } | Should -Throw '*Console-service execution contaminates*'
+ }
+
+ It 'Rejects missing product modules' {
+ $document = New-CoverageReport
+ $node = $document.SelectSingleNode("//package[@name='GVFS.Mount']")
+ [void]$node.ParentNode.RemoveChild($node)
+ $path = Save-CoverageReport $document
+ { & $assertion -CoveragePath $path } | Should -Throw '*Expected one coverage module for GVFS.Mount*'
+ }
+
+ It 'Rejects product modules with metadata but no executed lines' {
+ $document = New-CoverageReport
+ $document.SelectSingleNode("//package[@name='GVFS.Hooks']//line").SetAttribute('hits', '0')
+ $path = Save-CoverageReport $document
+ { & $assertion -CoveragePath $path } | Should -Throw '*No executed lines in required product module GVFS.Hooks*'
+ }
+
+ It 'Rejects unit-copy coverage in a product report' {
+ $document = New-CoverageReport
+ $node = $document.CreateElement('package')
+ $node.SetAttribute('name', 'GVFS.UnitTests')
+ [void]$document.coverage.packages.AppendChild($node)
+ $path = Save-CoverageReport $document
+ { & $assertion -CoveragePath $path } | Should -Throw '*Test assemblies contaminate*'
+ }
+}
diff --git a/scripts/Assert-AotCoverage.ps1 b/scripts/Assert-AotCoverage.ps1
new file mode 100644
index 0000000000..86ef5a1d56
--- /dev/null
+++ b/scripts/Assert-AotCoverage.ps1
@@ -0,0 +1,61 @@
+param(
+ [Parameter(Mandatory)]
+ [string]$CoveragePath
+)
+
+$ErrorActionPreference = 'Stop'
+
+[xml]$coverage = Get-Content -LiteralPath $CoveragePath -Raw
+$packages = @($coverage.coverage.packages.package)
+$requiredModules = @(
+ 'GVFS',
+ 'GVFS.Hooks',
+ 'GVFS.Mount',
+ 'GVFS.Service',
+ 'GVFS.Common',
+ 'GVFS.Platform.Windows',
+ 'GVFS.Virtualization'
+)
+
+foreach ($name in $requiredModules) {
+ $module = @($packages | Where-Object { $_.name -eq $name })
+ if ($module.Count -ne 1) {
+ throw "Expected one coverage module for $name; found $($module.Count)."
+ }
+
+ $hitLines = @($module[0].classes.class.lines.line | Where-Object { [int]$_.hits -gt 0 })
+ if ($hitLines.Count -eq 0) {
+ throw "No executed lines in required product module $name."
+ }
+}
+
+$testModules = @($packages | Where-Object { $_.name -match '(UnitTests|FunctionalTests|^GVFS.Tests$)' })
+if ($testModules.Count -ne 0) {
+ throw "Test assemblies contaminate the product report: $($testModules.name -join ', ')."
+}
+
+$service = $packages | Where-Object { $_.name -eq 'GVFS.Service' }
+$serviceClass = $service.classes.class | Where-Object { $_.name -eq 'GVFS.Service.GVFSService' }
+$onStart = @($serviceClass.methods.method | Where-Object { $_.name -eq 'OnStart' })
+$startupHits = @($onStart.lines.line | Where-Object { [int]$_.hits -gt 0 })
+if ($startupHits.Count -eq 0) {
+ throw 'The report contains no executed GVFSService.OnStart lines.'
+}
+
+$program = $service.classes.class | Where-Object { $_.name -eq 'GVFS.Service.Program' }
+$consoleEntry = @($program.methods.method | Where-Object { $_.name -eq 'RunAsConsoleApp' })
+$consoleHits = @($consoleEntry.lines.line | Where-Object { [int]$_.hits -gt 0 })
+if ($consoleHits.Count -ne 0) {
+ throw 'Console-service execution contaminates the SCM-service probe.'
+}
+
+[ordered]@{
+ Scope = 'Managed NativeAOT product; x64 shard 0 of 12'
+ Modules = $packages.name
+ ScmServiceStartupCovered = $true
+ ConsoleServiceEntryCovered = $false
+ NativeCppCovered = $false
+ CompleteSuiteCoverage = $false
+ CoveredLines = [int]$coverage.coverage.'lines-covered'
+ MeasuredLines = [int]$coverage.coverage.'lines-valid'
+} | ConvertTo-Json -Depth 3