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
-
.clean_socket_files(config_path) ⇒ void
Remove stale socket and pid files.
- .config_hash(config_path) ⇒ String
-
.ensure_running!(config_path: nil, daemonize: false, timeout: 5) ⇒ void
Start the server daemon if not running.
-
.env_hash ⇒ String
Hash of environment files that affect analysis results.
-
.handle_stale_socket?(config_path) ⇒ Boolean
Handle ECONNREFUSED: check if the pid process is alive.
- .pid_path(config_path = nil) ⇒ String
- .process_alive?(pid) ⇒ Boolean
- .read_pid(config_path = nil) ⇒ Integer??
-
.running?(config_path = nil) ⇒ Boolean
Whether a server process is listening on the socket.
-
.socket_path(config_path = nil) ⇒ String
Derive a project-specific socket path from the current working directory.
- .start_daemon_process(config_path:, daemonize:) ⇒ void
-
.wait_for_ready(config_path: nil, timeout: 5, raise_on_timeout: true) ⇒ Boolean
Start the server daemon and wait for it to become ready.
Class Method Details
.clean_socket_files(config_path) ⇒ void
This method returns an undefined value.
Remove stale socket and pid files.
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
178 179 180 181 182 |
# File 'lib/docscribe/server.rb', line 178 def config_hash(config_path) resolved = File.(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.
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_hash ⇒ String
Hash of environment files that affect analysis results. When any of these change, the daemon is invalidated (new socket path).
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.
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
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
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??
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.
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.
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.(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.
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.
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 |