Skip to content

Commit cb0cbd4

Browse files
committed
Fix tests
1 parent a27ad2f commit cb0cbd4

File tree

1 file changed

+92
-137
lines changed

1 file changed

+92
-137
lines changed

src/TALXIS.CLI.Workspace/TemplateEngine/TemplatePackageService.cs

Lines changed: 92 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,26 @@
44
using Microsoft.TemplateEngine.Edge.Settings;
55
using Microsoft.TemplateEngine.Edge;
66
using System.Security.Cryptography;
7+
using System.Diagnostics;
78

89
namespace TALXIS.CLI.Workspace.TemplateEngine
910
{
1011
/// <summary>
11-
/// Service responsible for managing template packages (installation, listing, etc.)
12+
/// Manages the TALXIS template package ensuring a single installation across processes.
1213
/// </summary>
1314
public class TemplatePackageService : IDisposable
1415
{
1516
private readonly TemplatePackageManager _templatePackageManager;
1617
private readonly IEngineEnvironmentSettings _environmentSettings;
1718
private readonly string _templatePackageName = "TALXIS.DevKit.Templates.Dataverse";
1819
private readonly SemaphoreSlim _installationSemaphore = new(1, 1);
19-
private volatile bool _isTemplateInstalled = false;
20+
private volatile bool _isTemplateInstalled;
2021
private IManagedTemplatePackage? _installedTemplatePackage;
2122

23+
// Tunables
24+
private const int MutexPollDelayMs = 300; // Small delay between attempts
25+
private static readonly TimeSpan MutexMaxWait = TimeSpan.FromSeconds(30); // Fail fast threshold
26+
2227
public string TemplatePackageName => _templatePackageName;
2328

2429
public TemplatePackageService(TemplatePackageManager templatePackageManager, IEngineEnvironmentSettings environmentSettings)
@@ -27,188 +32,138 @@ public TemplatePackageService(TemplatePackageManager templatePackageManager, IEn
2732
_environmentSettings = environmentSettings ?? throw new ArgumentNullException(nameof(environmentSettings));
2833
}
2934

35+
/// <summary>
36+
/// Ensures the template package is installed (idempotent, thread + process safe).
37+
/// </summary>
3038
public async Task EnsureTemplatePackageInstalledAsync(string? version = null)
3139
{
32-
// Double-checked locking pattern for thread safety within the same process
33-
if (_isTemplateInstalled && _installedTemplatePackage != null)
34-
{
35-
return; // Already installed and we have a reference to it
36-
}
40+
// Fast in-memory short‑circuit
41+
if (_isTemplateInstalled && _installedTemplatePackage != null) return;
3742

3843
await _installationSemaphore.WaitAsync();
3944
try
4045
{
41-
// Check again inside the lock (double-checked locking)
42-
if (_isTemplateInstalled && _installedTemplatePackage != null)
43-
{
44-
return; // Another thread completed the installation
45-
}
46-
47-
// Use cross-process synchronization to prevent race conditions between multiple CLI/MCP instances
48-
await EnsureTemplatePackageInstalledWithCrossProcessLockAsync(version);
46+
if (_isTemplateInstalled && _installedTemplatePackage != null) return;
47+
await EnsureInstalledCrossProcessAsync(version);
4948
}
5049
finally
5150
{
5251
_installationSemaphore.Release();
5352
}
5453
}
5554

56-
/// <summary>
57-
/// Ensures template package installation with cross-process synchronization to prevent race conditions
58-
/// during parallel test execution or multiple CLI instances.
59-
/// </summary>
60-
private async Task EnsureTemplatePackageInstalledWithCrossProcessLockAsync(string? version)
55+
// ---------------------------- Internal helpers ----------------------------
56+
57+
private async Task EnsureInstalledCrossProcessAsync(string? version)
6158
{
62-
// Create a cross-process mutex name based on the package name
59+
// Pre-check without lock (cheap) – if another process already completed install.
60+
if (await TryLoadExistingInstalledPackageAsync()) return;
61+
6362
var mutexName = CreateCrossProcessMutexName(_templatePackageName);
64-
65-
using var mutex = new Mutex(false, mutexName, out var createdNew);
66-
var mutexAcquired = false;
67-
63+
using var mutex = new Mutex(false, mutexName, out _);
64+
var acquired = await AcquireMutexWithPollingAsync(mutex, version);
6865
try
6966
{
70-
// Wait for the mutex with a reasonable timeout to prevent hanging tests
71-
mutexAcquired = mutex.WaitOne(TimeSpan.FromMinutes(5));
72-
if (!mutexAcquired)
67+
if (!acquired)
7368
{
74-
throw new TimeoutException($"Timeout waiting for cross-process lock to install template package '{_templatePackageName}'");
69+
throw new TimeoutException($"Timeout ({MutexMaxWait.TotalSeconds:F0}s) waiting to install '{_templatePackageName}'. Another process may be stalled.");
7570
}
7671

77-
// First check if the package is already installed globally (cross-process safety)
78-
var existingPackages = await _templatePackageManager.GetManagedTemplatePackagesAsync(false, CancellationToken.None);
79-
var existingPackage = existingPackages.FirstOrDefault(p =>
80-
string.Equals(p.Identifier, _templatePackageName, StringComparison.OrdinalIgnoreCase));
81-
82-
if (existingPackage != null)
83-
{
84-
// Package is already installed globally, just store reference
85-
_installedTemplatePackage = existingPackage;
86-
_isTemplateInstalled = true;
87-
return;
88-
}
72+
// Final check inside critical section (double-checked cross-process)
73+
if (await TryLoadExistingInstalledPackageAsync()) return;
8974

90-
// Package not installed, proceed with installation
91-
// Following the official dotnet CLI pattern: create install request with details
92-
var installRequest = new InstallRequest(_templatePackageName, version, details: new Dictionary<string, string>(), force: false);
93-
94-
// Get the managed provider for global scope (matches official CLI approach)
95-
var provider = _templatePackageManager.GetBuiltInManagedProvider(InstallationScope.Global);
96-
var installResults = await provider.InstallAsync(new[] { installRequest }, CancellationToken.None);
97-
98-
// Check if installation was successful
99-
var installResult = installResults.FirstOrDefault();
100-
if (installResult == null || !installResult.Success)
101-
{
102-
var packageId = _templatePackageName;
103-
var detailedErrors = installResult?.ErrorMessage ?? "Unknown installation error";
104-
105-
var userErrorMessage = $"Failed to install template package '{packageId}'.\n" +
106-
$"Details:\n{detailedErrors}\n\n" +
107-
$"💡 Corrective actions:\n" +
108-
$" • Check your internet connection\n" +
109-
$" • Verify the package name and version are correct\n" +
110-
$" • Ensure you have sufficient permissions for global package installation\n" +
111-
$" • If using a private package source, ensure it's properly configured";
112-
113-
throw new InvalidOperationException(userErrorMessage);
114-
}
115-
116-
// Following the official dotnet CLI pattern: store reference to the installed package
117-
// This is crucial for later template discovery
118-
_installedTemplatePackage = installResult.TemplatePackage as IManagedTemplatePackage;
119-
if (_installedTemplatePackage == null)
120-
{
121-
throw new InvalidOperationException($"Template package '{_templatePackageName}' was installed but could not be retrieved as a managed package");
122-
}
123-
124-
// Set flag last to ensure atomic operation visibility
125-
_isTemplateInstalled = true;
75+
await InstallTemplatePackageAsync(version);
12676
}
127-
catch (AbandonedMutexException)
77+
finally
12878
{
129-
// Previous process crashed while holding the mutex, but we can continue
130-
// The mutex is now owned by this thread
131-
mutexAcquired = true; // Mark as acquired since we now own it
132-
133-
// Retry the installation operation (but avoid infinite recursion)
134-
// Just perform the installation logic directly here
135-
var existingPackages = await _templatePackageManager.GetManagedTemplatePackagesAsync(false, CancellationToken.None);
136-
var existingPackage = existingPackages.FirstOrDefault(p =>
137-
string.Equals(p.Identifier, _templatePackageName, StringComparison.OrdinalIgnoreCase));
138-
139-
if (existingPackage != null)
79+
if (acquired)
14080
{
141-
_installedTemplatePackage = existingPackage;
142-
_isTemplateInstalled = true;
143-
return;
81+
try { mutex.ReleaseMutex(); } catch { /* ignore */ }
14482
}
145-
146-
// If package still needs installation, let the exception propagate
147-
// to avoid complex retry logic
148-
throw new InvalidOperationException($"Template package installation was interrupted by another process crash. Please retry the operation.");
149-
}
150-
catch (Exception ex) when (!(ex is InvalidOperationException) && !(ex is TimeoutException))
151-
{
152-
// Wrap unexpected exceptions with user-friendly message
153-
var userErrorMessage = $"Unexpected error while installing template package '{_templatePackageName}'{(version != null ? $" version {version}" : "")}.\n" +
154-
$"Technical details: {ex.Message}\n\n" +
155-
$"💡 Corrective actions:\n" +
156-
$" • Check your internet connection\n" +
157-
$" • Ensure you have permission to install global packages\n" +
158-
$" • Check if the package source is accessible";
159-
160-
throw new InvalidOperationException(userErrorMessage, ex);
16183
}
162-
finally
84+
}
85+
86+
/// <summary>
87+
/// Polls for mutex ownership while periodically re-checking whether installation completed elsewhere.
88+
/// </summary>
89+
private async Task<bool> AcquireMutexWithPollingAsync(Mutex mutex, string? version)
90+
{
91+
var sw = Stopwatch.StartNew();
92+
while (sw.Elapsed < MutexMaxWait)
16393
{
164-
// Only release the mutex if we successfully acquired it
165-
if (mutexAcquired)
94+
try
16695
{
167-
try
168-
{
169-
mutex.ReleaseMutex();
170-
}
171-
catch (Exception)
172-
{
173-
// Ignore release errors - mutex will be released when the process exits
174-
}
96+
if (mutex.WaitOne(TimeSpan.Zero)) return true; // Acquired immediately
17597
}
98+
catch (AbandonedMutexException)
99+
{
100+
return true; // Treat abandoned as success (we now own it)
101+
}
102+
103+
// Re-check installation status – if installed we do not need the lock anymore.
104+
if (await TryLoadExistingInstalledPackageAsync()) return false; // False = we did not own the mutex but work is done
105+
106+
await Task.Delay(MutexPollDelayMs);
176107
}
108+
return false; // Timed out
177109
}
178110

179111
/// <summary>
180-
/// Creates a deterministic mutex name for cross-process synchronization based on the package name.
112+
/// Attempts to locate an already installed package; updates internal state if found.
181113
/// </summary>
114+
private async Task<bool> TryLoadExistingInstalledPackageAsync()
115+
{
116+
var existingPackages = await _templatePackageManager.GetManagedTemplatePackagesAsync(false, CancellationToken.None);
117+
var existing = existingPackages.FirstOrDefault(p => string.Equals(p.Identifier, _templatePackageName, StringComparison.OrdinalIgnoreCase));
118+
if (existing == null) return false;
119+
_installedTemplatePackage = existing;
120+
_isTemplateInstalled = true;
121+
return true;
122+
}
123+
124+
private async Task InstallTemplatePackageAsync(string? version)
125+
{
126+
var request = new InstallRequest(_templatePackageName, version, details: new Dictionary<string, string>(), force: false);
127+
var provider = _templatePackageManager.GetBuiltInManagedProvider(InstallationScope.Global);
128+
var results = await provider.InstallAsync(new[] { request }, CancellationToken.None);
129+
var result = results.FirstOrDefault();
130+
131+
if (result == null || !result.Success)
132+
{
133+
var details = result?.ErrorMessage ?? "Unknown installation error";
134+
throw new InvalidOperationException($"Failed to install template package '{_templatePackageName}'.\nDetails:\n{details}\n" +
135+
"💡 Corrective actions:\n" +
136+
" • Check internet connectivity\n" +
137+
" • Verify package name/version\n" +
138+
" • Ensure global install permissions\n" +
139+
" • Validate private feeds (if used)");
140+
}
141+
142+
_installedTemplatePackage = result.TemplatePackage as IManagedTemplatePackage
143+
?? throw new InvalidOperationException($"Template package '{_templatePackageName}' installed but not retrievable as managed package");
144+
_isTemplateInstalled = true; // Publish state last
145+
}
146+
182147
private static string CreateCrossProcessMutexName(string packageName)
183148
{
184-
// Create a hash of the package name to ensure the mutex name is valid and deterministic
185149
using var sha256 = SHA256.Create();
186-
var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(packageName));
187-
var hashString = Convert.ToBase64String(hashBytes).Replace('+', '-').Replace('/', '_').TrimEnd('=');
188-
189-
// Prefix with a namespace to avoid conflicts with other applications
190-
return $"Global\\TALXIS_CLI_TemplatePackage_{hashString}";
150+
var hash = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(packageName));
151+
var token = Convert.ToBase64String(hash).Replace('+', '-').Replace('/', '_').TrimEnd('=');
152+
// Omit Windows-specific Global\ prefix for cross-platform consistency.
153+
return $"TALXIS_CLI_TemplatePackage_{token}";
191154
}
192155

193156
public async Task<List<ITemplateInfo>> ListTemplatesAsync(string? version = null)
194157
{
195158
await EnsureTemplatePackageInstalledAsync(version);
196-
197-
// Read the installed package reference with memory barrier for thread safety
198-
var installedPackage = _installedTemplatePackage;
199-
if (installedPackage == null)
200-
{
201-
throw new InvalidOperationException("Template package was installed but reference is not available");
202-
}
203-
204-
// Use the official dotnet CLI pattern - get templates from the specific installed package
205-
var templates = await _templatePackageManager.GetTemplatesAsync(installedPackage, CancellationToken.None);
159+
var pkg = _installedTemplatePackage ?? throw new InvalidOperationException("Template package reference missing after install.");
160+
var templates = await _templatePackageManager.GetTemplatesAsync(pkg, CancellationToken.None);
206161
return templates.ToList();
207162
}
208163

209164
public void Dispose()
210165
{
211-
_installationSemaphore?.Dispose();
166+
_installationSemaphore.Dispose();
212167
}
213168
}
214169
}

0 commit comments

Comments
 (0)