1+ local IS_WINDOWS = vim .fn .has (" win32" ) == 1
2+
13local 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 )
2125end
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 )
71108end
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 )
95146end
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 )
138179end
139180
140181--- Test if a process is responding on `port`.
@@ -151,39 +192,58 @@ local function test_port(port)
151192 end
152193end
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.
159212function 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 })
189249end
0 commit comments