sentry-0.1.0: Sentry/Core.hs
{-# LANGUAGE RecordWildCards #-}
-- |
-- Module : Sentry.Core
-- Copyright : (c) 2012 Vo Minh Thu,
--
-- License : BSD-style
-- Maintainer : thu@hypered.be
-- Stability : experimental
-- Portability : GHC
--
-- This module is Sentry's core implementation: state initialization, process
-- spawning and monitoring.
module Sentry.Core
(
-- * Processes
spawn
, follow
, terminate
, updateProcess
, removeProcess
-- * Application state
, initializeState
, saveState
, readState
-- * Main entry points
, startMonitor
, continueMonitor
, sendSIGHUP
, compile
, reexecute
) where
import Control.Concurrent (forkIO, threadDelay)
import Control.Concurrent.Chan
import Control.Concurrent.MVar
import Control.Applicative ((<$>))
import Control.Monad (forM_, replicateM, when)
import qualified Data.ByteString as B
import Data.Either (partitionEithers)
import Data.List (foldl')
import Data.Maybe (isJust)
import Data.Serialize (runGet, runPut)
import Data.SafeCopy (safeGet, safePut)
import Data.Time.Clock.POSIX(getPOSIXTime)
import System.Console.ANSI (setSGRCode, ColorIntensity(..), Color(..)
, ConsoleLayer(..), SGR(..))
import System.Directory (createDirectoryIfMissing, doesFileExist, getHomeDirectory)
import System.Exit (ExitCode(..))
import System.FilePath ((<.>), (</>), takeDirectory)
import System.IO (hGetLine, hIsEOF, hPutStrLn, hReady, hSetBuffering
, stderr, stdout, withFile
, BufferMode(..), IOMode(..), Handle)
import System.Locale (defaultTimeLocale)
import System.Posix.Files (readSymbolicLink)
import System.Posix.Process (executeFile, getProcessID)
import System.Posix.Signals (installHandler, sigHUP, sigINT, signalProcess
, Handler(..))
import System.Process (createProcess, getProcessExitCode, proc
, runProcess, std_out, terminateProcess, waitForProcess)
import System.Process.Internals
import System.Time (formatCalendarTime, getClockTime, toCalendarTime)
import Sentry.Types
-- | Create a new process, monitored by a new thread. The provided channel
-- is used by the monitoring thread when the process exits to notify the
-- main thread.
spawn :: Chan Command -> Entry -> IO Int -- TODO newtype ProcessID
spawn chan e@Entry{..} = do
(_, Just hout, Just herr, h) <- createProcess $
(proc eCommand eArguments)
{ std_out = CreatePipe, std_err = CreatePipe }
i <- processHandleToInt h
t1 <- getTime
logP e i $ "Started at " ++ show t1 ++ "."
follow chan e i
_ <- forkIO $ pipeToStdout e i hout herr
return i
-- | Wait for a process to complete. When it happens, it will notify the main
-- thread using the provided channel. Return without blocking.
follow :: Chan Command -> Entry -> Int -> IO ()
follow chan p@Entry{..} i = do
_ <- forkIO $ do
-- After a re-exec, waitForProcess will make an error if the
-- child has already exited, so use getProcessExitCode at first.
-- If it returns Nothing, then we have to continue waiting for t
-- (the handle is valid) otherwise the process has completed.
h <- intToProcessHandle i
mCode <- getProcessExitCode h
case mCode of
Just _ -> writeChan chan $ ProcessExited eType i
Nothing -> do
exitCode <- waitForProcess h
t <- getTime
logP p i $ "Exited at " ++ show t ++ " with " ++ show exitCode ++ "."
threadDelay $ eDelay * 1000
writeChan chan $ ProcessExited eType i
return ()
terminate :: MonitoredEntry -> IO ()
terminate MonitoredEntry{..} = do
putStrLn $ "Process type `" ++ eType mEntry ++ "` removed. Killing "
++ "workers."
mapM_ (\i -> intToProcessHandle i >>= terminateProcess) mHandles
-- | Given a monitored process, adjust the number of worker processes to match
-- the possibly updated spec.
updateProcess :: Chan Command -> MonitoredEntry -> IO MonitoredEntry
updateProcess chan p@MonitoredEntry{..} =
case length mHandles `compare` eCount mEntry of
EQ -> return p
GT -> do
let n = length mHandles - eCount mEntry
toTerminate = take n mHandles
toKeep = drop n mHandles
-- logP mEntry $ show n ++ " less workers required."
hs <- mapM intToProcessHandle toTerminate -- TODO make sure eCount always >= 0
mapM_ terminateProcess hs -- TODO is SIGTERM really what we want?
return p { mHandles = toKeep }
LT -> do
let n = eCount mEntry - length mHandles
is <- replicateM n $ spawn chan mEntry
return p { mHandles = is ++ mHandles }
-- | Remove the process handle from the monitored process (if they match,
-- otherwise do nothing).
removeProcess :: ProcessType -> Int -> MonitoredEntry -> MonitoredEntry
removeProcess typ i p@MonitoredEntry{..} =
if eType mEntry == typ
then p { mHandles = filter (/= i) mHandles }
else p
-- | Given a list of process specifications, start to monitor them.
startMonitor :: [Entry] -> IO ()
startMonitor entries = do
state <- initializeState entries
home <- getHomeDirectory
let sentry = home </> ".sentry"
conf = sentry </> "conf"
binPath = sExecutablePath state
pidPath = binPath <.> "pid"
pid <- fromIntegral <$> getProcessID :: IO Int
if takeDirectory binPath /= conf
then putStrLn $ "Sentry started (PID: " ++ show pid ++ ")."
else do
putStrLn $ "Sentry started (PID: " ++ show pid ++ " saved in "
++ pidPath ++ ")."
writeFile pidPath $ show pid
monitor state
-- | Given new process specifications, continue the monitoring.
continueMonitor :: [Entry] -> IO ()
continueMonitor entries = do
mstate <- readState
case mstate of
Nothing -> return ()
Just state@Sentry{..} -> do
putStrLn $ "Sentry reexec'd. Initially started at " ++
show sStartTime ++ maybe "." (\r -> " (Previously reexec'd at " ++
show r ++ ").") sReexecTime
t <- getTime
let (walkingDeads, kept) = partitionEithers $
map (continueProcess entries) sProcesses
state' = state
{ sProcesses = addEntries kept entries
, sReexecTime = Just t }
mapM_ terminate walkingDeads
monitor state'
-- | Monitor the processes from the given application state.
monitor :: Sentry -> IO ()
monitor state = do
let state' = state { sProcesses = colorize $ sProcesses state }
hSetBuffering stdout LineBuffering
hSetBuffering stderr LineBuffering
chan <- newChan
setupHUP chan
setupINT chan
-- follow existing handles, if any (after a re-exec there can be some).
forM_ (sProcesses state') $
\p -> mapM_ (follow chan $ mEntry p) $ mHandles p
writeChan chan UpdateProcesses
processChan state' chan
-- | Process the command received on the main channel. This acts as a main
-- event loop.
processChan :: Sentry -> Chan Command -> IO ()
processChan state@Sentry{..} chan = do
command <- readChan chan
case command of
UpdateProcesses -> do
ps <- mapM (updateProcess chan) sProcesses
let state' = state { sProcesses = ps }
processChan state' chan
ProcessExited typ i -> do
let ps = map (removeProcess typ i) sProcesses
ps' <- mapM (updateProcess chan) ps
let state' = state { sProcesses = ps' }
processChan state' chan
Reexec -> do
-- TODO Compile/Reexec can be splitted:
-- First start a thread waiting while compiling.
-- Second push Reexec on the chan.
-- Or simply let the `sentry reload` command do it
-- before issuing the SIGHUP signal.
-- TODO compile only if the file has been modified.
b <- compile state
if b
then reexecute state
else processChan state chan
Quit -> do
putStrLn "Bye."
-- TODO SIGTERM should be replaced by SIGINT and a small
-- waiting period?
mapM_ (mapM_ (\i -> intToProcessHandle i >>=
terminateProcess) . mHandles) sProcesses
-- | Compile itself.
compile :: Sentry -> IO Bool
compile state = do
home <- getHomeDirectory
let sentry = home </> ".sentry"
conf = sentry </> "conf"
binPath = sExecutablePath state
sourcePath = binPath <.> "hs"
errorPath = binPath <.> "error"
if takeDirectory binPath /= conf
then do
hPutStrLn stderr $ binPath ++ " is not under " ++ conf ++ "."
return False
else do
status <- withFile errorPath WriteMode $ \h -> do
p <- runProcess "ghc"
[ "--make", sourcePath, "-fforce-recomp", "-v0", "-threaded",
"-o", binPath]
(Just conf)
Nothing Nothing Nothing (Just h)
waitForProcess p
if status == ExitSuccess
then do
putStrLn $ sourcePath ++ " successfully compiled."
return True
else do
content <- readFile errorPath
hPutStrLn stderr $ "Problem encountered while compiling " ++ sourcePath ++ ":"
hPutStrLn stderr content
return False
-- | Save the application state then re-exec itself (calling `sentry continue`).
reexecute :: Sentry -> IO a
reexecute state = do
saveState state
executeFile (sExecutablePath state) False ["continue"] Nothing
-- | Send a @SIGHUP@ signal to a running Sentry.
sendSIGHUP :: Sentry -> IO ()
sendSIGHUP state = do
let binPath = sExecutablePath state
pidPath = binPath <.> "pid"
-- TODO all these xxxPath could be function on Sentry.
-- TODO better error messages.
content <- readFile pidPath
signalProcess sigHUP $ fromIntegral (read content :: Int)
------------------------------------------------------------------------------
-- State
------------------------------------------------------------------------------
-- | Return the path where to save the application state.
getStatePath :: IO FilePath
getStatePath = do
home <- getHomeDirectory
return $ home </> ".sentry" </> "sentry.state"
-- Inspired by the `executale-path` package, which implements
-- a similar function for different OS.
getExecutablePath :: IO FilePath
getExecutablePath = do
pid <- fromIntegral <$> getProcessID :: IO Int
readSymbolicLink $ "/proc/" ++ show pid ++ "/exe"
-- | Make sure the directory where the application state is saved exists.
ensureStateDirectory :: IO ()
ensureStateDirectory = do
home <- getHomeDirectory
let dir = home </> ".sentry"
conf = dir </> "conf"
createDirectoryIfMissing False dir
createDirectoryIfMissing False conf
-- | Create a initial application state from a list of process specifications.
initializeState :: [Entry] -> IO Sentry
initializeState specs = do
path <- getExecutablePath
t <- getTime
return Sentry
{ sExecutablePath = path
, sStartTime = t
, sReexecTime = Nothing
, sProcesses = map (flip MonitoredEntry []) specs
}
-- | Save the application state to disk (normally done just before re-exec'ing
-- itself).
saveState :: Sentry -> IO ()
saveState state = do
ensureStateDirectory
statePath <- getStatePath
B.writeFile statePath . runPut $ safePut state
-- | Try to restore the application state (normally saved previously before
-- re-exec'ing itself).
readState :: IO (Maybe Sentry)
readState = do
statePath <- getStatePath
b <- doesFileExist statePath
if b
then do
content <- B.readFile statePath
case runGet safeGet content of
Left err -> do
hPutStrLn stderr $ "Can't parse existing state." ++
" Sentry continues with its current state." ++
" The error was: " ++ err
return Nothing
Right a -> return $ Just a
else do
hPutStrLn stderr $ "The file `" ++ statePath ++
"` doesn't exist. Sentry continues with its current state."
return Nothing
-- | Given a list of "new" entries, either modify the monitored entry if it
-- must be updated (and return @Right@) or return @Left@ if it must be
-- deleted. @Left@ is used instead of @Nothing@ so the process handles can
-- be terminated if necessary.
continueProcess :: [Entry] -> MonitoredEntry ->
Either MonitoredEntry MonitoredEntry
continueProcess entries m@MonitoredEntry{..} =
case lookupProcess mEntry entries of
Just p -> Right $ MonitoredEntry p mHandles
Nothing -> Left m
-- | Add entries to a list of monitored entries if they are not already in
-- there.
addEntries :: [MonitoredEntry] -> [Entry] -> [MonitoredEntry]
addEntries = foldl' addEntry
-- | Add an entry to a list of monitored entries if it is not already in
-- there.
addEntry :: [MonitoredEntry] -> Entry -> [MonitoredEntry]
-- Order doesn't matter as it will be a Map anyway.
addEntry es e = if present then es else MonitoredEntry e [] : es
where present = isJust $ lookupProcess e $ map mEntry es
-- | Try to find a matching process in the given list.
lookupProcess :: Entry -> [Entry] -> Maybe Entry
lookupProcess p entries =
case filter (sameEntries p) entries of
[p'] -> Just p'
[] -> Nothing
_ -> error "More than one process"
-- TODO `entries` must be a Map instead of a list.
-- | Compare if two entries are equal, i.e. if they have
-- same type, command and arguments.
sameEntries :: Entry -> Entry -> Bool
sameEntries p1 p2 = eType p1 == eType p2
&& eCommand p1 == eCommand p2
&& eArguments p1 == eArguments p2
------------------------------------------------------------------------------
-- Logging
------------------------------------------------------------------------------
colorize :: [MonitoredEntry] -> [MonitoredEntry]
colorize entries = zipWith f entries $ cycle $ map Just
[Red, Blue, Green, Yellow, Magenta, Cyan, White]
where f e c = e { mEntry = (mEntry e) { eColor = c } }
colorized :: Entry -> String -> String
colorized Entry{..} str =
case eColor of
Nothing -> pad str
Just c ->
setSGRCode [SetColor Foreground Dull c] ++ pad str ++ setSGRCode []
-- TODO non-hardcoded constant
pad :: String -> String
pad s = s ++ replicate (25 - length s) ' '
logP :: Entry -> Int -> String -> IO ()
logP p@Entry{..} i s = do
ts <- getTimeString
putStrLn $ colorized p (ts ++ " " ++ eType ++ "." ++ show i) ++ s
------------------------------------------------------------------------------
-- Signals
------------------------------------------------------------------------------
-- | Install the handleHUP function as a SIGHUP handler.
setupHUP :: Chan Command -> IO ()
setupHUP chan = do
_ <- installHandler sigHUP (Catch $ handleHUP chan) Nothing
return ()
-- | SIGHUP handler. When the handler is run, it simply instructs the main
-- thread to re-exec the program.
handleHUP :: Chan Command -> IO ()
handleHUP = flip writeChan Reexec
-- | Install the handleINT function as a SIGINT handler.
setupINT :: Chan Command -> IO ()
setupINT chan = do
_ <- installHandler sigINT (Catch $ handleINT chan) Nothing
return ()
-- | SIGINT handler. When the handler is run, it simply instructs the main
-- thread to quit the program.
handleINT :: Chan Command -> IO ()
handleINT = flip writeChan Quit
------------------------------------------------------------------------------
-- Utilities
------------------------------------------------------------------------------
-- | Convenience function to turn a ProcessHandle into an Int (used later when
-- saving/restoring the state with SafeCopy).
processHandleToInt :: ProcessHandle -> IO Int
processHandleToInt (ProcessHandle mvar) = do
OpenHandle i <- readMVar mvar
return $ fromIntegral i
-- | Convenience function to turn an Int into a ProcessHandle (used later when
-- saving/restoring the state with SafeCopy).
intToProcessHandle :: Int -> IO ProcessHandle
intToProcessHandle i = do
mvar <- newMVar $ OpenHandle $ fromIntegral i
return $ ProcessHandle mvar
-- | Convenience function to get a Posix time as an Int.
getTime :: IO Int
getTime = floor <$> getPOSIXTime
getTimeString :: IO String
getTimeString = do
tm <- getClockTime
ct <- toCalendarTime tm
return $ formatCalendarTime defaultTimeLocale "%H:%M:%S" ct
-- | Copy two handles to stout. It is better if the handles are line-buffered.
pipeToStdout :: Entry -> Int -> Handle -> Handle -> IO ()
pipeToStdout p i h1 h2 = do
eof1 <- hIsEOF h1
eof2 <- hIsEOF h2
ready1 <- if eof1 then return False else hReady h1
ready2 <- if eof2 then return False else hReady h2
when ready1 $ do
l <- hGetLine h1
logP p i l
when ready2 $ do
l <- hGetLine h2
logP p i l
when (not eof1 || not eof2) $
pipeToStdout p i h1 h2