Skip to content

Commit 0e2b9a6

Browse files
committed
fix(server): refactor server with a uv detached process
- Refactored OpencodeServer to use detached process groups for reliable shutdown of subprocesses. - Ensured SIGTERM/SIGKILL are sent both to the process group and the direct process. - Improved resource cleanup for libuv pipes and process handles. - Updated and expanded unit tests to mock libuv APIs and test server lifecycle, including error handling and exit events. This should hopefully resolves #270
1 parent 5ce7f13 commit 0e2b9a6

2 files changed

Lines changed: 278 additions & 132 deletions

File tree

lua/opencode/opencode_server.lua

Lines changed: 128 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ local Promise = require('opencode.promise')
44
local config = require('opencode.config')
55

66
--- @class OpencodeServer
7-
--- @field job any The vim.system job handle
7+
--- @field job any The process handle and metadata
88
--- @field url string|nil The server URL once ready
99
--- @field handle any Compatibility property for job.stop interface
1010
--- @field spawn_promise Promise<OpencodeServer>
@@ -19,11 +19,12 @@ local function ensure_vim_leave_autocmd()
1919
end
2020
vim_leave_setup = true
2121

22-
vim.api.nvim_create_autocmd('VimLeavePre', {
22+
vim.api.nvim_create_autocmd({ 'VimLeavePre', 'VimLeave' }, {
2323
group = vim.api.nvim_create_augroup('OpencodeVimLeavePre', { clear = true }),
24-
callback = function()
25-
local state = require('opencode.state')
24+
callback = function(event)
2625
local log = require('opencode.log')
26+
log.debug('VimLeave event triggered: %s', event.event)
27+
local state = require('opencode.state')
2728
if state.opencode_server then
2829
state.opencode_server:shutdown()
2930
end
@@ -34,7 +35,6 @@ end
3435
--- Create a new ServerJob instance
3536
--- @return OpencodeServer
3637
function OpencodeServer.new()
37-
local log = require('opencode.log')
3838
ensure_vim_leave_autocmd()
3939

4040
return setmetatable({
@@ -50,34 +50,52 @@ function OpencodeServer:is_running()
5050
return self.job and self.job.pid ~= nil
5151
end
5252

53-
local function kill_process(pid, signal, desc)
53+
local function kill_process(pid, signal, desc, pgid)
5454
local log = require('opencode.log')
55-
local ok, err = pcall(vim.uv.kill, pid, signal)
56-
log.debug('shutdown: %s pid=%d sig=%d ok=%s err=%s', desc, pid, signal, tostring(ok), tostring(err))
57-
return ok, err
55+
local target = pgid and -pid or pid
56+
local ok, err = pcall(vim.uv.kill, assert(tonumber(target)), assert(tonumber(signal)))
57+
log.debug('shutdown: %s target=%d sig=%d ok=%s err=%s', desc, target, signal, tostring(ok), tostring(err))
58+
if ok then
59+
return true, nil
60+
else
61+
return false, tostring(err)
62+
end
63+
end
64+
65+
--- Close a libuv pipe handle safely.
66+
--- @param pipe userdata|nil
67+
local function close_pipe(pipe)
68+
if pipe and not pipe:is_closing() then
69+
pipe:read_stop()
70+
pipe:close()
71+
end
5872
end
5973

6074
function OpencodeServer:shutdown()
6175
local log = require('opencode.log')
62-
if self.shutdown_promise:is_resolved() then
76+
77+
local pid = self.job and self.job.pid
78+
if self.shutdown_promise:is_resolved() and not pid then
79+
log.debug('shutdown: already resolved, returning existing promise %d', pid or -1)
6380
return self.shutdown_promise
6481
end
6582

6683
if self.job and self.job.pid then
67-
---@cast self.job vim.SystemObj
68-
local pid = self.job.pid
69-
local children = vim.api.nvim_get_proc_children(pid)
84+
local process_handle = self.job.process_handle
7085

71-
if #children > 0 then
72-
log.debug('shutdown: process pid=%d has %d children (%s)', pid, #children, vim.inspect(children))
86+
kill_process(pid, 15, 'SIGTERM process group', true)
87+
kill_process(pid, 15, 'SIGTERM direct', false)
7388

74-
for _, cid in ipairs(children) do
75-
kill_process(cid, 15, 'SIGTERM child')
76-
end
89+
kill_process(pid, 9, 'SIGKILL process group (escalation)', true)
90+
kill_process(pid, 9, 'SIGKILL direct (escalation)', false)
91+
92+
-- Close the process handle if still alive
93+
if process_handle and not process_handle:is_closing() then
94+
process_handle:close()
7795
end
7896

79-
kill_process(pid, 15, 'SIGTERM')
80-
kill_process(pid, 9, 'SIGKILL')
97+
close_pipe(self.job.stdout_pipe)
98+
close_pipe(self.job.stderr_pipe)
8199
else
82100
log.debug('shutdown: no job running')
83101
end
@@ -93,65 +111,111 @@ end
93111
--- @field cwd? string
94112
--- @field on_ready fun(job: any, url: string)
95113
--- @field on_error fun(err: any)
96-
--- @field on_exit fun(exit_opts: vim.SystemCompleted )
114+
--- @field on_exit fun(exit_opts: vim.SystemCompleted)
97115

98116
--- Spawn the opencode server for this ServerJob instance.
117+
--- Uses vim.uv.spawn with detached=true so the server and all its children
118+
--- belong to their own process group, allowing reliable cleanup on shutdown.
99119
--- @param opts? OpencodeServerSpawnOpts
100120
--- @return Promise<OpencodeServer>
101121
function OpencodeServer:spawn(opts)
102122
opts = opts or {}
103123
local log = require('opencode.log')
104124

105-
self.job = vim.system({
106-
config.opencode_executable,
107-
'serve',
108-
}, {
125+
local stdout_pipe = vim.uv.new_pipe(false)
126+
local stderr_pipe = vim.uv.new_pipe(false)
127+
128+
if not stdout_pipe or not stderr_pipe then
129+
local err = 'Failed to create libuv pipes'
130+
self.spawn_promise:reject(err)
131+
safe_call(opts.on_error, err)
132+
return self.spawn_promise
133+
end
134+
135+
local process_handle, pid
136+
process_handle, pid = vim.uv.spawn(config.opencode_executable, {
137+
args = { 'serve' },
109138
cwd = opts.cwd,
110-
stdout = function(err, data)
111-
if err then
112-
safe_call(opts.on_error, err)
113-
return
114-
end
115-
if data then
116-
local url = data:match('opencode server listening on ([^%s]+)')
117-
if url then
118-
self.url = url
119-
self.spawn_promise:resolve(self)
120-
safe_call(opts.on_ready, self.job, url)
121-
log.debug('spawn: server ready at url=%s', url)
122-
end
139+
stdio = { nil, stdout_pipe, stderr_pipe },
140+
detached = true, -- new process group
141+
}, function(code, signal)
142+
-- on_exit callback from libuv — runs on the libuv thread so schedule
143+
-- back into the main loop for safe nvim API access.
144+
vim.schedule(function()
145+
close_pipe(stdout_pipe)
146+
close_pipe(stderr_pipe)
147+
148+
if process_handle and not process_handle:is_closing() then
149+
process_handle:close()
123150
end
124-
end,
125-
stderr = function(err, data)
126-
if err then
127-
self.spawn_promise:reject(err)
128-
safe_call(opts.on_error, err)
129-
return
151+
152+
-- Clear fields if not already cleared by shutdown()
153+
self.job = nil
154+
self.url = nil
155+
self.handle = nil
156+
157+
safe_call(opts.on_exit, { code = code, signal = signal })
158+
if not self.shutdown_promise:is_resolved() then
159+
self.shutdown_promise:resolve(true)
130160
end
131-
if data then
132-
-- Filter out INFO/WARN/DEBUG log lines (not actual errors)
133-
local log_level = data:match('^%s*(%u+)%s')
134-
if log_level and (log_level == 'INFO' or log_level == 'WARN' or log_level == 'DEBUG') then
135-
-- Ignore log lines, don't reject
136-
return
137-
end
138-
-- Only reject on actual errors
139-
self.spawn_promise:reject(data)
140-
safe_call(opts.on_error, data)
161+
end)
162+
end)
163+
164+
if not process_handle then
165+
close_pipe(stdout_pipe)
166+
close_pipe(stderr_pipe)
167+
local err = 'Failed to spawn opencode: ' .. tostring(pid)
168+
self.spawn_promise:reject(err)
169+
safe_call(opts.on_error, err)
170+
return self.spawn_promise
171+
end
172+
173+
-- Store everything callers and shutdown() need
174+
self.job = {
175+
pid = pid,
176+
process_handle = process_handle,
177+
stdout_pipe = stdout_pipe,
178+
stderr_pipe = stderr_pipe,
179+
}
180+
self.handle = pid
181+
182+
-- Read stdout for the "listening on …" line
183+
stdout_pipe:read_start(function(err, data)
184+
if err then
185+
safe_call(opts.on_error, err)
186+
return
187+
end
188+
if data then
189+
local url = data:match('opencode server listening on ([^%s]+)')
190+
if url then
191+
self.url = url
192+
self.spawn_promise:resolve(self)
193+
safe_call(opts.on_ready, self.job, url)
194+
log.debug('spawn: server ready at url=%s', url)
141195
end
142-
end,
143-
}, function(exit_opts)
144-
-- Clear fields if not already cleared by shutdown()
145-
self.job = nil
146-
self.url = nil
147-
self.handle = nil
148-
safe_call(opts.on_exit, exit_opts)
149-
self.shutdown_promise:resolve(true)
196+
end
150197
end)
151198

152-
self.handle = self.job and self.job.pid
199+
-- Read stderr — only treat real errors as rejections
200+
stderr_pipe:read_start(function(err, data)
201+
if err then
202+
self.spawn_promise:reject(err)
203+
safe_call(opts.on_error, err)
204+
return
205+
end
206+
if data then
207+
-- Filter out INFO/WARN/DEBUG log lines (not actual errors)
208+
local log_level = data:match('^%s*(%u+)%s')
209+
if log_level and (log_level == 'INFO' or log_level == 'WARN' or log_level == 'DEBUG') then
210+
return
211+
end
212+
-- Only reject on actual errors
213+
self.spawn_promise:reject(data)
214+
safe_call(opts.on_error, data)
215+
end
216+
end)
153217

154-
log.debug('spawn: started job with pid=%s', tostring(self.job and self.job.pid))
218+
log.debug('spawn: started job with pid=%s (detached process group)', tostring(pid))
155219
return self.spawn_promise
156220
end
157221

0 commit comments

Comments
 (0)