Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 121 additions & 28 deletions lua/livepreview/server/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, function(client:userdata, data:{filename: string, events: FsEvent}):void>)
--- @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",
Expand All @@ -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
Expand Down