{- System.Process enhancements, including additional ways of running
 - processes, logging, and amelorations for cases where FDs are not able to
 - be opened with close-on-exec.
 -
 - Copyright 2012-2025 Joey Hess <id@joeyh.name>
 -
 - License: BSD-2-clause
 -}

{-# LANGUAGE CPP, Rank2Types, LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# OPTIONS_GHC -fno-warn-tabs #-}

module Utility.Process (
	module X,
	StdHandle(..),
	readProcess,
	readProcess',
	readProcessEnv,
	writeReadProcessEnv,
	forceSuccessProcess,
	forceSuccessProcess',
	checkSuccessProcess,
	withNullHandle,
	noCreateProcessWhile,
	createProcess,
	withCreateProcess,
	waitForProcess,
	cleanupProcess,
	hGetLineUntilExitOrEOF,
	startInteractiveProcess,
	stdinHandle,
	stdoutHandle,
	stderrHandle,
	processHandle,
	showCmd,
	devNull,
) where

import qualified Utility.Process.Shim
import Utility.Process.Shim as X (CreateProcess(..), ProcessHandle, StdStream(..), CmdSpec(..), proc, getPid, getProcessExitCode, shell, terminateProcess, interruptProcessGroupOf, Pid)
import Utility.Misc
import Utility.Exception
import Utility.Monad
import Utility.Debug

import System.Exit
import System.IO
import Control.Monad.IO.Class
import Control.Concurrent.Async
import Control.Concurrent
import qualified Data.ByteString as S
import System.IO.Unsafe (unsafePerformIO)

data StdHandle = StdinHandle | StdoutHandle | StderrHandle
	deriving (Eq)

-- | Normally, when reading from a process, it does not need to be fed any
-- standard input.
readProcess :: FilePath	-> [String] -> IO String
readProcess cmd args = readProcess' (proc cmd args)

readProcessEnv :: FilePath -> [String] -> Maybe [(String, String)] -> IO String
readProcessEnv cmd args environ = 
	readProcess' $ (proc cmd args) { env = environ }

readProcess' :: CreateProcess -> IO String
readProcess' p = withCreateProcess p' go
  where
	p' = p { std_out = CreatePipe }
	go _ (Just h) _ pid = do
		output  <- hGetContentsStrict h
		hClose h
		forceSuccessProcess p' pid
		return output
	go _ _ _ _ = error "internal"

-- | Runs an action to write to a process on its stdin, 
-- returns its output, and also allows specifying the environment.
writeReadProcessEnv
	:: FilePath
	-> [String]
	-> Maybe [(String, String)]
	-> (Maybe (Handle -> IO ()))
	-> IO S.ByteString
writeReadProcessEnv cmd args environ writestdin = withCreateProcess p go
  where
	p = (proc cmd args)
		{ std_in = CreatePipe
		, std_out = CreatePipe
		, std_err = Inherit
		, env = environ
		}
	
	go (Just inh) (Just outh) _ pid = do
		let reader = hClose outh `after` S.hGetContents outh
		let writer = do
			maybe (return ()) (\a -> a inh >> hFlush inh) writestdin
			hClose inh
		(output, ()) <- concurrently reader writer

		forceSuccessProcess p pid

		return output
	go _ _ _ _ = error "internal"

-- | Waits for a ProcessHandle, and throws an IOError if the process
-- did not exit successfully.
forceSuccessProcess :: CreateProcess -> ProcessHandle -> IO ()
forceSuccessProcess p pid = waitForProcess pid >>= forceSuccessProcess' p

forceSuccessProcess' :: CreateProcess -> ExitCode -> IO ()
forceSuccessProcess' _ ExitSuccess = return ()
forceSuccessProcess' p (ExitFailure n) = fail $
	showCmd p ++ " exited " ++ show n

-- | Waits for a ProcessHandle and returns True if it exited successfully.
checkSuccessProcess :: ProcessHandle -> IO Bool
checkSuccessProcess pid = do
	code <- waitForProcess pid
	return $ code == ExitSuccess

withNullHandle :: (MonadIO m, MonadMask m) => (Handle -> m a) -> m a
withNullHandle = bracket
	(liftIO $ openFile devNull WriteMode)
	(liftIO . hClose)

devNull :: FilePath
#ifndef mingw32_HOST_OS
devNull = "/dev/null"
#else
-- Use device namespace to prevent GHC from rewriting path
devNull = "\\\\.\\NUL"
#endif

-- | Extract a desired handle from createProcess's tuple.
-- These partial functions are safe as long as createProcess is run
-- with appropriate parameters to set up the desired handle.
-- Get it wrong and the runtime crash will always happen, so should be
-- easily noticed.
type HandleExtractor = (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle) -> Handle

stdinHandle :: HandleExtractor
stdinHandle (Just h, _, _, _) = h
stdinHandle _ = error "expected stdinHandle"
stdoutHandle :: HandleExtractor
stdoutHandle (_, Just h, _, _) = h
stdoutHandle _ = error "expected stdoutHandle"
stderrHandle :: HandleExtractor
stderrHandle (_, _, Just h, _) = h
stderrHandle _ = error "expected stderrHandle"

processHandle :: (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle) -> ProcessHandle
processHandle (_, _, _, pid) = pid

-- | Shows the command that a CreateProcess will run.
showCmd :: CreateProcess -> String
showCmd = go . cmdspec
  where
	go (ShellCommand s) = s
	go (RawCommand c ps) = c ++ " " ++ show ps

-- | Starts an interactive process. Unlike runInteractiveProcess in
-- System.Process, stderr is inherited.
startInteractiveProcess
	:: FilePath
	-> [String]
	-> Maybe [(String, String)]
	-> IO (ProcessHandle, Handle, Handle)
startInteractiveProcess cmd args environ = do
	let p = (proc cmd args)
		{ std_in = CreatePipe
		, std_out = CreatePipe
		, std_err = Inherit
		, env = environ
		}
	(Just from, Just to, _, pid) <- createProcess p
	return (pid, to, from)

-- | Runs an action, preventing any new processes from being started
-- until it is finished.
--
-- Unfortunately, Haskell has a pervasive problem with the close-on-exec
-- flag not being set when opening files. It's also difficult to portably
-- dup or pipe a FD with the close-on-exec flag set. So, this can be used
-- to run an action that opens a FD, and then calls setFdOption to set the
-- close-on-exec flag, without risking a race with a process being forked
-- at the same time.
--
-- Note that only one of these actions can run at a time, and long-duration
-- actions are not advisable.
noCreateProcessWhile :: (MonadIO m, MonadMask m) => (m a) -> m a
noCreateProcessWhile = bracket setup cleanup . const
  where
	setup = liftIO $ takeMVar createProcessSem
	cleanup () = liftIO $ putMVar createProcessSem ()

-- | A shared global MVar. Processes are not created while it is empty.
{-# NOINLINE createProcessSem #-}
createProcessSem :: MVar ()
createProcessSem = unsafePerformIO $ newMVar ()

-- | Wrapper around 'System.Process.createProcess'. 
-- This adds debug logging, and avoids starting a process when in a
-- noCreateProcessWhile block.
createProcess :: CreateProcess -> IO (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle)
createProcess p = noCreateProcessWhile $ do
	r@(_, _, _, h) <- Utility.Process.Shim.createProcess p
	debugProcess p h
	return r

-- | Wrapper around 'System.Process.withCreateProcess' that does debug logging.
withCreateProcess :: CreateProcess -> (Maybe Handle -> Maybe Handle -> Maybe Handle -> ProcessHandle -> IO a) -> IO a
withCreateProcess p action = bracket (createProcess p) cleanupProcess
	(\(m_in, m_out, m_err, ph) -> action m_in m_out m_err ph)

-- | Debugging trace for a CreateProcess.
debugProcess :: CreateProcess -> ProcessHandle -> IO ()
debugProcess p h = do
	pid <- getPid h
	debug "Utility.Process" $ unwords $
		[ describePid pid
		, action ++ ":"
		, showCmd p
		] ++ case cwd p of
			Nothing -> []
			Just c -> ["in", show c]
  where
	action
		| piped (std_in p) && piped (std_out p) = "chat"
		| piped (std_in p)                      = "feed"
		| piped (std_out p)                     = "read"
		| otherwise                             = "call"
	piped Inherit = False
	piped _ = True

describePid :: Maybe Utility.Process.Shim.Pid -> String
describePid Nothing = "process"
describePid (Just p) = "process [" ++  show p ++ "]"

-- | Wrapper around 'System.Process.waitForProcess' that does debug logging.
waitForProcess ::  ProcessHandle -> IO ExitCode
waitForProcess h = do
	-- Have to get pid before waiting, which closes the ProcessHandle.
	pid <- getPid h
	r <- Utility.Process.Shim.waitForProcess h
	debug "Utility.Process" (describePid pid ++ " done " ++ show r)
	return r

cleanupProcess :: (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle) -> IO () 
cleanupProcess = Utility.Process.Shim.cleanupProcess

{- | Like hGetLine, reads a line from the Handle. Returns Nothing if end of
 - file is reached, or the handle is closed, or if the process has exited
 - and there is nothing more buffered to read from the handle.
 -
 - This is useful to protect against situations where the process might
 - have transferred the handle being read to another process, and so
 - the handle could remain open after the process has exited. That is a rare
 - situation, but can happen. Consider a the process that started up a
 - daemon, and the daemon inherited stderr from it, rather than the more
 - usual behavior of closing the file descriptor. Reading from stderr
 - would block past the exit of the process.
 -
 - In that situation, this will detect when the process has exited,
 - and avoid blocking forever. But will still return anything the process
 - buffered to the handle before exiting.
 -
 - Note on newline mode: This ignores whatever newline mode is configured
 - for the handle, because there is no way to query that. On Windows,
 - it will remove any \r coming before the \n. On other platforms,
 - it does not treat \r specially.
 -}
hGetLineUntilExitOrEOF :: ProcessHandle -> Handle -> IO (Maybe String)
hGetLineUntilExitOrEOF ph h = go []
  where
	go buf = do
		ready <- waitforinputorerror smalldelay
		if ready
			then getloop buf go
			else getProcessExitCode ph >>= \case
				-- Process still running, wait longer.
				Nothing -> go buf
				-- Process is done. It's possible
				-- that it output something and exited
				-- since the prior hWaitForInput,
				-- so check one more time for any buffered
				-- output.
				Just _ -> finalcheck buf

	finalcheck buf = do
		ready <- waitforinputorerror 0
		if ready
			then getloop buf finalcheck
			-- No remaining buffered input, though the handle
			-- may not be EOF if something else is keeping it
			-- open. Treated the same as EOF.
			else eofwithnolineend buf

	-- On exception, proceed as if there was input;
	-- EOF and any encoding issues are dealt with
	-- when reading from the handle.
	waitforinputorerror t = hWaitForInput h t
		`catchNonAsync` const (pure True)

	getchar = 
		catcherr EOF $
			-- If the handle is closed, reading from it is
			-- an IllegalOperation.
			catcherr IllegalOperation $
				Just <$> hGetChar h
	  where
		catcherr t = catchIOErrorType t (const (pure Nothing))

	getloop buf cont =
		getchar >>= \case
			Just c
				| c == '\n' -> return (Just (gotline buf))
				| otherwise -> cont (c:buf)
			Nothing -> eofwithnolineend buf

#ifndef mingw32_HOST_OS
	gotline buf = reverse buf
#else
	gotline ('\r':buf) = reverse buf
	gotline buf = reverse buf
#endif

	eofwithnolineend buf = return $
		if null buf 
			then Nothing -- no line read
			else Just (reverse buf)

	-- Tenth of a second delay. If the process exits with the FD being
	-- held open, will wait up to twice this long before returning.
	-- This delay could be made smaller. However, that is an unusual
	-- case, and making it too small would cause lots of wakeups while
	-- waiting for output. Bearing in mind that this could be run on
	-- many processes at the same time.
	smalldelay = 100 -- milliseconds
