-
Notifications
You must be signed in to change notification settings - Fork 53
Expand file tree
/
Copy pathport_mapping.lua
More file actions
297 lines (258 loc) · 8.41 KB
/
port_mapping.lua
File metadata and controls
297 lines (258 loc) · 8.41 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
local log = require('opencode.log')
local config = require('opencode.config')
local util = require('opencode.util')
local OpencodeServer = require('opencode.opencode_server')
local M = {}
-- Signal 0 only checks if a process exists, doesn't actually signal it
local SIG_PID_EXISTS = 0
--- @class PortMappingEntry
--- @field pid number
--- @field directory string
--- @field mode string
--- @class PortMapping
--- @field directory string
--- @field nvim_pids PortMappingEntry[]
--- @field auto_kill boolean
--- @field started_by_nvim boolean
--- @field url string|nil The URL the opencode server is listening on
--- @field server_pid number|nil The PID of the opencode server process (local servers only)
--- @return string
local function file_path()
return vim.fn.stdpath('data') .. '/opencode_port_mappings.json'
end
--- @return table<string, PortMapping>
local function load()
local file = io.open(file_path(), 'r')
if not file then
return {}
end
local content = file:read('*all')
file:close()
local ok, data = pcall(vim.json.decode, content or '')
return ok and data or {}
end
--- @param mappings table<string, PortMapping>
local function save(mappings)
local path = file_path()
local file = io.open(path, 'w')
if not file then
log.warn('port_mapping: could not open %s for writing', path)
return
end
file:write(vim.json.encode(mappings))
file:close()
end
--- @param entry PortMappingEntry
--- @return boolean
local function pid_alive(entry)
return vim.fn.getpid() == entry.pid or vim.uv.kill(entry.pid, SIG_PID_EXISTS) == 0
end
--- Fire-and-forget graceful shutdown request to a server with no clients.
--- Also force-kills the process if server_pid is available.
--- @param port number
--- @param server_pid number|nil
local function kill_orphaned_server(port, server_pid)
local server_url = config.server.url or '127.0.0.1'
local normalized_url = util.normalize_url_protocol(server_url)
local base_url = string.format('%s:%d', normalized_url, port)
log.info('port_mapping: sending shutdown to orphaned server at %s (server_pid=%s)', base_url, tostring(server_pid))
OpencodeServer.request_graceful_shutdown(base_url)
if server_pid then
OpencodeServer.kill_pid(server_pid)
else
log.debug('port_mapping: no server PID available, relying on graceful shutdown only')
end
end
--- Purge dead nvim PIDs from every mapping and kill any newly-orphaned servers.
local function clean_stale()
local mappings = load()
local changed = false
for port_key, mapping in pairs(mappings) do
mapping.nvim_pids = mapping.nvim_pids or {}
local before = #mapping.nvim_pids
mapping.nvim_pids = vim.tbl_filter(pid_alive, mapping.nvim_pids)
if #mapping.nvim_pids < before then
changed = true
end
if #mapping.nvim_pids == 0 then
local port = tonumber(port_key)
if port and mapping.started_by_nvim then
kill_orphaned_server(port, mapping.server_pid)
end
log.debug('port_mapping: removing port %s (no connected clients)', port_key)
mappings[port_key] = nil
end
end
if changed then
save(mappings)
end
end
--- Return the directory a port is already mapped to, or nil when the port is
--- either free or already mapped to current_dir.
--- @param port number
--- @param current_dir string
--- @return string|nil
function M.mapped_directory(port, current_dir)
clean_stale()
local mapping = load()[tostring(port)]
if mapping and mapping.directory and mapping.directory ~= current_dir then
return mapping.directory
end
end
--- Return an existing port serving current_dir, or nil.
--- @param current_dir string
--- @return number|nil
function M.find_port_for_directory(current_dir)
clean_stale()
for port_key, mapping in pairs(load()) do
if mapping.directory == current_dir and mapping.nvim_pids and #mapping.nvim_pids > 0 then
local port = tonumber(port_key)
if port then
return port
end
end
end
end
--- Record that this nvim instance is using the given port.
--- @param port number
--- @param directory string
--- @param started_by_nvim boolean
--- @param mode? string 'serve'|'attach'|'custom'
--- @param url? string The URL the server is listening on
--- @param server_pid? number The PID of the server process (local servers only)
function M.register(port, directory, started_by_nvim, mode, url, server_pid)
mode = mode or 'serve'
clean_stale()
local mappings = load()
local port_key = tostring(port)
local current_pid = vim.fn.getpid()
local auto_kill = config.server.auto_kill
if not mappings[port_key] then
mappings[port_key] = {
directory = directory,
nvim_pids = {},
auto_kill = auto_kill,
started_by_nvim = started_by_nvim,
}
end
local mapping = mappings[port_key]
mapping.nvim_pids = mapping.nvim_pids or {}
if mapping.auto_kill == nil then
mapping.auto_kill = auto_kill
end
if mapping.started_by_nvim == nil then
mapping.started_by_nvim = started_by_nvim
end
if url then
mapping.url = url
end
-- Only update server_pid if provided (don't overwrite existing PID with nil)
if server_pid then
mapping.server_pid = server_pid
end
local pid_exists = false
local updated = {}
for _, entry in ipairs(mapping.nvim_pids) do
table.insert(updated, entry)
if entry.pid == current_pid then
pid_exists = true
end
end
mapping.nvim_pids = updated
if not pid_exists then
table.insert(mapping.nvim_pids, { pid = current_pid, directory = directory, mode = mode })
end
save(mappings)
log.debug(
'port_mapping.register: port=%d dir=%s pid=%d mode=%s started_by_nvim=%s auto_kill=%s url=%s server_pid=%s',
port,
directory,
current_pid,
mode,
tostring(started_by_nvim),
tostring(auto_kill),
tostring(url),
tostring(server_pid)
)
end
--- Remove this nvim instance from a port's client list.
--- Shuts the server down when it was the last client and auto_kill is set.
--- Also shuts down attach-mode processes unconditionally.
--- @param port number|nil
--- @param server OpencodeServer instance (state.opencode_server)
function M.unregister(port, server)
if not port then
return
end
clean_stale()
local mappings = load()
local port_key = tostring(port)
local mapping = mappings[port_key]
if not mapping then
return
end
local current_pid = vim.fn.getpid()
local remaining = {}
for _, entry in ipairs(mapping.nvim_pids or {}) do
if entry.pid ~= current_pid then
table.insert(remaining, entry)
end
end
mapping.nvim_pids = remaining
local should_shutdown = #remaining == 0 and mapping.started_by_nvim and mapping.auto_kill
if server then
local is_last_client = #remaining == 0 and mapping.started_by_nvim
if server.mode == 'attach' then
if is_last_client then
log.debug('port_mapping.unregister: last attached client for port %d, killing server', port)
if mapping.server_pid then
kill_orphaned_server(port, mapping.server_pid)
end
end
elseif is_last_client then
local auto_kill_custom_server = config.server.auto_kill and config.server.kill_command
local server_is_owned = server.job
log.debug(
'port_mapping.unregister: last nvim instance for port %d, killing orphaned server',
port,
tostring(server_is_owned),
tostring(auto_kill_custom_server)
)
if auto_kill_custom_server or server_is_owned then
server:shutdown()
end
end
elseif should_shutdown then
log.debug('port_mapping.unregister: no server object, killing orphaned server for port %d', port)
kill_orphaned_server(port, mapping.server_pid)
end
if should_shutdown then
mappings[port_key] = nil
else
log.debug('port_mapping.unregister: port=%d still has %d client(s)', port, #remaining)
end
save(mappings)
end
--- Return the started_by_nvim flag for a port, or false if unknown.
--- @param port number
--- @return boolean
function M.started_by_nvim(port)
local mapping = load()[tostring(port)]
return mapping and mapping.started_by_nvim or false
end
--- Find any existing server port (regardless of directory)
--- @return number|nil port number if found, nil otherwise
function M.find_any_existing_port()
clean_stale()
local mappings = load()
for port_key, mapping in pairs(mappings) do
if mapping.nvim_pids and #mapping.nvim_pids > 0 then
local port = tonumber(port_key)
if port then
return port
end
end
end
return nil
end
return M