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
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 )
71127end
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 )
95161end
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 )
138192end
139193
140194--- Test if a process is responding on `port`.
@@ -151,41 +205,64 @@ local function test_port(port)
151205 end
152206end
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.
159225function 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 )
189266end
190267
191268return M
0 commit comments