Merge pull request #25006 from lewis6991/fix/systemkill

`vim.system` fixes and improvements
This commit is contained in:
Lewis Russell 2023-09-05 21:50:18 +01:00 committed by GitHub
commit 4ce9875feb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 203 additions and 118 deletions

View File

@ -1777,7 +1777,9 @@ vim.system({cmd}, {opts}, {on_exit}) *vim.system()*
`fun(err: string, data: string)`. Defaults to `true`. `fun(err: string, data: string)`. Defaults to `true`.
• text: (boolean) Handle stdout and stderr as text. • text: (boolean) Handle stdout and stderr as text.
Replaces `\r\n` with `\n`. Replaces `\r\n` with `\n`.
• timeout: (integer) • timeout: (integer) Run the command with a time limit.
Upon timeout the process is sent the TERM signal (15) and
the exit code is set to 124.
• detach: (boolean) If true, spawn the child process in a • detach: (boolean) If true, spawn the child process in a
detached state - this will make it a process group detached state - this will make it a process group
leader, and will effectively enable the child to keep leader, and will effectively enable the child to keep
@ -1790,16 +1792,18 @@ vim.system({cmd}, {opts}, {on_exit}) *vim.system()*
object, see return of SystemObj:wait(). object, see return of SystemObj:wait().
Return: ~ Return: ~
SystemObj Object with the fields: vim.SystemObj Object with the fields:
• pid (integer) Process ID • pid (integer) Process ID
• wait (fun(timeout: integer|nil): SystemCompleted) • wait (fun(timeout: integer|nil): SystemCompleted) Wait for the
process to complete. Upon timeout the process is sent the KILL
signal (9) and the exit code is set to 124.
• SystemCompleted is an object with the fields: • SystemCompleted is an object with the fields:
• code: (integer) • code: (integer)
• signal: (integer) • signal: (integer)
• stdout: (string), nil if stdout argument is passed • stdout: (string), nil if stdout argument is passed
• stderr: (string), nil if stderr argument is passed • stderr: (string), nil if stderr argument is passed
• kill (fun(signal: integer)) • kill (fun(signal: integer|string))
• write (fun(data: string|nil)) Requires `stdin=true`. Pass `nil` to • write (fun(data: string|nil)) Requires `stdin=true`. Pass `nil` to
close the stream. close the stream.
• is_closing (fun(): boolean) • is_closing (fun(): boolean)
@ -2541,7 +2545,7 @@ vim.ui.open({path}) *vim.ui.open()*
• {path} (string) Path or URL to open • {path} (string) Path or URL to open
Return (multiple): ~ Return (multiple): ~
SystemCompleted|nil Command result, or nil if not found. vim.SystemCompleted|nil Command result, or nil if not found.
(string|nil) Error message on failure (string|nil) Error message on failure
See also: ~ See also: ~

View File

@ -107,7 +107,8 @@ vim.log = {
--- Handle output from stdout. When passed as a function must have the signature `fun(err: string, data: string)`. --- Handle output from stdout. When passed as a function must have the signature `fun(err: string, data: string)`.
--- Defaults to `true`. --- Defaults to `true`.
--- - text: (boolean) Handle stdout and stderr as text. Replaces `\r\n` with `\n`. --- - text: (boolean) Handle stdout and stderr as text. Replaces `\r\n` with `\n`.
--- - timeout: (integer) --- - timeout: (integer) Run the command with a time limit. Upon timeout the process is sent the
--- TERM signal (15) and the exit code is set to 124.
--- - detach: (boolean) If true, spawn the child process in a detached state - this will make it --- - detach: (boolean) If true, spawn the child process in a detached state - this will make it
--- a process group leader, and will effectively enable the child to keep running after the --- a process group leader, and will effectively enable the child to keep running after the
--- parent exits. Note that the child process will still keep the parent's event loop alive --- parent exits. Note that the child process will still keep the parent's event loop alive
@ -116,15 +117,16 @@ vim.log = {
--- @param on_exit (function|nil) Called when subprocess exits. When provided, the command runs --- @param on_exit (function|nil) Called when subprocess exits. When provided, the command runs
--- asynchronously. Receives SystemCompleted object, see return of SystemObj:wait(). --- asynchronously. Receives SystemCompleted object, see return of SystemObj:wait().
--- ---
--- @return SystemObj Object with the fields: --- @return vim.SystemObj Object with the fields:
--- - pid (integer) Process ID --- - pid (integer) Process ID
--- - wait (fun(timeout: integer|nil): SystemCompleted) --- - wait (fun(timeout: integer|nil): SystemCompleted) Wait for the process to complete. Upon
--- timeout the process is sent the KILL signal (9) and the exit code is set to 124.
--- - SystemCompleted is an object with the fields: --- - SystemCompleted is an object with the fields:
--- - code: (integer) --- - code: (integer)
--- - signal: (integer) --- - signal: (integer)
--- - stdout: (string), nil if stdout argument is passed --- - stdout: (string), nil if stdout argument is passed
--- - stderr: (string), nil if stderr argument is passed --- - stderr: (string), nil if stderr argument is passed
--- - kill (fun(signal: integer)) --- - kill (fun(signal: integer|string))
--- - write (fun(data: string|nil)) Requires `stdin=true`. Pass `nil` to close the stream. --- - write (fun(data: string|nil)) Requires `stdin=true`. Pass `nil` to close the stream.
--- - is_closing (fun(): boolean) --- - is_closing (fun(): boolean)
function vim.system(cmd, opts, on_exit) function vim.system(cmd, opts, on_exit)

View File

@ -2,8 +2,8 @@ local uv = vim.uv
--- @class SystemOpts --- @class SystemOpts
--- @field stdin? string|string[]|true --- @field stdin? string|string[]|true
--- @field stdout? fun(err:string, data: string)|false --- @field stdout? fun(err:string?, data: string?)|false
--- @field stderr? fun(err:string, data: string)|false --- @field stderr? fun(err:string?, data: string?)|false
--- @field cwd? string --- @field cwd? string
--- @field env? table<string,string|number> --- @field env? table<string,string|number>
--- @field clear_env? boolean --- @field clear_env? boolean
@ -11,51 +11,61 @@ local uv = vim.uv
--- @field timeout? integer Timeout in ms --- @field timeout? integer Timeout in ms
--- @field detach? boolean --- @field detach? boolean
--- @class SystemCompleted --- @class vim.SystemCompleted
--- @field code integer --- @field code integer
--- @field signal integer --- @field signal integer
--- @field stdout? string --- @field stdout? string
--- @field stderr? string --- @field stderr? string
--- @class SystemState --- @class vim.SystemState
--- @field handle? uv.uv_process_t --- @field handle? uv.uv_process_t
--- @field timer? uv.uv_timer_t --- @field timer? uv.uv_timer_t
--- @field pid? integer --- @field pid? integer
--- @field timeout? integer --- @field timeout? integer
--- @field done? boolean --- @field done? boolean|'timeout'
--- @field stdin? uv.uv_stream_t --- @field stdin? uv.uv_stream_t
--- @field stdout? uv.uv_stream_t --- @field stdout? uv.uv_stream_t
--- @field stderr? uv.uv_stream_t --- @field stderr? uv.uv_stream_t
--- @field result? SystemCompleted --- @field stdout_data? string[]
--- @field stderr_data? string[]
--- @field result? vim.SystemCompleted
---@param state SystemState --- @enum vim.SystemSig
local function close_handles(state) local SIG = {
for _, handle in pairs({ state.handle, state.stdin, state.stdout, state.stderr }) do HUP = 1, -- Hangup
if not handle:is_closing() then INT = 2, -- Interrupt from keyboard
handle:close() KILL = 9, -- Kill signal
end TERM = 15, -- Termination signal
-- STOP = 17,19,23 -- Stop the process
}
---@param handle uv.uv_handle_t?
local function close_handle(handle)
if handle and not handle:is_closing() then
handle:close()
end end
end end
--- @param cmd string[] ---@param state vim.SystemState
--- @return SystemCompleted local function close_handles(state)
local function timeout_result(cmd) close_handle(state.handle)
local cmd_str = table.concat(cmd, ' ') close_handle(state.stdin)
local err = string.format("Command timed out: '%s'", cmd_str) close_handle(state.stdout)
return { code = 0, signal = 2, stdout = '', stderr = err } close_handle(state.stderr)
close_handle(state.timer)
end end
--- @class SystemObj --- @class vim.SystemObj
--- @field pid integer --- @field pid integer
--- @field private _state SystemState --- @field private _state vim.SystemState
--- @field wait fun(self: SystemObj, timeout?: integer): SystemCompleted --- @field wait fun(self: vim.SystemObj, timeout?: integer): vim.SystemCompleted
--- @field kill fun(self: SystemObj, signal: integer) --- @field kill fun(self: vim.SystemObj, signal: integer|string)
--- @field write fun(self: SystemObj, data?: string|string[]) --- @field write fun(self: vim.SystemObj, data?: string|string[])
--- @field is_closing fun(self: SystemObj): boolean? --- @field is_closing fun(self: vim.SystemObj): boolean?
local SystemObj = {} local SystemObj = {}
--- @param state SystemState --- @param state vim.SystemState
--- @return SystemObj --- @return vim.SystemObj
local function new_systemobj(state) local function new_systemobj(state)
return setmetatable({ return setmetatable({
pid = state.pid, pid = state.pid,
@ -63,27 +73,35 @@ local function new_systemobj(state)
}, { __index = SystemObj }) }, { __index = SystemObj })
end end
--- @param signal integer --- @param signal integer|string
function SystemObj:kill(signal) function SystemObj:kill(signal)
local state = self._state self._state.handle:kill(signal)
state.handle:kill(signal) end
close_handles(state)
--- @package
--- @param signal? vim.SystemSig
function SystemObj:_timeout(signal)
self._state.done = 'timeout'
self:kill(signal or SIG.TERM)
end end
local MAX_TIMEOUT = 2 ^ 31 local MAX_TIMEOUT = 2 ^ 31
--- @param timeout? integer --- @param timeout? integer
--- @return SystemCompleted --- @return vim.SystemCompleted
function SystemObj:wait(timeout) function SystemObj:wait(timeout)
local state = self._state local state = self._state
vim.wait(timeout or state.timeout or MAX_TIMEOUT, function() local done = vim.wait(timeout or state.timeout or MAX_TIMEOUT, function()
return state.done return state.result ~= nil
end) end)
if not state.done then if not done then
self:kill(6) -- 'sigint' -- Send sigkill since this cannot be caught
state.result = timeout_result(state.cmd) self:_timeout(SIG.KILL)
vim.wait(timeout or state.timeout or MAX_TIMEOUT, function()
return state.result ~= nil
end)
end end
return state.result return state.result
@ -125,9 +143,9 @@ function SystemObj:is_closing()
return handle == nil or handle:is_closing() return handle == nil or handle:is_closing()
end end
---@param output function|'false' ---@param output fun(err:string?, data: string?)|false
---@return uv.uv_stream_t? ---@return uv.uv_stream_t?
---@return function? Handler ---@return fun(err:string?, data: string?)? Handler
local function setup_output(output) local function setup_output(output)
if output == nil then if output == nil then
return assert(uv.new_pipe(false)), nil return assert(uv.new_pipe(false)), nil
@ -159,7 +177,7 @@ end
--- @return table<string,string> --- @return table<string,string>
local function base_env() local function base_env()
local env = vim.fn.environ() local env = vim.fn.environ() --- @type table<string,string>
env['NVIM'] = vim.v.servername env['NVIM'] = vim.v.servername
env['NVIM_LISTEN_ADDRESS'] = nil env['NVIM_LISTEN_ADDRESS'] = nil
return env return env
@ -212,7 +230,7 @@ end
local M = {} local M = {}
--- @param cmd string --- @param cmd string
--- @param opts uv.aliases.spawn_options --- @param opts uv.spawn.options
--- @param on_exit fun(code: integer, signal: integer) --- @param on_exit fun(code: integer, signal: integer)
--- @param on_error fun() --- @param on_error fun()
--- @return uv.uv_process_t, integer --- @return uv.uv_process_t, integer
@ -225,12 +243,68 @@ local function spawn(cmd, opts, on_exit, on_error)
return handle, pid_or_err --[[@as integer]] return handle, pid_or_err --[[@as integer]]
end end
---@param timeout integer
---@param cb fun()
---@return uv.uv_timer_t
local function timer_oneshot(timeout, cb)
local timer = assert(uv.new_timer())
timer:start(timeout, 0, function()
timer:stop()
timer:close()
cb()
end)
return timer
end
--- @param state vim.SystemState
--- @param code integer
--- @param signal integer
--- @param on_exit fun(result: vim.SystemCompleted)?
local function _on_exit(state, code, signal, on_exit)
close_handles(state)
local check = assert(uv.new_check())
check:start(function()
for _, pipe in pairs({ state.stdin, state.stdout, state.stderr }) do
if not pipe:is_closing() then
return
end
end
check:stop()
check:close()
if state.done == nil then
state.done = true
end
if (code == 0 or code == 1) and state.done == 'timeout' then
-- Unix: code == 0
-- Windows: code == 1
code = 124
end
local stdout_data = state.stdout_data
local stderr_data = state.stderr_data
state.result = {
code = code,
signal = signal,
stdout = stdout_data and table.concat(stdout_data) or nil,
stderr = stderr_data and table.concat(stderr_data) or nil,
}
if on_exit then
on_exit(state.result)
end
end)
end
--- Run a system command --- Run a system command
--- ---
--- @param cmd string[] --- @param cmd string[]
--- @param opts? SystemOpts --- @param opts? SystemOpts
--- @param on_exit? fun(out: SystemCompleted) --- @param on_exit? fun(out: vim.SystemCompleted)
--- @return SystemObj --- @return vim.SystemObj
function M.run(cmd, opts, on_exit) function M.run(cmd, opts, on_exit)
vim.validate({ vim.validate({
cmd = { cmd, 'table' }, cmd = { cmd, 'table' },
@ -244,7 +318,7 @@ function M.run(cmd, opts, on_exit)
local stderr, stderr_handler = setup_output(opts.stderr) local stderr, stderr_handler = setup_output(opts.stderr)
local stdin, towrite = setup_input(opts.stdin) local stdin, towrite = setup_input(opts.stdin)
--- @type SystemState --- @type vim.SystemState
local state = { local state = {
done = false, done = false,
cmd = cmd, cmd = cmd,
@ -254,60 +328,29 @@ function M.run(cmd, opts, on_exit)
stderr = stderr, stderr = stderr,
} }
-- Define data buckets as tables and concatenate the elements at the end as --- @diagnostic disable-next-line:missing-fields
-- one operation.
--- @type string[], string[]
local stdout_data, stderr_data
state.handle, state.pid = spawn(cmd[1], { state.handle, state.pid = spawn(cmd[1], {
args = vim.list_slice(cmd, 2), args = vim.list_slice(cmd, 2),
stdio = { stdin, stdout, stderr }, stdio = { stdin, stdout, stderr },
cwd = opts.cwd, cwd = opts.cwd,
--- @diagnostic disable-next-line:assign-type-mismatch
env = setup_env(opts.env, opts.clear_env), env = setup_env(opts.env, opts.clear_env),
detached = opts.detach, detached = opts.detach,
hide = true, hide = true,
}, function(code, signal) }, function(code, signal)
close_handles(state) _on_exit(state, code, signal, on_exit)
if state.timer then
state.timer:stop()
state.timer:close()
end
local check = assert(uv.new_check())
check:start(function()
for _, pipe in pairs({ state.stdin, state.stdout, state.stderr }) do
if not pipe:is_closing() then
return
end
end
check:stop()
check:close()
state.done = true
state.result = {
code = code,
signal = signal,
stdout = stdout_data and table.concat(stdout_data) or nil,
stderr = stderr_data and table.concat(stderr_data) or nil,
}
if on_exit then
on_exit(state.result)
end
end)
end, function() end, function()
close_handles(state) close_handles(state)
end) end)
if stdout then if stdout then
stdout_data = {} state.stdout_data = {}
stdout:read_start(stdout_handler or default_handler(stdout, opts.text, stdout_data)) stdout:read_start(stdout_handler or default_handler(stdout, opts.text, state.stdout_data))
end end
if stderr then if stderr then
stderr_data = {} state.stderr_data = {}
stderr:read_start(stderr_handler or default_handler(stderr, opts.text, stderr_data)) stderr:read_start(stderr_handler or default_handler(stderr, opts.text, state.stderr_data))
end end
local obj = new_systemobj(state) local obj = new_systemobj(state)
@ -318,16 +361,9 @@ function M.run(cmd, opts, on_exit)
end end
if opts.timeout then if opts.timeout then
state.timer = assert(uv.new_timer()) state.timer = timer_oneshot(opts.timeout, function()
state.timer:start(opts.timeout, 0, function()
state.timer:stop()
state.timer:close()
if state.handle and state.handle:is_active() then if state.handle and state.handle:is_active() then
obj:kill(6) --- 'sigint' obj:_timeout()
state.result = timeout_result(state.cmd)
if on_exit then
on_exit(state.result)
end
end end
end) end)
end end

View File

@ -648,7 +648,7 @@ function M.start(cmd, cmd_args, dispatchers, extra_spawn_params)
dispatchers = merge_dispatchers(dispatchers) dispatchers = merge_dispatchers(dispatchers)
local sysobj ---@type SystemObj local sysobj ---@type vim.SystemObj
local client = new_client(dispatchers, { local client = new_client(dispatchers, {
write = function(msg) write = function(msg)
@ -708,7 +708,7 @@ function M.start(cmd, cmd_args, dispatchers, extra_spawn_params)
return return
end end
sysobj = sysobj_or_err --[[@as SystemObj]] sysobj = sysobj_or_err --[[@as vim.SystemObj]]
return public_client(client) return public_client(client)
end end

View File

@ -118,7 +118,7 @@ end
--- ---
---@param path string Path or URL to open ---@param path string Path or URL to open
--- ---
---@return SystemCompleted|nil # Command result, or nil if not found. ---@return vim.SystemCompleted|nil # Command result, or nil if not found.
---@return string|nil # Error message on failure ---@return string|nil # Error message on failure
--- ---
---@see |vim.system()| ---@see |vim.system()|

View File

@ -5,27 +5,46 @@ local eq = helpers.eq
local function system_sync(cmd, opts) local function system_sync(cmd, opts)
return exec_lua([[ return exec_lua([[
return vim.system(...):wait() local cmd, opts = ...
local obj = vim.system(...)
if opts.timeout then
-- Minor delay before calling wait() so the timeout uv timer can have a headstart over the
-- internal call to vim.wait() in wait().
vim.wait(10)
end
local res = obj:wait()
-- Check the process is no longer running
local proc = vim.api.nvim_get_proc(obj.pid)
assert(not proc, 'process still exists')
return res
]], cmd, opts) ]], cmd, opts)
end end
local function system_async(cmd, opts) local function system_async(cmd, opts)
exec_lua([[ return exec_lua([[
local cmd, opts = ... local cmd, opts = ...
_G.done = false _G.done = false
vim.system(cmd, opts, function(obj) local obj = vim.system(cmd, opts, function(obj)
_G.done = true _G.done = true
_G.ret = obj _G.ret = obj
end) end)
local ok = vim.wait(10000, function()
return _G.done
end)
assert(ok, 'process did not exit')
-- Check the process is no longer running
local proc = vim.api.nvim_get_proc(obj.pid)
assert(not proc, 'process still exists')
return _G.ret
]], cmd, opts) ]], cmd, opts)
while true do
if exec_lua[[return _G.done]] then
break
end
end
return exec_lua[[return _G.ret]]
end end
describe('vim.system', function() describe('vim.system', function()
@ -43,15 +62,39 @@ describe('vim.system', function()
eq('hellocat', system({ 'cat' }, { stdin = 'hellocat', text = true }).stdout) eq('hellocat', system({ 'cat' }, { stdin = 'hellocat', text = true }).stdout)
end) end)
it ('supports timeout', function() it('supports timeout', function()
eq({ eq({
code = 0, code = 124,
signal = 2, signal = 15,
stdout = '', stdout = '',
stderr = "Command timed out: 'sleep 10'" stderr = ''
}, system({ 'sleep', '10' }, { timeout = 1 })) }, system({ 'sleep', '10' }, { timeout = 1000 }))
end) end)
end) end)
end end
it('kill processes', function()
exec_lua([[
local signal
local cmd = vim.system({ 'cat', '-' }, { stdin = true }, function(r)
signal = r.signal
end) -- run forever
cmd:kill('sigint')
-- wait for the process not to exist
local done = vim.wait(2000, function()
return signal ~= nil
end)
assert(done, 'process did not exit')
-- Check the process is no longer running
local proc = vim.api.nvim_get_proc(cmd.pid)
assert(not proc, 'process still exists')
assert(signal == 2)
]])
end)
end) end)