packages feed

unionmount (empty) → 0.1.0.0

raw patch · 4 files changed

+472/−0 lines, 4 filesdep +asyncdep +basedep +bytestring

Dependencies added: async, base, bytestring, containers, data-default, directory, filepath, filepattern, fsnotify, lvar, monad-logger, mtl, relude, text, time, unliftio, with-utf8

Files

+ LICENSE view
@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2021 Sridhar Ratnakumar++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
+ README.md view
@@ -0,0 +1,4 @@+# unionmount+++Union mount library used in [Ema](https://ema.srid.ca) and [Emanote](https://note.ema.srid.ca).
+ src/System/UnionMount.hs view
@@ -0,0 +1,381 @@+{-# LANGUAGE DeriveFunctor #-}+{-# LANGUAGE TypeApplications #-}++module System.UnionMount where++import Control.Concurrent (threadDelay)+import Control.Monad.Logger+  ( LogLevel (LevelDebug, LevelError, LevelInfo, LevelWarn),+    MonadLogger,+    logWithoutLoc,+  )+import Data.LVar (LVar)+import qualified Data.LVar as LVar+import qualified Data.Map.Strict as Map+import qualified Data.Set as Set+import Data.Time.Clock (NominalDiffTime)+import System.Directory (canonicalizePath)+import System.FSNotify+  ( ActionPredicate,+    Debounce (Debounce),+    Event (..),+    StopListening,+    WatchConfig (..),+    WatchManager,+    defaultConfig,+    eventIsDirectory,+    eventPath,+    watchTree,+    withManagerConf,+  )+import System.FilePath (isRelative, makeRelative)+import System.FilePattern (FilePattern, (?==))+import System.FilePattern.Directory (getDirectoryFilesIgnore)+import UnliftIO (MonadUnliftIO, finally, newTBQueueIO, race, try, withRunInIO, writeTBQueue)+import UnliftIO.STM (TBQueue, readTBQueue)++-- | Simplified variation of `unionMountOnLVar` with exactly one source.+mountOnLVar ::+  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] ->+  -- | The `LVar` onto which to mount.+  --+  -- NOTE: It must not be set already. Otherwise, the value will be overriden+  -- with the initial value argument (next).+  LVar model ->+  -- | 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 (Maybe Cmd)+mountOnLVar folder pats ignore var var0 toAction' =+  let tag0 = ()+      sources = one (tag0, folder)+   in unionMountOnLVar sources pats ignore var 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 ($)++-- | Like `unionMount` but updates a `LVar` as well handles exceptions (and+-- unhandled events) by logging them.+unionMountOnLVar ::+  forall source tag model m.+  ( MonadIO m,+    MonadUnliftIO m,+    MonadLogger m,+    Ord source,+    Ord tag+  ) =>+  Set (source, FilePath) ->+  [(tag, FilePattern)] ->+  [FilePattern] ->+  LVar model ->+  model ->+  (Change source tag -> m (model -> model)) ->+  m (Maybe Cmd)+unionMountOnLVar sources pats ignore modelLVar model0 handleAction = do+  -- This TMVar is used to ensure the LVar semantics that `set` is called once+  -- /after/ the initial file listing is read, and `modify` is called for each+  -- subsequent inotify events.+  initialized <- newTMVarIO False+  unionMount sources pats ignore $ \changes -> do+    updateModel <-+      interceptExceptions id $+        handleAction changes+    atomically (takeTMVar initialized) >>= \case+      False -> do+        LVar.set modelLVar (updateModel model0)+      True -> do+        LVar.modify modelLVar updateModel+    atomically $ putTMVar initialized True+    pure Nothing++-- 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 $ "User 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)++unionMount ::+  forall source tag m.+  ( MonadIO m,+    MonadUnliftIO m,+    MonadLogger m,+    Ord source,+    Ord tag+  ) =>+  Set (source, FilePath) ->+  [(tag, FilePattern)] ->+  [FilePattern] ->+  (Change source tag -> m (Maybe Cmd)) ->+  m (Maybe Cmd)+unionMount sources pats ignore handleAction = do+  fmap (join . rightToMaybe . fst) . flip runStateT (emptyOverlayFs @source) $ do+    -- Initial traversal of sources+    changes0 :: Change source tag <-+      fmap snd . flip runStateT Map.empty $ do+        forM_ sources $ \(src, folder) -> do+          taggedFiles <- filesMatchingWithTag folder pats ignore+          forM_ taggedFiles $ \(tag, fs) -> do+            forM_ fs $ \fp -> do+              put =<< lift . changeInsert src tag fp (Refresh Existing ()) =<< get+    void $ lift $ handleAction changes0+    -- Run fsnotify on sources+    ofs <- get+    q :: TBQueue (x, FilePath, Either (FolderAction ()) (FileAction ())) <- liftIO $ newTBQueueIO 1+    lift $+      race (onChange q (toList sources)) $+        fmap fst . flip runStateT ofs $ do+          let loop = do+                (src, fp, actE) <- atomically $ readTBQueue q+                let shouldIgnore = any (?== fp) ignore+                case actE of+                  Left _ -> do+                    if shouldIgnore+                      then do+                        log LevelWarn "Unhandled folder event on an ignored path"+                        loop+                      else do+                        -- We don't know yet how to deal with folder events. Just reboot the mount.+                        log LevelWarn "Unhandled folder event; suggesting a re-mount"+                        pure $ Just Cmd_Remount+                  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 fp act =<< get+                        mcmd <- lift $ handleAction changes+                        maybe loop (pure . pure) mcmd+          loop++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, pattern) -> do+            guard $ pattern ?== 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.+  (MonadIO m, MonadLogger m, MonadUnliftIO m) =>+  TBQueue (x, FilePath, Either (FolderAction ()) (FileAction ())) ->+  [(x, FilePath)] ->+  -- | The filepath is relative to the folder being monitored, unless if its+  -- ancestor is a symlink.+  m ()+onChange q roots = do+  -- 100ms is a reasonable wait period to gather (possibly related) events.+  -- One such related event is a MOVE, which fsnotify doesn't native support;+  -- and spits out a DELETE and ADD instead.+  let debounceDurationSecs :: NominalDiffTime = 0.1+      cfg = defaultConfig {confDebounce = Debounce debounceDurationSecs}+  withManagerM cfg $ \mgr -> do+    stops <- forM roots $ \(x, rootRel) -> 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+        let rel = makeRelative root+            f a fp act = atomically $ writeTBQueue q (a, fp, act)+        if eventIsDirectory event+          then f x (rel . eventPath $ event) $ Left $ FolderAction ()+          else case event of+            Added (rel -> fp) _ _ -> f x fp $ Right $ Refresh New ()+            Modified (rel -> fp) _ _ -> f x fp $ Right $ Refresh Update ()+            Removed (rel -> fp) _ _ -> f x fp $ Right Delete+            Unknown (rel -> fp) _ _ -> f x fp $ Right Delete+    liftIO (threadDelay maxBound)+      `finally` do+        log LevelInfo "Stopping fsnotify monitor."+        liftIO $ forM_ stops id++withManagerM ::+  (MonadIO m, MonadUnliftIO m) =>+  WatchConfig ->+  (WatchManager -> m a) ->+  m a+withManagerM cfg f = do+  withRunInIO $ \run ->+    withManagerConf cfg $ \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 "Ema.Helper.FileSystem"++-- TODO: Abstract in module with StateT / MonadState+newtype OverlayFs source = OverlayFs+  { unOverlayFs :: Map FilePath (Set source)+  }++-- TODO: Replace this with a function taking `NonEmpty source`+emptyOverlayFs :: Ord source => OverlayFs source+emptyOverlayFs = OverlayFs mempty++overlayFsModify :: FilePath -> (Set src -> Set src) -> 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 -> OverlayFs src -> OverlayFs src+overlayFsAdd fp src =+  overlayFsModify fp $ Set.insert src++overlayFsRemove :: Ord src => FilePath -> src -> OverlayFs src -> OverlayFs src+overlayFsRemove fp src =+  overlayFsModify fp $ Set.delete src++overlayFsLookup :: FilePath -> OverlayFs source -> Maybe (NonEmpty (source, 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 ->+  FilePath ->+  FileAction () ->+  Change source tag ->+  m (Change source tag)+changeInsert src tag fp act ch = do+  fmap snd . flip runStateT ch $ do+    -- First, register this change in the overlayFs+    lift $+      modify $+        (if act == Delete then overlayFsRemove else overlayFsAdd)+          fp+          src+    overlays <-+      lift (gets $ overlayFsLookup fp) <&> \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+    gets (Map.lookup tag) >>= \case+      Nothing ->+        modify $ Map.insert tag $ Map.singleton fp overlays+      Just files ->+        modify $ Map.insert tag $ Map.insert fp overlays files
+ unionmount.cabal view
@@ -0,0 +1,66 @@+cabal-version:      2.4+name:               unionmount+version:            0.1.0.0+license:            MIT+copyright:          2021 Sridhar Ratnakumar+maintainer:         srid@srid.ca+author:             Sridhar Ratnakumar+category:           Filesystem++-- TODO: Before hackage release.+-- A short (one-line) description of the package.+synopsis: Union mount filesystem paths into Haskell datastructures++-- A longer description of the package.+description: Union mount filesystem paths into Haskell datastructures++-- A URL where users can report bugs.+bug-reports: https://github.com/srid/unionmount++extra-source-files:+  LICENSE+  README.md++library+  build-depends:+    , async+    , base >=4.13.0.0 && <=4.17.0.0+    , bytestring+    , containers+    , data-default+    , directory+    , filepath+    , filepattern+    , fsnotify+    , lvar+    , monad-logger+    , mtl+    , relude+    , text+    , time+    , unliftio+    , with-utf8++  mixins:+    base hiding (Prelude),+    relude (Relude as Prelude, Relude.Container.One),+    relude++  ghc-options:+    -Wall -Wincomplete-record-updates -Wincomplete-uni-patterns++  exposed-modules:    System.UnionMount+  default-extensions:+    FlexibleContexts+    FlexibleInstances+    KindSignatures+    LambdaCase+    MultiParamTypeClasses+    MultiWayIf+    OverloadedStrings+    ScopedTypeVariables+    TupleSections+    ViewPatterns++  hs-source-dirs:     src+  default-language:   Haskell2010