crunghc-0.1.0.0: main.hs
{-# LANGUAGE CPP #-}
import Control.Applicative
import qualified Control.Exception as E
import Control.Monad
import Control.Monad.IO.Class
import Control.Monad.Trans.Maybe
import qualified Data.ByteString as S
import qualified Data.ByteString.Lazy as L
import Data.Char
import qualified Data.Digest.Pure.SHA as SHA
import Data.List
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import Data.Time.Clock
import Data.Maybe
import Data.Monoid
import System.Directory
import System.Environment
import System.Exit
import System.FileLock
import System.FilePath
import System.IO
import System.IO.Error
import System.Process
#if USE_UNIX
import System.Posix.Files (createLink)
#endif
data Config = Config
{ cmdGhcFlags :: [String]
, cmdScriptPath :: FilePath
, cmdScriptArgs :: [String]
}
data CachePlan = CP
{ cpCachedir :: FilePath
, cpStrategy :: CacheStrategy
}
data CacheStrategy
= HashPath FilePath
| HashContent
S.ByteString -- ^ 0-separated ghc args
S.ByteString -- ^ content
main :: IO ()
main = do
config <- fromRightIO . parseConfig =<< getArgs
plan <- chooseCachePlan config
createDirectoryIfMissing True $ cpCachedir plan
(tempExeCopy, cleanup) <- withFileLock (lockfile plan) Exclusive $ \_ -> do
needRecomp <- testRecompilationNeeded config plan
when needRecomp $ recompile config plan
-- Here we make a copy of the cached executable, and run that copy.
-- This is needed for two reasons:
--
-- 1. we want to avoid the race condition where some other process replaces
-- the cached executable after we unlock it and before we execute it.
-- 2. on Windows you cannot replace an executable while it's running.
makeTemporaryExeCopy plan
runTemp config tempExeCopy `E.finally` cleanup
parseConfig :: [String] -> Either String Config
parseConfig args = case span ("-" `isPrefixOf`) args of
(flags, path:scriptArgs) -> Right Config
{ cmdGhcFlags = flags
, cmdScriptPath = path
, cmdScriptArgs = scriptArgs
}
_ -> Left "No file is given"
lockfile :: CachePlan -> FilePath
lockfile plan = cpCachedir plan </> "lock"
-- | Decide where to store the cache, and which strategy to use.
chooseCachePlan :: Config -> IO CachePlan
chooseCachePlan config = do
contentHead <- readPrefix (contentHashThreshold + 1) $ cmdScriptPath config
let contentAndGhcArgs = contentHead <> packedGhcArgs
(toHash, strat) <- if S.length contentAndGhcArgs <= contentHashThreshold
then return (contentAndGhcArgs, HashContent packedGhcArgs contentHead)
else do
canonicalScriptPath <- canonicalizePath $ cmdScriptPath config
return
( T.encodeUtf8 $ T.pack canonicalScriptPath
, HashPath canonicalScriptPath
)
appDir <- getAppUserDataDirectory "crunghc"
ghcVersion <- filter (/='\n') <$> readProcess "ghc" ["--numeric-version"] ""
return $ CP
{ cpCachedir = appDir </> ghcVersion </> hashToDirname toHash
, cpStrategy = strat
}
where
-- | Use filepath hashing if the total size of the ghc command line and the
-- script itself exceeds this value. In particular it should be large enough
-- to allow the boilerplate Setup.hs to be content-hashed.
contentHashThreshold = 50
packedGhcArgs = S.intercalate (S.singleton 0) $
map (T.encodeUtf8 . T.pack) $ cmdGhcFlags config
readPrefix :: Int -> FilePath -> IO S.ByteString
readPrefix n path = withFile path ReadMode $ \h -> S.hGet h n
-- | Does the script need to be recompiled?
testRecompilationNeeded :: Config -> CachePlan -> IO Bool
testRecompilationNeeded config CP{cpCachedir=cachedir, cpStrategy=strat}
= fmap isNothing $ runMaybeT $ do
case strat of
HashPath canonicalScriptPath -> do
shouldEqual canonicalScriptPath $
readFile $ cachedir </> "path"
HashContent ghcArgs content -> do
shouldEqual ghcArgs $ S.readFile $ cachedir </> "ghc-args"
shouldEqual content $ S.readFile $ cachedir </> "content"
exeModTime <- checkIOError $
getModificationTime $ cachedir </> "cached.exe"
deps <- readDependencies $ cachedir </> "deps"
if length deps == 1
then do -- Single-file program
scriptModTime <- checkIOError $ getModificationTime $
cmdScriptPath config
guard $ exeModTime > scriptModTime
else do
-- Make sure that the working directory is the same as when the cache
-- was created.
wdir <- liftIO getCurrentDirectory
cachedWdir <- checkIOError $ readFile $ cachedir </> "wdir"
guard $ wdir == cachedWdir
-- Check for modification times of all the source files
forM_ deps $ \srcPath -> do
modTime <- checkIOError $ getModificationTime srcPath
guard $ exeModTime > modTime
where
shouldEqual val action = do
r <- checkIOError action
guard $ val == r
-- | Compile the script and populate the cache directory.
recompile :: Config -> CachePlan -> IO ()
recompile config CP{cpCachedir=cachedir, cpStrategy=strat} = do
createDirectoryIfMissing True $ cachedir </> "build"
runGhc $ fullGhcArgs ["-M", "-dep-makefile", cachedir </> "deps"]
runGhc $ fullGhcArgs
[ "-hidir", builddir, "-odir", builddir
, "-threaded"
, "-o", cachedir </> "cached.exe"
]
-- Try to strip it, but don't care if it fails.
void (rawSystem "strip" [cachedir </> "cached.exe"])
`E.catch` \e -> const (return ()) (e::E.IOException)
wdir <- getCurrentDirectory
writeFile (cachedir </> "wdir") wdir
case strat of
HashPath canonicalScriptPath ->
writeFile (cachedir </> "path") canonicalScriptPath
HashContent ghcArgs content -> do
S.writeFile (cachedir </> "ghc-args") ghcArgs
S.writeFile (cachedir </> "content") content
where
fullGhcArgs flags = cmdGhcFlags config ++ flags ++ [cmdScriptPath config]
builddir = cachedir </> "build"
runGhc args = do
(exitCode, _, err) <- readProcessWithExitCode "ghc" args ""
case exitCode of
ExitSuccess -> return ()
ExitFailure{} -> die err
-- | Create a temporary copy or hard link of the cached executable for
-- exclusive use by this process. Return the path of the copy and a cleanup
-- action.
makeTemporaryExeCopy :: CachePlan -> IO (FilePath, IO ())
makeTemporaryExeCopy plan = do
createDirectoryIfMissing True tempdir
mydir <- createFreshDirectoryIn tempdir
let tempPath = mydir </> "temp.exe"
linkOrCopy (cpCachedir plan </> "cached.exe") tempPath
return (tempPath, removeDirectoryRecursive mydir)
where
tempdir = cpCachedir plan </> "temp"
#if USE_UNIX
-- Use hard links if available, because they are much cheaper to create.
linkOrCopy = createLink
#else
linkOrCopy = copyFile
#endif
createFreshDirectoryIn :: FilePath -> IO FilePath
createFreshDirectoryIn dir = loop =<< randomNumber
where
loop n = do
let fresh = dir </> show n
r <- E.tryJust (guard . isAlreadyExistsError) $ createDirectory fresh
case r of
Left{} -> loop $ n + 1
Right{} -> return fresh
randomNumber = do
tod <- utctDayTime <$> getCurrentTime
return $ flip mod (100000::Int) $
floor $ realToFrac tod * (1000000 :: Double)
-- | Run the cached executable.
runTemp :: Config -> FilePath -> IO ()
runTemp config path = exitWith =<< rawSystem path (cmdScriptArgs config)
readDependencies :: FilePath -> MaybeT IO [FilePath]
readDependencies depFile = do
lns <- checkIOError $ lines <$> readFile depFile
return $ mapMaybe extractDependency lns
extractDependency :: String -> Maybe FilePath
extractDependency ln
| looksLikeSourceFile = Just $ takeWhileEnd (not . isSpace) ln
| otherwise = Nothing
where
looksLikeSourceFile =
".hs" `isSuffixOf` ln ||
".lhs" `isSuffixOf` ln
takeWhileEnd :: (a -> Bool) -> [a] -> [a]
takeWhileEnd p = reverse . takeWhile p . reverse
checkIOError :: IO a -> MaybeT IO a
checkIOError action = MaybeT $
(Just <$> action) `E.catch` \e -> const (return Nothing) (e::IOError)
hashToDirname :: S.ByteString -> FilePath
hashToDirname = SHA.showDigest . SHA.sha1 . L.fromChunks . (:[])
fromRightIO :: Either String a -> IO a
fromRightIO = either die return
die :: String -> IO a
die msg = do
prog <- getProgName
hPutStrLn stderr $ prog ++ ": " ++ msg
exitFailure