{-# LANGUAGE OverloadedStrings, CPP, PatternGuards, LambdaCase #-} {-# OPTIONS_GHC -fno-warn-orphans #-} module HsDev.Server.Commands ( commands, serverOpts, serverDefCfg, clientOpts, clientDefCfg, clientCmd, sendCmd, initLog, runServer, processRequest, processClient, withCache, writeCache, readCache ) where import Control.Applicative import Control.Arrow (second) import Control.Concurrent import Control.Exception import Control.Monad import Control.Monad.Except import Data.Aeson hiding (Result, Error) import Data.Aeson.Encode.Pretty import qualified Data.ByteString.Char8 as BS import Data.ByteString.Lazy.Char8 (ByteString) import qualified Data.ByteString.Lazy.Char8 as L import Data.Either (isLeft) import qualified Data.Map as M import Data.Maybe import Data.Text (Text) import qualified Data.Text as T (pack, unpack) import Network.Socket hiding (connect) import qualified Network.Socket as Net hiding (send) import qualified Network.Socket.ByteString as Net (send) import qualified Network.Socket.ByteString.Lazy as Net (getContents) import System.Directory import System.Exit import System.IO import System.Log.Simple hiding (Level(..), Message(..)) import System.Log.Simple.Base (writeLog) import qualified System.Log.Simple.Base as Log import Text.Read (readMaybe) import Control.Apply.Util import Control.Concurrent.Util import qualified Control.Concurrent.FiniteChan as F import Data.Lisp import qualified System.Directory.Watcher as Watcher import System.Console.Cmd hiding (run) import Text.Format ((~~), (%), Format(..)) import qualified HsDev.Cache.Structured as SC import qualified HsDev.Client.Commands as Client import HsDev.Database import qualified HsDev.Database.Async as DB import qualified HsDev.Database.Update as Update import HsDev.Tools.Ghc.Worker import HsDev.Tools.GhcMod (ghcModMultiWorker) import HsDev.Server.Message as M import HsDev.Server.Types import HsDev.Util #if mingw32_HOST_OS import Data.Aeson.Types hiding (Result, Error) import Data.Char import Data.List import System.Environment import System.Process import System.Win32.FileMapping.Memory (withMapFile, readMapFile) import System.Win32.FileMapping.NamePool import System.Win32.PowerShell (escape, quote, quoteDouble) #else import System.Posix.Process import System.Posix.IO #endif -- | Server commands commands :: [Cmd (IO ())] commands = [ cmd' "start" server' "start remote server" start', cmd' "run" server' "run server" run', cmd' "stop" client' "stop remote server" stop', cmd' "connect" client' "connect to send commands directly" connect'] where cmd' :: String -> ([Opt], Opts String) -> String -> (Args -> IO ()) -> Cmd (IO ()) cmd' nm (opts', defOpts') desc' act' = cmd nm [] opts' desc' act' `with` [defaultOpts defOpts'] server' = (serverOpts, serverDefCfg) client' = (clientOpts, clientDefCfg) -- | Start remote server start' :: Args -> IO () start' (Args _ sopts) = do #if mingw32_HOST_OS let args = ["run"] ++ toArgs (Args [] sopts) myExe <- getExecutablePath curDir <- getCurrentDirectory let -- one escape for start-process and other for callable process -- seems, that start-process just concats arguments into one string -- start-process foo 'bar baz' ⇒ foo bar baz -- not expected -- start-process foo '"bar baz"' ⇒ foo "bar baz" -- ok biescape = escape quote . escape quoteDouble script = "try { start-process $ $ -WindowStyle Hidden -WorkingDirectory $ } catch { $$_.Exception, $$_.InvocationInfo.Line }" ~~ ( escape quote myExe % (intercalate ", " (map biescape args)) % escape quote curDir) r <- readProcess "powershell" [ "-Command", script] "" if all isSpace r then putStrLn $ "Server started at port " ++ (fromJust $ arg "port" sopts) else mapM_ putStrLn [ "Failed to start server", "\tCommand: " ++ script, "\tResult: " ++ r] #else let forkError :: SomeException -> IO () forkError e = putStrLn $ "Failed to start server: " ++ show e proxy :: IO () proxy = do _ <- createSession _ <- forkProcess serverAction exitImmediately ExitSuccess serverAction :: IO () serverAction = do mapM_ closeFd [stdInput, stdOutput, stdError] nullFd <- openFd "/dev/null" ReadWrite Nothing defaultFileFlags mapM_ (dupTo nullFd) [stdInput, stdOutput, stdError] closeFd nullFd run' (Args [] sopts) handle forkError $ do _ <- forkProcess proxy putStrLn $ "Server started at port " ++ fromJust (arg "port" sopts) #endif -- | Run server run' :: Args -> IO () run' (Args _ sopts) | flagSet "as-client" sopts = runServer sopts $ \copts -> do commandLog copts Log.Info $ "Server started as client connecting at port " ++ fromJust (arg "port" sopts) me <- myThreadId s <- socket AF_INET Stream defaultProtocol addr' <- inet_addr "127.0.0.1" Net.connect s $ SockAddrInet (fromIntegral $ fromJust $ iarg "port" sopts) addr' flip finally (close s) $ processClientSocket s (copts { commandExit = killThread me }) | otherwise = runServer sopts $ \copts -> do commandLog copts Log.Info $ "Server started at port " ++ fromJust (arg "port" sopts) waitListen <- newEmptyMVar clientChan <- F.newChan void $ forkIO $ do accepter <- myThreadId let serverStop :: IO () serverStop = void $ forkIO $ do void $ tryPutMVar waitListen () killThread accepter s <- socket AF_INET Stream defaultProtocol bind s $ SockAddrInet (fromIntegral $ fromJust $ iarg "port" sopts) iNADDR_ANY listen s maxListenQueue forever $ logIO "accept client exception: " (commandLog copts Log.Error) $ do s' <- fst <$> accept s void $ forkIO $ logIO (show s' ++ " exception: ") (commandLog copts Log.Error) $ flip finally (close s') $ bracket newEmptyMVar (`putMVar` ()) $ \done -> do me <- myThreadId let timeoutWait = do notDone <- isEmptyMVar done when notDone $ do void $ forkIO $ do threadDelay 1000000 void $ tryPutMVar done () killThread me takeMVar done -- waitForever = forever $ hGetLineBS h F.putChan clientChan timeoutWait processClientSocket s' (copts { -- commandHold = waitForever, commandExit = serverStop }) takeMVar waitListen DB.readAsync (commandDatabase copts) >>= writeCache sopts (commandLog copts) F.stopChan clientChan >>= sequence_ commandLog copts Log.Info "server stopped" -- | Stop remote server stop' :: Args -> IO () stop' (Args _ copts) = runArgs (map clientCmd Client.commands) onDef onError (Args ["exit"] copts) where onDef = putStrLn "Command 'exit' not found" onError es = putStrLn $ "Failed to stop server: " ++ es -- | Connect to remote server connect' :: Args -> IO () connect' (Args _ copts) = do curDir <- getCurrentDirectory s <- socket AF_INET Stream defaultProtocol addr' <- inet_addr "127.0.0.1" Net.connect s (SockAddrInet (fromIntegral $ fromJust $ iarg "port" copts) addr') bracket (socketToHandle s ReadWriteMode) hClose $ \h -> forM_ [(1 :: Integer)..] $ \i -> ignoreIO $ do input' <- hGetLineBS stdin case decodeLispOrJSON input' of Left _ -> L.putStrLn $ encodeValue False $ object ["error" .= ("invalid command" :: String)] Right (isLisp, req') -> do L.hPutStrLn h $ encodeLispOrJSON isLisp $ Message (Just $ show i) $ req' `M.withOpts` ["current-directory" %-- curDir] waitResp h where pretty = flagSet "pretty" copts encodeValue :: ToJSON a => Bool -> a -> L.ByteString encodeValue True = encodeLisp encodeValue False | pretty = encodePretty | otherwise = encode waitResp h = do resp <- hGetLineBS h parseResp h resp parseResp h str = case decodeLispOrJSON str of Left e -> putStrLn $ "Can't decode response: " ++ e Right (isLisp, Message i r) -> do r' <- unMmap r putStrLn $ fromMaybe "_" i ++ ":" ++ fromUtf8 (encodeValue isLisp r') case r of Left _ -> waitResp h _ -> return () -- | Server options serverOpts :: [Opt] serverOpts = [ req "port" "number" `desc` "listen port", req "timeout" "msec" `desc` "query timeout", req "log" "file" `short` ['l'] `desc` "log file", req "log-config" "config" `desc` "config log", req "cache" "path" `desc` "cache directory", flag "load" `desc` "force load all data from cache on startup"] -- | Client options clientOpts :: [Opt] clientOpts = [ req "port" "number" `desc` "connection port", flag "pretty" `desc` "pretty json output", flag "stdin" `desc` "pass data to stdin", req "timeout" "msec" `desc` "overwrite timeout duration", flag "silent" `desc` "supress notifications"] -- | Server default options serverDefCfg :: Opts String serverDefCfg = mconcat [ "port" %-- (4567 :: Int), "timeout" %-- (1000 :: Int)] -- | Client default options clientDefCfg :: Opts String clientDefCfg = mconcat ["port" %-- (4567 :: Int)] -- | Command to send to client clientCmd :: Cmd CommandAction -> Cmd (IO ()) clientCmd c = cmd (cmdName c) (cmdArgs c) (cmdOpts c ++ clientOpts) (cmdDesc c) (sendCmd (cmdName c)) `with` [defaultOpts clientDefCfg] -- | Send command to server sendCmd :: String -> Args -> IO () sendCmd name (Args args opts) = do var <- newEmptyMVar thId <- forkIO $ ignoreIO sendReceive >> putMVar var () handle (\(SomeException _) -> killThread thId) $ takeMVar var where (copts, opts') = splitOpts clientOpts opts reqCall = Request name args opts' pretty = flagSet "pretty" copts encodeValue :: ToJSON a => a -> L.ByteString encodeValue | pretty = encodePretty | otherwise = encode sendReceive = do curDir <- getCurrentDirectory input <- if flagSet "stdin" copts then liftM Just L.getContents else return $ fmap toUtf8 $ arg "data" copts let parseData :: L.ByteString -> IO Value parseData cts = case eitherDecode cts of Left err -> putStrLn ("Invalid data: " ++ err) >> exitFailure Right v -> return v dat <- traverse parseData input s <- socket AF_INET Stream defaultProtocol addr' <- inet_addr "127.0.0.1" Net.connect s (SockAddrInet (fromIntegral $ fromJust $ iarg "port" copts) addr') bracket (socketToHandle s ReadWriteMode) hClose $ \h -> do L.hPutStrLn h $ encode $ Message Nothing $ reqCall `M.withOpts` [ "current-directory" %-- curDir, "data" %-? (fromUtf8 . encode <$> dat), "timeout" %-? (iarg "timeout" copts :: Maybe Integer), if flagSet "silent" copts then hoist "silent" else mempty] hFlush h peekResponse h peekResponse h = do resp <- hGetLineBS h parseResponse h resp parseResponse h str = case decodeLispOrJSON str of Left e -> putStrLn $ "Can't decode response: " ++ e Right (_, Message _ r) -> do r' <- unMmap r L.putStrLn $ case r' of Left n -> encodeValue n Right (Result v) -> encodeValue v Right e -> encodeValue e when (isLeft r') $ peekResponse h chaner :: F.Chan String -> Consumer Text chaner ch = Consumer withChan where withChan f = f (F.putChan ch . T.unpack) instance Format Log.Level where -- | Inits log chan and returns functions (print message, wait channel) initLog :: Opts String -> IO (Log, Log.Level -> String -> IO (), ([String] -> IO ()) -> IO (), IO ()) initLog sopts = do msgs <- F.newChan outputDone <- newEmptyMVar void $ forkIO $ finally (F.readChan msgs >>= mapM_ (const $ return ())) (putMVar outputDone ()) l <- newLog (constant [rule']) $ concat [ [logger text console], [logger text (chaner msgs)], maybeToList $ (logger text . file) <$> arg "log" sopts] Log.writeLog l Log.Info ("Log politics: low = $, high = $" ~~ (logLow % logHigh)) let listenLog f = logException "listen log" (F.putChan msgs) $ do msgs' <- F.dupChan msgs F.readChan msgs' >>= f return (l, \lev -> writeLog l lev . T.pack, listenLog, F.closeChan msgs >> takeMVar outputDone) where rule' :: Log.Rule rule' = fromMaybe (Log.Rule null $ const Log.tracePolitics) $ (parseRule_ . T.pack . ("/: " ++)) <$> arg "log-config" sopts (Log.Politics logLow logHigh) = Log.rulePolitics rule' Log.defaultPolitics -- | Run server runServer :: Opts String -> (CommandOptions -> IO ()) -> IO () runServer sopts act = bracket (initLog sopts) (\(_, _, _, x) -> x) $ \(logger', outputStr, listenLog, waitOutput) -> Log.scopeLog logger' (T.pack "hsdev") $ Watcher.withWatcher $ \watcher -> do db <- DB.newAsync when (flagSet "load" sopts) $ withCache sopts () $ \cdir -> do outputStr Log.Info $ "Loading cache from " ++ cdir dbCache <- liftA merge <$> SC.load cdir case dbCache of Left err -> outputStr Log.Error $ "Failed to load cache: " ++ err Right dbCache' -> DB.update db (return dbCache') #if mingw32_HOST_OS mmapPool <- Just <$> createPool "hsdev" #endif ghcw <- ghcWorker [] (return ()) ghciw <- ghciWorker ghcmodw <- ghcModMultiWorker let copts = CommandOptions db (writeCache sopts outputStr) (readCache sopts outputStr) "." outputStr logger' listenLog waitOutput watcher #if mingw32_HOST_OS mmapPool #endif ghcw ghciw ghcmodw (const $ return ()) (return ()) (return ()) (return ()) _ <- forkIO $ Update.onEvent watcher (Update.processEvent $ Update.settings copts mempty) act copts decodeLispOrJSON :: FromJSON a => ByteString -> Either String (Bool, a) decodeLispOrJSON str = ((,) <$> pure False <*> eitherDecode str) <|> ((,) <$> pure True <*> decodeLisp str) encodeLispOrJSON :: ToJSON a => Bool -> a -> ByteString encodeLispOrJSON True = encodeLisp encodeLispOrJSON False = encode -- | Process request, notifications can be sent during processing processRequest :: CommandOptions -> (Notification -> IO ()) -> Request -> IO Result processRequest copts onNotify req' = runArgs Client.commands unknownCommand requestError (requestToArgs req') (copts { commandNotify = onNotify }) where unknownCommand :: CommandAction unknownCommand _ = return $ Error "Unknown command" M.empty requestError :: String -> CommandAction requestError errs _ = return $ Error "Command syntax error" $ M.fromList [ ("what", toJSON $ lines errs)] -- | Process client, listen for requests and process them processClient :: String -> IO ByteString -> (ByteString -> IO ()) -> CommandOptions -> IO () processClient name receive send' copts = do commandLog copts Log.Info $ name ++ " connected" respChan <- newChan void $ forkIO $ getChanContents respChan >>= mapM_ (send' . uncurry encodeLispOrJSON) linkVar <- newMVar $ return () let answer :: Bool -> Message Response -> IO () answer isLisp m@(Message _ r) = do when (not $ isNotification r) $ commandLog copts Log.Trace $ " << " ++ ellipsis (fromUtf8 (encode r)) writeChan respChan (isLisp, m) where ellipsis :: String -> String ellipsis s | length s < 100 = s | otherwise = take 100 s ++ "..." flip finally (disconnected linkVar) $ forever $ Log.scopeLog (commandLogger copts) (T.pack name) $ do req' <- receive commandLog copts Log.Trace $ " => " ++ fromUtf8 req' case second (fmap extractMeta) <$> decodeLispOrJSON req' of Left _ -> do commandLog copts Log.Warning $ "Invalid request: " ++ fromUtf8 req' answer False $ Message Nothing $ responseError "Invalid request" [ "request" .= fromUtf8 req'] Right (isLisp, m) -> Log.scopeLog (commandLogger copts) (T.pack $ fromMaybe "_" (messageId m)) $ do resp' <- flip traverse m $ \(cdir, noFile, silent, tm, reqArgs) -> do let onNotify n | silent = return () | otherwise = traverse (const $ mmap' noFile (Left n)) m >>= answer isLisp commandLog copts Log.Trace $ name ++ " >> " ++ fromUtf8 (encode reqArgs) resp <- fmap Right $ handleTimeout tm $ handleError $ processRequest (copts { commandRoot = cdir, commandLink = void (swapMVar linkVar $ commandExit copts) }) onNotify reqArgs mmap' noFile resp answer isLisp resp' where extractMeta :: Request -> (FilePath, Bool, Bool, Maybe Int, Request) extractMeta c = (cdir, noFile, silent, tm, c { requestOpts = opts' }) where cdir = fromMaybe (commandRoot copts) $ arg "current-directory" metaOpts noFile = flagSet "no-file" metaOpts silent = flagSet "silent" metaOpts tm = join $ fmap readMaybe $ arg "timeout" metaOpts (metaOpts, opts') = splitOpts [ req "current-directory" "path", flag "no-file", flag "silent", req "timeout" "ms"] (requestOpts c) handleTimeout :: Maybe Int -> IO Result -> IO Result handleTimeout Nothing = id handleTimeout (Just tm) = fmap (fromMaybe $ Error "Timeout" M.empty) . timeout tm handleError :: IO Result -> IO Result handleError = handle onErr where onErr :: SomeException -> IO Result onErr e = return $ Error "Exception" $ M.fromList [("what", toJSON $ show e)] mmap' :: Bool -> Response -> IO Response #if mingw32_HOST_OS mmap' False | Just pool <- commandMmapPool copts = mmap pool #endif mmap' _ = return disconnected :: MVar (IO ()) -> IO () disconnected var = do commandLog copts Log.Info $ name ++ " disconnected" join $ takeMVar var {- -- | Process client by Handle processClientHandle :: Show a => a -> Handle -> CommandOptions -> IO () processClientHandle n h = processClient (show n) (hGetLineBS h) (\s -> L.hPutStrLn h s >> hFlush h) -} -- | Process client by socket processClientSocket :: Socket -> CommandOptions -> IO () processClientSocket s copts = do recvChan <- F.newChan void $ forkIO $ finally (Net.getContents s >>= mapM_ (F.putChan recvChan) . L.lines) (F.closeChan recvChan) processClient (show s) (getChan_ recvChan) (sendLine s) (copts { commandHold = forever (getChan_ recvChan) }) where getChan_ :: F.Chan a -> IO a getChan_ = F.getChan >=> maybe noData return noData :: IO a noData = throwIO $ userError "Receive chan closed" -- NOTE: Network version of `sendAll` goes to infinite loop on client socket close -- when server's send is blocked, see https://github.com/haskell/network/issues/155 -- After that issue fixed we may revert to `processClientHandle` sendLine :: Socket -> ByteString -> IO () sendLine sock bs = sendAll sock $ L.toStrict $ L.snoc bs '\n' sendAll :: Socket -> BS.ByteString -> IO () sendAll sock bs | BS.null bs = return () | otherwise = do sent <- Net.send sock bs when (sent > 0) $ sendAll sock (BS.drop sent bs) -- | Perform action on cache withCache :: Opts String -> a -> (FilePath -> IO a) -> IO a withCache sopts v onCache = case arg "cache" sopts of Nothing -> return v Just cdir -> onCache cdir writeCache :: Opts String -> (Log.Level -> String -> IO ()) -> Database -> IO () writeCache sopts logMsg' d = withCache sopts () $ \cdir -> do logMsg' Log.Info $ "writing cache to " ++ cdir logIO "cache writing exception: " (logMsg' Log.Error) $ do let sd = structurize d SC.dump cdir sd forM_ (M.keys (structuredCabals sd)) $ \c -> logMsg' Log.Debug ("cache write: cabal " ++ show c) forM_ (M.keys (structuredProjects sd)) $ \p -> logMsg' Log.Debug ("cache write: project " ++ p) case allModules (structuredFiles sd) of [] -> return () ms -> logMsg' Log.Debug $ "cache write: " ++ show (length ms) ++ " files" logMsg' Log.Info $ "cache saved to " ++ cdir readCache :: Opts String -> (Log.Level -> String -> IO ()) -> (FilePath -> ExceptT String IO Structured) -> IO (Maybe Database) readCache sopts logMsg' act = withCache sopts Nothing $ join . liftM (either cacheErr cacheOk) . runExceptT . act where cacheErr e = logMsg' Log.Error ("Error reading cache: " ++ e) >> return Nothing cacheOk s = do forM_ (M.keys (structuredCabals s)) $ \c -> logMsg' Log.Debug ("cache read: cabal " ++ show c) forM_ (M.keys (structuredProjects s)) $ \p -> logMsg' Log.Debug ("cache read: project " ++ p) case allModules (structuredFiles s) of [] -> return () ms -> logMsg' Log.Debug $ "cache read: " ++ show (length ms) ++ " files" return $ Just $ merge s #if mingw32_HOST_OS data MmapFile = MmapFile String instance ToJSON MmapFile where toJSON (MmapFile f) = object ["file" .= f] instance FromJSON MmapFile where parseJSON = withObject "file" $ \v -> MmapFile <$> v .:: "file" -- | Push message to mmap and return response which points to this mmap mmap :: Pool -> Response -> IO Response mmap mmapPool r | L.length msg <= 1024 = return r | otherwise = withSync (responseError "timeout" []) $ \sync -> timeout 10000000 $ withName mmapPool $ \mmapName -> do runExceptT $ flip catchError (\e -> liftIO $ sync $ responseError e []) (withMapFile mmapName (L.toStrict msg) $ liftIO $ do sync $ result $ MmapFile mmapName -- give 10 seconds for client to read data threadDelay 10000000) where msg = encode r #endif -- | If response points to mmap, get its contents and parse unMmap :: Response -> IO Response #if mingw32_HOST_OS unMmap (Right (Result v)) | Just (MmapFile f) <- parseMaybe parseJSON v = do cts <- runExceptT (fmap L.fromStrict (readMapFile f)) case cts of Left _ -> return $ responseError "Unable to read map view of file" ["file" .= f] Right r' -> case eitherDecode r' of Left e' -> return $ responseError "Invalid response" ["response" .= fromUtf8 r', "parser error" .= e'] Right r'' -> return r'' #endif unMmap r = return r