steep 2.1.0.dev.1 → 2.1.0

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,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Steep
4
+ module Server
5
+ # Accepts connections on a UNIX socket and forwards the received requests to the master
6
+ #
7
+ # Started by `steep langserver` so that CLI commands like `steep query` and `steep check` can
8
+ # talk to the language server the IDE is running, instead of a standalone daemon process.
9
+ #
10
+ class CommandSocket
11
+ LSP = LanguageServer::Protocol
12
+
13
+ # A connection from a CLI client
14
+ #
15
+ # `#write` never raises even if the client is disconnected, because it may be called from
16
+ # the master's write thread, which should keep running for other clients.
17
+ #
18
+ class Session
19
+ attr_reader :socket
20
+
21
+ def initialize(socket)
22
+ @socket = socket
23
+ @writer = LSP::Transport::Io::Writer.new(socket)
24
+ @mutex = Mutex.new
25
+ @closed = false
26
+ end
27
+
28
+ def write(message)
29
+ @mutex.synchronize do
30
+ return if @closed
31
+ @writer.write(message)
32
+ end
33
+ rescue SystemCallError, IOError
34
+ close()
35
+ end
36
+
37
+ def close
38
+ @mutex.synchronize do
39
+ @closed = true
40
+ end
41
+ begin
42
+ @socket.close
43
+ rescue IOError
44
+ # Already closed
45
+ end
46
+ end
47
+ end
48
+
49
+ attr_reader :master, :configuration
50
+
51
+ def initialize(master:, configuration:)
52
+ @master = master
53
+ @configuration = configuration
54
+ @server = nil
55
+ @accept_thread = nil
56
+ end
57
+
58
+ def socket_path
59
+ configuration.socket_path
60
+ end
61
+
62
+ # Binds the UNIX socket and starts accepting connections in a background thread
63
+ #
64
+ # Returns `false` without binding when another process (a standalone `steep server` daemon or
65
+ # another language server) is already serving on the socket.
66
+ #
67
+ def start
68
+ if File.exist?(socket_path)
69
+ if socket_alive?
70
+ Steep.logger.warn { "Command socket is already served by another process: #{socket_path}" }
71
+ return false
72
+ end
73
+
74
+ unless File.socket?(socket_path)
75
+ Steep.logger.error { "#{socket_path} exists but is not a socket, skipping command socket setup" }
76
+ return false
77
+ end
78
+
79
+ File.delete(socket_path)
80
+ end
81
+
82
+ @server = UNIXServer.new(socket_path)
83
+ File.chmod(0o600, socket_path)
84
+
85
+ @accept_thread = Thread.new do
86
+ Thread.current.abort_on_exception = false
87
+ accept_loop()
88
+ end
89
+
90
+ Steep.logger.info { "Command socket is ready: #{socket_path}" }
91
+
92
+ true
93
+ rescue Errno::EADDRINUSE
94
+ Steep.logger.warn { "Command socket is already served by another process: #{socket_path}" }
95
+ false
96
+ end
97
+
98
+ def stop
99
+ server = @server
100
+ @server = nil
101
+
102
+ if server
103
+ begin
104
+ server.close
105
+ rescue IOError
106
+ # Already closed
107
+ end
108
+
109
+ @accept_thread&.join(1)
110
+
111
+ begin
112
+ File.delete(socket_path)
113
+ rescue Errno::ENOENT
114
+ # Already deleted
115
+ end
116
+ end
117
+ end
118
+
119
+ private
120
+
121
+ def socket_alive?
122
+ socket = UNIXSocket.new(socket_path)
123
+ socket.close
124
+ true
125
+ rescue SystemCallError
126
+ false
127
+ end
128
+
129
+ def accept_loop
130
+ while server = @server
131
+ socket =
132
+ begin
133
+ server.accept
134
+ rescue IOError, SystemCallError => error
135
+ Steep.logger.info { "Command socket accept loop stopping: #{error.inspect}" }
136
+ break
137
+ end
138
+
139
+ # The loop must survive anything but the accept errors above: it runs with
140
+ # `abort_on_exception` disabled, so an uncaught error would kill the thread
141
+ # silently and every connection after it would sit in the backlog forever.
142
+ begin
143
+ Steep.logger.info { "Command socket accepted a connection" }
144
+
145
+ # `session` is passed as a thread argument: `while` shares its locals across
146
+ # iterations, so a block reading `session` directly would serve the session of a
147
+ # later iteration when the accept loop wins the race against the thread's start,
148
+ # leaving the accepted connection unread.
149
+ Thread.new(Session.new(socket)) do |session|
150
+ Thread.current.abort_on_exception = false
151
+ serve(session)
152
+ end
153
+ rescue StandardError => error
154
+ Steep.logger.error { "Command socket could not serve an accepted connection: #{error.inspect}" }
155
+ begin
156
+ socket.close
157
+ rescue IOError
158
+ # Already closed
159
+ end
160
+ end
161
+ end
162
+ ensure
163
+ Steep.logger.info { "Command socket accept loop finished" }
164
+ end
165
+
166
+ def serve(session)
167
+ reader = LSP::Transport::Io::Reader.new(session.socket)
168
+ reader.read do |message|
169
+ Steep.logger.info { "Command socket received message: method=#{message[:method] || "-"}, id=#{message[:id] || "-"}" }
170
+ master.process_command_socket_message(message, session)
171
+ end
172
+ rescue IOError, SystemCallError => error
173
+ Steep.logger.warn { "Command socket client error: #{error.inspect}" }
174
+ ensure
175
+ Steep.logger.info { "Command socket connection closed" }
176
+ master.finish_command_socket_session(session)
177
+ session.close
178
+ end
179
+ end
180
+ end
181
+ end
@@ -96,6 +96,18 @@ module Steep
96
96
  { id: id, result: result }
97
97
  end
98
98
  end
99
+
100
+ module Query__Diagnostics
101
+ METHOD = "$/steep/query/diagnostics"
102
+
103
+ def self.request(id, params)
104
+ { method: METHOD, id: id, params: params }
105
+ end
106
+
107
+ def self.response(id, result)
108
+ { id: id, result: result }
109
+ end
110
+ end
99
111
  end
100
112
  end
101
113
  end
@@ -186,6 +186,12 @@ module Steep
186
186
  attr_accessor :typecheck_automatically
187
187
  attr_reader :start_type_checking_queue
188
188
 
189
+ # Type check requests waiting for the current type check to finish
190
+ attr_reader :pending_typecheck_requests
191
+
192
+ # Callbacks to be called when no type check is running anymore
193
+ attr_reader :typecheck_quiescent_callbacks
194
+
189
195
  def initialize(project:, reader:, writer:, interaction_worker:, typecheck_workers:, queue: Queue.new, refork: false)
190
196
  @project = project
191
197
  @reader = reader
@@ -199,6 +205,11 @@ module Steep
199
205
  @write_queue = SizedQueue.new(100)
200
206
  @refork_mutex = Mutex.new
201
207
  @need_to_refork = refork
208
+ @typecheck_quiescent_callbacks = []
209
+ @pending_typecheck_requests = []
210
+ @project_file_mtimes = nil
211
+ @command_socket_requests = {}
212
+ @command_socket_mutex = Mutex.new
202
213
 
203
214
  @controller = TypeCheckController.new(project: project)
204
215
  @result_controller = ResultController.new()
@@ -245,7 +256,7 @@ module Steep
245
256
  case job.dest
246
257
  when :client
247
258
  Steep.logger.info { "Processing SendMessageJob: dest=client, method=#{job.message[:method] || "-"}, id=#{job.message[:id] || "-"}" }
248
- writer.write job.message
259
+ write_message_to_client(job.message)
249
260
  when WorkerProcess
250
261
  refork_mutex.synchronize do
251
262
  Steep.logger.info { "Processing SendMessageJob: dest=#{job.dest.name}, method=#{job.message[:method] || "-"}, id=#{job.message[:id] || "-"}" }
@@ -420,6 +431,8 @@ module Steep
420
431
  progress.end()
421
432
  end
422
433
 
434
+ reset_project_file_mtimes()
435
+
423
436
  if file_system_watcher_supported?
424
437
  setup_file_system_watcher()
425
438
  end
@@ -445,6 +458,7 @@ module Steep
445
458
 
446
459
  unless controller.open_paths.include?(path)
447
460
  updated_watched_files << path
461
+ record_project_file_mtime(path)
448
462
 
449
463
  case type
450
464
  when LSP::Constant::FileChangeType::CREATED, LSP::Constant::FileChangeType::CHANGED
@@ -684,7 +698,7 @@ module Steep
684
698
  request.inline_paths << [target_name.to_sym, Pathname(path)]
685
699
  end
686
700
 
687
- start_type_check(request: request, last_request: nil)
701
+ start_type_check(request: request, last_request: current_type_check_request)
688
702
 
689
703
  when CustomMethods::TypeCheckGroups::METHOD
690
704
  params = message[:params] #: CustomMethods::TypeCheckGroups::params
@@ -703,6 +717,15 @@ module Steep
703
717
  request.needs_response = false
704
718
  start_type_check(request: request, last_request: current_type_check_request, report_progress_threshold: 0)
705
719
 
720
+ when CustomMethods::Query__Diagnostics::METHOD
721
+ id = message[:id]
722
+ params = message[:params] #: CustomMethods::Query__Diagnostics::params
723
+ paths = params[:paths]
724
+
725
+ run_when_typecheck_quiescent do
726
+ collect_query_diagnostics(id, paths)
727
+ end
728
+
706
729
  when "$/ping"
707
730
  enqueue_write_job SendMessageJob.to_client(
708
731
  message: {
@@ -805,6 +828,17 @@ module Steep
805
828
 
806
829
  def start_type_check(request: nil, last_request:, progress: nil, include_unchanged: false, report_progress_threshold: 10, needs_response: nil)
807
830
  Steep.logger.tagged "#start_type_check(#{progress&.guid || request&.guid}, #{last_request&.guid}" do
831
+ if (current = current_type_check_request) && typecheck_request_from_command_socket?(current)
832
+ # Never supersede a type check that a command socket client is waiting for
833
+ if request
834
+ Steep.logger.info { "Queueing type check request #{request.guid} until the command socket request finishes" }
835
+ pending_typecheck_requests << request
836
+ else
837
+ Steep.logger.info { "Deferring automatic type checking until the command socket request finishes" }
838
+ end
839
+ return
840
+ end
841
+
808
842
  if last_request
809
843
  finish_type_check(last_request)
810
844
  end
@@ -884,6 +918,7 @@ module Steep
884
918
  finish_type_check(current)
885
919
  @current_type_check_request = nil
886
920
  refork_workers
921
+ start_pending_typecheck()
887
922
  end
888
923
  end
889
924
  end
@@ -955,6 +990,17 @@ module Steep
955
990
  refork_finished.pop
956
991
  end
957
992
  end
993
+
994
+ # The reforked workers started from a copy of the primary's state, which has the
995
+ # results of the primary's assigned paths only, and the results the replaced workers
996
+ # had computed are gone with them. Type check everything again so that queries
997
+ # reading the stored results, like `$/steep/query/diagnostics`, see all files.
998
+ job_queue << -> do
999
+ guid = SecureRandom.uuid
1000
+ request = controller.make_all_request(guid: guid, progress: work_done_progress(guid))
1001
+ request.needs_response = false
1002
+ start_type_check(request: request, last_request: current_type_check_request, report_progress_threshold: 0)
1003
+ end
958
1004
  end
959
1005
  end
960
1006
 
@@ -1021,6 +1067,341 @@ module Steep
1021
1067
  write_queue.push(job)
1022
1068
  end
1023
1069
 
1070
+ # Calls the block once no type check is running
1071
+ #
1072
+ # Starts a type check for dirty paths first, so that the block observes up-to-date results.
1073
+ #
1074
+ def run_when_typecheck_quiescent(&block)
1075
+ unless current_type_check_request
1076
+ guid = SecureRandom.uuid
1077
+ if request = controller.make_request(guid: guid, progress: work_done_progress(guid))
1078
+ request.needs_response = false
1079
+ start_type_check(request: request, last_request: nil)
1080
+ end
1081
+ end
1082
+
1083
+ if current_type_check_request || !pending_typecheck_requests.empty?
1084
+ typecheck_quiescent_callbacks << block
1085
+ else
1086
+ yield
1087
+ end
1088
+ end
1089
+
1090
+ # Starts the next queued type check request if any, or the automatic type check for dirty paths
1091
+ #
1092
+ # Calls the quiescent callbacks when nothing is left to type check.
1093
+ #
1094
+ def start_pending_typecheck
1095
+ while request = pending_typecheck_requests.shift
1096
+ start_type_check(request: request, last_request: nil)
1097
+ return if current_type_check_request
1098
+ end
1099
+
1100
+ if typecheck_automatically && initialize_params
1101
+ guid = SecureRandom.uuid
1102
+ if request = controller.make_request(guid: guid, progress: work_done_progress(guid))
1103
+ request.needs_response = false
1104
+ start_type_check(request: request, last_request: nil)
1105
+ return if current_type_check_request
1106
+ end
1107
+ end
1108
+
1109
+ flush_typecheck_quiescent_callbacks()
1110
+ end
1111
+
1112
+ # Calls the waiting quiescent callbacks, once a type check for the paths that went dirty
1113
+ # in the meantime finishes
1114
+ #
1115
+ def flush_typecheck_quiescent_callbacks
1116
+ return if typecheck_quiescent_callbacks.empty?
1117
+ return if current_type_check_request
1118
+
1119
+ guid = SecureRandom.uuid
1120
+ if request = controller.make_request(guid: guid, progress: work_done_progress(guid))
1121
+ request.needs_response = false
1122
+ start_type_check(request: request, last_request: nil)
1123
+ return if current_type_check_request
1124
+ end
1125
+
1126
+ callbacks = typecheck_quiescent_callbacks.dup
1127
+ typecheck_quiescent_callbacks.clear
1128
+ callbacks.each(&:call)
1129
+ end
1130
+
1131
+ # Collects the stored diagnostics from all typecheck workers and sends the response
1132
+ #
1133
+ # `paths` is an array of absolute path strings to filter the result, or `nil` to return everything.
1134
+ #
1135
+ def collect_query_diagnostics(id, paths)
1136
+ uris = paths&.map {|path| PathHelper.to_uri(Pathname(path)).to_s }
1137
+
1138
+ result_controller << group_request do |group|
1139
+ typecheck_workers.each do |worker|
1140
+ group << send_request(method: CustomMethods::Query__Diagnostics::METHOD, params: nil, worker: worker)
1141
+ end
1142
+
1143
+ group.on_completion do |handlers|
1144
+ diagnostics = {} #: Hash[String, Array[untyped]]
1145
+
1146
+ handlers.each do |handler|
1147
+ result = handler.result or next
1148
+ result.each do |entry|
1149
+ array = diagnostics[entry[:uri]] ||= []
1150
+ array.concat(entry[:diagnostics] || [])
1151
+ array.uniq!
1152
+ end
1153
+ end
1154
+
1155
+ # @type var result: CustomMethods::Query__Diagnostics::result
1156
+ result =
1157
+ if uris
1158
+ # Files the server has not type checked yet are reported with `diagnostics: nil`
1159
+ uris.sort.map do |uri|
1160
+ { uri: uri, diagnostics: diagnostics[uri] }
1161
+ end
1162
+ else
1163
+ diagnostics.keys.sort.map do |uri|
1164
+ { uri: uri, diagnostics: diagnostics.fetch(uri) }
1165
+ end
1166
+ end
1167
+
1168
+ enqueue_write_job SendMessageJob.to_client(
1169
+ message: CustomMethods::Query__Diagnostics.response(id, result)
1170
+ )
1171
+ end
1172
+ end
1173
+ end
1174
+
1175
+ # Methods that command socket clients cannot send because they control the server lifecycle
1176
+ # The only methods a command socket client may send: what `steep check` and
1177
+ # `steep query` use. Everything else is rejected -- lifecycle methods because the
1178
+ # server belongs to whoever started it, and document synchronization because the
1179
+ # LSP client owns the content of the files it opens.
1180
+ COMMAND_SOCKET_ALLOWED_METHODS = [
1181
+ CustomMethods::TypeCheck::METHOD,
1182
+ CustomMethods::Query__Diagnostics::METHOD,
1183
+ CustomMethods::Query__Definition::METHOD,
1184
+ "textDocument/hover",
1185
+ "$/ping"
1186
+ ].freeze
1187
+
1188
+ # Notifications that are copied to command socket sessions with a pending `$/steep/typecheck` request
1189
+ COMMAND_SOCKET_FORWARDED_NOTIFICATIONS = ["textDocument/publishDiagnostics", "window/showMessage"].freeze
1190
+
1191
+ # Processes a message received from a command socket client
1192
+ #
1193
+ # Requests are assigned a fresh id and enqueued as if they came from the LSP client,
1194
+ # and the responses are routed back to the session through `#write_message_to_client`.
1195
+ #
1196
+ # May be called from any thread.
1197
+ #
1198
+ def process_command_socket_message(message, session)
1199
+ method = message[:method]
1200
+ id = message[:id]
1201
+
1202
+ if method && !COMMAND_SOCKET_ALLOWED_METHODS.include?(method.to_s)
1203
+ Steep.logger.warn { "Command socket: rejected `#{method}`" }
1204
+ if id
1205
+ session.write({
1206
+ id: id,
1207
+ error: {
1208
+ code: LSP::Constant::ErrorCodes::INVALID_REQUEST,
1209
+ message: "`#{method}` is not allowed through the command socket"
1210
+ }
1211
+ })
1212
+ end
1213
+ return
1214
+ end
1215
+
1216
+ unless initialize_params
1217
+ if id
1218
+ session.write({
1219
+ id: id,
1220
+ error: {
1221
+ code: LSP::Constant::ErrorCodes::SERVER_NOT_INITIALIZED,
1222
+ message: "The language server is not initialized yet"
1223
+ }
1224
+ })
1225
+ end
1226
+ return
1227
+ end
1228
+
1229
+ case
1230
+ when method && id
1231
+ guid = SecureRandom.uuid
1232
+ @command_socket_mutex.synchronize do
1233
+ @command_socket_requests[guid] = [session, id, method.to_s == CustomMethods::TypeCheck::METHOD]
1234
+ end
1235
+ message = message.merge({ id: guid })
1236
+ when id
1237
+ # A response from the client, but nothing is waiting for it
1238
+ return
1239
+ end
1240
+
1241
+ # Reload files changed on disk before processing, because command socket clients
1242
+ # modify files without sending `didChange` notifications
1243
+ job_queue << -> do
1244
+ sync_project_files_from_disk()
1245
+ process_message_from_client(message)
1246
+ end
1247
+ rescue ClosedQueueError
1248
+ Steep.logger.warn { "Command socket: server is shutting down" }
1249
+ end
1250
+
1251
+ # Deregisters the pending requests of a disconnected command socket session
1252
+ def finish_command_socket_session(session)
1253
+ @command_socket_mutex.synchronize do
1254
+ @command_socket_requests.delete_if {|_, entry| entry[0] == session }
1255
+ end
1256
+ end
1257
+
1258
+ def write_message_to_client(message)
1259
+ unless message.key?(:method)
1260
+ entry = @command_socket_mutex.synchronize { @command_socket_requests.delete(message[:id]) }
1261
+ if entry
1262
+ session, original_id, _ = entry
1263
+ session.write(message.merge({ id: original_id }))
1264
+ return
1265
+ end
1266
+ end
1267
+
1268
+ writer.write(message)
1269
+
1270
+ if message.key?(:method) && !message.key?(:id)
1271
+ forward_notification_to_command_sessions(message)
1272
+ end
1273
+ end
1274
+
1275
+ def forward_notification_to_command_sessions(message)
1276
+ return unless COMMAND_SOCKET_FORWARDED_NOTIFICATIONS.include?(message[:method].to_s)
1277
+
1278
+ sessions = @command_socket_mutex.synchronize do
1279
+ @command_socket_requests.each_value.with_object([]) do |entry, array|
1280
+ session, _, typecheck = entry
1281
+ array << session if typecheck
1282
+ end
1283
+ end
1284
+
1285
+ sessions.uniq!
1286
+ sessions.each {|session| session.write(message) }
1287
+ end
1288
+
1289
+ def typecheck_request_from_command_socket?(request)
1290
+ @command_socket_mutex.synchronize do
1291
+ @command_socket_requests.key?(request.guid)
1292
+ end
1293
+ end
1294
+
1295
+ # Records the mtimes of all project files, as the baseline of `#sync_project_files_from_disk`
1296
+ def reset_project_file_mtimes
1297
+ mtimes = {} #: Hash[Pathname, Time?]
1298
+ each_project_file_path do |path|
1299
+ mtimes[path] = file_mtime(path)
1300
+ end
1301
+ @project_file_mtimes = mtimes
1302
+ end
1303
+
1304
+ def record_project_file_mtime(path)
1305
+ if mtimes = @project_file_mtimes
1306
+ mtimes[path] = file_mtime(path)
1307
+ end
1308
+ end
1309
+
1310
+ def file_mtime(path)
1311
+ File.mtime(path)
1312
+ rescue SystemCallError
1313
+ nil
1314
+ end
1315
+
1316
+ def each_project_file_path(&block)
1317
+ loader = Services::FileLoader.new(base_dir: project.base_dir)
1318
+ project.targets.each do |target|
1319
+ loader.each_path_in_target(target) do |relative_path|
1320
+ yield project.absolute_path(relative_path)
1321
+ end
1322
+ end
1323
+ end
1324
+
1325
+ # Detects files changed on disk since the last load/sync, and reloads them
1326
+ #
1327
+ # Skips files open in the editor because the client owns their contents.
1328
+ # Does nothing until the initial project load finishes.
1329
+ #
1330
+ def sync_project_files_from_disk
1331
+ mtimes = @project_file_mtimes or return
1332
+
1333
+ changes = {} #: Hash[Pathname, Symbol]
1334
+ seen = Set[] #: Set[Pathname]
1335
+
1336
+ each_project_file_path do |path|
1337
+ next if seen.include?(path)
1338
+ seen << path
1339
+
1340
+ mtime = file_mtime(path)
1341
+ recorded = mtimes[path]
1342
+
1343
+ case
1344
+ when recorded.nil? && mtime
1345
+ changes[path] = mtimes.key?(path) ? :changed : :created
1346
+ when recorded && mtime.nil?
1347
+ changes[path] = :deleted
1348
+ when recorded != mtime
1349
+ changes[path] = :changed
1350
+ end
1351
+
1352
+ mtimes[path] = mtime
1353
+ end
1354
+
1355
+ deleted_paths = mtimes.keys.select {|path| !seen.include?(path) && mtimes[path] && file_mtime(path).nil? }
1356
+ deleted_paths.each do |path|
1357
+ changes[path] = :deleted
1358
+ mtimes[path] = nil
1359
+ end
1360
+
1361
+ changes.delete_if {|path, _| controller.open_paths.include?(path) }
1362
+ return if changes.empty?
1363
+
1364
+ Steep.logger.info { "Command socket: reloading #{changes.size} file(s) changed on disk" }
1365
+
1366
+ changes.each do |path, type|
1367
+ content =
1368
+ if type == :deleted
1369
+ ""
1370
+ else
1371
+ begin
1372
+ path.read
1373
+ rescue SystemCallError
1374
+ next
1375
+ end
1376
+ end
1377
+
1378
+ case
1379
+ when controller.code_path?(path)
1380
+ controller.add_dirty_code_path(path)
1381
+ when controller.signature_path?(path)
1382
+ controller.add_dirty_signature_path(path)
1383
+ when controller.inline_path?(path)
1384
+ controller.add_dirty_inline_path(path, content)
1385
+ end
1386
+
1387
+ broadcast_notification(CustomMethods::FileReset.notification({ uri: PathHelper.to_uri(path).to_s, content: content }))
1388
+ end
1389
+
1390
+ if typecheck_automatically
1391
+ start_type_checking_queue.execute do
1392
+ job_queue.push(
1393
+ -> do
1394
+ start_type_check(
1395
+ last_request: current_type_check_request,
1396
+ progress: work_done_progress(SecureRandom.uuid),
1397
+ needs_response: false
1398
+ )
1399
+ end
1400
+ )
1401
+ end
1402
+ end
1403
+ end
1404
+
1024
1405
  def work_done_progress(guid)
1025
1406
  if work_done_progress_supported?
1026
1407
  WorkDoneProgress.new(guid) do |message|