Skip to content

Commit 1e1fd08

Browse files
authored
fix: retry instead of spawning local server when auto_kill=false (#366)
* fix: retry instead of spawning local server when auto_kill=false When connecting to an externally managed server (auto_kill=false), the health check failure path fell through to spawn_local_server(). These spawned processes survived Neovim exit (Go binary ignores SIGHUP) and were never cleaned up because VimLeavePre only unregistered the port instead of killing the process. Now retries the health check (5 attempts with backoff) instead of spawning an orphan. * test: cover auto_kill=false retry path in server_job Add three tests for the new code path that retries connecting to an externally managed server instead of spawning a local one: - succeeds after transient health-check failures - rejects after exhausting all retries - never falls back to spawn_local_server
1 parent 78e4cc2 commit 1e1fd08

2 files changed

Lines changed: 149 additions & 0 deletions

File tree

lua/opencode/server_job.lua

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,23 @@ function M.try_connect_to_custom_server(base_url, timeout, promise, custom_port,
275275
log.warn('failed to connect to %s: %s', base_url, vim.inspect(err))
276276
if config.server.spawn_command and custom_port and custom_url then
277277
spawn_and_retry(base_url, custom_port, custom_url, promise, timeout)
278+
elseif not config.server.auto_kill then
279+
-- Server is externally managed (auto_kill=false). Retry connecting
280+
-- instead of spawning a local server that would leak as an orphan.
281+
log.debug('try_connect_to_custom_server: auto_kill=false, retrying instead of spawning local')
282+
retry_connect(base_url, timeout, 5, function(url)
283+
local existing_started_by_nvim = port_mapping.started_by_nvim(custom_port)
284+
port_mapping.register(custom_port, vim.fn.getcwd(), existing_started_by_nvim, 'attach', url, nil)
285+
state.jobs.set_server(opencode_server.from_custom(url, custom_port, 'attach'))
286+
log.notify(
287+
string.format('Connected to external server at %s on port %d.', base_url, custom_port),
288+
vim.log.levels.INFO
289+
)
290+
promise:resolve(state.opencode_server)
291+
end, function(retry_err)
292+
log.error('try_connect_to_custom_server: exhausted retries for external server: %s', vim.inspect(retry_err))
293+
promise:reject(string.format('Failed to connect to external server at %s after retries', base_url))
294+
end)
278295
else
279296
M.spawn_local_server(promise, custom_port, custom_url)
280297
end

tests/unit/server_job_spec.lua

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,5 +262,137 @@ describe('server_job', function()
262262
assert.equal(1, spawn_count)
263263
assert.same(fake_local, result._value or result)
264264
end)
265+
266+
it('retries and connects when auto_kill=false and health check eventually succeeds', function()
267+
local original_auto_kill = config.values.server.auto_kill
268+
local original_retry_delay = config.values.server.retry_delay
269+
local original_defer_fn = vim.defer_fn
270+
271+
config.values.server.url = 'http://192.168.1.100'
272+
config.values.server.port = 5555
273+
config.values.server.spawn_command = nil
274+
config.values.server.auto_kill = false
275+
config.values.server.retry_delay = 0
276+
277+
-- Make vim.defer_fn fire immediately so retries don't block
278+
vim.defer_fn = function(fn, _delay)
279+
vim.schedule(fn)
280+
end
281+
282+
local request_count = 0
283+
curl.request = function(opts)
284+
vim.schedule(function()
285+
request_count = request_count + 1
286+
if request_count <= 2 then
287+
-- First two attempts fail (initial + first retry)
288+
opts.callback({ status = 503, body = '{}' })
289+
else
290+
-- Third attempt succeeds
291+
opts.callback({ status = 200, body = '{"ok":true}' })
292+
end
293+
end)
294+
end
295+
296+
local registered_mode
297+
port_mapping.register = function(_port, _dir, _started, mode)
298+
registered_mode = mode
299+
end
300+
301+
local result = server_job.ensure_server():wait()
302+
assert.is_not_nil(result)
303+
assert.equal('http://192.168.1.100:5555', result.url)
304+
assert.equal(5555, result.port)
305+
assert.equal('attach', registered_mode)
306+
assert.is_true(request_count >= 3)
307+
308+
config.values.server.auto_kill = original_auto_kill
309+
config.values.server.retry_delay = original_retry_delay
310+
vim.defer_fn = original_defer_fn
311+
end)
312+
313+
it('rejects after exhausting retries when auto_kill=false', function()
314+
local original_auto_kill = config.values.server.auto_kill
315+
local original_retry_delay = config.values.server.retry_delay
316+
local original_defer_fn = vim.defer_fn
317+
318+
config.values.server.url = 'http://192.168.1.100'
319+
config.values.server.port = 5555
320+
config.values.server.spawn_command = nil
321+
config.values.server.auto_kill = false
322+
config.values.server.retry_delay = 0
323+
324+
vim.defer_fn = function(fn, _delay)
325+
vim.schedule(fn)
326+
end
327+
328+
-- All attempts fail
329+
curl.request = function(opts)
330+
vim.schedule(function()
331+
opts.callback({ status = 503, body = '{}' })
332+
end)
333+
end
334+
335+
local ok, err = pcall(function()
336+
server_job.ensure_server():wait()
337+
end)
338+
339+
assert.is_false(ok)
340+
assert.truthy(tostring(err):match('Failed to connect to external server'))
341+
342+
config.values.server.auto_kill = original_auto_kill
343+
config.values.server.retry_delay = original_retry_delay
344+
vim.defer_fn = original_defer_fn
345+
end)
346+
347+
it('does not spawn local server when auto_kill=false', function()
348+
local original_auto_kill = config.values.server.auto_kill
349+
local original_retry_delay = config.values.server.retry_delay
350+
local original_defer_fn = vim.defer_fn
351+
352+
config.values.server.url = 'http://192.168.1.100'
353+
config.values.server.port = 5555
354+
config.values.server.spawn_command = nil
355+
config.values.server.auto_kill = false
356+
config.values.server.retry_delay = 0
357+
358+
vim.defer_fn = function(fn, _delay)
359+
vim.schedule(fn)
360+
end
361+
362+
-- All attempts fail
363+
curl.request = function(opts)
364+
vim.schedule(function()
365+
opts.callback({ status = 503, body = '{}' })
366+
end)
367+
end
368+
369+
local spawn_count = 0
370+
opencode_server.new = function()
371+
return {
372+
url = 'http://127.0.0.1:8080',
373+
port = nil,
374+
is_running = function()
375+
return spawn_count > 0
376+
end,
377+
spawn = function(self, opts)
378+
spawn_count = spawn_count + 1
379+
vim.schedule(function()
380+
opts.on_ready({}, self.url)
381+
end)
382+
end,
383+
shutdown = function() end,
384+
}
385+
end
386+
387+
pcall(function()
388+
server_job.ensure_server():wait()
389+
end)
390+
391+
assert.equal(0, spawn_count)
392+
393+
config.values.server.auto_kill = original_auto_kill
394+
config.values.server.retry_delay = original_retry_delay
395+
vim.defer_fn = original_defer_fn
396+
end)
265397
end)
266398
end)

0 commit comments

Comments
 (0)