cachix 1.12.0 → 1.12.1
raw patch · 10 files changed
+416/−52 lines, 10 files
Files
- CHANGELOG.md +11/−0
- cachix.cabal +2/−1
- src/Cachix/Client/Command/Config.hs +16/−2
- src/Cachix/Daemon/PushManager.hs +91/−43
- src/Cachix/Daemon/Types/PushManager.hs +3/−0
- src/Cachix/Deploy/Activate.hs +1/−1
- src/Cachix/Deploy/Websocket.hs +21/−1
- test/CommandConfigSpec.hs +14/−0
- test/Daemon/PushManagerSpec.hs +242/−4
- test/DeploySpec.hs +15/−0
CHANGELOG.md view
@@ -5,6 +5,17 @@ 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). +## [Unreleased]++## [1.12.1] - 2026-08-31++### Fixed++- auth: explain that generating signing keys requires a personal administrator token+- deploy: time out stalled secure connection setup+- deploy: preserve trusted public keys for existing substituters when downloading store paths+- daemon: notify waiting clients when push jobs fail and prevent events after completion+ ## [1.12.0] - 2026-08-10 ### Added
cachix.cabal view
@@ -1,6 +1,6 @@ cabal-version: 2.2 name: cachix-version: 1.12.0+version: 1.12.1 synopsis: Command-line client for Nix binary cache hosting https://cachix.org @@ -249,6 +249,7 @@ main-is: Main.hs hs-source-dirs: test other-modules:+ CommandConfigSpec Daemon.NarinfoQuerySpec Daemon.PostBuildHookSpec Daemon.ProtocolSpec
src/Cachix/Client/Command/Config.hs view
@@ -7,6 +7,7 @@ import Cachix.API.Error import Cachix.Client.Config qualified as Config import Cachix.Client.Env (Env (..))+import Cachix.Client.Exception (CachixException (AccessDeniedBinaryCache)) import Cachix.Client.Retry (retryClientM) import Cachix.Client.Secrets ( SigningKey (SigningKey),@@ -19,6 +20,7 @@ import Data.String.Here import Data.Text qualified as T import Data.Text.IO qualified as T.IO+import Network.HTTP.Types.Status (status403) import Protolude hiding (toS) import Protolude.Conv import Servant.API (NoContent (..))@@ -40,9 +42,12 @@ signingKeyCreate = SigningKeyCreate.SigningKeyCreate (toS $ B64.encode pk) bcc = Config.BinaryCacheConfig name signingKey -- we first validate if key can be added to the binary cache+ createKeyResult <- retryClientM (clientenv env) (API.createKey cachixClient authToken name signingKeyCreate) (_ :: NoContent) <-- escalate- =<< retryClientM (clientenv env) (API.createKey cachixClient authToken name signingKeyCreate)+ case createKeyResult of+ Left err+ | isErr err status403 -> throwIO $ generateKeypairAccessDenied name+ _ -> escalate createKeyResult -- if key was successfully added, write it to the config -- TODO: warn if binary cache with the same key already exists let cfg = config env & Config.setBinaryCaches [bcc]@@ -67,3 +72,12 @@ |] :: Text )++generateKeypairAccessDenied :: Text -> CachixException+generateKeypairAccessDenied name =+ AccessDeniedBinaryCache+ [iTrim|+Cannot create a signing key for binary cache ${name}.++This operation requires a personal auth token belonging to a Cachix account that administers the cache. Per-cache read/write tokens can push and pull, but they can't manage signing keys.+ |]
src/Cachix/Daemon/PushManager.hs view
@@ -20,6 +20,7 @@ -- * Query filterPushJobs, getFailedPushJobs,+ failPushJob, failPendingJobs, -- * Store paths@@ -65,6 +66,7 @@ import Cachix.Types.BinaryCache qualified as BinaryCache import Conduit qualified as C import Control.Concurrent.Async qualified as Async+import Control.Concurrent.MVar qualified as MVar import Control.Concurrent.STM.TVar import Control.Monad.Catch qualified as E import Control.Monad.IO.Unlift (MonadUnliftIO)@@ -93,6 +95,7 @@ pmPushJobs <- newTVarIO mempty pmPendingJobCount <- newTVarIO 0 pmStorePathIndex <- newTVarIO mempty+ pmJobLock <- MVar.newMVar () pmTaskQueue <- atomically newTaskQueue pmTaskSemaphore <- QSem.newQSem (numJobs pushOptions) pmLastEventTimestamp <- newTVarIO =<< getCurrentTime@@ -211,37 +214,42 @@ handleMissingPushJob = Katip.logLocM Katip.ErrorS $ Katip.ls $ "Push job " <> (show pushId :: Text) <> " not found" --- | Apply an update to many push jobs atomically. After the update, a job--- transitions to a terminal state if either the update made it processed--- directly or it leaves 'pushQueue' empty. Returns the jobs that transitioned--- to a terminal state in this call.+-- | Apply an update to many push jobs atomically. Jobs that are already in a+-- terminal state are left untouched. After the update, a job transitions to+-- a terminal state if either the update made it processed directly or it+-- leaves 'pushQueue' empty. Jobs that reach a terminal state are removed from+-- the store path index so that in-flight uploads stop reporting to them.+-- Returns the jobs that were updated; use 'PushJob.isProcessed' to pick out+-- the ones that finished in this call. ----- Mark, completion check, and pending-counter decrement happen in one STM--- transaction so concurrent workers cannot race past the empty-queue check.+-- Mark, completion check, index cleanup, and pending-counter decrement happen+-- in one STM transaction so concurrent workers cannot race past the+-- empty-queue check. applyPushJobUpdates :: (Foldable t) => t Protocol.PushRequestId -> (UTCTime -> PushJob -> PushJob) -> PushManager [PushJob] applyPushJobUpdates pushIds update = do- PushManagerEnv {pmPushJobs, pmPendingJobCount} <- ask+ PushManagerEnv {pmPushJobs, pmPendingJobCount, pmStorePathIndex} <- ask ts <- liftIO getCurrentTime liftIO $ atomically $ do jobs <- readTVar pmPushJobs- let (jobs', finished) = foldl' (step ts) (jobs, []) pushIds+ let (jobs', updated) = foldl' (step ts) (jobs, []) pushIds+ finished = filter PushJob.isProcessed updated writeTVar pmPushJobs jobs' modifyTVar' pmPendingJobCount (subtract (length finished))- pure finished+ unless (null finished) $ do+ let finishedIds = Set.fromList (map PushJob.pushId finished)+ modifyTVar' pmStorePathIndex $ HashMap.map (Seq.filter (`Set.notMember` finishedIds))+ pure updated where step ts (!jobs, acc) pushId = case HashMap.lookup pushId jobs of Just job | not (PushJob.isProcessed job) -> let job' = transitionIfDone ts (update ts job)- jobs' = HashMap.insert pushId job' jobs- in if PushJob.isProcessed job'- then (jobs', job' : acc)- else (jobs', acc)+ in (HashMap.insert pushId job' jobs, job' : acc) _ -> (jobs, acc) transitionIfDone ts job@@ -252,28 +260,43 @@ else PushJob.complete ts job | otherwise = job -failPushJob :: Protocol.PushRequestId -> PushManager ()-failPushJob pushId = void $ applyPushJobUpdates [pushId] PushJob.fail+failPushJob :: Protocol.PushRequestId -> Text -> PushManager ()+failPushJob pushId reason = void $ failPushJobs [pushId] reason --- | Mark every non-terminal job as failed and emit failure events for any--- paths still in their queues. Failed jobs stay in 'pmPushJobs' so a later--- 'getFailedPushJobs' call sees them and the daemon exits with the right--- code; the natural path removes jobs via 'pushFinished'.+-- | Mark every non-terminal job as failed and emit failure events for their+-- remaining paths. failPendingJobs :: Text -> PushManager [PushJob] failPendingJobs reason = do pmPushJobs <- asks pmPushJobs allIds <- HashMap.keys <$> liftIO (readTVarIO pmPushJobs)- failed <- applyPushJobUpdates allIds PushJob.fail+ failPushJobs allIds reason +-- | Mark the given jobs as failed, emitting a 'PushStorePathFailed' event for+-- each path that will no longer be pushed, followed by 'PushFinished' so that+-- subscribers stop waiting. The jobs are dropped from the store path index at+-- the same time, so uploads that are still in flight do not emit any further+-- events for them. Failed jobs stay in 'pmPushJobs' so a later+-- 'getFailedPushJobs' call sees them and the daemon exits with the right+-- code; the natural path removes jobs via 'pushFinished'.+failPushJobs :: (Foldable t) => t Protocol.PushRequestId -> Text -> PushManager [PushJob]+failPushJobs pushIds reason = withJobLock $ do+ failedJobs <- applyPushJobUpdates pushIds PushJob.fail+ ts <- liftIO getCurrentTime sendPushEvent <- asks pmOnPushEvent- for_ failed $ \job -> do+ for_ failedJobs $ \job -> do let pid = PushJob.pushId job- for_ (PushJob.pushQueue job) $ \path ->+ for_ (unpushedPaths job) $ \path -> sendStorePathEventAt ts [pid] (PushStorePathFailed path reason) liftIO $ sendPushEvent pid (PushEvent ts pid PushFinished) - pure failed+ pure failedJobs+ where+ -- A job that fails before closure resolution has an empty queue; fall+ -- back to the requested paths so subscribers still see the failure.+ unpushedPaths job+ | isNothing (PushJob.startedAt job) = Set.fromList $ Protocol.storePaths (pushRequest job)+ | otherwise = PushJob.pushQueue job pendingJobCount :: PushManager Int pendingJobCount = do@@ -316,20 +339,27 @@ countQueuedPaths acc job = acc + fromIntegral (Set.size $ pushQueue job) resolvePushJob :: Protocol.PushRequestId -> PushJob.ResolvedClosure FilePath -> PushManager ()-resolvePushJob pushId closure = do+resolvePushJob pushId closure = withJobLock $ do Katip.logLocM Katip.DebugS $ Katip.ls $ showClosureStats closure - finishedJobs <- applyPushJobUpdates [pushId] (PushJob.populateQueue closure)+ updatedJobs <- applyPushJobUpdates [pushId] (PushJob.populateQueue closure) - withPushJob pushId $ \pushJob -> do+ -- A job that already reached a terminal state (e.g. failed during shutdown)+ -- must not start pushing, or it would emit events after 'PushFinished'.+ when (null updatedJobs) $+ Katip.logLocM Katip.DebugS $+ Katip.ls $+ "Push job " <> (show pushId :: Text) <> " is no longer active, ignoring resolved closure"++ for_ updatedJobs $ \pushJob -> do pushStarted pushJob let skippedPaths = Set.difference (PushJob.rcAllPaths closure) (PushJob.rcMissingPaths closure) ts <- liftIO getCurrentTime forM_ skippedPaths $ \path -> sendStorePathEventAt ts [pushId] (PushStorePathSkipped path) queueStorePaths pushId $ Set.toList (PushJob.rcMissingPaths closure)-- for_ finishedJobs pushFinished+ when (PushJob.isProcessed pushJob) $+ pushFinished pushJob where showClosureStats :: PushJob.ResolvedClosure FilePath -> Text showClosureStats PushJob.ResolvedClosure {..} =@@ -362,7 +392,7 @@ where failJob :: SomeException -> PushManager () failJob err = do- failPushJob pushId+ failPushJob pushId $ "Failed to resolve closure: " <> toS (displayException err) Katip.katipAddContext (Katip.sl "error" (displayException err)) $ Katip.logLocM Katip.ErrorS $@@ -385,7 +415,7 @@ -- Emit PushStorePathInvalid events for invalid paths ts <- liftIO getCurrentTime forM_ errors $ \(path, err) ->- sendStorePathEventAt ts [pushId] (PushStorePathInvalid path (formatStorePathError err))+ sendStorePathEventForActiveJobsAt ts [pushId] (PushStorePathInvalid path (formatStorePathError err)) paths <- computeClosure store validPaths @@ -399,7 +429,7 @@ where failJob :: SomeException -> PushManager () failJob err = do- failPushJob pushId+ failPushJob pushId $ "Failed to query missing paths: " <> toS (displayException err) Katip.katipAddContext (Katip.sl "error" (displayException err)) $ Katip.logLocM Katip.ErrorS $@@ -567,33 +597,51 @@ sendPushEvent pushId (PushEvent timestamp pushId msg) pushStorePathAttempt :: FilePath -> Int64 -> RetryStatus -> PushManager ()-pushStorePathAttempt storePath size retryStatus = do+pushStorePathAttempt storePath size retryStatus = withJobLock $ do let pushRetryStatus = newPushRetryStatus retryStatus pushIds <- lookupStorePathIndex storePath sendStorePathEvent pushIds (PushStorePathAttempt storePath size pushRetryStatus) pushStorePathProgress :: FilePath -> Int64 -> Int64 -> PushManager ()-pushStorePathProgress storePath currentBytes newBytes = do+pushStorePathProgress storePath currentBytes newBytes = withJobLock $ do pushIds <- lookupStorePathIndex storePath sendStorePathEvent pushIds (PushStorePathProgress storePath currentBytes newBytes) pushStorePathDone :: FilePath -> PushManager ()-pushStorePathDone storePath = do- pushIds <- lookupStorePathIndex storePath- finishedJobs <- applyPushJobUpdates pushIds (\_ -> PushJob.markStorePathPushed storePath)- sendStorePathEvent pushIds (PushStorePathDone storePath)- for_ finishedJobs pushFinished- removeStorePath storePath+pushStorePathDone storePath =+ finishStorePath storePath PushJob.markStorePathPushed (PushStorePathDone storePath) pushStorePathFailed :: FilePath -> Text -> PushManager ()-pushStorePathFailed storePath errMsg = do+pushStorePathFailed storePath errMsg =+ finishStorePath storePath PushJob.markStorePathFailed (PushStorePathFailed storePath errMsg)++-- | Record the outcome of a store path push on every job that is still+-- waiting for it, then emit the event to those jobs only. Jobs that already+-- reached a terminal state (e.g. failed during shutdown) receive nothing, so+-- 'PushFinished' stays the last event a subscriber sees.+finishStorePath :: FilePath -> (FilePath -> PushJob -> PushJob) -> PushEventMessage -> PushManager ()+finishStorePath storePath markStorePath msg = withJobLock $ do pushIds <- lookupStorePathIndex storePath- finishedJobs <- applyPushJobUpdates pushIds (\_ -> PushJob.markStorePathFailed storePath)- sendStorePathEvent pushIds (PushStorePathFailed storePath errMsg)- for_ finishedJobs pushFinished+ updatedJobs <- applyPushJobUpdates pushIds (\_ -> markStorePath storePath)+ sendStorePathEvent (map PushJob.pushId updatedJobs) msg+ for_ (filter PushJob.isProcessed updatedJobs) pushFinished removeStorePath storePath -- Helpers++-- | Run a job operation without interleaving its state changes and events with+-- another job operation. STM keeps the internal state consistent; this lock+-- also keeps the externally visible event stream consistent with that state.+withJobLock :: PushManager a -> PushManager a+withJobLock action = do+ env@PushManagerEnv {pmJobLock} <- ask+ liftIO $ MVar.withMVar pmJobLock $ \_ -> runPushManager env action++sendStorePathEventForActiveJobsAt :: (Foldable f) => UTCTime -> f Protocol.PushRequestId -> PushEventMessage -> PushManager ()+sendStorePathEventForActiveJobsAt timestamp pushIds msg = withJobLock $ do+ pushJobs <- asks pmPushJobs >>= liftIO . readTVarIO+ let isActive pushId = maybe False (not . PushJob.isProcessed) $ HashMap.lookup pushId pushJobs+ sendStorePathEventAt timestamp (filter isActive $ toList pushIds) msg storeToFilePath :: (MonadIO m) => Store -> StorePath -> m FilePath storeToFilePath store storePath = do
src/Cachix/Daemon/Types/PushManager.hs view
@@ -78,6 +78,9 @@ pmProgressEmitIntervalNs :: Word64, -- | The number of pending (uncompleted) jobs. pmPendingJobCount :: TVar Int,+ -- | Serializes job transitions with their externally visible events and+ -- store path index updates.+ pmJobLock :: MVar (), -- | Manager for batching narinfo queries pmNarinfoQueryManager :: NarinfoQueryManager Protocol.PushRequestId, -- | Latch to coordinate graceful shutdown of the push pipeline
src/Cachix/Deploy/Activate.hs view
@@ -208,7 +208,7 @@ officialCache = "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" substituters = ["--option", "extra-substituters", URI.serialize cacheURI] noNegativeCaching = ["--option", "narinfo-cache-negative-ttl", "0"]- sigs = ["--option", "trusted-public-keys", officialCache <> " " <> toS hostname <> "-1:" <> toS (WSS.publicKey cache)]+ sigs = ["--option", "extra-trusted-public-keys", officialCache <> " " <> toS hostname <> "-1:" <> toS (WSS.publicKey cache)] in substituters ++ sigs ++ noNegativeCaching runShell :: Log.LogStream -> FilePath -> [String] -> IO ()
src/Cachix/Deploy/Websocket.hs view
@@ -66,6 +66,11 @@ } deriving (Show) +data WebSocketConnectionTimeout = WebSocketConnectionTimeout+ deriving (Eq, Show)++instance Exception WebSocketConnectionTimeout+ -- | A more ergonomic version of the Websocket 'Message' data type data Message msg = ControlMessage WS.ControlMessage@@ -174,10 +179,25 @@ runClientWith :: Options -> WS.Connection.ConnectionOptions -> WS.ClientApp a -> IO a runClientWith Options {host, port, path, headers, useSSL} connectionOptions app = if useSSL- then Wuss.runSecureClientWith hostS (fromIntegral (URI.portNumber port)) (toS path) connectionOptions headers app+ then+ withConnectionTimeout+ timeoutMicroseconds+ (Wuss.newSecureClientConnectionWith hostS (fromIntegral (URI.portNumber port)) (toS path) connectionOptions headers)+ app else WS.runClientWith hostS (URI.portNumber port) (toS path) connectionOptions headers app where hostS = toS (URI.hostBS host)+ timeoutMicroseconds = WS.Connection.connectionTimeout connectionOptions * 1000 * 1000++-- | Bound connection establishment without applying the timeout to the+-- long-running client application. The acquisition action returns its cleanup+-- action so that a successfully established connection is always closed.+withConnectionTimeout :: Int -> IO (a, IO ()) -> (a -> IO b) -> IO b+withConnectionTimeout timeoutMicroseconds acquire app = do+ connection <- Timeout.timeout timeoutMicroseconds acquire+ case connection of+ Nothing -> throwIO WebSocketConnectionTimeout+ Just (resource, close) -> Safe.bracket (pure resource) (const close) app -- Handle JSON messages
+ test/CommandConfigSpec.hs view
@@ -0,0 +1,14 @@+module CommandConfigSpec (spec) where++import Cachix.Client.Command.Config (generateKeypairAccessDenied)+import Protolude+import Test.Hspec++spec :: Spec+spec =+ describe "generateKeypairAccessDenied" $ do+ it "explains that signing-key creation requires a personal administrator token" $ do+ let message = displayException $ generateKeypairAccessDenied "corestory"+ message `shouldContain` "Cannot create a signing key for binary cache corestory."+ message `shouldContain` "requires a personal auth token"+ message `shouldContain` "Per-cache read/write tokens"
test/Daemon/PushManagerSpec.hs view
@@ -9,10 +9,12 @@ import Cachix.Daemon.Push qualified as Daemon.Push import Cachix.Daemon.PushManager import Cachix.Daemon.PushManager.PushJob qualified as PushJob+import Cachix.Daemon.Types.PushEvent (PushEvent (..), PushEventMessage (..)) import Cachix.Daemon.Types.PushManager import Cachix.Types.BinaryCache qualified as BinaryCache import Cachix.Types.Permission (Permission (Write))-import Control.Concurrent.Async (concurrently_)+import Control.Concurrent.Async qualified as Async+import Control.Concurrent.MVar qualified as MVar import Control.Concurrent.STM.TVar import Control.Monad (fail) import Control.Retry (defaultRetryStatus)@@ -22,6 +24,7 @@ import Protolude import Servant.Auth.Client (Token (Token)) import System.IO.Temp (withSystemTempDirectory)+import System.Timeout qualified as Timeout import Test.Hspec instance MonadFail PushManager where@@ -147,6 +150,223 @@ prSkippedPaths = mempty } + describe "failing jobs" $ do+ it "notifies subscribers when a job fails before closure resolution" $ do+ events <- newTVarIO []+ withPushManagerOnEvent (recordEvents events) $ \pm -> do+ runPushManager pm $ do+ let request = Protocol.PushRequest {Protocol.storePaths = ["foo", "bar"], Protocol.subscribeToUpdates = True}+ Just pushId <- addPushJobFromRequest request+ failPushJob pushId "closure resolution failed"++ withPushJob pushId $ \pushJob ->+ liftIO $ PushJob.status pushJob `shouldBe` Failed++ messages <- map eventMessage . reverse <$> readTVarIO events+ messages+ `shouldBe` [ PushStorePathFailed "bar" "closure resolution failed",+ PushStorePathFailed "foo" "closure resolution failed",+ PushFinished+ ]++ it "does not emit further events for failed jobs on shutdown" $ do+ events <- newTVarIO []+ withPushManagerOnEvent (recordEvents events) $ \pm -> do+ runPushManager pm $ do+ let request = Protocol.PushRequest {Protocol.storePaths = ["foo"], Protocol.subscribeToUpdates = True}+ Just pushId <- addPushJobFromRequest request+ failPushJob pushId "closure resolution failed"++ eventsAfterFailure <- readTVarIO events+ failed <- runPushManager pm $ failPendingJobs "daemon stopped"+ length failed `shouldBe` 0+ eventsAfterShutdown <- readTVarIO events+ eventsAfterShutdown `shouldBe` eventsAfterFailure++ it "only reports paths that were not pushed when failing a resolved job" $ do+ events <- newTVarIO []+ withPushManagerOnEvent (recordEvents events) $ \pm -> do+ runPushManager pm $ do+ let paths = ["bar", "foo"]+ pathSet = Set.fromList paths+ let request = Protocol.PushRequest {Protocol.storePaths = paths, Protocol.subscribeToUpdates = True}+ Just pushId <- addPushJobFromRequest request+ resolvePushJob pushId (PushJob.ResolvedClosure pathSet pathSet)+ pushStorePathDone "bar"+ void $ failPendingJobs "daemon stopped"++ messages <- map eventMessage . reverse <$> readTVarIO events+ messages+ `shouldBe` [ PushStarted,+ PushStorePathDone "bar",+ PushStorePathFailed "foo" "daemon stopped",+ PushFinished+ ]++ it "does not emit events for a failed job when its in-flight paths finish" $ do+ events <- newTVarIO []+ withPushManagerOnEvent (recordEvents events) $ \pm -> do+ runPushManager pm $ do+ let paths = ["bar", "foo"]+ pathSet = Set.fromList paths+ let request = Protocol.PushRequest {Protocol.storePaths = paths, Protocol.subscribeToUpdates = True}+ Just pushId <- addPushJobFromRequest request+ resolvePushJob pushId (PushJob.ResolvedClosure pathSet pathSet)+ failPushJob pushId "daemon stopped"++ eventsAfterFailure <- readTVarIO events+ lastMay (map eventMessage (reverse eventsAfterFailure)) `shouldBe` Just PushFinished++ -- The uploads that were in flight when the job failed report back.+ runPushManager pm $ do+ pushStorePathAttempt "foo" 1 defaultRetryStatus+ pushStorePathProgress "foo" 1 1+ pushStorePathDone "foo"+ pushStorePathFailed "bar" "upload failed"++ eventsAfterUploads <- readTVarIO events+ eventsAfterUploads `shouldBe` eventsAfterFailure++ it "ignores closure resolution for a job that already failed" $ do+ events <- newTVarIO []+ withPushManagerOnEvent (recordEvents events) $ \pm -> do+ pushId <- runPushManager pm $ do+ let request = Protocol.PushRequest {Protocol.storePaths = ["foo"], Protocol.subscribeToUpdates = True}+ Just pushId <- addPushJobFromRequest request+ failPushJob pushId "daemon stopped"+ pure pushId++ eventsAfterFailure <- readTVarIO events+ lastMay (map eventMessage (reverse eventsAfterFailure)) `shouldBe` Just PushFinished++ runPushManager pm $ do+ let pathSet = Set.fromList ["foo"]+ resolvePushJob pushId (PushJob.ResolvedClosure pathSet pathSet)+ pushStorePathDone "foo"++ withPushJob pushId $ \pushJob ->+ liftIO $ PushJob.status pushJob `shouldBe` Failed+ count <- pendingJobCount+ liftIO $ count `shouldBe` 0++ eventsAfterResolve <- readTVarIO events+ eventsAfterResolve `shouldBe` eventsAfterFailure++ it "serializes closure resolution with failure" $ do+ events <- newTVarIO []+ startedEvent <- MVar.newEmptyMVar+ releaseStartedEvent <- MVar.newEmptyMVar+ let onEvent pushId event = do+ recordEvents events pushId event+ when (eventMessage event == PushStarted) $ do+ MVar.putMVar startedEvent ()+ MVar.takeMVar releaseStartedEvent++ withPushManagerOnEvent onEvent $ \pm -> do+ pushId <- runPushManager pm $ do+ let request = Protocol.PushRequest {Protocol.storePaths = ["foo"], Protocol.subscribeToUpdates = True}+ Just pushId <- addPushJobFromRequest request+ pure pushId++ let pathSet = Set.singleton "foo"+ resolve = runPushManager pm $ resolvePushJob pushId (PushJob.ResolvedClosure pathSet pathSet)+ failJob = runPushManager pm $ failPushJob pushId "daemon stopped"+ Async.withAsync resolve $ \resolveThread -> do+ MVar.takeMVar startedEvent+ failureInvoked <- MVar.newEmptyMVar+ Async.withAsync (MVar.putMVar failureInvoked () >> failJob) $ \failureThread -> do+ MVar.takeMVar failureInvoked+ assertStillRunning failureThread+ MVar.putMVar releaseStartedEvent ()+ Async.wait resolveThread+ Async.wait failureThread++ runPushManager pm $ do+ pushStorePathAttempt "foo" 1 defaultRetryStatus+ pushStorePathProgress "foo" 1 1+ pushStorePathDone "foo"++ messages <- map eventMessage . reverse <$> readTVarIO events+ messages+ `shouldBe` [ PushStarted,+ PushStorePathFailed "foo" "daemon stopped",+ PushFinished+ ]++ it "serializes in-flight progress events with failure" $ do+ events <- newTVarIO []+ progressEvent <- MVar.newEmptyMVar+ releaseProgressEvent <- MVar.newEmptyMVar+ let onEvent pushId event = do+ recordEvents events pushId event+ when (isProgressEvent event) $ do+ MVar.putMVar progressEvent ()+ MVar.takeMVar releaseProgressEvent++ withPushManagerOnEvent onEvent $ \pm -> do+ pushId <- runPushManager pm $ do+ let request = Protocol.PushRequest {Protocol.storePaths = ["foo"], Protocol.subscribeToUpdates = True}+ pathSet = Set.singleton "foo"+ Just pushId <- addPushJobFromRequest request+ resolvePushJob pushId (PushJob.ResolvedClosure pathSet pathSet)+ pure pushId++ let failJob = runPushManager pm $ failPushJob pushId "daemon stopped"+ Async.withAsync (runPushManager pm $ pushStorePathProgress "foo" 1 1) $ \progressThread -> do+ MVar.takeMVar progressEvent+ failureInvoked <- MVar.newEmptyMVar+ Async.withAsync (MVar.putMVar failureInvoked () >> failJob) $ \failureThread -> do+ MVar.takeMVar failureInvoked+ assertStillRunning failureThread+ MVar.putMVar releaseProgressEvent ()+ Async.wait progressThread+ Async.wait failureThread++ runPushManager pm $ do+ pushStorePathAttempt "foo" 1 defaultRetryStatus+ pushStorePathDone "foo"++ messages <- map eventMessage . reverse <$> readTVarIO events+ messages+ `shouldBe` [ PushStarted,+ PushStorePathProgress "foo" 1 1,+ PushStorePathFailed "foo" "daemon stopped",+ PushFinished+ ]++ it "keeps shared paths active for jobs that have not failed" $ do+ events <- newTVarIO []+ withPushManagerOnEvent (recordEvents events) $ \pm -> do+ (failedId, completedId) <- runPushManager pm $ do+ let request = Protocol.PushRequest {Protocol.storePaths = ["foo"], Protocol.subscribeToUpdates = True}+ pathSet = Set.singleton "foo"+ closure = PushJob.ResolvedClosure pathSet pathSet+ Just failedId <- addPushJobFromRequest request+ Just completedId <- addPushJobFromRequest request+ resolvePushJob failedId closure+ resolvePushJob completedId closure+ failPushJob failedId "daemon stopped"+ pushStorePathDone "foo"+ pure (failedId, completedId)++ recordedEvents <- reverse <$> readTVarIO events+ messagesFor failedId recordedEvents+ `shouldBe` [ PushStarted,+ PushStorePathFailed "foo" "daemon stopped",+ PushFinished+ ]+ messagesFor completedId recordedEvents+ `shouldBe` [ PushStarted,+ PushStorePathDone "foo",+ PushFinished+ ]++ failedJob <- runPushManager pm $ lookupPushJob failedId+ PushJob.status <$> failedJob `shouldBe` Just Failed+ completedJob <- runPushManager pm $ lookupPushJob completedId+ completedJob `shouldBe` Nothing+ runPushManager pm pendingJobCount `shouldReturn` 0+ describe "graceful shutdown" $ do it "shuts down with no jobs" $ withPushManager $ \pm -> do@@ -166,7 +386,7 @@ return pushId startTime <- getCurrentTime- concurrently_ (drainPushManager longTimeoutOptions pm) $+ Async.concurrently_ (drainPushManager longTimeoutOptions pm) $ runPushManager pm $ for_ paths pushStorePathDone endTime <- getCurrentTime@@ -192,7 +412,10 @@ result `shouldBe` False withPushManager :: (PushManagerEnv -> IO a) -> IO a-withPushManager f = do+withPushManager = withPushManagerOnEvent mempty++withPushManagerOnEvent :: OnPushEvent -> (PushManagerEnv -> IO a) -> IO a+withPushManagerOnEvent onPushEvent f = do CNix.init withTempStore $ \store -> do logger <- liftIO $ Log.new "daemon" Nothing Log.Debug@@ -203,7 +426,22 @@ pushOptions = defaultPushOptions batchOptions = defaultNarinfoQueryOptions pushParams = Daemon.Push.newPushParams store clientEnv binaryCache pushSecret pushOptions- newPushManagerEnv pushOptions batchOptions pushParams mempty logger >>= f+ newPushManagerEnv pushOptions batchOptions pushParams onPushEvent logger >>= f++recordEvents :: TVar [PushEvent] -> OnPushEvent+recordEvents events _ event = atomically $ modifyTVar' events (event :)++assertStillRunning :: Async.Async () -> IO ()+assertStillRunning thread = do+ result <- Timeout.timeout 100000 $ Async.wait thread+ result `shouldBe` Nothing++isProgressEvent :: PushEvent -> Bool+isProgressEvent PushEvent {eventMessage = PushStorePathProgress {}} = True+isProgressEvent _ = False++messagesFor :: Protocol.PushRequestId -> [PushEvent] -> [PushEventMessage]+messagesFor pushId = map eventMessage . filter ((== pushId) . eventPushId) inPushManager :: PushManager a -> IO a inPushManager f = withPushManager (`runPushManager` f)
test/DeploySpec.hs view
@@ -5,6 +5,8 @@ import Cachix.Deploy.Lock (withTryLock, withTryLockAndPid) import Cachix.Deploy.Log qualified as Log import Cachix.Deploy.OptionsParser qualified as CLI+import Cachix.Deploy.Websocket qualified as WebSocket+import Control.Concurrent.MVar qualified as MVar import Control.Retry qualified as Retry import Protolude import System.IO.Temp (withSystemTempDirectory)@@ -33,6 +35,19 @@ void $ withTryLockAndPid (lockFile agent) (pidFile agent) $ do mpid <- waitForAgent retryPolicy agent mpid `shouldSatisfy` isJust++ describe "WebSocket connection timeout" $ do+ it "interrupts stalled connection establishment" $ do+ let stalledConnection = do+ void (MVar.newEmptyMVar >>= MVar.takeMVar :: IO ())+ pure ((), pure ())+ WebSocket.withConnectionTimeout (10 * 1000) stalledConnection pure+ `shouldThrow` (== WebSocket.WebSocketConnectionTimeout)++ it "closes an established connection after the client exits" $ do+ closed <- MVar.newEmptyMVar+ WebSocket.withConnectionTimeout (10 * 1000) (pure ((), MVar.putMVar closed ())) pure+ MVar.tryTakeMVar closed `shouldReturn` Just () withTestAgent :: FilePath -> (Agent -> IO ()) -> IO () withTestAgent tempDir action = do