Skip to content

Commit 85c761c

Browse files
committed
fix(server): fail fast on startup hangs and chunked logs
1 parent be60d13 commit 85c761c

5 files changed

Lines changed: 146 additions & 18 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ require('opencode').setup({
123123
command = { 'opencode' }, -- Base runtime command, array-only
124124
serve_args = { 'serve' }, -- Optional, defaults to {'serve'}
125125
version_args = { '--version' }, -- Optional, defaults to {'--version'}
126+
startup_timeout_ms = 15000, -- Optional startup timeout before failing with an error
126127
path = {
127128
to_server = nil, -- Optional fun(path) -> server path
128129
to_local = nil, -- Optional fun(path) -> local path

lua/opencode/config.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ M.defaults = {
1717
command = { 'opencode' },
1818
serve_args = { 'serve' },
1919
version_args = { '--version' },
20+
startup_timeout_ms = 15000,
2021
path = {
2122
to_server = nil,
2223
to_local = nil,

lua/opencode/opencode_server.lua

Lines changed: 83 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
local util = require('opencode.util')
22
local safe_call = util.safe_call
33
local Promise = require('opencode.promise')
4+
local config = require('opencode.config')
45

56
--- @class OpencodeServer
67
--- @field job any The vim.system job handle
@@ -26,18 +27,33 @@ local function extract_server_url(data)
2627
return nil
2728
end
2829

29-
local lower = data:lower()
30-
if not lower:find('listening', 1, true) then
31-
return nil
32-
end
30+
local url = data:match('opencode server listening on%s+([^%s]+)')
31+
or data:match('server listening at%s+([^%s]+)')
32+
or data:match('listening on%s+([^%s]+)')
3333

34-
local url = data:match('opencode server listening on ([^%s]+)')
35-
or data:match('server listening at ([^%s]+)')
36-
or data:match('(https?://[^%s]+)')
34+
if not url then
35+
local lower = data:lower()
36+
if lower:find('listen', 1, true) then
37+
url = data:match('(https?://[^%s%]"\']+)')
38+
end
39+
end
3740

3841
return url
3942
end
4043

44+
local function append_chunk(buffer, chunk)
45+
if type(chunk) ~= 'string' or chunk == '' then
46+
return buffer
47+
end
48+
49+
local next_buffer = buffer .. chunk
50+
if #next_buffer > 8192 then
51+
next_buffer = next_buffer:sub(-8192)
52+
end
53+
54+
return next_buffer
55+
end
56+
4157
local vim_leave_setup = false
4258
local function ensure_vim_leave_autocmd()
4359
if vim_leave_setup then
@@ -126,6 +142,25 @@ end
126142
function OpencodeServer:spawn(opts)
127143
opts = opts or {}
128144
local log = require('opencode.log')
145+
local ready = false
146+
local stdout_buf = ''
147+
local stderr_buf = ''
148+
149+
local function mark_ready(url, from_stderr)
150+
if ready then
151+
return
152+
end
153+
ready = true
154+
self.url = normalize_server_url(url)
155+
self.spawn_promise:resolve(self)
156+
safe_call(opts.on_ready, self.job, self.url)
157+
158+
if from_stderr then
159+
log.debug('spawn: server ready at url=%s (detected from stderr)', self.url)
160+
else
161+
log.debug('spawn: server ready at url=%s', self.url)
162+
end
163+
end
129164

130165
local cmd = util.get_runtime_serve_command()
131166
local system_opts = {
@@ -135,32 +170,35 @@ function OpencodeServer:spawn(opts)
135170
self.job = vim.system(cmd, vim.tbl_extend('force', system_opts, {
136171
stdout = function(err, data)
137172
if err then
173+
if not ready then
174+
self.spawn_promise:reject(err)
175+
end
138176
safe_call(opts.on_error, err)
139177
return
140178
end
179+
141180
if data then
142-
local url = extract_server_url(data)
181+
stdout_buf = append_chunk(stdout_buf, data)
182+
local url = extract_server_url(stdout_buf)
143183
if url then
144-
self.url = normalize_server_url(url)
145-
self.spawn_promise:resolve(self)
146-
safe_call(opts.on_ready, self.job, self.url)
147-
log.debug('spawn: server ready at url=%s', self.url)
184+
mark_ready(url, false)
148185
end
149186
end
150187
end,
151188
stderr = function(err, data)
152189
if err then
153-
self.spawn_promise:reject(err)
190+
if not ready then
191+
self.spawn_promise:reject(err)
192+
end
154193
safe_call(opts.on_error, err)
155194
return
156195
end
196+
157197
if data then
158-
local url = extract_server_url(data)
198+
stderr_buf = append_chunk(stderr_buf, data)
199+
local url = extract_server_url(stderr_buf)
159200
if url then
160-
self.url = normalize_server_url(url)
161-
self.spawn_promise:resolve(self)
162-
safe_call(opts.on_ready, self.job, self.url)
163-
log.debug('spawn: server ready at url=%s (detected from stderr)', self.url)
201+
mark_ready(url, true)
164202
return
165203
end
166204

@@ -181,6 +219,10 @@ function OpencodeServer:spawn(opts)
181219
end
182220
end,
183221
}), function(exit_opts)
222+
if not ready then
223+
self.spawn_promise:reject(string.format('opencode server exited before ready (code=%s signal=%s)', tostring(exit_opts.code), tostring(exit_opts.signal)))
224+
end
225+
184226
-- Clear fields if not already cleared by shutdown()
185227
self.job = nil
186228
self.url = nil
@@ -191,6 +233,29 @@ function OpencodeServer:spawn(opts)
191233

192234
self.handle = self.job and self.job.pid
193235

236+
local startup_timeout_ms = tonumber(config.runtime and config.runtime.startup_timeout_ms) or 15000
237+
vim.defer_fn(function()
238+
if ready then
239+
return
240+
end
241+
242+
if self.job and self.job.kill then
243+
pcall(self.job.kill, self.job, 15)
244+
pcall(self.job.kill, self.job, 9)
245+
end
246+
247+
local err = string.format(
248+
'Timed out waiting for opencode server startup after %dms. command=%s stdout=%s stderr=%s',
249+
startup_timeout_ms,
250+
vim.inspect(cmd),
251+
vim.inspect(vim.trim(stdout_buf)),
252+
vim.inspect(vim.trim(stderr_buf))
253+
)
254+
255+
self.spawn_promise:reject(err)
256+
safe_call(opts.on_error, err)
257+
end, startup_timeout_ms)
258+
194259
log.debug('spawn: started job with pid=%s', tostring(self.job and self.job.pid))
195260
return self.spawn_promise
196261
end

lua/opencode/types.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@
204204
---@field command string[] Base command used to run opencode
205205
---@field serve_args? string[] Arguments appended when starting the server (defaults to {'serve'})
206206
---@field version_args? string[] Arguments appended when checking CLI version (defaults to {'--version'})
207+
---@field startup_timeout_ms? integer Time to wait for server ready URL before failing startup (defaults to 15000)
207208
---@field path? OpencodeRuntimePathConfig
208209

209210
---@class OpencodeConfig

tests/unit/opencode_server_spec.lua

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,34 @@ describe('opencode.opencode_server', function()
5050
assert.equals('http://127.0.0.1:7777', server.url)
5151
end)
5252

53+
it('spawn resolves when server URL is split across stdout chunks', function()
54+
local server = OpencodeServer.new()
55+
local resolved
56+
57+
vim.system = function(_, opts)
58+
vim.schedule(function()
59+
opts.stdout(nil, 'opencode server li')
60+
opts.stdout(nil, 'stening on http://127.0.0.1:7878')
61+
end)
62+
return { pid = 1, kill = function() end }
63+
end
64+
65+
server:spawn({
66+
cwd = '.',
67+
on_ready = function(_, url)
68+
resolved = url
69+
end,
70+
on_error = function() end,
71+
on_exit = function() end,
72+
})
73+
74+
vim.wait(100, function()
75+
return resolved ~= nil
76+
end)
77+
78+
assert.equals('http://127.0.0.1:7878', resolved)
79+
end)
80+
5381
it('spawn uses configured runtime command and normalizes 0.0.0.0 url', function()
5482
local server = OpencodeServer.new()
5583
local captured_cmd
@@ -325,4 +353,36 @@ describe('opencode.opencode_server', function()
325353
assert.is_nil(server.url)
326354
assert.is_nil(server.handle)
327355
end)
356+
357+
it('fails startup with timeout when no server url is emitted', function()
358+
local server = OpencodeServer.new()
359+
local called = { on_error = false, killed = false }
360+
config.runtime.startup_timeout_ms = 10
361+
362+
vim.system = function()
363+
return {
364+
pid = 45,
365+
kill = function()
366+
called.killed = true
367+
end,
368+
}
369+
end
370+
371+
server:spawn({
372+
cwd = '.',
373+
on_ready = function() end,
374+
on_error = function(err)
375+
called.on_error = true
376+
assert.is_truthy(tostring(err):find('Timed out waiting for opencode server startup', 1, true))
377+
end,
378+
on_exit = function() end,
379+
})
380+
381+
vim.wait(200, function()
382+
return called.on_error
383+
end)
384+
385+
assert.is_true(called.on_error)
386+
assert.is_true(called.killed)
387+
end)
328388
end)

0 commit comments

Comments
 (0)