diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..f98bab5 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,4 @@ +# Rake creates or removes this host-specific file when switching platforms. +include = [ + { path = "windows.toml", optional = true }, +] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 852e1b4..fe162ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,6 +124,13 @@ jobs: ruby-version: ${{ matrix.ruby }} bundler-cache: true + - name: Install Windows build dependencies + run: >- + ridk exec pacman -S --needed --noconfirm + mingw-w64-ucrt-x86_64-gcc + mingw-w64-ucrt-x86_64-clang + mingw-w64-ucrt-x86_64-cmake + mingw-w64-ucrt-x86_64-pkgconf + - name: Run tests - shell: pwsh - run: ./script/build_windows_gnu.ps1 -SkipBundleInstall -RunTests + run: bundle exec rake test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b812658..5feb172 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -140,12 +140,20 @@ jobs: ruby-version: "3.3" bundler-cache: true + - name: Install Windows build dependencies for Ruby 3.3 + run: >- + ridk exec pacman -S --needed --noconfirm + mingw-w64-ucrt-x86_64-gcc + mingw-w64-ucrt-x86_64-clang + mingw-w64-ucrt-x86_64-cmake + mingw-w64-ucrt-x86_64-pkgconf + - name: Compile for Ruby 3.3 shell: pwsh env: RB_SYS_CARGO_PROFILE: release run: | - ./script/build_windows_gnu.ps1 -SkipBundleInstall + bundle exec rake compile New-Item -ItemType Directory -Force "lib\wreq_ruby\3.3" | Out-Null Copy-Item "lib\wreq_ruby\wreq_ruby.so" "lib\wreq_ruby\3.3\wreq_ruby.so" -Force @@ -154,12 +162,20 @@ jobs: ruby-version: "3.4" bundler-cache: true + - name: Install Windows build dependencies for Ruby 3.4 + run: >- + ridk exec pacman -S --needed --noconfirm + mingw-w64-ucrt-x86_64-gcc + mingw-w64-ucrt-x86_64-clang + mingw-w64-ucrt-x86_64-cmake + mingw-w64-ucrt-x86_64-pkgconf + - name: Compile for Ruby 3.4 shell: pwsh env: RB_SYS_CARGO_PROFILE: release run: | - ./script/build_windows_gnu.ps1 -SkipBundleInstall + bundle exec rake compile New-Item -ItemType Directory -Force "lib\wreq_ruby\3.4" | Out-Null Copy-Item "lib\wreq_ruby\wreq_ruby.so" "lib\wreq_ruby\3.4\wreq_ruby.so" -Force @@ -168,12 +184,20 @@ jobs: ruby-version: "4.0" bundler-cache: true + - name: Install Windows build dependencies for Ruby 4.0 + run: >- + ridk exec pacman -S --needed --noconfirm + mingw-w64-ucrt-x86_64-gcc + mingw-w64-ucrt-x86_64-clang + mingw-w64-ucrt-x86_64-cmake + mingw-w64-ucrt-x86_64-pkgconf + - name: Compile for Ruby 4.0 shell: pwsh env: RB_SYS_CARGO_PROFILE: release run: | - ./script/build_windows_gnu.ps1 -SkipBundleInstall + bundle exec rake compile New-Item -ItemType Directory -Force "lib\wreq_ruby\4.0" | Out-Null Copy-Item "lib\wreq_ruby\wreq_ruby.so" "lib\wreq_ruby\4.0\wreq_ruby.so" -Force diff --git a/.gitignore b/.gitignore index f7aac7a..8b30577 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ test.rb DATA.md AGENTS.md .agents/ +.cargo/windows.toml diff --git a/Cargo.toml b/Cargo.toml index bad747a..02ce3f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ authors = ["SearchApi "] homepage = "https://github.com/SearchApi/wreq-ruby" repository = "https://github.com/SearchApi/wreq-ruby" edition = "2024" -rust-version = "1.85" +rust-version = "1.97" version = "1.2.4" [lib] diff --git a/Rakefile b/Rakefile index e4e61eb..bdfb7c3 100644 --- a/Rakefile +++ b/Rakefile @@ -1,5 +1,11 @@ # frozen_string_literal: true +require_relative "script/rust_env" + +# Cargo needs RubyInstaller's UCRT tools when Rake builds the native extension +# on Windows. Other platforms keep their existing native Cargo environment. +Wreq::RustEnv.activate + require "bundler/gem_tasks" require "rake/testtask" require "rb_sys/extensiontask" diff --git a/docs/windows-gnu-tokio-crash.md b/docs/windows-gnu-tokio-crash.md index bc16a96..cc96789 100644 --- a/docs/windows-gnu-tokio-crash.md +++ b/docs/windows-gnu-tokio-crash.md @@ -57,14 +57,60 @@ DLL Name: KERNEL32.dll problematic `Sleep` reference can come from Rust std, MinGW, or pthread-related linking paths, not only from `windows-sys`. -Verified with: +Windows builds use the `x86_64-pc-windows-gnu` target and the RubyInstaller +UCRT toolchain. The Rakefile configures that environment automatically, so the +extension and tests can be built with: ```powershell -.\script\build_windows_gnu.ps1 -SkipBundleInstall -SkipToolInstall -RunTests +rustup target add x86_64-pc-windows-gnu +ridk exec pacman -S --needed --noconfirm ` + mingw-w64-ucrt-x86_64-gcc ` + mingw-w64-ucrt-x86_64-clang ` + mingw-w64-ucrt-x86_64-cmake ` + mingw-w64-ucrt-x86_64-pkgconf +bundle exec rake test ``` +The first Rake invocation also writes a machine-local `.cargo/windows.toml`. +The checked-in `.cargo/config.toml` loads it automatically, so Cargo and Rust +language servers use the same GNU target and RubyInstaller tools without +editor-specific settings. + +## RubyInstaller development tools + +RubyInstaller adds Ruby itself to `PATH`, but it does not enable the bundled +MSYS2 development environment globally. Running `ridk enable` adds tools such +as GCC, CMake, and Make to the current terminal only. This is normally useful +because it avoids conflicts with other Windows toolchains. + +Rust language servers start Cargo directly and do not run `ridk enable` or the +Rakefile first. Add RubyInstaller's `ucrt64\bin` and `usr\bin` directories to +the user `PATH` so Cargo can load GCC, libclang, and their dependent DLLs from +any editor: + +```powershell +$rubyRoot = ruby -rrbconfig -e "print RbConfig::CONFIG.fetch('prefix')" +$required = @( + (Join-Path $rubyRoot "msys64\ucrt64\bin"), + (Join-Path $rubyRoot "msys64\usr\bin") +) +$userPath = [Environment]::GetEnvironmentVariable("Path", "User") +$entries = @($userPath -split ";" | Where-Object { + $_ -and $_ -notin $required +}) +$entries = $required + $entries +[Environment]::SetEnvironmentVariable("Path", ($entries -join ";"), "User") +``` + +Restart the editor after changing `PATH` so its language server inherits the +updated environment. Run `bundle exec rake compile` once after installing or +switching Ruby so `.cargo/windows.toml` points to the active RubyInstaller. +Run a Rake command after switching the same checkout between Windows and WSL; +Windows generates this local configuration, while other platforms remove it. + Result: ```text -160 runs, 775 assertions, 0 failures, 0 errors +254 runs, 1198 assertions, 0 failures, 0 errors +2 Rust tests passed ``` diff --git a/extconf.rb b/extconf.rb index b5cdd5c..552625c 100644 --- a/extconf.rb +++ b/extconf.rb @@ -1,4 +1,8 @@ require "mkmf" +require_relative "script/rust_env" + +Wreq::RustEnv.activate + # We use rb_sys for makefile generation only. # We can use `RB_SYS_CARGO_PROFILE` to choose Cargo profile # Read more https://github.com/oxidize-rb/rb-sys/blob/main/gem/README.md diff --git a/script/build_windows_gnu.ps1 b/script/build_windows_gnu.ps1 deleted file mode 100644 index 7a3ad72..0000000 --- a/script/build_windows_gnu.ps1 +++ /dev/null @@ -1,270 +0,0 @@ -param( - [switch]$SkipBundleInstall, - [switch]$SkipToolInstall, - [switch]$BuildGem, - [switch]$TestGem, - [switch]$RunTests, - [string]$SmokeUrl = $env:WREQ_SMOKE_URL -) - -$ErrorActionPreference = "Stop" - -$target = "x86_64-pc-windows-gnu" -$platform = "x64-mingw-ucrt" -$gemPattern = "pkg\wreq-*-$platform.gem" -if ([string]::IsNullOrWhiteSpace($SmokeUrl)) { - $SmokeUrl = "https://httpbin.io/get" -} -$rubyRoot = Split-Path (Split-Path (Get-Command ruby).Source -Parent) -Parent -$ucrt = Join-Path $rubyRoot "msys64\ucrt64" -$ucrtBin = Join-Path $ucrt "bin" -$msysPackages = @( - "mingw-w64-ucrt-x86_64-gcc", - "mingw-w64-ucrt-x86_64-clang", - "mingw-w64-ucrt-x86_64-cmake", - "mingw-w64-ucrt-x86_64-pkgconf" -) - -function Invoke-Step { - param( - [string]$Name, - [scriptblock]$Command - ) - - Write-Host "==> $Name" - # Native command failures do not honor ErrorActionPreference in Windows PowerShell. - $global:LASTEXITCODE = 0 - & $Command - if ($LASTEXITCODE -ne 0) { - throw "$Name failed with exit code $LASTEXITCODE." - } -} - -function Get-MissingUcrtTools { - $missing = @() - - if (-not (Test-Path (Join-Path $ucrtBin "gcc.exe")) -or -not (Test-Path (Join-Path $ucrtBin "g++.exe"))) { - $missing += "gcc/g++" - } - - if (-not (Test-Path (Join-Path $ucrtBin "clang.exe")) -or -not (Test-Path (Join-Path $ucrtBin "libclang.dll"))) { - $missing += "clang/libclang" - } - - if (-not (Test-Path (Join-Path $ucrtBin "cmake.exe"))) { - $missing += "cmake" - } - - if (-not (Test-Path (Join-Path $ucrtBin "pkgconf.exe")) -and -not (Test-Path (Join-Path $ucrtBin "pkg-config.exe"))) { - $missing += "pkgconf" - } - - $missing -} - -function Get-LatestPlatformGem { - $gem = Get-ChildItem -Path $gemPattern -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | - Select-Object -First 1 - - if (-not $gem) { - throw "No Windows GNU platform gem found at '$gemPattern'. Re-run with -BuildGem first." - } - - $gem -} - -function Invoke-RubySmoke { - param( - [string]$Name, - [string]$Code - ) - - Write-Host "Smoke: $Name" - ruby -e $Code - if ($LASTEXITCODE -ne 0) { - throw "$Name failed with exit code $LASTEXITCODE." - } -} - -Invoke-Step "Check Ruby platform" { - $rubyArch = & ruby -rrbconfig -e "print RbConfig::CONFIG['arch']" - if ($rubyArch -ne $platform) { - throw "Expected Ruby platform '$platform', got '$rubyArch'. Use RubyInstaller UCRT for this build." - } - - ruby -v - ruby -rrbconfig -rrubygems -e "puts RbConfig::CONFIG.values_at('arch', 'host', 'CC').join(%q{ }); puts Gem::Platform.local" -} - -Invoke-Step "Install Rust target" { - $installedTargets = @(rustup target list --installed) - if ($installedTargets -contains $target) { - Write-Host "Rust target $target already installed; skipping rustup." - return - } - - rustup target add $target - if ($LASTEXITCODE -ne 0) { - throw "Failed to install Rust target $target." - } -} - -Invoke-Step "Check MSYS2 UCRT build tools" { - $missing = @(Get-MissingUcrtTools) - if ($missing.Count -eq 0) { - Write-Host "MSYS2 UCRT build tools already present; skipping pacman." - return - } - - if ($SkipToolInstall) { - throw "Missing MSYS2 UCRT build tools: $($missing -join ', '). Re-run without -SkipToolInstall or install them with ridk." - } - - Write-Host "Missing MSYS2 UCRT build tools: $($missing -join ', ')" - ridk exec pacman -S --needed --noconfirm @msysPackages - if ($LASTEXITCODE -ne 0) { - $stillMissing = @(Get-MissingUcrtTools) - if ($stillMissing.Count -eq 0) { - Write-Warning "pacman returned exit code $LASTEXITCODE, but all required tools are present; continuing." - $global:LASTEXITCODE = 0 - return - } - - throw "Failed to install MSYS2 UCRT build tools. Missing: $($stillMissing -join ', '). If pacman cannot lock its database, close other pacman/ridk shells, run PowerShell as Administrator, or install RubyInstaller in a user-writable directory." - } -} - -Invoke-Step "Configure Windows GNU toolchain" { - $env:CARGO_BUILD_TARGET = $target - $env:LIBCLANG_PATH = $ucrtBin - $env:CC_x86_64_pc_windows_gnu = Join-Path $ucrtBin "gcc.exe" - $env:CXX_x86_64_pc_windows_gnu = Join-Path $ucrtBin "g++.exe" - $env:CMAKE = Join-Path $ucrtBin "cmake.exe" - Remove-Item Env:\CMAKE_GENERATOR -ErrorAction SilentlyContinue - - rustc -Vv - Write-Host "LIBCLANG_PATH=$env:LIBCLANG_PATH" - Write-Host "CC_x86_64_pc_windows_gnu=$env:CC_x86_64_pc_windows_gnu" - Write-Host "CXX_x86_64_pc_windows_gnu=$env:CXX_x86_64_pc_windows_gnu" - Write-Host "CMAKE=$env:CMAKE" -} - -if (-not $SkipBundleInstall) { - Invoke-Step "Install Ruby dependencies" { - bundle install - } -} - -Invoke-Step "Compile native extension" { - ridk exec ruby -S bundle exec rake compile -} - -Invoke-Step "Sync versioned extension" { - $rubyMinor = & ruby -e "print RUBY_VERSION[/\A\d+\.\d+/]" - $dest = "lib\wreq_ruby\$rubyMinor" - New-Item -ItemType Directory -Force $dest | Out-Null - Copy-Item "lib\wreq_ruby\wreq_ruby.so" (Join-Path $dest "wreq_ruby.so") -Force -} - -Invoke-Step "Verify extension loads" { - ruby -Ilib -rwreq -e "puts Wreq::VERSION; puts Wreq::Client.new.class" -} - -if ($RunTests) { - Invoke-Step "Run tests" { - ridk exec ruby -S bundle exec rake test - } -} - -if ($BuildGem) { - Invoke-Step "Build local platform gem" { - $rubyMinor = & ruby -e "print RUBY_VERSION[/\A\d+\.\d+/]" - $dest = "lib\wreq_ruby\$rubyMinor" - New-Item -ItemType Directory -Force $dest | Out-Null - Copy-Item "lib\wreq_ruby\wreq_ruby.so" (Join-Path $dest "wreq_ruby.so") -Force - - ruby script/build_platform_gem.rb $platform - } -} - -if ($TestGem) { - Invoke-Step "Install and smoke test platform gem" { - $gem = Get-LatestPlatformGem - $gemHome = Join-Path ([System.IO.Path]::GetTempPath()) "wreq-gem-test-$PID" - $gemBin = Join-Path $gemHome "bin" - $testDir = Join-Path ([System.IO.Path]::GetTempPath()) "wreq-gem-test-cwd-$PID" - - Remove-Item -Recurse -Force $gemHome, $testDir -ErrorAction SilentlyContinue - New-Item -ItemType Directory -Force $gemHome, $gemBin, $testDir | Out-Null - - $oldGemHome = $env:GEM_HOME - $oldGemPath = $env:GEM_PATH - $oldGemrc = $env:GEMRC - $oldRubyopt = $env:RUBYOPT - $oldRubylib = $env:RUBYLIB - $oldBundleGemfile = $env:BUNDLE_GEMFILE - $oldBundleBinPath = $env:BUNDLE_BIN_PATH - $oldBundlerVersion = $env:BUNDLER_VERSION - $oldSmokeUrl = $env:WREQ_SMOKE_URL - $oldWreqGemHome = $env:WREQ_GEM_HOME - - try { - $env:GEM_HOME = $gemHome - $env:GEM_PATH = $gemHome - $env:GEMRC = "" - $env:RUBYOPT = "" - $env:RUBYLIB = "" - $env:BUNDLE_GEMFILE = "" - $env:BUNDLE_BIN_PATH = "" - $env:BUNDLER_VERSION = "" - $env:WREQ_SMOKE_URL = $SmokeUrl - $env:WREQ_GEM_HOME = $gemHome - - gem install --norc --local --install-dir $gemHome --bindir $gemBin --no-document --force --no-user-install $gem.FullName - if ($LASTEXITCODE -ne 0) { - throw "Failed to install $($gem.FullName)." - } - - Push-Location $testDir - try { - Invoke-RubySmoke "Load installed platform gem" @' -STDOUT.sync = true -STDERR.sync = true -require "wreq" -spec = Gem.loaded_specs.fetch("wreq") -expected = File.expand_path(ENV.fetch("WREQ_GEM_HOME")) -actual = File.expand_path(spec.full_gem_path) -raise "loaded #{actual}, expected under #{expected}" unless actual.start_with?(expected) -puts "loaded #{spec.full_name}" -puts actual -puts "wreq #{Wreq::VERSION}" -'@ - - Invoke-RubySmoke "Request through installed platform gem" @' -STDOUT.sync = true -STDERR.sync = true -require "wreq" -client = Wreq::Client.new -resp = client.get(ENV.fetch("WREQ_SMOKE_URL"), timeout: 20) -puts "HTTP #{resp.code}" -raise "unexpected status #{resp.code}" unless resp.code == 200 -'@ - } finally { - Pop-Location - } - } finally { - $env:GEM_HOME = $oldGemHome - $env:GEM_PATH = $oldGemPath - $env:GEMRC = $oldGemrc - $env:RUBYOPT = $oldRubyopt - $env:RUBYLIB = $oldRubylib - $env:BUNDLE_GEMFILE = $oldBundleGemfile - $env:BUNDLE_BIN_PATH = $oldBundleBinPath - $env:BUNDLER_VERSION = $oldBundlerVersion - $env:WREQ_SMOKE_URL = $oldSmokeUrl - $env:WREQ_GEM_HOME = $oldWreqGemHome - Remove-Item -Recurse -Force $gemHome, $testDir -ErrorAction SilentlyContinue - } - } -} diff --git a/script/rust_env.rb b/script/rust_env.rb new file mode 100644 index 0000000..94e3066 --- /dev/null +++ b/script/rust_env.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "rbconfig" +require "fileutils" + +module Wreq + module RustEnv + WINDOWS_GNU_TARGET = "x86_64-pc-windows-gnu" + WINDOWS_RUBY_PLATFORM = "x64-mingw-ucrt" + + # Uses the UCRT toolchain shipped with the active RubyInstaller installation. + def self.activate + unless RbConfig::CONFIG["host_os"].match?(/mingw|mswin/) + FileUtils.rm_f(cargo_config_path) + return + end + + ruby_platform = RbConfig::CONFIG.fetch("arch") + unless ruby_platform == WINDOWS_RUBY_PLATFORM + raise "Unsupported Windows Ruby platform: #{ruby_platform}. " \ + "Use RubyInstaller UCRT (#{WINDOWS_RUBY_PLATFORM})." + end + + ruby_root = RbConfig::CONFIG.fetch("prefix") + msys_root = File.join(ruby_root, "msys64") + ucrt_bin = File.join(msys_root, "ucrt64", "bin") + msys_bin = File.join(msys_root, "usr", "bin") + + validate_tools(ucrt_bin, msys_bin) + write_cargo_config(ucrt_bin, msys_bin) + ENV["CARGO_BUILD_TARGET"] = WINDOWS_GNU_TARGET + ENV["LIBCLANG_PATH"] = ucrt_bin + ENV.delete("CMAKE_GENERATOR") + ENV["PATH"] = [ucrt_bin, msys_bin, ENV.fetch("PATH", nil)].compact.join(File::PATH_SEPARATOR) + end + + # Reports missing RubyInstaller packages before Cargo starts compiling. + def self.validate_tools(ucrt_bin, msys_bin) + required = %w[gcc.exe g++.exe gcc-ar.exe clang.exe libclang.dll cmake.exe] + .map { |tool| File.join(ucrt_bin, tool) } + .append(File.join(msys_bin, "make.exe")) + missing = required.reject { |tool| File.file?(tool) } + unless %w[pkgconf.exe pkg-config.exe].any? { |tool| File.file?(File.join(ucrt_bin, tool)) } + missing << File.join(ucrt_bin, "pkgconf.exe") + end + return if missing.empty? + + raise "Missing RubyInstaller UCRT tools: #{missing.join(", ")}. " \ + "Install the Windows build dependencies documented in " \ + "docs/windows-gnu-tokio-crash.md." + end + + # Generates machine-local Cargo defaults that work in command lines and IDEs. + def self.write_cargo_config(ucrt_bin, msys_bin) + config = <<~TOML + # Generated by script/rust_env.rb for the active RubyInstaller. + + [build] + target = "#{WINDOWS_GNU_TARGET}" + + [target.#{WINDOWS_GNU_TARGET}] + linker = "#{File.join(ucrt_bin, "gcc.exe")}" + + [env] + CC_x86_64_pc_windows_gnu = { value = "#{File.join(ucrt_bin, "gcc.exe")}", force = true } + CXX_x86_64_pc_windows_gnu = { value = "#{File.join(ucrt_bin, "g++.exe")}", force = true } + AR_x86_64_pc_windows_gnu = { value = "#{File.join(ucrt_bin, "gcc-ar.exe")}", force = true } + CMAKE = { value = "#{File.join(ucrt_bin, "cmake.exe")}", force = true } + CMAKE_GENERATOR = { value = "MSYS Makefiles", force = true } + CMAKE_MAKE_PROGRAM = { value = "#{File.join(msys_bin, "make.exe")}", force = true } + LIBCLANG_PATH = { value = "#{ucrt_bin}", force = true } + TOML + + return if File.exist?(cargo_config_path) && File.binread(cargo_config_path) == config + + FileUtils.mkdir_p(File.dirname(cargo_config_path)) + File.binwrite(cargo_config_path, config) + end + + # Returns the ignored Cargo configuration shared by Rake and language servers. + def self.cargo_config_path + File.expand_path("../.cargo/windows.toml", __dir__) + end + end +end diff --git a/wreq.gemspec b/wreq.gemspec index 90db9c7..8fd8fb3 100644 --- a/wreq.gemspec +++ b/wreq.gemspec @@ -56,7 +56,7 @@ Gem::Specification.new do |spec| # Exclude non-Ruby files from RDoc to prevent parsing errors spec.rdoc_options = ["--exclude", "Cargo\\..*", "--exclude", "\\.rs$"] - spec.requirements = ["Rust >= 1.85"] + spec.requirements = ["Rust >= 1.97"] # use a Ruby version which: # - supports Rubygems with the ability of compilation of Rust gem # - not end of life