Skip to content

Commit eafe48d

Browse files
committed
feat(server): auto-find port on Windows with async
1 parent 9ab9bc5 commit eafe48d

File tree

3 files changed

+213
-127
lines changed

3 files changed

+213
-127
lines changed

lua/opencode/cli/server.lua

Lines changed: 200 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,140 +1,194 @@
1+
local IS_WINDOWS = vim.fn.has("win32") == 1
2+
13
local M = {}
24

3-
---@param command string
4-
---@return string
5-
local function exec(command)
6-
-- TODO: Use vim.fn.jobstart for async, and so I can capture stderr (to throw error instead of it writing to the buffer).
7-
-- (or even the newer `vim.system`? Could update client.lua too? Or maybe not because SSE is long-running.)
8-
local executable = vim.split(command, " ")[1]
9-
if vim.fn.executable(executable) == 0 then
10-
error("`" .. executable .. "` command is not available", 0)
5+
---@param command string|string[]
6+
---@param cb fun(string?)
7+
local function exec_async(command, cb)
8+
if type(command) == "string" then
9+
command = vim.split(command, " ")
1110
end
1211

13-
local handle = io.popen(command)
14-
if not handle then
15-
error("Couldn't execute command: " .. command, 0)
12+
local executable = command[1]
13+
if vim.fn.executable(executable) == 0 then
14+
error("`" .. executable .. "` command is not available", 0)
1615
end
1716

18-
local output = handle:read("*a")
19-
handle:close()
20-
return output
17+
vim.system(command, { text = true }, function(handle)
18+
if handle.code ~= 0 then
19+
error("Couldn't execute command: " .. table.concat(command, " "))
20+
cb(nil)
21+
return
22+
end
23+
cb(handle.stdout)
24+
end)
2125
end
2226

23-
---@return Server[]
24-
local function find_servers()
27+
---@class Server
28+
---@field pid number
29+
---@field port number
30+
---@field cwd string
31+
32+
---@param cb fun(servers: Server[])
33+
local function find_servers(cb)
34+
if IS_WINDOWS then
35+
exec_async({
36+
"powershell",
37+
"-NoProfile",
38+
"-Command",
39+
[[Get-NetTCPConnection -State Listen | ForEach-Object {
40+
$p=Get-Process -Id $_.OwningProcess -ea 0;
41+
if($p -and ($p.ProcessName -ieq 'opencode' -or $p.ProcessName -ieq 'opencode.exe')) {
42+
'{0} {1}' -f $_.OwningProcess, $_.LocalPort
43+
}
44+
}]],
45+
}, function(output)
46+
local servers = {}
47+
for line in output:gmatch("[^\r\n]+") do
48+
local parts = vim.split(line, "%s+")
49+
local pid = tonumber(parts[1])
50+
local port = tonumber(parts[2])
51+
52+
if not pid or not port then
53+
error("Couldn't parse `opencode` PID and port from entry: " .. line, 0)
54+
end
55+
56+
-- have to skip CWD on Windows as it's non-trivial to get
57+
servers[#servers + 1] = {
58+
pid = pid,
59+
port = port,
60+
-- cwd = vim.fn.getcwd(),
61+
}
62+
end
63+
cb(servers)
64+
end)
65+
return
66+
end
67+
2568
if vim.fn.executable("lsof") == 0 then
2669
-- lsof is a common utility to list open files and ports, but not always available by default.
2770
error(
2871
"`lsof` executable not found in `PATH` to auto-find `opencode` — please install it or set `vim.g.opencode_opts.port`",
2972
0
3073
)
3174
end
32-
-- Going straight to `lsof` relieves us of parsing `ps` and all the non-portable 'opencode'-containing processes it might return.
33-
-- With these flags, we'll only get processes that are listening on TCP ports and have 'opencode' in their command name.
34-
-- i.e. pretty much guaranteed to be just opencode server processes.
35-
-- `-w` flag suppresses warnings about inaccessible filesystems (e.g. Docker FUSE).
36-
local output = exec("lsof -w -iTCP -sTCP:LISTEN -P -n | grep opencode")
37-
if output == "" then
38-
error("Couldn't find any `opencode` processes", 0)
39-
end
4075

41-
local servers = {}
42-
for line in output:gmatch("[^\r\n]+") do
43-
-- lsof output: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
44-
local parts = vim.split(line, "%s+")
76+
exec_async("lsof -w -iTCP -sTCP:LISTEN -P -n | grep opencode", function(output)
77+
if output == "" then
78+
error("Couldn't find any `opencode` processes", 0)
79+
end
80+
81+
local servers = {}
82+
local pending_cwds = 0
83+
local lines = {}
4584

46-
local pid = tonumber(parts[2])
47-
local port = tonumber(parts[9]:match(":(%d+)$")) -- Extract port from NAME field (which is e.g. "127.0.0.1:12345")
48-
if not pid or not port then
49-
error("Couldn't parse `opencode` PID and port from `lsof` entry: " .. line, 0)
85+
-- Collect all lines first
86+
for line in output:gmatch("[^\r\n]+") do
87+
table.insert(lines, line)
5088
end
5189

52-
local cwd = exec("lsof -w -a -p " .. pid .. " -d cwd"):match("%s+(/.*)$")
53-
if not cwd then
54-
error("Couldn't determine CWD for PID: " .. pid, 0)
90+
if #lines == 0 then
91+
cb(servers)
92+
return
5593
end
5694

57-
table.insert(
58-
servers,
59-
---@class Server
60-
---@field pid number
61-
---@field port number
62-
---@field cwd string
63-
{
64-
pid = pid,
65-
port = port,
66-
cwd = cwd,
67-
}
68-
)
69-
end
70-
return servers
95+
pending_cwds = #lines
96+
97+
for _, line in ipairs(lines) do
98+
-- lsof output: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
99+
local parts = vim.split(line, "%s+")
100+
101+
local pid = tonumber(parts[2])
102+
local port = tonumber(parts[9]:match(":(%d+)$")) -- Extract port from NAME field (which is e.g. "127.0.0.1:12345")
103+
if not pid or not port then
104+
error("Couldn't parse `opencode` PID and port from `lsof` entry: " .. line, 0)
105+
end
106+
107+
exec_async("lsof -w -a -p " .. pid .. " -d cwd", function(cwd_result)
108+
local cwd = cwd_result:match("%s+(/.*)$")
109+
110+
if not cwd then
111+
error("Couldn't determine CWD for PID: " .. pid, 0)
112+
else
113+
servers[#servers + 1] = {
114+
pid = pid,
115+
port = port,
116+
cwd = cwd,
117+
}
118+
end
119+
120+
pending_cwds = pending_cwds - 1
121+
if pending_cwds == 0 then
122+
cb(servers)
123+
end
124+
end)
125+
end
126+
end)
71127
end
72128

73-
local function is_descendant_of_neovim(pid)
129+
---@param cb fun(result: boolean)
130+
local function is_descendant_of_neovim(pid, cb)
74131
local neovim_pid = vim.fn.getpid()
75132
local current_pid = pid
133+
local cmd = vim.fn.has("win32") and "wmic process where ProcessId=%d get ParentProcessId /value"
134+
or "ps -o ppid= -p %d"
76135

77136
-- Walk up because the way some shells launch processes,
78137
-- Neovim will not be the direct parent.
79-
for _ = 1, 10 do -- limit to 10 steps to avoid infinite loop
80-
local parent_pid = tonumber(exec("ps -o ppid= -p " .. current_pid))
81-
if not parent_pid then
82-
error("Couldn't determine parent PID for: " .. current_pid, 0)
83-
end
84-
85-
if parent_pid == 1 then
86-
return false
87-
elseif parent_pid == neovim_pid then
88-
return true
89-
end
138+
local steps = {}
90139

91-
current_pid = parent_pid
140+
for _ = 1, 4 do -- limit to 4 steps to avoid infinite loop
141+
table.insert(steps, function(next)
142+
exec_async(cmd:format(current_pid), function(output)
143+
local parent_pid = tonumber(output:match("(%d+)"))
144+
if not parent_pid or parent_pid == 1 then
145+
cb(false)
146+
elseif parent_pid == neovim_pid then
147+
cb(true)
148+
else
149+
current_pid = parent_pid
150+
next()
151+
end
152+
end)
153+
end)
92154
end
93155

94-
return false
156+
table.insert(steps, function()
157+
cb(false)
158+
end)
159+
160+
require("opencode.util").chain(steps)
95161
end
96162

97-
---@return Server
98-
local function find_server_inside_nvim_cwd()
163+
---@param cb fun(server: Server|nil)
164+
local function find_server_inside_nvim_cwd(cb)
99165
local found_server
100166
local nvim_cwd = vim.fn.getcwd()
101-
for _, server in ipairs(find_servers()) do
102-
-- CWDs match exactly, or opencode's CWD is under neovim's CWD.
103-
if server.cwd:find(nvim_cwd, 1, true) == 1 then
104-
found_server = server
105-
if is_descendant_of_neovim(server.pid) then
106-
-- Stop searching to prioritize embedded
107-
break
108-
end
109-
end
110-
end
111-
112-
if not found_server then
113-
error("Couldn't find an `opencode` process running inside Neovim's CWD", 0)
114-
end
115167

116-
return found_server
117-
end
168+
find_servers(function(servers)
169+
local steps = {}
118170

119-
---@param fn fun(): number Function that checks for the port.
120-
---@param callback fun(ok: boolean, result: any) Called with eventually found port or error if not found after some time.
121-
local function poll_for_port(fn, callback)
122-
local retries = 0
123-
local timer = vim.uv.new_timer()
124-
timer:start(
125-
100,
126-
100,
127-
vim.schedule_wrap(function()
128-
local ok, find_port_result = pcall(fn)
129-
if ok or retries >= 20 then
130-
timer:stop()
131-
timer:close()
132-
callback(ok, find_port_result)
133-
else
134-
retries = retries + 1
171+
for i, server in ipairs(servers) do
172+
-- CWDs match exactly, or opencode's CWD is under neovim's CWD.
173+
if IS_WINDOWS or server.cwd:find(nvim_cwd, 1, true) == 1 then
174+
table.insert(steps, function(next)
175+
is_descendant_of_neovim(server.pid, function(is_descendant)
176+
if is_descendant then
177+
found_server = server
178+
else
179+
next()
180+
end
181+
end)
182+
end)
135183
end
184+
end
185+
186+
table.insert(steps, function()
187+
cb(found_server)
136188
end)
137-
)
189+
190+
require("opencode.util").chain(steps)
191+
end)
138192
end
139193

140194
---Test if a process is responding on `port`.
@@ -151,41 +205,64 @@ local function test_port(port)
151205
end
152206
end
153207

208+
local PORT_RANGE = { 4096, 5096 }
209+
---@return number
210+
local function find_free_port()
211+
for port = PORT_RANGE[1], PORT_RANGE[2] do
212+
local ok = pcall(test_port, port)
213+
if not ok then
214+
return port
215+
end
216+
end
217+
error("Couldn't find a free port in range: " .. PORT_RANGE[1] .. "-" .. PORT_RANGE[2], 0)
218+
end
219+
154220
---Attempt to get the `opencode` server's port. Tries, in order:
155221
---1. A process responding on `opts.port`.
156222
---2. Any `opencode` process running inside Neovim's CWD. Prioritizes embedded.
157-
---3. Calling `opts.on_opencode_not_found` and polling for the port.
223+
---3. Calling `opts.on_opencode_not_found` with random free port in range.
158224
---@param callback fun(ok: boolean, result: any) Called with eventually found port or error if not found after some time.
159225
function M.get_port(callback)
160-
local configured_port = require("opencode.config").opts.port
161-
local find_port_fn = configured_port and function()
162-
return test_port(configured_port)
163-
end or function()
164-
return find_server_inside_nvim_cwd().port
165-
end
166-
167-
require("opencode.util").chain({
226+
local step = {
168227
function(next)
169-
local ok, result = pcall(find_port_fn)
170-
if ok then
171-
callback(true, result)
172-
else
173-
next()
174-
end
228+
local configured_port = require("opencode.config").opts.port
229+
vim.schedule(function()
230+
if configured_port and test_port(configured_port) then
231+
callback(true, configured_port)
232+
else
233+
next()
234+
end
235+
end)
175236
end,
176237
function(next)
177-
local ok, result = pcall(require("opencode.config").opts.on_opencode_not_found)
178-
if not ok then
179-
callback(false, "Error in `vim.g.opencode_opts.on_opencode_not_found`: " .. result)
180-
else
181-
-- Always proceed - even if `opencode` wasn't started, failing to find it will give a more helpful error message.
182-
next()
183-
end
238+
find_server_inside_nvim_cwd(function(server)
239+
if server then
240+
vim.schedule(function()
241+
callback(true, server.port)
242+
end)
243+
else
244+
next()
245+
end
246+
end)
184247
end,
185-
function(_)
186-
poll_for_port(find_port_fn, callback)
248+
function()
249+
vim.schedule(function()
250+
local port_ok, port_result = pcall(find_free_port)
251+
if not port_ok then
252+
callback(false, "Error finding free port: " .. port_result)
253+
return
254+
end
255+
local ok, result = pcall(require("opencode.config").opts.on_opencode_not_found, port_result)
256+
if not ok then
257+
callback(false, "Error in `vim.g.opencode_opts.on_opencode_not_found`: " .. result)
258+
else
259+
callback(true, port_result)
260+
end
261+
end)
187262
end,
188-
})
263+
}
264+
265+
require("opencode.util").chain(step)
189266
end
190267

191268
return M

0 commit comments

Comments
 (0)