-
Notifications
You must be signed in to change notification settings - Fork 389
Fix #14026 NET task host MSB4216 via dotnet-host drive-casing probe (builds on #17002) #17039
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a00475d
c7e87d6
d42487d
1870324
7fd82d0
ae6747d
57ae827
dd1aa62
427dadd
21f1eaf
ca8b377
ba0ad56
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,5 +1,5 @@ | ||||||
| <!-- Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. --> | ||||||
| <Project> | ||||||
| <Project InitialTargets="NormalizeNetCoreSdkRootCasing"> | ||||||
|
|
||||||
| <!-- Workaround for https://github.com/Microsoft/msbuild/issues/1310 --> | ||||||
| <Target Name="ForceGenerationOfBindingRedirects" | ||||||
|
|
@@ -184,4 +184,341 @@ | |||||
| </ItemGroup> | ||||||
| </Target> | ||||||
|
|
||||||
| <!-- | ||||||
| ========= | ||||||
| TEMPORARY WORKAROUND for https://github.com/dotnet/msbuild/issues/14026 | ||||||
| ========= | ||||||
|
|
||||||
| Problem | ||||||
| ~~~~~~~ | ||||||
| The .NET task host handshake "salt" is a case-sensitive hash of the SDK tools | ||||||
| directory. The two sides derive that directory string differently: | ||||||
|
|
||||||
| * Host (.NET Framework MSBuild, e.g. VS): hashes the $(NetCoreSdkRoot) | ||||||
| property, whose drive-letter casing is propagated verbatim from the | ||||||
| environment (e.g. an Azure DevOps "D:\a\_work\1\s" sources path) and is | ||||||
| never canonicalized, because managed Path.* APIs preserve the caller's | ||||||
| drive casing. | ||||||
|
|
||||||
| * Child (.NET task host): resolves its own path via Environment.ProcessPath | ||||||
| (GetModuleFileNameW under the hood), which on some agents reports the | ||||||
| volume's canonical drive-letter casing ("d:"). | ||||||
|
|
||||||
| When those casings differ ("D:" vs "d:") the salts differ and the handshake | ||||||
| fails with: | ||||||
|
|
||||||
| error MSB4216: Could not run the "..." task because MSBuild could not | ||||||
| create or connect to a task host with runtime "NET" ... | ||||||
|
|
||||||
| Fix | ||||||
| ~~~ | ||||||
| Rewrite ONLY the drive letter of $(NetCoreSdkRoot) to the casing the child | ||||||
| will use, so the host computes the same salt. To learn that casing we launch | ||||||
| the SDK's dotnet host (dotnet.exe - the runtime host, which always runs and | ||||||
| lives on the same volume as the SDK) on a tiny throwaway project whose inline | ||||||
| task reads GetModuleFileNameW(NULL) - the exact API behind the child's | ||||||
| Environment.ProcessPath. We adopt the returned drive letter only when it is | ||||||
| the same letter as $(NetCoreSdkRoot) differing solely in case, so this can | ||||||
| never change the actual drive, only its casing. | ||||||
|
|
||||||
| NOTE: an earlier revision used GetFinalPathNameByHandle on $(NetCoreSdkRoot). | ||||||
| That always returns an UPPERCASE drive letter on the affected agents, so it | ||||||
| did not match the child's lowercase "d:" and was a silent no-op (proven from | ||||||
| the failing build's comm traces / binlog). Launching the SDK's own MSBuild.exe | ||||||
| apphost instead fails because its matching runtime is not resolvable in the | ||||||
| host process environment. The dotnet-host probe avoids both problems. | ||||||
|
|
||||||
| This is Windows-only, idempotent, cached per drive, and a safe no-op when the | ||||||
| probe cannot run. | ||||||
|
|
||||||
| Knobs | ||||||
| ~~~~~ | ||||||
| * $(DisableNetCoreSdkRootDriveCasingWorkaround)=true | ||||||
| Fully disables this workaround (the target is skipped). | ||||||
|
|
||||||
| * $(NetCoreSdkRootDriveCasingOverride)=lower | upper | ||||||
| Deterministic escape hatch. Forces the $(NetCoreSdkRoot) drive letter to | ||||||
| the requested casing WITHOUT probing, for emergencies or testing. Both a | ||||||
| property and an environment variable of the same name work. | ||||||
|
|
||||||
| Remove this workaround once BOTH the VS MSBuild host and the .NET SDK task | ||||||
| host carry the bilateral salt-casing normalization from #14026. | ||||||
|
|
||||||
| Wiring | ||||||
| ~~~~~~ | ||||||
| Implemented in Workarounds.targets, imported automatically by | ||||||
| Microsoft.DotNet.Arcade.Sdk (see sdk/Sdk.targets). The InitialTargets on this | ||||||
| <Project> aggregates into the importing project's InitialTargets, so the target | ||||||
| runs before the first .NET task host is launched. NetCoreSdkRoot is defined | ||||||
| during evaluation, so it is available when the target runs, and the handshake | ||||||
| salt reads the live (target-mutated) property value. | ||||||
| ========= | ||||||
| --> | ||||||
|
|
||||||
| <UsingTask TaskName="NormalizeSdkRootDriveCasing" | ||||||
| TaskFactory="RoslynCodeTaskFactory" | ||||||
| AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll" | ||||||
| Condition="'$(MSBuildRuntimeType)' == 'Full' and '$(NetCoreSdkRoot)' != ''"> | ||||||
| <ParameterGroup> | ||||||
| <SdkRoot ParameterType="System.String" Required="true" /> | ||||||
| <Override ParameterType="System.String" /> | ||||||
| <Result ParameterType="System.String" Output="true" /> | ||||||
| </ParameterGroup> | ||||||
| <Task> | ||||||
| <Code Type="Class" Language="cs"><![CDATA[ | ||||||
| using System; | ||||||
| using System.Collections.Generic; | ||||||
| using System.Diagnostics; | ||||||
| using System.IO; | ||||||
| using System.Text; | ||||||
| using Microsoft.Build.Framework; | ||||||
| using Microsoft.Build.Utilities; | ||||||
|
|
||||||
| public class NormalizeSdkRootDriveCasing : Task | ||||||
| { | ||||||
| [Required] | ||||||
| public string SdkRoot { get; set; } | ||||||
|
|
||||||
| public string Override { get; set; } | ||||||
|
|
||||||
| [Output] | ||||||
| public string Result { get; set; } | ||||||
|
|
||||||
| private const string Marker = "__SDKDRIVEPROBE__="; | ||||||
|
|
||||||
| // Cache the registered drive letter per drive for the lifetime of the MSBuild process, | ||||||
| // so the (one-time) dotnet-host probe launch is not repeated for every project's InitialTargets. | ||||||
| private static readonly object s_lock = new object(); | ||||||
|
Comment on lines
+289
to
+291
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a good idea. I would generally have single-instanced it by pushing the invocation of the task into a single project invocation, by having the body of the Of course there's subtlety to that, see dotnet/msbuild#11390. Since this is tested I'd move forward with the current implementation rather than rewriting to my preference. |
||||||
| private static readonly Dictionary<char, char> s_driveCache = new Dictionary<char, char>(); | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be case-insensitive? |
||||||
|
|
||||||
| public override bool Execute() | ||||||
| { | ||||||
| // Best-effort: never fail the build over a casing tweak. Default to a no-op. | ||||||
| Result = SdkRoot; | ||||||
|
|
||||||
| try | ||||||
| { | ||||||
| if (string.IsNullOrEmpty(SdkRoot) || SdkRoot.Length < 2 || SdkRoot[1] != ':') | ||||||
| { | ||||||
| return true; | ||||||
| } | ||||||
|
|
||||||
| // Deterministic escape hatch: the NetCoreSdkRootDriveCasingOverride property = "lower" or | ||||||
| // "upper" forces the drive-letter casing without probing the SDK, for emergencies or testing. | ||||||
| if (!string.IsNullOrEmpty(Override)) | ||||||
| { | ||||||
| char forced = SdkRoot[0]; | ||||||
| if (string.Equals(Override, "lower", StringComparison.OrdinalIgnoreCase)) | ||||||
| { | ||||||
| forced = char.ToLowerInvariant(SdkRoot[0]); | ||||||
| } | ||||||
| else if (string.Equals(Override, "upper", StringComparison.OrdinalIgnoreCase)) | ||||||
| { | ||||||
| forced = char.ToUpperInvariant(SdkRoot[0]); | ||||||
| } | ||||||
|
|
||||||
| if (forced != SdkRoot[0]) | ||||||
| { | ||||||
| Result = forced + SdkRoot.Substring(1); | ||||||
| Log.LogMessage(MessageImportance.High, | ||||||
| "Forced NetCoreSdkRoot drive casing '{0}' -> '{1}' via NetCoreSdkRootDriveCasingOverride='{2}' (#14026 workaround).", | ||||||
| SdkRoot[0], forced, Override); | ||||||
| } | ||||||
|
|
||||||
| return true; | ||||||
| } | ||||||
|
|
||||||
| char registeredDrive = GetRegisteredSdkDrive(SdkRoot); | ||||||
| // Casing-only guard: only adopt the probed drive when it is the SAME letter as the SDK's | ||||||
| // (differing solely in case). This can never change the actual drive, only its casing. | ||||||
| if (registeredDrive != '\0' && | ||||||
| registeredDrive != SdkRoot[0] && | ||||||
| char.ToUpperInvariant(registeredDrive) == char.ToUpperInvariant(SdkRoot[0])) | ||||||
| { | ||||||
| Result = registeredDrive + SdkRoot.Substring(1); | ||||||
| Log.LogMessage(MessageImportance.High, | ||||||
| "Normalized NetCoreSdkRoot drive casing '{0}' -> '{1}' to match the SDK task host (#14026 workaround).", | ||||||
| SdkRoot[0], registeredDrive); | ||||||
| } | ||||||
| } | ||||||
| catch (Exception ex) | ||||||
| { | ||||||
| Log.LogMessage(MessageImportance.Low, "NormalizeSdkRootDriveCasing skipped: {0}", ex.Message); | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: if it fails log the whole dang stack :)
Suggested change
|
||||||
| } | ||||||
|
|
||||||
| return true; | ||||||
| } | ||||||
|
|
||||||
| // Returns the volume-registered drive letter for the SDK, derived the same way the .NET task host | ||||||
| // child does: by launching the SDK's dotnet host and reading the drive-letter casing | ||||||
| // GetModuleFileNameW(NULL) - the API behind Environment.ProcessPath - resolves for that process. | ||||||
| // Returns '\0' if it cannot be determined. | ||||||
| private char GetRegisteredSdkDrive(string sdkRoot) | ||||||
| { | ||||||
| char driveKey = sdkRoot[0]; | ||||||
| lock (s_lock) | ||||||
| { | ||||||
| if (s_driveCache.TryGetValue(driveKey, out char cached)) | ||||||
| { | ||||||
| return cached; | ||||||
| } | ||||||
|
|
||||||
| char probed = ProbeSdkDrive(sdkRoot); | ||||||
| s_driveCache[driveKey] = probed; | ||||||
| return probed; | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| // Finds the dotnet host (dotnet.exe) ON THE SAME VOLUME as the SDK, so its drive-letter casing | ||||||
| // matches what the task host child will report. Prefers the dotnet root derived from the SDK | ||||||
| // directory (<dotnetRoot>\sdk\<ver>\ -> <dotnetRoot>\dotnet.exe), then DOTNET_HOST_PATH but only | ||||||
| // if it is on the same drive letter as the SDK. Returns null if none exists. | ||||||
|
Comment on lines
+374
to
+375
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's the case for DOTNET_HOST_PATH being needed? |
||||||
| private static string LocateDotnetHost(string sdkRoot) | ||||||
| { | ||||||
| try | ||||||
| { | ||||||
| // sdkRoot = <dotnetRoot>\sdk\<version>\ -> up two directories = <dotnetRoot>. | ||||||
| string sdkVersionDir = sdkRoot.TrimEnd('\\', '/'); | ||||||
| string sdkDir = Path.GetDirectoryName(sdkVersionDir); | ||||||
| string dotnetRoot = Path.GetDirectoryName(sdkDir); | ||||||
| if (!string.IsNullOrEmpty(dotnetRoot)) | ||||||
| { | ||||||
| string candidate = Path.Combine(dotnetRoot, "dotnet.exe"); | ||||||
| if (File.Exists(candidate)) | ||||||
| { | ||||||
| return candidate; | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| catch | ||||||
| { | ||||||
| // fall through to DOTNET_HOST_PATH | ||||||
| } | ||||||
|
|
||||||
| string fromEnv = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH"); | ||||||
| if (!string.IsNullOrEmpty(fromEnv) && fromEnv.Length >= 2 && fromEnv[1] == ':' && | ||||||
| char.ToUpperInvariant(fromEnv[0]) == char.ToUpperInvariant(sdkRoot[0]) && | ||||||
| File.Exists(fromEnv) && | ||||||
| fromEnv.EndsWith("dotnet.exe", StringComparison.OrdinalIgnoreCase)) | ||||||
| { | ||||||
| return fromEnv; | ||||||
| } | ||||||
|
|
||||||
| return null; | ||||||
| } | ||||||
|
|
||||||
| private char ProbeSdkDrive(string sdkRoot) | ||||||
| { | ||||||
| // Launch the dotnet host (NOT the SDK's MSBuild.exe apphost, which needs a matching runtime | ||||||
| // that may not be resolvable in this process's environment). dotnet.exe is the runtime host | ||||||
| // itself, lives on the same volume as the SDK, and always launches; its own process-path | ||||||
| // drive-letter casing (GetModuleFileNameW) therefore equals the casing the .NET task host | ||||||
| // child will report for the SDK directory. | ||||||
| string dotnetExe = LocateDotnetHost(sdkRoot); | ||||||
| if (dotnetExe == null) | ||||||
| { | ||||||
| Log.LogMessage(MessageImportance.Low, "NormalizeSdkRootDriveCasing: dotnet host not found for SDK '{0}'.", sdkRoot); | ||||||
| return '\0'; | ||||||
| } | ||||||
|
|
||||||
| string tasksDll = Path.Combine(sdkRoot, "Microsoft.Build.Tasks.Core.dll"); | ||||||
| string tempDir = Path.Combine(Path.GetTempPath(), "sdkdrvprobe_" + Guid.NewGuid().ToString("N")); | ||||||
| Directory.CreateDirectory(tempDir); | ||||||
| string proj = Path.Combine(tempDir, "probe.proj"); | ||||||
|
|
||||||
| // Bare <Project> (no Sdk attribute / no imports) so it does NOT re-import Arcade and cannot | ||||||
| // recurse into this workaround. The inline task P/Invokes GetModuleFileNameW(NULL) - the exact | ||||||
| // API behind the child task host's Environment.ProcessPath - so we read the same drive-letter | ||||||
|
Comment on lines
+430
to
+431
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. but we can't just call |
||||||
| // casing the child will. No CDATA in the inner task code (it would close the outer CDATA). | ||||||
| var probe = new StringBuilder(); | ||||||
| probe.Append("<Project>"); | ||||||
| probe.Append("<UsingTask TaskName=\"PrintProcPath\" TaskFactory=\"RoslynCodeTaskFactory\" AssemblyFile=\"").Append(tasksDll).Append("\">"); | ||||||
| probe.Append("<Task><Code Type=\"Class\" Language=\"cs\">\n"); | ||||||
| probe.Append("using System;\n"); | ||||||
| probe.Append("using System.Runtime.InteropServices;\n"); | ||||||
| probe.Append("using System.Text;\n"); | ||||||
| probe.Append("using Microsoft.Build.Framework;\n"); | ||||||
| probe.Append("using Microsoft.Build.Utilities;\n"); | ||||||
| probe.Append("public class PrintProcPath : Task {\n"); | ||||||
| probe.Append(" [DllImport(\"kernel32.dll\", CharSet = CharSet.Unicode, SetLastError = true)]\n"); | ||||||
| probe.Append(" static extern uint GetModuleFileNameW(IntPtr hModule, StringBuilder lpFilename, uint nSize);\n"); | ||||||
| probe.Append(" public override bool Execute() {\n"); | ||||||
| probe.Append(" var sb = new StringBuilder(1024);\n"); | ||||||
| probe.Append(" GetModuleFileNameW(IntPtr.Zero, sb, 1024u);\n"); | ||||||
| probe.Append(" Log.LogMessage(MessageImportance.High, \"").Append(Marker).Append("\" + sb.ToString());\n"); | ||||||
| probe.Append(" return true;\n"); | ||||||
| probe.Append(" }\n"); | ||||||
| probe.Append("}\n"); | ||||||
| probe.Append("</Code></Task></UsingTask>"); | ||||||
| probe.Append("<Target Name=\"P\"><PrintProcPath /></Target>"); | ||||||
| probe.Append("</Project>"); | ||||||
| File.WriteAllText(proj, probe.ToString()); | ||||||
|
|
||||||
| try | ||||||
| { | ||||||
| var psi = new ProcessStartInfo | ||||||
| { | ||||||
| FileName = dotnetExe, | ||||||
| Arguments = "msbuild \"" + proj + "\" -nologo -nodeReuse:false -v:m -t:P", | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Style nit: use short form arguments only on the interactive command line
Suggested change
|
||||||
| UseShellExecute = false, | ||||||
| RedirectStandardOutput = true, | ||||||
| RedirectStandardError = true, | ||||||
| CreateNoWindow = true, | ||||||
| WorkingDirectory = tempDir, | ||||||
| }; | ||||||
|
|
||||||
| using (var p = Process.Start(psi)) | ||||||
| { | ||||||
| string stdout = p.StandardOutput.ReadToEnd(); | ||||||
| string stderr = p.StandardError.ReadToEnd(); | ||||||
| if (!p.WaitForExit(120000)) | ||||||
| { | ||||||
| try { p.Kill(); } catch { } | ||||||
| Log.LogMessage(MessageImportance.Low, "NormalizeSdkRootDriveCasing: probe timed out launching '{0}'.", dotnetExe); | ||||||
| return '\0'; | ||||||
| } | ||||||
|
|
||||||
|
Comment on lines
+470
to
+480
|
||||||
| int idx = stdout.IndexOf(Marker, StringComparison.Ordinal); | ||||||
| if (idx >= 0) | ||||||
| { | ||||||
| string resolved = stdout.Substring(idx + Marker.Length).TrimStart(); | ||||||
| int nl = resolved.IndexOfAny(new[] { '\r', '\n' }); | ||||||
| if (nl >= 0) | ||||||
| { | ||||||
| resolved = resolved.Substring(0, nl); | ||||||
| } | ||||||
|
|
||||||
| if (resolved.Length >= 2 && resolved[1] == ':') | ||||||
| { | ||||||
| return resolved[0]; | ||||||
| } | ||||||
| } | ||||||
| else | ||||||
| { | ||||||
| Log.LogMessage(MessageImportance.Low, | ||||||
| "NormalizeSdkRootDriveCasing: probe produced no result (exit {0}). stderr=[{1}]", | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we don't ever write to stderr, do we? I think stdout would likely be more informative |
||||||
| p.ExitCode, stderr.Replace("\r", "").Replace("\n", " | ")); | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| finally | ||||||
| { | ||||||
| try { Directory.Delete(tempDir, recursive: true); } catch { } | ||||||
| } | ||||||
|
|
||||||
| return '\0'; | ||||||
| } | ||||||
| } | ||||||
| ]]></Code> | ||||||
| </Task> | ||||||
| </UsingTask> | ||||||
|
|
||||||
| <Target Name="NormalizeNetCoreSdkRootCasing" | ||||||
| Condition="'$(MSBuildRuntimeType)' == 'Full' and '$(NetCoreSdkRoot)' != '' and '$(DisableNetCoreSdkRootDriveCasingWorkaround)' != 'true'"> | ||||||
| <NormalizeSdkRootDriveCasing SdkRoot="$(NetCoreSdkRoot)" Override="$(NetCoreSdkRootDriveCasingOverride)"> | ||||||
| <Output TaskParameter="Result" PropertyName="NetCoreSdkRoot" /> | ||||||
| </NormalizeSdkRootDriveCasing> | ||||||
| </Target> | ||||||
|
|
||||||
| </Project> | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style nit, no need to fix here (since it'd probably require package authoring changes): Any time there's anything other than a short snippet, I prefer using the
Sourceattribute and putting the code in a.csfile (so it can get at least minimal syntax highlighting).