docscribe 1.6.0 → 1.6.1
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.
- checksums.yaml +4 -4
- data/lib/docscribe/server/base.rb +224 -0
- data/lib/docscribe/server/client.rb +86 -0
- data/lib/docscribe/server/daemon.rb +501 -0
- data/lib/docscribe/server/protocol.rb +50 -0
- data/lib/docscribe/server.rb +4 -835
- data/lib/docscribe/version.rb +1 -1
- metadata +7 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 24df08456004986c5cae869a2e7dc9ba4a1176cbf5dbaaca679cb4661836a3dc
|
|
4
|
+
data.tar.gz: 746d064972fe321f6b0d7459d87f1fc4cec8c414b4b4b73b9cce916f72b4139d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4cf1ba70c9a43ec923abda6c16bbd599b287803fd9c6968b0e4c0ea87079fd8d1dd95317f7a20715f6664a7a1876da46cf008b3f6c21fc115237592b041f29b2
|
|
7
|
+
data.tar.gz: 0e7e994de57e167edf3a47b3d92ad1c123cb2decf973d137fcc7ac0bcd33f869fc380abd2d2705a2820d09837bce012b12f2f7efc2bbf0fe679da226040e43fb
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'digest/md5'
|
|
6
|
+
require 'tmpdir'
|
|
7
|
+
|
|
8
|
+
module Docscribe
|
|
9
|
+
# Server/daemon mode for persistent multi-request operation.
|
|
10
|
+
#
|
|
11
|
+
# Architecture:
|
|
12
|
+
# - Daemon process loads Ruby runtime once, listens on a Unix socket
|
|
13
|
+
# - Client sends JSON-line requests, receives JSON-line responses
|
|
14
|
+
# - Auto-shutdown after idle timeout
|
|
15
|
+
# - Protocol: JSON-RPC 2.0 over Unix socket
|
|
16
|
+
module Server
|
|
17
|
+
# Unix socket path max is 104 bytes on macOS (the more restrictive).
|
|
18
|
+
# Dir.tmpdir on macOS often returns a long path under /var/folders/.../T
|
|
19
|
+
# that exceeds this limit, so we fall back to /tmp when needed.
|
|
20
|
+
SOCKET_DIR = begin
|
|
21
|
+
tmp = Dir.tmpdir || '/tmp'
|
|
22
|
+
sock_overhead = "/docscribe-#{'a' * 32}.sock".bytesize # 48
|
|
23
|
+
tmp.bytesize <= 104 - sock_overhead ? tmp : '/tmp'
|
|
24
|
+
end
|
|
25
|
+
IDLE_TIMEOUT = 300
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Start the server daemon if not running.
|
|
29
|
+
#
|
|
30
|
+
# @param [String, nil] config_path optional config file path
|
|
31
|
+
# @param [Boolean] daemonize redirect stdin/stdout/stderr to /dev/null
|
|
32
|
+
# @param [Integer] timeout max seconds to wait for readiness
|
|
33
|
+
# @return [void]
|
|
34
|
+
def ensure_running!(config_path: nil, daemonize: false, timeout: 5)
|
|
35
|
+
return if running?(config_path)
|
|
36
|
+
|
|
37
|
+
check_platform_support!
|
|
38
|
+
|
|
39
|
+
lock_path = "#{socket_path(config_path)}.lock"
|
|
40
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |lock|
|
|
41
|
+
lock.flock(File::LOCK_EX)
|
|
42
|
+
next if running?(config_path)
|
|
43
|
+
|
|
44
|
+
start_daemon_process(config_path: config_path, daemonize: daemonize)
|
|
45
|
+
end
|
|
46
|
+
wait_for_ready(config_path: config_path, timeout: timeout)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Start the server daemon and wait for it to become ready.
|
|
50
|
+
#
|
|
51
|
+
# @param [String, nil] config_path optional config path for socket/pid lookup
|
|
52
|
+
# @param [Integer] timeout max seconds to wait for readiness
|
|
53
|
+
# @param [Boolean] raise_on_timeout
|
|
54
|
+
# @raise [StandardError]
|
|
55
|
+
# @return [Boolean]
|
|
56
|
+
def wait_for_ready(config_path: nil, timeout: 5, raise_on_timeout: true) # rubocop:disable SortedMethodsByCall/Waterfall
|
|
57
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
58
|
+
loop do
|
|
59
|
+
return true if running?(config_path)
|
|
60
|
+
|
|
61
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
62
|
+
raise('Docscribe: server failed to start') if raise_on_timeout
|
|
63
|
+
|
|
64
|
+
warn('Docscribe server failed to start within timeout')
|
|
65
|
+
return false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
sleep 0.1
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Whether a server process is listening on the socket.
|
|
73
|
+
#
|
|
74
|
+
# On ECONNREFUSED, checks whether the PID process is still alive:
|
|
75
|
+
# if yes, the daemon is still starting up (don't clean up);
|
|
76
|
+
# if no, removes stale socket and pid files.
|
|
77
|
+
#
|
|
78
|
+
# @param [String, nil] config_path optional config path for socket lookup
|
|
79
|
+
# @raise [Errno::ECONNREFUSED]
|
|
80
|
+
# @raise [Errno::ENOENT]
|
|
81
|
+
# @raise [Errno::ENOTSOCK]
|
|
82
|
+
# @raise [StandardError]
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
# @return [Boolean] if Errno::ECONNREFUSED
|
|
85
|
+
# @return [void, Boolean] if Errno::ENOENT, Errno::ENOTSOCK
|
|
86
|
+
# @return [Boolean] if StandardError
|
|
87
|
+
def running?(config_path = nil)
|
|
88
|
+
return false unless defined?(UNIXSocket)
|
|
89
|
+
|
|
90
|
+
socket = UNIXSocket.new(socket_path(config_path))
|
|
91
|
+
socket.close
|
|
92
|
+
true
|
|
93
|
+
rescue Errno::ECONNREFUSED
|
|
94
|
+
handle_stale_socket?(config_path)
|
|
95
|
+
rescue Errno::ENOENT, Errno::ENOTSOCK
|
|
96
|
+
clean_socket_files(config_path) && false
|
|
97
|
+
rescue StandardError
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Handle ECONNREFUSED: check if the pid process is alive.
|
|
102
|
+
# Cleans up only if the process is dead.
|
|
103
|
+
#
|
|
104
|
+
# @param [String, nil] config_path
|
|
105
|
+
# @return [Boolean] false (not running)
|
|
106
|
+
def handle_stale_socket?(config_path)
|
|
107
|
+
pid = read_pid(config_path)
|
|
108
|
+
return false if pid && process_alive?(pid)
|
|
109
|
+
|
|
110
|
+
clean_socket_files(config_path)
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @param [Integer] pid
|
|
115
|
+
# @raise [Errno::ESRCH]
|
|
116
|
+
# @return [Boolean]
|
|
117
|
+
# @return [Boolean] if Errno::ESRCH
|
|
118
|
+
def process_alive?(pid)
|
|
119
|
+
Process.kill(0, pid)
|
|
120
|
+
true
|
|
121
|
+
rescue Errno::ESRCH
|
|
122
|
+
false
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @param [String, nil] config_path
|
|
126
|
+
# @raise [StandardError]
|
|
127
|
+
# @return [Integer, nil]
|
|
128
|
+
# @return [nil] if StandardError
|
|
129
|
+
def read_pid(config_path = nil)
|
|
130
|
+
File.read(pid_path(config_path)).to_i if File.exist?(pid_path(config_path))
|
|
131
|
+
rescue StandardError
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Remove stale socket and pid files.
|
|
136
|
+
#
|
|
137
|
+
# @param [String, nil] config_path
|
|
138
|
+
# @return [void]
|
|
139
|
+
def clean_socket_files(config_path)
|
|
140
|
+
FileUtils.rm_f(socket_path(config_path))
|
|
141
|
+
FileUtils.rm_f(pid_path(config_path))
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @param [String, nil] config_path
|
|
145
|
+
# @return [String]
|
|
146
|
+
def pid_path(config_path = nil)
|
|
147
|
+
"#{socket_path(config_path)}.pid"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
ENV_FILES = %w[Gemfile.lock rbs_collection.lock.yaml].freeze
|
|
151
|
+
|
|
152
|
+
# @param [String] config_path
|
|
153
|
+
# @return [String]
|
|
154
|
+
def config_hash(config_path)
|
|
155
|
+
resolved = File.expand_path(config_path)
|
|
156
|
+
mtime = File.exist?(resolved) ? File.mtime(resolved).to_f : 0.0
|
|
157
|
+
Digest::MD5.hexdigest("#{resolved}:#{mtime}")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Check platform compatibility before starting server.
|
|
161
|
+
#
|
|
162
|
+
# @raise [StandardError]
|
|
163
|
+
# @return [void]
|
|
164
|
+
def check_platform_support!
|
|
165
|
+
unless defined?(UNIXSocket)
|
|
166
|
+
raise 'Server mode requires Unix domain sockets, which are not available on Windows. ' \
|
|
167
|
+
'Use docscribe directly without --server flag.'
|
|
168
|
+
end
|
|
169
|
+
return if Process.respond_to?(:fork)
|
|
170
|
+
|
|
171
|
+
raise 'Server mode requires Process.fork, which is not available on JRuby. ' \
|
|
172
|
+
'Use docscribe directly without --server flag.'
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Derive a project-specific socket path from the current working directory.
|
|
176
|
+
# Uses MD5 (deterministic across processes) instead of String#hash
|
|
177
|
+
# (which varies per Ruby process due to random seeding).
|
|
178
|
+
# When a config_path is given, its path + mtime are included in the hash
|
|
179
|
+
# so different configs get different daemons.
|
|
180
|
+
# Environment files (Gemfile.lock, rbs_collection.lock.yaml) are also
|
|
181
|
+
# included so daemon is invalidated when gems or RBS types change.
|
|
182
|
+
#
|
|
183
|
+
# @param [String, nil] config_path optional config path to differentiate
|
|
184
|
+
# @return [String]
|
|
185
|
+
def socket_path(config_path = nil)
|
|
186
|
+
seed = +Dir.pwd
|
|
187
|
+
seed << ":#{env_hash}"
|
|
188
|
+
if config_path
|
|
189
|
+
resolved = File.expand_path(config_path)
|
|
190
|
+
mtime = File.exist?(resolved) ? File.mtime(resolved).to_f : 0.0
|
|
191
|
+
seed << ":#{resolved}:#{mtime}"
|
|
192
|
+
end
|
|
193
|
+
"#{SOCKET_DIR}/docscribe-#{Digest::MD5.hexdigest(seed)}.sock"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Hash of environment files that affect analysis results.
|
|
197
|
+
# When any of these change, the daemon is invalidated (new socket path).
|
|
198
|
+
#
|
|
199
|
+
# @return [String]
|
|
200
|
+
def env_hash
|
|
201
|
+
parts = ENV_FILES.map do |file|
|
|
202
|
+
path = File.join(Dir.pwd, file)
|
|
203
|
+
File.exist?(path) ? File.mtime(path).to_f.to_s : '0'
|
|
204
|
+
end
|
|
205
|
+
Digest::MD5.hexdigest(parts.join(':'))
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
public :read_pid, :pid_path, :socket_path
|
|
209
|
+
|
|
210
|
+
# @param [String, nil] config_path
|
|
211
|
+
# @param [Boolean] daemonize
|
|
212
|
+
# @return [void]
|
|
213
|
+
def start_daemon_process(config_path:, daemonize:)
|
|
214
|
+
warn 'Docscribe: starting server...' if daemonize
|
|
215
|
+
pid = Process.fork do # steep:ignore NoMethod
|
|
216
|
+
[$stdin, $stdout].each { _1.reopen(File::NULL) }
|
|
217
|
+
$stderr.reopen(File::NULL)
|
|
218
|
+
Daemon.new(config_path: config_path).start
|
|
219
|
+
end
|
|
220
|
+
Process.detach(pid)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
|
|
5
|
+
module Docscribe
|
|
6
|
+
module Server
|
|
7
|
+
# Client for communicating with a running Docscribe daemon.
|
|
8
|
+
class Client
|
|
9
|
+
# @param [String, nil] socket_path custom socket path (defaults to server default)
|
|
10
|
+
# @param [String, nil] config_path optional config path for socket lookup
|
|
11
|
+
# @return [void]
|
|
12
|
+
def initialize(socket_path = nil, config_path: nil)
|
|
13
|
+
@socket_path = socket_path || Server.socket_path(config_path)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Send a check request to the server.
|
|
17
|
+
#
|
|
18
|
+
# @param [String] file path to file to check
|
|
19
|
+
# @param [Symbol] strategy rewrite strategy (:safe, :aggressive)
|
|
20
|
+
# @param [Hash<Symbol, Object>] rest extra JSON-RPC params (e.g. cli_overrides)
|
|
21
|
+
# @return [Hash<String, Object>, nil] response hash or nil if server unreachable
|
|
22
|
+
def check(file:, strategy: :safe, **rest)
|
|
23
|
+
request('check', file: file, strategy: strategy, **rest)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Send a fix request to the server.
|
|
27
|
+
#
|
|
28
|
+
# @param [String] file path to file to fix
|
|
29
|
+
# @param [Symbol] strategy rewrite strategy (:safe, :aggressive)
|
|
30
|
+
# @param [Hash<Symbol, Object>] rest extra JSON-RPC params (e.g. cli_overrides)
|
|
31
|
+
# @return [Hash<String, Object>, nil] response hash or nil if server unreachable
|
|
32
|
+
def fix(file:, strategy: :safe, **rest)
|
|
33
|
+
request('fix', file: file, strategy: strategy, **rest)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Send a shutdown request to the server.
|
|
37
|
+
#
|
|
38
|
+
# @return [Hash<String, Object>, nil] response hash or nil if server unreachable
|
|
39
|
+
def shutdown
|
|
40
|
+
request('shutdown')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Ping the server and get version/pid/uptime info.
|
|
44
|
+
#
|
|
45
|
+
# @return [Hash<String, Object>, nil] response hash or nil if server unreachable
|
|
46
|
+
def ping
|
|
47
|
+
request('ping')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Send a JSON-RPC request and read the response.
|
|
53
|
+
#
|
|
54
|
+
# @private
|
|
55
|
+
# @param [String] method method name
|
|
56
|
+
# @param [Hash<Symbol, Object>] params request parameters
|
|
57
|
+
# @return [Hash<String, Object>, nil]
|
|
58
|
+
def request(method, **params)
|
|
59
|
+
connect do |socket|
|
|
60
|
+
req = Protocol.build_request(method, params)
|
|
61
|
+
socket.write(Protocol.serialize(req))
|
|
62
|
+
socket.close_write
|
|
63
|
+
line = socket.gets
|
|
64
|
+
break unless line
|
|
65
|
+
|
|
66
|
+
Protocol.parse_response(line)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Connect to the Unix socket and yield the connection.
|
|
71
|
+
#
|
|
72
|
+
# @private
|
|
73
|
+
# @raise [Errno::ECONNREFUSED]
|
|
74
|
+
# @raise [Errno::ENOENT]
|
|
75
|
+
# @return [T, nil] yield return value or nil on connection error
|
|
76
|
+
def connect
|
|
77
|
+
socket = UNIXSocket.new(@socket_path)
|
|
78
|
+
yield socket
|
|
79
|
+
rescue Errno::ECONNREFUSED, Errno::ENOENT
|
|
80
|
+
nil
|
|
81
|
+
ensure
|
|
82
|
+
socket&.close
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|