cachix 1.7.8 → 1.7.9
raw patch · 11 files changed
+305/−101 lines, 11 files
Files
- CHANGELOG.md +13/−0
- cachix.cabal +1/−1
- src/Cachix/Client/NixConf.hs +73/−25
- src/Cachix/Daemon.hs +48/−14
- src/Cachix/Daemon/EventLoop.hs +59/−30
- src/Cachix/Daemon/ShutdownLatch.hs +56/−10
- src/Cachix/Daemon/Types.hs +4/−0
- src/Cachix/Daemon/Types/Daemon.hs +0/−3
- src/Cachix/Daemon/Types/EventLoop.hs +3/−6
- src/Cachix/Deploy/Agent.hs +32/−5
- test/NixConfSpec.hs +16/−7
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