@@ -4,7 +4,7 @@ local Promise = require('opencode.promise')
44local 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
3435--- Create a new ServerJob instance
3536--- @return OpencodeServer
3637function 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
5151end
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
5872end
5973
6074function 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>
101121function 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
156220end
157221
0 commit comments