Skip to content

Log response errors returned from Plugins #2988

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jul 1, 2022
85 changes: 52 additions & 33 deletions ghcide/src/Development/IDE/Plugin/HLS.hs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ module Development.IDE.Plugin.HLS
) where

import Control.Exception (SomeException)
import Control.Lens ((^.))
import Control.Monad
import qualified Data.Aeson as J
import Data.Bifunctor
Expand All @@ -21,6 +22,7 @@ import qualified Data.List as List
import Data.List.NonEmpty (NonEmpty, nonEmpty, toList)
import qualified Data.Map as Map
import Data.String
import Data.Text (Text)
import qualified Data.Text as T
import Development.IDE.Core.Shake hiding (Log)
import Development.IDE.Core.Tracing
Expand All @@ -33,9 +35,10 @@ import Ide.Plugin.Config
import Ide.PluginUtils (getClientConfig)
import Ide.Types as HLS
import qualified Language.LSP.Server as LSP
import Language.LSP.VFS
import Language.LSP.Types
import qualified Language.LSP.Types as J
import qualified Language.LSP.Types.Lens as LSP
import Language.LSP.VFS
import Text.Regex.TDFA.Text ()
import UnliftIO (MonadUnliftIO)
import UnliftIO.Async (forConcurrently)
Expand All @@ -44,20 +47,48 @@ import UnliftIO.Exception (catchAny)
-- ---------------------------------------------------------------------
--

data Log
= LogNoEnabledPlugins
deriving Show
data Log = LogPluginError ResponseError
deriving Show

instance Pretty Log where
pretty = \case
LogNoEnabledPlugins ->
"extensibleNotificationPlugins no enabled plugins"
LogPluginError err -> responseErrorToLogMessage err

responseErrorToLogMessage :: ResponseError -> Doc a
responseErrorToLogMessage err = errorCode <> ":" <+> errorBody
where
errorCode = pretty $ show $ err ^. LSP.code
errorBody = pretty $ err ^. LSP.message

-- various error message specific builders
pluginNotEnabled :: SMethod m -> [(PluginId, b, a)] -> Text
pluginNotEnabled method availPlugins = "No plugin enabled for " <> T.pack (show method) <> ", available: " <> T.pack (show $ map (\(plid,_,_) -> plid) availPlugins)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use pretty for the plugin ids, define an instance if there isn't one? Could even do some layout so they appear on subsequent lines if that's useful.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I hoisted these error message builders outside of the plugin handlers. Just wanted to clean up the surrounding code in the handlers. I'll go for the instance option and see if I can clean up some of these messages

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use pretty for the plugin ids, define an instance if there isn't one? Could even do some layout so they appear on subsequent lines if that's useful.

I just realized that these build the response error messages so I don’t think there is any reason to add Pretty instances. They aren't usable in this context anyways


pluginDoesntExist :: PluginId -> Text
pluginDoesntExist (PluginId pid) = "Plugin " <> pid <> " doesn't exist"

commandDoesntExist :: CommandId -> PluginId -> [PluginCommand ideState] -> Text
commandDoesntExist (CommandId com) (PluginId pid) legalCmds = "Command " <> com <> " isn't defined for plugin " <> pid <> ". Legal commands are: " <> T.pack (show $ map commandId legalCmds)

failedToParseArgs :: CommandId -- ^ command that failed to parse
-> PluginId -- ^ Plugin that created the command
-> String -- ^ The JSON Error message
-> J.Value -- ^ The Argument Values
-> Text
failedToParseArgs (CommandId com) (PluginId pid) err arg = "Error while parsing args for " <> com <> " in plugin " <> pid <> ": " <> T.pack err <> "\narg = " <> T.pack (show arg)

-- | Build a ResponseError and log it before returning to the caller
logAndReturnError :: Recorder (WithPriority Log) -> ErrorCode -> Text -> LSP.LspT Config IO (Either ResponseError a)
logAndReturnError recorder errCode msg = do
let err = ResponseError errCode msg Nothing
logWith recorder Error $ LogPluginError err
pure $ Left err

-- | Map a set of plugins to the underlying ghcide engine.
asGhcIdePlugin :: Recorder (WithPriority Log) -> IdePlugins IdeState -> Plugin Config
asGhcIdePlugin recorder (IdePlugins ls) =
mkPlugin rulesPlugins HLS.pluginRules <>
mkPlugin executeCommandPlugins HLS.pluginCommands <>
mkPlugin (executeCommandPlugins recorder) HLS.pluginCommands <>
mkPlugin (extensiblePlugins recorder) id <>
mkPlugin (extensibleNotificationPlugins recorder) id <>
mkPlugin dynFlagsPlugins HLS.pluginModifyDynflags
Expand Down Expand Up @@ -91,11 +122,11 @@ dynFlagsPlugins rs = mempty

-- ---------------------------------------------------------------------

executeCommandPlugins :: [(PluginId, [PluginCommand IdeState])] -> Plugin Config
executeCommandPlugins ecs = mempty { P.pluginHandlers = executeCommandHandlers ecs }
executeCommandPlugins :: Recorder (WithPriority Log) -> [(PluginId, [PluginCommand IdeState])] -> Plugin Config
executeCommandPlugins recorder ecs = mempty { P.pluginHandlers = executeCommandHandlers recorder ecs }

executeCommandHandlers :: [(PluginId, [PluginCommand IdeState])] -> LSP.Handlers (ServerM Config)
executeCommandHandlers ecs = requestHandler SWorkspaceExecuteCommand execCmd
executeCommandHandlers :: Recorder (WithPriority Log) -> [(PluginId, [PluginCommand IdeState])] -> LSP.Handlers (ServerM Config)
executeCommandHandlers recorder ecs = requestHandler SWorkspaceExecuteCommand execCmd
where
pluginMap = Map.fromList ecs

Expand Down Expand Up @@ -134,21 +165,15 @@ executeCommandHandlers ecs = requestHandler SWorkspaceExecuteCommand execCmd
Just (plugin, cmd) -> runPluginCommand ide plugin cmd cmdParams

-- Couldn't parse the command identifier
_ -> return $ Left $ ResponseError InvalidParams "Invalid command identifier" Nothing
_ -> logAndReturnError recorder InvalidParams "Invalid command Identifier"

runPluginCommand ide p@(PluginId p') com@(CommandId com') arg =
runPluginCommand ide p com arg =
case Map.lookup p pluginMap of
Nothing -> return
(Left $ ResponseError InvalidRequest ("Plugin " <> p' <> " doesn't exist") Nothing)
Nothing -> logAndReturnError recorder InvalidRequest (pluginDoesntExist p)
Just xs -> case List.find ((com ==) . commandId) xs of
Nothing -> return $ Left $
ResponseError InvalidRequest ("Command " <> com' <> " isn't defined for plugin " <> p'
<> ". Legal commands are: " <> T.pack(show $ map commandId xs)) Nothing
Nothing -> logAndReturnError recorder InvalidRequest (commandDoesntExist com p xs)
Just (PluginCommand _ _ f) -> case J.fromJSON arg of
J.Error err -> return $ Left $
ResponseError InvalidParams ("error while parsing args for " <> com' <> " in plugin " <> p'
<> ": " <> T.pack err
<> "\narg = " <> T.pack (show arg)) Nothing
J.Error err -> logAndReturnError recorder InvalidParams (failedToParseArgs com p err arg)
J.Success a -> f ide a

-- ---------------------------------------------------------------------
Expand All @@ -169,19 +194,15 @@ extensiblePlugins recorder xs = mempty { P.pluginHandlers = handlers }
config <- Ide.PluginUtils.getClientConfig
-- Only run plugins that are allowed to run on this request
let fs = filter (\(_, desc, _) -> pluginEnabled m params desc config) fs'
-- Clients generally don't display ResponseErrors so instead we log any that we come across
case nonEmpty fs of
Nothing -> do
logWith recorder Info LogNoEnabledPlugins
pure $ Left $ ResponseError InvalidRequest
( "No plugin enabled for " <> T.pack (show m)
<> ", available: " <> T.pack (show $ map (\(plid,_,_) -> plid) fs)
)
Nothing
Nothing -> logAndReturnError recorder InvalidRequest (pluginNotEnabled m fs')
Just fs -> do
let msg e pid = "Exception in plugin " <> T.pack (show pid) <> "while processing " <> T.pack (show m) <> ": " <> T.pack (show e)
handlers = fmap (\(plid,_,handler) -> (plid,handler)) fs
es <- runConcurrently msg (show m) handlers ide params
let (errs,succs) = partitionEithers $ toList es
unless (null errs) $ forM_ errs $ \err -> logWith recorder Warning $ LogPluginError err
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why Warning here and Error elsewhere?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think my reasoning was that in the event succs has responses we should just mark errors as Warnings but I guess ANY ResponseError should be classified as an error.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but yes I will update to Error

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, this code is kind of suspect. I'd be more comfortable with failing if any of them fail, rather than succeeding if any of them succeed 🤔

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the only thing I can thing of is if I trigger a code action, and a single code action fails, I don't want to fail ALL of the code actions. I'd rather get back the ones that succeed and ignore the failures

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess that's true. Hopefully that will still happen, they'll just get more obvious logs...

case nonEmpty succs of
Nothing -> pure $ Left $ combineErrors errs
Just xs -> do
Expand All @@ -206,9 +227,7 @@ extensibleNotificationPlugins recorder xs = mempty { P.pluginHandlers = handlers
-- Only run plugins that are allowed to run on this request
let fs = filter (\(_, desc, _) -> pluginEnabled m params desc config) fs'
case nonEmpty fs of
Nothing -> do
logWith recorder Info LogNoEnabledPlugins
pure ()
Nothing -> void $ logAndReturnError recorder InvalidRequest (pluginNotEnabled m fs')
Just fs -> do
-- We run the notifications in order, so the core ghcide provider
-- (which restarts the shake process) hopefully comes last
Expand All @@ -227,7 +246,7 @@ runConcurrently
-> m (NonEmpty (Either ResponseError d))
runConcurrently msg method fs a b = fmap join $ forConcurrently fs $ \(pid,f) -> otTracedProvider pid (fromString method) $ do
f a b
`catchAny` (\e -> pure $ pure $ Left $ ResponseError InternalError (msg e pid) Nothing)
`catchAny` (\e -> pure $ pure $ Left $ ResponseError InternalError (msg e pid) Nothing)

combineErrors :: [ResponseError] -> ResponseError
combineErrors [x] = x
Expand Down
6 changes: 2 additions & 4 deletions hls-plugin-api/src/Ide/PluginUtils.hs
Original file line number Diff line number Diff line change
Expand Up @@ -253,10 +253,8 @@ getNormalizedFilePath (PluginId plId) uri = handleMaybe errMsg
errMsg = T.unpack $ "Error(" <> plId <> "): converting " <> getUri uri <> " to NormalizedFilePath"

-- ---------------------------------------------------------------------
throwPluginError :: Monad m => PluginId -> String -> String -> ExceptT String m b
throwPluginError (PluginId who) what where' = throwE msg
where
msg = (T.unpack who) <> " failed with " <> what <> " at " <> where'
throwPluginError :: Monad m => String -> ExceptT String m b
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inline?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I plan on coming back to this (assuming people are ok with my suggestion). I want to come up with some way to tie back log messages to their context. So for instance I'm thinking this would eventually return ExceptT Context m b where Context can provide some information for what happened. This Context should be usable in all locations where we log (so internal to ghcide or shake etc.) This way plugins can just give an error message and the plugin id is automatically attached.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you rather want ReaderT Context (ExceptT String m) b or something?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure It would be in a follow up PR and the main detractor is whatever the solution is, has to cross ghcide and hls-plugin-api package barrier -- which I'm not sure how to deal with

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think adding a ReaderT would not be too controversial. I wonder if there's a way we can sneak it into Recorder? So we'd do something like

let subcomponentRecorder = cmap (addContext "SubComponent") recorder

where addContext does... something.

throwPluginError = throwE

handleMaybe :: Monad m => e -> Maybe b -> ExceptT e m b
handleMaybe msg = maybe (throwE msg) return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ codeActionHandler state plId (CodeActionParams _ _ docId currRange _) = pluginRe
literalPairs = map (\lit -> (lit, alternateFormat lit)) litsInRange
-- make a code action for every literal and its' alternates (then flatten the result)
actions = concatMap (\(lit, alts) -> map (mkCodeAction nfp lit enabledExtensions pragma) alts) literalPairs

pure $ List actions
where
inCurrentRange :: Literal -> Bool
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ incomingCalls state pluginId param = pluginResponse $ do
mergeIncomingCalls
case calls of
Just x -> pure $ Just $ List x
Nothing -> throwPluginError callHierarchyId "Internal Error" "incomingCalls"
Nothing -> throwPluginError "incomingCalls - Internal Error"
where
mkCallHierarchyIncomingCall :: Vertex -> Action (Maybe CallHierarchyIncomingCall)
mkCallHierarchyIncomingCall = mkCallHierarchyCall CallHierarchyIncomingCall
Expand All @@ -224,7 +224,7 @@ outgoingCalls state pluginId param = pluginResponse $ do
mergeOutgoingCalls
case calls of
Just x -> pure $ Just $ List x
Nothing -> throwPluginError callHierarchyId "Internal Error" "outgoingCalls"
Nothing -> throwPluginError "outgoingCalls - Internal Error"
where
mkCallHierarchyOutgoingCall :: Vertex -> Action (Maybe CallHierarchyOutgoingCall)
mkCallHierarchyOutgoingCall = mkCallHierarchyCall CallHierarchyOutgoingCall
Expand Down
2 changes: 1 addition & 1 deletion test/functional/Format.hs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ providerTests = testGroup "formatting provider" [
testCase "respects none" $ runSessionWithConfig (formatConfig "none") hlsCommand fullCaps "test/testdata/format" $ do
doc <- openDoc "Format.hs" "haskell"
resp <- request STextDocumentFormatting $ DocumentFormattingParams Nothing doc (FormattingOptions 2 True Nothing Nothing Nothing)
liftIO $ resp ^. LSP.result @?= Left (ResponseError InvalidRequest "No plugin enabled for STextDocumentFormatting, available: []" Nothing)
liftIO $ resp ^. LSP.result @?= Left (ResponseError InvalidRequest "No plugin enabled for STextDocumentFormatting, available: [PluginId \"floskell\",PluginId \"fourmolu\",PluginId \"ormolu\",PluginId \"stylish-haskell\",PluginId \"brittany\"]" Nothing)

, requiresOrmoluPlugin . requiresFloskellPlugin $ testCase "can change on the fly" $ runSession hlsCommand fullCaps "test/testdata/format" $ do
formattedOrmolu <- liftIO $ T.readFile "test/testdata/format/Format.ormolu.formatted.hs"
Expand Down