Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions lib/ldclient-rb/impl/file_data/watcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,15 @@ module FileData
# watches the directory of the link's target for the target's name, so that an edit to the
# target is reported. The links are resolved again after every reported change, after a retry
# sets up watches, and when a directory that holds a target is deleted, as happens when a
# Kubernetes ConfigMap volume is updated. So a link that comes to point elsewhere is followed.
# A link that cannot be resolved, for example because its target's directory does not exist
# yet or was deleted, is resolved again once per second until its target is watched, and the
# callback then runs once.
# Kubernetes ConfigMap volume is updated. A link that cannot be resolved, for example because
# its target's directory does not exist yet or was deleted, is resolved again once per second
# until its target is watched, and the callback then runs once.
#
# A change that none of those events reports is not followed: a link further along the chain
# that is pointed elsewhere while its old target stays, a configured directory that is itself
# a link and is pointed elsewhere, or a configured directory that is renamed and replaced.
# Deployments that switch files that way, such as a `current` link to a release directory,
# should use polling, which reads every configured path on each interval.
#
# @private
#
Expand Down Expand Up @@ -274,7 +279,9 @@ def stop
# The targets of links that changed are dropped, and a watch that served only such targets
# is removed. A link that cannot be resolved, for example because its target's directory
# does not exist yet, or whose target's directory cannot be watched, keeps the retry
# running, which resolves the links again. Returns true if it added a watch.
# running, which resolves the links again. Returns true if it added a watch or if a link
# now leads to a target it did not lead to before, which can be in a directory that is
# already watched.
#
private def refresh_links
desired = {}
Expand All @@ -292,6 +299,10 @@ def stop
@lock.synchronize do
return false if @stopped.value

desired.each do |real_directory, names|
previous = @watched[real_directory]&.targets || Set.new
added = true unless (names - previous).empty?
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed watch retriggers callback every retry

Low Severity

When a resolvable link's directory is missing from @watched, the new target check treats every name as unseen. If add_watch then fails, that directory is never recorded, so each retry reports a change and retry_start fires the callback again.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 294127d. Configure here.

@watched.each { |real_directory, entry| entry.targets = desired[real_directory] || Set.new }
@watched.delete_if do |_real_directory, entry|
next false unless entry.directories.empty? && entry.targets.empty?
Expand Down
18 changes: 18 additions & 0 deletions spec/impl/file_data/watcher_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,24 @@ def make_symlink(target, link)
end
end

it "invokes the callback when a link resolves to a target in a directory that is already watched" do
Dir.mkdir(path("config"))
Dir.mkdir(path("other"))
File.write(path("other/other.json"), "{}")
# The link leads through data, which does not exist yet, to a directory that the watcher
# already watches because another configured file is in it.
make_symlink(path("data/flags.json"), path("config/flags.json"))
with_watcher([path("config/flags.json"), path("other/other.json")]) do |_watcher, calls|
sleep 0.3
File.write(path("other/flags.json"), "{}")
before = settled(calls)

make_symlink(path("other"), path("data"))
# The retry that resolves the link runs the callback once.
expect(wait_for { calls.value > before }).to be true
end
end

it "watches the target of a symbolic link once the target's directory appears" do
Dir.mkdir(path("config"))
make_symlink(path("data/flags.json"), path("config/flags.json"))
Expand Down
Loading