From 77f7cebab44ad8fae01bfbefba3364860fa95e7f Mon Sep 17 00:00:00 2001 From: Biser Stoilov Date: Sun, 11 Jan 2026 14:59:31 +0200 Subject: [PATCH] fix(server): make Server:start resilient to bind/listen races --- lua/livepreview/server/init.lua | 149 ++++++++++++++++++++++++++------ 1 file changed, 121 insertions(+), 28 deletions(-) diff --git a/lua/livepreview/server/init.lua b/lua/livepreview/server/init.lua index a9152be3..31d2013b 100644 --- a/lua/livepreview/server/init.lua +++ b/lua/livepreview/server/init.lua @@ -147,19 +147,32 @@ function Server:watch_dir() end end ---- Start the server +--- Start the server with bind/retry logic --- @param ip string: IP address to bind to --- @param port number: port to bind to ---- @param opts ServerStartOptions: a table with the following fields ---- - on_events (table) +--- @param opts ServerStartOptions|table: options: +--- - on_events (table) +--- - max_retries (number) default 5 +--- - retry_delay (ms) default 1000 +--- - try_next_port (bool) default false (if true, will try port+1, port+2, ...) function Server:start(ip, port, opts) - self.server:bind(ip, port) + opts = opts or {} local on_events = opts.on_events + local max_retries = opts.max_retries or 5 + local retry_delay = opts.retry_delay or 1000 + local try_next_port = opts.try_next_port or false + + -- schedule-safe notify wrapper + local notify = vim.schedule_wrap(function(...) + vim.notify(...) + end) + + -- set up autocommands for on_events as before if on_events then if on_events.LivePreviewDirChanged then self:watch_dir() end - for k, v in pairs(opts.on_events) do + for k, v in pairs(on_events) do if k:match("^LivePreview*") then api.nvim_create_autocmd("User", { group = "LivePreview", @@ -184,34 +197,114 @@ function Server:start(ip, port, opts) end end - self.server:listen(128, function(err) - --- Connect to client - local client = uv.new_tcp() - self.server:accept(client) - handler.client(client, function(error, request) - if error or not request then - vim.notify(error and error, vim.log.levels.ERROR) - for i, c in ipairs(M.connecting_clients) do - if c == client then - client:close() - table.remove(M.connecting_clients, i) + -- retry / bind helper + local attempt = 0 + + local function do_listen() + -- Capture the current server instance into a local variable. + -- Use only this local `srv` inside the callbacks to avoid race on self.server = nil. + local srv = self.server + if not srv then + -- nothing to listen on + return + end + + -- Listen on the captured server + local ok_listen, listen_err = pcall(function() srv:listen(128, function(err) + if err then + notify("listen error: " .. tostring(err), vim.log.levels.ERROR) + return + end + + --- Connect to client + local client = uv.new_tcp() + + -- Check the captured server variable again before accept + if not srv then + pcall(function() client:close() end) + return + end + + local ok_accept, accept_err = pcall(function() srv:accept(client) end) + if not ok_accept then + notify("accept error: " .. tostring(accept_err), vim.log.levels.ERROR) + pcall(function() client:close() end) + return + end + + -- proceed with handling request + handler.client(client, function(error, request) + if error or not request then + notify(error and error, vim.log.levels.ERROR) + for i, c in ipairs(M.connecting_clients) do + if c == client then + pcall(function() client:close() end) + table.remove(M.connecting_clients, i) + end + end + return + else + local req_info = handler.request(client, request) + if req_info then + local path = req_info.path + local if_none_match = req_info.if_none_match + local file_path = self:routes(path) + handler.serve_file(client, file_path, if_none_match) end end + end) + + table.insert(M.connecting_clients, client) + end) end) + + if not ok_listen then + notify("listen setup failed: " .. tostring(listen_err), vim.log.levels.ERROR) + end + end + + local function try_bind(p) + -- create socket if nil (or recreate) + if not self.server then + self.server = uv.new_tcp() + end + + local ok, bind_err = pcall(function() self.server:bind(ip, p) end) + if not ok then + local errstr = tostring(bind_err or "") + -- message likely contains EADDRINUSE or "address already in use" + if (errstr:match("address already in use") or errstr:match("EADDRINUSE")) and attempt < max_retries then + attempt = attempt + 1 + notify(("Port %d busy, retry %d/%d in %dms"):format(p, attempt, max_retries, retry_delay), vim.log.levels.WARN) + -- close socket and schedule retry + pcall(function() + if self.server then + self.server:close() + end + end) + -- clear the server reference so other code knows it's closed + self.server = nil + vim.defer_fn(function() + local next_p = p + if try_next_port then + next_p = p + attempt -- try subsequent ports + end + try_bind(next_p) + end, retry_delay) return - else - local req_info = handler.request(client, request) - if req_info then - local path = req_info.path - local if_none_match = req_info.if_none_match - local file_path = self:routes(path) - handler.serve_file(client, file_path, if_none_match) - end end - end) - table.insert(M.connecting_clients, client) - end) + -- non-retryable or retries exhausted + notify("Failed to bind: " .. errstr, vim.log.levels.ERROR) + return + end + + -- bind succeeded -> start listening and run loop + do_listen() + -- run uv loop (this blocks until uv.stop() is called somewhere) + uv.run() + end - uv.run() + -- start attempts + try_bind(port) end --- Stop the server