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.
@@ -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