Skip to content

Commit 4bc8e99

Browse files
Merge pull request #98 from Microsoft365DSC/dev
Release 3.1.0.1
2 parents cc1e188 + 8f15ec4 commit 4bc8e99

9 files changed

Lines changed: 651 additions & 16 deletions

File tree

.github/workflows/Build and Test.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,57 @@ jobs:
137137
}
138138
139139
Write-Host "Class-based discovery and parsing validated against $($classBasedModule.Name) ($($classBased.Count) class-based resources)."
140+
141+
# Regression where the engine wipes its DSC caches when a Configuration is compiled to MOF.
142+
# Test against that behavior by simulating a wipe of the engine's caches and asserting that both entry points self-heal.
143+
$reflection = [DSCParser.PSDSC.DscResourceService].Assembly.GetType('DSCParser.PSDSC.DscClassCacheReflection')
144+
$reflection.GetMethod('ClearCache').Invoke($null, $null)
145+
$reflection.GetMethod('ResetDynamicKeywords').Invoke($null, $null)
146+
147+
$parsed = @(ConvertTo-DSCObject -Content $config -ErrorAction Stop)
148+
if ($parsed.Count -ne 1 -or $parsed[0].ResourceName -ne $resource.Name)
149+
{
150+
throw "ConvertTo-DSCObject did not recover after the engine DSC caches were cleared."
151+
}
152+
153+
$resourcesAfterWipe = @(Get-DscResourceV2 -Module $classBasedModule.Name -ErrorAction Stop)
154+
if ($resourcesAfterWipe.Count -ne $resources.Count)
155+
{
156+
throw "Get-DscResourceV2 returned $($resourcesAfterWipe.Count) resources after the engine DSC caches were cleared, expected $($resources.Count)."
157+
}
158+
159+
Write-Host "Stale engine cache recovery validated (deterministic wipe)."
160+
161+
# Same regression through the real trigger with compiling a Configuration to MOF.
162+
if (Get-Module -ListAvailable -Name PSDesiredStateConfiguration)
163+
{
164+
Import-Module PSDesiredStateConfiguration -ErrorAction Stop
165+
$wipeOutput = Join-Path ([System.IO.Path]::GetTempPath()) 'dscparser-wipe'
166+
# Compile in this process; even a failing compile wipes the caches.
167+
try
168+
{
169+
Invoke-Expression "Configuration DscParserWipeTest { Node localhost { } }`nDscParserWipeTest -OutputPath '$wipeOutput'" | Out-Null
170+
}
171+
catch
172+
{
173+
throw "Compiling a Configuration after using DSCParser failed: $_"
174+
}
175+
176+
$parsed = @(ConvertTo-DSCObject -Content $config -ErrorAction Stop)
177+
if ($parsed.Count -ne 1 -or $parsed[0].ResourceName -ne $resource.Name)
178+
{
179+
throw "ConvertTo-DSCObject did not recover after a Configuration was compiled to MOF."
180+
}
181+
182+
$resourcesAfterCompile = @(Get-DscResourceV2 -Module $classBasedModule.Name -ErrorAction Stop)
183+
if ($resourcesAfterCompile.Count -ne $resources.Count)
184+
{
185+
throw "Get-DscResourceV2 returned $($resourcesAfterCompile.Count) resources after a MOF compile, expected $($resources.Count)."
186+
}
187+
188+
Write-Host "Stale engine cache recovery validated (real MOF compile)."
189+
}
190+
else
191+
{
192+
Write-Host "::warning::PSDesiredStateConfiguration is not installed; skipping the MOF-compile recovery validation."
193+
}

Modules/DSCParser/DSCParser.psd1

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
RootModule = 'DSCParser.psm1'
1313

1414
# Version number of this module.
15-
ModuleVersion = '3.1.0.0'
15+
ModuleVersion = '3.1.0.1'
1616

1717
# ID used to uniquely identify this module
1818
GUID = 'e168239a-233d-468d-9025-d6dfc0e4e2b6'
@@ -110,7 +110,7 @@
110110
IconUri = 'https://github.com/Microsoft365DSC/DSCParser/blob/master/Images/DSCParser.png?raw=true'
111111

112112
# ReleaseNotes of this module
113-
ReleaseNotes = '* Refactored the module and fixed an issue with wildcard pattern matching in the Get-DscResourceV2 cmdlet.'
113+
ReleaseNotes = '* Fixed a regression where compiling a Configuration to MOF cleared the engine DSC caches underneath DSCParser, after which Get-DscResourceV2 returned no resources and ConvertTo-DSCObject failed. The keyword registry now detects the wipe and re-imports automatically.'
114114
} # End of PSData hashtable
115115

116116
} # End of PrivateData hashtable

Modules/DSCParser/Modules/DSCParser.psm1

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,11 @@ function ConvertTo-DSCObject
155155
}
156156

157157
# Buffer diagnostics and emit them after the call, so Write-Warning runs on the pipeline
158-
# thread and honours the caller's $WarningPreference. The sink is a process-wide static.
158+
# thread and honours the caller's $WarningPreference. The sinks are process-wide statics.
159159
$parserWarnings = [System.Collections.Generic.List[System.String]]::new()
160-
[DSCParser.CSharp.DscParser]::WarningSink = [System.Action[System.String]] { param($Message) $parserWarnings.Add($Message) }
160+
$warningSink = [System.Action[System.String]] { param($Message) $parserWarnings.Add($Message) }
161+
[DSCParser.CSharp.DscParser]::WarningSink = $warningSink
162+
[DSCParser.PSDSC.DscResourceService]::WarningSink = $warningSink
161163

162164
try
163165
{
@@ -174,6 +176,7 @@ function ConvertTo-DSCObject
174176
finally
175177
{
176178
[DSCParser.CSharp.DscParser]::WarningSink = $null
179+
[DSCParser.PSDSC.DscResourceService]::WarningSink = $null
177180
foreach ($parserWarning in $parserWarnings)
178181
{
179182
Write-Warning -Message $parserWarning

src/DSCParser.CSharp/DscParser.cs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,25 @@ public static List<DscResourceInstance> ConvertToDscObject(string? path = null,
133133
dscContent = RemoveModuleVersionInfo(dscContent, modulesToRemoveVersionFrom);
134134

135135
List<ModuleReference> modulesToLoad = GetModulesToLoad(dscContent);
136-
RegisterKeywords(modulesToLoad, errorPrefix);
137136

138137
// Parse the DSC configuration using PowerShell AST instead of with "Import-DscResource"
139-
// to avoid loading modules from disk, which may be very slow with many class-based resources
140-
ScriptBlockAst ast = Parser.ParseInput(
141-
RemoveImportDscResourceStatements(dscContent), out Token[] tokens, out ParseError[] parseErrors);
138+
// to avoid loading modules from disk, which may be very slow with many class-based resources.
139+
// The DynamicKeyword table only exists for the duration of the parse because leaving it populated
140+
// breaks the engine's own Configuration-to-MOF compilation afterwards.
141+
ScriptBlockAst ast;
142+
Token[] tokens;
143+
ParseError[] parseErrors;
144+
try
145+
{
146+
RegisterKeywords(modulesToLoad, errorPrefix);
147+
DscKeywordRegistry.MaterializeKeywordTable();
148+
ast = Parser.ParseInput(
149+
RemoveImportDscResourceStatements(dscContent), out tokens, out parseErrors);
150+
}
151+
finally
152+
{
153+
DscKeywordRegistry.ClearKeywordTable();
154+
}
142155

143156
// Find the Configuration definition
144157
ConfigurationDefinitionAst? configAst = ast.Find(a => a is ConfigurationDefinitionAst, false) as ConfigurationDefinitionAst;

src/DSCParser.PSDSC/DscClassCacheReflection.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ internal static class DscClassCacheReflection
4949
private static readonly MethodInfo? ResetDynamicKeywordsMethod =
5050
typeof(DynamicKeyword).GetMethod("Reset", BindingFlags.Public | BindingFlags.Static);
5151

52+
public static bool IsDscClassCacheAvailable => CacheType is not null;
53+
5254
public static void LoadDefaultCimKeywords()
5355
{
5456
try
@@ -106,6 +108,25 @@ public static List<CimClass> GetCachedClassByFileName(string fileName)
106108
return GetFileDefiningClassMethod?.Invoke(null, [className]) as List<string>;
107109
}
108110

111+
/// <summary>
112+
/// Whether the engine's class cache currently holds a definition for the given class.
113+
/// Returns false when the lookup method is unavailable or throws. Staleness probes must
114+
/// pair it with <see cref="IsDscClassCacheAvailable"/> to avoid misreading an unsupported
115+
/// host as a wiped cache.
116+
/// </summary>
117+
public static bool HasCachedClass(string className)
118+
{
119+
try
120+
{
121+
return GetFileDefiningClassMethod is not null
122+
&& GetFileDefiningClass(className) is { Count: > 0 };
123+
}
124+
catch
125+
{
126+
return false;
127+
}
128+
}
129+
109130
public static void ClearCache()
110131
{
111132
try
@@ -123,6 +144,7 @@ public static void ResetDynamicKeywords()
123144
_ = ResetDynamicKeywordsMethod?.Invoke(null, null);
124145
}
125146

147+
126148
private static Dictionary<string, ScriptBlock> NewFunctionTable()
127149
{
128150
return new Dictionary<string, ScriptBlock>(StringComparer.OrdinalIgnoreCase);

src/DSCParser.PSDSC/DscKeywordRegistry.cs

Lines changed: 164 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.IO;
44
using System.Linq;
55
using System.Management.Automation;
6+
using System.Management.Automation.Language;
67

78
namespace DSCParser.PSDSC
89
{
@@ -14,14 +15,57 @@ namespace DSCParser.PSDSC
1415
/// PowerShell rebuilds this registration from scratch every time it parses a configuration that
1516
/// contains an Import-DSCResource statement, probing the file system for a schema file per
1617
/// resource. On a module the size of Microsoft365DSC that dominates the cost of parsing. Keeping
17-
/// the registration for the lifetime of the process lets callers strip the Import-DSCResource
18-
/// statement and skip that work entirely.
18+
/// the registration alive lets callers strip the Import-DSCResource statement and skip that
19+
/// work entirely.
1920
/// </remarks>
2021
public static class DscKeywordRegistry
2122
{
22-
private static readonly HashSet<string> ImportedModules = new(StringComparer.OrdinalIgnoreCase);
23+
[ThreadStatic]
24+
private static HashSet<string>? t_importedModules;
2325

24-
private static bool _defaultKeywordsLoaded;
26+
private static HashSet<string> ImportedModules => t_importedModules ??= new(StringComparer.OrdinalIgnoreCase);
27+
28+
[ThreadStatic]
29+
private static bool t_defaultKeywordsLoaded;
30+
31+
[ThreadStatic]
32+
private static bool t_engineUnsupported;
33+
34+
[ThreadStatic]
35+
private static List<DynamicKeyword>? t_defaultTableKeywords;
36+
37+
[ThreadStatic]
38+
private static bool t_staleWarningIssued;
39+
40+
[ThreadStatic]
41+
private static int t_expectedCachedKeywordCount;
42+
43+
// Registered into the class cache by LoadDefaultCimKeywords and dies with it, so its
44+
// absence while the bookkeeping claims otherwise means the engine wiped the cache.
45+
private const string ClassCacheSentinel = "OMI_ConfigurationDocument";
46+
47+
// Lives in the DynamicKeyword table (not the class cache) and is re-added by
48+
// LoadDefaultCimKeywords, so it tells whether the table currently holds the defaults.
49+
private const string NodeKeyword = "Node";
50+
51+
private static bool EngineStateIsFresh
52+
{
53+
get
54+
{
55+
if (!DscClassCacheReflection.HasCachedClass(ClassCacheSentinel))
56+
{
57+
return false;
58+
}
59+
60+
return t_expectedCachedKeywordCount == 0
61+
|| CurrentCachedKeywordCount() >= t_expectedCachedKeywordCount;
62+
}
63+
}
64+
65+
private static int CurrentCachedKeywordCount()
66+
{
67+
return DscClassCacheReflection.GetCachedKeywords()?.Count() ?? 0;
68+
}
2569

2670
/// <summary>
2771
/// Registers the resources of the supplied modules, skipping modules already registered.
@@ -47,6 +91,8 @@ public static void ImportModules(IEnumerable<PSModuleInfo> modules)
4791
// A later request that names no version is satisfied by any registered version.
4892
_ = ImportedModules.Add(GetModuleKey(module.Name, null));
4993
}
94+
95+
t_expectedCachedKeywordCount = CurrentCachedKeywordCount();
5096
}
5197

5298
/// <summary>
@@ -62,6 +108,9 @@ public static bool EnsureRegistered(string moduleName, Version? version)
62108
return false;
63109
}
64110

111+
// Heals a wiped engine cache before the fast path below can return a stale answer.
112+
EnsureDefaultKeywordsLoaded();
113+
65114
if (ImportedModules.Contains(GetModuleKey(moduleName, version)))
66115
{
67116
return true;
@@ -79,25 +128,132 @@ public static bool EnsureRegistered(string moduleName, Version? version)
79128
}
80129

81130
/// <summary>
82-
/// Drops every registered keyword and the underlying class cache.
131+
/// Drops every registered keyword and the underlying class cache on the current thread.
83132
/// </summary>
84133
public static void Reset()
85134
{
86135
ImportedModules.Clear();
87-
_defaultKeywordsLoaded = false;
136+
t_defaultKeywordsLoaded = false;
137+
t_engineUnsupported = false;
138+
t_defaultTableKeywords = null;
139+
t_expectedCachedKeywordCount = 0;
88140
DscClassCacheReflection.ResetDynamicKeywords();
89141
DscClassCacheReflection.ClearCache();
90142
}
91143

92144
internal static void EnsureDefaultKeywordsLoaded()
93145
{
94-
if (_defaultKeywordsLoaded)
146+
_ = HandleExternalCacheReset();
147+
148+
if (t_defaultKeywordsLoaded)
95149
{
96150
return;
97151
}
98152

99153
DscClassCacheReflection.LoadDefaultCimKeywords();
100-
_defaultKeywordsLoaded = true;
154+
t_defaultKeywordsLoaded = true;
155+
t_engineUnsupported = !EngineStateIsFresh;
156+
157+
List<DynamicKeyword> snapshot = [];
158+
IEnumerable<string> defaultNames =
159+
(DscClassCacheReflection.GetCachedKeywords()?.Select(k => k.Keyword) ?? [])
160+
.Concat([NodeKeyword, "Import-DscResource"]);
161+
foreach (string name in defaultNames.Distinct(StringComparer.OrdinalIgnoreCase))
162+
{
163+
if (DynamicKeyword.GetKeyword(name) is { } keyword)
164+
{
165+
snapshot.Add(keyword);
166+
}
167+
}
168+
169+
t_defaultTableKeywords = snapshot;
170+
t_expectedCachedKeywordCount = CurrentCachedKeywordCount();
171+
}
172+
173+
/// <summary>
174+
/// The DSC engine clears its internal class cache and DynamicKeyword table whenever a
175+
/// Configuration block is compiled to MOF in this process. This means that this class
176+
/// then no longer matches engine state, resulting in no resource results although
177+
/// shown as imported.
178+
/// </summary>
179+
public static bool HandleExternalCacheReset()
180+
{
181+
if (t_engineUnsupported || !DscClassCacheReflection.IsDscClassCacheAvailable)
182+
{
183+
return false;
184+
}
185+
186+
if (ImportedModules.Count == 0 && !t_defaultKeywordsLoaded)
187+
{
188+
return false;
189+
}
190+
191+
if (EngineStateIsFresh)
192+
{
193+
return false;
194+
}
195+
196+
if (!t_staleWarningIssued)
197+
{
198+
t_staleWarningIssued = true;
199+
DscResourceService.ReportWarning(
200+
"The PowerShell engine cleared its internal DSC caches (typically caused by compiling a "
201+
+ "Configuration to MOF). DSCParser re-imported its DSC resource keywords automatically.");
202+
}
203+
204+
Reset();
205+
return true;
206+
}
207+
208+
/// <summary>
209+
/// Fills the engine's DynamicKeyword table with the default CIM keywords (including Node)
210+
/// and every keyword the class cache holds, so a configuration can be parsed without any
211+
/// Import-DscResource statement. Pair with <see cref="ClearKeywordTable"/> once parsing is
212+
/// done.
213+
/// </summary>
214+
public static void MaterializeKeywordTable()
215+
{
216+
EnsureDefaultKeywordsLoaded();
217+
218+
List<DynamicKeyword>? cachedKeywords = DscClassCacheReflection.GetCachedKeywords()?.ToList();
219+
220+
if (!DynamicKeyword.ContainsKeyword(NodeKeyword))
221+
{
222+
if (t_defaultTableKeywords is { Count: > 0 } defaults)
223+
{
224+
foreach (DynamicKeyword keyword in defaults)
225+
{
226+
if (!DynamicKeyword.ContainsKeyword(keyword.Keyword))
227+
{
228+
DynamicKeyword.AddKeyword(keyword);
229+
}
230+
}
231+
}
232+
else if (ImportedModules.Count == 0)
233+
{
234+
DscClassCacheReflection.LoadDefaultCimKeywords();
235+
}
236+
}
237+
238+
if (cachedKeywords is not null)
239+
{
240+
foreach (DynamicKeyword keyword in cachedKeywords)
241+
{
242+
if (!DynamicKeyword.ContainsKeyword(keyword.Keyword))
243+
{
244+
DynamicKeyword.AddKeyword(keyword);
245+
}
246+
}
247+
}
248+
}
249+
250+
/// <summary>
251+
/// Empties the engine's DynamicKeyword table while keeping the class cache. Every public
252+
/// operation must end with this.
253+
/// </summary>
254+
public static void ClearKeywordTable()
255+
{
256+
DscClassCacheReflection.ResetDynamicKeywords();
101257
}
102258

103259
private static string GetModuleKey(string moduleName, Version? version)

0 commit comments

Comments
 (0)