packages feed

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 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))