packages feed

nova-nix-0.10.0.0: src/Nix/Builder/Log.hs

{-# LANGUAGE CPP #-}

-- | A builder's output, on its way to the build log.
--
-- Upstream gives the builder one channel for its standard output and
-- standard error, so what it wrote arrives in the order it wrote it,
-- and its default logger prints each line to standard error as the line
-- completes, filtered for the terminal.  The line discipline and the
-- filter are pure ('feedBuildLog', 'endBuildLog', 'filterLogLine'); the
-- pipe and the copy loop are the IO around them.
module Nix.Builder.Log
  ( -- * Line discipline
    PendingLine,
    emptyPendingLine,
    feedBuildLog,
    endBuildLog,

    -- * Terminal filter
    EscapeFilter (..),
    filterLogLine,

    -- * Streaming
    openLogPipe,
    streamBuildLog,
  )
where

import Data.Bits ((.&.))
import qualified Data.ByteString as BS
import qualified Data.ByteString.Builder as BB
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as BL
import Data.Foldable (for_)
import Data.Word (Word8)
import Nix.Environment (EnvLookup (..), lookupEnvBytes)
import qualified System.Process as Proc

#if defined(mingw32_HOST_OS)

import Control.Exception (IOException, catch)
import System.IO (Handle, hIsTerminalDevice)

#else

import Control.Exception (IOException, catch, onException)
import GHC.IO.FD (fdFD)
import GHC.IO.Handle.FD (handleToFd)
import System.IO (Handle, hClose, hIsTerminalDevice)
import System.Posix.IO (FdOption (CloseOnExec), setFdOption)
import System.Posix.Types (Fd (..))

#endif

-- ---------------------------------------------------------------------------
-- Line discipline
-- ---------------------------------------------------------------------------

-- | The line a builder is writing, held as a terminal holds it: the
-- bytes left of the cursor, and the bytes from the cursor on, which the
-- next bytes written overwrite.  Upstream's @currentLogLine@ and
-- @currentLogLinePos@ (derivation-goal.hh at 2.24.9).
data PendingLine
  = PendingLine
      -- | The bytes left of the cursor in chunks, latest first, each
      -- shorter than the one after it ('pushChunk').
      ![BS.ByteString]
      -- | The bytes from the cursor to the end of the line.
      !BS.ByteString

-- | No line started yet.
emptyPendingLine :: PendingLine
emptyPendingLine = PendingLine [] BS.empty

-- | Split builder output into the lines it completes, in order, and the
-- line it leaves pending, by upstream's rule
-- (@DerivationGoal::handleChildOutput@, derivation-goal.cc at 2.24.9):
-- a line feed ends the line, a carriage return moves the cursor back to
-- the start of the line so what follows overwrites it, and any other
-- byte is written at the cursor.  A CRLF therefore ends a line without
-- leaving the CR in it, and a progress counter redrawn after CRs leaves
-- its last state.  The lines do not depend on where the output was
-- split into reads.
feedBuildLog :: PendingLine -> BS.ByteString -> ([BS.ByteString], PendingLine)
feedBuildLog = go []
  where
    go done !pending bytes =
      let (run, rest) = BS8.break isControl bytes
          !written = writeAtCursor run pending
       in case BS8.uncons rest of
            Nothing -> (reverse done, written)
            Just ('\r', after) -> go done (toLineStart written) after
            -- The line feed, the only other byte the break stops at.
            Just (_, after) ->
              let !line = lineBytes written
               in go (line : done) emptyPendingLine after
    isControl c = c == '\n' || c == '\r'

-- | The line pending when the builder's output ends, which upstream's
-- @handleEOF@ flushes when it holds any bytes.
endBuildLog :: PendingLine -> Maybe BS.ByteString
endBuildLog pending
  | BS.null line = Nothing
  | otherwise = Just line
  where
    line = lineBytes pending

-- | Write bytes at the cursor, overwriting as many as they replace.
writeAtCursor :: BS.ByteString -> PendingLine -> PendingLine
writeAtCursor !run pending@(PendingLine before fromCursor)
  | BS.null run = pending
  | otherwise = PendingLine (pushChunk run before) (BS.drop (BS.length run) fromCursor)

-- | Add the latest chunk left of the cursor, merging it into the chunks
-- before it while it is at least as long as they are.  A line that
-- arrives a few bytes per read is then held in a number of chunks
-- logarithmic in its length, rather than one per read.
pushChunk :: BS.ByteString -> [BS.ByteString] -> [BS.ByteString]
pushChunk !newer chunks = case chunks of
  older : rest
    | BS.length newer >= BS.length older -> pushChunk (older <> newer) rest
  _ -> newer : chunks

-- | Move the cursor to the start of the line, keeping the line.
toLineStart :: PendingLine -> PendingLine
toLineStart pending = PendingLine [] (lineBytes pending)

-- | The whole line, cursor or not.
lineBytes :: PendingLine -> BS.ByteString
lineBytes (PendingLine before fromCursor) = BS.concat (reverse (fromCursor : before))

-- ---------------------------------------------------------------------------
-- Terminal filter
-- ---------------------------------------------------------------------------

-- | Which escape sequences a build-log line keeps on its way to the user.
data EscapeFilter
  = -- | Keep colour (an SGR sequence, CSI ending in @m@) and drop the rest,
    -- for a terminal that renders it.
    KeepColour
  | -- | Drop every escape sequence.
    DropEscapes
  deriving (Eq, Show)

-- | A build-log line as upstream's logger writes it to standard error
-- (@filterANSIEscapes@, terminal.cc at 2.24.9, at the unlimited width
-- @SimpleLogger::log@ passes): an escape sequence is dropped unless the
-- filter keeps it, a tab becomes spaces up to the next multiple of
-- eight columns, a bell or carriage return is dropped, and every other
-- byte is kept.  A column is one character as upstream groups one: a
-- UTF-8 lead byte with as many continuation bytes as it announces and
-- as follow it, or any other byte alone.
filterLogLine :: EscapeFilter -> BS.ByteString -> BS.ByteString
filterLogLine escapes = BL.toStrict . BB.toLazyByteString . go 0
  where
    go :: Int -> BS.ByteString -> BB.Builder
    go !column bytes =
      let (plain, rest) = BS.span isPlainAscii bytes
       in BB.byteString plain <> special (column + BS.length plain) rest
    special column bytes = case BS.uncons bytes of
      Nothing -> mempty
      Just (byte, rest)
        | byte == escapeByte ->
            let (escape, after) = escapeSequence rest
                escapeBytes = BS.cons byte (BS.take (BS.length rest - BS.length after) rest)
             in kept escape escapeBytes <> go column after
        | byte == tabByte ->
            let width = tabStop - column `mod` tabStop
             in BB.byteString (BS.replicate width spaceByte) <> go (column + width) rest
        | byte == bellByte || byte == carriageReturnByte -> go column rest
        | otherwise ->
            let continued = BS.takeWhile isContinuation (BS.take (continuationCount byte) rest)
             in BB.word8 byte <> BB.byteString continued <> go (column + 1) (BS.drop (BS.length continued) rest)
    kept escape escapeBytes
      | escapes == KeepColour && escape == ColourEscape = BB.byteString escapeBytes
      | otherwise = mempty
    isPlainAscii byte =
      byte < asciiLimit
        && byte /= escapeByte
        && byte /= tabByte
        && byte /= bellByte
        && byte /= carriageReturnByte

-- | What an escape sequence does, as far as the filter cares.
data EscapeKind = ColourEscape | OtherEscape
  deriving (Eq)

-- | The kind of escape the bytes after an ESC begin, and the bytes after
-- its sequence, which upstream's filter reads as a CSI's parameter,
-- intermediate and final bytes, or the one byte a two-byte escape takes,
-- or nothing.
escapeSequence :: BS.ByteString -> (EscapeKind, BS.ByteString)
escapeSequence bytes = case BS.uncons bytes of
  Just (byte, afterBracket)
    | byte == csiByte ->
        let afterParameters = BS.dropWhile (within csiParameterBytes) afterBracket
            afterIntermediates = BS.dropWhile (within csiIntermediateBytes) afterParameters
         in case BS.uncons afterIntermediates of
              Just (final, after)
                | within csiFinalBytes final -> (if final == sgrFinalByte then ColourEscape else OtherEscape, after)
              _ -> (OtherEscape, afterIntermediates)
    | within escapeFinalBytes byte -> (OtherEscape, afterBracket)
  _ -> (OtherEscape, bytes)

-- | How many continuation bytes a UTF-8 lead byte announces.
continuationCount :: Word8 -> Int
continuationCount lead
  | lead .&. 0xE0 == 0xC0 = 1
  | lead .&. 0xF0 == 0xE0 = 2
  | lead .&. 0xF8 == 0xF0 = 3
  | otherwise = 0

-- | Whether a byte continues a UTF-8 character.
isContinuation :: Word8 -> Bool
isContinuation byte = byte .&. 0xC0 == 0x80

-- | Whether a byte lies in an inclusive range.
within :: (Word8, Word8) -> Word8 -> Bool
within (low, high) byte = low <= byte && byte <= high

-- | The control bytes the filter treats specially, and the space a tab
-- expands to.
escapeByte, tabByte, bellByte, carriageReturnByte, spaceByte :: Word8
escapeByte = 0x1B
tabByte = 0x09
bellByte = 0x07
carriageReturnByte = 0x0D
spaceByte = 0x20

-- | The byte opening a control sequence after an ESC (@[@), and the
-- final byte of a colour one (@m@).
csiByte, sgrFinalByte :: Word8
csiByte = 0x5B
sgrFinalByte = 0x6D

-- | The ECMA-48 byte ranges upstream's filter reads a sequence by.
csiParameterBytes, csiIntermediateBytes, csiFinalBytes, escapeFinalBytes :: (Word8, Word8)
csiParameterBytes = (0x30, 0x3F)
csiIntermediateBytes = (0x20, 0x2F)
csiFinalBytes = (0x40, 0x7E)
escapeFinalBytes = (0x40, 0x5F)

-- | The first byte that is not ASCII.
asciiLimit :: Word8
asciiLimit = 0x80

-- | The columns between tab stops.
tabStop :: Int
tabStop = 8

-- | The filter upstream's logger applies (@isTTY@, terminal.cc at
-- 2.24.9): colour survives only on a terminal, with @TERM@ set to
-- something other than @dumb@, and neither @NO_COLOR@ nor @NOCOLOR@
-- set.  Upstream asks about its standard error; this asks about the
-- handle the log goes to, which is standard error by default.
escapeFilterFor :: Handle -> IO EscapeFilter
escapeFilterFor buildLog = do
  terminal <- hIsTerminalDevice buildLog
  term <- lookupEnvBytes termVar
  noColour <- traverse lookupEnvBytes noColourVars
  pure $
    if terminal && rendersColour term && all (== EnvUnset) noColour
      then KeepColour
      else DropEscapes
  where
    rendersColour term = case term of
      EnvUnset -> False
      EnvValue value -> value /= dumbTerm
      EnvUnpairedSurrogate -> True

-- | The variable naming the terminal type, and the type upstream reads
-- as rendering no colour.
termVar, dumbTerm :: BS.ByteString
termVar = "TERM"
dumbTerm = "dumb"

-- | The variables whose presence, whatever their value, turns colour off.
noColourVars :: [BS.ByteString]
noColourVars = ["NO_COLOR", "NOCOLOR"]

-- ---------------------------------------------------------------------------
-- Streaming
-- ---------------------------------------------------------------------------

-- | A pipe to hand a builder as both its standard output and its
-- standard error, as @(read end, write end)@.
--
-- One channel keeps the order the builder wrote in.  Upstream's channel
-- is a pseudoterminal: the builder's standard error is its slave side,
-- and standard output is a copy of it (@openSlave@ in
-- local-derivation-goal.cc and @commonChildInit@ in child.cc at
-- 2.24.9).  A pipe is the channel "System.Process" offers on both
-- platforms, and what a builder can tell apart is @isatty@, which
-- upstream's terminal answers yes: a program that line-buffers or
-- colours its output only on a terminal block-buffers and stays plain
-- here.
--
-- On POSIX both ends are close-on-exec, so the builder holds the pipe
-- only as its standard output and standard error, as upstream's child
-- does after @closeMostFDs@: a daemon that closes those lets the build
-- finish.  A process another thread spawned between the pipe's creation
-- and the flags being set would still inherit it; nova-nix runs one
-- build at a time.  On Windows the ends stay inheritable, so a process
-- spawned while the pipe is open inherits them.  With one build at a
-- time that is the builder's own tree, which the job object the build
-- runs in already holds it open for until the tree exits.
openLogPipe :: IO (Handle, Handle)

#if defined(mingw32_HOST_OS)

openLogPipe = Proc.createPipe

#else

openLogPipe = do
  ends@(readEnd, writeEnd) <- Proc.createPipe
  (closeOnExec readEnd *> closeOnExec writeEnd)
    `onException` (hClose readEnd *> hClose writeEnd)
  pure ends
  where
    closeOnExec end = do
      fd <- handleToFd end
      setFdOption (Fd (fdFD fd)) CloseOnExec True

#endif

-- | Copy a builder's output to the build log a line at a time as each
-- line completes, until every holder of the pipe's write end has
-- closed it.
--
-- A line goes out filtered ('filterLogLine', under 'escapeFilterFor'
-- the log) in one write ending in a line feed, as upstream's default
-- logger writes a build-log line to standard error (@SimpleLogger@,
-- logging.cc at 2.24.9).  Upstream first reads a line beginning
-- @\@nix @ as a JSON log message from the builder and does not print
-- one that parses (@handleJSONLogMessage@); here every line is printed.
-- Upstream points @NIX_LOG_FD@ at standard error so stdenv writes such
-- lines, and nova-nix sets no @NIX_LOG_FD@.
--
-- A line the log fails to take is dropped and the build goes on, as
-- upstream's @writeToStderr@ ignores a failing write (logging.cc), so a
-- reader of standard error that went away does not fail the build.  The
-- output is read to its end either way, so the builder never blocks on
-- a full pipe.
streamBuildLog :: Handle -> Handle -> IO ()
streamBuildLog fromBuilder buildLog = do
  escapes <- escapeFilterFor buildLog
  let writeLine line = BS.hPut buildLog (BS8.snoc (filterLogLine escapes line) '\n') `catch` dropLine
      go pending = do
        chunk <- BS.hGetSome fromBuilder readSize
        if BS.null chunk
          then for_ (endBuildLog pending) writeLine
          else do
            let (complete, rest) = feedBuildLog pending chunk
            for_ complete writeLine
            go rest
  go emptyPendingLine
  where
    dropLine :: IOException -> IO ()
    dropLine _ = pure ()

-- | How much builder output one read takes at most.  The lines written
-- do not depend on it.
readSize :: Int
readSize = 65536