packages feed

hix-0.9.0: lib/Hix/Managed/Build/NixProcess.hs

module Hix.Managed.Build.NixProcess where

import Control.Concurrent.STM (STM, atomically)
import qualified Data.ByteString.Char8 as ByteString
import qualified Data.Text as Text
import Exon (exon)
import Path (Abs, Dir, Path, toFilePath)
import System.IO (BufferMode (LineBuffering), Handle, hSetBuffering)
import System.IO.Error (tryIOError)
import System.Process.Typed (
  ExitCode (ExitFailure, ExitSuccess),
  ProcessConfig,
  byteStringOutput,
  createPipe,
  getStderr,
  getStdout,
  inherit,
  nullStream,
  proc,
  setStderr,
  setStdout,
  setWorkingDir,
  waitExitCode,
  withProcessTerm,
  )
import System.Timeout (timeout)

import Hix.Data.Error (Error)
import Hix.Data.Monad (LogLevel (LogDebug), M)
import Hix.Error (pathText)
import qualified Hix.Log as Log
import Hix.Managed.Build.NixOutput (outputParse, runOutputState)
import qualified Hix.Managed.Data.BuildConfig
import Hix.Managed.Data.BuildConfig (BuildConfig)
import Hix.Managed.Data.NixOutput (OutputResult (..))
import Hix.Managed.Data.StageState (BuildFailure (..), BuildResult (..))
import Hix.Monad (shouldLog, withLowerTry')

data OutputConfig =
  OutputDebug
  |
  OutputParse
  |
  OutputIgnore

data NixProcResult =
  NixProcSuccess [Text]
  |
  NixProcFailure Text
  deriving stock (Eq, Show, Generic)

outputLines ::
  MonadIO m =>
  (ByteString -> m ()) ->
  Handle ->
  m (Maybe Text)
outputLines parse handle = do
  liftIO (hSetBuffering handle LineBuffering)
  spin mempty
  where
    spin buf = do
      liftIO (tryIOError (ByteString.hGet handle 4096)) >>= \case
        Left err -> pure (Just (show err))
        Right new ->
          join <$> for (completeLines buf new) \ (lns, newBuf) -> do
            for_ lns parse
            spin newBuf

    completeLines = \cases
      -- Empty output means the handle was closed, empty buffer means we can terminate
      "" "" ->
        Nothing
      -- First iteration after handle was closed, need to emit the last line
      buf "" ->
        Just ([buf], "")
      buf new ->
        Just (breakNl [] (buf <> new))

    breakNl acc s =
      case ByteString.break (== '\n') s of
        (rest, "") -> (reverse acc, rest)
        (ln, rest) -> breakNl (ln : acc) (ByteString.drop 1 rest)

nixProc ::
  OutputConfig ->
  Path Abs Dir ->
  [Text] ->
  Text ->
  [Text] ->
  M (ProcessConfig () (STM LByteString) (Maybe Handle))
nixProc output root cmd installable extra = do
  Log.debug [exon|Running nix at '#{pathText root}' with args #{show args}|]
  pure conf
  where
    conf =
      err $
      setStdout byteStringOutput $
      setWorkingDir (toFilePath root) $
      proc "nix" args

    err = case output of
      OutputParse -> setStderr (Just <$> createPipe)
      OutputDebug -> setStderr (Nothing <$ inherit)
      OutputIgnore -> setStderr (Nothing <$ nullStream)

    args = toString <$> cmd ++ [exon|path:#{".#"}#{installable}|] : extra ++ logArgs

    logArgs = case output of
      OutputParse -> ["--log-format", "internal-json"]
      OutputDebug -> ["-L"]
      OutputIgnore -> []

runProc ::
  BuildConfig ->
  (Handle -> M a) ->
  ProcessConfig () (STM LByteString) (Maybe Handle) ->
  M (Maybe (Maybe (Either Error a), [Text], ExitCode))
runProc buildConf pipeHandler conf =
  withLowerTry' \ lower -> withProcessTerm conf \ prc -> do
    limit do
      result <- traverse (lower . pipeHandler) (getStderr prc)
      res <- waitExitCode prc
      stdout <- atomically (getStdout prc)
      pure (result, Text.lines (decodeUtf8 stdout), res)
  where
    limit | Just t <- buildConf.timeout
          , t > 0
          = timeout (coerce t * 1_000_000)
          | otherwise = fmap Just

outputResult ::
  Maybe (Either Error (Maybe Text, OutputResult)) ->
  [Text] ->
  ExitCode ->
  BuildResult
outputResult result stdout = \case
  ExitSuccess -> BuildSuccess stdout
  ExitFailure _ -> BuildFailure (maybe UnknownFailure failure result)
  where
    failure = \case
      Right (unhandled, OutputResult {unknownMessages, failedPackages}) ->
        let alt = maybe UnknownFailure UnexpectedFailure (nonEmpty unknownMessages <> (pure <$> unhandled))
        in maybe alt PackageFailure failedPackages
      Left err ->
        AppFailure err

nixBuild ::
  BuildConfig ->
  Path Abs Dir ->
  Text ->
  M BuildResult
nixBuild buildConf root attr = do
  debug <- shouldLog LogDebug
  conf <- nixProc (outputHandler debug) root args [exon|__hix-internal__.#{attr}|] []
  runProc buildConf runOutput conf <&> \case
    Just (result, stdout, code) ->
      outputResult result stdout code
    Nothing -> BuildFailure (TimeoutFailure [])
  where
    args = ["build", "--no-link", "--show-trace", "--print-out-paths"]

    runOutput handle =
      runOutputState (outputLines (outputParse buildConf) handle)

    outputHandler debug
      | buildConf.disableNixMonitor =
        if debug || buildConf.buildOutput
        then OutputDebug
        else OutputIgnore
      | otherwise =
        OutputParse