Module: Docscribe::Server

Defined in:
lib/docscribe/server/base.rb,
lib/docscribe/server/client.rb,
lib/docscribe/server/daemon.rb,
lib/docscribe/server/protocol.rb

Overview

Server/daemon mode for persistent multi-request operation.

Architecture:

  • Daemon process loads Ruby runtime once, listens on a Unix socket
  • Client sends JSON-line requests, receives JSON-line responses
  • Auto-shutdown after idle timeout
  • Protocol: JSON-RPC 2.0 over Unix socket

Defined Under Namespace

Modules: Protocol Classes: Client, Daemon

Constant Summary collapse

SOCKET_DIR =

Unix socket path max is 104 bytes on macOS (the more restrictive). Dir.tmpdir on macOS often returns a long path under /var/folders/.../T that exceeds this limit, so we fall back to /tmp when needed.

begin
  tmp = Dir.tmpdir || '/tmp'
  sock_overhead = "/docscribe-#{'a' * 32}.sock".bytesize # 48
  tmp.bytesize <= 104 - sock_overhead ? tmp : '/tmp'
end
IDLE_TIMEOUT =
300
ENV_FILES =
%w[Gemfile.lock rbs_collection.lock.yaml docscribe.yml].freeze
SIG_RBS_GLOB =
'sig/**/*.rbs'

Class Method Summary collapse

Class Method Details

.check_platform_support!void

This method returns an undefined value.

Check platform compatibility before starting server.

Raises:

  • (StandardError)


165
166
167
168
169
170
171
172
173
174
# File 'lib/docscribe/server/base.rb', line 165

def check_platform_support!
  unless defined?(UNIXSocket)
    raise 'Server mode requires Unix domain sockets, which are not available on Windows. ' \
          'Use docscribe directly without --server flag.'
  end
  return if Process.respond_to?(:fork)

  raise 'Server mode requires Process.fork, which is not available on JRuby. ' \
        'Use docscribe directly without --server flag.'
end

.clean_socket_files(config_path) ⇒ void

This method returns an undefined value.

Remove stale socket and pid files.

Parameters:

  • config_path (String?)


139
140
141
142
# File 'lib/docscribe/server/base.rb', line 139

def clean_socket_files(config_path)
  FileUtils.rm_f(socket_path(config_path))
  FileUtils.rm_f(pid_path(config_path))
end

.config_hash(config_path) ⇒ String

Parameters:

  • config_path (String)

Returns:

  • (String)


155
156
157
158
159
# File 'lib/docscribe/server/base.rb', line 155

def config_hash(config_path)
  resolved = File.expand_path(config_path)
  mtime = File.exist?(resolved) ? File.mtime(resolved).to_f : 0.0
  Digest::MD5.hexdigest("#{resolved}:#{mtime}")
end

.ensure_running!(config_path: nil, daemonize: false, timeout: 5) ⇒ void

This method returns an undefined value.

Start the server daemon if not running.

Parameters:

  • config_path (String?) (defaults to: nil)

    optional config file path

  • daemonize (Boolean) (defaults to: false)

    redirect stdin/stdout/stderr to /dev/null

  • timeout (Integer) (defaults to: 5)

    max seconds to wait for readiness



34
35
36
37
38
39
40
41
42
43
44
45
46
47
# File 'lib/docscribe/server/base.rb', line 34

def ensure_running!(config_path: nil, daemonize: false, timeout: 5)
  return if running?(config_path)

  check_platform_support!

  lock_path = "#{socket_path(config_path)}.lock"
  File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |lock|
    lock.flock(File::LOCK_EX)
    next if running?(config_path)

    start_daemon_process(config_path: config_path, daemonize: daemonize)
  end
  wait_for_ready(config_path: config_path, timeout: timeout)
end

.env_file_mtime(file) ⇒ String

Parameters:

  • file (String)

Returns:

  • (String)


221
222
223
224
# File 'lib/docscribe/server/base.rb', line 221

def env_file_mtime(file)
  path = File.join(Dir.pwd, file)
  File.exist?(path) ? File.mtime(path).to_f.to_s : '0'
end

.env_hashString

Hash of environment files that affect analysis results. When any of these change, the daemon is invalidated (new socket path). Includes Gemfile.lock, rbs_collection.lock.yaml, docscribe.yml and all sig/**/*.rbs.

Returns:

  • (String)


202
203
204
205
206
# File 'lib/docscribe/server/base.rb', line 202

def env_hash
  parts = ENV_FILES.map { |file| env_file_mtime(file) }
  parts.concat(sig_env_parts)
  Digest::MD5.hexdigest(parts.join(':'))
end

.handle_stale_socket?(config_path) ⇒ Boolean

Handle ECONNREFUSED: check if the pid process is alive. Cleans up only if the process is dead.

Parameters:

  • config_path (String?)

Returns:

  • (Boolean)

    false (not running)



106
107
108
109
110
111
112
# File 'lib/docscribe/server/base.rb', line 106

def handle_stale_socket?(config_path)
  pid = read_pid(config_path)
  return false if pid && process_alive?(pid)

  clean_socket_files(config_path)
  false
end

.pid_path(config_path = nil) ⇒ String

Parameters:

  • config_path (String?) (defaults to: nil)

Returns:

  • (String)


146
147
148
# File 'lib/docscribe/server/base.rb', line 146

def pid_path(config_path = nil)
  "#{socket_path(config_path)}.pid"
end

.process_alive?(pid) ⇒ Boolean

Parameters:

  • pid (Integer)

Returns:

  • (Boolean)
  • (Boolean)

    if Errno::ESRCH

Raises:

  • (Errno::ESRCH)


118
119
120
121
122
123
# File 'lib/docscribe/server/base.rb', line 118

def process_alive?(pid)
  Process.kill(0, pid)
  true
rescue Errno::ESRCH
  false
end

.read_pid(config_path = nil) ⇒ Integer??

Parameters:

  • config_path (String?) (defaults to: nil)

Returns:

  • (Integer?)
  • (nil)

    if StandardError

Raises:

  • (StandardError)


129
130
131
132
133
# File 'lib/docscribe/server/base.rb', line 129

def read_pid(config_path = nil)
  File.read(pid_path(config_path)).to_i if File.exist?(pid_path(config_path))
rescue StandardError
  nil
end

.running?(config_path = nil) ⇒ Boolean, void

Whether a server process is listening on the socket.

On ECONNREFUSED, checks whether the PID process is still alive: if yes, the daemon is still starting up (don't clean up); if no, removes stale socket and pid files.

Parameters:

  • config_path (String?) (defaults to: nil)

    optional config path for socket lookup

Returns:

  • (Boolean)
  • (Boolean)

    if Errno::ECONNREFUSED

  • (void, Boolean)

    if Errno::ENOENT, Errno::ENOTSOCK

  • (Boolean)

    if StandardError

Raises:

  • (Errno::ECONNREFUSED)
  • (Errno::ENOENT)
  • (Errno::ENOTSOCK)
  • (StandardError)


87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/docscribe/server/base.rb', line 87

def running?(config_path = nil)
  return false unless defined?(UNIXSocket)

  socket = UNIXSocket.new(socket_path(config_path))
  socket.close
  true
rescue Errno::ECONNREFUSED
  handle_stale_socket?(config_path)
rescue Errno::ENOENT, Errno::ENOTSOCK
  clean_socket_files(config_path) && false
rescue StandardError
  false
end

.sig_env_partsArray<String>

Returns:

  • (Array<String>)


227
228
229
230
231
232
# File 'lib/docscribe/server/base.rb', line 227

def sig_env_parts
  files = sig_files
  mtimes = files.map { |p| File.mtime(p).to_f.to_s }
  mtimes << files.size.to_s
  mtimes
end

.sig_filesArray<String>

Returns:

  • (Array<String>)


235
236
237
# File 'lib/docscribe/server/base.rb', line 235

def sig_files
  Dir.glob(File.join(Dir.pwd, SIG_RBS_GLOB)).sort
end

.sig_hashString

Hash of RBS signature files for cache invalidation inside daemon. Used by Daemon#rewrite_file to detect sig changes without requiring a new socket.

Returns:

  • (String)


212
213
214
215
216
217
# File 'lib/docscribe/server/base.rb', line 212

def sig_hash
  files = sig_files
  parts = files.map { |p| "#{p}:#{File.mtime(p).to_f}" }
  parts << "count:#{files.size}"
  Digest::MD5.hexdigest(parts.join('|'))
end

.socket_path(config_path = nil) ⇒ String

Derive a project-specific socket path from the current working directory. Uses MD5 (deterministic across processes) instead of String#hash (which varies per Ruby process due to random seeding). When a config_path is given, its path + mtime are included in the hash so different configs get different daemons. Environment files (Gemfile.lock, rbs_collection.lock.yaml) are also included so daemon is invalidated when gems or RBS types change.

Parameters:

  • config_path (String?) (defaults to: nil)

    optional config path to differentiate

Returns:

  • (String)


186
187
188
189
190
191
192
193
194
195
# File 'lib/docscribe/server/base.rb', line 186

def socket_path(config_path = nil)
  seed = +Dir.pwd
  seed << ":#{env_hash}"
  if config_path
    resolved = File.expand_path(config_path)
    mtime = File.exist?(resolved) ? File.mtime(resolved).to_f : 0.0
    seed << ":#{resolved}:#{mtime}"
  end
  "#{SOCKET_DIR}/docscribe-#{Digest::MD5.hexdigest(seed)}.sock"
end

.start_daemon_process(config_path:, daemonize:) ⇒ void

This method returns an undefined value.

Parameters:

  • config_path (String?)
  • daemonize (Boolean)


244
245
246
247
248
249
250
251
252
# File 'lib/docscribe/server/base.rb', line 244

def start_daemon_process(config_path:, daemonize:)
  warn 'Docscribe: starting server...' if daemonize
  pid = Process.fork do # steep:ignore NoMethod
    [$stdin, $stdout].each { _1.reopen(File::NULL) }
    $stderr.reopen(File::NULL)
    Daemon.new(config_path: config_path).start
  end
  Process.detach(pid)
end

.wait_for_ready(config_path: nil, timeout: 5, raise_on_timeout: true) ⇒ Boolean

Start the server daemon and wait for it to become ready.

Parameters:

  • config_path (String?) (defaults to: nil)

    optional config path for socket/pid lookup

  • timeout (Integer) (defaults to: 5)

    max seconds to wait for readiness

  • raise_on_timeout (Boolean) (defaults to: true)

Returns:

  • (Boolean)

Raises:

  • (StandardError)


56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/docscribe/server/base.rb', line 56

def wait_for_ready(config_path: nil, timeout: 5, raise_on_timeout: true) # rubocop:disable SortedMethodsByCall/Waterfall
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
  loop do
    return true if running?(config_path)

    if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
      raise('Docscribe: server failed to start') if raise_on_timeout

      warn('Docscribe server failed to start within timeout')
      return false
    end

    sleep 0.1
  end
end