Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

abort on filesystem loop #273

Merged
merged 1 commit into from
Nov 14, 2014
Merged
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
54 changes: 27 additions & 27 deletions lib/listen/record.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
require 'listen/record/entry'
require 'listen/record/symlink_detector'

module Listen
class Record
include Celluloid

# TODO: one Record object per watched directory?

# TODO: deprecate
Expand Down Expand Up @@ -100,34 +102,32 @@ def _fast_unset_path(dir, dirname, basename)
end
end

# TODO: test with a file name given
# TODO: test other permissions
# TODO: test with mixed encoding
def _fast_build(root)
symlink_detector = SymlinkDetector.new
@paths[root] = _auto_hash
left = Queue.new
left << '.'

until left.empty?
dirname = left.pop
add_dir(root, dirname)

path = ::File.join(root, dirname)
current = Dir.entries(path.to_s) - %w(. ..)

current.each do |entry|
full_path = ::File.join(path, entry)

if Dir.exist?(full_path)
left << (dirname == '.' ? entry : ::File.join(dirname, entry))
else
begin
lstat = ::File.lstat(full_path)
data = { mtime: lstat.mtime.to_f, mode: lstat.mode }
_fast_update_file(root, dirname, entry, data)
rescue SystemCallError
_fast_unset_path(root, dirname, entry)
end
end
end
end
remaining = Queue.new
remaining << Entry.new(root, nil, nil)
_fast_build_dir(remaining, symlink_detector) until remaining.empty?
end

def _fast_build_dir(remaining, symlink_detector)
entry = remaining.pop
entry.children.each { |child| remaining << child }
symlink_detector.verify_unwatched!(entry)
add_dir(entry.root, entry.record_dir_key)
rescue Errno::ENOTDIR
_fast_try_file(entry)
rescue SystemCallError
_fast_unset_path(entry.root, entry.relative, entry.name)
end

def _fast_try_file(entry)
_fast_update_file(entry.root, entry.relative, entry.name, entry.meta)
rescue SystemCallError
_fast_unset_path(entry.root, entry.relative, entry.name)
end
end
end
51 changes: 51 additions & 0 deletions lib/listen/record/entry.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
module Listen
# @private api
class Record
# Represents a directory entry (dir or file)
class Entry
# file: "/home/me/watched_dir", "app/models", "foo.rb"
# dir, "/home/me/watched_dir", "."
def initialize(root, relative, name = nil)
@root, @relative, @name = root, relative, name
end

attr_reader :root, :relative, :name

def children
child_relative = _join
(Dir.entries(sys_path) - %w(. ..)).map do |name|
Entry.new(@root, child_relative, name)
end
end

def meta
lstat = ::File.lstat(sys_path)
{ mtime: lstat.mtime.to_f, mode: lstat.mode }
end

# record hash is e.g.
# if @record["/home/me/watched_dir"]["project/app/models"]["foo.rb"]
# if @record["/home/me/watched_dir"]["project/app"]["models"]
# record_dir_key is "project/app/models"
def record_dir_key
::File.join(*[@relative, @name].compact)
end

def sys_path
# Use full path in case someone uses chdir
::File.join(*[@root, @relative, @name].compact)
end

def real_path
@real_path ||= ::File.realpath(sys_path)
end

private

def _join
args = [@relative, @name].compact
args.empty? ? nil : ::File.join(*args)
end
end
end
end
59 changes: 59 additions & 0 deletions lib/listen/record/symlink_detector.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
require 'set'

module Listen
# @private api
class Record
class SymlinkDetector
SYMLINK_LOOP_ERROR = <<-EOS
** ERROR: Listen detected a duplicate directory being watched! **

(This may be due to symlinks pointing to parent directories).

Duplicate: %s

which already is added as: %s

Listen is refusing to continue, because this may likely result in
an infinite loop.

Suggestions:

1) (best option) watch only directories you care about, e.g.
either symlinked folders or folders with the real directories,
but not both.

2) reorganize your project so that watched directories do not
contain symlinked directories

3) submit patches so that Listen can reliably and quickly (!)
detect symlinks to already watched read directories, skip
them, and then reasonably choose which symlinked paths to
report as changed (if any)

4) (not worth it) help implement a "reverse symlink lookup"
function in Listen, which - given a real directory - would
return all the symlinks pointing to that directory

Issue: https://github.com/guard/listen/issues/259
EOS

def initialize
@real_dirs = Set.new
end

def verify_unwatched!(entry)
real_path = entry.real_path
@real_dirs.add?(real_path) || _fail(entry.sys_path, real_path)
end

private

def _fail(symlinked, real_path)
STDERR.puts format(SYMLINK_LOOP_ERROR, symlinked, real_path)

# Note Celluloid eats up abort message anyway
fail 'Failed due to looped symlinks'
end
end
end
end
98 changes: 57 additions & 41 deletions spec/lib/listen/record_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -193,21 +193,24 @@
before do
allow(listener).to receive(:directories) { directories }

allow(::File).to receive(:lstat) do |path|
fail "::File.lstat stub called with: #{path.inspect}"
end

allow(::Dir).to receive(:entries) do |path|
fail "::Dir.entries stub called with: #{path.inspect}"
end

allow(::Dir).to receive(:exist?) do |path|
fail "::Dir.exist? stub called with: #{path.inspect}"
stubs = {
::File => %w(lstat realpath),
::Dir => %w(entries exist?)
}

stubs.each do |klass, meths|
meths.each do |meth|
allow(klass).to receive(meth.to_sym) do |*args|
fail "stub called: #{klass}.#{meth}(#{args.map(&:inspect) * ', '})"
end
end
end
end

it 're-inits paths' do
allow(::Dir).to receive(:entries) { [] }
allow(::Dir).to receive(:entries).and_return([])
allow(::File).to receive(:realpath).with('/dir1').and_return('/dir1')
allow(::File).to receive(:realpath).with('/dir2').and_return('/dir2')

record.update_file(dir, 'path/file.rb', mtime: 1.1)
record.build
Expand All @@ -219,18 +222,19 @@
let(:bar_stat) { instance_double(::File::Stat, mtime: 2.3, mode: 0755) }

context 'with no subdirs' do

before do
expect(::Dir).to receive(:entries).with('/dir1/.') { %w(foo bar) }
expect(::Dir).to receive(:exist?).with('/dir1/./foo') { false }
expect(::Dir).to receive(:exist?).with('/dir1/./bar') { false }
expect(::File).to receive(:lstat).with('/dir1/./foo') { foo_stat }
expect(::File).to receive(:lstat).with('/dir1/./bar') { bar_stat }

expect(::Dir).to receive(:entries).with('/dir2/.') { [] }
allow(::Dir).to receive(:entries).with('/dir1') { %w(foo bar) }
allow(::Dir).to receive(:entries).with('/dir1/foo').and_raise(Errno::ENOTDIR)
allow(::Dir).to receive(:entries).with('/dir1/bar').and_raise(Errno::ENOTDIR)
allow(::File).to receive(:lstat).with('/dir1/foo') { foo_stat }
allow(::File).to receive(:lstat).with('/dir1/bar') { bar_stat }
allow(::Dir).to receive(:entries).with('/dir2') { [] }

allow(::File).to receive(:realpath).with('/dir1').and_return('/dir1')
allow(::File).to receive(:realpath).with('/dir2').and_return('/dir2')
end

it 'builds record' do
it 'builds record' do
record.build
expect(record.paths.keys).to eq %w( /dir1 /dir2 )
expect(record.paths['/dir1']).
Expand All @@ -242,15 +246,16 @@

context 'with subdir containing files' do
before do
expect(::Dir).to receive(:entries).with('/dir1/.') { %w(foo) }
expect(::Dir).to receive(:exist?).with('/dir1/./foo') { true }

expect(::Dir).to receive(:entries).with('/dir1/foo') { %w(bar) }

expect(::Dir).to receive(:exist?).with('/dir1/foo/bar') { false }
expect(::File).to receive(:lstat).with('/dir1/foo/bar') { bar_stat }

expect(::Dir).to receive(:entries).with('/dir2/.') { [] }
allow(::Dir).to receive(:entries).with('/dir1') { %w(foo) }
allow(::Dir).to receive(:entries).with('/dir1/foo') { %w(bar) }
allow(::Dir).to receive(:entries).with('/dir1/foo/bar').and_raise(Errno::ENOTDIR)
allow(::File).to receive(:lstat).with('/dir1/foo/bar') { bar_stat }
allow(::Dir).to receive(:entries).with('/dir2') { [] }

allow(::File).to receive(:realpath).with('/dir1').and_return('/dir1')
allow(::File).to receive(:realpath).with('/dir2').and_return('/dir2')
allow(::File).to receive(:realpath).with('/dir1/foo').
and_return('/dir1/foo')
end

it 'builds record' do
Expand All @@ -264,18 +269,12 @@

context 'with subdir containing dirs' do
before do
expect(::Dir).to receive(:entries).with('/dir1/.') { %w(foo) }
expect(::Dir).to receive(:exist?).with('/dir1/./foo') { true }

expect(::Dir).to receive(:entries).with('/dir1/foo') { %w(bar baz) }

expect(::Dir).to receive(:exist?).with('/dir1/foo/bar') { true }
expect(::Dir).to receive(:entries).with('/dir1/foo/bar') { [] }

expect(::Dir).to receive(:exist?).with('/dir1/foo/baz') { true }
expect(::Dir).to receive(:entries).with('/dir1/foo/baz') { [] }

expect(::Dir).to receive(:entries).with('/dir2/.') { [] }
allow(::File).to receive(:realpath) { |path| path }
allow(::Dir).to receive(:entries).with('/dir1') { %w(foo) }
allow(::Dir).to receive(:entries).with('/dir1/foo') { %w(bar baz) }
allow(::Dir).to receive(:entries).with('/dir1/foo/bar') { [] }
allow(::Dir).to receive(:entries).with('/dir1/foo/baz') { [] }
allow(::Dir).to receive(:entries).with('/dir2') { [] }
end

it 'builds record' do
Expand All @@ -290,5 +289,22 @@
expect(record.paths['/dir2']).to eq({})
end
end

context 'with subdir containing symlink to parent' do
subject { record.paths }
before do
allow(::Dir).to receive(:entries).with('/dir1') { %w(foo) }
allow(::Dir).to receive(:entries).with('/dir1/foo') { %w(foo) }
allow(::File).to receive(:realpath).with('/dir1').and_return('/bar')
allow(::File).to receive(:realpath).with('/dir1/foo').and_return('/bar')
end

it 'shows message and aborts with error' do
expect(STDERR).to receive(:puts).with(/detected a duplicate directory/)

expect { record.build }.to raise_error(RuntimeError,
/Failed due to looped symlinks/)
end
end
end
end