Skip to content

Commit 3c372c2

Browse files
committed
fix(spawn): expand executable paths on Windows before passing to uv_spawn
This fixes issues on Windows where uv_spawn fails to locate certain types of executables in PATH.
1 parent 2fca788 commit 3c372c2

File tree

3 files changed

+85
-60
lines changed

3 files changed

+85
-60
lines changed

lua/mason-core/process.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ function M.spawn(cmd, opts, callback)
224224
if handle == nil then
225225
log.fmt_error("Failed to spawn process. cmd=%s, err=%s", cmd, pid_or_err)
226226
if type(pid_or_err) == "string" and pid_or_err:find "ENOENT" == 1 then
227-
opts.stdio_sink:stderr(("Could not find executable %q in path.\n"):format(cmd))
227+
opts.stdio_sink:stderr(("Could not find executable %q in PATH.\n"):format(cmd))
228228
else
229229
opts.stdio_sink:stderr(("Failed to spawn process cmd=%s err=%s\n"):format(cmd, pid_or_err))
230230
end

lua/mason-core/spawn.lua

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,6 @@ local is_not_nil = _.complement(_.equals(vim.NIL))
1010
---@alias JobSpawn table<string, async fun(opts: SpawnArgs): Result>
1111
---@type JobSpawn
1212
local spawn = {
13-
_aliases = {
14-
npm = platform.is.win and "npm.cmd" or "npm",
15-
gem = platform.is.win and "gem.cmd" or "gem",
16-
composer = platform.is.win and "composer.bat" or "composer",
17-
gradlew = platform.is.win and "gradlew.bat" or "gradlew",
18-
-- for hererocks installations
19-
luarocks = (platform.is.win and vim.fn.executable "luarocks.bat" == 1) and "luarocks.bat" or "luarocks",
20-
rebar3 = platform.is.win and "rebar3.cmd" or "rebar3",
21-
},
2213
_flatten_cmd_args = _.compose(_.filter(is_not_nil), _.flatten),
2314
}
2415

@@ -35,10 +26,7 @@ local function Failure(err, cmd)
3526
}))
3627
end
3728

38-
local is_executable = _.memoize(function(cmd)
39-
a.scheduler()
40-
return vim.fn.executable(cmd) == 1
41-
end, _.identity)
29+
local has_path = _.any(_.starts_with "PATH=")
4230

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

5239
setmetatable(spawn, {
53-
---@param normalized_cmd string
54-
__index = function(self, normalized_cmd)
40+
---@param canonical_cmd string
41+
__index = function(self, canonical_cmd)
5542
---@param args SpawnArgs
5643
return function(args)
5744
local cmd_args = self._flatten_cmd_args(args)
@@ -74,13 +61,15 @@ setmetatable(spawn, {
7461
spawn_args.stdio_sink = process.BufferedSink:new()
7562
end
7663

77-
local cmd = self._aliases[normalized_cmd] or normalized_cmd
64+
local cmd = canonical_cmd
7865

79-
if (env and env.PATH) == nil and args.check_executable ~= false and not is_executable(cmd) then
80-
log.fmt_debug("%s is not executable", cmd)
81-
return Failure({
82-
stderr = ("%s is not executable"):format(cmd),
83-
}, cmd)
66+
-- Find the executable path via vim.fn.exepath on Windows because libuv fails to resolve certain executables
67+
-- in PATH.
68+
if platform.is.win and (spawn_args.env and has_path(spawn_args.env)) == nil then
69+
local expanded_cmd = vim.fn.exepath(canonical_cmd)
70+
if expanded_cmd ~= "" then
71+
cmd = expanded_cmd
72+
end
8473
end
8574

8675
local _, exit_code, signal = a.wait(function(resolve)
@@ -108,12 +97,12 @@ setmetatable(spawn, {
10897
signal = signal,
10998
stdout = table.concat(sink.buffers.stdout, "") or nil,
11099
stderr = table.concat(sink.buffers.stderr, "") or nil,
111-
}, cmd)
100+
}, canonical_cmd)
112101
else
113102
return Failure({
114103
exit_code = exit_code,
115104
signal = signal,
116-
}, cmd)
105+
}, canonical_cmd)
117106
end
118107
end
119108
end

tests/mason-core/spawn_spec.lua

Lines changed: 71 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
local a = require "mason-core.async"
22
local match = require "luassert.match"
3+
local platform = require "mason-core.platform"
34
local process = require "mason-core.process"
45
local spawn = require "mason-core.spawn"
56
local spy = require "luassert.spy"
@@ -146,46 +147,81 @@ describe("async spawn", function()
146147
)
147148
end)
148149

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

158-
it("should skip checking whether command is executable", function()
159-
stub(process, "spawn", function(_, _, callback)
160-
callback(false, 127)
155+
after_each(function()
156+
platform.is.win = nil
161157
end)
162158

163-
local result = a.run_blocking(spawn.my_cmd, { "arg1", check_executable = false })
164-
assert.is_true(result:is_failure())
165-
assert.spy(process.spawn).was_called(1)
166-
assert.spy(process.spawn).was_called_with(
167-
"my_cmd",
168-
match.tbl_containing {
169-
args = match.same { "arg1" },
170-
},
171-
match.is_function()
172-
)
173-
end)
159+
it("should use exepath to get absolute path to executable", function()
160+
stub(process, "spawn", function(_, _, callback)
161+
callback(true, 0, 0)
162+
end)
163+
164+
local result = a.run_blocking(spawn.bash, { "arg1" })
165+
assert.is_true(result:is_success())
166+
assert.spy(process.spawn).was_called(1)
167+
assert.spy(process.spawn).was_called_with(
168+
vim.fn.exepath "bash",
169+
match.tbl_containing {
170+
args = match.same { "arg1" },
171+
},
172+
match.is_function()
173+
)
174+
end)
174175

175-
it("should skip checking whether command is executable if with_paths is provided", function()
176-
stub(process, "spawn", function(_, _, callback)
177-
callback(false, 127)
176+
it("should not use exepath if env.PATH is set", function()
177+
stub(process, "spawn", function(_, _, callback)
178+
callback(true, 0, 0)
179+
end)
180+
181+
local result = a.run_blocking(spawn.bash, { "arg1", env = { PATH = "C:\\some\\path" } })
182+
assert.is_true(result:is_success())
183+
assert.spy(process.spawn).was_called(1)
184+
assert.spy(process.spawn).was_called_with(
185+
"bash",
186+
match.tbl_containing {
187+
args = match.same { "arg1" },
188+
},
189+
match.is_function()
190+
)
178191
end)
179192

180-
local result = a.run_blocking(spawn.my_cmd, { "arg1", with_paths = {} })
181-
assert.is_true(result:is_failure())
182-
assert.spy(process.spawn).was_called(1)
183-
assert.spy(process.spawn).was_called_with(
184-
"my_cmd",
185-
match.tbl_containing {
186-
args = match.same { "arg1" },
187-
},
188-
match.is_function()
189-
)
193+
it("should not use exepath if env_raw.PATH is set", function()
194+
stub(process, "spawn", function(_, _, callback)
195+
callback(true, 0, 0)
196+
end)
197+
198+
local result = a.run_blocking(spawn.bash, { "arg1", env_raw = { "PATH=C:\\some\\path" } })
199+
assert.is_true(result:is_success())
200+
assert.spy(process.spawn).was_called(1)
201+
assert.spy(process.spawn).was_called_with(
202+
"bash",
203+
match.tbl_containing {
204+
args = match.same { "arg1" },
205+
},
206+
match.is_function()
207+
)
208+
end)
209+
210+
it("should not use exepath if with_paths is provided", function()
211+
stub(process, "spawn", function(_, _, callback)
212+
callback(true, 0, 0)
213+
end)
214+
215+
local result = a.run_blocking(spawn.bash, { "arg1", with_paths = { "C:\\some\\path" } })
216+
assert.is_true(result:is_success())
217+
assert.spy(process.spawn).was_called(1)
218+
assert.spy(process.spawn).was_called_with(
219+
"bash",
220+
match.tbl_containing {
221+
args = match.same { "arg1" },
222+
},
223+
match.is_function()
224+
)
225+
end)
190226
end)
191227
end)

0 commit comments

Comments
 (0)