Add support for branch- and branding-based feature flagging (#10361)

This pull request implements a "feature flagging" system that will let
us turn Terminal and conhost features on/off by branch, "release" status
or branding (Dev, Preview, etc.).

It's loosely modelled after the Windows OS concept of "Velocity," but
only insofar as it is driven by an XML document and there's a tool that
emits a header file for you to include.

It only supports toggling features at compile time, and the feature flag
evaluators are intended to be fully constant expressions.

Features are added to `src\features.xml` and marked with a "stage". For
now, the only stages available are `AlwaysDisabled` and `AlwaysEnabled`.
Features can be toggled to different states using branch and branding
tokens, as documented in the included feature flag docs.

For a given feature Feature_XYZ, we will emit two fixtures visible to
the compiler:

1. A preprocessor define `TIL_FEATURE_XYZ_ENABLED` (usable from MIDL,
   C++ and C)
2. A feature class type `Feature_XYZ` with a static constexpr member
   `IsEnabled()` (usable from C++, designed for `if constexpr()`).

Like Velocity, we rely on the compiler to eliminate dead code caused by
things that compile down to `if constexpr (false)`. :)

Michael suggested that we could use `WindowsInbox` as a branding to
determine when we were being built inside Windows to supplant our use of
the `__INSIDE_WINDOWS` preprocessor token. It was brilliant.

Design Decisions
----------------

* Emitting the header as part of an MSBuild project
   * WHY: This allows the MSBuild engine to ensure that the build is
     only run once, even in a parallel build situation.
* Only having one feature flag document for the entire project
   * WHY: Ease.
* Forcibly including `TilFeatureStaging` with `/FI` for all CL compiler
  invocations.
   * WHY: If this is a project-wide feature system, we should make it as
     easy as possible to use.
* Emitting preprocessor definitions instead of constexpr/consteval
   * WHY: Removing entire functions/includes is impossible with `if
     constexpr`.
   * WHY: MIDL cannot use a `static constexpr bool`, but it can rely on
     the C preprocessor to remove text.
* Using MSBuild to emit the text instead of PowerShell
   * WHY: This allows us to leverage MSBuild's `WriteOnlyWhenDifferent`
     task parameter to avoid changing the file's modification time when
     it would have resulted in the same contents. This lets us use the
     same FeatureStaging header across multiple builds and multiple
     branches and brandings _assuming that they do not result in a
     feature flag change_.
   * The risk in using a force-include is always that it, for some
     reason, determines that the entire project is out of date. We've
     gone to great lengths to make sure that it only does so if the
     features _actually materially changed_.
This commit is contained in:
Dustin L. Howett 2021-06-10 18:09:52 -05:00 committed by GitHub
parent 9294ecc8e5
commit 31a39b3b12
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 484 additions and 0 deletions

View File

@ -45,6 +45,7 @@ IBind
IBox
IClass
IComparable
IComparer
IConnection
ICustom
IDialog
@ -85,6 +86,7 @@ NOREPEAT
ntprivapi
oaidl
ocidl
ODR
osver
OSVERSIONINFOEXW
otms

View File

@ -163,6 +163,7 @@ BPBF
bpp
BPPF
branchconfig
brandings
BRK
Browsable
bsearch

View File

@ -0,0 +1,97 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" ToolsVersion="16.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<!-- THIS PROJECT CANNOT BE LOADED INTO THE SOLUTION. -->
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Release|Any CPU">
<Configuration>Release</Configuration>
<Platform>AnyCPU</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Fuzzing|Any CPU">
<Configuration>Fuzzing</Configuration>
<Platform>AnyCPU</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="AuditMode|Any CPU">
<Configuration>AuditMode</Configuration>
<Platform>AnyCPU</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Debug|Any CPU">
<Configuration>Debug</Configuration>
<Platform>AnyCPU</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<ProjectGuid>d97c3c61-53cd-4e72-919b-9a0940e038f9</ProjectGuid>
</PropertyGroup>
<PropertyGroup>
<IntermediateOutputPath>$(SolutionDir)obj\$(Configuration)\GenerateFeatureFlags\</IntermediateOutputPath>
<OpenConsoleCommonOutDir>$(SolutionDir)bin\$(Configuration)\</OpenConsoleCommonOutDir>
<_WTBrandingName Condition="'$(WindowsTerminalBranding)'=='Preview'">Preview</_WTBrandingName>
<_WTBrandingName Condition="'$(WindowsTerminalBranding)'=='Release'">Release</_WTBrandingName>
<_WTBrandingName Condition="'$(_WTBrandingName)'==''">Dev</_WTBrandingName>
</PropertyGroup>
<Target Name="_GenerateBranchAndBrandingCache">
<Exec Command="git.exe rev-parse --abbrev-ref HEAD"
CustomWarningRegularExpression="^fatal:.*"
ConsoleToMsBuild="true"
IgnoreExitCode="true">
<Output TaskParameter="ConsoleOutput" ItemName="_GitBranchLines" />
</Exec>
<ItemGroup>
<_BrandingLines Include="$(_WTBrandingName)" />
</ItemGroup>
<WriteLinesToFile File="$(IntermediateOutputPath)branch_branding_cache.txt"
Lines="@(_GitBranchLines);@(_BrandingLines)"
Overwrite="true"
WriteOnlyWhenDifferent="true" />
<ItemGroup>
<FileWrites Include="$(IntermediateOutputPath)branch_branding_cache.txt" />
<_BranchBrandingCacheFiles Include="$(IntermediateOutputPath)branch_branding_cache.txt" />
</ItemGroup>
</Target>
<Target Name="_RunFeatureFlagScript"
Inputs="@(FeatureFlagFile);@(_BranchBrandingCacheFiles)"
Outputs="$(OpenConsoleCommonOutDir)\inc\TilFeatureStaging.h"
DependsOnTargets="_GenerateBranchAndBrandingCache">
<MakeDir Directories="$(OpenConsoleCommonOutDir)\inc" />
<Exec
Command="powershell -NoProfile -Command &quot;$(SolutionDir)\tools\Generate-FeatureStagingHeader.ps1&quot; -Path &quot;%(FeatureFlagFile.FullPath)&quot; -Branding $(_WTBrandingName)"
ConsoleToMsBuild="true"
StandardOutputImportance="low">
<Output TaskParameter="ConsoleOutput" ItemName="_FeatureFlagFileLines" />
</Exec>
<!--
We gather the feature flag output in MSBuild and emit the file so that we can take advantage of
WriteOnlyWhenDifferent. Doing this ensures that we don't rebuild the world when the branch changes
(if it results in a new TilFeatureStaging.h that would have had the same content/features as the previous one)
-->
<WriteLinesToFile File="$(OpenConsoleCommonOutDir)\inc\TilFeatureStaging.h"
Lines="@(_FeatureFlagFileLines)"
Overwrite="true"
WriteOnlyWhenDifferent="true" />
<ItemGroup>
<FileWrites Include="$(OpenConsoleCommonOutDir)\inc\TilFeatureStaging.h" />
</ItemGroup>
</Target>
<Target Name="Build" DependsOnTargets="_RunFeatureFlagScript" />
<Target Name="Clean">
<Delete Files="$(OpenConsoleCommonOutDir)\inc\TilFeatureStaging.h" />
</Target>
<ItemGroup>
<FeatureFlagFile Include="$(SolutionDir)\src\features.xml" />
</ItemGroup>
</Project>

65
doc/feature_flags.md Normal file
View File

@ -0,0 +1,65 @@
# til::feature
Feature flags are controlled by an XML document stored at `src/features.xml`.
## Example Document
```xml
<?xml version="1.0"?>
<featureStaging xmlns="http://microsoft.com/TilFeatureStaging-Schema.xsd">
<feature>
<!-- This will produce Feature_XYZ::IsEnabled() and TIL_FEATURE_XYZ_ENABLED (preprocessor) -->
<name>Feature_XYZ</name>
<description>Does a cool thing</description>
<!-- GitHub deliverable number; optional -->
<id>1234</id>
<!-- Whether the feature defaults to enabled or disabled -->
<stage>AlwaysEnabled|AlwaysDisabled</stage>
<!-- Branch wildcards where the feature should be *DISABLED* -->
<alwaysDisabledBranchTokens>
<branchToken>branch/with/wildcard/*</branchToken>
<!-- ... more branchTokens ... -->
</alwaysDisabledBranchTokens>
<!-- Just like alwaysDisabledBranchTokens, but for *ENABLING* the feature. -->
<alwaysEnabledBranchTokens>
<branchToken>...</branchToken>
</alwaysEnabledBranchTokens>
<!-- Brandings where the feature should be *DISABLED* -->
<alwaysDisabledBrandingTokens>
<!-- Valid brandings include Dev, Preview, Release, WindowsInbox -->
<brandingToken>Release</brandingToken>
<!-- ... more brandingTokens ... -->
</alwaysDisabledBrandingTokens>
<!-- Just like alwaysDisabledBrandingTokens, but for *ENABLING* the feature -->
<alwaysEnabledBrandingTokens>
<branchToken>...</branchToken>
</alwaysEnabledBrandingTokens>
<!-- Unequivocally disable this feature in Release -->
<alwaysDisabledReleaseTokens />
</feature>
</featureStaging>
```
## Notes
Features that are disabled for Release using `alwaysDisabledReleaseTokens` are
*always* disabled in Release, even if they come from a branch that would have
been enabled by the wildcard.
### Precedence
1. `alwaysDisabledReleaseTokens`
2. Enabled branches
3. Disabled branches
* The longest branch token that matches your branch will win.
3. Enabled brandings
4. Disabled brandings
5. The feature's default state

View File

@ -64,4 +64,21 @@
<Touch Files="@(_PCHFileToCleanWithTimestamp)" Time="%(LastWriteTime)" AlwaysCreate="true" />
<Message Text="PCH and Precomp object @(_PCHFileToCleanWithTimestamp) has been deleted for $(ProjectName)." />
</Target>
<PropertyGroup>
<BuildDependsOn>
OCCallFeatureFlagGenerator;
$(BuildDependsOn)
</BuildDependsOn>
</PropertyGroup>
<Target Name="OCCallFeatureFlagGenerator">
<MSBuild Projects="$(SolutionDir)\build\rules\GenerateFeatureFlags.proj" />
</Target>
<ItemDefinitionGroup>
<ClCompile>
<ForcedIncludeFiles>$(SolutionDir)\bin\$(Configuration)\inc\TilFeatureStaging.h;%(ForcedIncludeFiles)</ForcedIncludeFiles>
</ClCompile>
</ItemDefinitionGroup>
</Project>

13
src/features.xml Normal file
View File

@ -0,0 +1,13 @@
<?xml version="1.0"?>
<featureStaging xmlns="http://microsoft.com/TilFeatureStaging-Schema.xsd">
<!-- See doc/feature_flags.md for more info. -->
<feature>
<name>Feature_ExampleFeat</name>
<description>This feature will be replaced in the next pull request.</description>
<stage>AlwaysEnabled</stage>
<alwaysDisabledBrandingTokens>
<brandingToken>Preview</brandingToken>
<brandingToken>WindowsInbox</brandingToken>
</alwaysDisabledBrandingTokens>
</feature>
</featureStaging>

View File

@ -0,0 +1,83 @@
<?xml version="1.0"?>
<xs:schema targetNamespace="http://microsoft.com/TilFeatureStaging-Schema.xsd"
elementFormDefault="qualified"
xmlns="http://microsoft.com/TilFeatureStaging-Schema.xsd"
xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:til="http://microsoft.com/TilFeatureStaging-Schema.xsd"
version="1.0"
>
<xs:element name="featureStaging">
<xs:complexType>
<xs:sequence>
<xs:element name="feature" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:all>
<!-- Required -->
<xs:element name="name" type="til:featureNameType" />
<xs:element name="description" type="til:stringNoEmpty" />
<xs:element name="stage" type="til:stageType" />
<!-- Optional -->
<xs:element name="id" type="xs:positiveInteger" minOccurs="0" />
<xs:element name="alwaysDisabledBranchTokens" type="til:branchTokenListType" minOccurs="0" />
<xs:element name="alwaysEnabledBranchTokens" type="til:branchTokenListType" minOccurs="0" />
<xs:element name="alwaysDisabledReleaseTokens" type="til:releaseTokenListType" minOccurs="0" />
<xs:element name="alwaysEnabledBrandingTokens" type="til:brandingTokenListType" minOccurs="0" />
<xs:element name="alwaysDisabledBrandingTokens" type="til:brandingTokenListType" minOccurs="0" />
</xs:all>
</xs:complexType>
<xs:key name="featureBranchOverridesKey">
<xs:selector xpath="*/til:branchToken"/>
<xs:field xpath="."/>
</xs:key>
<xs:key name="featureBrandingOverridesKey">
<xs:selector xpath="*/til:brandingToken"/>
<xs:field xpath="."/>
</xs:key>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
<!-- Definitions -->
<xs:simpleType name="stringNoEmpty">
<xs:restriction base="xs:string">
<xs:pattern value=".+" />
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="featureNameType">
<xs:restriction base="til:stringNoEmpty">
<xs:pattern value="^Feature_[a-zA-Z0-9_]+" />
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="stageType">
<xs:restriction base="til:stringNoEmpty">
<xs:pattern value="AlwaysDisabled|AlwaysEnabled" />
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="brandingType">
<xs:restriction base="til:stringNoEmpty">
<xs:pattern value="Dev|Preview|Release|WindowsInbox" />
</xs:restriction>
</xs:simpleType>
<xs:complexType name="brandingTokenListType">
<xs:sequence>
<xs:element name="brandingToken" type="til:brandingType" minOccurs="1" maxOccurs="unbounded" />
</xs:sequence>
</xs:complexType>
<xs:complexType name="branchTokenListType">
<xs:sequence>
<xs:element name="branchToken" type="til:stringNoEmpty" minOccurs="1" maxOccurs="unbounded" />
</xs:sequence>
</xs:complexType>
<xs:complexType name="releaseTokenListType">
<xs:sequence>
<xs:element name="exceptToken" type="til:stringNoEmpty" minOccurs="0" maxOccurs="unbounded" />
</xs:sequence>
</xs:complexType>
</xs:schema>

View File

@ -0,0 +1,206 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.
################################################################################
# This script generates a header describing which Terminal/Console features
# should be compiled-in, based on an XML document describing them.
[CmdletBinding()]
Param(
[Parameter(Position=0, Mandatory=$True)]
[ValidateScript({ Test-Path $_ })]
[string]$Path,
[ValidateSet("Dev", "Preview", "Release", "WindowsInbox")]
[string]$Branding = "Dev",
[string]$BranchOverride = $Null,
[string]$OutputPath
)
Enum Stage {
AlwaysDisabled;
AlwaysEnabled;
}
Function ConvertTo-FeatureStage([string]$stage) {
Switch($stage) {
"AlwaysEnabled" { [Stage]::AlwaysEnabled; Return }
"AlwaysDisabled" { [Stage]::AlwaysDisabled; Return }
}
Throw "Invalid feature stage $stage"
}
Class Feature {
[string]$Name
[Stage]$Stage
[System.Collections.Generic.Dictionary[string, Stage]]$BranchTokenStages
[System.Collections.Generic.Dictionary[string, Stage]]$BrandingTokenStages
[bool]$DisabledReleaseToken
Feature([System.Xml.XmlElement]$entry) {
$this.Name = $entry.name
$this.Stage = ConvertTo-FeatureStage $entry.stage
$this.BranchTokenStages = [System.Collections.Generic.Dictionary[string, Stage]]::new()
$this.BrandingTokenStages = [System.Collections.Generic.Dictionary[string, Stage]]::new()
$this.DisabledReleaseToken = $Null -Ne $entry.alwaysDisabledReleaseTokens
ForEach ($b in $entry.alwaysDisabledBranchTokens.branchToken) {
$this.BranchTokenStages[$b] = [Stage]::AlwaysDisabled
}
# AlwaysEnabled branches win over AlwaysDisabled branches
ForEach ($b in $entry.alwaysEnabledBranchTokens.branchToken) {
$this.BranchTokenStages[$b] = [Stage]::AlwaysEnabled
}
ForEach ($b in $entry.alwaysDisabledBrandingTokens.brandingToken) {
$this.BrandingTokenStages[$b] = [Stage]::AlwaysDisabled
}
# AlwaysEnabled brandings win over AlwaysDisabled brandings
ForEach ($b in $entry.alwaysEnabledBrandingTokens.brandingToken) {
$this.BrandingTokenStages[$b] = [Stage]::AlwaysEnabled
}
}
[string] PreprocessorName() {
return "TIL_$($this.Name.ToUpper())_ENABLED"
}
}
class FeatureComparer : System.Collections.Generic.IComparer[Feature] {
[int] Compare([Feature]$a, [Feature]$b) {
If ($a.Name -lt $b.Name) {
Return -1
} ElseIf ($a.Name -gt $b.Name) {
Return 1
} Else {
Return 0
}
}
}
Function Resolve-FinalFeatureStage {
Param(
[Feature]$Feature,
[string]$Branch,
[string]$Branding
)
# RELEASE=DISABLED wins all checks
# Then, branch match by most-specific branch
# Then, branding type (if no overriding branch match)
If ($Branding -Eq "Release" -And $Feature.DisabledReleaseToken) {
[Stage]::AlwaysDisabled
Return
}
If (-Not [String]::IsNullOrEmpty($Branch)) {
$lastMatchLen = 0
$branchStage = $Null
ForEach ($branchToken in $Feature.BranchTokenStages.Keys) {
# Match the longest branch token -- it should be the most specific
If ($Branch -Like $branchToken -And $branchToken.Length -Gt $lastMatchLen) {
$lastMatchLen = $branchToken.Length
$branchStage = $Feature.BranchTokenStages[$branchToken]
}
}
If ($Null -Ne $branchStage) {
$branchStage
Return
}
}
$BrandingStage = $Feature.BrandingTokenStages[$Branding]
If ($Null -Ne $BrandingStage) {
$BrandingStage
Return
}
$Feature.Stage
}
$ErrorActionPreference = "Stop"
$x = [xml](Get-Content $Path -EA:Stop)
$x.Schemas.Add('http://microsoft.com/TilFeatureStaging-Schema.xsd', (Resolve-Path (Join-Path $PSScriptRoot "FeatureStagingSchema.xsd")).Path) | Out-Null
$x.Validate($null)
$featureComparer = [FeatureComparer]::new()
$features = [System.Collections.Generic.List[Feature]]::new(16)
ForEach ($entry in $x.featureStaging.feature) {
$features.Add([Feature]::new($entry))
}
$features.Sort($featureComparer)
$featureFinalStages = [System.Collections.Generic.Dictionary[string, Stage]]::new(16)
$branch = $BranchOverride
If ([String]::IsNullOrEmpty($branch)) {
Try {
$branch = & git branch --show-current 2>$Null
} Catch {
Try {
$branch = & git rev-parse --abbrev-ref HEAD 2>$Null
} Catch {
Write-Verbose "Cannot determine current Git branch; skipping branch validation"
}
}
}
ForEach ($feature in $features) {
$featureFinalStages[$feature.Name] = Resolve-FinalFeatureStage -Feature $feature -Branch $branch -Branding $Branding
}
### CODE GENERATION
$script:Output = ""
Function AddOutput($s) {
$script:Output += $s
}
AddOutput @"
// THIS FILE IS AUTOMATICALLY GENERATED; DO NOT EDIT IT
// INPUT FILE: $Path
"@
ForEach ($feature in $features) {
$stage = $featureFinalStages[$feature.Name]
AddOutput @"
#define $($feature.PreprocessorName()) $(If ($stage -eq [Stage]::AlwaysEnabled) { "1" } Else { "0" })
"@
}
AddOutput @"
#if defined(__cplusplus)
"@
ForEach ($feature in $features) {
AddOutput @"
__pragma(detect_mismatch("ODR_violation_$($feature.PreprocessorName())_mismatch", "$($feature.Stage)"))
struct $($feature.Name)
{
static constexpr bool IsEnabled() { return $($feature.PreprocessorName()) == 1; }
};
"@
}
AddOutput @"
#endif
"@
If ([String]::IsNullOrEmpty($OutputPath)) {
$script:Output
} Else {
Out-File -Encoding UTF8 -FilePath $OutputPath -InputObject $script:Output
}