Skip to content

Commit 31f1cbc

Browse files
gfraiteurclaude
andcommitted
Fix race condition in DotNetTool.Install for parallel builds
Add a global mutex to serialize dotnet tool installation when multiple parallel MSBuild targets invoke the sign command simultaneously. Also add verification that the tool manifest was created at the expected location with diagnostic error message if not. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 545f374 commit 31f1cbc

1 file changed

Lines changed: 95 additions & 56 deletions

File tree

src/PostSharp.Engineering.BuildTools/Utilities/DotNetTool.cs

Lines changed: 95 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.Collections.Immutable;
88
using System.IO;
99
using System.Text.Json;
10+
using System.Threading;
1011

1112
namespace PostSharp.Engineering.BuildTools.Utilities
1213
{
@@ -42,89 +43,127 @@ public bool Install( BuildContext context )
4243
var configFilePath = Path.Combine( baseDirectory, ".config", "dotnet-tools.json" );
4344
var resourceDirectory = Path.Combine( baseDirectory, ".tools" );
4445

45-
// 1. Create the dotnet tool manifest.
46-
if ( !File.Exists( configFilePath ) )
46+
// Use a named mutex to prevent race conditions when multiple parallel builds
47+
// try to install the dotnet tool at the same time.
48+
var mutexName = "Global\\DotNetToolInstall_" + baseDirectory.Replace( '\\', '_' ).Replace( '/', '_' ).Replace( ':', '_' );
49+
50+
using var mutex = new Mutex( false, mutexName );
51+
52+
try
4753
{
48-
if ( !ToolInvocationHelper.InvokeTool(
49-
context.Console,
50-
"dotnet",
51-
$"new tool-manifest",
52-
baseDirectory ) )
54+
// Wait up to 5 minutes for the mutex.
55+
if ( !mutex.WaitOne( TimeSpan.FromMinutes( 5 ) ) )
5356
{
57+
context.Console.WriteError( "Timeout waiting for dotnet tool installation lock." );
58+
5459
return false;
5560
}
5661
}
57-
58-
// Open the config file and see if we have to install or update.
59-
string? installVerb = null;
60-
var configDocument = JsonDocument.Parse( File.ReadAllText( configFilePath ) );
61-
62-
var installedVersionString = configDocument.RootElement.GetPropertyOrNull( "tools" )
63-
.GetPropertyOrNull( this.PackageId.ToLowerInvariant() )
64-
.GetPropertyOrNull( "version" )
65-
?.GetString();
66-
67-
if ( installedVersionString == null )
62+
catch ( AbandonedMutexException )
6863
{
69-
installVerb = "install";
64+
// Another process crashed while holding the mutex. We now own it.
7065
}
71-
else
66+
67+
try
7268
{
73-
var installedVersion = NuGetVersion.Parse( installedVersionString );
69+
// 1. Create the dotnet tool manifest.
70+
if ( !File.Exists( configFilePath ) )
71+
{
72+
if ( !ToolInvocationHelper.InvokeTool(
73+
context.Console,
74+
"dotnet",
75+
$"new tool-manifest",
76+
baseDirectory ) )
77+
{
78+
return false;
79+
}
80+
81+
// Verify the manifest was created where expected.
82+
if ( !File.Exists( configFilePath ) )
83+
{
84+
context.Console.WriteError(
85+
$"The 'dotnet new tool-manifest' command succeeded but the manifest was not created at the expected location: '{configFilePath}'. " +
86+
$"Working directory was: '{baseDirectory}'." );
87+
88+
return false;
89+
}
90+
}
91+
92+
// Open the config file and see if we have to install or update.
93+
string? installVerb = null;
94+
var configDocument = JsonDocument.Parse( File.ReadAllText( configFilePath ) );
95+
96+
var installedVersionString = configDocument.RootElement.GetPropertyOrNull( "tools" )
97+
.GetPropertyOrNull( this.PackageId.ToLowerInvariant() )
98+
.GetPropertyOrNull( "version" )
99+
?.GetString();
74100

75-
if ( installedVersion < NuGetVersion.Parse( this.Version ) )
101+
if ( installedVersionString == null )
76102
{
77-
installVerb = "update";
103+
installVerb = "install";
78104
}
79-
}
105+
else
106+
{
107+
var installedVersion = NuGetVersion.Parse( installedVersionString );
80108

81-
// 2. Restore the tool.
82-
if ( installVerb != null )
83-
{
109+
if ( installedVersion < NuGetVersion.Parse( this.Version ) )
110+
{
111+
installVerb = "update";
112+
}
113+
}
114+
115+
// 2. Restore the tool.
116+
if ( installVerb != null )
117+
{
118+
if ( !ToolInvocationHelper.InvokeTool(
119+
context.Console,
120+
"dotnet",
121+
$"tool {installVerb} {this.PackageId} --version {this.Version} --local --add-source \"https://api.nuget.org/v3/index.json\"",
122+
baseDirectory ) )
123+
{
124+
return false;
125+
}
126+
}
127+
128+
// 3. Restore the tools from the manifest
129+
// The manifest might contain tools, that have been removed from the machine, or not yet installed.
130+
// The tools are stored in NuGet package cache, that can be cleaned.
84131
if ( !ToolInvocationHelper.InvokeTool(
85132
context.Console,
86133
"dotnet",
87-
$"tool {installVerb} {this.PackageId} --version {this.Version} --local --add-source \"https://api.nuget.org/v3/index.json\"",
134+
$"tool restore --add-source \"https://api.nuget.org/v3/index.json\"",
88135
baseDirectory ) )
89136
{
90137
return false;
91138
}
92-
}
93139

94-
// 3. Restore the tools from the manifest
95-
// The manifest might contain tools, that have been removed from the machine, or not yet installed.
96-
// The tools are stored in NuGet package cache, that can be cleaned.
97-
if ( !ToolInvocationHelper.InvokeTool(
98-
context.Console,
99-
"dotnet",
100-
$"tool restore --add-source \"https://api.nuget.org/v3/index.json\"",
101-
baseDirectory ) )
102-
{
103-
return false;
104-
}
105-
106-
// 4. Restore resource tools.
107-
Directory.CreateDirectory( resourceDirectory );
108-
var assembly = this.GetType().Assembly;
109-
110-
foreach ( var resourceName in assembly.GetManifestResourceNames() )
111-
{
112-
const string prefix = "PostSharp.Engineering.BuildTools.Resources.Tools.";
140+
// 4. Restore resource tools.
141+
Directory.CreateDirectory( resourceDirectory );
142+
var assembly = this.GetType().Assembly;
113143

114-
if ( resourceName.StartsWith( prefix, StringComparison.Ordinal ) )
144+
foreach ( var resourceName in assembly.GetManifestResourceNames() )
115145
{
116-
using var resource = assembly.GetManifestResourceStream( resourceName );
117-
118-
var file = Path.Combine( resourceDirectory, resourceName.Substring( prefix.Length ) );
146+
const string prefix = "PostSharp.Engineering.BuildTools.Resources.Tools.";
119147

120-
using ( var outputStream = File.Create( file ) )
148+
if ( resourceName.StartsWith( prefix, StringComparison.Ordinal ) )
121149
{
122-
resource!.CopyTo( outputStream );
150+
using var resource = assembly.GetManifestResourceStream( resourceName );
151+
152+
var file = Path.Combine( resourceDirectory, resourceName.Substring( prefix.Length ) );
153+
154+
using ( var outputStream = File.Create( file ) )
155+
{
156+
resource!.CopyTo( outputStream );
157+
}
123158
}
124159
}
125-
}
126160

127-
return true;
161+
return true;
162+
}
163+
finally
164+
{
165+
mutex.ReleaseMutex();
166+
}
128167
}
129168

130169
public virtual bool Invoke( BuildContext context, string command, ToolInvocationOptions? options = null )

0 commit comments

Comments
 (0)