From 8c9e4d76cc23905ab57984a099eb39f5e1cd7c7d Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 4 Nov 2026 08:47:13 +0900 Subject: [PATCH 1/4] Prefer arm64 builds from the binary index on ARM64 Windows The index carries arm64-mswin64_140 packages next to the x64 ones, with tags assigned per platform. An ARM64 host now installs its own platform first and falls back to x64, which runs under emulation, with a warning. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +- docs/test-plan.md | 42 ++++++- src/rbmanager/BinaryIndex.cs | 63 +++++++--- src/rbmanager/Program.cs | 20 +++- tests/rbmanager.Tests/BinaryIndexTests.cs | 113 ++++++++++++++---- .../rbmanager.Tests/InstallFromIndexTests.cs | 82 ++++++++++++- tests/rbmanager.Tests/ListRemoteTests.cs | 49 +++++++- tests/rbmanager.Tests/PublishE2eTests.cs | 33 +++++ tests/rbmanager.Tests/Support/E2eSandbox.cs | 13 +- tests/rbmanager.Tests/Support/RbSandbox.cs | 6 +- 10 files changed, 375 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 7da12fa..6641927 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,14 @@ verified against the sha256 recorded in the index. An unsigned build (all dev snapshots are unsigned) installs with a warning. A zip path or URL skips the index and installs directly. +On ARM64 Windows `install` takes the arm64 build of a version or tag +and falls back to the x64 build, which runs under emulation, with a +warning when there is none. A full package name such as +`ruby-3.4.12-x64-mswin64_140` picks that build either way. + `list --remote` prints what the index currently offers for this -platform, newest first: the package name, the channel, and the tags +machine, newest first, with the arm64 builds above the x64 ones on +ARM64: the package name, the channel, the platform, and the tags `install` accepts for it. It is the counterpart of `list`, which shows what is installed, and it exists so that nothing outside rbmanager has to fetch and interpret the feed. diff --git a/docs/test-plan.md b/docs/test-plan.md index 407a958..6ba49f6 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -30,7 +30,7 @@ Command surface and contracts: | Command | Behavior | Exit | |---|---|---| | `rb setup` | Copy own exe to `\bin\rb.exe` (skip if already running from there, case-insensitive), append that dir to user PATH | 0 | -| `rb install ` | An argument that is not a URL and does not look like a zip path (no existing file, no path separator, no `.zip` suffix) is resolved through the binary index (see 4.13): pick the newest matching `x64-mswin64_140` build, download it, verify its `sha256`, warn on stderr when it is unsigned. If URL, download to `%TEMP%` first (deleted in `finally`). Zip must contain exactly one root dir named `ruby-*`; extract under `rubies`, inject the embedded `operating_system.rb` trust hook into `lib\ruby\site_ruby\rubygems\defaults\`, then switch `current` to it and ensure `current\bin` on user PATH. Refuse if already installed | 0 / 1 | +| `rb install ` | An argument that is not a URL and does not look like a zip path (no existing file, no path separator, no `.zip` suffix) is resolved through the binary index (see 4.13): pick the newest matching build of the first platform the host runs (`arm64-mswin64_140` then `x64-mswin64_140` on ARM64, `x64-mswin64_140` elsewhere, see 4.15), warn on stderr when an ARM64 host falls back to x64, download it, verify its `sha256`, warn on stderr when it is unsigned. If URL, download to `%TEMP%` first (deleted in `finally`). Zip must contain exactly one root dir named `ruby-*`; extract under `rubies`, inject the embedded `operating_system.rb` trust hook into `lib\ruby\site_ruby\rubygems\defaults\`, then switch `current` to it and ensure `current\bin` on user PATH. Refuse if already installed | 0 / 1 | | `rb list` | Installed names sorted, active one starred | 0 | | `rb use ` | Resolve query (exact or case-insensitive substring; must be unambiguous), recreate the `current` junction, ensure PATH | 0 / 1 | | `rb uninstall ` | Resolve; if active, delete the junction first and print a hint; delete the install dir recursively | 0 / 1 | @@ -505,8 +505,8 @@ Unit (`BinaryIndexTests`): Integration (`ListRemoteTests`, Serial): -121. Name, channel and the `install` tags, newest first, in aligned - columns. +121. Name, channel, platform and the `install` tags, newest first, in + aligned columns. 122. An index with nothing for this platform → empty stdout, a note on stderr, exit 0. 123. `schema: 2` fails here as it does on install. @@ -516,6 +516,42 @@ E2E (`CliE2eTests`): 124. `--remote` is the only flag `list` takes, and it takes no argument; anything else is usage and exit 2. +### 4.15 ARM64 hosts + +The index carries `arm64-mswin64_140` builds next to the x64 ones, with +tags assigned per platform. An ARM64 host prefers its own platform and +falls back to x64, which runs there under emulation. Any other host sees +only x64, as before. The host comes from +`RuntimeInformation.OSArchitecture`, which reports the machine even to an +emulated x64 process. `RBMANAGER_OS_ARCH` (an `Architecture` name) +overrides it, and `RbSandbox`/`E2eSandbox` pin it to `X64` so the other +suites stay host-independent. + +Install and `list --remote` (`BinaryIndexTests` Unit, `ListRemoteTests` +and `InstallFromIndexTests` Integration, `PublishE2eTests` Publish): + +125. `PlatformsFor(Arm64)` is arm64 then x64. X64 and X86 get x64 only. +126. On ARM64 a tag picks the arm64 build even when an x64 build of the + same tag is newer. An x64 host picks the x64 one. +127. A tag with no arm64 build falls back to x64. +128. A full x64 package name picks that build on ARM64. +129. `Available` on ARM64 lists the arm64 builds first, each platform + newest first. +130. `rb list --remote` on ARM64 lists the arm64 builds above the x64 + ones, with the platform column. +131. `rb install ` installs the older arm64 snapshot on ARM64 and the + x64 one on x64, without a warning. +132. No arm64 build for the tag on ARM64 → the x64 build installs and + stderr carries `warning: no arm64-mswin64_140 build matches '', + falling back to x64-mswin64_140 under emulation`. +133. A full x64 name on ARM64 installs that build without the warning. +144. The win-x64 AOT exe lists the arm64 builds exactly when the machine + is ARM64, so emulation does not hide the host. +145. `Pick` ranks only the builds that match the query, so the version of + any other build is never parsed. +146. An `RBMANAGER_OS_ARCH` that names no `Architecture` member + (`arm64ec`, `99`) stops with an error naming the variable. + ## 5. Execution plan Phased so each phase leaves the tree green. diff --git a/src/rbmanager/BinaryIndex.cs b/src/rbmanager/BinaryIndex.cs index 2376e5f..4af1a15 100644 --- a/src/rbmanager/BinaryIndex.cs +++ b/src/rbmanager/BinaryIndex.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Serialization; @@ -10,7 +11,32 @@ namespace RbManager; // parsing `version`, whose syntax depends on the channel. internal static class BinaryIndex { - internal const string Platform = "x64-mswin64_140"; + internal const string X64 = "x64-mswin64_140"; + internal const string Arm64 = "arm64-mswin64_140"; + + // The platforms this machine runs, native first. ARM64 Windows runs + // x64 binaries under emulation, so x64 builds back up arm64 there. + internal static string[] Platforms => PlatformsFor(HostArchitecture()); + + internal static string[] PlatformsFor(Architecture host) => + host == Architecture.Arm64 ? [Arm64, X64] : [X64]; + + // OSArchitecture reports the machine rather than the process, so + // rb-x64.exe under emulation still sees Arm64. RBMANAGER_OS_ARCH + // stands in for it in tests. + private static Architecture HostArchitecture() + { + if (Environment.GetEnvironmentVariable("RBMANAGER_OS_ARCH") is not { Length: > 0 } arch) + return RuntimeInformation.OSArchitecture; + string name = Enum.GetNames() + .FirstOrDefault(n => n.Equals(arch, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException( + $"RBMANAGER_OS_ARCH must name an Architecture such as X64 or Arm64, not '{arch}'"); + return Enum.Parse(name); + } + + internal static bool IsOn(string name, string platform) => + name.EndsWith($"-{platform}", StringComparison.OrdinalIgnoreCase); private const string DefaultUrl = "https://cache.ruby-lang.org/pub/ruby/binaries/index.json"; @@ -23,10 +49,10 @@ internal static class BinaryIndex : DefaultUrl; public static async Task Resolve(string query) => - Pick(await FetchAll(), query) ?? throw new InvalidOperationException( + Pick(await FetchAll(), query, Platforms) ?? throw new InvalidOperationException( $"no binary package matches '{query}' in the index"); - public static async Task Available() => Available(await FetchAll()); + public static async Task Available() => Available(await FetchAll(), Platforms); private static async Task> FetchAll() { @@ -62,20 +88,27 @@ internal static IndexPage Parse(string json) return page; } - // The newest match wins regardless of feed order: series tags like + // The first of `platforms` with a match wins, even over a newer build + // of a later one, because the feed assigns tags per platform. Within + // it the newest match wins regardless of feed order: series tags like // "4.0" sit on every 4.0.x release, and dev tags like "4.1-dev" on - // every snapshot of the series. - internal static Build? Pick(IEnumerable builds, string query) => + // every snapshot of the series. A full package name matches only its + // own build, so it is taken whatever its platform. + internal static Build? Pick(IEnumerable builds, string query, string[] platforms) => + Ordered(builds.Where(b => b.Tags.Contains(query, StringComparer.OrdinalIgnoreCase) || + string.Equals(b.Name, query, StringComparison.OrdinalIgnoreCase)), platforms) + .FirstOrDefault(); + + // Everything installable here, in the order Pick resolves, so a tag + // always installs the topmost line carrying it. + internal static Build[] Available(IEnumerable builds, string[] platforms) => + Ordered(builds, platforms).ToArray(); + + private static IEnumerable Ordered(IEnumerable builds, string[] platforms) => builds - .Where(b => b.Platform == Platform) - .Where(b => b.Tags.Contains(query, StringComparer.OrdinalIgnoreCase) || - string.Equals(b.Name, query, StringComparison.OrdinalIgnoreCase)) - .MaxBy(Rank); - - // Everything installable here, newest first. Same order Pick resolves - // in, so a tag always installs the topmost line carrying it. - internal static Build[] Available(IEnumerable builds) => - builds.Where(b => b.Platform == Platform).OrderByDescending(Rank).ToArray(); + .Where(b => platforms.Contains(b.Platform)) + .OrderBy(b => Array.IndexOf(platforms, b.Platform)) + .ThenByDescending(Rank); // Version, then reissue revision (SIGNING.md in ruby/actions), then // commit date, which keeps this consistent with Program.Resolve's diff --git a/src/rbmanager/Program.cs b/src/rbmanager/Program.cs index 0195de0..856a0dd 100644 --- a/src/rbmanager/Program.cs +++ b/src/rbmanager/Program.cs @@ -106,6 +106,7 @@ internal static async Task Install(string source) { Build build = await BinaryIndex.Resolve(source); Console.WriteLine($"Resolved {source} to {build.Name}"); + WarnIfEmulated(source, build.Name); if (!build.Signed) Console.Error.WriteLine($"warning: {build.Name} is not code-signed"); source = build.Url; @@ -194,15 +195,16 @@ internal static async Task ListRemote() Build[] builds = await BinaryIndex.Available(); if (builds.Length == 0) { - Console.Error.WriteLine( - $"rb: the binary index offers no {BinaryIndex.Platform} builds"); + Console.Error.WriteLine("rb: the binary index offers no " + + $"{string.Join(" or ", BinaryIndex.Platforms)} builds"); return 0; } int name = builds.Max(b => b.Name.Length); int channel = builds.Max(b => b.Channel.Length); + int platform = builds.Max(b => b.Platform.Length); foreach (Build b in builds) Console.WriteLine($"{b.Name.PadRight(name)} {b.Channel.PadRight(channel)} " + - string.Join(", ", b.Tags)); + $"{b.Platform.PadRight(platform)} " + string.Join(", ", b.Tags)); return 0; } @@ -214,6 +216,18 @@ internal static int Use(string query) return 0; } + // For a query that had nothing on this machine's own platform. A full + // package name asked for that very build, so it is not a fallback. + private static void WarnIfEmulated(string query, string name) + { + string[] platforms = BinaryIndex.Platforms; + if (string.Equals(name, query, StringComparison.OrdinalIgnoreCase)) return; + if (platforms.Skip(1).FirstOrDefault(p => BinaryIndex.IsOn(name, p)) is { } fallback) + Console.Error.WriteLine( + $"warning: no {platforms[0]} build matches '{query}', " + + $"falling back to {fallback} under emulation"); + } + internal static int Uninstall(string query) { string name = Resolve(query); diff --git a/tests/rbmanager.Tests/BinaryIndexTests.cs b/tests/rbmanager.Tests/BinaryIndexTests.cs index 8654aef..d6bfc05 100644 --- a/tests/rbmanager.Tests/BinaryIndexTests.cs +++ b/tests/rbmanager.Tests/BinaryIndexTests.cs @@ -1,3 +1,5 @@ +using System.Runtime.InteropServices; + namespace RbManager.Tests; // Plan 4.13: BinaryIndex.Parse and BinaryIndex.Pick — pure logic against @@ -5,8 +7,11 @@ namespace RbManager.Tests; [Trait("Category", "Unit")] public class BinaryIndexTests { + private static readonly string[] X64Host = BinaryIndex.PlatformsFor(Architecture.X64); + private static readonly string[] Arm64Host = BinaryIndex.PlatformsFor(Architecture.Arm64); + private static Build Make(string name, string version, string channel = "release", - int? revision = 0, string[]? tags = null, string platform = BinaryIndex.Platform, + int? revision = 0, string[]? tags = null, string platform = BinaryIndex.X64, string? commit = null, string? commitDate = null, string? publishedAt = null) => new() { Name = name, @@ -60,7 +65,7 @@ public void Parse_PublishedShape_PopulatesEveryKey() Assert.Equal("dev", b.Channel); Assert.Null(b.Revision); Assert.Contains("ruby-dev", b.Tags); - Assert.Equal(BinaryIndex.Platform, b.Platform); + Assert.Equal(BinaryIndex.X64, b.Platform); Assert.Equal("313063a97245ec48f125180346f589b43403ea8dc1afe98de0309f7e2baefd6b", b.Sha256); Assert.Equal(29021956, b.Size); Assert.Equal("2026-08-21", b.CommitDate); @@ -87,9 +92,9 @@ public void Pick_SeriesTag_PicksHighestVersion_RegardlessOfOrder() Make("ruby-4.0.5-x64-mswin64_140", "4.0.5", tags: ["4.0.5", "4.0", "4"]), ]; - Assert.Equal("ruby-4.0.5-x64-mswin64_140", BinaryIndex.Pick(builds, "4.0")!.Name); + Assert.Equal("ruby-4.0.5-x64-mswin64_140", BinaryIndex.Pick(builds, "4.0", X64Host)!.Name); Assert.Equal("ruby-4.0.5-x64-mswin64_140", - BinaryIndex.Pick(builds.Reverse(), "4")!.Name); + BinaryIndex.Pick(builds.Reverse(), "4", X64Host)!.Name); } [Fact] // case 102: a reissue supersedes by revision, not by feed order @@ -102,9 +107,10 @@ public void Pick_SameVersion_PicksHighestRevision() tags: ["4.0.5-1", "4.0.5", "4.0", "4"]), ]; - Assert.Equal("ruby-4.0.5-1-x64-mswin64_140", BinaryIndex.Pick(builds, "4.0.5")!.Name); Assert.Equal("ruby-4.0.5-1-x64-mswin64_140", - BinaryIndex.Pick(builds.Reverse(), "4.0.5")!.Name); + BinaryIndex.Pick(builds, "4.0.5", X64Host)!.Name); + Assert.Equal("ruby-4.0.5-1-x64-mswin64_140", + BinaryIndex.Pick(builds.Reverse(), "4.0.5", X64Host)!.Name); } [Fact] // case 103: a superseded revision stays reachable by its exact tag @@ -117,7 +123,8 @@ public void Pick_SupersededRevision_ByRevisionedTag() tags: ["4.0.5-1", "4.0.5", "4.0", "4"]), ]; - Assert.Equal("ruby-4.0.5-x64-mswin64_140", BinaryIndex.Pick(builds, "4.0.5-0")!.Name); + Assert.Equal("ruby-4.0.5-x64-mswin64_140", + BinaryIndex.Pick(builds, "4.0.5-0", X64Host)!.Name); } [Fact] // case 104: equal dev versions fall back to the commit date @@ -134,9 +141,9 @@ public void Pick_DevSeries_PicksNewestSnapshot() ]; Assert.Equal("ruby-4.1.0dev-20260821-e4462a9514-x64-mswin64_140", - BinaryIndex.Pick(builds, "ruby-dev")!.Name); + BinaryIndex.Pick(builds, "ruby-dev", X64Host)!.Name); Assert.Equal("ruby-4.1.0dev-20260821-e4462a9514-x64-mswin64_140", - BinaryIndex.Pick(builds.Reverse(), "4.1-dev")!.Name); + BinaryIndex.Pick(builds.Reverse(), "4.1-dev", X64Host)!.Name); } [Fact] // case 105: other platforms never resolve, even on a tag match @@ -146,8 +153,8 @@ public void Pick_ForeignPlatform_Filtered() tags: ["4.0.5", "4.0", "4"], platform: "arm64-mswin64_140"); Build x64 = Make("ruby-4.0.5-x64-mswin64_140", "4.0.5", tags: ["4.0.5", "4.0", "4"]); - Assert.Null(BinaryIndex.Pick([arm], "4.0.5")); - Assert.Equal(x64.Name, BinaryIndex.Pick([arm, x64], "4.0.5")!.Name); + Assert.Null(BinaryIndex.Pick([arm], "4.0.5", X64Host)); + Assert.Equal(x64.Name, BinaryIndex.Pick([arm, x64], "4.0.5", X64Host)!.Name); } [Fact] // case 106: the full package name resolves alongside the tags @@ -155,8 +162,73 @@ public void Pick_ExactName_Resolves() { Build b = Make("ruby-4.0.5-x64-mswin64_140", "4.0.5", tags: ["4.0.5", "4.0", "4"]); - Assert.Equal(b.Name, BinaryIndex.Pick([b], "ruby-4.0.5-x64-mswin64_140")!.Name); - Assert.Equal(b.Name, BinaryIndex.Pick([b], "RUBY-4.0.5-X64-MSWIN64_140")!.Name); + Assert.Equal(b.Name, BinaryIndex.Pick([b], "ruby-4.0.5-x64-mswin64_140", X64Host)!.Name); + Assert.Equal(b.Name, BinaryIndex.Pick([b], "RUBY-4.0.5-X64-MSWIN64_140", X64Host)!.Name); + } + + [Fact] // case 125: arm64 hosts add x64 after their own platform + public void PlatformsFor_Arm64PrefersItsOwnThenX64() + { + Assert.Equal([BinaryIndex.Arm64, BinaryIndex.X64], Arm64Host); + Assert.Equal([BinaryIndex.X64], X64Host); + Assert.Equal([BinaryIndex.X64], BinaryIndex.PlatformsFor(Architecture.X86)); + } + + // 3.4 has an arm64 build and a newer x64 one, 3.3 is x64 only. + private static readonly Build[] Mixed = + [ + Make("ruby-3.4.13-x64-mswin64_140", "3.4.13", tags: ["3.4.13", "3.4", "3"]), + Make("ruby-3.4.12-arm64-mswin64_140", "3.4.12", tags: ["3.4.12", "3.4", "3"], + platform: BinaryIndex.Arm64), + Make("ruby-3.3.11-x64-mswin64_140", "3.3.11", tags: ["3.3.11", "3.3", "3"]), + ]; + + [Fact] // case 126: tags are per platform, so arm64 wins over a newer x64 + public void Pick_Arm64Host_PrefersArm64OverNewerX64() + { + Assert.Equal("ruby-3.4.12-arm64-mswin64_140", + BinaryIndex.Pick(Mixed, "3.4", Arm64Host)!.Name); + Assert.Equal("ruby-3.4.12-arm64-mswin64_140", + BinaryIndex.Pick(Mixed.Reverse(), "3", Arm64Host)!.Name); + Assert.Equal("ruby-3.4.13-x64-mswin64_140", + BinaryIndex.Pick(Mixed, "3.4", X64Host)!.Name); + } + + [Fact] // case 127: a tag with no arm64 build falls back to x64 + public void Pick_Arm64Host_FallsBackToX64() + { + Assert.Equal("ruby-3.3.11-x64-mswin64_140", + BinaryIndex.Pick(Mixed, "3.3", Arm64Host)!.Name); + } + + [Fact] // case 128: a full package name bypasses the platform order + public void Pick_Arm64Host_FullX64NameIsTaken() + { + Assert.Equal("ruby-3.4.13-x64-mswin64_140", + BinaryIndex.Pick(Mixed, "ruby-3.4.13-x64-mswin64_140", Arm64Host)!.Name); + } + + [Fact] // case 145: only the matches are ranked, so another build's version never is + public void Pick_RanksOnlyTheMatches() + { + Build[] builds = [.. Mixed, Make("ruby-head-x64-mswin64_140", "head", tags: ["head"])]; + + Assert.Equal("ruby-3.4.12-arm64-mswin64_140", + BinaryIndex.Pick(builds, "3.4", Arm64Host)!.Name); + Assert.Equal("ruby-3.3.11-x64-mswin64_140", + BinaryIndex.Pick(builds, "ruby-3.3.11-x64-mswin64_140", X64Host)!.Name); + } + + [Fact] // case 129: arm64 lines first, each platform newest first + public void Available_Arm64Host_Arm64FirstThenX64() + { + Assert.Equal( + [ + "ruby-3.4.12-arm64-mswin64_140", + "ruby-3.4.13-x64-mswin64_140", + "ruby-3.3.11-x64-mswin64_140", + ], + BinaryIndex.Available(Mixed, Arm64Host).Select(b => b.Name).ToArray()); } [Fact] // case 119: the order `rb list --remote` prints, newest first @@ -175,7 +247,7 @@ public void Available_NewestFirst() "ruby-4.0.5-x64-mswin64_140", "ruby-4.0.4-x64-mswin64_140", ], - BinaryIndex.Available(builds).Select(b => b.Name).ToArray()); + BinaryIndex.Available(builds, X64Host).Select(b => b.Name).ToArray()); } [Fact] // case 120: builds this rb cannot install are not offered @@ -187,7 +259,7 @@ public void Available_ForeignPlatform_Filtered() Make("ruby-4.0.5-x64-mswin64_140", "4.0.5"), ]; - Build b = Assert.Single(BinaryIndex.Available(builds)); + Build b = Assert.Single(BinaryIndex.Available(builds, X64Host)); Assert.Equal("ruby-4.0.5-x64-mswin64_140", b.Name); } @@ -196,7 +268,7 @@ public void Pick_NoMatch_ReturnsNull() { Build b = Make("ruby-4.0.5-x64-mswin64_140", "4.0.5", tags: ["4.0.5", "4.0", "4"]); - Assert.Null(BinaryIndex.Pick([b], "3.9")); + Assert.Null(BinaryIndex.Pick([b], "3.9", X64Host)); } [Fact] // case 108: a prerelease tag never shadows the release of a series @@ -210,8 +282,8 @@ public void Pick_PrereleaseExactTagOnly() ]; Assert.Equal("ruby-4.1.0-rc1-x64-mswin64_140", - BinaryIndex.Pick(builds, "4.1.0-rc1")!.Name); - Assert.Null(BinaryIndex.Pick(builds, "4.1")); + BinaryIndex.Pick(builds, "4.1.0-rc1", X64Host)!.Name); + Assert.Null(BinaryIndex.Pick(builds, "4.1", X64Host)); } } @@ -231,9 +303,10 @@ public async Task PublishedIndex_ParsesAndResolvesRubyDev() "https://cache.ruby-lang.org/pub/ruby/binaries/index.json"); IndexPage page = BinaryIndex.Parse(json); - Build? build = BinaryIndex.Pick(page.Builds, "ruby-dev"); + Build? build = BinaryIndex.Pick(page.Builds, "ruby-dev", + BinaryIndex.PlatformsFor(Architecture.X64)); Assert.NotNull(build); - Assert.Equal(BinaryIndex.Platform, build!.Platform); + Assert.Equal(BinaryIndex.X64, build!.Platform); Assert.Matches("^[0-9a-f]{64}$", build.Sha256); Assert.StartsWith("https://cache.ruby-lang.org/", build.Url); } diff --git a/tests/rbmanager.Tests/InstallFromIndexTests.cs b/tests/rbmanager.Tests/InstallFromIndexTests.cs index 0eab05e..62eeac3 100644 --- a/tests/rbmanager.Tests/InstallFromIndexTests.cs +++ b/tests/rbmanager.Tests/InstallFromIndexTests.cs @@ -16,19 +16,20 @@ private static string Sha256Of(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); // One dev-channel entry in the published feed's exact shape. - private static string BuildJson(string name, string url, string sha256, bool signed) => $$""" + private static string BuildJson(string name, string url, string sha256, bool signed, + string platform = BinaryIndex.X64, string commitDate = "2026-08-21") => $$""" { "name": "{{name}}", "version": "4.1.0dev", "channel": "dev", "revision": null, "tags": ["4.1-dev", "4.1-dev-20260821", "4.1.0dev-20260821-0123456789", "ruby-dev"], - "platform": "x64-mswin64_140", + "platform": "{{platform}}", "url": "{{url}}", "sha256": "{{sha256}}", "size": 1, "commit": "0123456789", - "commit_date": "2026-08-21", + "commit_date": "{{commitDate}}", "published_at": "2026-08-21", "signed": {{(signed ? "true" : "false")}} } @@ -173,6 +174,81 @@ public async Task InstallUnknownVersion_Throws() Assert.Equal("no binary package matches '3.9' in the index", ex.Message); } + // An older arm64 snapshot next to the x64 one: the arm64 host takes its + // own, an x64 host never sees it. + private const string ArmDevName = "ruby-4.1.0dev-20260820-0123456789-arm64-mswin64_140"; + + [Theory] // case 131 + [InlineData("Arm64", ArmDevName)] + [InlineData("X64", DevName)] + public async Task InstallByTag_PicksTheHostsOwnPlatform(string arch, string expected) + { + using var sb = new RbSandbox(); + using var cap = new ConsoleCapture(); + byte[] zip = File.ReadAllBytes( + Zips.WriteRuby(Path.Combine(sb.Root, "_src", "pkg.zip"), expected)); + using var server = new LoopbackZipServer(zip, $"/{expected}.zip"); + using var env = new EnvScope(); + env.Set("RBMANAGER_OS_ARCH", arch); + string UrlOf(string name) => + name == expected ? server.ZipUrl : "https://example.invalid/x.zip"; + env.Set("RBMANAGER_INDEX_URL", WriteIndex(sb, "index.json", null, + BuildJson(DevName, UrlOf(DevName), Sha256Of(zip), signed: true), + BuildJson(ArmDevName, UrlOf(ArmDevName), Sha256Of(zip), signed: true, + platform: BinaryIndex.Arm64, commitDate: "2026-08-20"))); + + int rc = await Program.Install("4.1-dev"); + + Assert.Equal(0, rc); + Assert.Equal(expected, Program.CurrentTarget()); + Assert.Equal("", cap.Err); + } + + [Fact] // case 132: no arm64 build for the tag installs x64 with a warning + public async Task InstallByTag_Arm64HostWithoutArm64Build_FallsBackAndWarns() + { + using var sb = new RbSandbox(); + using var cap = new ConsoleCapture(); + byte[] zip = File.ReadAllBytes( + Zips.WriteRuby(Path.Combine(sb.Root, "_src", "pkg.zip"), DevName)); + using var server = new LoopbackZipServer(zip, $"/{DevName}.zip"); + using var env = new EnvScope(); + env.Set("RBMANAGER_OS_ARCH", "Arm64"); + env.Set("RBMANAGER_INDEX_URL", WriteIndex(sb, "index.json", null, + BuildJson(DevName, server.ZipUrl, Sha256Of(zip), signed: true))); + + int rc = await Program.Install("4.1-dev"); + + Assert.Equal(0, rc); + Assert.Equal(DevName, Program.CurrentTarget()); + Assert.Equal( + "warning: no arm64-mswin64_140 build matches '4.1-dev', " + + "falling back to x64-mswin64_140 under emulation", + cap.Err.TrimEnd()); + } + + [Fact] // case 133: a full x64 name is taken as asked, without the warning + public async Task InstallByFullName_Arm64Host_TakesX64Silently() + { + using var sb = new RbSandbox(); + using var cap = new ConsoleCapture(); + byte[] zip = File.ReadAllBytes( + Zips.WriteRuby(Path.Combine(sb.Root, "_src", "pkg.zip"), DevName)); + using var server = new LoopbackZipServer(zip, $"/{DevName}.zip"); + using var env = new EnvScope(); + env.Set("RBMANAGER_OS_ARCH", "Arm64"); + env.Set("RBMANAGER_INDEX_URL", WriteIndex(sb, "index.json", null, + BuildJson(DevName, server.ZipUrl, Sha256Of(zip), signed: true), + BuildJson(ArmDevName, "https://example.invalid/x.zip", new string('0', 64), + signed: true, platform: BinaryIndex.Arm64))); + + int rc = await Program.Install(DevName); + + Assert.Equal(0, rc); + Assert.Equal(DevName, Program.CurrentTarget()); + Assert.Equal("", cap.Err); + } + [Fact] // case 117: a missing zip path fails as a file, not as a version public async Task InstallMissingZipPath_DoesNotHitTheIndex() { diff --git a/tests/rbmanager.Tests/ListRemoteTests.cs b/tests/rbmanager.Tests/ListRemoteTests.cs index 0681a53..96e69fd 100644 --- a/tests/rbmanager.Tests/ListRemoteTests.cs +++ b/tests/rbmanager.Tests/ListRemoteTests.cs @@ -13,7 +13,7 @@ public class ListRemoteTests private const string Rel = "ruby-4.0.5-x64-mswin64_140"; private static string BuildJson(string name, string version, string channel, - string[] tags, string platform = BinaryIndex.Platform, string? commitDate = null) => $$""" + string[] tags, string platform = BinaryIndex.X64, string? commitDate = null) => $$""" { "name": "{{name}}", "version": "{{version}}", @@ -41,7 +41,7 @@ private static void WriteIndex(RbSandbox sb, EnvScope env, params string[] build env.Set("RBMANAGER_INDEX_URL", path); } - [Fact] // case 121: name, channel and the tags `install` accepts + [Fact] // case 121: name, channel, platform and the tags `install` accepts public async Task ListsBuildsNewestFirstInColumns() { using var sb = new RbSandbox(); @@ -57,12 +57,51 @@ public async Task ListsBuildsNewestFirstInColumns() Assert.Equal(0, rc); Assert.Equal( [ - $"{Dev} dev ruby-dev, 4.1-dev", - $"{Rel} release 4.0.5, 4.0, 4", + $"{Dev} dev x64-mswin64_140 ruby-dev, 4.1-dev", + $"{Rel} release x64-mswin64_140 4.0.5, 4.0, 4", ], cap.OutLines); } + [Fact] // case 130: an arm64 host lists its own builds first, then x64 + public async Task Arm64Host_ListsArm64ThenX64() + { + using var sb = new RbSandbox(); + using var cap = new ConsoleCapture(); + using var env = new EnvScope(); + env.Set("RBMANAGER_OS_ARCH", "Arm64"); + WriteIndex(sb, env, + BuildJson("ruby-4.0.10-x64-mswin64_140", "4.0.10", "release", ["4.0.10", "4.0"]), + BuildJson("ruby-4.0.9-arm64-mswin64_140", "4.0.9", "release", ["4.0.9", "4.0"], + platform: BinaryIndex.Arm64)); + + int rc = await Program.ListRemote(); + + Assert.Equal(0, rc); + Assert.Equal( + [ + "ruby-4.0.9-arm64-mswin64_140 release arm64-mswin64_140 4.0.9, 4.0", + "ruby-4.0.10-x64-mswin64_140 release x64-mswin64_140 4.0.10, 4.0", + ], + cap.OutLines); + } + + [Theory] // case 146: an override naming no Architecture stops and names the variable + [InlineData("arm64ec")] + [InlineData("99")] + public async Task UnknownOsArchOverride_Throws(string arch) + { + using var sb = new RbSandbox(); + using var env = new EnvScope(); + WriteIndex(sb, env, BuildJson(Rel, "4.0.5", "release", ["4.0.5"])); + env.Set("RBMANAGER_OS_ARCH", arch); + + var ex = await Assert.ThrowsAsync(Program.ListRemote); + + Assert.Contains("RBMANAGER_OS_ARCH", ex.Message); + Assert.Contains($"'{arch}'", ex.Message); + } + [Fact] // case 122: an index with nothing for this platform is not an error public async Task NoBuildsForThePlatform_NotesOnStderr_Exit0() { @@ -77,7 +116,7 @@ public async Task NoBuildsForThePlatform_NotesOnStderr_Exit0() Assert.Equal(0, rc); Assert.Equal("", cap.Out); - Assert.Contains($"no {BinaryIndex.Platform} builds", cap.Err); + Assert.Contains($"no {BinaryIndex.X64} builds", cap.Err); } [Fact] // case 123: an index this rb does not understand fails here too diff --git a/tests/rbmanager.Tests/PublishE2eTests.cs b/tests/rbmanager.Tests/PublishE2eTests.cs index 72a9e94..003d16e 100644 --- a/tests/rbmanager.Tests/PublishE2eTests.cs +++ b/tests/rbmanager.Tests/PublishE2eTests.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using RbManager.Tests.Support; namespace RbManager.Tests; @@ -46,6 +47,38 @@ public void Version_UnderAot_MatchesTheInProcessResolution() Assert.Equal(Program.SelfVersion(), r.Out.Trim()); } + // The fixture publishes win-x64, so on an ARM64 machine this runs the + // exe under emulation, where OSArchitecture must still report Arm64. + [SkippableFact] // case 144 + public void ListRemote_UnderAot_SeesTheMachineArchitecture() + { + Skip.If(_fx.ExePath is null, _fx.SkipReason); + using var sb = new E2eSandbox(); + sb.UseHostArchitecture(); + string index = Path.Combine(sb.Root, "_index", "index.json"); + Directory.CreateDirectory(Path.GetDirectoryName(index)!); + File.WriteAllText(index, $$""" + {"schema": 1, "next": null, + "builds": [{{Entry(BinaryIndex.Arm64)}}, {{Entry(BinaryIndex.X64)}}]} + """); + sb.UseIndex(index); + + RbResult r = sb.RunExe(_fx.ExePath!, "list", "--remote"); + + Assert.Equal(0, r.ExitCode); + Assert.Contains($"ruby-4.0.9-{BinaryIndex.X64}", r.Out); + Assert.Equal(RuntimeInformation.OSArchitecture == Architecture.Arm64, + r.Out.Contains($"ruby-4.0.9-{BinaryIndex.Arm64}")); + + static string Entry(string platform) => $$""" + {"name": "ruby-4.0.9-{{platform}}", "version": "4.0.9", "channel": "release", + "revision": 0, "tags": ["4.0.9"], "platform": "{{platform}}", + "url": "https://example.invalid/x.zip", "sha256": "{{new string('0', 64)}}", + "size": 1, "commit": null, "commit_date": null, "published_at": null, + "signed": false} + """; + } + [SkippableFact] // case 32: self-copy skip works on the self-contained exe public void Setup_FromCopiedExe_NoSelfCopyError() { diff --git a/tests/rbmanager.Tests/Support/E2eSandbox.cs b/tests/rbmanager.Tests/Support/E2eSandbox.cs index 7042162..e6fc3e8 100644 --- a/tests/rbmanager.Tests/Support/E2eSandbox.cs +++ b/tests/rbmanager.Tests/Support/E2eSandbox.cs @@ -6,8 +6,9 @@ namespace RbManager.Tests.Support; // does not touch the test process environment; the redirection is passed to // each child rb.exe invocation, so E2E tests run in parallel. The VC++ // Redistributable probe is redirected to a scratch subkey seeded as -// "installed" (and the DLL fallback to a nonexistent path), so children -// never consult the host machine's real state. +// "installed" (and the DLL fallback to a nonexistent path), and the host +// is pinned to x64, so children never consult the host machine's real +// state. internal sealed class E2eSandbox : IDisposable { private readonly TempDir _tmp = new("e2e"); @@ -27,9 +28,17 @@ public E2eSandbox() { ["RBMANAGER_VC_REDIST_KEY"] = _vcSub, ["RBMANAGER_VCRUNTIME"] = Path.Combine(Root, "no-vcruntime140.dll"), + ["RBMANAGER_OS_ARCH"] = "X64", }; } + // Blanks the x64 pin, so the child reads the real OSArchitecture. + // Dropping the key would pass on whatever the test process inherited. + public void UseHostArchitecture() => _extraEnv["RBMANAGER_OS_ARCH"] = ""; + + // Points the child at a binary index file. + public void UseIndex(string path) => _extraEnv["RBMANAGER_INDEX_URL"] = path; + public RbResult Run(params string[] args) => RunExe(Rb.Exe, args); public RbResult RunExe(string exe, params string[] args) => Rb.RunExe(exe, Root, EnvKey, _extraEnv, args); diff --git a/tests/rbmanager.Tests/Support/RbSandbox.cs b/tests/rbmanager.Tests/Support/RbSandbox.cs index ad7a595..f6bd2e2 100644 --- a/tests/rbmanager.Tests/Support/RbSandbox.cs +++ b/tests/rbmanager.Tests/Support/RbSandbox.cs @@ -6,8 +6,9 @@ namespace RbManager.Tests.Support; // wired to RBMANAGER_ROOT, plus a scratch HKCU subkey wired to // RBMANAGER_ENV_KEY so UserPath.Ensure never touches the real user PATH. // The VC++ Redistributable probe is redirected to a scratch subkey seeded -// as "installed", so tests never depend on the host machine's real state. -// Only safe inside the Serial collection (it mutates process env vars). +// as "installed", and the host is pinned to x64, so tests never depend on +// the host machine's real state. Only safe inside the Serial collection +// (it mutates process env vars). internal sealed class RbSandbox : IDisposable { private readonly TempDir _tmp = new("root"); @@ -32,6 +33,7 @@ public RbSandbox() _env.Set("RBMANAGER_ENV_KEY", _regSub); _env.Set("RBMANAGER_VC_REDIST_KEY", _vcSub); _env.Set("RBMANAGER_VCRUNTIME", Path.Combine(Root, "no-vcruntime140.dll")); + _env.Set("RBMANAGER_OS_ARCH", "X64"); } // Creates rubies\ (with a bin subdir) without going through install. From 400be89d48cde25b7de580acb64f91437a2bc033 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 4 Nov 2026 08:47:22 +0900 Subject: [PATCH 2/4] Prefer installed arm64 rubies in rb use on ARM64 Windows A query that matches both an arm64 and an x64 install now picks the arm64 one, as install does through the index, unless it is the exact version of one of them, so `3.4.1` still means 3.4.1 next to an arm64 3.4.12. Landing on x64 gives the same warning. uninstall keeps its old resolution, so such a query is still ambiguous there. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- docs/test-plan.md | 20 +++- src/rbmanager/Program.cs | 28 +++++- .../rbmanager.Tests/ProgramLifecycleTests.cs | 22 +++++ tests/rbmanager.Tests/ResolveTests.cs | 93 +++++++++++++++++++ 5 files changed, 162 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6641927..1af2493 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,8 @@ URL skips the index and installs directly. On ARM64 Windows `install` takes the arm64 build of a version or tag and falls back to the x64 build, which runs under emulation, with a -warning when there is none. A full package name such as +warning when there is none. `use` makes the same choice among the +installed rubies. A full package name such as `ruby-3.4.12-x64-mswin64_140` picks that build either way. `list --remote` prints what the index currently offers for this diff --git a/docs/test-plan.md b/docs/test-plan.md index 6ba49f6..84a2bd0 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -32,7 +32,7 @@ Command surface and contracts: | `rb setup` | Copy own exe to `\bin\rb.exe` (skip if already running from there, case-insensitive), append that dir to user PATH | 0 | | `rb install ` | An argument that is not a URL and does not look like a zip path (no existing file, no path separator, no `.zip` suffix) is resolved through the binary index (see 4.13): pick the newest matching build of the first platform the host runs (`arm64-mswin64_140` then `x64-mswin64_140` on ARM64, `x64-mswin64_140` elsewhere, see 4.15), warn on stderr when an ARM64 host falls back to x64, download it, verify its `sha256`, warn on stderr when it is unsigned. If URL, download to `%TEMP%` first (deleted in `finally`). Zip must contain exactly one root dir named `ruby-*`; extract under `rubies`, inject the embedded `operating_system.rb` trust hook into `lib\ruby\site_ruby\rubygems\defaults\`, then switch `current` to it and ensure `current\bin` on user PATH. Refuse if already installed | 0 / 1 | | `rb list` | Installed names sorted, active one starred | 0 | -| `rb use ` | Resolve query (exact or case-insensitive substring; must be unambiguous), recreate the `current` junction, ensure PATH | 0 / 1 | +| `rb use ` | Resolve query (exact or case-insensitive substring; must be unambiguous), on ARM64 let an arm64 match hide the x64 ones and warn as install does when landing on x64, recreate the `current` junction, ensure PATH | 0 / 1 | | `rb uninstall ` | Resolve; if active, delete the junction first and print a hint; delete the install dir recursively | 0 / 1 | | `rb msvc [--vsver ] [--] ` | Locate VsDevCmd via vswhere (narrowed to the requested VS product year, if any; `--vsver` > `RBMANAGER_VSVER` > newest), compute the env delta of activation and apply it to a `cmd /s /c` child (so `.cmd` shims resolve); removes `NoDefaultCurrentDirectoryInExePath`; propagates the child's exit code. Options are leading-only; `--` ends option parsing. `enable` is the only reserved word after `msvc` | child / 1 | | `rb msvc enable [--vsver ] [shell]` | Same delta printed as per-shell assignments plus an unset of `NoDefaultCurrentDirectoryInExePath`. Shell defaults to PowerShell; `cmd`/`bat` selects cmd syntax. `--vsver` parses on either side of `enable`. No toolchain: actionable warning on stderr | 0 / 1 | @@ -552,6 +552,24 @@ and `InstallFromIndexTests` Integration, `PublishE2eTests` Publish): 146. An `RBMANAGER_OS_ARCH` that names no `Architecture` member (`arm64ec`, `99`) stops with an error naming the variable. +`rb use` (`ResolveTests` and `ProgramLifecycleTests`, Integration): + +134. `Resolve(preferNative: true)` on ARM64 lets an arm64 match hide the + x64 ones, including a newer x64 version. +135. With no arm64 match it resolves the x64 ones as before. +136. A full x64 name still resolves to that build. +137. Two arm64 versions stay ambiguous, since the preference only orders + platforms. +138. On an x64 host, and without `preferNative` (what `uninstall` uses), + an arm64 and an x64 build of one version stay ambiguous. +139. `rb use` on ARM64 warns as install does only when it lands on x64 by + fallback, not for an arm64 match or a full x64 name. +147. When the matches span both platforms, the version the query spells + out beats the platform order: `3.4.1` picks an x64 3.4.1 over an + arm64 3.4.12. +148. Within one platform an exact version stays as ambiguous as before, + on x64 and ARM64 hosts alike. + ## 5. Execution plan Phased so each phase leaves the tree green. diff --git a/src/rbmanager/Program.cs b/src/rbmanager/Program.cs index 856a0dd..a1def82 100644 --- a/src/rbmanager/Program.cs +++ b/src/rbmanager/Program.cs @@ -210,8 +210,10 @@ internal static async Task ListRemote() internal static int Use(string query) { - SwitchTo(Resolve(query)); + string name = Resolve(query, preferNative: true); + SwitchTo(name); Console.WriteLine($"Now using {CurrentTarget()}"); + WarnIfEmulated(query, name); VcRedist.WarnIfMissing(); return 0; } @@ -279,12 +281,13 @@ internal static string FormatVersion(string? informational, string? assembly, st private static IEnumerable InstalledRubies() => Directory.Exists(Rubies) ? Directory.EnumerateDirectories(Rubies).Order() : []; - internal static string Resolve(string query) + internal static string Resolve(string query, bool preferNative = false) { string[] matches = InstalledRubies() .Select(Path.GetFileName) .Where(n => n == query || n!.Contains(query, StringComparison.OrdinalIgnoreCase)) .ToArray()!; + if (preferNative) matches = FirstPlatformOnly(query, matches); return matches switch { [var single] => single!, @@ -294,6 +297,27 @@ internal static string Resolve(string query) }; } + // The installed counterpart of BinaryIndex.Pick's platform order: the + // first of BinaryIndex.Platforms with a match hides the matches on the + // others. Names on none of them stay, so they remain as ambiguous as + // before. Across platforms the version the query spells out comes + // first, or "3.4.1" would land on an arm64 3.4.12 instead of an x64 + // 3.4.1. + private static string[] FirstPlatformOnly(string query, string[] matches) + { + string[] platforms = BinaryIndex.Platforms; + string[] exact = matches + .Where(n => PackageName.Match(n) is { Success: true } m && + m.Groups["ver"].Value.Equals(query, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (exact.Length > 0 && platforms.Count(p => matches.Any(n => BinaryIndex.IsOn(n, p))) > 1) + matches = exact; + string? first = platforms.FirstOrDefault(p => matches.Any(n => BinaryIndex.IsOn(n, p))); + return matches + .Where(n => !platforms.Any(p => p != first && BinaryIndex.IsOn(n, p))) + .ToArray(); + } + // ruby-[-]-: a reissued release package // (SIGNING.md in ruby/actions) differs from the original only by the // numeric revision after the version. The revision never collides diff --git a/tests/rbmanager.Tests/ProgramLifecycleTests.cs b/tests/rbmanager.Tests/ProgramLifecycleTests.cs index ebdc2cd..ce2daab 100644 --- a/tests/rbmanager.Tests/ProgramLifecycleTests.cs +++ b/tests/rbmanager.Tests/ProgramLifecycleTests.cs @@ -207,6 +207,28 @@ public void Use_Ambiguous_Throws_JunctionUnchanged() Assert.Equal(V405, Program.CurrentTarget()); } + [Theory] // case 139: use warns as install does when it lands on x64 + [InlineData("3.3", "ruby-3.3.11-x64-mswin64_140", true)] + [InlineData("3.4", "ruby-3.4.12-arm64-mswin64_140", false)] + [InlineData("ruby-3.4.12-x64-mswin64_140", "ruby-3.4.12-x64-mswin64_140", false)] + public void Use_Arm64Host_WarnsOnlyOnFallback(string query, string expected, bool warns) + { + using var sb = new RbSandbox(); + using var cap = new ConsoleCapture(); + using var env = new EnvScope(); + env.Set("RBMANAGER_OS_ARCH", "Arm64"); + sb.Seed("ruby-3.4.12-arm64-mswin64_140", "ruby-3.4.12-x64-mswin64_140", + "ruby-3.3.11-x64-mswin64_140"); + + Assert.Equal(0, Program.Use(query)); + + Assert.Equal(expected, Program.CurrentTarget()); + Assert.Equal(warns + ? $"warning: no arm64-mswin64_140 build matches '{query}', " + + "falling back to x64-mswin64_140 under emulation" + : "", cap.Err.TrimEnd()); + } + [Fact] // case 27 public void Uninstall_NonActive_RemovesDir_KeepsCurrent() { diff --git a/tests/rbmanager.Tests/ResolveTests.cs b/tests/rbmanager.Tests/ResolveTests.cs index cc490c8..db12941 100644 --- a/tests/rbmanager.Tests/ResolveTests.cs +++ b/tests/rbmanager.Tests/ResolveTests.cs @@ -124,4 +124,97 @@ public void RevisionPreference_PrereleaseStaysAmbiguous() var ex = Assert.Throws(() => Program.Resolve("3.4.0")); Assert.Contains("is ambiguous", ex.Message); } + + // Plan 4.15: preferNative, which `use` passes, on an ARM64 host. + + private const string A3412 = "ruby-3.4.12-arm64-mswin64_140"; + private const string X3412 = "ruby-3.4.12-x64-mswin64_140"; + private const string X3413 = "ruby-3.4.13-x64-mswin64_140"; + private const string X3311 = "ruby-3.3.11-x64-mswin64_140"; + + private static EnvScope Arm64Host() + { + var env = new EnvScope(); + env.Set("RBMANAGER_OS_ARCH", "Arm64"); + return env; + } + + [Fact] // case 134: an arm64 match hides the x64 ones, even a newer version + public void PreferNative_Arm64Host_Arm64HidesX64() + { + using var sb = new RbSandbox(); + using var env = Arm64Host(); + sb.Seed(A3412, X3412, X3413); + Assert.Equal(A3412, Program.Resolve("3.4.12", preferNative: true)); + Assert.Equal(A3412, Program.Resolve("3.4", preferNative: true)); + } + + [Fact] // case 135: without an arm64 match the x64 ones resolve as before + public void PreferNative_Arm64Host_FallsBackToX64() + { + using var sb = new RbSandbox(); + using var env = Arm64Host(); + sb.Seed(A3412, X3311); + Assert.Equal(X3311, Program.Resolve("3.3", preferNative: true)); + } + + [Fact] // case 136: a full x64 name is still that build + public void PreferNative_Arm64Host_FullX64Name() + { + using var sb = new RbSandbox(); + using var env = Arm64Host(); + sb.Seed(A3412, X3412); + Assert.Equal(X3412, Program.Resolve(X3412, preferNative: true)); + } + + [Fact] // case 137: the preference never decides between arm64 builds + public void PreferNative_Arm64Host_Arm64VersionsStayAmbiguous() + { + using var sb = new RbSandbox(); + using var env = Arm64Host(); + sb.Seed(A3412, "ruby-3.4.11-arm64-mswin64_140", X3413); + var ex = Assert.Throws( + () => Program.Resolve("3.4.1", preferNative: true)); + Assert.Equal( + $"'3.4.1' is ambiguous: ruby-3.4.11-arm64-mswin64_140, {A3412}", ex.Message); + } + + [Fact] // case 147: across platforms the exact version beats the platform order + public void PreferNative_Arm64Host_ExactVersionBeatsPlatform() + { + const string x341 = "ruby-3.4.1-x64-mswin64_140"; + using var sb = new RbSandbox(); + using var env = Arm64Host(); + sb.Seed(A3412, x341, X3412); + Assert.Equal(x341, Program.Resolve("3.4.1", preferNative: true)); + Assert.Equal(A3412, Program.Resolve("3.4.12", preferNative: true)); + } + + [Fact] // case 148: within one platform an exact version is still ambiguous + public void PreferNative_SinglePlatform_ExactVersionStaysAmbiguous() + { + const string x341 = "ruby-3.4.1-x64-mswin64_140"; + using var sb = new RbSandbox(); + sb.Seed(x341, X3412); + Assert.Throws( + () => Program.Resolve("3.4.1", preferNative: true)); + + using var env = Arm64Host(); + var ex = Assert.Throws( + () => Program.Resolve("3.4.1", preferNative: true)); + Assert.Equal($"'3.4.1' is ambiguous: {x341}, {X3412}", ex.Message); + } + + [Fact] // case 138: x64 hosts, and uninstall's resolution, are unchanged + public void PreferNative_X64HostOrNotPreferred_StaysAmbiguous() + { + using var sb = new RbSandbox(); + sb.Seed(A3412, X3412); + Assert.Throws( + () => Program.Resolve("3.4.12", preferNative: true)); + + using var env = Arm64Host(); + var ex = Assert.Throws(() => Program.Resolve("3.4.12")); + Assert.Equal($"'3.4.12' is ambiguous: {A3412}, {X3412}", ex.Message); + } } From c15e377cdccfd91d0cd8d02a1c5d64ab9a109fe4 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 4 Nov 2026 08:47:35 +0900 Subject: [PATCH 3/4] Check the ARM64 VC++ runtime for arm64 rubies An arm64 ruby.exe loads the ARM64 vcruntime140.dll, which an older x64 redistributable does not carry. install and use now check the Runtimes key matching the ruby, and setup on ARM64 requires both keys, without the vcruntime140.dll fallback that the two runtimes share there. The installer stays vc_redist.x64.exe, which provides both runtimes on ARM64. https://learn.microsoft.com/cpp/windows/latest-supported-vc-redist Co-Authored-By: Claude Opus 5.5 --- docs/test-plan.md | 17 +++ src/rbmanager/Program.cs | 4 +- src/rbmanager/VcRedist.cs | 64 ++++++--- tests/rbmanager.Tests/Support/E2eSandbox.cs | 4 +- tests/rbmanager.Tests/Support/RbSandbox.cs | 23 ++-- tests/rbmanager.Tests/VcRedistTests.cs | 143 +++++++++++++++++--- 6 files changed, 204 insertions(+), 51 deletions(-) diff --git a/docs/test-plan.md b/docs/test-plan.md index 84a2bd0..7b0c01d 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -570,6 +570,23 @@ and `InstallFromIndexTests` Integration, `PublishE2eTests` Publish): 148. Within one platform an exact version stays as ambiguous as before, on x64 and ARM64 hosts alike. +VC++ runtime (`VcRedistTests`, Unit). `RBMANAGER_VC_REDIST_KEY` now +stands in for the `Runtimes` key, with `x64` and `ARM64` subkeys under +it. + +140. `RuntimeOf` maps arm64 package names and the platform to `ARM64`, + everything else to `x64`. +141. `rb setup` on ARM64 is satisfied only when both runtimes are + registered. +142. With only the x64 runtime on ARM64, setup names `ARM64` as missing + and offers the x64 package. +143. `WarnIfMissing` checks an arm64 ruby against the ARM64 runtime and + an x64 ruby against the x64 one. +149. On ARM64 a `vcruntime140.dll` in the system directory does not stand + in for an unregistered runtime: setup still names `ARM64` as missing. +150. Nor does it for `IsInstalled` and `WarnIfMissing`, which warn for an + arm64 ruby. On x64 the DLL fallback is unchanged. + ## 5. Execution plan Phased so each phase leaves the tree green. diff --git a/src/rbmanager/Program.cs b/src/rbmanager/Program.cs index a1def82..5f867b7 100644 --- a/src/rbmanager/Program.cs +++ b/src/rbmanager/Program.cs @@ -145,7 +145,7 @@ internal static async Task Install(string source) Console.WriteLine($"Installed {name}"); Console.WriteLine("Open a new terminal to pick up PATH changes."); - VcRedist.WarnIfMissing(); + VcRedist.WarnIfMissing(name); return 0; } finally @@ -214,7 +214,7 @@ internal static int Use(string query) SwitchTo(name); Console.WriteLine($"Now using {CurrentTarget()}"); WarnIfEmulated(query, name); - VcRedist.WarnIfMissing(); + VcRedist.WarnIfMissing(name); return 0; } diff --git a/src/rbmanager/VcRedist.cs b/src/rbmanager/VcRedist.cs index 03bbc81..005e7b6 100644 --- a/src/rbmanager/VcRedist.cs +++ b/src/rbmanager/VcRedist.cs @@ -16,20 +16,26 @@ namespace RbManager; // https://bugs.ruby-lang.org/issues/22180 internal static partial class VcRedist { - private const string RuntimesSubKey = - @"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64"; + private const string RuntimesKey = @"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes"; // Microsoft's permalink to the latest 2015-2022 x64 redistributable. + // On ARM64 Windows it installs the ARM64 runtime alongside the x64 one. private const string InstallerUrl = "https://aka.ms/vs/17/release/vc_redist.x64.exe"; + // The Runtimes subkey ("x64" or "ARM64") a ruby package or platform needs. + internal static string RuntimeOf(string rubyOrPlatform) => + rubyOrPlatform.EndsWith(BinaryIndex.Arm64, StringComparison.OrdinalIgnoreCase) + ? "ARM64" + : "x64"; + // Registry probe, the documented way to detect the 2015-2022 runtime. - // RBMANAGER_VC_REDIST_KEY redirects it to an HKCU-relative scratch - // subkey so tests never depend on the host machine's real state. - internal static string? InstalledVersion() + // RBMANAGER_VC_REDIST_KEY redirects the Runtimes key to an HKCU-relative + // scratch subkey so tests never depend on the host machine's real state. + internal static string? InstalledVersion(string runtime) { if (Environment.GetEnvironmentVariable("RBMANAGER_VC_REDIST_KEY") is { Length: > 0 } sub) { - using var scratch = Registry.CurrentUser.OpenSubKey(sub); + using var scratch = Registry.CurrentUser.OpenSubKey($@"{sub}\{runtime}"); return VersionOf(scratch); } // The redist setup is a 32-bit process; probe both views so a key @@ -37,7 +43,7 @@ internal static partial class VcRedist foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 }) { using var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view); - using var key = hklm.OpenSubKey(RuntimesSubKey); + using var key = hklm.OpenSubKey($@"{RuntimesKey}\{runtime}"); if (VersionOf(key) is { } version) return version; } return null; @@ -61,23 +67,34 @@ internal static bool RuntimeDllExists() return File.Exists(dll); } - internal static bool IsInstalled() => InstalledVersion() is not null || RuntimeDllExists(); + // On ARM64 the x64 and ARM64 runtimes share the one vcruntime140.dll + // path in the system directory, so finding it there does not say which + // of them is installed. Only the Runtimes keys can tell. + private static bool DllMayStandIn => BinaryIndex.Platforms is [_]; + + internal static bool IsInstalled(string runtime) => + InstalledVersion(runtime) is not null || (DllMayStandIn && RuntimeDllExists()); public static async Task Ensure(bool assumeYes) { - if (InstalledVersion() is { } version) + // There is no ruby to ask yet, so cover every platform rb picks on + // this machine. On ARM64 that includes x64 for the fallback builds. + string[] runtimes = BinaryIndex.Platforms.Select(RuntimeOf).ToArray(); + string?[] versions = runtimes.Select(InstalledVersion).ToArray(); + if (versions.All(v => v is not null)) { - Console.WriteLine($"VC++ Redistributable {version} is already installed."); + Console.WriteLine($"VC++ Redistributable {versions[^1]} is already installed."); return 0; } - if (RuntimeDllExists()) + if (DllMayStandIn && RuntimeDllExists()) { Console.WriteLine("vcruntime140.dll is already present in the system directory."); return 0; } + string missing = string.Join(", ", runtimes.Where((_, i) => versions[i] is null)); Console.WriteLine($""" - The Microsoft Visual C++ Redistributable (x64) is not installed. + The Microsoft Visual C++ Redistributable ({missing}) is not installed. Ruby's official Windows packages rely on it; without vcruntime140.dll ruby.exe cannot start. Installing it affects the whole machine and prompts for administrator approval (UAC). Without administrator @@ -93,21 +110,22 @@ ruby.exe cannot start. Installing it affects the whole machine and return 1; } } - return await Install(); + return await Install(runtimes); } // Non-blocking companion for install/use: the selected ruby stays in // place, it just cannot start until the runtime shows up. - public static void WarnIfMissing() + public static void WarnIfMissing(string ruby) { - if (IsInstalled()) return; - Console.Error.WriteLine(""" - rb: warning: the Microsoft Visual C++ Redistributable (x64) was not found. + string runtime = RuntimeOf(ruby); + if (IsInstalled(runtime)) return; + Console.Error.WriteLine($""" + rb: warning: the Microsoft Visual C++ Redistributable ({runtime}) was not found. ruby.exe cannot start without vcruntime140.dll. Run `rb setup` to install it. """); } - private static async Task Install() + private static async Task Install(string[] runtimes) { string installer = Path.Combine(Path.GetTempPath(), "vc_redist.x64.exe"); Console.WriteLine($"Downloading {InstallerUrl} ..."); @@ -148,7 +166,15 @@ private static async Task Install() $"rb: vc_redist.x64.exe failed with exit code {code}"); return 1; } - Console.WriteLine(InstalledVersion() is { } installed + if (!DllMayStandIn && + runtimes.Where(r => InstalledVersion(r) is null).ToArray() is { Length: > 0 } missing) + { + Console.Error.WriteLine( + $"rb: the VC++ Redistributable ({string.Join(", ", missing)}) " + + "is still not registered after vc_redist.x64.exe"); + return 1; + } + Console.WriteLine(InstalledVersion("x64") is { } installed ? $"Installed VC++ Redistributable {installed}." : "Installed the VC++ Redistributable."); return 0; diff --git a/tests/rbmanager.Tests/Support/E2eSandbox.cs b/tests/rbmanager.Tests/Support/E2eSandbox.cs index e6fc3e8..0422452 100644 --- a/tests/rbmanager.Tests/Support/E2eSandbox.cs +++ b/tests/rbmanager.Tests/Support/E2eSandbox.cs @@ -21,9 +21,7 @@ internal sealed class E2eSandbox : IDisposable public E2eSandbox() { - using var key = Registry.CurrentUser.CreateSubKey(_vcSub, writable: true); - key.SetValue("Installed", 1, RegistryValueKind.DWord); - key.SetValue("Version", "v14.99.0.0"); + RbSandbox.SeedVcRedist(_vcSub); _extraEnv = new Dictionary { ["RBMANAGER_VC_REDIST_KEY"] = _vcSub, diff --git a/tests/rbmanager.Tests/Support/RbSandbox.cs b/tests/rbmanager.Tests/Support/RbSandbox.cs index f6bd2e2..1b513e1 100644 --- a/tests/rbmanager.Tests/Support/RbSandbox.cs +++ b/tests/rbmanager.Tests/Support/RbSandbox.cs @@ -6,9 +6,9 @@ namespace RbManager.Tests.Support; // wired to RBMANAGER_ROOT, plus a scratch HKCU subkey wired to // RBMANAGER_ENV_KEY so UserPath.Ensure never touches the real user PATH. // The VC++ Redistributable probe is redirected to a scratch subkey seeded -// as "installed", and the host is pinned to x64, so tests never depend on -// the host machine's real state. Only safe inside the Serial collection -// (it mutates process env vars). +// as "installed" for both runtimes, and the host is pinned to x64, so +// tests never depend on the host machine's real state. Only safe inside +// the Serial collection (it mutates process env vars). internal sealed class RbSandbox : IDisposable { private readonly TempDir _tmp = new("root"); @@ -24,11 +24,7 @@ public RbSandbox() { _regSub = $@"Software\rbmanager-tests\{Guid.NewGuid():N}"; _vcSub = $@"Software\rbmanager-tests\{Guid.NewGuid():N}"; - using (var key = Registry.CurrentUser.CreateSubKey(_vcSub, writable: true)) - { - key.SetValue("Installed", 1, RegistryValueKind.DWord); - key.SetValue("Version", "v14.99.0.0"); - } + SeedVcRedist(_vcSub); _env.Set("RBMANAGER_ROOT", Root); _env.Set("RBMANAGER_ENV_KEY", _regSub); _env.Set("RBMANAGER_VC_REDIST_KEY", _vcSub); @@ -36,6 +32,17 @@ public RbSandbox() _env.Set("RBMANAGER_OS_ARCH", "X64"); } + // The Runtimes key the x64 package leaves on ARM64 Windows. + internal static void SeedVcRedist(string sub) + { + foreach (string runtime in new[] { "x64", "ARM64" }) + { + using var key = Registry.CurrentUser.CreateSubKey($@"{sub}\{runtime}", writable: true); + key.SetValue("Installed", 1, RegistryValueKind.DWord); + key.SetValue("Version", "v14.99.0.0"); + } + } + // Creates rubies\ (with a bin subdir) without going through install. public string Seed(string name) { diff --git a/tests/rbmanager.Tests/VcRedistTests.cs b/tests/rbmanager.Tests/VcRedistTests.cs index d54d34d..9ed3292 100644 --- a/tests/rbmanager.Tests/VcRedistTests.cs +++ b/tests/rbmanager.Tests/VcRedistTests.cs @@ -5,21 +5,34 @@ namespace RbManager.Tests; // VC++ Redistributable detection and the rb setup consent flow. Registry // state is simulated through RBMANAGER_VC_REDIST_KEY (an HKCU scratch -// subkey) and the DLL fallback through RBMANAGER_VCRUNTIME, so nothing here -// reads the host machine's real installation state. The install path itself -// (download, signature check, elevation) is not driven end to end; the -// signature verifier is covered by the unsigned-rejection case here and by -// the opt-in Network test against the real installer. +// subkey standing in for the Runtimes key), the DLL fallback through +// RBMANAGER_VCRUNTIME and the host through RBMANAGER_OS_ARCH, so nothing +// here reads the host machine's real installation state. The install path +// itself (download, signature check, elevation) is not driven end to end; +// the signature verifier is covered by the unsigned-rejection case here +// and by the opt-in Network test against the real installer. [Trait("Category", "Unit")] [Collection(Serial.Name)] public class VcRedistTests { private const string Version = "v14.99.1.0"; - private static void SeedInstalled(ScratchRegistryKey key, int installed = 1) + private static void SeedInstalled(ScratchRegistryKey key, int installed = 1, + string runtime = "x64") { - key.Key.SetValue("Installed", installed, RegistryValueKind.DWord); - key.Key.SetValue("Version", Version); + using RegistryKey sub = key.Key.CreateSubKey(runtime); + sub.SetValue("Installed", installed, RegistryValueKind.DWord); + sub.SetValue("Version", Version); + } + + // The seams at the scratch key on a host of the given architecture. + private static EnvScope Redist(ScratchRegistryKey key, TempDir tmp, string arch = "X64") + { + var env = new EnvScope(); + env.Set("RBMANAGER_VC_REDIST_KEY", key.SubKeyPath); + env.Set("RBMANAGER_VCRUNTIME", Path.Combine(tmp.Path, "no-vcruntime140.dll")); + env.Set("RBMANAGER_OS_ARCH", arch); + return env; } // Points both seams at empty/nonexistent targets: a machine without the @@ -30,6 +43,7 @@ private static EnvScope MissingRedist(TempDir tmp) env.Set("RBMANAGER_VC_REDIST_KEY", $@"Software\rbmanager-tests\{Guid.NewGuid():N}"); env.Set("RBMANAGER_VCRUNTIME", Path.Combine(tmp.Path, "no-vcruntime140.dll")); + env.Set("RBMANAGER_OS_ARCH", "X64"); return env; } @@ -48,7 +62,8 @@ public void InstalledVersion_InstalledOne_ReturnsVersion() SeedInstalled(key); env.Set("RBMANAGER_VC_REDIST_KEY", key.SubKeyPath); - Assert.Equal(Version, VcRedist.InstalledVersion()); + Assert.Equal(Version, VcRedist.InstalledVersion("x64")); + Assert.Null(VcRedist.InstalledVersion("ARM64")); } [Fact] @@ -59,7 +74,7 @@ public void InstalledVersion_InstalledZero_ReturnsNull() SeedInstalled(key, installed: 0); env.Set("RBMANAGER_VC_REDIST_KEY", key.SubKeyPath); - Assert.Null(VcRedist.InstalledVersion()); + Assert.Null(VcRedist.InstalledVersion("x64")); } [Fact] @@ -69,7 +84,7 @@ public void InstalledVersion_MissingKey_ReturnsNull() env.Set("RBMANAGER_VC_REDIST_KEY", $@"Software\rbmanager-tests\{Guid.NewGuid():N}"); - Assert.Null(VcRedist.InstalledVersion()); + Assert.Null(VcRedist.InstalledVersion("x64")); } [Fact] @@ -77,10 +92,11 @@ public void InstalledVersion_NoVersionValue_ReturnsPlaceholder() { using var key = new ScratchRegistryKey(); using var env = new EnvScope(); - key.Key.SetValue("Installed", 1, RegistryValueKind.DWord); + using (RegistryKey sub = key.Key.CreateSubKey("x64")) + sub.SetValue("Installed", 1, RegistryValueKind.DWord); env.Set("RBMANAGER_VC_REDIST_KEY", key.SubKeyPath); - Assert.Equal("(unknown version)", VcRedist.InstalledVersion()); + Assert.Equal("(unknown version)", VcRedist.InstalledVersion("x64")); } [Fact] @@ -88,22 +104,31 @@ public void IsInstalled_RegistryMissing_FallsBackToDll() { using var tmp = new TempDir("vcr"); using var env = MissingRedist(tmp); - Assert.False(VcRedist.IsInstalled()); + Assert.False(VcRedist.IsInstalled("x64")); string dll = Path.Combine(tmp.Path, "vcruntime140.dll"); File.WriteAllBytes(dll, [0]); env.Set("RBMANAGER_VCRUNTIME", dll); - Assert.True(VcRedist.IsInstalled()); + Assert.True(VcRedist.IsInstalled("x64")); } + [Theory] // case 140: arm64 rubies need the ARM64 runtime, the rest x64 + [InlineData("ruby-3.4.12-arm64-mswin64_140", "ARM64")] + [InlineData(BinaryIndex.Arm64, "ARM64")] + [InlineData("ruby-3.4.12-x64-mswin64_140", "x64")] + [InlineData(BinaryIndex.X64, "x64")] + [InlineData("ruby-3.4.12-x86-mswin64_140", "x64")] + public void RuntimeOf_MapsPlatformToRuntimesKey(string ruby, string runtime) => + Assert.Equal(runtime, VcRedist.RuntimeOf(ruby)); + [Fact] public async Task Ensure_AlreadyInstalled_ReportsAndReturnsZero() { using var key = new ScratchRegistryKey(); - using var env = new EnvScope(); + using var tmp = new TempDir("vcr"); + using var env = Redist(key, tmp); using var cap = new ConsoleCapture(); SeedInstalled(key); - env.Set("RBMANAGER_VC_REDIST_KEY", key.SubKeyPath); int rc = await VcRedist.Ensure(assumeYes: false); @@ -111,6 +136,70 @@ public async Task Ensure_AlreadyInstalled_ReportsAndReturnsZero() Assert.Contains($"VC++ Redistributable {Version} is already installed", cap.Out); } + [Fact] // case 141: on ARM64 setup needs both runtimes + public async Task Ensure_Arm64Host_BothRuntimes_ReturnsZero() + { + using var key = new ScratchRegistryKey(); + using var tmp = new TempDir("vcr"); + using var env = Redist(key, tmp, "Arm64"); + using var cap = new ConsoleCapture(); + SeedInstalled(key); + SeedInstalled(key, runtime: "ARM64"); + + Assert.Equal(0, await VcRedist.Ensure(assumeYes: false)); + Assert.Contains("is already installed", cap.Out); + } + + [Fact] // case 142: an x64-only runtime on ARM64 offers the x64 package + public async Task Ensure_Arm64Host_MissingArm64Runtime_Offers() + { + using var key = new ScratchRegistryKey(); + using var tmp = new TempDir("vcr"); + using var env = Redist(key, tmp, "Arm64"); + using var cap = new ConsoleCapture(); + using var stdin = new StdinScope(""); + SeedInstalled(key); + + Assert.Equal(1, await VcRedist.Ensure(assumeYes: false)); + Assert.Contains("Visual C++ Redistributable (ARM64) is not installed", cap.Out); + Assert.Contains("vc_redist.x64.exe", cap.Out); + } + + [Fact] // case 149: on ARM64 the DLL does not stand in for the ARM64 runtime + public async Task Ensure_Arm64Host_DllPresent_StillOffersArm64() + { + using var key = new ScratchRegistryKey(); + using var tmp = new TempDir("vcr"); + using var env = Redist(key, tmp, "Arm64"); + using var cap = new ConsoleCapture(); + using var stdin = new StdinScope(""); + SeedInstalled(key); + string dll = Path.Combine(tmp.Path, "vcruntime140.dll"); + File.WriteAllBytes(dll, [0]); + env.Set("RBMANAGER_VCRUNTIME", dll); + + Assert.Equal(1, await VcRedist.Ensure(assumeYes: false)); + Assert.Contains("Visual C++ Redistributable (ARM64) is not installed", cap.Out); + Assert.DoesNotContain("already present", cap.Out); + } + + [Fact] // case 150: nor for either runtime of a ruby installed or used there + public void WarnIfMissing_Arm64Host_DllPresent_StillWarns() + { + using var key = new ScratchRegistryKey(); + using var tmp = new TempDir("vcr"); + using var env = Redist(key, tmp, "Arm64"); + using var cap = new ConsoleCapture(); + string dll = Path.Combine(tmp.Path, "vcruntime140.dll"); + File.WriteAllBytes(dll, [0]); + env.Set("RBMANAGER_VCRUNTIME", dll); + + Assert.False(VcRedist.IsInstalled("ARM64")); + Assert.False(VcRedist.IsInstalled("x64")); + VcRedist.WarnIfMissing("ruby-9.9.9-arm64-mswin64_140"); + Assert.Contains("Visual C++ Redistributable (ARM64) was not found", cap.Err); + } + [Fact] public async Task Ensure_DllPresentWithoutRegistry_ReturnsZero() { @@ -153,7 +242,7 @@ public void WarnIfMissing_Missing_WarnsToStderr() using var env = MissingRedist(tmp); using var cap = new ConsoleCapture(); - VcRedist.WarnIfMissing(); + VcRedist.WarnIfMissing("ruby-9.9.9-x64-mswin64_140"); Assert.Equal("", cap.Out); Assert.Contains("rb: warning:", cap.Err); @@ -169,12 +258,28 @@ public void WarnIfMissing_Installed_Silent() SeedInstalled(key); env.Set("RBMANAGER_VC_REDIST_KEY", key.SubKeyPath); - VcRedist.WarnIfMissing(); + VcRedist.WarnIfMissing("ruby-9.9.9-x64-mswin64_140"); Assert.Equal("", cap.Out); Assert.Equal("", cap.Err); } + [Fact] // case 143: an arm64 ruby is checked against the ARM64 runtime + public void WarnIfMissing_Arm64Ruby_ChecksArm64Runtime() + { + using var key = new ScratchRegistryKey(); + using var tmp = new TempDir("vcr"); + using var env = Redist(key, tmp, "Arm64"); + using var cap = new ConsoleCapture(); + SeedInstalled(key); + + VcRedist.WarnIfMissing("ruby-9.9.9-x64-mswin64_140"); + Assert.Equal("", cap.Err); + + VcRedist.WarnIfMissing("ruby-9.9.9-arm64-mswin64_140"); + Assert.Contains("Visual C++ Redistributable (ARM64) was not found", cap.Err); + } + [Fact] public void VerifyMicrosoftSignature_UnsignedFile_Throws() { From 2a6b43e1eae0266a19d9109be139ee7fabcd2d31 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 4 Nov 2026 08:47:35 +0900 Subject: [PATCH 4/4] Note that rb msvc targets x64 only install and use now default to arm64 rubies on ARM64 Windows, where extensions built under the x64 toolchain fail to link. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1af2493..2ed6149 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,8 @@ install wins; `--vsver <2017|2019|2022|2026|latest>` (or the `RBMANAGER_VSVER` environment variable) pins a specific Visual Studio version, and `msvc --list` shows what is installed. Apart from `enable`, every word after `msvc` is the command to run, so future -`msvc` operations are spelled as flags. +`msvc` operations are spelled as flags. The environment targets x64, +so it cannot build extensions for an arm64 ruby. `version` identifies the running binary, in cargo's shape, as in `rbmanager 0.1.0 (9a1b2c3 2026-07-28)`. `--version` and `-V` print the