diff --git a/lib/file_system.ex b/lib/file_system.ex index 691faa07f89d34091c5d41d8dc81f0d6064e320f..23f98aee99c7e2c85ffff67fdb0602dfbbdc297c 100644 --- a/lib/file_system.ex +++ b/lib/file_system.ex @@ -4,7 +4,7 @@ defmodule FileSystem do @doc """ ## Options - * `:dirs` ([string], requires), the dir list to monitor + * `:dirs` ([string], required), the dir list to monitor * `:backend` (atom, optional), default backends: `:fs_mac` for `macos`, `:fs_inotify` for `linux` and `freebsd`, @@ -45,7 +45,7 @@ defmodule FileSystem do {:file_event, worker_pid, {file_path, events}} {:file_event, worker_pid, :stop} """ - @spec subscribe(pid() | atom()) :: :ok + @spec subscribe(GenServer.server) :: :ok def subscribe(pid) do GenServer.call(pid, :subscribe) end diff --git a/lib/file_system/backend.ex b/lib/file_system/backend.ex index 7d397242c350f37c9fbfd66c9ce763a5357706e5..3fe5d2cc0e1fa3ca7d62723c925b9f7f7a174294 100644 --- a/lib/file_system/backend.ex +++ b/lib/file_system/backend.ex @@ -40,6 +40,7 @@ defmodule FileSystem.Backend do defp backend_module(:fs_mac), do: {:ok, FileSystem.Backends.FSMac} defp backend_module(:fs_inotify), do: {:ok, FileSystem.Backends.FSInotify} defp backend_module(:fs_windows), do: {:ok, FileSystem.Backends.FSWindows} + defp backend_module(:fs_poll), do: {:ok, FileSystem.Backends.FSPoll} defp backend_module({:unsupported_system, system}) do Logger.error "I'm so sorry but `file_system` does NOT support your current system #{inspect system} for now." {:error, :unsupported_system} diff --git a/lib/file_system/backends/fs_poll.ex b/lib/file_system/backends/fs_poll.ex new file mode 100644 index 0000000000000000000000000000000000000000..826b39cbc4f0e1b668dcd849d0b99cb8624f5b19 --- /dev/null +++ b/lib/file_system/backends/fs_poll.ex @@ -0,0 +1,106 @@ +require Logger + +defmodule FileSystem.Backends.FSPoll do + @moduledoc """ + FileSysetm backend for any OS, a GenServer that regularly scans file system to + detect changes and send them to the worker process. + + ## Backend Options + + * `:interval` (integer, default: 1000), polling interval + + ## Use FSPoll Backend + + Unlike other backends, polling backend is never automatically chosen in any + OS environment, despite being usable on all platforms. + + To use polling backend, one has to explicitly specify in the backend option. + """ + + use GenServer + @behaviour FileSystem.Backend + + def bootstrap, do: :ok + + def supported_systems do + [{:unix, :linux}, {:unix, :freebsd}, {:unix, :darwin}, {:win32, :nt}] + end + + def known_events do + [:created, :deleted, :modified] + end + + def start_link(args) do + GenServer.start_link(__MODULE__, args, []) + end + + def init(args) do + worker_pid = Keyword.fetch!(args, :worker_pid) + dirs = Keyword.fetch!(args, :dirs) + interval = Keyword.get(args, :interval, 1000) + + Logger.info("Polling file changes every #{interval}ms...") + send(self(), :first_check) + + {:ok, {worker_pid, dirs, interval, %{}}} + end + + def handle_info(:first_check, {worker_pid, dirs, interval, _empty_map}) do + schedule_check(interval) + {:noreply, {worker_pid, dirs, interval, files_mtimes(dirs)}} + end + + def handle_info(:check, {worker_pid, dirs, interval, stale_mtimes}) do + fresh_mtimes = files_mtimes(dirs) + + diff(stale_mtimes, fresh_mtimes) + |> Tuple.to_list + |> Enum.zip([:created, :deleted, :modified]) + |> Enum.each(&report_change(&1, worker_pid)) + + schedule_check(interval) + {:noreply, {worker_pid, dirs, interval, fresh_mtimes}} + end + + defp schedule_check(interval) do + Process.send_after(self(), :check, interval) + end + + defp files_mtimes(dirs, files_mtimes_map \\ %{}) do + Enum.reduce(dirs, files_mtimes_map, fn dir, map -> + case File.stat!(dir) do + %{type: :regular, mtime: mtime} -> + Map.put(map, dir, mtime) + %{type: :directory} -> + dir + |> Path.join("*") + |> Path.wildcard + |> files_mtimes(map) + %{type: _other} -> + map + end + end) + end + + @doc false + def diff(stale_mtimes, fresh_mtimes) do + fresh_file_paths = fresh_mtimes |> Map.keys |> MapSet.new + stale_file_paths = stale_mtimes |> Map.keys |> MapSet.new + + created_file_paths = + MapSet.difference(fresh_file_paths, stale_file_paths) |> MapSet.to_list + deleted_file_paths = + MapSet.difference(stale_file_paths, fresh_file_paths) |> MapSet.to_list + modified_file_paths = + for file_path <- MapSet.intersection(stale_file_paths, fresh_file_paths), + stale_mtimes[file_path] != fresh_mtimes[file_path], do: file_path + + {created_file_paths, deleted_file_paths, modified_file_paths} + end + + defp report_change({file_paths, event}, worker_pid) do + for file_path <- file_paths do + send(worker_pid, {:backend_file_event, self(), {file_path, [event]}}) + end + end +end diff --git a/test/backends/fs_poll_test.exs b/test/backends/fs_poll_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..86d9d5316c25ef19b9eaf8b104075eb976223bdb --- /dev/null +++ b/test/backends/fs_poll_test.exs @@ -0,0 +1,34 @@ +defmodule FileSystem.Backends.FSPollTest do + use ExUnit.Case, async: true + import FileSystem.Backends.FSPoll + + @mtime1 {{2017, 11, 13}, {10, 14, 00}} + @mtime2 {{2017, 11, 13}, {10, 15, 00}} + + @stale %{ + "modified" => @mtime1, + "deleted" => @mtime1, + } + + @fresh %{ + "created" => @mtime2, + "modified" => @mtime2 + } + + describe "diff" do + test "detect created file" do + {created, _, _} = diff(@stale, @fresh) + created = ["created"] + end + + test "detect modified file" do + {_, modified, _} = diff(@stale, @fresh) + modified = ["modified"] + end + + test "detect deleted file" do + {_, _, deleted} = diff(@stale, @fresh) + deleted = ["deleted"] + end + end +end