pikuri-lsp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +87 -0
- data/lib/pikuri/lsp/anchor.rb +397 -0
- data/lib/pikuri/lsp/client_wrapper.rb +581 -0
- data/lib/pikuri/lsp/connection.rb +366 -0
- data/lib/pikuri/lsp/extension.rb +75 -0
- data/lib/pikuri/lsp/location.rb +100 -0
- data/lib/pikuri/lsp/lsp_tool.rb +157 -0
- data/lib/pikuri/lsp/mailbox.rb +98 -0
- data/lib/pikuri/lsp/navigator.rb +340 -0
- data/lib/pikuri/lsp/operation.rb +115 -0
- data/lib/pikuri/lsp/position.rb +72 -0
- data/lib/pikuri/lsp/position_encoding.rb +126 -0
- data/lib/pikuri/lsp/range.rb +65 -0
- data/lib/pikuri/lsp/readiness.rb +185 -0
- data/lib/pikuri/lsp/refusal.rb +23 -0
- data/lib/pikuri/lsp/registry.rb +286 -0
- data/lib/pikuri/lsp/renderer.rb +453 -0
- data/lib/pikuri/lsp/server_progress.rb +57 -0
- data/lib/pikuri/lsp/servers.rb +208 -0
- data/lib/pikuri/lsp/sources.rb +272 -0
- data/lib/pikuri/lsp/symbol_name.rb +48 -0
- data/lib/pikuri/lsp/testing.rb +417 -0
- data/lib/pikuri/lsp/uris.rb +61 -0
- data/lib/pikuri-lsp.rb +39 -0
- metadata +107 -0
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module Pikuri
|
|
6
|
+
module Lsp
|
|
7
|
+
# One long-lived language server: the child process, the LSP handshake, and
|
|
8
|
+
# a restart when it dies under us. {Connection} carries the bytes; this owns
|
|
9
|
+
# the lifecycle around them.
|
|
10
|
+
#
|
|
11
|
+
# client = ClientWrapper.spawn(entry, root: filesystem.project_root)
|
|
12
|
+
# client.supports?('definitionProvider') # => true
|
|
13
|
+
# client.position_encoding # => "utf-8" (ruby-lsp; jdtls stays utf-16)
|
|
14
|
+
# client.wait_until_ready { |progress| emitter.call(progress) }
|
|
15
|
+
# client.request('textDocument/definition', params, cancellable: cancellable)
|
|
16
|
+
# client.close
|
|
17
|
+
#
|
|
18
|
+
# {#wait_until_ready} before the first request is not optional politeness:
|
|
19
|
+
# ruby-lsp answers a mid-index query with a successful +[]+, which an agent
|
|
20
|
+
# reads as "no definition exists". See {Readiness}.
|
|
21
|
+
#
|
|
22
|
+
# {.spawn} raises rather than returning a half-live object, so a missing
|
|
23
|
+
# binary or a server that dies during its handshake is a boot-time failure
|
|
24
|
+
# with the child's own last words attached ({#stderr_tail}) — the one thing
|
|
25
|
+
# that says *why* it died.
|
|
26
|
+
#
|
|
27
|
+
# == Capabilities are per workspace, not per binary
|
|
28
|
+
#
|
|
29
|
+
# {#supports?} answers from *this* +initialize+ response, never from a matrix
|
|
30
|
+
# keyed on the binary: the same ruby-lsp advertises document formatting in a
|
|
31
|
+
# project whose bundle has rubocop and withholds it in one that doesn't. That
|
|
32
|
+
# is why the check belongs at dispatch time, and why {#capabilities} stays a
|
|
33
|
+
# plain Hash — pikuri only ever asks whether a key is there.
|
|
34
|
+
#
|
|
35
|
+
# == Restart, and what a fresh server has forgotten
|
|
36
|
+
#
|
|
37
|
+
# A {Connection::Closed} mid-call means the child is gone for good, so
|
|
38
|
+
# {#request} respawns and retries up to {MAX_ATTEMPTS} times before raising
|
|
39
|
+
# {ServerDied}; a wrapper built with no +respawn:+ (a test over pipes) raises
|
|
40
|
+
# on the first death instead. Documents need no replay — pikuri re-sends
|
|
41
|
+
# +didOpen+ before every call anyway — but a fresh server's *index* is empty,
|
|
42
|
+
# so a restart also puts {#ready?} back to +false+ and the next
|
|
43
|
+
# {#wait_until_ready} waits the new child out from scratch.
|
|
44
|
+
#
|
|
45
|
+
# == Documents: an agent is not an editor, so it re-opens
|
|
46
|
+
#
|
|
47
|
+
# {#open_document} sends +textDocument/didOpen+ with the file's current disk
|
|
48
|
+
# text before every position-based call, and {#close_document} hands it back
|
|
49
|
+
# when the call is done. No open-URI set and no version comparison — the pair
|
|
50
|
+
# is per call, which is also what keeps it correct across a restart, since a
|
|
51
|
+
# respawned child inherits no open documents. See {#open_document} for why
|
|
52
|
+
# each alternative to re-opening was measured and rejected.
|
|
53
|
+
#
|
|
54
|
+
# == The Subprocess-seam exception
|
|
55
|
+
#
|
|
56
|
+
# +Subprocess.spawn+ is one-shot (stdin closed, output merged, read to EOF)
|
|
57
|
+
# and a language server is the opposite on all three counts, so this is the
|
|
58
|
+
# documented exception to the chokepoint convention alongside stdio MCP's:
|
|
59
|
+
# +Open3.popen3+ here, and {#close} owns the teardown — +shutdown+, +exit+,
|
|
60
|
+
# then SIGTERM to the whole process group, because jdtls forks helpers.
|
|
61
|
+
class ClientWrapper
|
|
62
|
+
LOGGER = Pikuri.logger_for('Lsp::ClientWrapper')
|
|
63
|
+
private_constant :LOGGER
|
|
64
|
+
|
|
65
|
+
# Total {#request} attempts, including the first — 3 means one normal try
|
|
66
|
+
# plus two restart-then-retry rounds.
|
|
67
|
+
MAX_ATTEMPTS = 3
|
|
68
|
+
|
|
69
|
+
# Lines of the child's stderr kept for {#stderr_tail}. jdtls is chatty;
|
|
70
|
+
# what matters is the last thing it said before dying.
|
|
71
|
+
STDERR_TAIL_LINES = 20
|
|
72
|
+
|
|
73
|
+
# Seconds {#close} gives the child to leave on its own, at each escalation
|
|
74
|
+
# (EOF, then SIGTERM, then SIGKILL). Teardown is the one place with a
|
|
75
|
+
# clock, and this is what it is spent on: a close that blocks forever is
|
|
76
|
+
# worse than an impolite one.
|
|
77
|
+
CLOSE_GRACE = 2
|
|
78
|
+
|
|
79
|
+
# Seconds a death report waits for the child's exit status and its final
|
|
80
|
+
# stderr, both of which are in flight the moment stdout hits EOF.
|
|
81
|
+
REAP_ON_DEATH = 0.5
|
|
82
|
+
|
|
83
|
+
# What pikuri tells the server it can do. Four declarations are
|
|
84
|
+
# load-bearing, each measured against a real server: +positionEncodings+
|
|
85
|
+
# (offer utf-8 and convert when a server ignores the offer),
|
|
86
|
+
# +workDoneProgress+ (jdtls emits *no* +$/progress+ without it, narrating
|
|
87
|
+
# startup through a non-spec notification instead), +linkSupport+ (so a
|
|
88
|
+
# definition answers +LocationLink+ and pins the identifier's own range),
|
|
89
|
+
# and +contentFormat+ for hover. The empty objects merely say "this
|
|
90
|
+
# operation exists here"; +documentSymbol+ deliberately does not claim
|
|
91
|
+
# hierarchical support, so symbols arrive flat.
|
|
92
|
+
CLIENT_CAPABILITIES = {
|
|
93
|
+
general: { positionEncodings: PositionEncoding::OFFERED },
|
|
94
|
+
window: { workDoneProgress: true },
|
|
95
|
+
textDocument: {
|
|
96
|
+
synchronization: { dynamicRegistration: false },
|
|
97
|
+
definition: { linkSupport: true },
|
|
98
|
+
typeDefinition: { linkSupport: true },
|
|
99
|
+
implementation: { linkSupport: true },
|
|
100
|
+
references: {},
|
|
101
|
+
hover: { contentFormat: %w[markdown plaintext] },
|
|
102
|
+
documentSymbol: {},
|
|
103
|
+
typeHierarchy: {},
|
|
104
|
+
callHierarchy: {}
|
|
105
|
+
},
|
|
106
|
+
workspace: { symbol: {}, workspaceFolders: true }
|
|
107
|
+
}.freeze
|
|
108
|
+
|
|
109
|
+
# The server cannot answer: it never started, it exited, or it stopped
|
|
110
|
+
# answering and restarts did not bring it back. The message carries the
|
|
111
|
+
# exit status and {#stderr_tail}, which is everything a caller needs to
|
|
112
|
+
# report the failure — and nothing that steers what to do instead.
|
|
113
|
+
class ServerDied < StandardError; end
|
|
114
|
+
|
|
115
|
+
# +window/logMessage+ +type+ → logger level. Level 1 is where ruby-lsp
|
|
116
|
+
# reports having crashed on a notification, which is otherwise invisible.
|
|
117
|
+
LOG_LEVELS = { 1 => :error, 2 => :warn, 3 => :info, 4 => :debug }.freeze
|
|
118
|
+
private_constant :LOG_LEVELS
|
|
119
|
+
|
|
120
|
+
# Spawn the child, run the handshake, return a ready client.
|
|
121
|
+
#
|
|
122
|
+
# @param entry [Registry::StdioEntry] what to launch.
|
|
123
|
+
# @param root [String, Pathname] the workspace root: the child's cwd *and*
|
|
124
|
+
# the +rootUri+ it indexes. One value, so the server's idea of the
|
|
125
|
+
# project cannot drift from the confinement boundary.
|
|
126
|
+
# @param cancellable [Pikuri::Agent::Control::Cancellable, nil] breaks the
|
|
127
|
+
# handshake wait.
|
|
128
|
+
# @return [ClientWrapper] started, handshaken; indexing continues in the
|
|
129
|
+
# background.
|
|
130
|
+
# @raise [ServerDied] if the binary is missing, or the child dies before
|
|
131
|
+
# completing the handshake. Nothing is left half-open.
|
|
132
|
+
def self.spawn(entry, root:, cancellable: nil)
|
|
133
|
+
respawn = -> { spawn_child(entry, root) }
|
|
134
|
+
wrapper = new(entry: entry, root: root, respawn: respawn, **respawn.call)
|
|
135
|
+
begin
|
|
136
|
+
wrapper.start(cancellable: cancellable)
|
|
137
|
+
rescue StandardError
|
|
138
|
+
wrapper.close
|
|
139
|
+
raise
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @return [Hash{Symbol => Object}] the {#initialize} channel kwargs:
|
|
144
|
+
# +{stdin:, stdout:, stderr:, wait_thread:}+.
|
|
145
|
+
# @raise [ServerDied] when the binary cannot be executed.
|
|
146
|
+
def self.spawn_child(entry, root)
|
|
147
|
+
stdin, stdout, stderr, wait_thread =
|
|
148
|
+
Open3.popen3(Pikuri::BundlerEnv.clean_delta.merge(entry.env),
|
|
149
|
+
*entry.command, chdir: root.to_s, pgroup: true)
|
|
150
|
+
LOGGER.info("#{entry.id}: spawned #{entry.command.join(' ')} (pid #{wait_thread.pid}) in #{root}")
|
|
151
|
+
{ stdin: stdin, stdout: stdout, stderr: stderr, wait_thread: wait_thread }
|
|
152
|
+
rescue SystemCallError => e
|
|
153
|
+
raise ServerDied, "#{entry.id}: cannot spawn #{entry.command.join(' ')}: #{e.message}"
|
|
154
|
+
end
|
|
155
|
+
private_class_method :spawn_child
|
|
156
|
+
|
|
157
|
+
# @return [Registry::StdioEntry] the configuration this client runs.
|
|
158
|
+
attr_reader :entry
|
|
159
|
+
|
|
160
|
+
# @return [Hash{String => Object}] the server's advertised capabilities,
|
|
161
|
+
# verbatim from +initialize+ — +{}+ before {#start}. Values are unions in
|
|
162
|
+
# practice (+true+ from one server, an options object from another), which
|
|
163
|
+
# is why nothing types them.
|
|
164
|
+
attr_reader :capabilities
|
|
165
|
+
|
|
166
|
+
# @return [Hash{String => Object}, nil] the server's +serverInfo+
|
|
167
|
+
# (+name+ / +version+), when it sent one.
|
|
168
|
+
attr_reader :server_info
|
|
169
|
+
|
|
170
|
+
# @return [String, nil] the negotiated +positionEncoding+ — every
|
|
171
|
+
# {Position} crossing this channel converts with it — or +nil+ before
|
|
172
|
+
# {#start}. {PositionEncoding::DEFAULT} when the server answers nothing,
|
|
173
|
+
# which is jdtls's answer.
|
|
174
|
+
attr_reader :position_encoding
|
|
175
|
+
|
|
176
|
+
# Adopts the channel and starts reading it, but sends nothing: {#start} is
|
|
177
|
+
# the handshake. Splitting the two is what lets a fake server drive a
|
|
178
|
+
# wrapper over +IO.pipe+, with no binary in sight.
|
|
179
|
+
#
|
|
180
|
+
# @param entry [Registry::StdioEntry]
|
|
181
|
+
# @param root [String, Pathname] workspace root, reported as +rootUri+.
|
|
182
|
+
# @param stdin [IO] the server's stdin.
|
|
183
|
+
# @param stdout [IO] the server's stdout.
|
|
184
|
+
# @param stderr [IO, nil] the server's stderr, drained into {#stderr_tail};
|
|
185
|
+
# +nil+ when there is no child to have one.
|
|
186
|
+
# @param wait_thread [Process::Waiter, nil] the child's waiter, for exit
|
|
187
|
+
# status and signalling; +nil+ over pipes.
|
|
188
|
+
# @param respawn [Proc, nil] returns the kwargs for a fresh child
|
|
189
|
+
# (+{stdin:, stdout:, stderr:, wait_thread:}+). Without it, a dead
|
|
190
|
+
# channel is terminal — see the class's restart note.
|
|
191
|
+
# @param readiness [Readiness, nil] the indexing gate, which this
|
|
192
|
+
# subscribes to the server's notifications. Injectable so a spec can
|
|
193
|
+
# shorten the settle window; production passes nothing.
|
|
194
|
+
def initialize(entry:, root:, stdin:, stdout:, stderr: nil, wait_thread: nil, respawn: nil,
|
|
195
|
+
readiness: nil)
|
|
196
|
+
@entry = entry
|
|
197
|
+
@root = File.expand_path(root.to_s)
|
|
198
|
+
@respawn = respawn
|
|
199
|
+
@handlers = []
|
|
200
|
+
@capabilities = {}
|
|
201
|
+
@stderr_tail = []
|
|
202
|
+
@stderr_io = nil
|
|
203
|
+
@tail_mutex = Mutex.new
|
|
204
|
+
@started = false
|
|
205
|
+
@document_version = 0
|
|
206
|
+
@closed = false
|
|
207
|
+
@readiness = readiness || Readiness.new(server_id: entry.id)
|
|
208
|
+
adopt(stdin: stdin, stdout: stdout, stderr: stderr, wait_thread: wait_thread)
|
|
209
|
+
on_notification { |method, params| @readiness.observe(method, params) }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Run the LSP handshake: +initialize+, then +initialized+.
|
|
213
|
+
#
|
|
214
|
+
# @param cancellable [Pikuri::Agent::Control::Cancellable, nil]
|
|
215
|
+
# @return [self]
|
|
216
|
+
# @raise [ServerDied] if the child dies mid-handshake.
|
|
217
|
+
# @raise [KeyError] if the server answers no +capabilities+ — it is not
|
|
218
|
+
# speaking LSP.
|
|
219
|
+
def start(cancellable: nil)
|
|
220
|
+
raise "#{@entry.id}: already started" if @started
|
|
221
|
+
|
|
222
|
+
handshake(cancellable)
|
|
223
|
+
@started = true
|
|
224
|
+
self
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Send a request, restarting the server and retrying if it dies.
|
|
228
|
+
#
|
|
229
|
+
# @param method [String] e.g. +"textDocument/definition"+.
|
|
230
|
+
# @param params [Hash]
|
|
231
|
+
# @param cancellable [Pikuri::Agent::Control::Cancellable, nil]
|
|
232
|
+
# @return [Hash, Array, String, Integer, true, false, nil] the +result+
|
|
233
|
+
# member; +nil+ and +[]+ are answers, not failures.
|
|
234
|
+
# @raise [ServerDied] when the server is gone and restarts did not help.
|
|
235
|
+
# @raise [Connection::ServerError] when the server refuses the method —
|
|
236
|
+
# which a capability check should have caught first.
|
|
237
|
+
# @raise [Pikuri::Agent::Control::Cancellable::Cancelled] on cancellation.
|
|
238
|
+
def request(method, params = {}, cancellable: nil)
|
|
239
|
+
with_restart(cancellable) { @connection.request(method, params, cancellable: cancellable) }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Send a notification (+textDocument/didOpen+, +exit+), restarting on death
|
|
243
|
+
# like {#request} — a notification that vanishes into a dead pipe would
|
|
244
|
+
# otherwise be discovered by the *next* request answering nonsense.
|
|
245
|
+
#
|
|
246
|
+
# @param method [String]
|
|
247
|
+
# @param params [Hash]
|
|
248
|
+
# @return [void]
|
|
249
|
+
# @raise [ServerDied] when the server is gone and restarts did not help.
|
|
250
|
+
def notify(method, params = {})
|
|
251
|
+
with_restart(nil) { @connection.notify(method, params) }
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Tell the server what this file says *now*, by re-opening it.
|
|
255
|
+
#
|
|
256
|
+
# client.open_document(path, filesystem.resolve_for_read(path).read)
|
|
257
|
+
#
|
|
258
|
+
# This is the whole document-sync policy, and it is sound rather than lucky
|
|
259
|
+
# because **pikuri has no unsaved buffers**. An editor owns text the disk
|
|
260
|
+
# does not have, which is the only reason LSP's sync model exists; +write+
|
|
261
|
+
# and +edit+ land on disk immediately, so the disk is always the truth and
|
|
262
|
+
# the server's copy is only ever a cache of it. Re-opening is how you say
|
|
263
|
+
# "here is the truth again", and the text it needs is the text the tool has
|
|
264
|
+
# already read to locate the symbol.
|
|
265
|
+
#
|
|
266
|
+
# Every alternative was measured and each fails differently:
|
|
267
|
+
#
|
|
268
|
+
# * A **rangeless whole-document +didChange+** is not ours to send: both
|
|
269
|
+
# servers advertise *incremental* sync, and +TextDocumentSyncKind+ is the
|
|
270
|
+
# server's declaration of which shape it accepts. jdtls tolerates it;
|
|
271
|
+
# ruby-lsp raises inside +push_edits+, which reads +edit[:range][:start]+
|
|
272
|
+
# unconditionally, and a notification has no reply channel, so the only
|
|
273
|
+
# report is a +window/logMessage+ line while the document keeps its old
|
|
274
|
+
# text.
|
|
275
|
+
# * The **ranged** whole-document change is honoured by both, and then
|
|
276
|
+
# desynchronizes ruby-lsp's *index* from the document it just updated:
|
|
277
|
+
# +RubyDocument#should_index?+ reindexes only when the edit's start
|
|
278
|
+
# position looks like a declaration, and a whole-file replace starts at
|
|
279
|
+
# 0:0 — the +frozen_string_literal+ comment. +documentSymbol+ then reports
|
|
280
|
+
# the new name while +definition+ answers +[]+ for it.
|
|
281
|
+
# * **+didClose+ alone**, as the way to refresh: it resyncs ruby-lsp (its
|
|
282
|
+
# store entry is dropped, so the next request re-reads disk) and does
|
|
283
|
+
# nothing for jdtls, which keeps answering its pre-close copy. Rejected as
|
|
284
|
+
# the *mechanism* — {#close_document} still sends it, to balance the open
|
|
285
|
+
# rather than to refresh anything.
|
|
286
|
+
# * The spec's own out-of-band channel, +didChangeWatchedFiles+, is ignored
|
|
287
|
+
# by both *for a URI the client has opened* — a client that opened a
|
|
288
|
+
# document is presumed to be its authority, which is exactly the
|
|
289
|
+
# assumption an agent writing straight to disk breaks.
|
|
290
|
+
#
|
|
291
|
+
# Re-opening was the only mechanism that worked on both servers, refreshing
|
|
292
|
+
# document *and* index. The cost, named: the server re-parses the file on
|
|
293
|
+
# every call. That is a local child process doing what it does on every
|
|
294
|
+
# keystroke in an editor, and the alternative is a correctness bug that
|
|
295
|
+
# reports success.
|
|
296
|
+
#
|
|
297
|
+
# @param path [String, Pathname] absolute path of the document; the +file:+
|
|
298
|
+
# URI is derived from it.
|
|
299
|
+
# @param text [String] its current contents, read through the +Workspace+
|
|
300
|
+
# seam by the caller.
|
|
301
|
+
# @return [void]
|
|
302
|
+
# @raise [ServerDied] when the server is gone and restarts did not help.
|
|
303
|
+
def open_document(path, text)
|
|
304
|
+
@document_version += 1
|
|
305
|
+
notify('textDocument/didOpen',
|
|
306
|
+
{ textDocument: { uri: Uris.for_path(path), languageId: @entry.language_id,
|
|
307
|
+
version: @document_version, text: text } })
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Hand the document back, so the server returns to reading it from disk.
|
|
311
|
+
#
|
|
312
|
+
# Balances {#open_document} (+D_lsp_document_sync+), and must go out *after*
|
|
313
|
+
# the reply to the request it brackets: a server that answers concurrently
|
|
314
|
+
# can otherwise process the close first and answer about a document it has
|
|
315
|
+
# just dropped.
|
|
316
|
+
#
|
|
317
|
+
# Alone among the notifications here it neither restarts nor raises — a
|
|
318
|
+
# respawned child never received the +didOpen+ this balances, so there is
|
|
319
|
+
# nothing for a retry to reach.
|
|
320
|
+
#
|
|
321
|
+
# @param path [String, Pathname] the document opened earlier.
|
|
322
|
+
# @return [void]
|
|
323
|
+
def close_document(path)
|
|
324
|
+
return unless alive?
|
|
325
|
+
|
|
326
|
+
@connection.notify('textDocument/didClose', { textDocument: { uri: Uris.for_path(path) } })
|
|
327
|
+
rescue Connection::Closed => e
|
|
328
|
+
LOGGER.debug("#{@entry.id}: could not close #{path}: #{e.message}")
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Register a server-notification handler, kept across restarts and
|
|
332
|
+
# re-attached to each new channel.
|
|
333
|
+
#
|
|
334
|
+
# @yieldparam method [String]
|
|
335
|
+
# @yieldparam params [Hash, nil]
|
|
336
|
+
# @return [void]
|
|
337
|
+
# @see Connection#on_notification for the reader-thread contract
|
|
338
|
+
def on_notification(&block)
|
|
339
|
+
@handlers << block
|
|
340
|
+
@connection.on_notification(&block)
|
|
341
|
+
nil
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Whether the server advertised an operation *in this workspace*.
|
|
345
|
+
#
|
|
346
|
+
# @param name [String] a capability key, e.g. +"definitionProvider"+.
|
|
347
|
+
# @return [Boolean] +false+ both when the key is absent and when it is
|
|
348
|
+
# explicitly +false+.
|
|
349
|
+
def supports?(name)
|
|
350
|
+
value = @capabilities[name]
|
|
351
|
+
!value.nil? && value != false
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# @return [Boolean] whether the server has finished indexing — see
|
|
355
|
+
# {Readiness} for what that means and why it is one rule for every
|
|
356
|
+
# server. A request sent while this is +false+ is answered wrongly by
|
|
357
|
+
# ruby-lsp and not at all by jdtls.
|
|
358
|
+
def ready?
|
|
359
|
+
@readiness.ready?
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Block until the index is built, yielding progress while it is not.
|
|
363
|
+
#
|
|
364
|
+
# client.wait_until_ready(cancellable: cancellable) { |progress| emitter.call(progress) }
|
|
365
|
+
#
|
|
366
|
+
# There is no timeout: a cold jdtls import is minutes, no number separates
|
|
367
|
+
# a slow index from a hung one, and the yielded {ServerProgress} is what
|
|
368
|
+
# keeps the block legible rather than a hang. What ends a wait instead is
|
|
369
|
+
# readiness, the child's death, or +cancellable+ — the human as the
|
|
370
|
+
# timeout. A server that dies waiting is restarted and waited on again,
|
|
371
|
+
# {MAX_ATTEMPTS} times, because a fresh child indexes from nothing — but a
|
|
372
|
+
# replacement that cannot get through its *handshake* ends the attempt
|
|
373
|
+
# there and then, so a misconfigured server costs one respawn rather than
|
|
374
|
+
# a spawn per attempt.
|
|
375
|
+
#
|
|
376
|
+
# @param cancellable [Pikuri::Agent::Control::Cancellable, nil]
|
|
377
|
+
# @yieldparam progress [ServerProgress] on the *calling* thread, which is
|
|
378
|
+
# what makes it safe to emit as an agent event.
|
|
379
|
+
# @return [true]
|
|
380
|
+
# @raise [ServerDied] when the server died indexing and restarts did not
|
|
381
|
+
# get it further.
|
|
382
|
+
# @raise [Pikuri::Agent::Control::Cancellable::Cancelled] on cancellation.
|
|
383
|
+
def wait_until_ready(cancellable: nil, &progress)
|
|
384
|
+
progress ||= ->(_update) {}
|
|
385
|
+
with_restart(cancellable) do
|
|
386
|
+
next true if @readiness.wait(cancellable: cancellable, alive: -> { alive? }, &progress)
|
|
387
|
+
|
|
388
|
+
raise Connection::Closed, 'died while its index was building'
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# @return [Boolean] whether a request could still be answered.
|
|
393
|
+
def alive?
|
|
394
|
+
!@closed && @connection.alive?
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# @return [String] the child's last {STDERR_TAIL_LINES} stderr lines,
|
|
398
|
+
# newline-joined, +""+ when it said nothing (or when there is no child).
|
|
399
|
+
# Reset on restart, so this is always the *current* child's account.
|
|
400
|
+
def stderr_tail
|
|
401
|
+
@tail_mutex.synchronize { @stderr_tail.join("\n") }
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# @return [Integer, nil] the child's pid, +nil+ over pipes.
|
|
405
|
+
def pid
|
|
406
|
+
@wait_thread&.pid
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Shut the server down: +shutdown+, +exit+, close the channel, then signal
|
|
410
|
+
# anything still breathing. Idempotent, and never raises — teardown runs at
|
|
411
|
+
# exit, where a raise would take the rest of the sweep with it.
|
|
412
|
+
#
|
|
413
|
+
# @return [void]
|
|
414
|
+
def close
|
|
415
|
+
return if @closed
|
|
416
|
+
|
|
417
|
+
@closed = true
|
|
418
|
+
say_goodbye
|
|
419
|
+
@connection.close
|
|
420
|
+
# After the child is gone, not before: closing our read end while it is
|
|
421
|
+
# still writing hands a dying server EPIPE on its own log channel.
|
|
422
|
+
reap_child
|
|
423
|
+
close_stderr
|
|
424
|
+
nil
|
|
425
|
+
rescue StandardError => e
|
|
426
|
+
LOGGER.warn("#{@entry.id}: teardown raised #{e.class}: #{e.message}")
|
|
427
|
+
nil
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
private
|
|
431
|
+
|
|
432
|
+
def adopt(stdin:, stdout:, stderr: nil, wait_thread: nil)
|
|
433
|
+
close_stderr
|
|
434
|
+
@wait_thread = wait_thread
|
|
435
|
+
@tail_mutex.synchronize { @stderr_tail.clear }
|
|
436
|
+
@connection = Connection.new(stdin: stdin, stdout: stdout, server_id: @entry.id)
|
|
437
|
+
@connection.on_notification { |method, params| log_server_message(method, params) }
|
|
438
|
+
@handlers.each { |handler| @connection.on_notification(&handler) }
|
|
439
|
+
@stderr_io = stderr
|
|
440
|
+
@stderr_thread = stderr && drain_stderr(stderr)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# The previous child's stderr pipe, which +Connection#close+ never saw.
|
|
444
|
+
def close_stderr
|
|
445
|
+
@stderr_io&.close unless @stderr_io&.closed?
|
|
446
|
+
@stderr_io = nil
|
|
447
|
+
rescue IOError, SystemCallError
|
|
448
|
+
nil
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def handshake(cancellable)
|
|
452
|
+
result = @connection.request('initialize', initialize_params, cancellable: cancellable)
|
|
453
|
+
@capabilities = result.fetch('capabilities')
|
|
454
|
+
@server_info = result['serverInfo']
|
|
455
|
+
@position_encoding = @capabilities['positionEncoding'] || PositionEncoding::DEFAULT
|
|
456
|
+
@connection.notify('initialized', {})
|
|
457
|
+
LOGGER.info("#{@entry.id}: ready — #{@server_info&.fetch('name', nil) || 'unnamed server'}, " \
|
|
458
|
+
"#{@position_encoding}, #{@capabilities.keys.size} capabilities")
|
|
459
|
+
rescue Connection::Closed => e
|
|
460
|
+
raise ServerDied, death_message(e, during: 'startup')
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def initialize_params
|
|
464
|
+
{
|
|
465
|
+
processId: Process.pid,
|
|
466
|
+
clientInfo: { name: 'pikuri', version: Pikuri::VERSION },
|
|
467
|
+
rootUri: Uris.for_path(@root),
|
|
468
|
+
# Deprecated in favour of rootUri since 3.3, and jdtls still reads it.
|
|
469
|
+
rootPath: @root,
|
|
470
|
+
workspaceFolders: [{ uri: Uris.for_path(@root), name: File.basename(@root) }],
|
|
471
|
+
capabilities: CLIENT_CAPABILITIES,
|
|
472
|
+
initializationOptions: @entry.init_options
|
|
473
|
+
}.compact
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def with_restart(cancellable)
|
|
477
|
+
attempt = 1
|
|
478
|
+
begin
|
|
479
|
+
yield
|
|
480
|
+
rescue Connection::Closed => e
|
|
481
|
+
raise ServerDied, death_message(e) if @closed || @respawn.nil? || attempt >= MAX_ATTEMPTS
|
|
482
|
+
|
|
483
|
+
LOGGER.warn("#{@entry.id}: #{e.message}; restarting " \
|
|
484
|
+
"(attempt #{attempt + 1}/#{MAX_ATTEMPTS})")
|
|
485
|
+
restart(cancellable)
|
|
486
|
+
attempt += 1
|
|
487
|
+
retry
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def restart(cancellable)
|
|
492
|
+
@connection.close
|
|
493
|
+
reap_child
|
|
494
|
+
adopt(**@respawn.call)
|
|
495
|
+
@readiness.reset!
|
|
496
|
+
handshake(cancellable)
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def say_goodbye
|
|
500
|
+
return unless @connection.alive?
|
|
501
|
+
|
|
502
|
+
# The shutdown reply is never awaited: a server wedged mid-index would
|
|
503
|
+
# turn teardown into a hang, and the grace it would buy — time to persist
|
|
504
|
+
# before dying — is already {#reap_child}'s, waiting on the real signal.
|
|
505
|
+
@connection.send_request('shutdown')
|
|
506
|
+
@connection.notify('exit')
|
|
507
|
+
rescue StandardError => e
|
|
508
|
+
LOGGER.debug("#{@entry.id}: could not say goodbye (#{e.class}: #{e.message})")
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def reap_child
|
|
512
|
+
return unless @wait_thread
|
|
513
|
+
|
|
514
|
+
stop_leader
|
|
515
|
+
# Swept even when the leader left on its own: a server that honours
|
|
516
|
+
# +exit+ still strands the helpers it forked, and the group is the only
|
|
517
|
+
# handle on those.
|
|
518
|
+
signal_group('TERM')
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def stop_leader
|
|
522
|
+
return unless @wait_thread.alive?
|
|
523
|
+
return if @wait_thread.join(CLOSE_GRACE)
|
|
524
|
+
|
|
525
|
+
signal_group('TERM')
|
|
526
|
+
return if @wait_thread.join(CLOSE_GRACE)
|
|
527
|
+
|
|
528
|
+
LOGGER.warn("#{@entry.id}: pid #{@wait_thread.pid} ignored SIGTERM; killing the group")
|
|
529
|
+
signal_group('KILL')
|
|
530
|
+
@wait_thread.join(CLOSE_GRACE)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# Signals the whole group, not the pid: jdtls forks helpers, and the group
|
|
534
|
+
# is what +pgroup: true+ bought.
|
|
535
|
+
def signal_group(name)
|
|
536
|
+
Process.kill("-#{name}", @wait_thread.pid)
|
|
537
|
+
rescue Errno::ESRCH, Errno::EPERM => e
|
|
538
|
+
LOGGER.debug("#{@entry.id}: SIG#{name} to the group failed (#{e.class})")
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def drain_stderr(stderr)
|
|
542
|
+
Thread.new do
|
|
543
|
+
stderr.each_line do |line|
|
|
544
|
+
line = line.chomp
|
|
545
|
+
@tail_mutex.synchronize do
|
|
546
|
+
@stderr_tail << line
|
|
547
|
+
@stderr_tail.shift while @stderr_tail.size > STDERR_TAIL_LINES
|
|
548
|
+
end
|
|
549
|
+
LOGGER.debug("#{@entry.id} stderr: #{line}")
|
|
550
|
+
end
|
|
551
|
+
rescue IOError, SystemCallError
|
|
552
|
+
nil
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def log_server_message(method, params)
|
|
557
|
+
return unless %w[window/logMessage window/showMessage].include?(method)
|
|
558
|
+
|
|
559
|
+
level = LOG_LEVELS.fetch(params&.fetch('type', nil), :info)
|
|
560
|
+
LOGGER.public_send(level, "#{@entry.id}: #{params&.fetch('message', nil)}")
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def death_message(error, during: nil)
|
|
564
|
+
# EOF on stdout means the child is on its way out; give it a moment to
|
|
565
|
+
# land so the report can name an exit status instead of guessing.
|
|
566
|
+
@wait_thread&.join(REAP_ON_DEATH)
|
|
567
|
+
@stderr_thread&.join(REAP_ON_DEATH)
|
|
568
|
+
status = @wait_thread && !@wait_thread.alive? ? @wait_thread.value : nil
|
|
569
|
+
headline = status ? "exited with #{status_text(status)}" : 'stopped answering'
|
|
570
|
+
headline += " during #{during}" if during
|
|
571
|
+
tail = stderr_tail
|
|
572
|
+
message = "#{@entry.id} #{headline} — #{error.message}"
|
|
573
|
+
tail.empty? ? message : "#{message}; last stderr: #{tail}"
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def status_text(status)
|
|
577
|
+
status.exitstatus ? "exit #{status.exitstatus}" : "signal #{status.termsig}"
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
end
|