packages feed

ephemeral-pg-0.3.0.0: test/StaleInstances.hs

module StaleInstances (spec, childMode) where

import Control.Concurrent (forkIO, newEmptyMVar, putMVar, takeMVar, threadDelay, throwTo)
import Control.Exception (AsyncException (ThreadKilled), bracket, finally, throwIO, try)
import Control.Monad (forM_, forever, when)
import Data.IORef
import Data.Monoid (Last (..))
import EphemeralPg qualified as Pg
import EphemeralPg.Config qualified as Config
import EphemeralPg.Database (PostgresProcess (..))
import EphemeralPg.Internal.Cache (getCacheDirectory, getCacheKey)
import EphemeralPg.Internal.Instance
import EphemeralPg.Internal.ProcessIdentity
import EphemeralPg.Internal.Sweep qualified as Sweep
import Hasql.Connection qualified as Connection
import System.Directory qualified as D
import System.Environment (getEnv, getExecutablePath, setEnv)
import System.FilePath
import System.IO
import System.IO.Temp (withTempDirectory)
import System.Posix.Files (setFileMode)
import System.Posix.Process (getProcessID)
import System.Posix.Signals (sigKILL, signalProcess)
import System.Posix.User (getEffectiveUserID)
import System.Process
import System.Timeout (timeout)
import Test.Hspec

childMode :: [String] -> IO Bool
childMode ["--stale-lock-child", root] = do
  (path, lease) <- registerInstance root
  putStrLn path
  hFlush stdout
  forever (threadDelay 1000000) `finally` releaseInstance lease
childMode ["--stale-db-child", root, mode] = do
  let config = Pg.defaultConfig {Pg.temporaryRoot = Last (Just root)}
      cache = Pg.defaultCacheConfig {Pg.root = Just (root </> "templates"), Pg.enabled = mode /= "disabled"}
      launch = if mode `elem` ["uncached", "legacy"] then Pg.start config else Pg.startCached config cache
  when (mode `elem` ["warm", "fallback"]) $ launch >>= either (fail . show) Pg.stop
  when (mode == "fallback") $ do
    let bin = root </> "bin"
    D.createDirectory bin
    writeFile (bin </> "cp") "#!/bin/sh\nexit 1\n"
    setFileMode (bin </> "cp") 0o700
    previous <- getEnv "PATH"
    setEnv "PATH" (bin <> ":" <> previous)
  db <- launch >>= either (fail . show) pure
  print (db.dataDirectory, db.process.pid)
  hFlush stdout
  forever (threadDelay 1000000) `finally` Pg.stop db
childMode _ = pure False

spec :: Spec
spec = describe "Stale instances" $ do
  it "excludes same-process claims and survives directory replacement" $
    withTempDirectory "/tmp" "epg" $ \rawRoot -> do
      root <- D.canonicalizePath rawRoot
      (path, lease) <- registerInstance root
      registry <- registryFor root
      let claim = claimInstance registry path $ \lock -> (lock == Nothing) `shouldBe` True
      ( do
          claim
          D.removeDirectory path
          claim
          D.createDirectory path
          claim
        )
        `finally` releaseInstance lease
      claimInstance registry path $ \lock -> (lock /= Nothing) `shouldBe` True
      releaseInstance lease
  it "releases ownership after a consumer is SIGKILLed" $
    withTempDirectory "/tmp" "epg" $ \rawRoot -> do
      root <- D.canonicalizePath rawRoot
      exe <- getExecutablePath
      bracket
        (createProcess (proc exe ["--stale-lock-child", root]) {std_out = CreatePipe})
        (\(_, output, _, process) -> terminateProcess process >> waitForProcess process >> mapM_ hClose output)
        $ \(_, output, _, process) -> case output of
          Nothing -> expectationFailure "Missing child pipe"
          Just handle -> do
            path <- timeout 5000000 (hGetLine handle) >>= maybe (fail "Child readiness timed out") pure
            registry <- registryFor root
            claimInstance registry path $ \lock -> (lock == Nothing) `shouldBe` True
            pid <- getPid process >>= maybe (fail "Missing child PID") pure
            signalProcess sigKILL pid
            _ <- waitForProcess process
            claimInstance registry path $ \lock -> (lock /= Nothing) `shouldBe` True
  it "classifies this live process without guessing identity" $ do
    pid <- getProcessID
    observation <- systemInspector.inspect pid
    case observation of
      Present ident -> ident.pid `shouldBe` pid
      other -> expectationFailure (show other)
  it "rejects malformed and unsafe PID records" $ do
    mapM_
      (\p -> parsePidRecord (p <> "\n/tmp/data\n1\n5432\n/tmp\nlocalhost\n1 1\nready\n") `shouldBe` Nothing)
      ["-2", "0", "1", "999999999999999999999999999"]
    parsePidRecord "123\n/tmp/data\n1\n" `shouldBe` Nothing

  it "removes dead tracked initialization debris exactly once across concurrent sweeps" $
    fixture $ \config path -> do
      first <- newEmptyMVar
      second <- newEmptyMVar
      _ <- forkIO $ Sweep.sweepWith goneInspector config >>= putMVar first
      _ <- forkIO $ Sweep.sweepWith goneInspector config >>= putMVar second
      results <- (<>) <$> takeMVar first <*> takeMVar second
      [p | (p, Sweep.Removed) <- results] `shouldBe` [path]
      Sweep.sweepWith goneInspector config `shouldReturn` []
  it "preserves missing legacy PID files, symlinks, permanent paths and malformed metadata" $
    fixture $ \config path -> do
      registry <- registryFor (takeDirectory path)
      writeFile (recordPath registry path) "invalid"
      Sweep.sweepWith goneInspector config `shouldReturn` [(path, Sweep.Failed)]
      D.removeFile (recordPath registry path)
      Sweep.sweepWith goneInspector config `shouldReturn` [(path, Sweep.Uncertain)]
      let link = takeDirectory path </> "ephpg-data-link"
      D.createDirectoryLink path link
      outcomes <- Sweep.sweepWith goneInspector config {Config.dataDirectory = Pg.DirectoryPermanent path}
      outcomes `shouldSatisfy` elem (path, Sweep.Active)
      outcomes `shouldSatisfy` elem (link, Sweep.Failed)
      D.doesDirectoryExist path `shouldReturn` True
  it "preserves uncertain owners and failed process enumeration" $
    fixture $ \config path -> do
      let uncertain = goneInspector {inspect = \_ -> pure (Unknown "permission denied")}
      Sweep.sweepWith uncertain config `shouldReturn` [(path, Sweep.Uncertain)]
      Sweep.sweepWith goneInspector {enumerate = pure (Left "enumeration failed")} config
        `shouldReturn` [(path, Sweep.Uncertain)]
      D.doesDirectoryExist path `shouldReturn` True
  it "rejects symlinked metadata and lifetime lock files" $
    fixture $ \config path -> do
      registry <- registryFor (takeDirectory path)
      let metadata = recordPath registry path
          saved = metadata <> ".saved"
          lock = registry </> takeFileName path <.> "lock"
      D.renameFile metadata saved
      D.createFileLink saved metadata
      Sweep.sweepWith goneInspector config `shouldReturn` [(path, Sweep.Failed)]
      D.removeFile metadata
      D.renameFile saved metadata
      D.renameFile lock (lock <> ".saved")
      D.createFileLink (lock <> ".saved") lock
      Sweep.sweepWith goneInspector config `shouldReturn` [(path, Sweep.Failed)]
      D.doesDirectoryExist path `shouldReturn` True
  it "does not remove data when ownership metadata changes during inspection" $
    fixture $ \config path -> do
      registry <- registryFor (takeDirectory path)
      let inspector = goneInspector {inspect = \_ -> writeFile (recordPath registry path) "changed" >> pure Gone}
      Sweep.sweepWith inspector config `shouldReturn` [(path, Sweep.Failed)]
      D.doesDirectoryExist path `shouldReturn` True
  it "propagates asynchronous cancellation" $
    fixture $ \config _ -> do
      result <- try @AsyncException $ Sweep.sweepWith goneInspector {inspect = \_ -> throwIO ThreadKilled} config
      result `shouldBe` Left ThreadKilled
  it "rejects replacement of the candidate during inspection" $
    fixture $ \config path -> do
      changed <- newIORef False
      let inspector =
            goneInspector
              { inspect = \_ -> do
                  old <- atomicModifyIORef' changed (\x -> (True, x))
                  if old then pure () else D.renameDirectory path (path <> "-saved") >> D.createDirectory path
                  pure Gone
              }
      results <- Sweep.sweepWith inspector config
      results `shouldSatisfy` elem (path, Sweep.Failed)
      D.doesDirectoryExist path `shouldReturn` True

  it "never signals reused PIDs, changed start identities, or non-orphan legacy servers" $
    fixture $ \config path -> do
      uid <- getEffectiveUserID
      let ident =
            Identity
              12345
              1
              uid
              1700000000
              "/test/postgres"
              ("/test/postgres -D " <> path <> " -k /tmp/socket")
              (Just path)
              False
      writeFile (path </> "postmaster.pid") (pidText path)
      signals <- newIORef []
      let run current =
            Sweep.sweepWith
              goneInspector
                { inspect = \pid -> pure $ if pid == 12345 then Present current else Gone,
                  interrupt = \pid -> modifyIORef' signals (pid :)
                }
              config
      run ident {command = "/test/unrelated"} `shouldReturn` [(path, Sweep.Uncertain)]
      run (Identity ident.pid ident.parent ident.uid 1800000000 ident.command ident.arguments ident.workingDirectory ident.zombie) `shouldReturn` [(path, Sweep.Uncertain)]
      registry <- registryFor (takeDirectory path)
      D.removeFile (recordPath registry path)
      writeFile (path </> "PG_VERSION") "17\n"
      run ident {parent = 42} `shouldReturn` [(path, Sweep.Active)]
      readIORef signals `shouldReturn` []
  it "leaves a timed-out server intact without escalating" $
    fixture $ \config path -> do
      uid <- getEffectiveUserID
      let ident =
            Identity
              12345
              1
              uid
              1700000000
              "/test/postgres"
              ("/test/postgres -D " <> path <> " -k /tmp/socket")
              (Just path)
              False
      writeFile (path </> "postmaster.pid") (pidText path)
      signals <- newIORef []
      Sweep.sweepWith
        goneInspector
          { inspect = \pid -> pure $ if pid == 12345 then Present ident else Gone,
            enumerate = pure (Right [ident]),
            interrupt = \pid -> modifyIORef' signals (pid :)
          }
        config
        `shouldReturn` [(path, Sweep.TimedOut)]
      readIORef signals `shouldReturn` [12345]
      D.doesDirectoryExist path `shouldReturn` True
  it "preserves an orphaned initialization child" $
    fixture $ \config path -> do
      uid <- getEffectiveUserID
      let ident = Identity 12345 1 uid 1700000000 "/test/initdb" "initdb" (Just path) False
      Sweep.sweepWith goneInspector {enumerate = pure (Right [ident])} config
        `shouldReturn` [(path, Sweep.Uncertain)]

  it "combines the automatic-sweep setting with right-biased identity" $ do
    let enabled = mempty {Pg.sweepStaleOnStart = Last (Just True)}
        disabled = mempty {Pg.sweepStaleOnStart = Last (Just False)}
    (mempty <> disabled).sweepStaleOnStart `shouldBe` Last (Just False)
    (disabled <> mempty).sweepStaleOnStart `shouldBe` Last (Just False)
    (enabled <> disabled).sweepStaleOnStart `shouldBe` Last (Just False)
    Pg.defaultConfig.sweepStaleOnStart `shouldBe` Last (Just True)
  it "preserves permanent data through both startup variants" $
    withTempDirectory "/tmp" "epg" $ \rawRoot -> do
      root <- D.canonicalizePath rawRoot
      forM_ [False, True] $ \cached -> do
        let path = root </> ("ephpg-data-permanent-" <> show cached)
            config = Pg.defaultConfig {Pg.temporaryRoot = Last (Just root), Config.dataDirectory = Pg.DirectoryPermanent path}
            launch = if cached then Pg.startCached config Pg.defaultCacheConfig else Pg.start config
        bracket (launch >>= either (fail . show) pure) Pg.stop $ \db -> db.dataDirectory `shouldBe` path
        Pg.sweepStaleInstances config `shouldReturn` []
        D.doesFileExist (path </> "PG_VERSION") `shouldReturn` True
  it "cleans failed startup before and after PostgreSQL launch" $
    withTempDirectory "/tmp" "epg" $ \rawRoot -> do
      root <- D.canonicalizePath rawRoot
      let config = Pg.defaultConfig {Pg.temporaryRoot = Last (Just root)}
      forM_
        [ config {Pg.initDbArgs = ["--invalid-ephemeral-test"]},
          config {Config.databaseName = "broken", Pg.createDbArgs = ["--invalid-ephemeral-test"]}
        ]
        $ \broken -> do
          result <- Pg.start broken
          case result of Left _ -> pure (); Right db -> Pg.stop db >> expectationFailure "Expected startup failure"
          names <- D.listDirectory root
          filter (\name -> take 11 name == "ephpg-data-") names `shouldBe` []

  forM_ ["initdb", "cp", "createdb"] $ \tool ->
    it ("protects ownership and propagates cancellation at the " <> tool <> " startup barrier") $
      withTempDirectory "/tmp" "epg" $ \rawRoot -> do
        root <- D.canonicalizePath rawRoot
        let config = Pg.defaultConfig {Pg.temporaryRoot = Last (Just root), Config.databaseName = "barrierdb"}
            cache = Pg.defaultCacheConfig {Pg.root = Just (root </> "templates")}
            launch = if tool == "cp" then Pg.startCached config cache else Pg.start config
            bin = root </> "bin"
            marker = root </> "barrier"
        when (tool == "cp") $ launch >>= either (fail . show) Pg.stop
        D.createDirectory bin
        writeFile (bin </> tool) ("#!/bin/sh\nprintf ready > " <> show marker <> "\nexec sleep 60\n")
        setFileMode (bin </> tool) 0o700
        bracket (getEnv "PATH") (setEnv "PATH") $ \previous -> do
          setEnv "PATH" (bin <> ":" <> previous)
          done <- newEmptyMVar
          worker <- forkIO $ try @AsyncException launch >>= putMVar done
          let awaitBarrier = D.doesFileExist marker >>= \ready -> if ready then pure () else threadDelay 10000 >> awaitBarrier
          flip finally (throwTo worker ThreadKilled) $ do
            timeout 15000000 awaitBarrier >>= maybe (fail "Startup barrier timed out") pure
            Pg.sweepStaleInstances config `shouldReturn` []
            throwTo worker ThreadKilled
            result <- timeout 10000000 (takeMVar done) >>= maybe (fail "Cancellation cleanup timed out") pure
            case result of Left ThreadKilled -> pure (); _ -> expectationFailure "Cancellation was swallowed"
            names <- D.listDirectory root
            filter (\name -> take 11 name == "ephpg-data-") names `shouldBe` []

  forM_ ["uncached", "cold", "warm", "disabled", "fallback", "legacy"] $ \mode ->
    forM_ [False, True] $ \automatic ->
      it ("recovers " <> mode <> " orphan with " <> (if automatic then "startup" else "explicit sweep") <> " and preserves live connections") $
        withTempDirectory "/tmp" "epg" $ \rawRoot -> do
          root <- D.canonicalizePath rawRoot
          let config = Pg.defaultConfig {Pg.temporaryRoot = Last (Just root)}
          bracket (Pg.start config >>= either (fail . show) pure) Pg.stop $ \survivor -> do
            exe <- getExecutablePath
            bracket
              (createProcess (proc exe ["--stale-db-child", root, mode]) {std_out = CreatePipe})
              (\(_, output, _, process) -> terminateProcess process >> waitForProcess process >> mapM_ hClose output)
              $ \(_, output, _, process) -> case output of
                Nothing -> expectationFailure "Missing child pipe"
                Just handle -> do
                  line <- timeout 15000000 (hGetLine handle) >>= maybe (fail "Child readiness timed out") pure
                  let (path, pgPid) = read line
                  let cleanup = do
                        _ <- Sweep.sweepStaleInstances config
                        exists <- D.doesFileExist (path </> "postmaster.pid")
                        if exists
                          then do
                            _ <- readProcessWithExitCode "pg_ctl" ["-D", path, "-m", "fast", "-w", "stop"] ""
                            pure ()
                          else pure ()
                  ( do
                      record <- boundedRead (path </> "postmaster.pid") >>= maybe (fail "Bad PID record") pure . parsePidRecord
                      observed <- systemInspector.inspect pgPid
                      case observed of
                        Present ident -> do
                          uid <- getEffectiveUserID
                          (ident, matchesServer uid record ident) `shouldSatisfy` snd
                          ident.pid `shouldBe` record.pid
                          -- Report enough evidence to diagnose OS identity disagreements.
                          abs (ident.started - record.started) `shouldSatisfy` (<= 2)
                        other -> expectationFailure (show other)
                      pid <- getPid process >>= maybe (fail "Missing child PID") pure
                      signalProcess sigKILL pid
                      _ <- waitForProcess process
                      systemInspector.inspect pgPid >>= (\case Present _ -> pure (); other -> expectationFailure (show other))
                      when (mode == "legacy") $ do
                        registry <- registryFor root
                        D.removeFile (recordPath registry path)
                      -- Opt-out startup must leave the orphan available for explicit cleanup.
                      bracket (Pg.start config {Pg.sweepStaleOnStart = Last (Just False)} >>= either (fail . show) pure) Pg.stop $ \_ ->
                        D.doesDirectoryExist path `shouldReturn` True
                      if automatic
                        then bracket (Pg.start config >>= either (fail . show) pure) Pg.stop $ \_ -> pure ()
                        else Pg.sweepStaleInstances config `shouldReturn` [path]
                      when (mode `elem` ["cold", "warm", "fallback"]) $ do
                        key <- getCacheKey config >>= either (fail . show) pure
                        cacheDir <- getCacheDirectory key (Just (root </> "templates"))
                        version <- readFile (cacheDir </> "data" </> "PG_VERSION")
                        bracket (Pg.startCached config Pg.defaultCacheConfig {Pg.root = Just (root </> "templates")} >>= either (fail . show) pure) Pg.stop $ \_ -> pure ()
                        readFile (cacheDir </> "data" </> "PG_VERSION") `shouldReturn` version
                      connected <- Connection.acquire (Pg.connectionSettings survivor)
                      either (fail . show) Connection.release connected
                      D.doesDirectoryExist path `shouldReturn` False
                      Sweep.sweepStaleInstances config `shouldReturn` []
                      D.doesDirectoryExist survivor.dataDirectory `shouldReturn` True
                    )
                    `finally` cleanup

goneInspector :: Inspector
goneInspector =
  Inspector
    { inspect = \_ -> pure Gone,
      enumerate = pure (Right []),
      interrupt = \_ -> expectationFailure "Unexpected signal"
    }

fixture :: (Pg.Config -> FilePath -> IO a) -> IO a
fixture action = withTempDirectory "/tmp" "epg" $ \rawRoot -> do
  root <- D.canonicalizePath rawRoot
  (path, lease) <- registerInstance root
  registry <- registryFor root
  record <- readRecord registry path
  releaseInstance lease
  writeFile (recordPath registry path) (show record {owner = 999999})
  action Pg.defaultConfig {Pg.temporaryRoot = Last (Just root)} path

pidText :: FilePath -> String
pidText path = "12345\n" <> path <> "\n1700000000\n5432\n/tmp/socket\n127.0.0.1\n1 1\nready\n"