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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1016144165d119ade7719092f38974b29082a1bd86824864dd3a9b3412268771
4
- data.tar.gz: ed2f6bbaf4e6f5892e0977a34c9aecb4924badc7e21ef25892d88bb4b0f8611a
3
+ metadata.gz: 24df08456004986c5cae869a2e7dc9ba4a1176cbf5dbaaca679cb4661836a3dc
4
+ data.tar.gz: 746d064972fe321f6b0d7459d87f1fc4cec8c414b4b4b73b9cce916f72b4139d
5
5
  SHA512:
6
- metadata.gz: 7d1a11cef005ba87ef7fdf97d594ea699d638bc2a75dd62838ccec6f9260fca9b090e44a3176957584a30506f1159545354566a372c54e16382a2d384ae8248d
7
- data.tar.gz: fd23695cdba7b7726e306ac2300665cb1f5eaf2764d2e19946df99fec6f7fee112408459fc774710305358a55d394413c4c8b3c663bd432514ffaf8f939d6dcf
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