packages feed

ghcid-1.0.0: app/Server.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}

module Server
  ( ServerEnv (..),
    withServer,
    setReloading,
    clearReloading,
    updateMessages,
    updateSession,
    rewriteRequestForGhci,
    rewriteResponseFromGhci,
  )
where

import Control.Concurrent
import Control.Concurrent.Async
import Control.Concurrent.Extra
import Control.Exception
import Control.Monad
import qualified Data.ByteString.Base64.URL as B64URL
import qualified Data.ByteString.Char8 as BS
import Data.List (isInfixOf, isPrefixOf, stripPrefix)
import Data.IORef
import Data.Maybe (fromMaybe)
import qualified Data.Aeson.Micro as JSON
import Data.Aeson.Micro ((.=), (.:))
import qualified Data.Text as T
import Language.Haskell.Ghcid.Escape (unescape)
import Language.Haskell.Ghcid.Types
import Network.Socket
import qualified Network.Socket.ByteString as NBS
import Session (PathMode (..), Session, sessionCurrentDir, sessionExec, sessionPathMode)
import System.Directory
import System.FilePath
import System.Info (os)
import Crypto.Hash.SHA256
import Language.Haskell.Ghcid.Util
#if MIN_VERSION_base(4,14,0)
import System.IO.Error (isResourceVanishedError)
#endif
import System.IO.Unsafe (unsafePerformIO)

data ServerEnv = ServerEnv
  { seSession :: IORef Session,
    seLock :: Lock,
    seReloading :: Var Bool,
    seMessages :: MVar [Load],
    seClients :: Var [Socket]
  }

newServerEnv :: IO ServerEnv
newServerEnv = do
  sessionRef <- newIORef (error "Session not initialized")
  lock <- newLock
  reloading <- newVar False
  messages <- newEmptyMVar
  clients <- newVar []
  pure
    ServerEnv
      { seSession = sessionRef,
        seLock = lock,
        seReloading = reloading,
        seMessages = messages,
        seClients = clients
      }

updateSession :: ServerEnv -> Session -> IO ()
updateSession ServerEnv {..} = writeIORef seSession

setReloading :: ServerEnv -> IO ()
setReloading ServerEnv {..} = writeVar seReloading True

clearReloading :: ServerEnv -> IO ()
clearReloading ServerEnv {..} = writeVar seReloading False

updateMessages :: ServerEnv -> [Load] -> IO ()
updateMessages env@ServerEnv {..} msgs = do
  inserted <- tryPutMVar seMessages msgs
  unless inserted $ void $ swapMVar seMessages msgs
  broadcastDiagnostics env (renderDiagnostics msgs)

withServer :: (ServerEnv -> IO a) -> IO a
withServer act = do
  env <- newServerEnv
  createDirectoryIfMissing True socketDir
  other <- canConnect serverSocketPath
  weClose <- newIORef False
  if other
    then do
      logInfo $ "withServer: Another ghcid server is already running at " ++ serverSocketPath
      act env
    else do
      removeIfExists serverSocketPath
      withListenServerSocket env $ \sock -> do

        let serverLoop = forever $ do
              (conn, _) <- accept sock
              logDebug "withServer: Accepted socket connection"
              void $ async $ handle
                (\(e :: SomeException) ->
                  if isExpectedDisconnect e
                    then logDebug $ "withServer: Client disconnected: " ++ show e
                    else do
                      weClosed <- readIORef weClose
                      if weClosed
                        then logDebug $ "withServer: Ignoring error after shutdown: " ++ show e
                        else logErr $ "withServer: Socket session crashed: " ++ show e)
                (serveClient env conn `finally` (do
                  close conn
                  logDebug "withServer: Closed socket connection"
                  ))

        mask $ \restore -> do
          serverAsync <- async serverLoop
          let shutdownServerLoop = do
                writeIORef weClose True
                ignored $ close sock
                cancel serverAsync
                void $ waitCatch serverAsync
          flip finally shutdownServerLoop $ do
            restore (act env)

{-# NOINLINE socketDir #-}
socketDir :: FilePath
socketDir = unsafePerformIO $ do
  -- Prefer /tmp over long $TMPDIR on mac to avoid socket length limit
  tmp <- (if os == "mingw32" then getTemporaryDirectory else pure "/tmp/ghcid")
  cwd <- getCurrentDirectory
  let digest = hash (BS.pack cwd)
  let shortHash = BS.unpack $ BS.take 8 $ B64URL.encodeUnpadded digest
  pure $ tmp </> shortHash

{-# NOINLINE serverSocketPath #-}
serverSocketPath :: FilePath
serverSocketPath = socketDir </> "server.sock"

withListenServerSocket :: ServerEnv -> (Socket -> IO a) -> IO a
withListenServerSocket env =
  bracket acquire release
  where
    acquire = do
      s <- socket AF_UNIX Stream defaultProtocol
      bind s (SockAddrUnix serverSocketPath)
      listen s 8
      pure s

    release sock = do
      clients <- readVar $ seClients env
      mapM_ (ignored . close) clients
      ignored $ close sock
      removeIfExists serverSocketPath

removeIfExists :: FilePath -> IO ()
removeIfExists p = removeFile p `catch` (\(_ :: IOException) -> pure ())

canConnect :: FilePath -> IO Bool
canConnect path = do
  result <- try $ bracket (socketConnect path) close (const $ pure ())
  pure $ case result of
    Right () -> True
    Left (_ :: IOException) -> False

socketConnect :: FilePath -> IO Socket
socketConnect path = do
  s <- socket AF_UNIX Stream defaultProtocol
  connect s (SockAddrUnix path)
  pure s

isExpectedDisconnect :: SomeException -> Bool
isExpectedDisconnect e =
  case fromException e :: Maybe IOException of
    Just ioe
      | isResourceVanishedErrorCompat ioe -> True
      | otherwise -> False
    Nothing -> False

isResourceVanishedErrorCompat :: IOError -> Bool
#if MIN_VERSION_base(4,14,0)
isResourceVanishedErrorCompat = isResourceVanishedError
#else
isResourceVanishedErrorCompat _ = False
#endif

serveClient :: ServerEnv -> Socket -> IO ()
serveClient env@ServerEnv {..} sock = do
  bracket_
    (modifyVar_ seClients $ pure . (sock :))
    (modifyVar_ seClients $ pure . filter (/= sock))
    $ do
        initial <- renderDiagnostics <$> readMVar seMessages
        sendLine sock "diag" initial
        loop BS.empty
  where
    loop pending = do
      (line, rest) <- recvLine sock pending
      case line of
         Nothing -> logDebug "Client disconnected"
         Just raw -> handleLine raw >> loop rest

    handleLine raw = case parseRequestLine raw of
      Left err -> sendLine sock "err" err
      Right cmd -> do
        logDebug $ "client->server " ++ BS.unpack raw
        result <- execGhci env cmd
        case result of
          Left err -> sendLine sock "err" err
          Right out -> sendLine sock "stdout" out

recvLine :: Socket -> BS.ByteString -> IO (Maybe BS.ByteString, BS.ByteString)
recvLine sock pending =
  case BS.break (== '\n') pending of
    (line, rest)
      | not (BS.null rest) -> pure (Just line, BS.drop 1 rest)
      | otherwise -> do
          chunk <- NBS.recv sock 4096
          if BS.null chunk
            then
              if BS.null pending
                then pure (Nothing, BS.empty)
                else pure (Just pending, BS.empty)
            else recvLine sock (pending <> chunk)

parseRequestLine :: BS.ByteString -> Either String String
parseRequestLine line = do
  let Nothing <?> a = Left a
      Just b <?> _ = Right b

  let parseRequest = JSON.withObject "Request" $ \obj -> do
        typ <- obj .: "type"
        payload <- obj .: "payload"
        if typ == "stdin"
          then pure (T.unpack payload)
          else fail $ "Unsupported message type: " ++ T.unpack typ

  value <- JSON.decodeStrict line <?> "Invalid JSON"

  JSON.parseMaybe parseRequest value <?> "Invalid request payload"

sendLine :: Socket -> T.Text -> String -> IO ()
sendLine sock tag payload = do
  let o = JSON.encodeStrict (JSON.object ["type" .= tag, "payload" .= T.pack payload]) <> "\n"
  logDebug $ "server->client " ++ BS.unpack o
  NBS.sendAll sock o


execGhci :: ServerEnv -> String -> IO (Either String String)
execGhci ServerEnv {..} cmd = do
  reloading <- readVar seReloading
  if reloading
    then do
      logDebug "Rejecting request because reload is in progress"
      pure $ Left "Reload in progress"
    else do
      result <- withLock seLock $ try $ do
        session <- readIORef seSession
        projectDir <- sessionCurrentDir session
        mode <- sessionPathMode session
        let rewrittenCmd = rewriteRequestForGhci mode projectDir cmd
        ls <- sessionExec session rewrittenCmd
        pure $ rewriteResponseFromGhci mode projectDir ls
      case result of
        Left (e :: SomeException) -> do
          logErr $ "sessionExec threw: " ++ show e
          pure $ Left (show e)
        Right ls -> pure $ Right (unlines ls)

rewriteRequestForGhci :: PathMode -> FilePath -> String -> String
rewriteRequestForGhci mode projectDir cmd =
  if mode == PathRelative
    then foldr rewrite cmd [":type-at", ":loc-at", ":uses"]
    else cmd
  where
    rewrite prefix acc =
      fromMaybe acc $ do
        rest <- stripPrefix (prefix ++ " " ++ addTrailingPathSeparator projectDir) acc
        pure $ prefix ++ " " ++ rest

rewriteResponseFromGhci :: PathMode -> FilePath -> [String] -> [String]
rewriteResponseFromGhci mode projectDir
  | mode == PathRelative = map (rewriteLocationLine projectDir)
  | otherwise = id

rewriteLocationLine :: FilePath -> String -> String
rewriteLocationLine projectDir line =
  if looksRelativeLocation line
    then projectDir </> line
    else line
  where
    looksRelativeLocation x =
      not (null x)
        && not (isAbsolute x)
        && not ("<" `isPrefixOf` x)
        && any (`isInfixOf` x) [".hs:", ".lhs:", ".hs-boot:"]

renderDiagnostics :: [Load] -> String
renderDiagnostics msgs =
  case [unescape line | Message {loadMessage = ls} <- msgs, line <- ls] of
    [] -> "All good (0 modules)\n"
    ls -> unlines ls

broadcastDiagnostics :: ServerEnv -> String -> IO ()
broadcastDiagnostics ServerEnv {..} payload = do
  clients <- readVar seClients
  forM_ clients $ \sock -> do
    result <- try $ sendLine sock "diag" payload
    case result of
      Left (e :: IOException) -> do
        logDebug $ "Failed to push diag to client: " ++ show e
      Right () -> pure ()