diff --git a/lua/opencode/cli/server.lua b/lua/opencode/cli/server.lua index 3fe0c1a..ed7f3be 100644 --- a/lua/opencode/cli/server.lua +++ b/lua/opencode/cli/server.lua @@ -1,27 +1,70 @@ +local IS_WINDOWS = vim.fn.has("win32") == 1 + local M = {} ----@param command string ----@return string -local function exec(command) - -- TODO: Use vim.fn.jobstart for async, and so I can capture stderr (to throw error instead of it writing to the buffer). - -- (or even the newer `vim.system`? Could update client.lua too? Or maybe not because SSE is long-running.) - local executable = vim.split(command, " ")[1] - if vim.fn.executable(executable) == 0 then - error("`" .. executable .. "` command is not available", 0) +---@param command string|string[] +---@param cb fun(string?) +local function exec_async(command, cb) + if type(command) == "string" then + command = vim.split(command, " ") end - local handle = io.popen(command) - if not handle then - error("Couldn't execute command: " .. command, 0) + local executable = command[1] + if vim.fn.executable(executable) == 0 then + error("`" .. executable .. "` command is not available", 0) end - local output = handle:read("*a") - handle:close() - return output + vim.system(command, { text = true }, function(handle) + if handle.code ~= 0 then + error("Couldn't execute command: " .. table.concat(command, " ")) + cb(nil) + return + end + cb(handle.stdout) + end) end ----@return Server[] -local function find_servers() +---@class Server +---@field pid number +---@field port number +---@field cwd string + +---@param cb fun(servers: Server[]) +local function find_servers(cb) + if IS_WINDOWS then + exec_async({ + "powershell", + "-NoProfile", + "-Command", + [[Get-NetTCPConnection -State Listen | ForEach-Object { + $p=Get-Process -Id $_.OwningProcess -ea 0; + if($p -and ($p.ProcessName -ieq 'opencode' -or $p.ProcessName -ieq 'opencode.exe')) { + '{0} {1}' -f $_.OwningProcess, $_.LocalPort + } + }]], + }, function(output) + local servers = {} + for line in output:gmatch("[^\r\n]+") do + local parts = vim.split(line, "%s+") + local pid = tonumber(parts[1]) + local port = tonumber(parts[2]) + + if not pid or not port then + error("Couldn't parse `opencode` PID and port from entry: " .. line, 0) + end + + -- have to skip CWD on Windows as it's non-trivial to get + servers[#servers + 1] = { + pid = pid, + port = port, + -- cwd = vim.fn.getcwd(), + } + end + cb(servers) + end) + return + end + if vim.fn.executable("lsof") == 0 then -- lsof is a common utility to list open files and ports, but not always available by default. error( @@ -29,112 +72,123 @@ local function find_servers() 0 ) end - -- Going straight to `lsof` relieves us of parsing `ps` and all the non-portable 'opencode'-containing processes it might return. - -- With these flags, we'll only get processes that are listening on TCP ports and have 'opencode' in their command name. - -- i.e. pretty much guaranteed to be just opencode server processes. - -- `-w` flag suppresses warnings about inaccessible filesystems (e.g. Docker FUSE). - local output = exec("lsof -w -iTCP -sTCP:LISTEN -P -n | grep opencode") - if output == "" then - error("Couldn't find any `opencode` processes", 0) - end - local servers = {} - for line in output:gmatch("[^\r\n]+") do - -- lsof output: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME - local parts = vim.split(line, "%s+") + exec_async("lsof -w -iTCP -sTCP:LISTEN -P -n | grep opencode", function(output) + if output == "" then + error("Couldn't find any `opencode` processes", 0) + end + + local servers = {} + local pending_cwds = 0 + local lines = {} - local pid = tonumber(parts[2]) - local port = tonumber(parts[9]:match(":(%d+)$")) -- Extract port from NAME field (which is e.g. "127.0.0.1:12345") - if not pid or not port then - error("Couldn't parse `opencode` PID and port from `lsof` entry: " .. line, 0) + -- Collect all lines first + for line in output:gmatch("[^\r\n]+") do + table.insert(lines, line) end - local cwd = exec("lsof -w -a -p " .. pid .. " -d cwd"):match("%s+(/.*)$") - if not cwd then - error("Couldn't determine CWD for PID: " .. pid, 0) + if #lines == 0 then + cb(servers) + return end - table.insert( - servers, - ---@class Server - ---@field pid number - ---@field port number - ---@field cwd string - { - pid = pid, - port = port, - cwd = cwd, - } - ) - end - return servers + pending_cwds = #lines + + for _, line in ipairs(lines) do + -- lsof output: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME + local parts = vim.split(line, "%s+") + + local pid = tonumber(parts[2]) + local port = tonumber(parts[9]:match(":(%d+)$")) -- Extract port from NAME field (which is e.g. "127.0.0.1:12345") + if not pid or not port then + error("Couldn't parse `opencode` PID and port from `lsof` entry: " .. line, 0) + end + + exec_async("lsof -w -a -p " .. pid .. " -d cwd", function(cwd_result) + local cwd = cwd_result:match("%s+(/.*)$") + + if not cwd then + error("Couldn't determine CWD for PID: " .. pid, 0) + else + servers[#servers + 1] = { + pid = pid, + port = port, + cwd = cwd, + } + end + + pending_cwds = pending_cwds - 1 + if pending_cwds == 0 then + cb(servers) + end + end) + end + end) end -local function is_descendant_of_neovim(pid) +---@param cb fun(result: boolean) +local function is_descendant_of_neovim(pid, cb) local neovim_pid = vim.fn.getpid() local current_pid = pid + local cmd = vim.fn.has("win32") and "wmic process where ProcessId=%d get ParentProcessId /value" + or "ps -o ppid= -p %d" -- Walk up because the way some shells launch processes, -- Neovim will not be the direct parent. - for _ = 1, 10 do -- limit to 10 steps to avoid infinite loop - local parent_pid = tonumber(exec("ps -o ppid= -p " .. current_pid)) - if not parent_pid then - error("Couldn't determine parent PID for: " .. current_pid, 0) - end - - if parent_pid == 1 then - return false - elseif parent_pid == neovim_pid then - return true - end + local steps = {} - current_pid = parent_pid + for _ = 1, 4 do -- limit to 4 steps to avoid infinite loop + table.insert(steps, function(next) + exec_async(cmd:format(current_pid), function(output) + local parent_pid = tonumber(output:match("(%d+)")) + if not parent_pid or parent_pid == 1 then + cb(false) + elseif parent_pid == neovim_pid then + cb(true) + else + current_pid = parent_pid + next() + end + end) + end) end - return false + table.insert(steps, function() + cb(false) + end) + + require("opencode.util").chain(steps) end ----@return Server -local function find_server_inside_nvim_cwd() +---@param cb fun(server: Server|nil) +local function find_server_inside_nvim_cwd(cb) local found_server local nvim_cwd = vim.fn.getcwd() - for _, server in ipairs(find_servers()) do - -- CWDs match exactly, or opencode's CWD is under neovim's CWD. - if server.cwd:find(nvim_cwd, 1, true) == 1 then - found_server = server - if is_descendant_of_neovim(server.pid) then - -- Stop searching to prioritize embedded - break - end - end - end - - if not found_server then - error("Couldn't find an `opencode` process running inside Neovim's CWD", 0) - end - return found_server -end + find_servers(function(servers) + local steps = {} ----@param fn fun(): number Function that checks for the port. ----@param callback fun(ok: boolean, result: any) Called with eventually found port or error if not found after some time. -local function poll_for_port(fn, callback) - local retries = 0 - local timer = vim.uv.new_timer() - timer:start( - 100, - 100, - vim.schedule_wrap(function() - local ok, find_port_result = pcall(fn) - if ok or retries >= 20 then - timer:stop() - timer:close() - callback(ok, find_port_result) - else - retries = retries + 1 + for i, server in ipairs(servers) do + -- CWDs match exactly, or opencode's CWD is under neovim's CWD. + if IS_WINDOWS or server.cwd:find(nvim_cwd, 1, true) == 1 then + table.insert(steps, function(next) + is_descendant_of_neovim(server.pid, function(is_descendant) + if is_descendant then + found_server = server + else + next() + end + end) + end) end + end + + table.insert(steps, function() + cb(found_server) end) - ) + + require("opencode.util").chain(steps) + end) end ---Test if a process is responding on `port`. @@ -151,41 +205,64 @@ local function test_port(port) end end +local PORT_RANGE = { 4096, 5096 } +---@return number +local function find_free_port() + for port = PORT_RANGE[1], PORT_RANGE[2] do + local ok = pcall(test_port, port) + if not ok then + return port + end + end + error("Couldn't find a free port in range: " .. PORT_RANGE[1] .. "-" .. PORT_RANGE[2], 0) +end + ---Attempt to get the `opencode` server's port. Tries, in order: ---1. A process responding on `opts.port`. ---2. Any `opencode` process running inside Neovim's CWD. Prioritizes embedded. ----3. Calling `opts.on_opencode_not_found` and polling for the port. +---3. Calling `opts.on_opencode_not_found` with random free port in range. ---@param callback fun(ok: boolean, result: any) Called with eventually found port or error if not found after some time. function M.get_port(callback) - local configured_port = require("opencode.config").opts.port - local find_port_fn = configured_port and function() - return test_port(configured_port) - end or function() - return find_server_inside_nvim_cwd().port - end - - require("opencode.util").chain({ + local step = { function(next) - local ok, result = pcall(find_port_fn) - if ok then - callback(true, result) - else - next() - end + local configured_port = require("opencode.config").opts.port + vim.schedule(function() + if configured_port and test_port(configured_port) then + callback(true, configured_port) + else + next() + end + end) end, function(next) - local ok, result = pcall(require("opencode.config").opts.on_opencode_not_found) - if not ok then - callback(false, "Error in `vim.g.opencode_opts.on_opencode_not_found`: " .. result) - else - -- Always proceed - even if `opencode` wasn't started, failing to find it will give a more helpful error message. - next() - end + find_server_inside_nvim_cwd(function(server) + if server then + vim.schedule(function() + callback(true, server.port) + end) + else + next() + end + end) end, - function(_) - poll_for_port(find_port_fn, callback) + function() + vim.schedule(function() + local port_ok, port_result = pcall(find_free_port) + if not port_ok then + callback(false, "Error finding free port: " .. port_result) + return + end + local ok, result = pcall(require("opencode.config").opts.on_opencode_not_found, port_result) + if not ok then + callback(false, "Error in `vim.g.opencode_opts.on_opencode_not_found`: " .. result) + else + callback(true, port_result) + end + end) end, - }) + } + + require("opencode.util").chain(step) end return M diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 1f5cf59..dcb2c1f 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -142,10 +142,15 @@ local defaults = { OPENCODE_THEME = "system", }, }, - on_opencode_not_found = function() + ---@param port? number Free port number that `opencode` can use. + on_opencode_not_found = function(port) -- Ignore error so users can safely exclude `snacks.nvim` dependency without overriding this function. -- Could incidentally hide an unexpected error in `snacks.terminal`, but seems unlikely. - pcall(require("opencode.terminal").open) + local cmd = M.opts.terminal.cmd + if not cmd:find("--port") then + cmd = cmd .. " --port " .. tostring(port) + end + pcall(require("opencode.terminal").open, cmd) end, on_send = function() -- "if exists" because user may alternate between embedded and external `opencode`. diff --git a/lua/opencode/terminal.lua b/lua/opencode/terminal.lua index dadd636..8164da8 100644 --- a/lua/opencode/terminal.lua +++ b/lua/opencode/terminal.lua @@ -13,9 +13,13 @@ function M.toggle() end ---Open an embedded `opencode` terminal. -function M.open() +---@param cmd? string Command to run in the terminal. Defaults to `opts.terminal.cmd`. +function M.open(cmd) -- We use `get`, not `open`, so that `toggle` will reference the same terminal - safe_snacks_terminal().get(require("opencode.config").opts.terminal.cmd, require("opencode.config").opts.terminal) + safe_snacks_terminal().get( + cmd or require("opencode.config").opts.terminal.cmd, + require("opencode.config").opts.terminal + ) end ---Show the embedded `opencode` terminal, if it already exists.