Class: Pikuri::Lsp::ClientWrapper

Inherits:
Object
  • Object
show all
Defined in:
lib/pikuri/lsp/client_wrapper.rb

Overview

One long-lived language server: the child process, the LSP handshake, and a restart when it dies under us. Connection carries the bytes; this owns the lifecycle around them.

client = ClientWrapper.spawn(entry, root: filesystem.project_root)
client.supports?('definitionProvider')   # => true
client.position_encoding                 # => "utf-8"  (ruby-lsp; jdtls stays utf-16)
client.wait_until_ready { |progress| emitter.call(progress) }
client.request('textDocument/definition', params, cancellable: cancellable)
client.close

#wait_until_ready before the first request is not optional politeness: ruby-lsp answers a mid-index query with a successful [], which an agent reads as "no definition exists". See Readiness.

ClientWrapper.spawn raises rather than returning a half-live object, so a missing binary or a server that dies during its handshake is a boot-time failure with the child's own last words attached (#stderr_tail) — the one thing that says why it died.

Capabilities are per workspace, not per binary

#supports? answers from this initialize response, never from a matrix keyed on the binary: the same ruby-lsp advertises document formatting in a project whose bundle has rubocop and withholds it in one that doesn't. That is why the check belongs at dispatch time, and why #capabilities stays a plain Hash — pikuri only ever asks whether a key is there.

Restart, and what a fresh server has forgotten

A Pikuri::Lsp::Connection::Closed mid-call means the child is gone for good, so #request respawns and retries up to MAX_ATTEMPTS times before raising ServerDied; a wrapper built with no respawn: (a test over pipes) raises on the first death instead. Documents need no replay — pikuri re-sends didOpen before every call anyway — but a fresh server's index is empty, so a restart also puts #ready? back to false and the next #wait_until_ready waits the new child out from scratch.

Documents: an agent is not an editor, so it re-opens

#open_document sends textDocument/didOpen with the file's current disk text before every position-based call, and #close_document hands it back when the call is done. No open-URI set and no version comparison — the pair is per call, which is also what keeps it correct across a restart, since a respawned child inherits no open documents. See #open_document for why each alternative to re-opening was measured and rejected.

The Subprocess-seam exception

Subprocess.spawn is one-shot (stdin closed, output merged, read to EOF) and a language server is the opposite on all three counts, so this is the documented exception to the chokepoint convention alongside stdio MCP's: Open3.popen3 here, and #close owns the teardown — shutdown, exit, then SIGTERM to the whole process group, because jdtls forks helpers.

Defined Under Namespace

Classes: ServerDied

Constant Summary collapse

MAX_ATTEMPTS =

Total #request attempts, including the first — 3 means one normal try plus two restart-then-retry rounds.

3
STDERR_TAIL_LINES =

Lines of the child's stderr kept for #stderr_tail. jdtls is chatty; what matters is the last thing it said before dying.

20
CLOSE_GRACE =

Seconds #close gives the child to leave on its own, at each escalation (EOF, then SIGTERM, then SIGKILL). Teardown is the one place with a clock, and this is what it is spent on: a close that blocks forever is worse than an impolite one.

2
REAP_ON_DEATH =

Seconds a death report waits for the child's exit status and its final stderr, both of which are in flight the moment stdout hits EOF.

0.5
CLIENT_CAPABILITIES =

What pikuri tells the server it can do. Four declarations are load-bearing, each measured against a real server: positionEncodings (offer utf-8 and convert when a server ignores the offer), workDoneProgress (jdtls emits no $/progress without it, narrating startup through a non-spec notification instead), linkSupport (so a definition answers LocationLink and pins the identifier's own range), and contentFormat for hover. The empty objects merely say "this operation exists here"; documentSymbol deliberately does not claim hierarchical support, so symbols arrive flat.

{
  general: { positionEncodings: PositionEncoding::OFFERED },
  window: { workDoneProgress: true },
  textDocument: {
    synchronization: { dynamicRegistration: false },
    definition: { linkSupport: true },
    typeDefinition: { linkSupport: true },
    implementation: { linkSupport: true },
    references: {},
    hover: { contentFormat: %w[markdown plaintext] },
    documentSymbol: {},
    typeHierarchy: {},
    callHierarchy: {}
  },
  workspace: { symbol: {}, workspaceFolders: true }
}.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(entry:, root:, stdin:, stdout:, stderr: nil, wait_thread: nil, respawn: nil, readiness: nil) ⇒ ClientWrapper

Adopts the channel and starts reading it, but sends nothing: #start is the handshake. Splitting the two is what lets a fake server drive a wrapper over IO.pipe, with no binary in sight.

Parameters:

  • entry (Registry::StdioEntry)
  • root (String, Pathname)

    workspace root, reported as rootUri.

  • stdin (IO)

    the server's stdin.

  • stdout (IO)

    the server's stdout.

  • stderr (IO, nil) (defaults to: nil)

    the server's stderr, drained into #stderr_tail; nil when there is no child to have one.

  • wait_thread (Process::Waiter, nil) (defaults to: nil)

    the child's waiter, for exit status and signalling; nil over pipes.

  • respawn (Proc, nil) (defaults to: nil)

    returns the kwargs for a fresh child (+stdout:, stderr:, wait_thread:+). Without it, a dead channel is terminal — see the class's restart note.

  • readiness (Readiness, nil) (defaults to: nil)

    the indexing gate, which this subscribes to the server's notifications. Injectable so a spec can shorten the settle window; production passes nothing.



194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/pikuri/lsp/client_wrapper.rb', line 194

def initialize(entry:, root:, stdin:, stdout:, stderr: nil, wait_thread: nil, respawn: nil,
               readiness: nil)
  @entry = entry
  @root = File.expand_path(root.to_s)
  @respawn = respawn
  @handlers = []
  @capabilities = {}
  @stderr_tail = []
  @stderr_io = nil
  @tail_mutex = Mutex.new
  @started = false
  @document_version = 0
  @closed = false
  @readiness = readiness || Readiness.new(server_id: entry.id)
  adopt(stdin: stdin, stdout: stdout, stderr: stderr, wait_thread: wait_thread)
  on_notification { |method, params| @readiness.observe(method, params) }
end

Instance Attribute Details

#capabilitiesHash{String => Object} (readonly)

Returns the server's advertised capabilities, verbatim from initialize{} before #start. Values are unions in practice (+true+ from one server, an options object from another), which is why nothing types them.

Returns:

  • (Hash{String => Object})

    the server's advertised capabilities, verbatim from initialize{} before #start. Values are unions in practice (+true+ from one server, an options object from another), which is why nothing types them.



164
165
166
# File 'lib/pikuri/lsp/client_wrapper.rb', line 164

def capabilities
  @capabilities
end

#entryRegistry::StdioEntry (readonly)

Returns the configuration this client runs.

Returns:



158
159
160
# File 'lib/pikuri/lsp/client_wrapper.rb', line 158

def entry
  @entry
end

#position_encodingString? (readonly)

Returns the negotiated positionEncoding — every Position crossing this channel converts with it — or nil before #start. PositionEncoding::DEFAULT when the server answers nothing, which is jdtls's answer.

Returns:

  • (String, nil)

    the negotiated positionEncoding — every Position crossing this channel converts with it — or nil before #start. PositionEncoding::DEFAULT when the server answers nothing, which is jdtls's answer.



174
175
176
# File 'lib/pikuri/lsp/client_wrapper.rb', line 174

def position_encoding
  @position_encoding
end

#server_infoHash{String => Object}? (readonly)

Returns the server's serverInfo (+name+ / version), when it sent one.

Returns:

  • (Hash{String => Object}, nil)

    the server's serverInfo (+name+ / version), when it sent one.



168
169
170
# File 'lib/pikuri/lsp/client_wrapper.rb', line 168

def server_info
  @server_info
end

Class Method Details

.spawn(entry, root:, cancellable: nil) ⇒ ClientWrapper

Spawn the child, run the handshake, return a ready client.

Parameters:

  • entry (Registry::StdioEntry)

    what to launch.

  • root (String, Pathname)

    the workspace root: the child's cwd and the rootUri it indexes. One value, so the server's idea of the project cannot drift from the confinement boundary.

  • cancellable (Pikuri::Agent::Control::Cancellable, nil) (defaults to: nil)

    breaks the handshake wait.

Returns:

  • (ClientWrapper)

    started, handshaken; indexing continues in the background.

Raises:

  • (ServerDied)

    if the binary is missing, or the child dies before completing the handshake. Nothing is left half-open.



132
133
134
135
136
137
138
139
140
141
# File 'lib/pikuri/lsp/client_wrapper.rb', line 132

def self.spawn(entry, root:, cancellable: nil)
  respawn = -> { spawn_child(entry, root) }
  wrapper = new(entry: entry, root: root, respawn: respawn, **respawn.call)
  begin
    wrapper.start(cancellable: cancellable)
  rescue StandardError
    wrapper.close
    raise
  end
end

Instance Method Details

#alive?Boolean

Returns whether a request could still be answered.

Returns:

  • (Boolean)

    whether a request could still be answered.



393
394
395
# File 'lib/pikuri/lsp/client_wrapper.rb', line 393

def alive?
  !@closed && @connection.alive?
end

#closevoid

This method returns an undefined value.

Shut the server down: shutdown, exit, close the channel, then signal anything still breathing. Idempotent, and never raises — teardown runs at exit, where a raise would take the rest of the sweep with it.



414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
# File 'lib/pikuri/lsp/client_wrapper.rb', line 414

def close
  return if @closed

  @closed = true
  say_goodbye
  @connection.close
  # After the child is gone, not before: closing our read end while it is
  # still writing hands a dying server EPIPE on its own log channel.
  reap_child
  close_stderr
  nil
rescue StandardError => e
  LOGGER.warn("#{@entry.id}: teardown raised #{e.class}: #{e.message}")
  nil
end

#close_document(path) ⇒ void

This method returns an undefined value.

Hand the document back, so the server returns to reading it from disk.

Balances #open_document (+D_lsp_document_sync+), and must go out after the reply to the request it brackets: a server that answers concurrently can otherwise process the close first and answer about a document it has just dropped.

Alone among the notifications here it neither restarts nor raises — a respawned child never received the didOpen this balances, so there is nothing for a retry to reach.

Parameters:

  • path (String, Pathname)

    the document opened earlier.



323
324
325
326
327
328
329
# File 'lib/pikuri/lsp/client_wrapper.rb', line 323

def close_document(path)
  return unless alive?

  @connection.notify('textDocument/didClose', { textDocument: { uri: Uris.for_path(path) } })
rescue Connection::Closed => e
  LOGGER.debug("#{@entry.id}: could not close #{path}: #{e.message}")
end

#notify(method, params = {}) ⇒ void

This method returns an undefined value.

Send a notification (+textDocument/didOpen+, exit), restarting on death like #request — a notification that vanishes into a dead pipe would otherwise be discovered by the next request answering nonsense.

Parameters:

  • method (String)
  • params (Hash) (defaults to: {})

Raises:

  • (ServerDied)

    when the server is gone and restarts did not help.



250
251
252
# File 'lib/pikuri/lsp/client_wrapper.rb', line 250

def notify(method, params = {})
  with_restart(nil) { @connection.notify(method, params) }
end

#on_notification {|method, params| ... } ⇒ void

This method returns an undefined value.

Register a server-notification handler, kept across restarts and re-attached to each new channel.

Yield Parameters:

  • method (String)
  • params (Hash, nil)

See Also:



338
339
340
341
342
# File 'lib/pikuri/lsp/client_wrapper.rb', line 338

def on_notification(&block)
  @handlers << block
  @connection.on_notification(&block)
  nil
end

#open_document(path, text) ⇒ void

This method returns an undefined value.

Tell the server what this file says now, by re-opening it.

client.open_document(path, filesystem.resolve_for_read(path).read)

This is the whole document-sync policy, and it is sound rather than lucky because pikuri has no unsaved buffers. An editor owns text the disk does not have, which is the only reason LSP's sync model exists; write and edit land on disk immediately, so the disk is always the truth and the server's copy is only ever a cache of it. Re-opening is how you say "here is the truth again", and the text it needs is the text the tool has already read to locate the symbol.

Every alternative was measured and each fails differently:

  • A rangeless whole-document +didChange+ is not ours to send: both servers advertise incremental sync, and TextDocumentSyncKind is the server's declaration of which shape it accepts. jdtls tolerates it; ruby-lsp raises inside push_edits, which reads edit[:range][:start] unconditionally, and a notification has no reply channel, so the only report is a window/logMessage line while the document keeps its old text.
  • The ranged whole-document change is honoured by both, and then desynchronizes ruby-lsp's index from the document it just updated: RubyDocument#should_index? reindexes only when the edit's start position looks like a declaration, and a whole-file replace starts at 0:0 — the frozen_string_literal comment. documentSymbol then reports the new name while definition answers [] for it.
  • +didClose+ alone, as the way to refresh: it resyncs ruby-lsp (its store entry is dropped, so the next request re-reads disk) and does nothing for jdtls, which keeps answering its pre-close copy. Rejected as the mechanism#close_document still sends it, to balance the open rather than to refresh anything.
  • The spec's own out-of-band channel, didChangeWatchedFiles, is ignored by both for a URI the client has opened — a client that opened a document is presumed to be its authority, which is exactly the assumption an agent writing straight to disk breaks.

Re-opening was the only mechanism that worked on both servers, refreshing document and index. The cost, named: the server re-parses the file on every call. That is a local child process doing what it does on every keystroke in an editor, and the alternative is a correctness bug that reports success.

Parameters:

  • path (String, Pathname)

    absolute path of the document; the file: URI is derived from it.

  • text (String)

    its current contents, read through the Workspace seam by the caller.

Raises:

  • (ServerDied)

    when the server is gone and restarts did not help.



303
304
305
306
307
308
# File 'lib/pikuri/lsp/client_wrapper.rb', line 303

def open_document(path, text)
  @document_version += 1
  notify('textDocument/didOpen',
         { textDocument: { uri: Uris.for_path(path), languageId: @entry.language_id,
                           version: @document_version, text: text } })
end

#pidInteger?

Returns the child's pid, nil over pipes.

Returns:

  • (Integer, nil)

    the child's pid, nil over pipes.



405
406
407
# File 'lib/pikuri/lsp/client_wrapper.rb', line 405

def pid
  @wait_thread&.pid
end

#ready?Boolean

Returns whether the server has finished indexing — see Readiness for what that means and why it is one rule for every server. A request sent while this is false is answered wrongly by ruby-lsp and not at all by jdtls.

Returns:

  • (Boolean)

    whether the server has finished indexing — see Readiness for what that means and why it is one rule for every server. A request sent while this is false is answered wrongly by ruby-lsp and not at all by jdtls.



358
359
360
# File 'lib/pikuri/lsp/client_wrapper.rb', line 358

def ready?
  @readiness.ready?
end

#request(method, params = {}, cancellable: nil) ⇒ Hash, ...

Send a request, restarting the server and retrying if it dies.

Parameters:

  • method (String)

    e.g. "textDocument/definition".

  • params (Hash) (defaults to: {})
  • cancellable (Pikuri::Agent::Control::Cancellable, nil) (defaults to: nil)

Returns:

  • (Hash, Array, String, Integer, true, false, nil)

    the result member; nil and [] are answers, not failures.

Raises:

  • (ServerDied)

    when the server is gone and restarts did not help.

  • (Connection::ServerError)

    when the server refuses the method — which a capability check should have caught first.

  • (Pikuri::Agent::Control::Cancellable::Cancelled)

    on cancellation.



238
239
240
# File 'lib/pikuri/lsp/client_wrapper.rb', line 238

def request(method, params = {}, cancellable: nil)
  with_restart(cancellable) { @connection.request(method, params, cancellable: cancellable) }
end

#start(cancellable: nil) ⇒ self

Run the LSP handshake: initialize, then initialized.

Parameters:

  • cancellable (Pikuri::Agent::Control::Cancellable, nil) (defaults to: nil)

Returns:

  • (self)

Raises:

  • (ServerDied)

    if the child dies mid-handshake.

  • (KeyError)

    if the server answers no capabilities — it is not speaking LSP.



219
220
221
222
223
224
225
# File 'lib/pikuri/lsp/client_wrapper.rb', line 219

def start(cancellable: nil)
  raise "#{@entry.id}: already started" if @started

  handshake(cancellable)
  @started = true
  self
end

#stderr_tailString

Returns the child's last STDERR_TAIL_LINES stderr lines, newline-joined, "" when it said nothing (or when there is no child). Reset on restart, so this is always the current child's account.

Returns:

  • (String)

    the child's last STDERR_TAIL_LINES stderr lines, newline-joined, "" when it said nothing (or when there is no child). Reset on restart, so this is always the current child's account.



400
401
402
# File 'lib/pikuri/lsp/client_wrapper.rb', line 400

def stderr_tail
  @tail_mutex.synchronize { @stderr_tail.join("\n") }
end

#supports?(name) ⇒ Boolean

Whether the server advertised an operation in this workspace.

Parameters:

  • name (String)

    a capability key, e.g. "definitionProvider".

Returns:

  • (Boolean)

    false both when the key is absent and when it is explicitly false.



349
350
351
352
# File 'lib/pikuri/lsp/client_wrapper.rb', line 349

def supports?(name)
  value = @capabilities[name]
  !value.nil? && value != false
end

#wait_until_ready(cancellable: nil) {|progress| ... } ⇒ true

Block until the index is built, yielding progress while it is not.

client.wait_until_ready(cancellable: cancellable) { |progress| emitter.call(progress) }

There is no timeout: a cold jdtls import is minutes, no number separates a slow index from a hung one, and the yielded ServerProgress is what keeps the block legible rather than a hang. What ends a wait instead is readiness, the child's death, or cancellable — the human as the timeout. A server that dies waiting is restarted and waited on again, MAX_ATTEMPTS times, because a fresh child indexes from nothing — but a replacement that cannot get through its handshake ends the attempt there and then, so a misconfigured server costs one respawn rather than a spawn per attempt.

Parameters:

  • cancellable (Pikuri::Agent::Control::Cancellable, nil) (defaults to: nil)

Yield Parameters:

  • progress (ServerProgress)

    on the calling thread, which is what makes it safe to emit as an agent event.

Returns:

  • (true)

Raises:

  • (ServerDied)

    when the server died indexing and restarts did not get it further.

  • (Pikuri::Agent::Control::Cancellable::Cancelled)

    on cancellation.



383
384
385
386
387
388
389
390
# File 'lib/pikuri/lsp/client_wrapper.rb', line 383

def wait_until_ready(cancellable: nil, &progress)
  progress ||= ->(_update) {}
  with_restart(cancellable) do
    next true if @readiness.wait(cancellable: cancellable, alive: -> { alive? }, &progress)

    raise Connection::Closed, 'died while its index was building'
  end
end