diff --git a/AGENTS.develop.md b/AGENTS.develop.md
index 3ba2eddf..b68a6d40 100644
--- a/AGENTS.develop.md
+++ b/AGENTS.develop.md
@@ -241,6 +241,7 @@ Never `unwrap_or_default()` a store error on a search path — "no results" and
## Changelog highlights (recent)
+- **v1.5.31 (pending)** — scip-csharp VS-MSBuild restore fallback: NETSDK1045-class CLI SDK gaps (hollow SDK dir, net10-only-in-VS) retry via vswhere + `MSBuild -t:restore`, and MSBuild registration picks a VS instance when no CLI SDK can target the repo
- **v1.5.31 (pending)** — serve TUI status: `Indexing` only for real text indexing (pure SCIP rebuilds drive the C#/TS columns only); resting repos read `Idle` again
- **v1.5.16 (pending)** — C# restore-pending retries 20×60s → 3 attempts with exponential backoff; restore exhaustion renders as yellow C#⚠ partial on every serve path instead of a red error
- **v1.5.6 (pending)** — scip-csharp demotes MSBuild check advisories + BuildHost shutdown chatter from index warnings
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b6f950d3..724cab8f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,8 @@ finalized in place with a date — no renaming/migration step needed.
### Changed
+- **scip-csharp retries the solution restore with the VS MSBuild when the CLI SDK set cannot target the repo.** When `dotnet restore` fails on SDK resolution (NETSDK1045 — CLI SDKs older than the repo's TFM — or an unsatisfiable `global.json`; detection reads stdout AND stderr, because NETSDK1045 prints to stdout on current hosts), the helper locates the newest VS MSBuild via vswhere and retries the restore once with `MSBuild -t:restore` — VS ships its own MSBuild + SDK targets independent of `C:\Program Files\dotnet`, so it restores solutions the CLI cannot even evaluate. Timeout keeps the existing pending-marker/gated-exit-75 contract (a hung VS restore is never killed); on VS failure the per-project fallback still runs. The workspace-load MSBuild registration is now capability-aware too: when every CLI SDK instance is older than the repo's raw-text TFM floor (max over all `TargetFrameworks` + the nearest `global.json` major), the highest VS instance is registered instead — otherwise an SDK 8 instance would evaluate net10 projects into certain NETSDK1045 load failures even with fresh assets. On healthy machines the choice is unchanged (single-SDK machines byte-identical; multi-SDK machines now deterministically take the highest qualifying SDK, was first-enumerated). Motivated by the 2026-10-15 field incident: a hollow `10.0.101` SDK dir left the CLI blind to net10 on a machine where VS built fine, degrading every net10 repo's symbol index to "0 projects loaded".
+
- **The serve TUI status column no longer shows `Indexing` for a pure symbol (SCIP) rebuild.** Only text-indexing flows (startup warmup, file-watcher batches, explicit reindex) light the general repo label; a C#/TypeScript symbol rebuild — including a long restore-pending retry loop — drives only the language columns, so a busy C# pass can no longer read as "the normal index is stuck behind the SCIP index". A queued phase-2 rebuild stays visible in the C# column only. The `/status` wire vocabulary is unchanged. A repo whose index is on disk but which is not loaded renders `Idle` again — the interim `idx` rendering read as active indexing on done, non-SCIP repos.
## [1.5.30]
diff --git a/helpers/csharp/MsbuildRestore.cs b/helpers/csharp/MsbuildRestore.cs
new file mode 100644
index 00000000..b011207d
--- /dev/null
+++ b/helpers/csharp/MsbuildRestore.cs
@@ -0,0 +1,305 @@
+namespace ScipCsharp;
+
+///
+/// One installable MSBuild candidate for the capability-aware instance
+/// choice: either the MSBuild of a .NET SDK install (CLI-visible) or of a
+/// Visual Studio instance (bundled with VS, independent of
+/// C:\Program Files\dotnet). Dependency-free so the decision logic
+/// is unit-testable without spinning up real installs.
+///
+internal readonly record struct MsbuildCandidate(Version Version, bool IsDotNetSdk);
+
+internal static class MsbuildRestore
+{
+ ///
+ /// vswhere ships in the fixed VS Installer location on every Windows
+ /// machine; probing it anywhere else just multiplies failure modes.
+ ///
+ private const string VsWherePath = @"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe";
+
+ // NETSDK1045: "The current .NET SDK does not support targeting .NET X.0".
+ private const string SdkDoesNotSupportTargeting = "does not support targeting";
+ // Current hosts: "A compatible .NET SDK was not found" (global.json
+ // unsatisfiable); legacy hosts worded it "A compatible installed .NET
+ // SDK for global.json at ... was not found." Match both — neither
+ // phrasing contains the other.
+ private const string SdkGlobalJsonNotFoundModern = "compatible .NET SDK";
+ private const string SdkGlobalJsonNotFoundLegacy = "compatible installed .NET SDK";
+
+ /// vswhere probe bound — generous for a local EXE, still bounded.
+ private const int VsWhereWaitMs = 10_000;
+
+ ///
+ /// True when a dotnet restore failed on SDK resolution — the
+ /// CLI-visible SDK set cannot target the repo's TFM (NETSDK1045) or
+ /// cannot satisfy global.json — rather than on packages. These are the
+ /// failures a Visual Studio install can still recover: VS ships its own
+ /// MSBuild + SDK targets, blind spots of C:\Program Files\dotnet
+ /// included (2026-10-15 incident: a hollow 10.0.101 SDK dir left the
+ /// CLI unable to target net10 while VS built fine). Feed/auth failures
+ /// (NU11xx) deliberately do NOT match — VS restores through the same
+ /// NuGet stack and would fail identically, so retrying those buys a
+ /// second slow failure.
+ ///
+ internal static bool IsSdkResolutionFailure(string restoreOutput) =>
+ restoreOutput.Contains(SdkDoesNotSupportTargeting, StringComparison.OrdinalIgnoreCase)
+ || restoreOutput.Contains(SdkGlobalJsonNotFoundModern, StringComparison.OrdinalIgnoreCase)
+ || restoreOutput.Contains(SdkGlobalJsonNotFoundLegacy, StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// Full path of the newest VS MSBuild via vswhere, or null when vswhere,
+ /// VS, or the MSBuild component is absent (non-Windows included).
+ /// Best effort by design: a probe failure must never break the restore
+ /// path it is meant to improve. -prerelease includes Insiders
+ /// channels; -find (vswhere ≥ 2.8) returns MSBuild.exe paths
+ /// directly, newest instance only via -latest.
+ ///
+ internal static string? LocateVsMsbuild()
+ {
+ if (!OperatingSystem.IsWindows() || !File.Exists(VsWherePath))
+ {
+ return null;
+ }
+ try
+ {
+ var psi = new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = VsWherePath,
+ Arguments = "-latest -prerelease -products * -requires Microsoft.Component.MSBuild " +
+ "-find MSBuild\\**\\Bin\\MSBuild.exe",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ };
+ using var process = System.Diagnostics.Process.Start(psi);
+ if (process is null)
+ {
+ return null;
+ }
+ // Drain async, THEN bounded-wait — and KILL on timeout: unlike a
+ // restore (never killed — its work must survive), vswhere is a
+ // local metadata probe whose death costs one failed probe. The
+ // kill also closes the pipes, so the stdout read cannot block.
+ var stdoutTask = process.StandardOutput.ReadToEndAsync();
+ _ = process.StandardError.ReadToEndAsync();
+ if (!process.WaitForExit(VsWhereWaitMs))
+ {
+ try
+ {
+ process.Kill(entireProcessTree: true);
+ }
+ catch
+ {
+ // Already exiting — nothing to bound anymore.
+ }
+ return null;
+ }
+ var path = ParseFirstVswherePath(stdoutTask.GetAwaiter().GetResult());
+ return path is not null && File.Exists(path) ? path : null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ /// First non-blank line of vswhere -find output.
+ internal static string? ParseFirstVswherePath(string vswhereOutput)
+ {
+ foreach (var raw in vswhereOutput.Split('\n'))
+ {
+ var line = raw.Trim().TrimEnd('\r');
+ if (line.Length > 0)
+ {
+ return line;
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// The highest .NET major the solution's projects provably target:
+ /// max over every .csproj's first TargetFramework(s) raw-text
+ /// match (raw text — this runs BEFORE any MSBuild evaluation, which is
+ /// the whole point) and over global.json's pinned SDK version when one
+ /// sits at or above the solution dir. null when nothing parseable was
+ /// found — callers must then keep the default instance choice, because
+ /// an unknown floor proves nothing.
+ ///
+ internal static int? RequiredSdkMajorFloor(string? solutionPath)
+ {
+ if (solutionPath is null || !File.Exists(solutionPath))
+ {
+ return null;
+ }
+ var solutionDir = Path.GetDirectoryName(Path.GetFullPath(solutionPath)) ?? ".";
+ int? floor = null;
+ foreach (var projectPath in EnumerateSolutionProjects(solutionPath, solutionDir))
+ {
+ var major = TargetFrameworkMajor(ReadTextBestEffort(projectPath));
+ if (major is not null && (floor is null || major > floor))
+ {
+ floor = major;
+ }
+ }
+ var globalJsonMajor = GlobalJsonSdkMajor(solutionDir);
+ if (globalJsonMajor is not null && (floor is null || globalJsonMajor > floor))
+ {
+ floor = globalJsonMajor;
+ }
+ return floor;
+ }
+
+ ///
+ /// Which install to register MSBuild from: the highest .NET SDK instance
+ /// that can target (matching the
+ /// "register the highest available MSBuild instance" intent, unlike the
+ /// historical first-match), falling back to the highest VS instance when
+ /// no SDK instance qualifies — VS bundles its own SDK targets, so it can
+ /// evaluate TFMs the installed CLI SDKs cannot. The VS path is the
+ /// documented net472-BuildHost-crash risk, accepted ONLY when the SDK
+ /// path is certain to fail the load anyway. Returns the index of the
+ /// chosen candidate within and whether it
+ /// is an SDK instance (for logging the flip explicitly).
+ ///
+ internal static (int Index, bool IsSdk) ChooseMsBuildInstance(
+ IReadOnlyList candidates,
+ int? requiredMajor)
+ {
+ var sdkCandidates = candidates
+ .Select((c, i) => (Candidate: c, Index: i))
+ .Where(e => e.Candidate.IsDotNetSdk)
+ .OrderByDescending(e => e.Candidate.Version)
+ .ToList();
+ if (sdkCandidates.Count > 0)
+ {
+ var qualifying = requiredMajor is null
+ ? sdkCandidates[0]
+ : sdkCandidates.FirstOrDefault(e => e.Candidate.Version.Major >= requiredMajor);
+ if (!qualifying.Equals(default))
+ {
+ return (qualifying.Index, true);
+ }
+ }
+ // No SDK instance can target the repo (or none exists): highest
+ // instance overall — a VS install when present, else whatever the
+ // SDK set offered (preserves the old behaviour as the last resort).
+ var overall = candidates
+ .Select((c, i) => (Candidate: c, Index: i))
+ .OrderByDescending(e => e.Candidate.Version)
+ .First();
+ return (overall.Index, overall.Candidate.IsDotNetSdk);
+ }
+
+ private static IEnumerable EnumerateSolutionProjects(string solutionPath, string solutionDir)
+ {
+ string[] lines;
+ try
+ {
+ lines = File.ReadAllLines(solutionPath);
+ }
+ catch
+ {
+ yield break;
+ }
+ // Project("{GUID}") = "Name", "Relative\Path.csproj", "{GUID}"
+ foreach (var line in lines)
+ {
+ var trimmed = line.Trim();
+ if (!trimmed.StartsWith("Project(", StringComparison.Ordinal))
+ {
+ continue;
+ }
+ var parts = trimmed.Split(',');
+ if (parts.Length < 2)
+ {
+ continue;
+ }
+ var pathPart = parts[1].Trim();
+ if (pathPart.Length < 2 || !pathPart.StartsWith('"') || !pathPart.EndsWith('"'))
+ {
+ continue;
+ }
+ var relative = pathPart[1..^1];
+ if (relative.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase))
+ {
+ yield return Path.GetFullPath(
+ relative.Replace('\\', Path.DirectorySeparatorChar), Path.GetFullPath(solutionDir));
+ }
+ }
+ }
+
+ private static string? ReadTextBestEffort(string path)
+ {
+ try
+ {
+ return File.Exists(path) ? File.ReadAllText(path) : null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// The HIGHEST netX.Y major in raw project text — a floor wants the max,
+ /// so a net8.0;net10.0 multi-target floors at 10 (unlike the EOL
+ /// skip-guard, whose first-TFM stance serves a different purpose).
+ /// netstandard/netcoreapp deliberately do not match — they load under
+ /// any modern SDK, and EOL ones are skipped anyway. The minor dot is
+ /// REQUIRED: the extension-less .NET Framework spellings (net48,
+ /// net472) must not raise the floor — an SDK loads those from reference
+ /// assemblies, and a "48" floor would flip healthy SDK+VS machines to
+ /// the VS instance for no reason.
+ ///
+ private static int? TargetFrameworkMajor(string? projectText)
+ {
+ if (projectText is null)
+ {
+ return null;
+ }
+ var match = System.Text.RegularExpressions.Regex.Match(
+ projectText, @"([^<]+)");
+ if (!match.Success)
+ {
+ return null;
+ }
+ int? floor = null;
+ foreach (var raw in match.Groups[1].Value.Split(';'))
+ {
+ var version = System.Text.RegularExpressions.Regex.Match(raw.Trim(), @"^net(\d+)\.");
+ if (version.Success && int.TryParse(version.Groups[1].Value, out var major)
+ && (floor is null || major > floor))
+ {
+ floor = major;
+ }
+ }
+ return floor;
+ }
+
+ ///
+ /// Major of the SDK version pinned by the nearest global.json walking
+ /// up from the solution dir — NuGet's own lookup order. rollForward
+ /// never lowers the floor, so the pinned major is the safe bound.
+ ///
+ private static int? GlobalJsonSdkMajor(string solutionDir)
+ {
+ var dir = Path.GetFullPath(solutionDir);
+ while (true)
+ {
+ var globalJson = Path.Combine(dir, "global.json");
+ if (File.Exists(globalJson))
+ {
+ var match = System.Text.RegularExpressions.Regex.Match(
+ ReadTextBestEffort(globalJson) ?? "", @"""version""\s*:\s*""(\d+)\.");
+ return match.Success && int.TryParse(match.Groups[1].Value, out var major) ? major : null;
+ }
+ var parent = Directory.GetParent(dir)?.FullName;
+ if (string.IsNullOrEmpty(parent) || string.Equals(parent, dir, StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+ dir = parent;
+ }
+ }
+}
diff --git a/helpers/csharp/Program.cs b/helpers/csharp/Program.cs
index 846d97da..b1fce4fd 100644
--- a/helpers/csharp/Program.cs
+++ b/helpers/csharp/Program.cs
@@ -86,7 +86,7 @@ private static async Task RunServeAsync(string[] args)
var parsed = ParseServeArgs(args);
if (parsed is null) return 1;
- if (!TryRegisterMsBuild(out var regErr)) { await Console.Error.WriteLineAsync(regErr).ConfigureAwait(false); return 1; }
+ if (!TryRegisterMsBuild(parsed, out var regErr)) { await Console.Error.WriteLineAsync(regErr).ConfigureAwait(false); return 1; }
// A resident workspace must not come up mid-restore: the host CACHES
// its find-refs answers, so degraded answers from a workspace loaded
@@ -167,7 +167,7 @@ private static async Task RunIndexAsync(string[] args)
var parsed = ParseIndexArgs(args);
if (parsed is null) return 1;
- if (!TryRegisterMsBuild(out var regErr)) { await Console.Error.WriteLineAsync(regErr).ConfigureAwait(false); return 1; }
+ if (!TryRegisterMsBuild(parsed.Value.SolutionPath, out var regErr)) { await Console.Error.WriteLineAsync(regErr).ConfigureAwait(false); return 1; }
using var workspace = CreateTolerantWorkspace();
@@ -238,7 +238,7 @@ private static async Task RunFindRefsAsync(string[] args)
var parsed = ParseFindRefsArgs(args);
if (parsed is null) return 1;
- if (!TryRegisterMsBuild(out var regErr)) { await Console.Error.WriteLineAsync(regErr).ConfigureAwait(false); return 1; }
+ if (!TryRegisterMsBuild(parsed.Value.SolutionPath, out var regErr)) { await Console.Error.WriteLineAsync(regErr).ConfigureAwait(false); return 1; }
using var workspace = CreateTolerantWorkspace();
@@ -299,7 +299,7 @@ private static async Task RunBatchFindRefsAsync(string[] args)
var parsed = ParseBatchFindRefsArgs(args);
if (parsed is null) return 1;
- if (!TryRegisterMsBuild(out var regErr)) { await Console.Error.WriteLineAsync(regErr).ConfigureAwait(false); return 1; }
+ if (!TryRegisterMsBuild(parsed.Value.SolutionPath, out var regErr)) { await Console.Error.WriteLineAsync(regErr).ConfigureAwait(false); return 1; }
using var workspace = CreateTolerantWorkspace();
@@ -534,8 +534,51 @@ private static List RestoreSolution(
{
return [];
}
- var stderr = stderrDrain.GetAwaiter().GetResult();
- var nuDetail = RestorePolicy.ExtractRestoreErrors(stderr);
+ // NETSDK1045 and friends print to STDOUT on current dotnet hosts
+ // (NU11xx detail may land on either stream) — classify over both.
+ var restoreOutput = stderrDrain.GetAwaiter().GetResult()
+ + Environment.NewLine + stdoutDrain.GetAwaiter().GetResult();
+ var nuDetail = RestorePolicy.ExtractRestoreErrors(restoreOutput);
+ if (MsbuildRestore.IsSdkResolutionFailure(restoreOutput))
+ {
+ // The CLI SDK set cannot even target this solution — packages
+ // were never the problem, so the per-project fallback (same
+ // dotnet, same SDK wall) can only re-fail per project. A
+ // Visual Studio install ships its own MSBuild + SDK targets
+ // and can restore the full solution in one go.
+ var vsMsbuild = MsbuildRestore.LocateVsMsbuild();
+ if (vsMsbuild is null)
+ {
+ Console.Error.WriteLine(
+ "[INFO] no VS MSBuild found via vswhere — the CLI SDK gap cannot be worked around.");
+ }
+ else
+ {
+ Console.Error.WriteLine(
+ "[INFO] the CLI .NET SDK set cannot target this solution — retrying the restore " +
+ $"with VS MSBuild at '{vsMsbuild}'.");
+ switch (TryRestoreSolutionWithVsMsbuild(
+ vsMsbuild, solutionPath, timeoutSeconds, gateRestorePending))
+ {
+ case VsRestoreOutcome.Restored:
+ Console.Error.WriteLine(
+ "[INFO] VS MSBuild restore succeeded — full solution assets restored.");
+ return [];
+ case VsRestoreOutcome.StillRunningUngated:
+ // The VS restore keeps writing obj/ in the
+ // background; per-project dotnet restores would
+ // race those writes, so degrade with an honest
+ // per-project reason instead.
+ return loadable.Select(e => new ProjectRestoreFailure(
+ Path.GetFileNameWithoutExtension(e.RelativePath),
+ e.RelativePath,
+ "not attempted — a VS MSBuild restore is still running")).ToList();
+ }
+ // Failed → the per-project fallback below still runs: in a
+ // mixed-TFM solution it recovers the projects the CLI CAN
+ // target.
+ }
+ }
var fallback = RestorePerProjectFallback(
solutionPath, solutionDir: Path.GetDirectoryName(solutionPath) ?? ".",
loadable, timeoutSeconds, gateRestorePending);
@@ -579,6 +622,83 @@ private static List RestoreSolution(
}
}
+ /// Outcome of the one-shot VS-MSBuild solution restore.
+ private enum VsRestoreOutcome
+ {
+ Restored,
+ Failed,
+ /// Timed out ungated: the restore keeps running, serve degrades.
+ StillRunningUngated,
+ }
+
+ ///
+ /// One bounded attempt to restore the solution with a VS MSBuild instead
+ /// of the CLI dotnet — the fallback for SDK-resolution failures, where
+ /// the machine's Visual Studio can do what its CLI SDK set cannot. Same
+ /// contract as the dotnet restore above: never killed on timeout (the
+ /// marker gates later runs), gated commands exit
+ /// instead of building a degraded
+ /// index. -nr:false because the helper is a short-lived subprocess
+ /// on a hot serve loop and must not leave MSBuild worker nodes behind.
+ ///
+ private static VsRestoreOutcome TryRestoreSolutionWithVsMsbuild(
+ string msbuildPath,
+ string solutionPath,
+ int timeoutSeconds,
+ bool gateRestorePending)
+ {
+ try
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = msbuildPath,
+ Arguments = $"\"{solutionPath}\" -t:restore -nologo -v:q -nr:false",
+ RedirectStandardError = true,
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ };
+ using var process = Process.Start(psi);
+ if (process is null)
+ {
+ return VsRestoreOutcome.Failed;
+ }
+ _ = process.StandardOutput.ReadToEndAsync();
+ var stderrDrain = process.StandardError.ReadToEndAsync();
+ if (!process.WaitForExit(timeoutSeconds * 1000))
+ {
+ RestorePendingMarker.Write(
+ solutionPath, process.Id, process.StartTime.ToUniversalTime());
+ if (gateRestorePending)
+ {
+ Console.Error.WriteLine(
+ "[RESTORE-PENDING] the VS MSBuild restore is still running — exiting " +
+ $"{ExitCodes.RestorePending} without building an index; retry when it finishes.");
+ throw new RestorePendingException();
+ }
+ Console.Error.WriteLine(RestorePolicy.StillRunningWarning(timeoutSeconds, "VS MSBuild restore"));
+ return VsRestoreOutcome.StillRunningUngated;
+ }
+ if (process.ExitCode == 0)
+ {
+ return VsRestoreOutcome.Restored;
+ }
+ Console.Error.WriteLine(
+ "[INFO] VS MSBuild restore failed" +
+ RestorePolicy.ExtractRestoreErrors(stderrDrain.GetAwaiter().GetResult()));
+ return VsRestoreOutcome.Failed;
+ }
+ catch (RestorePendingException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine(
+ $"[INFO] VS MSBuild restore could not start ({ex.GetType().Name}: {ex.Message}).");
+ return VsRestoreOutcome.Failed;
+ }
+ }
+
///
/// The per-project fallback after a failed solution restore: NuGet is
/// all-or-nothing per restore, so one dead package fails every project —
@@ -1015,10 +1135,19 @@ private static string BuildFilteredSolution(string[] lines, HashSet skipLin
// ── MSBuild registration ─────────────────────────────────────────
///
- /// Register the highest available MSBuild instance.
- /// Returns true on success; sets on failure.
+ /// Register the MSBuild instance the workspace will evaluate through.
+ /// Capability-aware since the 2026-10-15 net10 incident: when every
+ /// CLI-visible .NET SDK is older than the repo's target frameworks
+ /// (raw TargetFramework scan + global.json floor via
+ /// ), an SDK instance
+ /// would fail every project with NETSDK1045 during evaluation — in that
+ /// case the highest VS instance wins instead, because VS bundles its own
+ /// SDK targets and can evaluate TFMs the CLI cannot. Returns true on
+ /// success; sets on failure.
///
- private static bool TryRegisterMsBuild([System.Diagnostics.CodeAnalysis.NotNullWhen(false)] out string? errorMessage)
+ private static bool TryRegisterMsBuild(
+ string? solutionPath,
+ [System.Diagnostics.CodeAnalysis.NotNullWhen(false)] out string? errorMessage)
{
// Guard against double-registration in case the function is ever called twice
// in the same process lifetime (e.g. future test harnesses, daemon mode).
@@ -1045,10 +1174,22 @@ private static bool TryRegisterMsBuild([System.Diagnostics.CodeAnalysis.NotNullW
// Prefer .NET SDK instances over Visual Studio instances.
// The VS BuildHost (net472) crashes with TypeInitializationException
- // when loading solutions that contain certain project types.
- var sdkInstance = instances
- .FirstOrDefault(i => i.DiscoveryType == DiscoveryType.DotNetSdk);
- var instance = sdkInstance ?? instances.OrderByDescending(i => i.Version).First();
+ // when loading solutions that contain certain project types — so the
+ // VS instance is only chosen when no SDK instance can even target
+ // the repo (certain NETSDK1045 load failure either way).
+ var requiredMajor = MsbuildRestore.RequiredSdkMajorFloor(solutionPath);
+ var candidates = instances
+ .Select(i => new MsbuildCandidate(i.Version, i.DiscoveryType == DiscoveryType.DotNetSdk))
+ .ToList();
+ var (index, isSdk) = MsbuildRestore.ChooseMsBuildInstance(candidates, requiredMajor);
+ var instance = instances[index];
+ if (!isSdk)
+ {
+ Console.Error.WriteLine(
+ $"[INFO] no CLI .NET SDK instance can target net{requiredMajor} projects of " +
+ $"'{solutionPath}' — registering the VS MSBuild '{instance.Name}' v{instance.Version} " +
+ "instead (VS-bundled SDK targets).");
+ }
Console.Error.WriteLine(
$"MSBuild: registering '{instance.Name}' v{instance.Version} at {instance.MSBuildPath}");
diff --git a/helpers/csharp/RestorePolicy.cs b/helpers/csharp/RestorePolicy.cs
index 7eeb9fd9..6b638818 100644
--- a/helpers/csharp/RestorePolicy.cs
+++ b/helpers/csharp/RestorePolicy.cs
@@ -233,18 +233,19 @@ public static int RestoreTimeoutSeconds()
}
///
- /// The line emitted when the bounded wait expires while
- /// dotnet restore is still running — serve mode only: gated
- /// commands exit instead. The
- /// restore is NOT killed: it keeps running in the background (the child
- /// outlives the helper), serve keeps answering from the existing
- /// workspace, and any later index/find-refs run is gated by the restore
- /// marker until the fresh assets land. INFO, never WARN: restore state
- /// must not drive the caller-facing warning icon. Pinned by test so a
- /// "killed" wording cannot sneak back in.
+ /// The line emitted when the bounded wait expires while a restore is
+ /// still running — serve mode only: gated commands exit
+ /// instead. The restore is NOT
+ /// killed: it keeps running in the background (the child outlives this
+ /// helper), serve keeps answering from the existing workspace, and any
+ /// later index/find-refs run is gated by the restore marker until the
+ /// fresh assets land. INFO, never WARN: restore state must not drive the
+ /// caller-facing warning icon. Pinned by test so a "killed" wording
+ /// cannot sneak back in. names the restore
+ /// flavor still running (dotnet restore, or the VS MSBuild fallback).
///
- public static string StillRunningWarning(int seconds) =>
- $"[INFO] dotnet restore still running after {seconds}s — serve keeps answering from the " +
+ public static string StillRunningWarning(int seconds, string tool = "dotnet restore") =>
+ $"[INFO] {tool} still running after {seconds}s — serve keeps answering from the " +
"existing workspace (degraded symbols) while restore continues in the background; " +
"index and find-refs runs are gated until it finishes and the next reindex picks up " +
$"the fresh assets. Raise with {TimeoutEnvName} if this solution legitimately needs longer.";
diff --git a/helpers/csharp/tests/MsbuildRestoreTests.cs b/helpers/csharp/tests/MsbuildRestoreTests.cs
new file mode 100644
index 00000000..fb695a64
--- /dev/null
+++ b/helpers/csharp/tests/MsbuildRestoreTests.cs
@@ -0,0 +1,235 @@
+using ScipCsharp;
+using Xunit;
+
+namespace ScipCsharp.Tests;
+
+///
+/// Unit tests for the VS-MSBuild restore fallback's pure logic: SDK-
+/// resolution failure detection (the feature's trigger — pinned against
+/// the exact host wordings seen live on 2026-10-15, incl. NETSDK1045
+/// printing to stdout), vswhere -find output parsing, the capability-aware
+/// MSBuild instance choice, and the repo TFM floor scan.
+///
+public class MsbuildRestoreTests : IDisposable
+{
+ private readonly string _root;
+
+ public MsbuildRestoreTests()
+ {
+ _root = Path.Combine(Path.GetTempPath(), "cs-msbuild-restore-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_root);
+ }
+
+ public void Dispose()
+ {
+ Directory.Delete(_root, recursive: true);
+ }
+
+ // ── IsSdkResolutionFailure ────────────────────────────────────────
+
+ [Fact]
+ public void Netsdk1045StdoutWordingTriggers()
+ {
+ // Verbatim shape of the live net99.0 probe output (stdout), the
+ // 2026-10-15 incident's failure mode.
+ var stdout =
+ "C:\\Program Files\\dotnet\\sdk\\8.0.425\\Sdks\\Microsoft.NET.Sdk\\targets\\" +
+ "Microsoft.NET.TargetFrameworkInference.targets(166,5): error NETSDK1045: " +
+ "The current .NET SDK does not support targeting .NET 10.0. Either target " +
+ ".NET 8.0 or lower, or use a version of the .NET SDK that supports .NET 10.0.";
+
+ Assert.True(MsbuildRestore.IsSdkResolutionFailure(stdout));
+ }
+
+ [Theory]
+ [InlineData("A compatible .NET SDK was not found. Install or update the .NET SDK.")] // current host
+ [InlineData("A compatible installed .NET SDK for global.json at [C:\\repo\\global.json] was not found.")] // legacy host
+ public void GlobalJsonUnsatisfiableWordingsTriggerBothHostGenerations(string line)
+ {
+ Assert.True(MsbuildRestore.IsSdkResolutionFailure(line));
+ }
+
+ [Theory]
+ [InlineData("error NU1101: Unable to find package Vendor.Internal.Base. No packages exist with this id in source(s): private-feed.")]
+ [InlineData("App.Core.csproj: error NU1301: Unable to load the service index for source https://pkgs.dev.azure.com/org/_packaging/private-feed/nuget/v3/index.json.")]
+ [InlineData("")]
+ public void FeedAndAuthFailuresDoNotTrigger(string line)
+ {
+ // VS restores through the same NuGet stack — retrying those buys a
+ // second slow failure, so they must fall through to per-project.
+ Assert.False(MsbuildRestore.IsSdkResolutionFailure(line));
+ }
+
+ // ── ParseFirstVswherePath ─────────────────────────────────────────
+
+ [Fact]
+ public void VswhereParsingSkipsBlankLines()
+ {
+ var output = "\r\nC:\\Program Files\\Microsoft Visual Studio\\18\\Professional\\MSBuild\\Current\\Bin\\MSBuild.exe\r\n\r\n";
+
+ Assert.Equal(
+ "C:\\Program Files\\Microsoft Visual Studio\\18\\Professional\\MSBuild\\Current\\Bin\\MSBuild.exe",
+ MsbuildRestore.ParseFirstVswherePath(output));
+ }
+
+ [Fact]
+ public void VswhereParsingEmptyOutputYieldsNull()
+ {
+ Assert.Null(MsbuildRestore.ParseFirstVswherePath(""));
+ }
+
+ // ── ChooseMsBuildInstance ─────────────────────────────────────────
+
+ private static MsbuildCandidate Sdk(int major) => new(new Version(major, 0), IsDotNetSdk: true);
+
+ private static MsbuildCandidate Vs(int major) => new(new Version(major, 0), IsDotNetSdk: false);
+
+ [Fact]
+ public void NoFloorKeepsHighestSdk()
+ {
+ var candidates = new[] { Sdk(8), Sdk(10), Vs(18) };
+
+ var (index, isSdk) = MsbuildRestore.ChooseMsBuildInstance(candidates, requiredMajor: null);
+
+ Assert.Equal((1, true), (index, isSdk));
+ }
+
+ [Fact]
+ public void QualifyingSdkBeatsNewerVs()
+ {
+ var candidates = new[] { Sdk(10), Vs(18) };
+
+ var (index, isSdk) = MsbuildRestore.ChooseMsBuildInstance(candidates, requiredMajor: 10);
+
+ Assert.Equal((0, true), (index, isSdk));
+ }
+
+ [Fact]
+ public void SdkOlderThanFloorFallsToVs()
+ {
+ // The incident layout: CLI SDK 8 only, repo targets net10, VS 18 present.
+ var candidates = new[] { Sdk(8), Vs(18) };
+
+ var (index, isSdk) = MsbuildRestore.ChooseMsBuildInstance(candidates, requiredMajor: 10);
+
+ Assert.Equal((1, false), (index, isSdk));
+ }
+
+ [Fact]
+ public void SdkOlderThanFloorWithoutVsKeepsSdkAsLastResort()
+ {
+ var candidates = new[] { Sdk(8) };
+
+ var (index, isSdk) = MsbuildRestore.ChooseMsBuildInstance(candidates, requiredMajor: 10);
+
+ Assert.Equal((0, true), (index, isSdk));
+ }
+
+ [Fact]
+ public void HighestQualifyingSdkWinsAmongSdkSiblings()
+ {
+ var candidates = new[] { Sdk(8), Sdk(10), Sdk(11) };
+
+ var (index, isSdk) = MsbuildRestore.ChooseMsBuildInstance(candidates, requiredMajor: 10);
+
+ Assert.Equal((2, true), (index, isSdk));
+ }
+
+ // ── RequiredSdkMajorFloor ─────────────────────────────────────────
+
+ private string WriteSolution(params (string RelativePath, string TargetFrameworks)[] projects)
+ {
+ var solutionLines = new List { "Microsoft Visual Studio Solution File, Format Version 12.00", "Global" };
+ foreach (var (relative, tfms) in projects)
+ {
+ var projectPath = Path.Combine(_root, relative.Replace('\\', Path.DirectorySeparatorChar));
+ Directory.CreateDirectory(Path.GetDirectoryName(projectPath)!);
+ File.WriteAllText(
+ projectPath,
+ $"\n \n {tfms}\n \n\n");
+ solutionLines.Insert(
+ 1,
+ $"Project(\"{{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}}\") = \"P\", \"{relative}\", \"{{GUID}}\"");
+ }
+ var solutionPath = Path.Combine(_root, "Repo.sln");
+ File.WriteAllLines(solutionPath, solutionLines);
+ return solutionPath;
+ }
+
+ [Fact]
+ public void MultiTargetListFloorsAtHighestTfm()
+ {
+ // net8.0;net10.0 must floor at 10 — first-TFM (the pre-fix stance)
+ // would floor at 8 and register an SDK 8 instance that cannot
+ // evaluate the net10 half (the exact incident class). The pinned
+ // 8.x SDK below must NOT lower it.
+ var solutionPath = WriteSolution(("src\\A\\A.csproj", "net8.0;net10.0"));
+ WriteGlobalJson(Path.Combine(_root, "global.json"), "8.0.100");
+
+ Assert.Equal(10, MsbuildRestore.RequiredSdkMajorFloor(solutionPath));
+ }
+
+ [Fact]
+ public void FloorTakesMaxAcrossProjectsAndGlobalJson()
+ {
+ var solutionPath = WriteSolution(
+ ("src\\A\\A.csproj", "net8.0"),
+ ("src\\B\\B.csproj", "net10.0"));
+ WriteGlobalJson(Path.Combine(_root, "global.json"), "10.0.303");
+
+ Assert.Equal(10, MsbuildRestore.RequiredSdkMajorFloor(solutionPath));
+ }
+
+ [Fact]
+ public void GlobalJsonAboveSolutionDirRaisesTheFloor()
+ {
+ // NuGet's own lookup order: the sln and its projects sit in a
+ // subdir, global.json at the parent — the walk-up must find the
+ // pinned 11 over the projects' net8.
+ var solutionPath = WriteSolution(("src\\A\\A.csproj", "net8.0"));
+ var repoDir = Path.Combine(_root, "repo");
+ Directory.CreateDirectory(repoDir);
+ Directory.Move(Path.Combine(_root, "src"), Path.Combine(repoDir, "src"));
+ File.Move(solutionPath, Path.Combine(repoDir, "Repo.sln"));
+ WriteGlobalJson(Path.Combine(_root, "global.json"), "11.0.100");
+
+ Assert.Equal(11, MsbuildRestore.RequiredSdkMajorFloor(Path.Combine(repoDir, "Repo.sln")));
+ }
+
+ [Fact]
+ public void NonNetTargetsAndMissingSolutionYieldNoFloor()
+ {
+ // The local global.json pins nothing parseable, so no ancestor
+ // stray file can perturb the null expectation.
+ var solutionPath = WriteSolution(("src\\A\\A.csproj", "netstandard2.0"));
+ WriteGlobalJson(Path.Combine(_root, "global.json"), "{}");
+
+ Assert.Null(MsbuildRestore.RequiredSdkMajorFloor(solutionPath));
+ Assert.Null(MsbuildRestore.RequiredSdkMajorFloor(null));
+ Assert.Null(MsbuildRestore.RequiredSdkMajorFloor(Path.Combine(_root, "absent.sln")));
+ }
+
+ [Fact]
+ public void NetFrameworkSpellingsDoNotRaiseTheFloor()
+ {
+ // net48/net472 (extension-less) must not become a "48" floor: an
+ // SDK loads them from reference assemblies, and a 48 floor would
+ // flip healthy SDK+VS machines to the VS instance for no reason.
+ // The pre-fix regex (^net(\d+)(?:\.|$)) matched net48 as major 48.
+ var net48Only = WriteSolution(("src\\Legacy\\Legacy.csproj", "net48"));
+
+ Assert.Null(MsbuildRestore.RequiredSdkMajorFloor(net48Only));
+
+ var mixed = WriteSolution(("src\\Mixed\\Mixed.csproj", "net48;net10.0"));
+
+ Assert.Equal(10, MsbuildRestore.RequiredSdkMajorFloor(mixed));
+ }
+
+ private static void WriteGlobalJson(string path, string version)
+ {
+ var content = version.StartsWith('{')
+ ? version
+ : $"{{ \"sdk\": {{ \"version\": \"{version}\" }} }}";
+ File.WriteAllText(path, content);
+ }
+}
diff --git a/helpers/csharp/tests/ScipCsharp.Tests.csproj b/helpers/csharp/tests/ScipCsharp.Tests.csproj
index 49ac02bc..bbd732b5 100644
--- a/helpers/csharp/tests/ScipCsharp.Tests.csproj
+++ b/helpers/csharp/tests/ScipCsharp.Tests.csproj
@@ -47,6 +47,7 @@
+