Skip to content

Commit 1918b6c

Browse files
authored
Prevent race condition when saving file during initial indexing (#3269)
1 parent f695a75 commit 1918b6c

File tree

3 files changed

+102
-23
lines changed

3 files changed

+102
-23
lines changed

lib/ruby_indexer/lib/ruby_indexer/index.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ class IndexNotEmptyError < StandardError; end
1313
#: Configuration
1414
attr_reader :configuration
1515

16+
#: bool
17+
attr_reader :initial_indexing_completed
18+
1619
class << self
1720
# Returns the real nesting of a constant name taking into account top level
1821
# references that may be included anywhere in the name or nesting where that
@@ -366,8 +369,6 @@ def index_all(uris: @configuration.indexable_uris, &block)
366369
"The index is not empty. To prevent invalid entries, `index_all` can only be called once."
367370
end
368371

369-
@initial_indexing_completed = true
370-
371372
RBSIndexer.new(self).index_ruby_core
372373
# Calculate how many paths are worth 1% of progress
373374
progress_step = (uris.length / 100.0).ceil
@@ -380,6 +381,8 @@ def index_all(uris: @configuration.indexable_uris, &block)
380381

381382
index_file(uri, collect_comments: false)
382383
end
384+
385+
@initial_indexing_completed = true
383386
end
384387

385388
#: (URI::Generic uri, String source, ?collect_comments: bool) -> void

lib/ruby_lsp/server.rb

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,6 +1012,21 @@ def text_document_definition(message)
10121012

10131013
#: (Hash[Symbol, untyped] message) -> void
10141014
def workspace_did_change_watched_files(message)
1015+
# If indexing is not complete yet, delay processing did change watched file notifications. We need initial
1016+
# indexing to be in place so that we can handle file changes appropriately without risking duplicates. We also
1017+
# have to sleep before re-inserting the notification in the queue otherwise the worker can get stuck in its own
1018+
# loop of pushing and popping the same notification
1019+
unless @global_state.index.initial_indexing_completed
1020+
Thread.new do
1021+
sleep(2)
1022+
# We have to ensure that the queue is not closed yet, since nothing stops the user from saving a file and then
1023+
# immediately telling the LSP to shutdown
1024+
@incoming_queue << message unless @incoming_queue.closed?
1025+
end
1026+
1027+
return
1028+
end
1029+
10151030
changes = message.dig(:params, :changes)
10161031
# We allow add-ons to register for watching files and we have no restrictions for what they register for. If the
10171032
# same pattern is registered more than once, the LSP will receive duplicate change notifications. Receiving them
@@ -1049,27 +1064,29 @@ def workspace_did_change_watched_files(message)
10491064

10501065
#: (RubyIndexer::Index index, String file_path, Integer change_type) -> void
10511066
def handle_ruby_file_change(index, file_path, change_type)
1052-
load_path_entry = $LOAD_PATH.find { |load_path| file_path.start_with?(load_path) }
1053-
uri = URI::Generic.from_path(load_path_entry: load_path_entry, path: file_path)
1054-
1055-
case change_type
1056-
when Constant::FileChangeType::CREATED
1057-
content = File.read(file_path)
1058-
# If we receive a late created notification for a file that has already been claimed by the client, we want to
1059-
# handle change for that URI so that the require path tree is updated
1060-
@store.key?(uri) ? index.handle_change(uri, content) : index.index_single(uri, content)
1061-
when Constant::FileChangeType::CHANGED
1062-
content = File.read(file_path)
1063-
# We only handle changes on file watched notifications if the client is not the one managing this URI.
1064-
# Otherwise, these changes are handled when running the combined requests
1065-
index.handle_change(uri, content) unless @store.key?(uri)
1066-
when Constant::FileChangeType::DELETED
1067-
index.delete(uri)
1068-
end
1069-
rescue Errno::ENOENT
1070-
# If a file is created and then delete immediately afterwards, we will process the created notification before we
1071-
# receive the deleted one, but the file no longer exists. This may happen when running a test suite that creates
1072-
# and deletes files automatically.
1067+
@global_state.synchronize do
1068+
load_path_entry = $LOAD_PATH.find { |load_path| file_path.start_with?(load_path) }
1069+
uri = URI::Generic.from_path(load_path_entry: load_path_entry, path: file_path)
1070+
1071+
case change_type
1072+
when Constant::FileChangeType::CREATED
1073+
content = File.read(file_path)
1074+
# If we receive a late created notification for a file that has already been claimed by the client, we want to
1075+
# handle change for that URI so that the require path tree is updated
1076+
@store.key?(uri) ? index.handle_change(uri, content) : index.index_single(uri, content)
1077+
when Constant::FileChangeType::CHANGED
1078+
content = File.read(file_path)
1079+
# We only handle changes on file watched notifications if the client is not the one managing this URI.
1080+
# Otherwise, these changes are handled when running the combined requests
1081+
index.handle_change(uri, content) unless @store.key?(uri)
1082+
when Constant::FileChangeType::DELETED
1083+
index.delete(uri)
1084+
end
1085+
rescue Errno::ENOENT
1086+
# If a file is created and then delete immediately afterwards, we will process the created notification before
1087+
# we receive the deleted one, but the file no longer exists. This may happen when running a test suite that
1088+
# creates and deletes files automatically.
1089+
end
10731090
end
10741091

10751092
#: (URI::Generic uri) -> void

test/server_test.rb

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,7 @@ def test_changed_file_only_indexes_ruby
475475
path = File.join(Dir.pwd, "lib", "foo.rb")
476476
File.write(path, "class Foo\nend")
477477

478+
@server.global_state.index.index_all(uris: [])
478479
@server.global_state.index.expects(:index_single).once.with do |uri|
479480
uri.full_path == path
480481
end
@@ -501,6 +502,7 @@ def test_changed_file_only_indexes_ruby
501502
end
502503

503504
def test_did_change_watched_files_does_not_fail_for_non_existing_files
505+
@server.global_state.index.index_all(uris: [])
504506
@server.process_message({
505507
method: "workspace/didChangeWatchedFiles",
506508
params: {
@@ -530,6 +532,7 @@ def test_did_change_watched_files_handles_deletions
530532

531533
uri = URI::Generic.from_path(path: path)
532534

535+
@server.global_state.index.index_all(uris: [])
533536
@server.process_message({
534537
method: "workspace/didChangeWatchedFiles",
535538
params: {
@@ -585,6 +588,7 @@ def version
585588
bar.expects(:workspace_did_change_watched_files).once
586589

587590
begin
591+
@server.global_state.index.index_all(uris: [])
588592
@server.process_message({
589593
method: "workspace/didChangeWatchedFiles",
590594
params: {
@@ -606,6 +610,7 @@ def version
606610
end
607611

608612
def test_did_change_watched_files_processes_unique_change_entries
613+
@server.global_state.index.index_all(uris: [])
609614
@server.expects(:handle_rubocop_config_change).once
610615
@server.process_message({
611616
method: "workspace/didChangeWatchedFiles",
@@ -1155,6 +1160,7 @@ def test_rubocop_config_changes_trigger_workspace_diagnostic_refresh
11551160
},
11561161
})
11571162

1163+
@server.global_state.index.index_all(uris: [])
11581164
@server.process_message({
11591165
method: "workspace/didChangeWatchedFiles",
11601166
params: {
@@ -1316,6 +1322,7 @@ class Foo
13161322
},
13171323
})
13181324

1325+
@server.global_state.index.index_all(uris: [])
13191326
@server.global_state.index.expects(:handle_change).never
13201327
@server.process_message({
13211328
method: "workspace/didChangeWatchedFiles",
@@ -1370,6 +1377,7 @@ class Foo
13701377
params: { textDocument: { uri: uri } },
13711378
})
13721379

1380+
@server.global_state.index.index_all(uris: [])
13731381
# Then send a late did change watched files notification for the creation of the file
13741382
@server.process_message({
13751383
method: "workspace/didChangeWatchedFiles",
@@ -1414,8 +1422,59 @@ def test_diagnose_state
14141422
assert_equal(0, result.response[:incomingQueueSize])
14151423
end
14161424

1425+
def test_modifying_files_during_initial_indexing_does_not_duplicate_entries
1426+
path = File.join(Dir.pwd, "lib", "foo.rb")
1427+
uri = URI::Generic.from_path(path: path)
1428+
1429+
begin
1430+
@server.process_message({
1431+
id: 1,
1432+
method: "initialize",
1433+
params: {
1434+
initializationOptions: {},
1435+
capabilities: { general: { positionEncodings: ["utf-8"] }, window: { workDoneProgress: true } },
1436+
},
1437+
})
1438+
1439+
# Start indexing
1440+
File.write(path, "class Foo\nend")
1441+
@server.process_message({ method: "initialized", params: {} })
1442+
1443+
# Then immediately notify that a file was modified before indexing is finished
1444+
File.write(path, "class Foo\n def bar\n end\nend")
1445+
@server.process_message({
1446+
method: "workspace/didChangeWatchedFiles",
1447+
params: {
1448+
changes: [
1449+
{
1450+
uri: uri.to_s,
1451+
type: RubyLsp::Constant::FileChangeType::CHANGED,
1452+
},
1453+
],
1454+
},
1455+
})
1456+
1457+
wait_for_indexing
1458+
1459+
# There should not be a duplicate declaration
1460+
index = @server.global_state.index
1461+
assert_equal(1, index["Foo"]&.length)
1462+
ensure
1463+
FileUtils.rm(path)
1464+
end
1465+
end
1466+
14171467
private
14181468

1469+
def wait_for_indexing
1470+
message = @server.pop_response
1471+
until message.is_a?(RubyLsp::Notification) && message.method == "$/progress" &&
1472+
T.unsafe(message).params.value.kind == "end"
1473+
1474+
message = @server.pop_response
1475+
end
1476+
end
1477+
14191478
def with_uninstalled_rubocop(&block)
14201479
rubocop_paths = $LOAD_PATH.select { |path| path.include?("gems/rubocop") }
14211480
rubocop_paths.each { |path| $LOAD_PATH.delete(path) }

0 commit comments

Comments
 (0)