unionmount-0.3.0.0: src/System/UnionMount.hs
{-# LANGUAGE DeriveFunctor #-}
{-# LANGUAGE TypeApplications #-}
module System.UnionMount
( -- * Mount endpoints
mount,
unionMount,
unionMount',
-- * Types
FileAction (..),
RefreshAction (..),
Change,
-- * For tests
chainM,
)
where
import Control.Concurrent (threadDelay)
import Control.Monad.Logger
( LogLevel (LevelDebug, LevelError, LevelInfo, LevelWarn),
MonadLogger,
logWithoutLoc,
)
import Data.LVar qualified as LVar
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import System.Directory (canonicalizePath)
import System.FSNotify
( ActionPredicate,
Event (..),
EventIsDirectory (IsDirectory),
StopListening,
WatchManager,
defaultConfig,
eventIsDirectory,
eventPath,
watchTree,
withManagerConf,
)
import System.FilePath (isRelative, makeRelative, (</>))
import System.FilePattern (FilePattern, (?==))
import System.FilePattern.Directory (getDirectoryFilesIgnore)
import UnliftIO (MonadUnliftIO, finally, race, try, withRunInIO)
-- | Simplified version of `unionMount` with exactly one layer.
mount ::
forall model m b.
( MonadIO m,
MonadUnliftIO m,
MonadLogger m,
Show b,
Ord b
) =>
-- | The directory to mount.
FilePath ->
-- | Only include these files (exclude everything else)
[(b, FilePattern)] ->
-- | Ignore these patterns
[FilePattern] ->
-- | Initial value of model, onto which to apply updates.
model ->
-- | How to update the model given a file action.
--
-- `b` is the tag associated with the `FilePattern` that selected this
-- `FilePath`. `FileAction` is the operation performed on this path. This
-- should return a function (in monadic context) that will update the model,
-- to reflect the given `FileAction`.
--
-- If the action throws an exception, it will be logged and ignored.
(b -> FilePath -> FileAction () -> m (model -> model)) ->
m (model, (model -> m ()) -> m ())
mount folder pats ignore var0 toAction' =
let tag0 = ()
sources = one (tag0, (folder, Nothing))
in unionMount sources pats ignore var0 $ \ch -> do
let fsSet = (fmap . fmap . fmap . fmap) void $ fmap Map.toList <$> Map.toList ch
(\(tag, xs) -> uncurry (toAction' tag) `chainM` xs) `chainM` fsSet
where
-- Monadic version of `chain`
chainM :: (Monad m) => (x -> m (a -> a)) -> [x] -> m (a -> a)
chainM f =
fmap chain . mapM f
where
-- Apply the list of actions in the given order to an initial argument.
--
-- chain [f1, f2, ...] a = ... (f2 (f1 x))
chain :: [a -> a] -> a -> a
chain = flip $ foldl' $ flip ($)
-- | Union mount a set of sources (directories) into a model.
unionMount ::
forall source tag model m.
( MonadIO m,
MonadUnliftIO m,
MonadLogger m,
Ord source,
Ord tag
) =>
Set (source, (FilePath, Maybe FilePath)) ->
[(tag, FilePattern)] ->
[FilePattern] ->
model ->
(Change source tag -> m (model -> model)) ->
m (model, (model -> m ()) -> m ())
unionMount sources pats ignore model0 handleAction = do
(x0, xf) <- unionMount' sources pats ignore
x0' <- interceptExceptions id $ handleAction x0
let initial = x0' model0
lvar <- LVar.new initial
let sender send = do
Cmd_Remount <- xf $ \change -> do
change' <- interceptExceptions id $ handleAction change
LVar.modify lvar change'
x <- LVar.get lvar
send x
log LevelInfo "Remounting..."
(a, b) <- unionMount sources pats ignore model0 handleAction
send a
b send
pure (x0' model0, sender)
-- Log and ignore exceptions
--
-- TODO: Make user define-able?
interceptExceptions :: (MonadIO m, MonadUnliftIO m, MonadLogger m) => a -> m a -> m a
interceptExceptions default_ f = do
try f >>= \case
Left (ex :: SomeException) -> do
log LevelError $ "Change handler exception: " <> show ex
pure default_
Right v ->
pure v
-------------------------------------
-- Candidate for moving to a library
-------------------------------------
data Evt source tag
= Evt_Change (Change source tag)
| Evt_Unhandled
deriving (Eq, Show)
data Cmd
= Cmd_Remount
deriving (Eq, Show)
-- | Like `unionMount` but without exception interrupting or re-mounting.
unionMount' ::
forall source tag m m1.
( MonadIO m,
MonadUnliftIO m,
MonadLogger m,
MonadLogger m1,
MonadIO m1,
Ord source,
Ord tag
) =>
Set (source, (FilePath, Maybe FilePath)) ->
[(tag, FilePattern)] ->
[FilePattern] ->
m1
( Change source tag,
(Change source tag -> m ()) ->
m Cmd
)
unionMount' sources pats ignore = do
flip evalStateT (emptyOverlayFs @source) $ do
-- Initial traversal of sources
changes0 :: Change source tag <-
fmap snd . flip runStateT Map.empty $ do
forM_ sources $ \(src, (folder, mountPoint)) -> do
taggedFiles <- filesMatchingWithTag folder pats ignore
forM_ taggedFiles $ \(tag, fs) -> do
forM_ fs $ \fp -> do
put =<< lift . changeInsert src tag mountPoint fp (Refresh Existing ()) =<< get
ofs <- get
pure
( changes0,
\reportChange -> do
-- Run fsnotify on sources
q :: TMVar (x, Maybe FilePath, FilePath, Either (FolderAction ()) (FileAction ())) <- liftIO newEmptyTMVarIO
fmap (either id id) $
race (onChange q (toList sources)) $
let readDebounced = do
-- Wait for some initial action in the queue.
_ <- atomically $ readTMVar q
-- 100ms is a reasonable wait period to gather (possibly related) events.
liftIO $ threadDelay 100000
-- If after this period the queue is empty again, retry.
-- (this can happen if a file is created and deleted in this short span)
maybe readDebounced pure
=<< atomically (tryTakeTMVar q)
loop = do
(src, mountPoint, fp, actE) <- readDebounced
let shouldIgnore = any (?== fp) ignore
case actE of
Left _ -> do
let reason = "Unhandled folder event on '" <> toText fp <> "'"
if shouldIgnore
then do
log LevelWarn $ reason <> " on an ignored path"
loop
else do
-- We don't know yet how to deal with folder events. Just reboot the mount.
log LevelWarn $ reason <> "; suggesting a re-mount"
pure Cmd_Remount -- Exit, asking user to remokunt
Right act -> do
case guard (not shouldIgnore) >> getTag pats fp of
Nothing -> loop
Just tag -> do
changes <- fmap snd . flip runStateT Map.empty $ do
put =<< lift . changeInsert src tag mountPoint fp act =<< get
lift $ reportChange changes
loop
in evalStateT loop ofs
)
filesMatching :: (MonadIO m, MonadLogger m) => FilePath -> [FilePattern] -> [FilePattern] -> m [FilePath]
filesMatching parent' pats ignore = do
parent <- liftIO $ canonicalizePath parent'
log LevelInfo $ toText $ "Traversing " <> parent <> " for files matching " <> show pats <> ", ignoring " <> show ignore
liftIO $ getDirectoryFilesIgnore parent pats ignore
-- | Like `filesMatching` but with a tag associated with a pattern so as to be
-- able to tell which pattern a resulting filepath is associated with.
filesMatchingWithTag :: (MonadIO m, MonadLogger m, Ord b) => FilePath -> [(b, FilePattern)] -> [FilePattern] -> m [(b, [FilePath])]
filesMatchingWithTag parent' pats ignore = do
fs <- filesMatching parent' (snd <$> pats) ignore
let m = Map.fromListWith (<>) $
flip mapMaybe fs $ \fp -> do
tag <- getTag pats fp
pure (tag, one fp)
pure $ Map.toList m
getTag :: [(b, FilePattern)] -> FilePath -> Maybe b
getTag pats fp =
let pull patterns =
listToMaybe $
flip mapMaybe patterns $ \(tag, pat) -> do
guard $ pat ?== fp
pure tag
in if isRelative fp
then pull pats
else -- `fp` is an absolute path (because of use of symlinks), so let's
-- be more lenient in matching it. Note that this does meat we might
-- match files the user may not have originally intended. This is
-- the trade offs with using symlinks.
pull $ second ("**/" <>) <$> pats
data RefreshAction
= -- | No recent change. Just notifying of file's existance
Existing
| -- | New file got created
New
| -- | The already existing file was updated.
Update
deriving (Eq, Show)
data FileAction a
= -- | A new file, or updated file, is available
Refresh RefreshAction a
| -- | The file just got deleted.
Delete
deriving (Eq, Show, Functor)
-- | This is not an action on file, rather an action on a directory (which
-- may contain files, which would be outside the scope of this fsnotify event,
-- and so the user must manually deal with them.)
newtype FolderAction a = FolderAction a
deriving (Eq, Show, Functor)
refreshAction :: FileAction a -> Maybe RefreshAction
refreshAction = \case
Refresh act _ -> Just act
_ -> Nothing
onChange ::
forall x m.
(Eq x, MonadIO m, MonadLogger m, MonadUnliftIO m) =>
TMVar (x, Maybe FilePath, FilePath, Either (FolderAction ()) (FileAction ())) ->
[(x, (FilePath, Maybe FilePath))] ->
-- | The filepath is relative to the folder being monitored, unless if its
-- ancestor is a symlink.
m Cmd
onChange q roots = do
withManagerM $ \mgr -> do
stops <- forM roots $ \(x, (rootRel, mountPoint)) -> do
-- NOTE: It is important to use canonical path, because this will allow us to
-- transform fsnotify event's (absolute) path into one that is relative to
-- @parent'@ (as passed by user), which is what @f@ will expect.
root <- liftIO $ canonicalizePath rootRel
log LevelInfo $ toText $ "Monitoring " <> root <> " for changes"
watchTreeM mgr root (const True) $ \event -> do
log LevelDebug $ show event
atomically $ do
lastQ <- tryTakeTMVar q
let fp = makeRelative root $ eventPath event
f act = putTMVar q (x, mountPoint, fp, act)
-- Re-add last item to the queue
reAddQ = forM_ lastQ (putTMVar q)
if eventIsDirectory event == IsDirectory
then f $ Left $ FolderAction ()
else do
let newAction = case event of
Added {} -> Just $ Refresh New ()
Modified {} -> Just $ Refresh Update ()
ModifiedAttributes {} -> Just $ Refresh Update ()
Removed {} -> Just Delete
_ -> Nothing
-- Merge with the last action when it makes sense to do so.
case (lastQ, newAction) of
(_, Nothing) -> reAddQ
(Just (lastTag, _lastMountPoint, lastFp, Right lastAction), Just a)
| lastTag == x && lastFp == fp ->
case (lastAction, a) of
(Delete, Refresh New ()) -> f $ Right $ Refresh Update ()
(Refresh New (), Refresh Update ()) -> f $ Right $ Refresh New ()
(Refresh New (), Delete) -> pure ()
_ -> f $ Right a
(_, Just a) -> reAddQ >> f (Right a)
liftIO (threadDelay maxBound)
`finally` do
log LevelInfo "Stopping fsnotify monitor."
liftIO $ forM_ stops id
-- Unreachable
pure Cmd_Remount
withManagerM ::
(MonadIO m, MonadUnliftIO m) =>
(WatchManager -> m a) ->
m a
withManagerM f = do
withRunInIO $ \run ->
withManagerConf defaultConfig $ \mgr -> run (f mgr)
watchTreeM ::
forall m.
(MonadIO m, MonadUnliftIO m) =>
WatchManager ->
FilePath ->
ActionPredicate ->
(Event -> m ()) ->
m StopListening
watchTreeM wm fp pr f =
withRunInIO $ \run ->
watchTree wm fp pr $ \evt -> run (f evt)
log :: (MonadLogger m) => LogLevel -> Text -> m ()
log = logWithoutLoc "System.UnionMount"
-- TODO: Abstract in module with StateT / MonadState
newtype OverlayFs source = OverlayFs (Map FilePath (Set (source, FilePath)))
-- TODO: Replace this with a function taking `NonEmpty source`
emptyOverlayFs :: (Ord source) => OverlayFs source
emptyOverlayFs = OverlayFs mempty
overlayFsModify :: FilePath -> (Set (src, FilePath) -> Set (src, FilePath)) -> OverlayFs src -> OverlayFs src
overlayFsModify k f (OverlayFs m) =
OverlayFs $
Map.insert k (f $ fromMaybe Set.empty $ Map.lookup k m) m
overlayFsAdd :: (Ord src) => FilePath -> (src, FilePath) -> OverlayFs src -> OverlayFs src
overlayFsAdd fp src =
overlayFsModify fp $ Set.insert src
overlayFsRemove :: (Ord src) => FilePath -> (src, FilePath) -> OverlayFs src -> OverlayFs src
overlayFsRemove fp src =
overlayFsModify fp $ Set.delete src
overlayFsLookup :: FilePath -> OverlayFs source -> Maybe (NonEmpty ((source, FilePath), FilePath))
overlayFsLookup fp (OverlayFs m) = do
sources <- nonEmpty . toList =<< Map.lookup fp m
pure $ sources <&> (,fp)
-- Files matched by each tag pattern, each represented by their corresponding
-- file (absolute path) in the individual sources. It is up to the user to union
-- them (for now).
type Change source tag = Map tag (Map FilePath (FileAction (NonEmpty (source, FilePath))))
-- | Report a change to overlay fs
changeInsert ::
(Ord source, Ord tag, MonadState (OverlayFs source) m) =>
source ->
tag ->
Maybe FilePath ->
FilePath ->
FileAction () ->
Change source tag ->
m (Change source tag)
changeInsert src tag mountPoint fp act ch = do
let fpMounted = maybe fp (</> fp) mountPoint
fmap snd . flip runStateT ch $ do
-- First, register this change in the overlayFs
lift $
modify $
(if act == Delete then overlayFsRemove else overlayFsAdd)
fpMounted
(src, fp)
overlays :: FileAction (NonEmpty (source, FilePath)) <-
lift (gets $ overlayFsLookup fpMounted) <&> \case
Nothing -> Delete
Just fs ->
-- We don't track per-source action (not ideal), so use 'Existing'
-- only if the current action is 'Deleted'. In every other scenario,
-- re-use the current action for all overlay files.
let combinedAction = fromMaybe Existing $ refreshAction act
in Refresh combinedAction $ fs <&> \((src', fp'), _) -> (src', fp')
gets (Map.lookup tag) >>= \case
Nothing ->
modify $ Map.insert tag $ Map.singleton fpMounted overlays
Just files ->
modify $ Map.insert tag $ Map.insert fpMounted overlays files