Module: Docscribe::Server

Defined in:
lib/docscribe/server.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].freeze

Class Method Summary collapse

Class Method Details

.clean_socket_files(config_path) ⇒ void

This method returns an undefined value.

Remove stale socket and pid files.

Parameters:

  • config_path (String?)


142
143
144
145
# File 'lib/docscribe/server.rb', line 142

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)


178
179
180
181
182
# File 'lib/docscribe/server.rb', line 178

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

Raises:

  • (StandardError)


39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'lib/docscribe/server.rb', line 39

def ensure_running!(config_path: nil, daemonize: false, timeout: 5)
  return if running?(config_path)
  raise 'Server mode is unavailable on this Ruby/platform (Process.fork not supported)' unless Process.respond_to?(:fork)

  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_hashString

Hash of environment files that affect analysis results. When any of these change, the daemon is invalidated (new socket path).

Returns:

  • (String)


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

def env_hash
  parts = ENV_FILES.map do |file|
    path = File.join(Dir.pwd, file)
    File.exist?(path) ? File.mtime(path).to_f.to_s : '0'
  end
  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)



109
110
111
112
113
114
115
# File 'lib/docscribe/server.rb', line 109

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)


149
150
151
# File 'lib/docscribe/server.rb', line 149

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)


121
122
123
124
125
126
# File 'lib/docscribe/server.rb', line 121

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)


132
133
134
135
136
# File 'lib/docscribe/server.rb', line 132

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

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

  • (Boolean)

    if Errno::ENOENT, Errno::ENOTSOCK

  • (Boolean)

    if StandardError

Raises:

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


91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/docscribe/server.rb', line 91

def running?(config_path = nil)
  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

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


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

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)


201
202
203
204
205
206
207
208
209
# File 'lib/docscribe/server.rb', line 201

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)


60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/docscribe/server.rb', line 60

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