Skip to content

Commit b2b6bc4

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

File tree

3 files changed

+195
-126
lines changed

3 files changed

+195
-126
lines changed

lua/opencode/cli/server.lua

Lines changed: 182 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,140 +1,181 @@
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
40-
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+")
4575

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)
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)
5079
end
5180

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)
55-
end
81+
local servers = {}
82+
for line in output:gmatch("[^\r\n]+") do
83+
-- lsof output: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
84+
local parts = vim.split(line, "%s+")
5685

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
86+
local pid = tonumber(parts[2])
87+
local port = tonumber(parts[9]:match(":(%d+)$")) -- Extract port from NAME field (which is e.g. "127.0.0.1:12345")
88+
if not pid or not port then
89+
error("Couldn't parse `opencode` PID and port from `lsof` entry: " .. line, 0)
90+
end
91+
92+
exec_async("lsof -w -a -p " .. pid .. " -d cwd", function(cwd_result)
93+
local cwd = cwd_result:match("%s+(/.*)$")
94+
95+
if not cwd then
96+
error("Couldn't determine CWD for PID: " .. pid, 0)
97+
else
98+
servers[#servers + 1] = {
99+
pid = pid,
100+
port = port,
101+
cwd = cwd,
102+
}
103+
end
104+
end)
105+
end
106+
cb(servers)
107+
end)
71108
end
72109

73-
local function is_descendant_of_neovim(pid)
110+
---@param cb fun(result: boolean)
111+
local function is_descendant_of_neovim(pid, cb)
74112
local neovim_pid = vim.fn.getpid()
75113
local current_pid = pid
114+
local cmd = vim.fn.has("win32") and "wmic process where ProcessId=%d get ParentProcessId /value"
115+
or "ps -o ppid= -p %d"
76116

77117
-- Walk up because the way some shells launch processes,
78118
-- 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
90-
91-
current_pid = parent_pid
119+
local steps = {}
120+
121+
for step = 1, 4 do -- limit to 4 steps to avoid infinite loop
122+
table.insert(steps, function(next)
123+
exec_async(cmd:format(current_pid), function(output)
124+
local parent_pid = tonumber(output:match("(%d+)"))
125+
if not parent_pid then
126+
error("Couldn't determine parent PID for: " .. current_pid, 0)
127+
end
128+
129+
if parent_pid == 1 then
130+
cb(false)
131+
elseif parent_pid == neovim_pid then
132+
cb(true)
133+
else
134+
current_pid = parent_pid
135+
next()
136+
end
137+
end)
138+
end)
92139
end
93140

94-
return false
141+
table.insert(steps, function()
142+
cb(false)
143+
end)
144+
145+
require("opencode.util").chain(steps)
95146
end
96147

97-
---@return Server
98-
local function find_server_inside_nvim_cwd()
148+
---@param cb fun(server: Server|nil)
149+
local function find_server_inside_nvim_cwd(cb)
99150
local found_server
100151
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
152+
153+
find_servers(function(servers)
154+
local steps = {}
155+
156+
for i, server in ipairs(servers) do
157+
-- CWDs match exactly, or opencode's CWD is under neovim's CWD.
158+
if IS_WINDOWS or server.cwd:find(nvim_cwd, 1, true) == 1 then
159+
found_server = server
160+
table.insert(steps, function(next)
161+
is_descendant_of_neovim(server.pid, function(is_descendant)
162+
if is_descendant then
163+
-- Stop searching to prioritize embedded
164+
cb(found_server)
165+
else
166+
next()
167+
end
168+
end)
169+
end)
108170
end
109171
end
110-
end
111172

112-
if not found_server then
113-
error("Couldn't find an `opencode` process running inside Neovim's CWD", 0)
114-
end
115-
116-
return found_server
117-
end
118-
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
135-
end
173+
table.insert(steps, function()
174+
cb(found_server)
136175
end)
137-
)
176+
177+
require("opencode.util").chain(steps)
178+
end)
138179
end
139180

140181
---Test if a process is responding on `port`.
@@ -151,39 +192,58 @@ local function test_port(port)
151192
end
152193
end
153194

195+
local PORT_RANGE = { 4096, 5096 }
196+
---@return number
197+
local function find_free_port()
198+
for port = PORT_RANGE[1], PORT_RANGE[2] do
199+
local ok = pcall(test_port, port)
200+
if ok then
201+
return port
202+
end
203+
end
204+
error("Couldn't find a free port in range: " .. PORT_RANGE[1] .. "-" .. PORT_RANGE[2], 0)
205+
end
206+
154207
---Attempt to get the `opencode` server's port. Tries, in order:
155208
---1. A process responding on `opts.port`.
156209
---2. Any `opencode` process running inside Neovim's CWD. Prioritizes embedded.
157-
---3. Calling `opts.on_opencode_not_found` and polling for the port.
210+
---3. Calling `opts.on_opencode_not_found` with random free port in range.
158211
---@param callback fun(ok: boolean, result: any) Called with eventually found port or error if not found after some time.
159212
function M.get_port(callback)
160213
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
214+
if configured_port and test_port(configured_port) then
215+
vim.schedule(function()
216+
callback(true, configured_port)
217+
end)
218+
return
165219
end
166220

167221
require("opencode.util").chain({
168222
function(next)
169-
local ok, result = pcall(find_port_fn)
170-
if ok then
171-
callback(true, result)
172-
else
173-
next()
174-
end
223+
find_server_inside_nvim_cwd(function(server)
224+
if server then
225+
vim.schedule(function()
226+
callback(true, server.port)
227+
end)
228+
else
229+
next()
230+
end
231+
end)
175232
end,
176-
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()
233+
function()
234+
local port_ok, port_result = pcall(find_free_port)
235+
if not port_ok then
236+
callback(false, "Error finding free port: " .. port_result)
237+
return
183238
end
184-
end,
185-
function(_)
186-
poll_for_port(find_port_fn, callback)
239+
vim.schedule(function()
240+
local ok, result = pcall(require("opencode.config").opts.on_opencode_not_found, port_result)
241+
if not ok then
242+
callback(false, "Error in `vim.g.opencode_opts.on_opencode_not_found`: " .. result)
243+
else
244+
callback(true, port_result)
245+
end
246+
end)
187247
end,
188248
})
189249
end

lua/opencode/config.lua

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,15 @@ local defaults = {
142142
OPENCODE_THEME = "system",
143143
},
144144
},
145-
on_opencode_not_found = function()
145+
---@param port? number Free port number that `opencode` can use.
146+
on_opencode_not_found = function(port)
146147
-- Ignore error so users can safely exclude `snacks.nvim` dependency without overriding this function.
147148
-- Could incidentally hide an unexpected error in `snacks.terminal`, but seems unlikely.
148-
pcall(require("opencode.terminal").open)
149+
local cmd = M.opts.terminal.cmd
150+
if not cmd:find("--port") then
151+
cmd = cmd .. " --port " .. tostring(port)
152+
end
153+
pcall(require("opencode.terminal").open, cmd)
149154
end,
150155
on_send = function()
151156
-- "if exists" because user may alternate between embedded and external `opencode`.

lua/opencode/terminal.lua

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,13 @@ function M.toggle()
1313
end
1414

1515
---Open an embedded `opencode` terminal.
16-
function M.open()
16+
---@param cmd? string Command to run in the terminal. Defaults to `opts.terminal.cmd`.
17+
function M.open(cmd)
1718
-- We use `get`, not `open`, so that `toggle` will reference the same terminal
18-
safe_snacks_terminal().get(require("opencode.config").opts.terminal.cmd, require("opencode.config").opts.terminal)
19+
safe_snacks_terminal().get(
20+
cmd or require("opencode.config").opts.terminal.cmd,
21+
require("opencode.config").opts.terminal
22+
)
1923
end
2024

2125
---Show the embedded `opencode` terminal, if it already exists.

0 commit comments

Comments
 (0)