packages feed

cachix 1.7.8 → 1.7.9

raw patch · 11 files changed

+305/−101 lines, 11 files

Files

CHANGELOG.md view
@@ -5,6 +5,19 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [1.7.9] - 2025-06-02++### Fixed++- fix handling of optional `!include` nix.conf statements that would error if the file was missing+- daemon: improve event loop responsiveness to shutdown signals+- daemon: gracefully handle sigTERM and sigINT signals++### Changed++- increase event loop size to handle rapid pushing of many small paths+- update dependencies+ ## [1.7.8] - 2025-04-09  ### Fixed
cachix.cabal view
@@ -1,6 +1,6 @@ cabal-version:      2.2 name:               cachix-version:            1.7.8+version:            1.7.9 synopsis:   Command-line client for Nix binary cache hosting https://cachix.org 
src/Cachix/Client/NixConf.hs view
@@ -39,6 +39,7 @@ import Cachix.Client.Exception (CachixException (..)) import Cachix.Client.URI qualified as URI import Cachix.Types.BinaryCache qualified as BinaryCache+import Control.Exception.Safe qualified as Safe import Data.List (nub) import Data.Text qualified as T import Protolude hiding (toS)@@ -46,11 +47,12 @@ import System.Directory   ( XdgDirectory (..),     createDirectoryIfMissing,-    doesFileExist,     getXdgDirectory,   ) import System.FilePath (normalise) import System.FilePath.Posix (takeDirectory, (</>))+import System.IO.Error (isDoesNotExistError)+import System.IO.Error qualified import Text.Megaparsec qualified as Mega import Text.Megaparsec.Char @@ -89,6 +91,13 @@   }   deriving stock (Show, Eq, Functor) +data NixConfError+  = -- | Error when trying to read the nix.conf file or an include+    IOError FilePath System.IO.Error.IOError+  | -- | Failed to parse the nix.conf file+    ParseError FilePath Text+  deriving (Show, Typeable)+ -- | Operations on nix.conf. -- Helps to work with both NixConf and NixConfSource. class NixConfOps a where@@ -199,17 +208,22 @@        if fullPath `elem` stack         then case includeType of-          RequiredInclude _ -> throwIO $ CircularInclude (formatCircularError fullPath)+          RequiredInclude _ ->+            throwIO $ CircularInclude (formatCircularError fullPath)           OptionalInclude _ -> return []         else do-          exists <- doesFileExist fullPath-          if not exists && isRequired includeType-            then throwIO $ IncludeNotFound $ toS fullPath-            else do-              content <- readFile fullPath-              case parse content of-                Left _err -> return []-                Right conf -> resolveIncludesWithStack (fullPath : stack) baseFile (NixConfSource fullPath conf)+          read' fullPath >>= \case+            Left err@(IOError _ _) ->+              if isRequired includeType+                then do+                  printNixConfError err+                  throwIO $ IncludeNotFound ("Failed to read required include file: " <> toS fullPath)+                else return []+            Left err@(ParseError _ _) -> do+              printNixConfError err+              throwIO $ IncludeNotFound ("Failed to read required include file: " <> toS fullPath)+            Right conf ->+              resolveIncludesWithStack (fullPath : stack) baseFile conf      isRequired (RequiredInclude _) = True     isRequired (OptionalInclude _) = False@@ -231,29 +245,40 @@ data NixConfLoc = Global | Local | Custom FilePath   deriving stock (Show, Eq) +-- | Safely read a nix.conf file from the given location.+-- Prints errors to stderr. read :: NixConfLoc -> IO (Maybe NixConfSource) read ncl = do   filename <- getFilename ncl-  read' filename--read' :: FilePath -> IO (Maybe NixConfSource)-read' filename = do-  doesExist <- doesFileExist filename-  if not doesExist-    then return Nothing-    else do-      result <- parse <$> readFile filename-      case result of-        Left err -> do-          putStrLn (Mega.errorBundlePretty err)-          panic $ toS filename <> " failed to parse, please copy the above error and contents of nix.conf and open an issue at https://github.com/cachix/cachix"-        Right conf -> return (Just (NixConfSource filename conf))+  read' filename >>= \case+    Left err -> do+      printNixConfError err+      return Nothing+    Right conf -> return $ Just conf +-- | Safely read a nix.conf file from the given location.+-- Return an empty NixConfSource if the file does not exist or cannot be read.+-- Prints errors to stderr. readWithDefault :: NixConfLoc -> IO NixConfSource readWithDefault ncl = do   filename <- getFilename ncl-  fromMaybe (new filename) <$> read' filename+  read' filename >>= \case+    Left err -> do+      printNixConfError err+      return $ new filename+    Right conf -> return conf +-- | Safely read a nix.conf file from the given file path.+read' :: FilePath -> IO (Either NixConfError NixConfSource)+read' filename = do+  econtent <- Safe.tryIO (readFile filename)+  return $ case econtent of+    Left err -> Left $ IOError filename err+    Right content ->+      case parse content of+        Left err -> Left $ ParseError filename $ toS (Mega.errorBundlePretty err)+        Right conf -> Right $ NixConfSource filename conf+ getFilename :: NixConfLoc -> IO FilePath getFilename ncl = do   dir <-@@ -262,6 +287,29 @@       Local -> getXdgDirectory XdgConfig "nix"       Custom filepath -> return filepath   return $ dir <> "/nix.conf"++printNixConfError :: NixConfError -> IO ()+printNixConfError (IOError path err) | isDoesNotExistError err = do+  putErrText $+    unlines+      [ "No config at " <> toS path <> ":",+        "",+        toS (displayException err)+      ]+printNixConfError (IOError path err) = do+  putErrText $+    unlines+      [ "Failed to read " <> toS path <> ":",+        "",+        toS (displayException err)+      ]+printNixConfError (ParseError path err) = do+  putErrText $+    unlines+      [ "Failed to parse " <> toS path <> ":",+        "",+        err+      ]  -- nix.conf Parser type Parser = Mega.Parsec Void Text
src/Cachix/Daemon.hs view
@@ -23,7 +23,6 @@ import Cachix.Daemon.Protocol as Protocol import Cachix.Daemon.Push as Push import Cachix.Daemon.PushManager qualified as PushManager-import Cachix.Daemon.ShutdownLatch import Cachix.Daemon.SocketStore qualified as SocketStore import Cachix.Daemon.Subscription as Subscription import Cachix.Daemon.Types as Types@@ -33,6 +32,7 @@ import Cachix.Types.BinaryCache qualified as BinaryCache import Control.Concurrent.STM.TMChan import Control.Exception.Safe (catchAny)+import Data.IORef (IORef, atomicModifyIORef', newIORef) import Data.Text qualified as T import Hercules.CNix.Store (Store, withStore) import Hercules.CNix.Util qualified as CNix.Util@@ -43,7 +43,7 @@ import System.IO.Error (isResourceVanishedError) import System.Posix.Process (getProcessID) import System.Posix.Signals qualified as Signal-import UnliftIO (MonadUnliftIO)+import UnliftIO (MonadUnliftIO, withRunInIO) import UnliftIO.Async qualified as Async import UnliftIO.Exception (bracket) @@ -71,7 +71,6 @@   daemonLogger <- Log.new "cachix.daemon" daemonLogHandle daemonLogLevel    daemonEventLoop <- EventLoop.new-  daemonShutdownLatch <- newShutdownLatch   daemonPid <- getProcessID    daemonSocketPath <- maybe getSocketPath pure (Options.daemonSocketPath daemonOptions)@@ -99,7 +98,7 @@ start daemonEnv daemonOptions daemonPushOptions daemonCacheName =   withStore $ \store -> do     daemon <- new daemonEnv store daemonOptions Nothing daemonPushOptions daemonCacheName-    installSignalHandlers daemon+    void $ runDaemon daemon installSignalHandlers     result <- run daemon     exitWith (toExitCode result) @@ -156,15 +155,53 @@ stopIO DaemonEnv {daemonEventLoop} =   EventLoop.sendIO daemonEventLoop ShutdownGracefully -installSignalHandlers :: DaemonEnv -> IO ()-installSignalHandlers daemon = do-  for_ [Signal.sigTERM, Signal.sigINT] $ \signal ->-    Signal.installHandler signal (Signal.CatchOnce handler) Nothing+installSignalHandlers :: Daemon ()+installSignalHandlers = do+  withRunInIO $ \runInIO -> do+    mainThreadId <- myThreadId+    -- Track Ctrl+C attempts+    interruptRef <- newIORef False++    -- Install signal handlers using runInIO to properly run Daemon actions from IO+    _ <- Signal.installHandler Signal.sigTERM (Signal.Catch (runInIO (termHandler mainThreadId))) Nothing+    _ <- Signal.installHandler Signal.sigINT (Signal.Catch (runInIO (intHandler mainThreadId interruptRef))) Nothing++    return ()   where-    handler = do-      CNix.Util.triggerInterrupt-      stopIO daemon+    -- SIGTERM: Trigger immediate shutdown+    termHandler :: ThreadId -> Daemon ()+    termHandler mainThreadId = do+      Katip.logFM Katip.InfoS "sigTERM received. Exiting immediately..."+      startExitTimer mainThreadId+      liftIO CNix.Util.triggerInterrupt+      eventLoop <- asks daemonEventLoop+      -- Signal directly to the event loop to ensure exit even if queue is full+      EventLoop.exitLoopWithFailure EventLoopClosed eventLoop +    -- SIGINT: First try to shutdown gracefully, on second press force exit+    intHandler :: ThreadId -> IORef Bool -> Daemon ()+    intHandler mainThreadId interruptRef = do+      liftIO CNix.Util.triggerInterrupt+      isSecondInterrupt <- liftIO $ atomicModifyIORef' interruptRef (True,)+      eventLoop <- asks daemonEventLoop++      if isSecondInterrupt+        then do+          Katip.logFM Katip.InfoS "Exiting immediately..."+          startExitTimer mainThreadId+          -- Force shutdown at the event loop level to ensure exit even if queue is full+          EventLoop.exitLoopWithFailure EventLoopClosed eventLoop+        else do+          Katip.logFM Katip.InfoS "Shutting down gracefully (Ctrl+C again to force exit)..."+          EventLoop.send eventLoop ShutdownGracefully++    -- Start a timer to ensure we exit even if the event loop hangs+    startExitTimer :: ThreadId -> Daemon ()+    startExitTimer mainThreadId = do+      void $ liftIO $ forkIO $ do+        threadDelay (15 * 1000 * 1000) -- 15 seconds+        throwTo mainThreadId ExitSuccess+ queueJob :: Protocol.PushRequest -> Daemon () queueJob pushRequest = do   daemonPushManager <- asks daemonPushManager@@ -207,9 +244,6 @@ shutdownGracefully :: Daemon (Either DaemonError ()) shutdownGracefully = do   DaemonEnv {..} <- ask--  -- Indicate that the daemon is shutting down-  initiateShutdown daemonShutdownLatch    -- Stop the push manager and wait for any remaining paths to be uploaded   shutdownPushManager daemonPushManager
src/Cachix/Daemon/EventLoop.hs view
@@ -4,11 +4,14 @@     sendIO,     run,     exitLoopWith,+    exitLoopWithFailure,     EventLoop,   ) where +import Cachix.Daemon.ShutdownLatch qualified as ShutdownLatch import Cachix.Daemon.Types.EventLoop (EventLoop (..), EventLoopError (..))+import Control.Concurrent.STM import Control.Concurrent.STM.TBMQueue   ( isFullTBMQueue,     newTBMQueueIO,@@ -21,9 +24,9 @@  new :: (MonadIO m) => m (EventLoop event a) new = do-  exitLatch <- liftIO newEmptyMVar-  queue <- liftIO $ newTBMQueueIO 100-  return $ EventLoop {queue, exitLatch}+  shutdownLatch <- ShutdownLatch.newShutdownLatch+  queue <- liftIO $ newTBMQueueIO 100_000+  return $ EventLoop {queue, shutdownLatch}  -- | Send an event to the event loop with logging. send :: (Katip.KatipContext m) => EventLoop event a -> event -> m ()@@ -38,41 +41,67 @@     logger _ _ = return ()  send' :: (MonadIO m) => (Katip.Severity -> Katip.LogStr -> m ()) -> EventLoop event a -> event -> m ()-send' logger eventloop@(EventLoop {queue}) event = do-  res <- liftIO $ atomically $ tryWriteTBMQueue queue event-  case res of-    -- The queue is closed.-    Nothing ->-      logger Katip.DebugS "Ignored an event because the event loop is closed"-    -- Successfully wrote to the queue-    Just True -> return ()-    -- Failed to write to the queue-    Just False -> do-      isFull <- liftIO $ atomically $ isFullTBMQueue queue-      let message =-            if isFull-              then "Event loop is full"-              else "Unknown error"-      logger Katip.ErrorS $ "Failed to write to event loop: " <> message-      exitLoopWithFailure EventLoopFull eventloop+send' logger eventloop@(EventLoop {queue, shutdownLatch}) event = do+  -- First check if shutdown has been requested+  isExiting <- ShutdownLatch.isShuttingDown shutdownLatch+  if isExiting+    then logger Katip.DebugS "Ignored an event because the event loop is shutting down"+    else do+      res <- liftIO $ atomically $ tryWriteTBMQueue queue event+      case res of+        -- The queue is closed.+        Nothing ->+          logger Katip.DebugS "Ignored an event because the event loop is closed"+        -- Successfully wrote to the queue+        Just True -> return ()+        -- Failed to write to the queue+        Just False -> do+          isFull <- liftIO $ atomically $ isFullTBMQueue queue+          let message =+                if isFull+                  then "Event loop is full"+                  else "Unknown error"+          logger Katip.ErrorS $ "Failed to write to event loop: " <> message+          exitLoopWithFailure EventLoopFull eventloop  -- | Run the event loop until it exits with 'exitLoopWith'. run :: (MonadIO m) => EventLoop event a -> (event -> m ()) -> m (Either EventLoopError a)-run eventloop f = do+run (EventLoop {queue, shutdownLatch}) f =   fix $ \loop -> do-    mevent <- liftIO $ atomically $ readTBMQueue (queue eventloop)-    case mevent of-      Just event -> f event-      Nothing -> exitLoopWithFailure EventLoopClosed eventloop+    -- Wait for either a shutdown signal or a message from the queue+    eitherResult <-+      liftIO $+        atomically $+          fmap Left (ShutdownLatch.waitForShutdownSTM shutdownLatch)+            `orElse`+            -- Try to read from queue+            ( do+                mevent <- readTBMQueue queue+                case mevent of+                  -- Got an event, return it+                  Just event -> return $ Right event+                  -- Queue is closed, signal shutdown+                  Nothing -> do+                    ShutdownLatch.initiateShutdownWithResultSTM (Left EventLoopClosed) shutdownLatch+                    result <- ShutdownLatch.waitForShutdownSTM shutdownLatch+                    return $ Left result+            ) -    liftIO (tryReadMVar (exitLatch eventloop)) >>= \case-      Just exitValue -> return exitValue-      Nothing -> loop+    -- Process the result+    case eitherResult of+      -- Shutdown requested, return the result+      Left result -> return result+      -- Got an event, process it and continue looping+      Right event -> do+        f event+        loop  -- | Short-circuit the event loop and exit with a given return value. exitLoopWith :: (MonadIO m) => a -> EventLoop event a -> m ()-exitLoopWith exitValue (EventLoop {exitLatch}) = void $ liftIO $ tryPutMVar exitLatch (Right exitValue)+exitLoopWith exitValue (EventLoop {shutdownLatch}) =+  ShutdownLatch.initiateShutdown exitValue shutdownLatch  -- | Short-circuit the event loop in case of an internal error. exitLoopWithFailure :: (MonadIO m) => EventLoopError -> EventLoop event a -> m ()-exitLoopWithFailure err (EventLoop {exitLatch}) = void $ liftIO $ tryPutMVar exitLatch (Left err)+exitLoopWithFailure err (EventLoop {shutdownLatch}) =+  ShutdownLatch.initiateShutdownWithResult (Left err) shutdownLatch
src/Cachix/Daemon/ShutdownLatch.hs view
@@ -3,24 +3,70 @@     newShutdownLatch,     waitForShutdown,     initiateShutdown,+    initiateShutdownWithResult,+    getResult,     isShuttingDown,+    -- STM operations+    isShuttingDownSTM,+    initiateShutdownSTM,+    initiateShutdownWithResultSTM,+    getResultSTM,+    waitForShutdownSTM,   ) where -import Control.Concurrent.MVar+import Control.Concurrent.STM import Protolude  -- | A latch to keep track of the shutdown process.-newtype ShutdownLatch = ShutdownLatch {unShutdownLatch :: MVar ()}+-- A shutdown latch holds a result value (Either e a) when shutdown is initiated.+-- Nothing means that shutdown has not been requested yet.+newtype ShutdownLatch e a = ShutdownLatch {unShutdownLatch :: TVar (Maybe (Either e a))} -newShutdownLatch :: (MonadIO m) => m ShutdownLatch-newShutdownLatch = ShutdownLatch <$> liftIO newEmptyMVar+-- | Create a new shutdown latch+newShutdownLatch :: (MonadIO m) => m (ShutdownLatch e a)+newShutdownLatch = ShutdownLatch <$> liftIO (newTVarIO Nothing) -waitForShutdown :: (MonadIO m) => ShutdownLatch -> m ()-waitForShutdown = liftIO . readMVar . unShutdownLatch+-- | Block until shutdown is requested and return the result+waitForShutdown :: (MonadIO m) => ShutdownLatch e a -> m (Either e a)+waitForShutdown latch = liftIO $ atomically $ waitForShutdownSTM latch -initiateShutdown :: (MonadIO m) => ShutdownLatch -> m ()-initiateShutdown = void . liftIO . flip tryPutMVar () . unShutdownLatch+-- | Signal shutdown with a "success" result+initiateShutdown :: (MonadIO m) => a -> ShutdownLatch e a -> m ()+initiateShutdown val latch = liftIO $ atomically $ initiateShutdownSTM val latch -isShuttingDown :: (MonadIO m) => ShutdownLatch -> m Bool-isShuttingDown = liftIO . fmap not . isEmptyMVar . unShutdownLatch+-- | Signal shutdown with a specific result+initiateShutdownWithResult :: (MonadIO m) => Either e a -> ShutdownLatch e a -> m ()+initiateShutdownWithResult result latch = liftIO $ atomically $ initiateShutdownWithResultSTM result latch++-- | Get the shutdown result if available+getResult :: (MonadIO m) => ShutdownLatch e a -> m (Maybe (Either e a))+getResult latch = liftIO $ atomically $ getResultSTM latch++-- | Check if shutdown has been requested+isShuttingDown :: (MonadIO m) => ShutdownLatch e a -> m Bool+isShuttingDown latch = liftIO $ atomically $ isShuttingDownSTM latch++-- STM Operations for use in atomic transactions++-- | Check if shutdown is requested+isShuttingDownSTM :: ShutdownLatch e a -> STM Bool+isShuttingDownSTM latch = isJust <$> readTVar (unShutdownLatch latch)++-- | Signal shutdown with a "success" result+initiateShutdownSTM :: a -> ShutdownLatch e a -> STM ()+initiateShutdownSTM val latch = writeTVar (unShutdownLatch latch) (Just (Right val))++-- | Signal shutdown with a specific result+initiateShutdownWithResultSTM :: Either e a -> ShutdownLatch e a -> STM ()+initiateShutdownWithResultSTM result latch = writeTVar (unShutdownLatch latch) (Just result)++-- | Get the shutdown result (if available)+getResultSTM :: ShutdownLatch e a -> STM (Maybe (Either e a))+getResultSTM = readTVar . unShutdownLatch++-- | Block until shutdown is requested, then return the result+waitForShutdownSTM :: ShutdownLatch e a -> STM (Either e a)+waitForShutdownSTM latch = do+  mresult <- readTVar (unShutdownLatch latch)+  maybe retry return mresult
src/Cachix/Daemon/Types.hs view
@@ -9,6 +9,9 @@     DaemonError (..),     HasExitCode (..), +    -- * EventLoop errors+    EventLoop.EventLoopError (..),+     -- * Log     LogLevel (..), @@ -27,6 +30,7 @@  import Cachix.Daemon.Types.Daemon import Cachix.Daemon.Types.Error+import Cachix.Daemon.Types.EventLoop as EventLoop import Cachix.Daemon.Types.Log import Cachix.Daemon.Types.PushEvent as PushEvent import Cachix.Daemon.Types.PushManager as PushManager
src/Cachix/Daemon/Types/Daemon.hs view
@@ -15,7 +15,6 @@ import Cachix.Client.OptionsParser (PushOptions) import Cachix.Daemon.Log qualified as Log import Cachix.Daemon.Protocol qualified as Protocol-import Cachix.Daemon.ShutdownLatch (ShutdownLatch) import Cachix.Daemon.Subscription (SubscriptionManager) import Cachix.Daemon.Types.Error (DaemonError (DaemonUnhandledException), UnhandledException (..)) import Cachix.Daemon.Types.EventLoop (EventLoop)@@ -73,8 +72,6 @@     daemonSubscriptionManagerThread :: MVar (Async ()),     -- | Logging env     daemonLogger :: Logger,-    -- | Shutdown latch-    daemonShutdownLatch :: ShutdownLatch,     -- | The PID of the daemon process     daemonPid :: ProcessID   }
src/Cachix/Daemon/Types/EventLoop.hs view
@@ -1,22 +1,19 @@ module Cachix.Daemon.Types.EventLoop   ( EventLoop (..),     EventLoopError (..),-    ExitLatch,   ) where +import Cachix.Daemon.ShutdownLatch (ShutdownLatch) import Control.Concurrent.STM.TBMQueue (TBMQueue) import Protolude  -- | An event loop that processes a queue of events. data EventLoop event output = EventLoop   { queue :: TBMQueue event,-    exitLatch :: ExitLatch output+    -- | Latch for signaling shutdown and returning results+    shutdownLatch :: ShutdownLatch EventLoopError output   }---- | An exit latch is a semaphore that signals the event loop to exit.--- The exit code should be returned by the 'EventLoop'.-type ExitLatch a = MVar (Either EventLoopError a)  data EventLoopError   = EventLoopClosed
src/Cachix/Deploy/Agent.hs view
@@ -186,11 +186,38 @@             tryToAcquireLock (attempts + 1)  installSignalHandlers :: IO () -> IO ()-installSignalHandlers shutdown =-  for_ [Signals.sigINT, Signals.sigTERM] $ \signal ->-    Signals.installHandler signal handler Nothing-  where-    handler = Signals.CatchOnce shutdown+installSignalHandlers shutdown = do+  mainThreadId <- myThreadId+  -- Track Ctrl+C attempts+  interruptRef <- newIORef False++  let safeShutdown = do+        -- Timeout after 30 seconds to ensure we don't hang+        void $ Timeout.timeout (30 * 1000 * 1000) shutdown++  -- Handle SIGTERM (systemd): always shutdown and exit immediately+  _ <-+    Signals.installHandler+      Signals.sigTERM+      ( Signals.Catch $ do+          safeShutdown+          throwTo mainThreadId ExitSuccess+      )+      Nothing++  -- Handle SIGINT (interactive Ctrl+C): first attempt initiates shutdown, second exits+  _ <-+    Signals.installHandler+      Signals.sigINT+      ( Signals.Catch $ do+          isSecondInterrupt <- liftIO $ atomicModifyIORef' interruptRef (True,)+          if isSecondInterrupt+            then throwTo mainThreadId ExitSuccess+            else safeShutdown+      )+      Nothing++  return ()  registerAgent :: Agent -> WSS.AgentInformation -> IO () registerAgent Agent {agentState, withLog} agentInformation = do
test/NixConfSpec.hs view
@@ -197,19 +197,28 @@         NixConf.write conf         readFile confPath `shouldReturn` confContents -    it "resolves includes" $ do+    -- Test that missing optional includes do not throw errors+    it "resolves required and optional includes" $ do       withTempDirectory "/tmp" "nixconf" $ \temp -> do         let confPath = temp </> "nix.conf"-            subConfPath = temp </> "sub.conf"-            confContents = "include " <> toS subConfPath <> "\n"-            parsedConfContents = NixConf [Include (RequiredInclude (toS subConfPath))]-        writeFile confPath confContents-        writeFile subConfPath realExample+            requiredConfPath = temp </> "required.conf"+            optionalConfPath = temp </> "optional.conf"+            parsedConfContents =+              NixConf+                [ Include (RequiredInclude (toS requiredConfPath)),+                  Include (OptionalInclude (toS optionalConfPath))+                ]+        writeFile confPath $+          unlines+            [ "include " <> toS requiredConfPath <> "\n",+              "!include " <> toS optionalConfPath <> "\n"+            ]+        writeFile requiredConfPath realExample          Just conf <- NixConf.read (Custom temp)         NixConf.resolveIncludes conf           `shouldReturn` [ NixConfSource confPath parsedConfContents,-                           NixConfSource subConfPath parsedRealExample+                           NixConfSource requiredConfPath parsedRealExample                          ]      it "detects cycles" $ do