Skip to content

Commit

Permalink
fix(spawn): expand executable paths on Windows before passing to uv_s…
Browse files Browse the repository at this point in the history
…pawn

This fixes issues on Windows where uv_spawn fails to locate certain
types of executables in PATH.
  • Loading branch information
williamboman committed Feb 20, 2025
1 parent 2fca788 commit 3c372c2
Show file tree
Hide file tree
Showing 3 changed files with 85 additions and 60 deletions.
2 changes: 1 addition & 1 deletion lua/mason-core/process.lua
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ function M.spawn(cmd, opts, callback)
if handle == nil then
log.fmt_error("Failed to spawn process. cmd=%s, err=%s", cmd, pid_or_err)
if type(pid_or_err) == "string" and pid_or_err:find "ENOENT" == 1 then
opts.stdio_sink:stderr(("Could not find executable %q in path.\n"):format(cmd))
opts.stdio_sink:stderr(("Could not find executable %q in PATH.\n"):format(cmd))
else
opts.stdio_sink:stderr(("Failed to spawn process cmd=%s err=%s\n"):format(cmd, pid_or_err))
end
Expand Down
37 changes: 13 additions & 24 deletions lua/mason-core/spawn.lua
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,6 @@ local is_not_nil = _.complement(_.equals(vim.NIL))
---@alias JobSpawn table<string, async fun(opts: SpawnArgs): Result>
---@type JobSpawn
local spawn = {
_aliases = {
npm = platform.is.win and "npm.cmd" or "npm",
gem = platform.is.win and "gem.cmd" or "gem",
composer = platform.is.win and "composer.bat" or "composer",
gradlew = platform.is.win and "gradlew.bat" or "gradlew",
-- for hererocks installations
luarocks = (platform.is.win and vim.fn.executable "luarocks.bat" == 1) and "luarocks.bat" or "luarocks",
rebar3 = platform.is.win and "rebar3.cmd" or "rebar3",
},
_flatten_cmd_args = _.compose(_.filter(is_not_nil), _.flatten),
}

Expand All @@ -35,10 +26,7 @@ local function Failure(err, cmd)
}))
end

local is_executable = _.memoize(function(cmd)
a.scheduler()
return vim.fn.executable(cmd) == 1
end, _.identity)
local has_path = _.any(_.starts_with "PATH=")

---@class SpawnArgs
---@field with_paths string[]? Paths to add to the PATH environment variable.
Expand All @@ -47,11 +35,10 @@ end, _.identity)
---@field stdio_sink StdioSink? If provided, will be used to write to stdout and stderr.
---@field cwd string?
---@field on_spawn (fun(handle: luv_handle, stdio: luv_pipe[], pid: integer))? Will be called when the process successfully spawns.
---@field check_executable boolean? Whether to check if the provided command is executable (defaults to true).

setmetatable(spawn, {
---@param normalized_cmd string
__index = function(self, normalized_cmd)
---@param canonical_cmd string
__index = function(self, canonical_cmd)
---@param args SpawnArgs
return function(args)
local cmd_args = self._flatten_cmd_args(args)
Expand All @@ -74,13 +61,15 @@ setmetatable(spawn, {
spawn_args.stdio_sink = process.BufferedSink:new()
end

local cmd = self._aliases[normalized_cmd] or normalized_cmd
local cmd = canonical_cmd

if (env and env.PATH) == nil and args.check_executable ~= false and not is_executable(cmd) then
log.fmt_debug("%s is not executable", cmd)
return Failure({
stderr = ("%s is not executable"):format(cmd),
}, cmd)
-- Find the executable path via vim.fn.exepath on Windows because libuv fails to resolve certain executables
-- in PATH.
if platform.is.win and (spawn_args.env and has_path(spawn_args.env)) == nil then
local expanded_cmd = vim.fn.exepath(canonical_cmd)
if expanded_cmd ~= "" then
cmd = expanded_cmd
end
end

local _, exit_code, signal = a.wait(function(resolve)
Expand Down Expand Up @@ -108,12 +97,12 @@ setmetatable(spawn, {
signal = signal,
stdout = table.concat(sink.buffers.stdout, "") or nil,
stderr = table.concat(sink.buffers.stderr, "") or nil,
}, cmd)
}, canonical_cmd)
else
return Failure({
exit_code = exit_code,
signal = signal,
}, cmd)
}, canonical_cmd)
end
end
end
Expand Down
106 changes: 71 additions & 35 deletions tests/mason-core/spawn_spec.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
local a = require "mason-core.async"
local match = require "luassert.match"
local platform = require "mason-core.platform"
local process = require "mason-core.process"
local spawn = require "mason-core.spawn"
local spy = require "luassert.spy"
Expand Down Expand Up @@ -146,46 +147,81 @@ describe("async spawn", function()
)
end)

it("should check whether command is executable", function()
local result = a.run_blocking(spawn.my_cmd, {})
assert.is_true(result:is_failure())
assert.equals(
"spawn: my_cmd failed with exit code - and signal -. my_cmd is not executable",
tostring(result:err_or_nil())
)
end)
describe("Windows", function()
before_each(function()
platform.is.win = true
end)

it("should skip checking whether command is executable", function()
stub(process, "spawn", function(_, _, callback)
callback(false, 127)
after_each(function()
platform.is.win = nil
end)

local result = a.run_blocking(spawn.my_cmd, { "arg1", check_executable = false })
assert.is_true(result:is_failure())
assert.spy(process.spawn).was_called(1)
assert.spy(process.spawn).was_called_with(
"my_cmd",
match.tbl_containing {
args = match.same { "arg1" },
},
match.is_function()
)
end)
it("should use exepath to get absolute path to executable", function()
stub(process, "spawn", function(_, _, callback)
callback(true, 0, 0)
end)

local result = a.run_blocking(spawn.bash, { "arg1" })
assert.is_true(result:is_success())
assert.spy(process.spawn).was_called(1)
assert.spy(process.spawn).was_called_with(
vim.fn.exepath "bash",
match.tbl_containing {
args = match.same { "arg1" },
},
match.is_function()
)
end)

it("should skip checking whether command is executable if with_paths is provided", function()
stub(process, "spawn", function(_, _, callback)
callback(false, 127)
it("should not use exepath if env.PATH is set", function()
stub(process, "spawn", function(_, _, callback)
callback(true, 0, 0)
end)

local result = a.run_blocking(spawn.bash, { "arg1", env = { PATH = "C:\\some\\path" } })
assert.is_true(result:is_success())
assert.spy(process.spawn).was_called(1)
assert.spy(process.spawn).was_called_with(
"bash",
match.tbl_containing {
args = match.same { "arg1" },
},
match.is_function()
)
end)

local result = a.run_blocking(spawn.my_cmd, { "arg1", with_paths = {} })
assert.is_true(result:is_failure())
assert.spy(process.spawn).was_called(1)
assert.spy(process.spawn).was_called_with(
"my_cmd",
match.tbl_containing {
args = match.same { "arg1" },
},
match.is_function()
)
it("should not use exepath if env_raw.PATH is set", function()
stub(process, "spawn", function(_, _, callback)
callback(true, 0, 0)
end)

local result = a.run_blocking(spawn.bash, { "arg1", env_raw = { "PATH=C:\\some\\path" } })
assert.is_true(result:is_success())
assert.spy(process.spawn).was_called(1)
assert.spy(process.spawn).was_called_with(
"bash",
match.tbl_containing {
args = match.same { "arg1" },
},
match.is_function()
)
end)

it("should not use exepath if with_paths is provided", function()
stub(process, "spawn", function(_, _, callback)
callback(true, 0, 0)
end)

local result = a.run_blocking(spawn.bash, { "arg1", with_paths = { "C:\\some\\path" } })
assert.is_true(result:is_success())
assert.spy(process.spawn).was_called(1)
assert.spy(process.spawn).was_called_with(
"bash",
match.tbl_containing {
args = match.same { "arg1" },
},
match.is_function()
)
end)
end)
end)

0 comments on commit 3c372c2

Please sign in to comment.