docscribe 1.5.0 → 1.5.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.
@@ -0,0 +1,608 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'socket'
5
+ require 'fileutils'
6
+ require 'securerandom'
7
+ require 'digest/md5'
8
+ require 'tmpdir'
9
+ require 'time'
10
+ require_relative 'lru_cache'
11
+
12
+ module Docscribe
13
+ # Server/daemon mode for persistent multi-request operation.
14
+ #
15
+ # Architecture:
16
+ # - Daemon process loads Ruby runtime once, listens on a Unix socket
17
+ # - Client sends JSON-line requests, receives JSON-line responses
18
+ # - Auto-shutdown after idle timeout
19
+ # - Protocol: JSON-RPC 2.0 over Unix socket
20
+ module Server
21
+ # Unix socket path max is 104 bytes on macOS (the more restrictive).
22
+ # Dir.tmpdir on macOS often returns a long path under /var/folders/.../T
23
+ # that exceeds this limit, so we fall back to /tmp when needed.
24
+ SOCKET_DIR = begin
25
+ tmp = Dir.tmpdir || '/tmp'
26
+ sock_overhead = "/docscribe-#{'a' * 32}.sock".bytesize # 48
27
+ tmp.bytesize <= 104 - sock_overhead ? tmp : '/tmp'
28
+ end
29
+ IDLE_TIMEOUT = 300
30
+
31
+ class << self
32
+ # Start the server daemon if not running.
33
+ #
34
+ # @param [String?] config_path optional config file path
35
+ # @param [Boolean] daemonize redirect stdin/stdout/stderr to /dev/null
36
+ # @param [Integer] timeout max seconds to wait for readiness
37
+ # @raise [StandardError]
38
+ # @return [void]
39
+ def ensure_running!(config_path: nil, daemonize: false, timeout: 5)
40
+ return if running?(config_path)
41
+ raise 'Server mode is unavailable on this Ruby/platform (Process.fork not supported)' unless Process.respond_to?(:fork)
42
+
43
+ lock_path = "#{socket_path(config_path)}.lock"
44
+ File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |lock|
45
+ lock.flock(File::LOCK_EX)
46
+ next if running?(config_path)
47
+
48
+ start_daemon_process(config_path: config_path, daemonize: daemonize)
49
+ end
50
+ wait_for_ready(config_path: config_path, timeout: timeout)
51
+ end
52
+
53
+ # Start the server daemon and wait for it to become ready.
54
+ #
55
+ # @param [String?] config_path optional config path for socket/pid lookup
56
+ # @param [Integer] timeout max seconds to wait for readiness
57
+ # @param [Boolean] raise_on_timeout
58
+ # @raise [StandardError]
59
+ # @return [Boolean]
60
+ def wait_for_ready(config_path: nil, timeout: 5, raise_on_timeout: true) # rubocop:disable SortedMethodsByCall/Waterfall
61
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
62
+ loop do
63
+ return true if running?(config_path)
64
+
65
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
66
+ raise('Docscribe: server failed to start') if raise_on_timeout
67
+
68
+ warn('Docscribe server failed to start within timeout')
69
+ return false
70
+ end
71
+
72
+ sleep 0.1
73
+ end
74
+ end
75
+
76
+ # Whether a server process is listening on the socket.
77
+ #
78
+ # On ECONNREFUSED, checks whether the PID process is still alive:
79
+ # if yes, the daemon is still starting up (don't clean up);
80
+ # if no, removes stale socket and pid files.
81
+ #
82
+ # @param [String?] config_path optional config path for socket lookup
83
+ # @raise [Errno::ECONNREFUSED]
84
+ # @raise [Errno::ENOENT]
85
+ # @raise [Errno::ENOTSOCK]
86
+ # @raise [StandardError]
87
+ # @return [Boolean]
88
+ # @return [Boolean] if Errno::ECONNREFUSED
89
+ # @return [Boolean] if Errno::ENOENT, Errno::ENOTSOCK
90
+ # @return [Boolean] if StandardError
91
+ def running?(config_path = nil)
92
+ socket = UNIXSocket.new(socket_path(config_path))
93
+ socket.close
94
+ true
95
+ rescue Errno::ECONNREFUSED
96
+ handle_stale_socket?(config_path)
97
+ rescue Errno::ENOENT, Errno::ENOTSOCK
98
+ clean_socket_files(config_path)
99
+ false
100
+ rescue StandardError
101
+ false
102
+ end
103
+
104
+ # Handle ECONNREFUSED: check if the pid process is alive.
105
+ # Cleans up only if the process is dead.
106
+ #
107
+ # @param [String?] config_path
108
+ # @return [Boolean] false (not running)
109
+ def handle_stale_socket?(config_path)
110
+ pid = read_pid(config_path)
111
+ return false if pid && process_alive?(pid)
112
+
113
+ clean_socket_files(config_path)
114
+ false
115
+ end
116
+
117
+ # @param [Integer] pid
118
+ # @raise [Errno::ESRCH]
119
+ # @return [Boolean]
120
+ # @return [Boolean] if Errno::ESRCH
121
+ def process_alive?(pid)
122
+ Process.kill(0, pid)
123
+ true
124
+ rescue Errno::ESRCH
125
+ false
126
+ end
127
+
128
+ # @param [String?] config_path
129
+ # @raise [StandardError]
130
+ # @return [Integer?]
131
+ # @return [nil] if StandardError
132
+ def read_pid(config_path = nil)
133
+ File.read(pid_path(config_path)).to_i if File.exist?(pid_path(config_path))
134
+ rescue StandardError
135
+ nil
136
+ end
137
+
138
+ # Remove stale socket and pid files.
139
+ #
140
+ # @param [String?] config_path
141
+ # @return [void]
142
+ def clean_socket_files(config_path)
143
+ FileUtils.rm_f(socket_path(config_path))
144
+ FileUtils.rm_f(pid_path(config_path))
145
+ end
146
+
147
+ # @param [String?] config_path
148
+ # @return [String]
149
+ def pid_path(config_path = nil)
150
+ "#{socket_path(config_path)}.pid"
151
+ end
152
+
153
+ ENV_FILES = %w[Gemfile.lock rbs_collection.lock.yaml].freeze
154
+
155
+ # Derive a project-specific socket path from the current working directory.
156
+ # Uses MD5 (deterministic across processes) instead of String#hash
157
+ # (which varies per Ruby process due to random seeding).
158
+ # When a config_path is given, its path + mtime are included in the hash
159
+ # so different configs get different daemons.
160
+ # Environment files (Gemfile.lock, rbs_collection.lock.yaml) are also
161
+ # included so daemon is invalidated when gems or RBS types change.
162
+ #
163
+ # @param [String?] config_path optional config path to differentiate
164
+ # @return [String]
165
+ def socket_path(config_path = nil)
166
+ seed = +Dir.pwd
167
+ seed << ":#{env_hash}"
168
+ if config_path
169
+ resolved = File.expand_path(config_path)
170
+ mtime = File.exist?(resolved) ? File.mtime(resolved).to_f : 0.0
171
+ seed << ":#{resolved}:#{mtime}"
172
+ end
173
+ "#{SOCKET_DIR}/docscribe-#{Digest::MD5.hexdigest(seed)}.sock"
174
+ end
175
+
176
+ # @param [String] config_path
177
+ # @return [String]
178
+ def config_hash(config_path)
179
+ resolved = File.expand_path(config_path)
180
+ mtime = File.exist?(resolved) ? File.mtime(resolved).to_f : 0.0
181
+ Digest::MD5.hexdigest("#{resolved}:#{mtime}")
182
+ end
183
+
184
+ # Hash of environment files that affect analysis results.
185
+ # When any of these change, the daemon is invalidated (new socket path).
186
+ #
187
+ # @return [String]
188
+ def env_hash
189
+ parts = ENV_FILES.map do |file|
190
+ path = File.join(Dir.pwd, file)
191
+ File.exist?(path) ? File.mtime(path).to_f.to_s : '0'
192
+ end
193
+ Digest::MD5.hexdigest(parts.join(':'))
194
+ end
195
+
196
+ public :read_pid, :pid_path, :socket_path
197
+
198
+ # @param [String?] config_path
199
+ # @param [Boolean] daemonize
200
+ # @return [void]
201
+ def start_daemon_process(config_path:, daemonize:)
202
+ warn 'Docscribe: starting server...' if daemonize
203
+ pid = Process.fork do # steep:ignore NoMethod
204
+ [$stdin, $stdout].each { _1.reopen(File::NULL) }
205
+ $stderr.reopen(File::NULL)
206
+ Daemon.new(config_path: config_path).start
207
+ end
208
+ Process.detach(pid)
209
+ end
210
+ end
211
+
212
+ # JSON-line protocol helpers.
213
+ module Protocol
214
+ module_function
215
+
216
+ # Build a JSON-RPC request hash.
217
+ #
218
+ # @note module_function: defines #build_request (visibility: private)
219
+ # @param [String] method method name
220
+ # @param [Hash<Symbol, Object>] params request parameters
221
+ # @return [Hash<Symbol, Object>]
222
+ def build_request(method, params = {})
223
+ {
224
+ jsonrpc: '2.0',
225
+ id: SecureRandom.hex(8),
226
+ method: method,
227
+ params: params
228
+ }
229
+ end
230
+
231
+ # Parse a single JSON-line response.
232
+ #
233
+ # @note module_function: defines #parse_response (visibility: private)
234
+ # @param [String] line raw JSON line
235
+ # @raise [JSON::ParserError]
236
+ # @return [Hash<String, Object>?]
237
+ # @return [nil] if JSON::ParserError
238
+ def parse_response(line)
239
+ JSON.parse(line)
240
+ rescue JSON::ParserError
241
+ nil
242
+ end
243
+
244
+ # Serialize a hash to a JSON line.
245
+ #
246
+ # @note module_function: defines #serialize (visibility: private)
247
+ # @param [Hash<Object, Object>] hash
248
+ # @return [String]
249
+ def serialize(hash)
250
+ "#{JSON.generate(hash)}\n"
251
+ end
252
+ end
253
+
254
+ # Client for communicating with a running Docscribe daemon.
255
+ class Client
256
+ # @param [String?] socket_path custom socket path (defaults to server default)
257
+ # @param [String?] config_path optional config path for socket lookup
258
+ # @return [void]
259
+ def initialize(socket_path = nil, config_path: nil)
260
+ @socket_path = socket_path || Server.socket_path(config_path)
261
+ end
262
+
263
+ # Send a check request to the server.
264
+ #
265
+ # @param [String] file path to file to check
266
+ # @param [Symbol] strategy rewrite strategy (:safe, :aggressive)
267
+ # @param [Object] rest
268
+ # @return [Hash<String, Object>?] response hash or nil if server unreachable
269
+ def check(file:, strategy: :safe, **rest)
270
+ request('check', file: file, strategy: strategy, **rest)
271
+ end
272
+
273
+ # Send a fix request to the server.
274
+ #
275
+ # @param [String] file path to file to fix
276
+ # @param [Symbol] strategy rewrite strategy (:safe, :aggressive)
277
+ # @param [Object] rest
278
+ # @return [Hash<String, Object>?] response hash or nil if server unreachable
279
+ def fix(file:, strategy: :safe, **rest)
280
+ request('fix', file: file, strategy: strategy, **rest)
281
+ end
282
+
283
+ # Send a shutdown request to the server.
284
+ #
285
+ # @return [Hash<String, Object>?] response hash or nil if server unreachable
286
+ def shutdown
287
+ request('shutdown')
288
+ end
289
+
290
+ # Ping the server and get version/pid/uptime info.
291
+ #
292
+ # @return [Hash<String, Object>?] response hash or nil if server unreachable
293
+ def ping
294
+ request('ping')
295
+ end
296
+
297
+ private
298
+
299
+ # Send a JSON-RPC request and read the response.
300
+ #
301
+ # @private
302
+ # @param [String] method method name
303
+ # @param [Object] params request parameters
304
+ # @return [Hash<String, Object>?]
305
+ def request(method, **params)
306
+ connect do |socket|
307
+ req = Protocol.build_request(method, params)
308
+ socket.write(Protocol.serialize(req))
309
+ socket.close_write
310
+ line = socket.gets
311
+ break unless line
312
+
313
+ Protocol.parse_response(line)
314
+ end
315
+ end
316
+
317
+ # Connect to the Unix socket and yield the connection.
318
+ #
319
+ # @private
320
+ # @raise [Errno::ECONNREFUSED]
321
+ # @raise [Errno::ENOENT]
322
+ # @return [T?] yield return value or nil on connection error
323
+ def connect
324
+ socket = UNIXSocket.new(@socket_path)
325
+ yield socket
326
+ rescue Errno::ECONNREFUSED, Errno::ENOENT
327
+ nil
328
+ ensure
329
+ socket&.close
330
+ end
331
+ end
332
+
333
+ # Daemon process that loads the Ruby runtime once and serves requests.
334
+ class Daemon
335
+ # @param [String?] socket_path custom socket path
336
+ # @param [Integer] idle_timeout seconds before automatic shutdown
337
+ # @param [String?] config_path custom config path
338
+ # @return [void]
339
+ def initialize(socket_path: nil, idle_timeout: IDLE_TIMEOUT, config_path: nil)
340
+ @socket_path = socket_path || Server.socket_path(config_path)
341
+ @idle_timeout = idle_timeout
342
+ @config_path = config_path
343
+ @last_request_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
344
+ @running = false
345
+ @server = nil
346
+ @file_cache = LRUCache.new
347
+ @started_at = Time.now
348
+ end
349
+
350
+ # Start the daemon: load dependencies, bind socket, enter listen loop.
351
+ #
352
+ # @return [void]
353
+ def start
354
+ load_dependencies
355
+ setup_socket
356
+ @running = true
357
+ $PROGRAM_NAME = "docscribe server (#{Dir.pwd})"
358
+ write_pid
359
+ listen_loop
360
+ end
361
+
362
+ private
363
+
364
+ # Load the full Docscribe runtime and build cached config.
365
+ #
366
+ # @private
367
+ # @return [void]
368
+ def load_dependencies
369
+ require 'docscribe'
370
+ @config = Docscribe::Config.load(@config_path)
371
+ @config&.load_plugins!
372
+ @core_rbs_provider = @config&.core_rbs_provider
373
+ end
374
+
375
+ # Create and bind the Unix domain socket.
376
+ #
377
+ # @private
378
+ # @return [void]
379
+ def setup_socket
380
+ FileUtils.rm_f(@socket_path)
381
+ FileUtils.mkdir_p(File.dirname(@socket_path))
382
+ @server = UNIXServer.new(@socket_path)
383
+ File.chmod(0o600, @socket_path)
384
+ end
385
+
386
+ # @private
387
+ # @return [void]
388
+ def write_pid
389
+ File.write("#{@socket_path}.pid", Process.pid)
390
+ end
391
+
392
+ # Main accept loop with idle timeout check.
393
+ #
394
+ # @private
395
+ # @raise [Interrupt]
396
+ # @return [void]
397
+ def listen_loop
398
+ while @running
399
+ check_idle_timeout
400
+ accept_client
401
+ end
402
+ rescue Interrupt
403
+ @running = false
404
+ ensure
405
+ cleanup
406
+ end
407
+
408
+ # Check whether the idle timeout has been exceeded.
409
+ #
410
+ # @private
411
+ # @return [void]
412
+ def check_idle_timeout
413
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_request_time
414
+ @running = false if elapsed > @idle_timeout
415
+ end
416
+
417
+ # Accept a client connection if one is available.
418
+ #
419
+ # @private
420
+ # @return [void]
421
+ def accept_client
422
+ client = @server&.accept if @server&.wait_readable(1)
423
+ return unless client
424
+
425
+ handle_client(client)
426
+ @last_request_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
427
+ end
428
+
429
+ # Read a request from a client connection and dispatch it.
430
+ #
431
+ # @private
432
+ # @param [UNIXSocket] client connected client socket
433
+ # @raise [StandardError]
434
+ # @return [void]
435
+ def handle_client(client)
436
+ request_line = client.gets or return
437
+ request = Protocol.parse_response(request_line)
438
+ request ? handle_request(client, request) : send_error(client, nil, -32_700, 'Parse error')
439
+ rescue StandardError => e
440
+ send_error(client, request&.dig('id'), -32_603, "#{e.class}: #{e.message}")
441
+ ensure
442
+ client.close
443
+ end
444
+
445
+ # Dispatch a parsed request to the appropriate handler.
446
+ #
447
+ # @private
448
+ # @param [UNIXSocket] client connected client socket
449
+ # @param [Hash<String, Object>] request parsed JSON-RPC request
450
+ # @return [void]
451
+ def handle_request(client, request)
452
+ method = request['method']
453
+ params = request['params'] || {}
454
+
455
+ case method
456
+ when 'check' then handle_check(client, request['id'], params)
457
+ when 'fix' then handle_fix(client, request['id'], params)
458
+ when 'shutdown' then handle_shutdown(client, request['id'])
459
+ when 'ping' then handle_ping(client, request['id'])
460
+ else send_error(client, request['id'], -32_601, "Unknown method: #{method}")
461
+ end
462
+ end
463
+
464
+ # @private
465
+ # @param [UNIXSocket] client
466
+ # @param [String, Integer] id
467
+ # @param [Hash<String, Object>] params
468
+ # @return [void]
469
+ def handle_check(client, id, params)
470
+ file = params['file']
471
+ strategy = (params['strategy'] || 'safe').to_sym
472
+ return send_error(client, id, -32_602, "File not found: #{file}") unless file && File.file?(file)
473
+
474
+ apply_cli_overrides(params['cli_overrides'])
475
+ src, result = rewrite_file(file, strategy)
476
+ send_result(client, id, 'status' => result[:output] == src ? 'ok' : 'fail',
477
+ 'changed' => result[:output] != src, 'changes' => result[:changes])
478
+ end
479
+
480
+ # @private
481
+ # @param [UNIXSocket] client
482
+ # @param [String, Integer] id
483
+ # @param [Hash<String, Object>] params
484
+ # @return [void]
485
+ def handle_fix(client, id, params)
486
+ file = params['file']
487
+ strategy = (params['strategy'] || 'safe').to_sym
488
+ return send_error(client, id, -32_602, "File not found: #{file}") unless file && File.file?(file)
489
+
490
+ apply_cli_overrides(params['cli_overrides'])
491
+ src, result = rewrite_file(file, strategy)
492
+ File.write(file, result[:output]) if result[:output] != src
493
+ send_result(client, id, 'status' => 'ok',
494
+ 'changed' => result[:output] != src, 'changes' => result[:changes])
495
+ end
496
+
497
+ # @private
498
+ # @param [Hash<String, Object>?] overrides
499
+ # @return [void]
500
+ def apply_cli_overrides(overrides)
501
+ return reset_effective_config if overrides.nil? || overrides.empty?
502
+ return if @applied_overrides == overrides
503
+
504
+ config = @config or return
505
+ require 'docscribe/cli/config_builder'
506
+ opts = overrides.transform_keys(&:to_sym)
507
+ @effective_config = Docscribe::CLI::ConfigBuilder.build(config, opts)
508
+ @file_cache.clear
509
+ @applied_overrides = overrides
510
+ end
511
+
512
+ # @private
513
+ # @return [void]
514
+ def reset_effective_config
515
+ return unless @effective_config
516
+
517
+ @effective_config = nil
518
+ @applied_overrides = nil
519
+ @file_cache.clear
520
+ end
521
+
522
+ # @private
523
+ # @param [String] file
524
+ # @param [Symbol] strategy
525
+ # @raise [StandardError]
526
+ # @return [(String, Hash<Symbol, Object>)]
527
+ def rewrite_file(file, strategy)
528
+ config = @effective_config || @config or raise 'Docscribe: config not loaded'
529
+ key = [file, strategy]
530
+ mtime = File.mtime(file)
531
+ hit = @file_cache[key]
532
+ return [hit[:src], hit[:result]] if hit && hit[:mtime] == mtime
533
+
534
+ src = File.read(file)
535
+ rbs = config.respond_to?(:core_rbs_provider) ? config.core_rbs_provider : nil
536
+ result = Docscribe::InlineRewriter.rewrite_with_report(src, strategy: strategy, config: config, core_rbs_provider: rbs, file: file)
537
+ @file_cache[key] = { mtime: mtime, src: src, result: result }
538
+ [src, result]
539
+ end
540
+
541
+ # Handle a shutdown request.
542
+ #
543
+ # @private
544
+ # @param [UNIXSocket] client connected client socket
545
+ # @param [String, Integer] id request ID
546
+ # @return [void]
547
+ def handle_shutdown(client, id)
548
+ send_result(client, id, { 'status' => 'shutting_down' })
549
+ @running = false
550
+ end
551
+
552
+ # Handle a ping request.
553
+ #
554
+ # @private
555
+ # @param [UNIXSocket] client connected client socket
556
+ # @param [String, Integer] id request ID
557
+ # @return [void]
558
+ def handle_ping(client, id)
559
+ uptime = (Time.now - @started_at).to_i
560
+ send_result(client, id, {
561
+ 'version' => Docscribe::VERSION,
562
+ 'pid' => Process.pid,
563
+ 'socket_path' => @socket_path,
564
+ 'started_at' => @started_at.iso8601,
565
+ 'uptime' => uptime
566
+ })
567
+ end
568
+
569
+ # Send a JSON-RPC result response.
570
+ #
571
+ # @private
572
+ # @param [UNIXSocket] client connected client socket
573
+ # @param [String, Integer] id request ID
574
+ # @param [Hash<String, Object>] result result data
575
+ # @return [void]
576
+ def send_result(client, id, result)
577
+ response = { jsonrpc: '2.0', id: id, result: result }
578
+ client.write(Protocol.serialize(response))
579
+ end
580
+
581
+ # @private
582
+ # @param [UNIXSocket] client
583
+ # @param [String, Integer, nil] id
584
+ # @param [Integer] code
585
+ # @param [String] message
586
+ # @return [void]
587
+ def send_error(client, id, code, message)
588
+ response = { jsonrpc: '2.0', id: id, error: { code: code, message: message } }
589
+ client.write(Protocol.serialize(response))
590
+ end
591
+
592
+ # Cleanup socket and PID files on shutdown.
593
+ #
594
+ # @private
595
+ # @raise [StandardError]
596
+ # @return [void]
597
+ # @return [nil] if StandardError
598
+ def cleanup
599
+ @server&.close
600
+ File.unlink(@socket_path) if @socket_path && File.exist?(@socket_path)
601
+ pid_path = "#{@socket_path}.pid"
602
+ FileUtils.rm_f(pid_path)
603
+ rescue StandardError
604
+ nil
605
+ end
606
+ end
607
+ end
608
+ end