diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,13 @@
 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.3] - 2024-05-17
+
+- daemon: implement robust shutdown handling and fix occasional hangs
+- daemon: respect RUNNER_TEMP
+- lix: support parsing lix version strings
+- deploy: fix path to lock file
+
 ## [1.7.2] - 2024-03-06
 
 ### Added
diff --git a/cachix-deployment/Main.hs b/cachix-deployment/Main.hs
--- a/cachix-deployment/Main.hs
+++ b/cachix-deployment/Main.hs
@@ -31,8 +31,8 @@
 import Protolude.Conv
 import System.IO (BufferMode (..), hSetBuffering)
 
-lockFilename :: Text -> FilePath
-lockFilename agentName = "deployment-" <> toS agentName
+lockFilenameFrom :: Text -> FilePath
+lockFilenameFrom agentName = "deployment-" <> toS agentName
 
 -- | Activate the new deployment.
 --
@@ -76,8 +76,10 @@
             WebSocket.identifier = Agent.agentIdentifier agentName
           }
 
+  lockFilePath <- Lock.newLockFilePath (lockFilenameFrom agentName)
+
   Log.withLog logOptions $ \withLog ->
-    void . Lock.withTryLock (lockFilename agentName) $ do
+    void . Lock.withTryLock lockFilePath $ do
       -- Open a connection to logging stream
       (logQueue, loggingThread) <- runLogStream withLog logWebsocketOptions
 
diff --git a/cachix.cabal b/cachix.cabal
--- a/cachix.cabal
+++ b/cachix.cabal
@@ -1,6 +1,6 @@
 cabal-version:      2.2
 name:               cachix
-version:            1.7.2
+version:            1.7.3
 synopsis:
   Command-line client for Nix binary cache hosting https://cachix.org
 
@@ -69,6 +69,7 @@
     Cachix.Client.Config.Orphans
     Cachix.Client.Daemon
     Cachix.Client.Daemon.Client
+    Cachix.Client.Daemon.EventLoop
     Cachix.Client.Daemon.Listen
     Cachix.Client.Daemon.Log
     Cachix.Client.Daemon.PostBuildHook
@@ -78,12 +79,15 @@
     Cachix.Client.Daemon.PushManager
     Cachix.Client.Daemon.PushManager.PushJob
     Cachix.Client.Daemon.ShutdownLatch
+    Cachix.Client.Daemon.SocketStore
     Cachix.Client.Daemon.Subscription
     Cachix.Client.Daemon.Types
     Cachix.Client.Daemon.Types.Daemon
+    Cachix.Client.Daemon.Types.EventLoop
     Cachix.Client.Daemon.Types.Log
     Cachix.Client.Daemon.Types.PushEvent
     Cachix.Client.Daemon.Types.PushManager
+    Cachix.Client.Daemon.Types.SocketStore
     Cachix.Client.Daemon.Worker
     Cachix.Client.Env
     Cachix.Client.Exception
@@ -249,6 +253,7 @@
 
   build-depends:
     , aeson
+    , async
     , bytestring
     , cachix
     , cachix-api
@@ -262,7 +267,9 @@
     , retry
     , servant-auth-client
     , servant-client-core
+    , stm
     , temporary
     , time
+    , versions
 
   build-tool-depends: hspec-discover:hspec-discover
diff --git a/src/Cachix/Client/Commands.hs b/src/Cachix/Client/Commands.hs
--- a/src/Cachix/Client/Commands.hs
+++ b/src/Cachix/Client/Commands.hs
@@ -93,7 +93,7 @@
 import System.Environment (getEnvironment)
 import System.IO (hIsTerminalDevice)
 import System.IO.Error (isEOFError)
-import System.IO.Temp (withSystemTempFile)
+import System.IO.Temp (withTempFile)
 import qualified System.Posix.Signals as Signals
 import qualified System.Process
 import qualified URI.ByteString as UBS
@@ -376,15 +376,15 @@
 -- Requires the user to be a trusted user in a multi-user installation.
 watchExecDaemon :: Env -> PushOptions -> BinaryCacheName -> Text -> [Text] -> IO ()
 watchExecDaemon env pushOpts cacheName cmd args =
-  Daemon.PostBuildHook.withSetup Nothing $ \daemonSock nixConfEnv ->
-    withSystemTempFile "daemon-log-capture" $ \_ logHandle -> do
-      let daemonOptions = DaemonOptions {daemonSocketPath = Just daemonSock}
+  Daemon.PostBuildHook.withSetup Nothing $ \hookEnv ->
+    withTempFile (Daemon.PostBuildHook.tempDir hookEnv) "daemon-log-capture" $ \_ logHandle -> do
+      let daemonOptions = DaemonOptions {daemonSocketPath = Just (Daemon.PostBuildHook.daemonSock hookEnv)}
       daemon <- Daemon.new env daemonOptions (Just logHandle) pushOpts cacheName
 
       exitCode <-
         bracket (startDaemonThread daemon) (shutdownDaemonThread daemon logHandle) $ \_ -> do
           processEnv <- getEnvironment
-          let newProcessEnv = Daemon.PostBuildHook.modifyEnv nixConfEnv processEnv
+          let newProcessEnv = Daemon.PostBuildHook.modifyEnv (Daemon.PostBuildHook.envVar hookEnv) processEnv
           let process =
                 (System.Process.proc (toS cmd) (toS <$> args))
                   { System.Process.std_out = System.Process.Inherit,
diff --git a/src/Cachix/Client/Daemon.hs b/src/Cachix/Client/Daemon.hs
--- a/src/Cachix/Client/Daemon.hs
+++ b/src/Cachix/Client/Daemon.hs
@@ -13,14 +13,17 @@
 import qualified Cachix.Client.Commands.Push as Commands.Push
 import qualified Cachix.Client.Config as Config
 import Cachix.Client.Config.Orphans ()
+import qualified Cachix.Client.Daemon.EventLoop as EventLoop
 import Cachix.Client.Daemon.Listen as Daemon
 import qualified Cachix.Client.Daemon.Log as Log
 import Cachix.Client.Daemon.Protocol as Protocol
 import Cachix.Client.Daemon.Push as Push
 import qualified Cachix.Client.Daemon.PushManager as PushManager
 import Cachix.Client.Daemon.ShutdownLatch
+import qualified Cachix.Client.Daemon.SocketStore as SocketStore
 import Cachix.Client.Daemon.Subscription as Subscription
 import Cachix.Client.Daemon.Types as Types
+import Cachix.Client.Daemon.Types.EventLoop (DaemonEvent (ShutdownGracefully))
 import qualified Cachix.Client.Daemon.Types.PushManager as PushManager
 import qualified Cachix.Client.Daemon.Worker as Worker
 import Cachix.Client.Env as Env
@@ -36,7 +39,9 @@
 import qualified Hercules.CNix.Util as CNix.Util
 import qualified Katip
 import qualified Network.Socket as Socket
+import qualified Network.Socket.ByteString as Socket.BS
 import Protolude
+import System.IO.Error (isResourceVanishedError)
 import System.Posix.Process (getProcessID)
 import qualified System.Posix.Signals as Signal
 import qualified UnliftIO.Async as Async
@@ -63,6 +68,9 @@
   daemonLogger <- Log.new "cachix.daemon" daemonLogHandle daemonLogLevel
 
   daemonSocketPath <- maybe getSocketPath pure (Options.daemonSocketPath daemonOptions)
+  daemonSocketThread <- newEmptyMVar
+  daemonEventLoop <- EventLoop.new
+  daemonClients <- SocketStore.newSocketStore
   daemonShutdownLatch <- newShutdownLatch
   daemonPid <- getProcessID
 
@@ -83,18 +91,17 @@
   installSignalHandlers daemon
   void $ run daemon
 
--- | Run a daemon from a given configuration
+-- | Run a daemon from a given configuration.
 run :: DaemonEnv -> IO ExitCode
 run daemon = runDaemon daemon $ flip E.onError (return $ ExitFailure 1) $ do
   Katip.logFM Katip.InfoS "Starting Cachix Daemon"
   DaemonEnv {..} <- ask
 
-  config <- showConfiguration
-  Katip.logFM Katip.InfoS $ Katip.ls $ "Configuration:\n" <> config
+  printConfiguration
 
   Push.withPushParams $ \pushParams -> do
     subscriptionManagerThread <-
-      liftIO $ Async.async $ runSubscriptionManager daemonSubscriptionManager
+      Async.async $ runSubscriptionManager daemonSubscriptionManager
 
     let runWorkerTask =
           liftIO . PushManager.runPushManager daemonPushManager . PushManager.handleTask pushParams
@@ -104,56 +111,36 @@
         (PushManager.pmTaskQueue daemonPushManager)
         runWorkerTask
 
-    -- TODO: retry the connection on socket errors
-    E.bracketOnError (Daemon.openSocket daemonSocketPath) Daemon.closeSocket $ \sock -> do
-      liftIO $ Socket.listen sock Socket.maxListenQueue
-      listenThread <- Async.async $ Daemon.listen stop queueJob sock
-
-      -- Wait for a shutdown signal
-      waitForShutdown daemonShutdownLatch
-
-      Katip.logFM Katip.InfoS "Shutting down daemon..."
-
-      -- Stop receiving new push requests
-      liftIO $ Socket.shutdown sock Socket.ShutdownReceive `catchAny` \_ -> return ()
-
-      PushManager.runPushManager daemonPushManager $ do
-        queuedStorePathCount <- PushManager.queuedStorePathCount
-        when (queuedStorePathCount > 0) $
-          Katip.logFM Katip.InfoS $
-            Katip.logStr $
-              "Remaining store paths: " <> (show queuedStorePathCount :: Text)
-
-      -- Finish processing remaining push jobs
-      liftIO $ PushManager.stopPushManager daemonPushManager
-
-      -- Gracefully shut down the worker before closing the socket
-      Worker.stopWorkers workersThreads
-
-      -- Close all event subscriptions
-      liftIO $ stopSubscriptionManager daemonSubscriptionManager
-      Async.wait subscriptionManagerThread
-
-      -- TODO: say goodbye to all clients waiting for their push to go through
-      listenThreadRes <- do
-        Async.cancel listenThread
-        Async.waitCatch listenThread
-
-      case listenThreadRes of
-        Right clientSock -> do
-          -- Wave goodbye to the client that requested the shutdown
-          liftIO $ Daemon.serverBye clientSock
-          liftIO $ Socket.shutdown clientSock Socket.ShutdownBoth `catchAny` \_ -> return ()
-        _ -> return ()
+    listenThread <- Async.async (Daemon.listen daemonEventLoop daemonSocketPath)
+    liftIO $ putMVar daemonSocketThread listenThread
 
-      return ExitSuccess
+    EventLoop.run daemonEventLoop $ \case
+      EventLoop.AddSocketClient conn ->
+        SocketStore.addSocket conn (Daemon.handleClient daemonEventLoop) daemonClients
+      EventLoop.RemoveSocketClient socketId ->
+        SocketStore.removeSocket socketId daemonClients
+      EventLoop.ReconnectSocket ->
+        -- TODO: implement reconnection logic
+        EventLoop.exitLoopWith (ExitFailure 1) daemonEventLoop
+      EventLoop.ReceivedMessage clientMsg ->
+        case clientMsg of
+          ClientPushRequest pushRequest -> queueJob pushRequest
+          ClientStop -> EventLoop.send daemonEventLoop EventLoop.ShutdownGracefully
+          _ -> return ()
+      EventLoop.ShutdownGracefully -> do
+        Katip.logFM Katip.InfoS "Shutting down daemon..."
+        shutdownGracefully subscriptionManagerThread workersThreads
+        Katip.logFM Katip.InfoS "Daemon shut down. Exiting."
+        EventLoop.exitLoopWith ExitSuccess daemonEventLoop
 
 stop :: Daemon ()
-stop = asks daemonShutdownLatch >>= initiateShutdown
+stop = do
+  eventloop <- asks daemonEventLoop
+  EventLoop.send eventloop ShutdownGracefully
 
 stopIO :: DaemonEnv -> IO ()
-stopIO DaemonEnv {daemonShutdownLatch} =
-  initiateShutdown daemonShutdownLatch
+stopIO DaemonEnv {daemonEventLoop} =
+  EventLoop.sendIO daemonEventLoop ShutdownGracefully
 
 installSignalHandlers :: DaemonEnv -> IO ()
 installSignalHandlers daemon = do
@@ -164,10 +151,10 @@
       CNix.Util.triggerInterrupt
       stopIO daemon
 
-queueJob :: Protocol.PushRequest -> Socket.Socket -> Daemon ()
-queueJob pushRequest _clientConn = do
-  DaemonEnv {..} <- ask
-  -- TODO: subscribe the socket to updates if requested
+queueJob :: Protocol.PushRequest -> Daemon ()
+queueJob pushRequest = do
+  daemonPushManager <- asks daemonPushManager
+  -- TODO: subscribe the socket to updates if available
 
   -- Queue the job
   void $
@@ -180,7 +167,13 @@
     subscribeToAllSTM daemonSubscriptionManager (SubChannel chan)
     dupTMChan chan
 
--- | Print debug information about the daemon configuration
+-- | Print the daemon configuration to the log.
+printConfiguration :: Daemon ()
+printConfiguration = do
+  config <- showConfiguration
+  Katip.logFM Katip.InfoS $ Katip.ls $ "Configuration:\n" <> config
+
+-- | Fetch debug information about the daemon configuration.
 showConfiguration :: Daemon Text
 showConfiguration = do
   DaemonEnv {..} <- ask
@@ -196,3 +189,51 @@
         "Cache is public: " <> show (BinaryCache.isPublic daemonBinaryCache),
         "Compression: " <> show (Push.getCompressionMethod daemonPushOptions daemonBinaryCache)
       ]
+
+shutdownGracefully :: Async () -> [Worker.Thread] -> Daemon ()
+shutdownGracefully subscriptionManagerThread workersThreads = do
+  DaemonEnv {..} <- ask
+
+  initiateShutdown daemonShutdownLatch
+  PushManager.runPushManager daemonPushManager $ do
+    queuedStorePathCount <- PushManager.queuedStorePathCount
+    when (queuedStorePathCount > 0) $
+      Katip.logFM Katip.InfoS $
+        Katip.logStr $
+          "Remaining store paths: " <> (show queuedStorePathCount :: Text)
+
+  Katip.logFM Katip.DebugS "Waiting for push manager to clear remaining jobs..."
+  -- Finish processing remaining push jobs
+  let timeoutOptions =
+        PushManager.TimeoutOptions
+          { PushManager.toTimeout = 60.0,
+            PushManager.toPollingInterval = 1.0
+          }
+  liftIO $ PushManager.stopPushManager timeoutOptions daemonPushManager
+  Katip.logFM Katip.DebugS "Push manager shut down."
+
+  -- Gracefully shut down the worker before closing the socket
+  Worker.stopWorkers workersThreads
+
+  -- Close all event subscriptions
+  Katip.logFM Katip.DebugS "Shutting down event manager..."
+  liftIO $ stopSubscriptionManager daemonSubscriptionManager
+  Async.wait subscriptionManagerThread
+  Katip.logFM Katip.DebugS "Event manager shut down."
+
+  let sayGoodbye socket = do
+        let clientSock = SocketStore.socket socket
+        let clientThread = SocketStore.handlerThread socket
+        Async.cancel clientThread
+
+        -- Wave goodbye to the client that requested the shutdown
+        liftIO $ Daemon.serverBye clientSock
+        liftIO $ Socket.shutdown clientSock Socket.ShutdownBoth `catchAny` (\_ -> return ())
+        -- Wait for the other end to disconnect
+        ebs <- liftIO $ try $ Socket.BS.recv clientSock 4096
+        case ebs of
+          Left err | isResourceVanishedError err -> Katip.logFM Katip.DebugS "Client did not disconnect cleanly."
+          Left err -> Katip.logFM Katip.DebugS $ Katip.ls $ "Client socket threw an error: " <> displayException err
+          Right _ -> Katip.logFM Katip.DebugS "Client disconnected."
+
+  Async.mapConcurrently_ sayGoodbye =<< SocketStore.toList daemonClients
diff --git a/src/Cachix/Client/Daemon/Client.hs b/src/Cachix/Client/Daemon/Client.hs
--- a/src/Cachix/Client/Daemon/Client.hs
+++ b/src/Cachix/Client/Daemon/Client.hs
@@ -4,23 +4,42 @@
 import Cachix.Client.Daemon.Protocol as Protocol
 import Cachix.Client.Env as Env
 import Cachix.Client.OptionsParser (DaemonOptions (..))
+import qualified Cachix.Client.Retry as Retry
 import qualified Control.Concurrent.Async as Async
+import Control.Concurrent.STM.TBMQueue
 import Control.Exception.Safe (catchAny)
 import qualified Data.Aeson as Aeson
 import qualified Data.ByteString as BS
+import qualified Data.ByteString.Lazy.Char8 as Lazy.Char8
+import Data.IORef
+import Data.Time.Clock
 import qualified Network.Socket as Socket
 import qualified Network.Socket.ByteString as Socket.BS
 import qualified Network.Socket.ByteString.Lazy as Socket.LBS
 import Protolude
-import qualified System.Posix.IO as Posix
 
+data SocketError
+  = -- | The socket has been closed
+    SocketClosed
+  | -- | The socket has stopped responding to pings
+    SocketStalled
+  | -- | Failed to decode a message from the socket
+    SocketDecodingError !Text
+  deriving stock (Show)
+
+instance Exception SocketError where
+  displayException = \case
+    SocketClosed -> "The socket has been closed"
+    SocketStalled -> "The socket has stopped responding to pings"
+    SocketDecodingError err -> "Failed to decode message from socket: " <> toS err
+
 -- | Queue up push requests with the daemon
 --
 -- TODO: wait for the daemon to respond that it has received the request
 push :: Env -> DaemonOptions -> [FilePath] -> IO ()
 push _env daemonOptions storePaths =
   withDaemonConn (daemonSocketPath daemonOptions) $ \sock -> do
-    Socket.LBS.sendAll sock (Aeson.encode pushRequest)
+    Socket.LBS.sendAll sock $ Aeson.encode pushRequest `Lazy.Char8.snoc` '\n'
   where
     pushRequest =
       Protocol.ClientPushRequest $
@@ -30,36 +49,84 @@
 stop :: Env -> DaemonOptions -> IO ()
 stop _env daemonOptions =
   withDaemonConn (daemonSocketPath daemonOptions) $ \sock -> do
-    Async.concurrently_ (waitForResponse sock) $
-      Socket.LBS.sendAll sock (Aeson.encode Protocol.ClientStop)
+    let size = 100
+    (rx, tx) <- atomically $ (,) <$> newTBMQueue size <*> newTBMQueue size
+
+    rxThread <- Async.async (handleIncoming rx sock)
+    txThread <- Async.async (handleOutgoing tx sock)
+
+    lastPongRef <- newIORef =<< getCurrentTime
+    pingThread <- Async.async (runPingThread lastPongRef rx tx)
+
+    mapM_ Async.link [rxThread, txThread, pingThread]
+
+    -- Request the daemon to stop
+    atomically $ writeTBMQueue tx Protocol.ClientStop
+
+    fix $ \loop -> do
+      mmsg <- atomically (readTBMQueue rx)
+      case mmsg of
+        Nothing -> return ()
+        Just (Left err) -> putErrText $ toS $ displayException err
+        Just (Right msg) ->
+          case msg of
+            Protocol.DaemonPong -> do
+              writeIORef lastPongRef =<< getCurrentTime
+              loop
+            Protocol.DaemonBye -> exitSuccess
   where
-    waitForResponse sock = do
-      -- Wait for the socket to close
-      bs <- Socket.BS.recv sock 4096 `catchAny` (\_ -> return BS.empty)
+    runPingThread lastPongRef rx tx = go
+      where
+        go = do
+          timestamp <- getCurrentTime
+          lastPong <- readIORef lastPongRef
 
-      -- A zero-length response means that the daemon has closed the socket
-      guard $ not $ BS.null bs
+          if timestamp >= addUTCTime 20 lastPong
+            then atomically $ writeTBMQueue rx (Left SocketStalled)
+            else do
+              atomically $ writeTBMQueue tx Protocol.ClientPing
+              threadDelay (2 * 1000 * 1000)
+              go
 
-      case Aeson.eitherDecodeStrict bs of
-        Left err -> putErrText (toS err)
-        Right DaemonBye -> return ()
+    handleOutgoing tx sock = go
+      where
+        go = do
+          mmsg <- atomically $ readTBMQueue tx
+          case mmsg of
+            Nothing -> return ()
+            Just msg -> do
+              Retry.retryAll $ const $ Socket.LBS.sendAll sock $ Aeson.encode msg `Lazy.Char8.snoc` '\n'
+              go
 
+    handleIncoming rx sock = go
+      where
+        go = do
+          -- Wait for the socket to close
+          bs <- Socket.BS.recv sock 4096 `catchAny` (\_ -> return BS.empty)
+
+          -- A zero-length response means that the daemon has closed the socket
+          if BS.null bs
+            then atomically $ writeTBMQueue rx (Left SocketClosed)
+            else case Aeson.eitherDecodeStrict bs of
+              Left err -> do
+                let terr = toS err
+                putErrText terr
+                atomically $ writeTBMQueue rx (Left (SocketDecodingError terr))
+              Right msg -> do
+                atomically $ writeTBMQueue rx (Right msg)
+                go
+
 withDaemonConn :: Maybe FilePath -> (Socket.Socket -> IO a) -> IO a
 withDaemonConn optionalSocketPath f = do
   socketPath <- maybe getSocketPath pure optionalSocketPath
-  bracket (open socketPath) Socket.close f `onException` failedToConnectTo socketPath
+  bracket (open socketPath `onException` failedToConnectTo socketPath) Socket.close f
   where
     open socketPath = do
       sock <- Socket.socket Socket.AF_UNIX Socket.Stream Socket.defaultProtocol
-      Socket.connect sock (Socket.SockAddrUnix socketPath)
-
-      -- Network.Socket.accept sets the socket to non-blocking by default.
-      Socket.withFdSocket sock $ \fd ->
-        Posix.setFdOption (fromIntegral fd) Posix.NonBlockingRead False
-
+      Retry.retryAll $ const $ Socket.connect sock (Socket.SockAddrUnix socketPath)
       return sock
 
     failedToConnectTo :: FilePath -> IO ()
     failedToConnectTo socketPath = do
-      putErrText "Failed to connect to Cachix Daemon"
+      putErrText "\nFailed to connect to Cachix Daemon"
       putErrText $ "Tried to connect to: " <> toS socketPath <> "\n"
diff --git a/src/Cachix/Client/Daemon/EventLoop.hs b/src/Cachix/Client/Daemon/EventLoop.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Client/Daemon/EventLoop.hs
@@ -0,0 +1,73 @@
+module Cachix.Client.Daemon.EventLoop
+  ( new,
+    send,
+    sendIO,
+    run,
+    exitLoopWith,
+    EventLoop,
+    DaemonEvent (..),
+  )
+where
+
+import Cachix.Client.Daemon.Types.EventLoop (DaemonEvent (..), EventLoop (..))
+import Control.Concurrent.STM.TBMQueue
+  ( isFullTBMQueue,
+    newTBMQueueIO,
+    readTBMQueue,
+    tryWriteTBMQueue,
+  )
+import Data.Text.Lazy.Builder (toLazyText)
+import qualified Katip
+import Protolude
+
+new :: (MonadIO m) => m EventLoop
+new = do
+  exitLatch <- liftIO newEmptyMVar
+  queue <- liftIO $ newTBMQueueIO 100
+  return $ EventLoop {queue, exitLatch}
+
+-- | Send an event to the event loop with logging.
+send :: (Katip.KatipContext m) => EventLoop -> DaemonEvent -> m ()
+send = send' Katip.logFM
+
+-- | Same as 'send', but does not require a 'Katip.KatipContext'.
+sendIO :: forall m. (MonadIO m) => EventLoop -> DaemonEvent -> m ()
+sendIO = send' logger
+  where
+    logger :: Katip.Severity -> Katip.LogStr -> m ()
+    logger Katip.ErrorS msg = liftIO $ hPutStrLn stderr (toLazyText $ Katip.unLogStr msg)
+    logger _ _ = return ()
+
+send' :: (MonadIO m) => (Katip.Severity -> Katip.LogStr -> m ()) -> EventLoop -> DaemonEvent -> 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
+      exitLoopWith (ExitFailure 1) eventloop
+
+run :: (MonadIO m) => EventLoop -> (DaemonEvent -> m ()) -> m ExitCode
+run eventloop f = do
+  fix $ \loop -> do
+    mevent <- liftIO $ atomically $ readTBMQueue (queue eventloop)
+    case mevent of
+      Just event -> f event
+      Nothing -> exitLoopWith (ExitFailure 1) eventloop
+
+    liftIO (tryReadMVar (exitLatch eventloop)) >>= \case
+      Just exitCode -> return exitCode
+      Nothing -> loop
+
+exitLoopWith :: (MonadIO m) => ExitCode -> EventLoop -> m ()
+exitLoopWith exitCode EventLoop {exitLatch} = void $ liftIO $ tryPutMVar exitLatch exitCode
diff --git a/src/Cachix/Client/Daemon/Listen.hs b/src/Cachix/Client/Daemon/Listen.hs
--- a/src/Cachix/Client/Daemon/Listen.hs
+++ b/src/Cachix/Client/Daemon/Listen.hs
@@ -1,5 +1,6 @@
 module Cachix.Client.Daemon.Listen
   ( listen,
+    handleClient,
     serverBye,
     getSocketPath,
     openSocket,
@@ -8,11 +9,17 @@
 where
 
 import Cachix.Client.Config.Orphans ()
+import qualified Cachix.Client.Daemon.EventLoop as EventLoop
 import Cachix.Client.Daemon.Protocol as Protocol
+import Cachix.Client.Daemon.Types.EventLoop (EventLoop)
+import Cachix.Client.Daemon.Types.SocketStore (SocketId)
 import Control.Exception.Safe (catchAny)
-import qualified Control.Exception.Safe as Safe
+import qualified Control.Monad.Catch as E
 import qualified Data.Aeson as Aeson
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Char8 as Char8
 import qualified Katip
+import Network.Socket (Socket)
 import qualified Network.Socket as Socket
 import qualified Network.Socket.ByteString as Socket.BS
 import qualified Network.Socket.ByteString.Lazy as Socket.LBS
@@ -25,8 +32,9 @@
   )
 import qualified System.Environment as System
 import System.FilePath ((</>))
-import System.IO.Error (isDoesNotExistError)
+import System.IO.Error (isDoesNotExistError, isResourceVanishedError)
 
+-- TODO: reconcile with Client
 data ListenError
   = SocketError SomeException
   | DecodingError Text
@@ -35,59 +43,68 @@
 instance Exception ListenError where
   displayException = \case
     SocketError err -> "Failed to read from the daemon socket: " <> show err
-    DecodingError err -> "Failed to decode request: " <> toS err
+    DecodingError err -> "Failed to decode request:\n" <> toS err
 
--- | The main daemon server loop.
+-- | Listen for incoming connections on the given socket path.
 listen ::
-  (Katip.KatipContext m) =>
-  -- | An action to run when the daemon is requested to stop
-  m () ->
-  -- | An action to run when a push request is received
-  (Protocol.PushRequest -> Socket.Socket -> m ()) ->
-  -- | An active socket to listen on
-  Socket.Socket ->
-  -- | Returns a socket to the client that requested the daemon to stop
-  m Socket.Socket
-listen onClientStop onPushRequest sock = loop
+  (E.MonadMask m, Katip.KatipContext m) =>
+  EventLoop ->
+  FilePath ->
+  m ()
+listen eventloop daemonSocketPath = forever $ do
+  E.bracketOnError (openSocket daemonSocketPath) closeSocket $ \sock -> do
+    liftIO $ Socket.listen sock Socket.maxListenQueue
+    (conn, _peerAddr) <- liftIO $ Socket.accept sock
+    EventLoop.send eventloop (EventLoop.AddSocketClient conn)
+
+-- | Handle incoming messages from a client.
+--
+-- Automatically responds to pings.
+-- Requests the daemon to remove the client socket once the loop exits.
+handleClient ::
+  forall m.
+  (E.MonadMask m, Katip.KatipContext m) =>
+  EventLoop ->
+  SocketId ->
+  Socket ->
+  m ()
+handleClient eventloop socketId conn = do
+  go `E.finally` removeClient
   where
-    loop = do
-      result <- liftIO $ runExceptT $ readPushRequest sock
+    go = do
+      ebs <- liftIO $ try $ Socket.BS.recv conn 4096
 
-      case result of
-        Right (ClientStop, clientConn) -> do
-          onClientStop
-          return clientConn
-        Right (ClientPushRequest pushRequest, clientConn) -> do
-          onPushRequest pushRequest clientConn
-          loop
-        Left err@(DecodingError _) -> do
-          Katip.logFM Katip.ErrorS $ Katip.ls $ displayException err
-          loop
-        Left err -> throwIO err
+      case ebs of
+        Left err | isResourceVanishedError err -> return ()
+        Left _ -> return ()
+        -- If the socket returns 0 bytes, then it is closed
+        Right bs | BS.null bs -> return ()
+        Right bs -> do
+          msgs <- catMaybes <$> mapM decodeMessage (Char8.split '\n' bs)
 
-serverBye :: Socket.Socket -> IO ()
-serverBye sock =
-  Socket.LBS.sendAll sock (Aeson.encode DaemonBye) `catchAny` (\_ -> return ())
+          forM_ msgs $ \msg -> do
+            EventLoop.send eventloop (EventLoop.ReceivedMessage msg)
+            case msg of
+              Protocol.ClientPing ->
+                liftIO $ Socket.LBS.sendAll conn (Aeson.encode DaemonPong)
+              _ -> return ()
 
--- | Try to read and decode a push request.
-readPushRequest :: Socket.Socket -> ExceptT ListenError IO (ClientMessage, Socket.Socket)
-readPushRequest sock = do
-  (bs, clientConn) <- readFromSocket `mapSyncException` SocketError
-  decodeMessage clientConn bs
-  where
-    readFromSocket = liftIO $ do
-      -- NOTE: this sets up a non-blocking socket to the client
-      (conn, _peerAddr) <- Socket.accept sock
-      bs <- Socket.BS.recv conn 4096
-      return (bs, conn)
+          go
 
-    decodeMessage conn bs =
+    decodeMessage :: ByteString -> m (Maybe Protocol.ClientMessage)
+    decodeMessage "" = return Nothing
+    decodeMessage bs =
       case Aeson.eitherDecodeStrict bs of
-        Left err -> throwE $ DecodingError (toS err)
-        Right pushRequest -> return (pushRequest, conn)
+        Left err -> do
+          Katip.logFM Katip.ErrorS $ Katip.ls $ displayException (DecodingError (toS err))
+          return Nothing
+        Right msg -> return (Just msg)
 
-mapSyncException :: (Exception e1, Exception e2, Safe.MonadCatch m) => m a -> (e1 -> e2) -> m a
-mapSyncException a f = a `Safe.catch` (Safe.throwM . f)
+    removeClient = EventLoop.send eventloop (EventLoop.RemoveSocketClient socketId)
+
+serverBye :: Socket.Socket -> IO ()
+serverBye sock =
+  Socket.LBS.sendAll sock (Aeson.encode DaemonBye) `catchAny` (\_ -> return ())
 
 getSocketPath :: IO FilePath
 getSocketPath = do
diff --git a/src/Cachix/Client/Daemon/PostBuildHook.hs b/src/Cachix/Client/Daemon/PostBuildHook.hs
--- a/src/Cachix/Client/Daemon/PostBuildHook.hs
+++ b/src/Cachix/Client/Daemon/PostBuildHook.hs
@@ -1,8 +1,22 @@
 {-# LANGUAGE QuasiQuotes #-}
 {-# LANGUAGE TypeApplications #-}
 
-module Cachix.Client.Daemon.PostBuildHook where
+module Cachix.Client.Daemon.PostBuildHook
+  ( -- * Post-build hook setup
+    PostBuildHookEnv (..),
+    withSetup,
 
+    -- * Set up env vars
+    EnvVar,
+    modifyEnv,
+
+    -- * Internal
+    buildNixConfEnv,
+    buildNixUserConfFilesEnv,
+  )
+where
+
+import Control.Monad.Catch (MonadMask)
 import Data.Containers.ListUtils (nubOrd)
 import Data.String (String)
 import Data.String.Here
@@ -15,7 +29,7 @@
   )
 import System.Environment (getExecutablePath, lookupEnv)
 import System.FilePath ((</>))
-import System.IO.Temp (withSystemTempDirectory)
+import System.IO.Temp (getCanonicalTemporaryDirectory, withTempDirectory)
 import System.Posix.Files
 
 type EnvVar = (String, String)
@@ -24,9 +38,17 @@
 modifyEnv (envName, envValue) processEnv =
   nubOrd $ (envName, envValue) : processEnv
 
-withSetup :: Maybe FilePath -> (FilePath -> EnvVar -> IO a) -> IO a
+data PostBuildHookEnv = PostBuildHookEnv
+  { tempDir :: !FilePath,
+    postBuildHookConfigPath :: !FilePath,
+    postBuildHookScriptPath :: !FilePath,
+    daemonSock :: !FilePath,
+    envVar :: !EnvVar
+  }
+
+withSetup :: Maybe FilePath -> (PostBuildHookEnv -> IO a) -> IO a
 withSetup mdaemonSock f =
-  withSystemTempDirectory "cachix-daemon" $ \tempDir -> do
+  withRunnerFriendlyTempDirectory "cachix-daemon" $ \tempDir -> do
     let postBuildHookScriptPath = tempDir </> "post-build-hook.sh"
         postBuildHookConfigPath = tempDir </> "nix.conf"
         daemonSock = fromMaybe (tempDir </> "daemon.sock") mdaemonSock
@@ -37,13 +59,13 @@
 
     mnixConfEnv <- buildNixConfEnv postBuildHookScriptPath
     nixUserConfFilesEnv <- buildNixUserConfFilesEnv postBuildHookConfigPath
-    nixConfEnvVar <- case mnixConfEnv of
+    envVar <- case mnixConfEnv of
       Just nixConfEnv -> return nixConfEnv
       Nothing -> do
         writeFile postBuildHookConfigPath (postBuildHookConfig postBuildHookScriptPath)
         return nixUserConfFilesEnv
 
-    f daemonSock nixConfEnvVar
+    f PostBuildHookEnv {..}
 
 -- | Build the NIX_CONF environment variable.
 --
@@ -118,3 +140,15 @@
   --socket ${toS socketPath :: Text} \\
   $OUT_PATHS
   |]
+
+-- | Run an action with a temporary directory.
+--
+-- Respects the RUNNER_TEMP environment variable if set.
+-- This is important on self-hosted GitHub runners with locked down system temp directories.
+-- The directory is deleted after use.
+withRunnerFriendlyTempDirectory :: (MonadIO m, MonadMask m) => String -> (FilePath -> m a) -> m a
+withRunnerFriendlyTempDirectory name action = do
+  runnerTempDir <- liftIO $ lookupEnv "RUNNER_TEMP"
+  systemTempDir <- liftIO getCanonicalTemporaryDirectory
+  let tempDir = maybe systemTempDir toS runnerTempDir
+  withTempDirectory tempDir name action
diff --git a/src/Cachix/Client/Daemon/Protocol.hs b/src/Cachix/Client/Daemon/Protocol.hs
--- a/src/Cachix/Client/Daemon/Protocol.hs
+++ b/src/Cachix/Client/Daemon/Protocol.hs
@@ -16,15 +16,17 @@
 
 -- | JSON messages that the client can send to the daemon
 data ClientMessage
-  = ClientPushRequest PushRequest
+  = ClientPushRequest !PushRequest
   | ClientStop
-  deriving stock (Generic)
+  | ClientPing
+  deriving stock (Generic, Show)
   deriving anyclass (Aeson.FromJSON, Aeson.ToJSON)
 
 -- | JSON messages that the daemon can send to the client
 data DaemonMessage
-  = DaemonBye
-  deriving stock (Generic)
+  = DaemonPong
+  | DaemonBye
+  deriving stock (Generic, Show)
   deriving anyclass (Aeson.FromJSON, Aeson.ToJSON)
 
 newtype PushRequestId = PushRequestId UUID
diff --git a/src/Cachix/Client/Daemon/PushManager.hs b/src/Cachix/Client/Daemon/PushManager.hs
--- a/src/Cachix/Client/Daemon/PushManager.hs
+++ b/src/Cachix/Client/Daemon/PushManager.hs
@@ -12,6 +12,7 @@
     lookupPushJob,
     withPushJob,
     resolvePushJob,
+    pendingJobCount,
 
     -- * Store paths
     queueStorePaths,
@@ -27,6 +28,9 @@
     pushStorePathProgress,
     pushStorePathDone,
     pushStorePathFailed,
+
+    -- * Helpers
+    atomicallyWithTimeout,
   )
 where
 
@@ -44,6 +48,7 @@
 import Cachix.Client.Retry (retryAll)
 import qualified Cachix.Types.BinaryCache as BinaryCache
 import qualified Conduit as C
+import qualified Control.Concurrent.Async as Async
 import Control.Concurrent.STM.TBMQueue
 import Control.Concurrent.STM.TVar
 import qualified Control.Monad.Catch as E
@@ -54,7 +59,7 @@
 import Data.IORef
 import qualified Data.Set as Set
 import qualified Data.Text as T
-import Data.Time (getCurrentTime)
+import Data.Time (UTCTime, diffUTCTime, getCurrentTime, secondsToNominalDiffTime)
 import Hercules.CNix (StorePath)
 import Hercules.CNix.Store (Store, parseStorePath, storePathToPath)
 import qualified Katip
@@ -66,23 +71,26 @@
 import qualified UnliftIO.QSem as QSem
 
 newPushManagerEnv :: (MonadIO m) => PushOptions -> Logger -> OnPushEvent -> m PushManagerEnv
-newPushManagerEnv pushOptions pmLogger pmOnPushEvent = liftIO $ do
+newPushManagerEnv pushOptions pmLogger onPushEvent = liftIO $ do
   pmPushJobs <- newTVarIO mempty
+  pmPendingJobCount <- newTVarIO 0
   pmStorePathReferences <- newTVarIO mempty
   pmTaskQueue <- newTBMQueueIO 1000
   pmTaskSemaphore <- QSem.newQSem (numJobs pushOptions)
+  pmLastEventTimestamp <- newTVarIO =<< getCurrentTime
+  let pmOnPushEvent id pushEvent = updateTimestampTVar pmLastEventTimestamp >> onPushEvent id pushEvent
+
   return $ PushManagerEnv {..}
 
 runPushManager :: (MonadIO m) => PushManagerEnv -> PushManager a -> m a
 runPushManager env f = liftIO $ unPushManager f `runReaderT` env
 
-stopPushManager :: PushManagerEnv -> IO ()
-stopPushManager PushManagerEnv {pmTaskQueue, pmPushJobs} =
-  atomically $ do
-    pushJobs <- readTVar pmPushJobs
-    if all PushJob.isProcessed (HashMap.elems pushJobs)
-      then closeTBMQueue pmTaskQueue
-      else retry
+stopPushManager :: TimeoutOptions -> PushManagerEnv -> IO ()
+stopPushManager timeoutOptions PushManagerEnv {..} = do
+  atomicallyWithTimeout timeoutOptions pmLastEventTimestamp $ do
+    pendingJobs <- readTVar pmPendingJobCount
+    check (pendingJobs <= 0)
+  atomically $ closeTBMQueue pmTaskQueue
 
 -- Manage push jobs
 
@@ -96,6 +104,7 @@
   liftIO $
     atomically $ do
       modifyTVar' pmPushJobs $ HashMap.insert (pushId pushJob) pushJob
+      incrementTVar pmPendingJobCount
       writeTBMQueue pmTaskQueue $ ResolveClosure (pushId pushJob)
 
   return (pushId pushJob)
@@ -103,7 +112,12 @@
 removePushJob :: Protocol.PushRequestId -> PushManager ()
 removePushJob pushId = do
   PushManagerEnv {..} <- ask
-  liftIO $ atomically $ modifyTVar' pmPushJobs $ HashMap.delete pushId
+  liftIO $ atomically $ do
+    mpushJob <- HashMap.lookup pushId <$> readTVar pmPushJobs
+    for_ mpushJob $ \pushJob -> do
+      -- Decrement the job count if this job had not been processed yet
+      unless (PushJob.isProcessed pushJob) (decrementTVar pmPendingJobCount)
+      modifyTVar' pmPushJobs (HashMap.delete pushId)
 
 lookupPushJob :: Protocol.PushRequestId -> PushManager (Maybe PushJob)
 lookupPushJob pushId = do
@@ -120,7 +134,11 @@
 modifyPushJob :: Protocol.PushRequestId -> (PushJob -> PushJob) -> PushManager (Maybe PushJob)
 modifyPushJob pushId f = do
   pushJobs <- asks pmPushJobs
-  liftIO $ atomically $ stateTVar pushJobs $ \jobs -> do
+  liftIO $ atomically $ modifyPushJobSTM pushJobs pushId f
+
+modifyPushJobSTM :: PushJobStore -> Protocol.PushRequestId -> (PushJob -> PushJob) -> STM (Maybe PushJob)
+modifyPushJobSTM pushJobs pushId f =
+  stateTVar pushJobs $ \jobs -> do
     let pj = HashMap.adjust f pushId jobs
     (HashMap.lookup pushId pj, pj)
 
@@ -132,9 +150,17 @@
 
 failPushJob :: Protocol.PushRequestId -> PushManager ()
 failPushJob pushId = do
+  PushManagerEnv {..} <- ask
   timestamp <- liftIO getCurrentTime
-  void $ modifyPushJob pushId $ PushJob.fail timestamp
+  liftIO $ atomically $ do
+    _ <- modifyPushJobSTM pmPushJobs pushId $ PushJob.fail timestamp
+    decrementTVar pmPendingJobCount
 
+pendingJobCount :: PushManager Int
+pendingJobCount = do
+  pmPendingJobCount <- asks pmPendingJobCount
+  liftIO $ readTVarIO pmPendingJobCount
+
 -- Manage store paths
 
 queueStorePaths :: Protocol.PushRequestId -> [FilePath] -> PushManager ()
@@ -164,11 +190,14 @@
 
 checkPushJobCompleted :: Protocol.PushRequestId -> PushManager ()
 checkPushJobCompleted pushId = do
+  PushManagerEnv {..} <- ask
   mpushJob <- lookupPushJob pushId
   for_ mpushJob $ \pushJob ->
     when (Set.null $ pushQueue pushJob) $ do
       timestamp <- liftIO getCurrentTime
-      _ <- modifyPushJob pushId $ PushJob.complete timestamp
+      liftIO $ atomically $ do
+        _ <- modifyPushJobSTM pmPushJobs pushId $ PushJob.complete timestamp
+        decrementTVar pmPendingJobCount
       pushFinished pushJob
 
 queuedStorePathCount :: PushManager Integer
@@ -401,9 +430,6 @@
 
 -- Helpers
 
-transactionally :: (Foldable t, MonadIO m) => t (STM ()) -> m ()
-transactionally = liftIO . atomically . sequence_
-
 storeToFilePath :: (MonadIO m) => Store -> StorePath -> m FilePath
 storeToFilePath store storePath = do
   fp <- liftIO $ storePathToPath store storePath
@@ -418,3 +444,48 @@
 
 withException :: (E.MonadCatch m) => m a -> (SomeException -> m a) -> m a
 withException action handler = action `E.catchAll` (\e -> handler e >> E.throwM e)
+
+-- STM helpers
+
+transactionally :: (Foldable t, MonadIO m) => t (STM ()) -> m ()
+transactionally = liftIO . atomically . sequence_
+
+updateTimestampTVar :: (MonadIO m) => TVar UTCTime -> m ()
+updateTimestampTVar tvar = liftIO $ do
+  now <- getCurrentTime
+  atomically $ writeTVar tvar now
+
+incrementTVar :: TVar Int -> STM ()
+incrementTVar tvar = modifyTVar' tvar (+ 1)
+
+decrementTVar :: TVar Int -> STM ()
+decrementTVar tvar = modifyTVar' tvar (subtract 1)
+
+-- | Run a transaction with a timeout.
+atomicallyWithTimeout ::
+  TimeoutOptions ->
+  -- | A TVar timestamp to compare against
+  TVar UTCTime ->
+  -- | The transaction to run
+  STM () ->
+  IO ()
+atomicallyWithTimeout TimeoutOptions {..} timeVar transaction = do
+  timeoutVar <- newTVarIO False
+  Async.race_
+    (updateShutdownTimeout timeoutVar)
+    (waitForGracefulShutdown timeoutVar)
+  where
+    waitForGracefulShutdown timeout =
+      atomically $ transaction `orElse` checkShutdownTimeout timeout
+
+    updateShutdownTimeout timeoutVar =
+      forever $ do
+        now <- getCurrentTime
+        atomically $ do
+          timestamp <- readTVar timeVar
+          let isTimeout =
+                secondsToNominalDiffTime (realToFrac toTimeout) <= now `diffUTCTime` timestamp
+          writeTVar timeoutVar isTimeout
+        threadDelay $ ceiling (toPollingInterval * 1000.0 * 1000.0)
+
+    checkShutdownTimeout timeout = check =<< readTVar timeout
diff --git a/src/Cachix/Client/Daemon/PushManager/PushJob.hs b/src/Cachix/Client/Daemon/PushManager/PushJob.hs
--- a/src/Cachix/Client/Daemon/PushManager/PushJob.hs
+++ b/src/Cachix/Client/Daemon/PushManager/PushJob.hs
@@ -58,16 +58,14 @@
 markStorePathPushed :: FilePath -> PushJob -> PushJob
 markStorePathPushed storePath pushJob@(PushJob {pushQueue, pushResult}) =
   pushJob
-    { pushStatus = Running,
-      pushQueue = Set.delete storePath pushQueue,
+    { pushQueue = Set.delete storePath pushQueue,
       pushResult = addPushedPath storePath pushResult
     }
 
 markStorePathFailed :: FilePath -> PushJob -> PushJob
 markStorePathFailed storePath pushJob@(PushJob {pushQueue, pushResult}) =
   pushJob
-    { pushStatus = Running,
-      pushQueue = Set.delete storePath pushQueue,
+    { pushQueue = Set.delete storePath pushQueue,
       pushResult = addFailedPath storePath pushResult
     }
 
diff --git a/src/Cachix/Client/Daemon/SocketStore.hs b/src/Cachix/Client/Daemon/SocketStore.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Client/Daemon/SocketStore.hs
@@ -0,0 +1,43 @@
+module Cachix.Client.Daemon.SocketStore
+  ( newSocketStore,
+    addSocket,
+    removeSocket,
+    toList,
+    Socket (..),
+  )
+where
+
+import Cachix.Client.Daemon.Types.SocketStore (Socket (..), SocketId, SocketStore (..))
+import Control.Concurrent.STM.TVar
+import Control.Monad.IO.Unlift (MonadUnliftIO)
+import qualified Data.HashMap.Strict as HashMap
+import qualified Data.UUID.V4 as UUID
+import qualified Network.Socket
+import Protolude hiding (toList)
+import qualified UnliftIO.Async as Async
+
+newSocketStore :: (MonadIO m) => m SocketStore
+newSocketStore = SocketStore <$> liftIO (newTVarIO mempty)
+
+newSocketId :: (MonadIO m) => m SocketId
+newSocketId = liftIO UUID.nextRandom
+
+addSocket :: (MonadUnliftIO m) => Network.Socket.Socket -> (SocketId -> Network.Socket.Socket -> m ()) -> SocketStore -> m ()
+addSocket socket handler (SocketStore st) = do
+  socketId <- newSocketId
+  handlerThread <- Async.async (handler socketId socket)
+  liftIO $ atomically $ modifyTVar' st $ HashMap.insert socketId (Socket {..})
+
+removeSocket :: (MonadIO m) => SocketId -> SocketStore -> m ()
+removeSocket socketId (SocketStore stvar) = do
+  msocket <- liftIO $ atomically $ stateTVar stvar $ \st ->
+    let msocket = HashMap.lookup socketId st
+     in (msocket, HashMap.delete socketId st)
+
+  -- shut down the handler thread
+  mapM_ (Async.uninterruptibleCancel . handlerThread) msocket
+
+toList :: (MonadIO m) => SocketStore -> m [Socket]
+toList (SocketStore st) = do
+  hm <- liftIO $ readTVarIO st
+  return $ HashMap.elems hm
diff --git a/src/Cachix/Client/Daemon/Types/Daemon.hs b/src/Cachix/Client/Daemon/Types/Daemon.hs
--- a/src/Cachix/Client/Daemon/Types/Daemon.hs
+++ b/src/Cachix/Client/Daemon/Types/Daemon.hs
@@ -13,9 +13,11 @@
 import qualified Cachix.Client.Daemon.Protocol as Protocol
 import Cachix.Client.Daemon.ShutdownLatch (ShutdownLatch)
 import Cachix.Client.Daemon.Subscription (SubscriptionManager)
+import Cachix.Client.Daemon.Types.EventLoop (EventLoop)
 import Cachix.Client.Daemon.Types.Log (Logger)
 import Cachix.Client.Daemon.Types.PushEvent (PushEvent)
 import Cachix.Client.Daemon.Types.PushManager (PushManagerEnv (..))
+import Cachix.Client.Daemon.Types.SocketStore (SocketStore)
 import Cachix.Client.Env as Env
 import Cachix.Client.OptionsParser (PushOptions)
 import Cachix.Client.Push
@@ -29,10 +31,14 @@
 data DaemonEnv = DaemonEnv
   { -- | Cachix client env
     daemonEnv :: Env,
+    -- | The main event loop
+    daemonEventLoop :: EventLoop,
     -- | Push options, like compression settings and number of jobs
     daemonPushOptions :: PushOptions,
     -- | Path to the socket that the daemon listens on
     daemonSocketPath :: FilePath,
+    -- | Main inbound socket thread
+    daemonSocketThread :: MVar (Async ()),
     -- | The push secret for the binary cache
     daemonPushSecret :: PushSecret,
     -- | The name of the binary cache to push to
@@ -41,6 +47,8 @@
     daemonBinaryCache :: BinaryCache,
     -- | The state of active push requests
     daemonPushManager :: PushManagerEnv,
+    -- | Connected clients over the socket
+    daemonClients :: SocketStore,
     -- | A multiplexer for push events.
     daemonSubscriptionManager :: SubscriptionManager Protocol.PushRequestId PushEvent,
     -- | Logging env
diff --git a/src/Cachix/Client/Daemon/Types/EventLoop.hs b/src/Cachix/Client/Daemon/Types/EventLoop.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Client/Daemon/Types/EventLoop.hs
@@ -0,0 +1,35 @@
+module Cachix.Client.Daemon.Types.EventLoop
+  ( EventLoop (..),
+    ExitLatch,
+    DaemonEvent (..),
+  )
+where
+
+import qualified Cachix.Client.Daemon.Protocol as Protocol
+import Cachix.Client.Daemon.Types.SocketStore (SocketId)
+import Control.Concurrent.STM.TBMQueue (TBMQueue)
+import Network.Socket (Socket)
+import Protolude
+
+-- | An event loop that processes 'DaemonEvent's.
+data EventLoop = EventLoop
+  { queue :: TBMQueue DaemonEvent,
+    exitLatch :: ExitLatch
+  }
+
+-- | An exit latch is a semaphore that signals the event loop to exit.
+-- The exit code should be returned by the 'EventLoop'.
+type ExitLatch = MVar ExitCode
+
+-- | Daemon events that are handled by the 'EventLoop'.
+data DaemonEvent
+  = -- | Shut down the daemon gracefully.
+    ShutdownGracefully
+  | -- | Re-establish the daemon socket
+    ReconnectSocket
+  | -- | Add a new client socket connection.
+    AddSocketClient Socket
+  | -- | Remove an existing client socket connection. For example, after it is closed.
+    RemoveSocketClient SocketId
+  | -- | Handle a new message from a client.
+    ReceivedMessage Protocol.ClientMessage
diff --git a/src/Cachix/Client/Daemon/Types/PushEvent.hs b/src/Cachix/Client/Daemon/Types/PushEvent.hs
--- a/src/Cachix/Client/Daemon/Types/PushEvent.hs
+++ b/src/Cachix/Client/Daemon/Types/PushEvent.hs
@@ -13,7 +13,8 @@
 import Protolude
 
 data PushEvent = PushEvent
-  { eventTimestamp :: UTCTime,
+  { -- TODO: newtype a monotonic clock
+    eventTimestamp :: UTCTime,
     eventPushId :: Protocol.PushRequestId,
     eventMessage :: PushEventMessage
   }
diff --git a/src/Cachix/Client/Daemon/Types/PushManager.hs b/src/Cachix/Client/Daemon/Types/PushManager.hs
--- a/src/Cachix/Client/Daemon/Types/PushManager.hs
+++ b/src/Cachix/Client/Daemon/Types/PushManager.hs
@@ -5,12 +5,14 @@
 module Cachix.Client.Daemon.Types.PushManager
   ( PushManagerEnv (..),
     PushManager (..),
+    PushJobStore,
     PushJob (..),
     JobStatus (..),
     JobStats (..),
     PushResult (..),
     OnPushEvent,
     Task (..),
+    TimeoutOptions (..),
   )
 where
 
@@ -48,6 +50,10 @@
     pmTaskSemaphore :: QSem,
     -- | A callback for push events.
     pmOnPushEvent :: OnPushEvent,
+    -- | The timestamp of the most recent event. This is used to track activity internally.
+    pmLastEventTimestamp :: TVar UTCTime,
+    -- | The number of pending (uncompleted) jobs.
+    pmPendingJobCount :: TVar Int,
     pmLogger :: Logger
   }
 
@@ -115,5 +121,13 @@
   { jsCreatedAt :: UTCTime,
     jsStartedAt :: Maybe UTCTime,
     jsCompletedAt :: Maybe UTCTime
+  }
+  deriving stock (Eq, Show)
+
+data TimeoutOptions = TimeoutOptions
+  { -- | The maximum time to wait in seconds.
+    toTimeout :: Float,
+    -- | The interval at which to check the timeout condition.
+    toPollingInterval :: Float
   }
   deriving stock (Eq, Show)
diff --git a/src/Cachix/Client/Daemon/Types/SocketStore.hs b/src/Cachix/Client/Daemon/Types/SocketStore.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Client/Daemon/Types/SocketStore.hs
@@ -0,0 +1,23 @@
+module Cachix.Client.Daemon.Types.SocketStore (Socket (..), SocketId, SocketStore (..)) where
+
+import Control.Concurrent.STM.TVar (TVar)
+import Data.HashMap.Strict (HashMap)
+import Data.UUID (UUID)
+import qualified Network.Socket as Network (Socket)
+import Protolude
+
+data Socket = Socket
+  { socketId :: SocketId,
+    socket :: Network.Socket,
+    handlerThread :: Async ()
+  }
+
+instance Eq Socket where
+  (==) = (==) `on` socketId
+
+instance Ord Socket where
+  compare = comparing socketId
+
+type SocketId = UUID
+
+newtype SocketStore = SocketStore (TVar (HashMap SocketId Socket))
diff --git a/src/Cachix/Client/Daemon/Worker.hs b/src/Cachix/Client/Daemon/Worker.hs
--- a/src/Cachix/Client/Daemon/Worker.hs
+++ b/src/Cachix/Client/Daemon/Worker.hs
@@ -3,6 +3,7 @@
     stopWorkers,
     startWorker,
     stopWorker,
+    Immortal.Thread,
   )
 where
 
diff --git a/src/Cachix/Client/NixVersion.hs b/src/Cachix/Client/NixVersion.hs
--- a/src/Cachix/Client/NixVersion.hs
+++ b/src/Cachix/Client/NixVersion.hs
@@ -1,33 +1,64 @@
+-- TODO: we may need to revisit this when the various flavours of Nix start to diverge
 module Cachix.Client.NixVersion
   ( assertNixVersion,
     parseNixVersion,
+    minimalVersion,
   )
 where
 
+import Data.Either.Extra (mapLeft)
 import Data.Text as T
-import Data.Versions
+import Data.Versions hiding (versioning')
 import Protolude
 import System.Process (readProcessWithExitCode)
+import Text.Megaparsec (Parsec, anySingle, choice, eof, lookAhead, manyTill, parse)
+import qualified Text.Megaparsec as P
+import Text.Megaparsec.Char (digitChar)
 
+minimalVersion :: SemVer
+minimalVersion =
+  semver "2.0.1" & fromRight (panic "Couldn't parse minimalVersion")
+
 assertNixVersion :: IO (Either Text ())
 assertNixVersion = do
+  nixVersion <- fmap (>>= parseNixVersion) getRawNixVersion
+  return $ case nixVersion of
+    Left err -> Left err
+    Right ver
+      | ver < Ideal minimalVersion -> Left "Nix 2.0.2 or lower is not supported. Please upgrade: https://nixos.org/nix/"
+      | otherwise -> Right ()
+
+getRawNixVersion :: IO (Either Text Text)
+getRawNixVersion = do
   (exitcode, out, err) <- readProcessWithExitCode "nix-env" ["--version"] mempty
   unless (err == "") $ putStrLn $ "nix-env stderr: " <> err
   return $ case exitcode of
     ExitFailure i -> Left $ "'nix-env --version' exited with " <> show i
-    ExitSuccess -> parseNixVersion $ toS out
+    ExitSuccess -> Right (toS out)
 
-parseNixVersion :: Text -> Either Text ()
-parseNixVersion output =
-  let verStr = T.drop 14 $ T.strip output
-      err = "Couldn't parse 'nix-env --version' output: " <> output
-   in case versioning verStr of
-        Left _ -> Left err
-        Right ver
-          | verStr == "" -> Left err
-          | ver < Ideal minimalVersion -> Left "Nix 2.0.2 or lower is not supported. Please upgrade: https://nixos.org/nix/"
-          | otherwise -> Right ()
+parseNixVersion :: Text -> Either Text Versioning
+parseNixVersion input =
+  mapLeft fromParsingError $ parse nixVersionParser "Nix version" input
+  where
+    fromParsingError pe =
+      unlines
+        [ "Couldn't parse 'nix-env --version' output: " <> input,
+          T.pack $ errorBundlePretty pe
+        ]
 
-minimalVersion :: SemVer
-minimalVersion =
-  semver "2.0.1" & fromRight (panic "Couldn't parse minimalVersion")
+-- | Parses a semver string out of the output of `nix-env --version` or `nix --version`.
+nixVersionParser :: Parsec Void Text Versioning
+nixVersionParser = do
+  _ <- manyTill anySingle (lookAhead digitChar)
+  v <- versioning'
+  _ <- manyTill anySingle eof
+  pure v
+
+-- | Same as `versioning'`, but without the restriction of preceding eof.
+versioning' :: Parsec Void Text Versioning
+versioning' =
+  choice
+    [ P.try (fmap Ideal semver'),
+      P.try (fmap General version'),
+      fmap Complex mess'
+    ]
diff --git a/src/Cachix/Deploy/Lock.hs b/src/Cachix/Deploy/Lock.hs
--- a/src/Cachix/Deploy/Lock.hs
+++ b/src/Cachix/Deploy/Lock.hs
@@ -1,6 +1,7 @@
 module Cachix.Deploy.Lock
   ( defaultLockDirectory,
     getLockDirectory,
+    newLockFilePath,
     readPidFile,
     withTryLock,
     withTryLockAndPid,
@@ -24,6 +25,11 @@
 
 defaultLockDirectory :: FilePath
 defaultLockDirectory = "cachix" </> "deploy" </> "locks"
+
+newLockFilePath :: FilePath -> IO FilePath
+newLockFilePath path = do
+  lockDirectory <- getLockDirectory
+  return $ lockDirectory </> path <.> lockExtension
 
 getLockDirectory :: IO FilePath
 getLockDirectory = do
diff --git a/test/Daemon/PushManagerSpec.hs b/test/Daemon/PushManagerSpec.hs
--- a/test/Daemon/PushManagerSpec.hs
+++ b/test/Daemon/PushManagerSpec.hs
@@ -6,6 +6,8 @@
 import qualified Cachix.Client.Daemon.PushManager.PushJob as PushJob
 import Cachix.Client.Daemon.Types.PushManager
 import Cachix.Client.OptionsParser (defaultPushOptions)
+import Control.Concurrent.Async (concurrently_)
+import Control.Concurrent.STM.TVar
 import Control.Retry (defaultRetryStatus)
 import qualified Data.Set as Set
 import Data.Time (getCurrentTime)
@@ -113,6 +115,41 @@
               prSkippedPaths = mempty
             }
 
+    describe "graceful shutdown" $ do
+      it "shuts down with no jobs" $ do
+        logger <- Log.new "daemon" Nothing Log.Debug
+        pm <- newPushManagerEnv defaultPushOptions logger mempty
+        stopPushManager timeoutOptions pm
+
+      it "shuts down after jobs complete" $ do
+        let paths = ["foo"]
+        let longTimeoutOptions = TimeoutOptions {toTimeout = 10.0, toPollingInterval = 0.1}
+        logger <- Log.new "daemon" Nothing Log.Debug
+        pm <- newPushManagerEnv defaultPushOptions logger mempty
+
+        _ <- runPushManager pm $ do
+          let request = Protocol.PushRequest {Protocol.storePaths = paths}
+          addPushJob request
+
+        concurrently_ (stopPushManager longTimeoutOptions pm) $
+          runPushManager pm $
+            for_ paths pushStorePathDone
+
+      it "shuts down on job stall" $ do
+        logger <- Log.new "daemon" Nothing Log.Debug
+        pm <- newPushManagerEnv defaultPushOptions logger mempty
+        _ <- runPushManager pm $ do
+          let request = Protocol.PushRequest {Protocol.storePaths = ["foo"]}
+          addPushJob request
+
+        stopPushManager timeoutOptions pm
+
+  describe "STM" $
+    describe "timeout" $ do
+      it "times out a transaction after n seconds" $ do
+        timestamp <- newTVarIO =<< getCurrentTime
+        atomicallyWithTimeout timeoutOptions timestamp retry
+
 withPushManager :: (PushManagerEnv -> IO a) -> IO a
 withPushManager f = do
   logger <- liftIO $ Log.new "daemon" Nothing Log.Debug
@@ -120,3 +157,6 @@
 
 inPushManager :: PushManager a -> IO a
 inPushManager f = withPushManager (`runPushManager` f)
+
+timeoutOptions :: TimeoutOptions
+timeoutOptions = TimeoutOptions {toTimeout = 0.2, toPollingInterval = 0.1}
diff --git a/test/NixVersionSpec.hs b/test/NixVersionSpec.hs
--- a/test/NixVersionSpec.hs
+++ b/test/NixVersionSpec.hs
@@ -1,23 +1,39 @@
-module NixVersionSpec where
+module NixVersionSpec (spec) where
 
 import Cachix.Client.NixVersion
+import Data.Versions (Versioning, versioning)
 import Protolude
 import Test.Hspec
 
 spec :: Spec
-spec = describe "parseNixVersion" $ do
-  it "parses 'nix-env (Nix) 2.0' as Nix20" $
-    parseNixVersion "nix-env (Nix) 2.0"
-      `shouldBe` Left "Nix 2.0.2 or lower is not supported. Please upgrade: https://nixos.org/nix/"
-  it "parses 'nix-env (Nix) 2.0.1' as Nix201" $
-    parseNixVersion "nix-env (Nix) 2.0.1"
-      `shouldBe` Right ()
-  it "parses 'nix-env (Nix) 1.11.13' as Nix1XX" $
-    parseNixVersion "nix-env (Nix) 1.11.13"
-      `shouldBe` Left "Nix 2.0.2 or lower is not supported. Please upgrade: https://nixos.org/nix/"
-  it "parses 'nix-env (Nix) 2.0.2' as Nix202" $
-    parseNixVersion "nix-env (Nix) 2.0.2"
-      `shouldBe` Right ()
-  it "fails with unknown string 'foobar'" $
-    parseNixVersion "foobar"
-      `shouldBe` Left "Couldn't parse 'nix-env --version' output: foobar"
+spec = do
+  describe "parseNixVersion" $ do
+    it "fails with unknown string 'foobar'" $
+      parseNixVersion "foobar" `shouldSatisfy` isLeft
+
+    it "parses out a semver out of an unexpected string" $
+      parseNixVersion "foobar 2.0.1 other stuff"
+        `shouldBe` Right (toVersioning "2.0.1")
+
+    describe "Nix" $
+      runParserTests
+        [ ("nix-env (Nix) 2.0", toVersioning "2.0"),
+          ("nix-env (Nix) 2.0.1", toVersioning "2.0.1"),
+          ("nix-env (Nix) 2.0.1\n", toVersioning "2.0.1")
+        ]
+
+    describe "Lix" $
+      runParserTests
+        [ ("nix-env (Lix, like Nix) 2.90-beta.0", toVersioning "2.90-beta.0"),
+          ("nix-env (Lix, like Nix) 2.90-beta.1-lixpre20240506-b6799ab", toVersioning "2.90-beta.1-lixpre20240506-b6799ab")
+        ]
+
+runParserTests :: [(Text, Versioning)] -> Spec
+runParserTests tests = do
+  forM_ tests $ \(input, expected) ->
+    it ("parses '" <> toS input <> "'") $
+      parseNixVersion input `shouldBe` Right expected
+
+toVersioning :: Text -> Versioning
+toVersioning str =
+  versioning str & fromRight (panic "Couldn't parse version")
