cachix 1.13.0 → 1.14.0
raw patch · 12 files changed
+239/−14 lines, 12 filesdep ~servantdep ~servant-client
Dependency ranges changed: servant, servant-client
Files
- CHANGELOG.md +13/−0
- cachix.cabal +15/−2
- src/Cachix/Client.hs +15/−0
- src/Cachix/Client/Env.hs +3/−2
- src/Cachix/Client/Push.hs +3/−1
- src/Cachix/Client/Retry.hs +2/−2
- src/Cachix/Client/Servant.hs +11/−0
- src/Cachix/Deploy/Websocket.hs +9/−7
- test/ClientSpec.hs +56/−0
- test/EnvSpec.hs +36/−0
- test/ServantSpec.hs +50/−0
- test/StalledServer.hs +26/−0
CHANGELOG.md view
@@ -7,6 +7,19 @@ ## [Unreleased] +## [1.14.0] - 2026-11-05++### Added++- api: Add OpenAPI 3.0 schemas for API payloads, streaming responses, and URL parameters.++### Fixed++- client: time out stalled connections and responses, including TLS handshakes+- client: handle SIGINT, SIGTERM and SIGHUP during commands and daemon startup+- push: preserve unlimited time for multipart upload completion with finite default HTTP timeouts+- deploy: report HTTP WebSocket handshake rejections+ ## [1.13.0] - 2026-10-21 ### Added
cachix.cabal view
@@ -1,6 +1,6 @@ cabal-version: 2.2 name: cachix-version: 1.13.0+version: 1.14.0 synopsis: Command-line client for Nix binary cache hosting https://cachix.org @@ -267,6 +267,7 @@ main-is: Main.hs hs-source-dirs: test other-modules:+ ClientSpec CommandConfigSpec CommandListSpec Daemon.NarinfoQuerySpec@@ -276,12 +277,15 @@ Daemon.TaskQueueSpec Daemon.TTLCacheSpec DeploySpec+ EnvSpec InstallationModeSpec NetRcSpec NixConfSpec NixVersionSpec RetrySpec+ ServantSpec Spec+ StalledServer TerminalSpec URISpec @@ -299,17 +303,26 @@ , hercules-ci-cnix-store , here , hspec+ , http-client+ , http-client-tls , http-types , katip+ , network+ , process , protolude , retry+ , servant , servant-auth-client+ , servant-client , servant-client-core , stm , temporary , time+ , unix , unliftio , uuid , versions - build-tool-depends: hspec-discover:hspec-discover+ build-tool-depends:+ , cachix:cachix+ , hspec-discover:hspec-discover
src/Cachix/Client.hs view
@@ -21,6 +21,7 @@ import Cachix.Deploy.Agent qualified as AgentCommand import Cachix.Deploy.OptionsParser qualified as DeployOptions import Hercules.CNix qualified as CNix+import Hercules.CNix.Util qualified as CNix.Util import Protolude import System.Console.AsciiProgress (displayConsoleRegions) import System.Posix.Signals qualified as Signal@@ -62,6 +63,20 @@ -- | Install client-wide signal handlers. installSignalHandlers :: IO () installSignalHandlers = do+ -- CNix drops its Haskell interrupt callback. Interrupt both Nix and the+ -- command thread so signals terminate work in either runtime.+ mainThreadId <- myThreadId+ for_ [Signal.sigINT, Signal.sigTERM, Signal.sigHUP] $ \signal ->+ Signal.installHandler+ signal+ ( Signal.Catch $ do+ CNix.Util.triggerInterrupt+ -- Use an asynchronous exception so HTTP clients do not wrap it as+ -- a connection failure and retry the interrupted request.+ throwTo mainThreadId UserInterrupt+ )+ Nothing+ -- Ignore sigPIPE. -- The default handler terminates the process. -- WARN: may be reset to SIG_DFL when initializing the Nix library.
src/Cachix/Client/Env.hs view
@@ -17,7 +17,7 @@ ( ManagerSettings, managerModifyRequest, managerResponseTimeout,- responseTimeoutNone,+ responseTimeoutMicro, ) import Network.HTTP.Client.TLS (newTlsManagerWith, tlsManagerSettings) import Network.HTTP.Simple (setRequestHeader)@@ -60,7 +60,8 @@ customManagerSettings :: ManagerSettings customManagerSettings = tlsManagerSettings- { managerResponseTimeout = responseTimeoutNone,+ { -- This also bounds connection acquisition, including the TLS handshake.+ managerResponseTimeout = responseTimeoutMicro (30 * 1_000_000), -- managerModifyRequest :: Request -> IO Request managerModifyRequest = return . setRequestHeader "User-Agent" [toS cachixVersion] }
src/Cachix/Client/Push.hs view
@@ -77,6 +77,7 @@ import Hercules.CNix.Std.Set qualified as Std.Set import Hercules.CNix.Store (Store) import Hercules.CNix.Store qualified as Store+import Network.HTTP.Client (responseTimeoutNone) import Network.HTTP.Types (status401, status404) import Nix.NarInfo qualified as NarInfo import Protolude hiding (toS)@@ -274,7 +275,8 @@ Multipart.narInfoCreate = nic } - liftIO $ void $ retryHttp $ withClientM completeMultipartUploadRequest clientEnv escalate+ -- Assembling a large multipart upload can take several minutes.+ liftIO $ void $ retryHttp $ withClientM (withResponseTimeout responseTimeoutNone completeMultipartUploadRequest) clientEnv escalate data UploadNarDetails = UploadNarDetails { undNarSize :: Integer,
src/Cachix/Client/Retry.hs view
@@ -43,8 +43,8 @@ -- Policies --- | Per-HTTP-request policy. Wall-time bounded so the user-visible delay is--- predictable regardless of how many attempts fit. Jittered exponential+-- | Per-HTTP-request policy. Bounds cumulative backoff delays, excluding time+-- spent executing requests. Jittered exponential -- backoff to avoid thundering-herd on shared origins (CDN incidents). httpRetryPolicy :: (MonadIO m) => RetryPolicyM m httpRetryPolicy =
src/Cachix/Client/Servant.hs view
@@ -7,6 +7,7 @@ cachixClient, deployClientV1, deployClientV2,+ withResponseTimeout, ) where @@ -14,6 +15,7 @@ import Cachix.API.Deploy.V1 qualified as API.Deploy.V1 import Cachix.API.Deploy.V2 qualified as API.Deploy.V2 import Cachix.Types.ContentTypes ()+import Network.HTTP.Client qualified as HTTP import Network.HTTP.Types (Status) import Protolude import Servant.API.Generic@@ -21,6 +23,15 @@ import Servant.Client qualified import Servant.Client.Streaming import Servant.Conduit ()++-- | Override the timeout only for requests made by this client action.+withResponseTimeout :: HTTP.ResponseTimeout -> ClientM a -> ClientM a+withResponseTimeout responseTimeout = local $ \env ->+ env+ { makeClientRequest = \url request -> do+ httpRequest <- makeClientRequest env url request+ pure httpRequest {HTTP.responseTimeout = responseTimeout}+ } isErr :: ClientError -> Status -> Bool isErr (Servant.Client.FailureResponse _ resp) status
src/Cachix/Deploy/Websocket.hs view
@@ -3,6 +3,7 @@ -- | A high-level, multiple reader, single writer interface for Websocket clients. module Cachix.Deploy.Websocket where +import Cachix.API.WebSocket.Client qualified as WebSocketClient import Cachix.Client.Retry qualified as Retry import Cachix.Client.URI qualified as URI import Cachix.Client.Version (versionNumber)@@ -178,13 +179,14 @@ runClientWith :: Options -> WS.Connection.ConnectionOptions -> WS.ClientApp a -> IO a runClientWith Options {host, port, path, headers, useSSL} connectionOptions app =- if useSSL- 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+ WebSocketClient.withHandshakeErrors $+ if useSSL+ 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
+ test/ClientSpec.hs view
@@ -0,0 +1,56 @@+module ClientSpec (spec) where++import Protolude+import StalledServer (withStalledServer)+import System.Environment (getEnvironment)+import System.IO.Temp (withSystemTempDirectory)+import System.Posix.Signals (Signal, sigHUP, sigINT, sigKILL, sigTERM, signalProcess)+import System.Process qualified as Process+import System.Timeout (timeout)+import Test.Hspec++spec :: Spec+spec = describe "signals during stalled requests" $+ forM_ [("SIGINT", sigINT), ("SIGTERM", sigTERM), ("SIGHUP", sigHUP)] $ \(name, signal) ->+ describe name $+ forM_ ["http", "https"] $ \scheme -> do+ it (toS $ "stops cachix use over " <> scheme) $+ terminatesOnSignal signal scheme ["use", "somecache"]+ it (toS $ "stops daemon startup over " <> scheme) $+ terminatesOnSignal signal scheme ["daemon", "run", "somecache"]++terminatesOnSignal :: Signal -> Text -> [Text] -> IO ()+terminatesOnSignal signal scheme command =+ withSystemTempDirectory "cachix-signal-test" $ \dir -> withFile (dir <> "/client.log") WriteMode $ \output ->+ withStalledServer $ \port connected -> do+ environment <- getEnvironment+ let overrides =+ [ ("CACHIX_AUTH_TOKEN", "test-token"),+ ("no_proxy", "127.0.0.1"),+ ("NO_PROXY", "127.0.0.1"),+ ("CACHIX_DAEMON_SOCKET", dir <> "/daemon.sock"),+ ("XDG_CONFIG_HOME", dir)+ ]+ args = ["--config", dir <> "/cachix.dhall", "--hostname", toS scheme <> "://127.0.0.1:" <> show port] <> map toS command+ process =+ (Process.proc "cachix" args)+ { Process.env = Just (overrides <> filter (\(key, _) -> key `notElem` ("CACHIX_SIGNING_KEY" : map fst overrides)) environment),+ Process.std_out = Process.UseHandle output,+ Process.std_err = Process.UseHandle output+ }+ bracket (Process.createProcess process) cleanup $ \(_, _, _, processHandle) -> do+ -- Wait for network I/O so the signal tests the initialized client,+ -- rather than racing its startup and signal-handler installation.+ timeout (10 * 1_000_000) connected `shouldReturn` Just ()+ pid <- Process.getPid processHandle >>= maybe (panic "Missing client PID") pure+ signalProcess signal pid+ result <- timeout (5 * 1_000_000) (Process.waitForProcess processHandle)+ result `shouldSatisfy` maybe False (/= ExitSuccess)+ where+ cleanup (_, _, _, processHandle) = do+ Process.getProcessExitCode processHandle >>= \case+ Just _ -> pure ()+ Nothing -> do+ pid <- Process.getPid processHandle+ for_ pid $ signalProcess sigKILL+ void $ Process.waitForProcess processHandle
+ test/EnvSpec.hs view
@@ -0,0 +1,36 @@+module EnvSpec (spec) where++import Cachix.Client.Env (customManagerSettings)+import Network.HTTP.Client qualified as HTTP+import Network.HTTP.Client.TLS (newTlsManagerWith)+import Protolude+import StalledServer (withStalledServer)+import System.Timeout (timeout)+import Test.Hspec++spec :: Spec+spec = parallel $ describe "HTTP manager timeouts" $ do+ it "times out a TLS handshake when the server accepts but never answers" $+ stalledRequest "https" isConnectionTimeout+ it "times out an HTTP response when the server accepts but never answers" $+ stalledRequest "http" isResponseTimeout++stalledRequest :: Text -> (HTTP.HttpException -> Bool) -> IO ()+stalledRequest scheme expected = withStalledServer $ \port _ -> do+ manager <- newTlsManagerWith $ HTTP.managerSetProxy HTTP.noProxy customManagerSettings+ request <- HTTP.parseRequest (toS scheme <> "://127.0.0.1:" <> show port)+ -- Keep the real manager default so removing the production timeout fails+ -- this test, while bounding the regression's own runtime.+ result <- timeout (45 * 1_000_000) (try (HTTP.httpNoBody request manager))+ case result of+ Just (Left err) -> err `shouldSatisfy` expected+ Just (Right _) -> expectationFailure "Expected the stalled request to time out"+ Nothing -> expectationFailure "The HTTP manager did not time out the stalled request"++isConnectionTimeout :: HTTP.HttpException -> Bool+isConnectionTimeout (HTTP.HttpExceptionRequest _ HTTP.ConnectionTimeout) = True+isConnectionTimeout _ = False++isResponseTimeout :: HTTP.HttpException -> Bool+isResponseTimeout (HTTP.HttpExceptionRequest _ HTTP.ResponseTimeout) = True+isResponseTimeout _ = False
+ test/ServantSpec.hs view
@@ -0,0 +1,50 @@+{-# LANGUAGE DataKinds #-}++module ServantSpec (spec) where++import Cachix.Client.Servant (withResponseTimeout)+import Network.HTTP.Client qualified as HTTP+import Network.Socket.ByteString qualified as Socket+import Protolude+import Servant.API (Get, JSON)+import Servant.Client.Streaming+import StalledServer (withServer)+import Test.Hspec++spec :: Spec+spec = describe "scoped response timeout" $ do+ it "allows a slow response only within the overridden action" $+ withDelayedClient $ \env -> do+ result <- runClientM (withResponseTimeout HTTP.responseTimeoutNone getNumber) env+ result `shouldBe` Right 17+ normalResult <- runClientM getNumber env+ normalResult `shouldSatisfy` isResponseTimeout+ it "keeps the default timeout for other actions" $+ withDelayedClient $ \env -> do+ result <- runClientM getNumber env+ result `shouldSatisfy` isResponseTimeout++getNumber :: ClientM Int+getNumber = client (Proxy :: Proxy (Get '[JSON] Int))++withDelayedClient :: (ClientEnv -> IO a) -> IO a+withDelayedClient action = withServer respond $ \port _ -> do+ manager <-+ HTTP.newManager $+ HTTP.managerSetProxy HTTP.noProxy $+ HTTP.defaultManagerSettings+ { HTTP.managerResponseTimeout = HTTP.responseTimeoutMicro 100_000+ }+ action $ mkClientEnv manager (BaseUrl Http "127.0.0.1" port "")+ where+ respond connection = do+ void $ Socket.recv connection 4096+ threadDelay 500_000+ Socket.sendAll connection "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n17"++isResponseTimeout :: Either ClientError a -> Bool+isResponseTimeout (Left (ConnectionError exception)) =+ case fromException exception of+ Just (HTTP.HttpExceptionRequest _ HTTP.ResponseTimeout) -> True+ _ -> False+isResponseTimeout _ = False
+ test/StalledServer.hs view
@@ -0,0 +1,26 @@+module StalledServer (withStalledServer, withServer) where++import Network.Socket qualified as Socket+import Protolude++-- Accept a connection and keep it open without sending any data. The action+-- receives the port and an action that waits until the client has connected.+withStalledServer :: (Int -> IO () -> IO a) -> IO a+withStalledServer = withServer $ \_ -> forever $ threadDelay 1_000_000++withServer :: (Socket.Socket -> IO ()) -> (Int -> IO () -> IO a) -> IO a+withServer respond action =+ bracket (Socket.socket Socket.AF_INET Socket.Stream Socket.defaultProtocol) Socket.close $ \server -> do+ Socket.bind server (Socket.SockAddrInet 0 (Socket.tupleToHostAddress (127, 0, 0, 1)))+ Socket.listen server 1+ address <- Socket.getSocketName server+ port <- case address of+ Socket.SockAddrInet port _ -> pure (fromIntegral port)+ _ -> panic "Expected an IPv4 listener"+ connected <- newEmptyMVar+ withAsync+ ( forever $ bracket (fst <$> Socket.accept server) Socket.close $ \connection -> do+ void $ tryPutMVar connected ()+ respond connection+ )+ (\_ -> action port (takeMVar connected))