Merge pull request #1772 from SupercedeTech/make-exception-catching-configurable

Make catching exceptions configurable.
This commit is contained in:
Michael Snoyman 2022-07-20 18:04:39 +03:00 committed by GitHub
commit 337a9928f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 157 additions and 63 deletions

View File

@ -1,5 +1,9 @@
# ChangeLog for yesod-core # ChangeLog for yesod-core
## 1.6.24.0
* Make catching exceptions configurable and set the default back to rethrowing async exceptions. [#1772](https://github.com/yesodweb/yesod/pull/1772).
## 1.6.23.1 ## 1.6.23.1
* Fix typo in creation of the description `<meta>` tag in `defaultLayout`. [#1766](https://github.com/yesodweb/yesod/pull/1766) * Fix typo in creation of the description `<meta>` tag in `defaultLayout`. [#1766](https://github.com/yesodweb/yesod/pull/1766)

View File

@ -1,7 +1,9 @@
{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Yesod.Core.Class.Yesod where module Yesod.Core.Class.Yesod where
import Yesod.Core.Content import Yesod.Core.Content
@ -52,8 +54,10 @@ import Yesod.Core.Types
import Yesod.Core.Internal.Session import Yesod.Core.Internal.Session
import Yesod.Core.Widget import Yesod.Core.Widget
import Data.CaseInsensitive (CI) import Data.CaseInsensitive (CI)
import qualified Network.Wai.Handler.Warp as Warp
import qualified Network.Wai.Request import qualified Network.Wai.Request
import Data.IORef import Data.IORef
import UnliftIO (SomeException, catch, MonadUnliftIO)
-- | Define settings for a Yesod applications. All methods have intelligent -- | Define settings for a Yesod applications. All methods have intelligent
-- defaults, and therefore no implementation is required. -- defaults, and therefore no implementation is required.
@ -70,6 +74,16 @@ class RenderRoute site => Yesod site where
approot :: Approot site approot :: Approot site
approot = guessApproot approot = guessApproot
-- | @since 1.6.24.0
-- allows the user to specify how exceptions are cought.
-- by default all async exceptions are thrown and synchronous
-- exceptions render a 500 page.
-- To catch all exceptions (even async) to render a 500 page,
-- set this to 'UnliftIO.Exception.catchSyncOrAsync'. Beware
-- this may have negative effects with functions like 'timeout'.
catchHandlerExceptions :: MonadUnliftIO m => site -> m a -> (SomeException -> m a) -> m a
catchHandlerExceptions _ = catch
-- | Output error response pages. -- | Output error response pages.
-- --
-- Default value: 'defaultErrorHandler'. -- Default value: 'defaultErrorHandler'.

View File

@ -1,10 +1,11 @@
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternGuards #-} {-# LANGUAGE PatternGuards #-}
{-# LANGUAGE RankNTypes #-} {-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TupleSections #-} {-# LANGUAGE TupleSections #-}
{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Yesod.Core.Internal.Run module Yesod.Core.Internal.Run
( toErrorHandler ( toErrorHandler
, errFromShow , errFromShow
@ -54,28 +55,7 @@ import Yesod.Routes.Class (Route, renderRoute)
import Control.DeepSeq (($!!), NFData) import Control.DeepSeq (($!!), NFData)
import UnliftIO.Exception import UnliftIO.Exception
import UnliftIO(MonadUnliftIO, withRunInIO) import UnliftIO(MonadUnliftIO, withRunInIO)
import Data.Proxy(Proxy(..))
-- | like `catch` but doesn't check for async exceptions,
-- thereby catching them too.
-- This is desirable for letting yesod generate a 500 error page
-- rather then warp.
--
-- Normally this is VERY dubious. you need to rethrow.
-- recovrery from async isn't allowed.
-- see async section: https://www.fpcomplete.com/blog/2018/04/async-exception-handling-haskell/
unsafeAsyncCatch
:: (MonadUnliftIO m, Exception e)
=> m a -- ^ action
-> (e -> m a) -- ^ handler
-> m a
unsafeAsyncCatch f g = withRunInIO $ \run -> run f `EUnsafe.catch` \e -> do
run (g e)
unsafeAsyncCatchAny :: (MonadUnliftIO m)
=> m a -- ^ action
-> (SomeException -> m a) -- ^ handler
-> m a
unsafeAsyncCatchAny = unsafeAsyncCatch
-- | Convert a synchronous exception into an ErrorResponse -- | Convert a synchronous exception into an ErrorResponse
toErrorHandler :: SomeException -> IO ErrorResponse toErrorHandler :: SomeException -> IO ErrorResponse
@ -108,7 +88,7 @@ basicRunHandler rhe handler yreq resState = do
-- Run the handler itself, capturing any runtime exceptions and -- Run the handler itself, capturing any runtime exceptions and
-- converting them into a @HandlerContents@ -- converting them into a @HandlerContents@
contents' <- unsafeAsyncCatch contents' <- rheCatchHandlerExceptions rhe
(do (do
res <- unHandlerFor handler (hd istate) res <- unHandlerFor handler (hd istate)
tc <- evaluate (toTypedContent res) tc <- evaluate (toTypedContent res)
@ -212,10 +192,11 @@ handleContents handleError' finalSession headers contents =
-- --
-- Note that this also catches async exceptions. -- Note that this also catches async exceptions.
evalFallback :: (Monoid w, NFData w) evalFallback :: (Monoid w, NFData w)
=> HandlerContents => (forall a. IO a -> (SomeException -> IO a) -> IO a)
-> HandlerContents
-> w -> w
-> IO (w, HandlerContents) -> IO (w, HandlerContents)
evalFallback contents val = unsafeAsyncCatchAny evalFallback catcher contents val = catcher
(fmap (, contents) (evaluate $!! val)) (fmap (, contents) (evaluate $!! val))
(fmap ((mempty, ) . HCError) . toErrorHandler) (fmap ((mempty, ) . HCError) . toErrorHandler)
@ -231,8 +212,8 @@ runHandler rhe@RunHandlerEnv {..} handler yreq = withInternalState $ \resState -
-- Evaluate the unfortunately-lazy session and headers, -- Evaluate the unfortunately-lazy session and headers,
-- propagating exceptions into the contents -- propagating exceptions into the contents
(finalSession, contents1) <- evalFallback contents0 (ghsSession state) (finalSession, contents1) <- evalFallback rheCatchHandlerExceptions contents0 (ghsSession state)
(headers, contents2) <- evalFallback contents1 (appEndo (ghsHeaders state) []) (headers, contents2) <- evalFallback rheCatchHandlerExceptions contents1 (appEndo (ghsHeaders state) [])
contents3 <- (evaluate contents2) `catchAny` (fmap HCError . toErrorHandler) contents3 <- (evaluate contents2) `catchAny` (fmap HCError . toErrorHandler)
-- Convert the HandlerContents into the final YesodResponse -- Convert the HandlerContents into the final YesodResponse
@ -275,7 +256,7 @@ safeEh log' er req = do
-- @HandlerFor@ is completely ignored, including changes to the -- @HandlerFor@ is completely ignored, including changes to the
-- session, cookies or headers. We only return you the -- session, cookies or headers. We only return you the
-- @HandlerFor@'s return value. -- @HandlerFor@'s return value.
runFakeHandler :: (Yesod site, MonadIO m) => runFakeHandler :: forall site m a . (Yesod site, MonadIO m) =>
SessionMap SessionMap
-> (site -> Logger) -> (site -> Logger)
-> site -> site
@ -296,6 +277,7 @@ runFakeHandler fakeSessionMap logger site handler = liftIO $ do
, rheLog = messageLoggerSource site $ logger site , rheLog = messageLoggerSource site $ logger site
, rheOnError = errHandler , rheOnError = errHandler
, rheMaxExpires = maxExpires , rheMaxExpires = maxExpires
, rheCatchHandlerExceptions = catchHandlerExceptions site
} }
handler' handler'
errHandler err req = do errHandler err req = do
@ -337,7 +319,7 @@ runFakeHandler fakeSessionMap logger site handler = liftIO $ do
_ <- runResourceT $ yapp fakeRequest _ <- runResourceT $ yapp fakeRequest
I.readIORef ret I.readIORef ret
yesodRunner :: (ToTypedContent res, Yesod site) yesodRunner :: forall res site . (ToTypedContent res, Yesod site)
=> HandlerFor site res => HandlerFor site res
-> YesodRunnerEnv site -> YesodRunnerEnv site
-> Maybe (Route site) -> Maybe (Route site)
@ -372,6 +354,7 @@ yesodRunner handler' YesodRunnerEnv {..} route req sendResponse = do
, rheLog = log' , rheLog = log'
, rheOnError = safeEh log' , rheOnError = safeEh log'
, rheMaxExpires = maxExpires , rheMaxExpires = maxExpires
, rheCatchHandlerExceptions = catchHandlerExceptions yreSite
} }
rhe = rheSafe rhe = rheSafe
{ rheOnError = runHandler rheSafe . errorHandler { rheOnError = runHandler rheSafe . errorHandler

View File

@ -8,6 +8,7 @@
{-# LANGUAGE TypeFamilies #-} {-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-} {-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE GADTs #-} {-# LANGUAGE GADTs #-}
{-# LANGUAGE RankNTypes #-}
module Yesod.Core.Types where module Yesod.Core.Types where
import Data.Aeson (ToJSON) import Data.Aeson (ToJSON)
@ -55,7 +56,7 @@ import Control.Monad.Reader (MonadReader (..))
import Control.DeepSeq (NFData (rnf)) import Control.DeepSeq (NFData (rnf))
import Yesod.Core.TypeCache (TypeMap, KeyedTypeMap) import Yesod.Core.TypeCache (TypeMap, KeyedTypeMap)
import Control.Monad.Logger (MonadLoggerIO (..)) import Control.Monad.Logger (MonadLoggerIO (..))
import UnliftIO (MonadUnliftIO (..)) import UnliftIO (MonadUnliftIO (..), SomeException)
-- Sessions -- Sessions
type SessionMap = Map Text ByteString type SessionMap = Map Text ByteString
@ -182,6 +183,11 @@ data RunHandlerEnv child site = RunHandlerEnv
-- --
-- Since 1.2.0 -- Since 1.2.0
, rheMaxExpires :: !Text , rheMaxExpires :: !Text
-- | @since 1.6.24.0
-- catch function for rendering 500 pages on exceptions.
-- by default this is catch from unliftio (rethrows all async exceptions).
, rheCatchHandlerExceptions :: !(forall a m . MonadUnliftIO m => m a -> (SomeException -> m a) -> m a)
} }
data HandlerData child site = HandlerData data HandlerData child site = HandlerData

View File

@ -1,12 +1,15 @@
{-# LANGUAGE TypeFamilies, QuasiQuotes, TemplateHaskell, MultiParamTypeClasses, OverloadedStrings #-} {-# LANGUAGE TypeFamilies, QuasiQuotes, TemplateHaskell, MultiParamTypeClasses, OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE ViewPatterns #-} {-# LANGUAGE ViewPatterns #-}
{-# LANGUAGE LambdaCase #-}
module YesodCoreTest.ErrorHandling module YesodCoreTest.ErrorHandling
( errorHandlingTest ( errorHandlingTest
, Widget , Widget
, resourcesApp , resourcesApp
) where ) where
import Data.Typeable(cast)
import qualified System.Mem as Mem import qualified System.Mem as Mem
import qualified Control.Concurrent.Async as Async import qualified Control.Concurrent.Async as Async
import Control.Concurrent as Conc import Control.Concurrent as Conc
@ -16,16 +19,19 @@ import Network.Wai
import Network.Wai.Test import Network.Wai.Test
import qualified Data.ByteString.Lazy as L import qualified Data.ByteString.Lazy as L
import qualified Data.ByteString.Char8 as S8 import qualified Data.ByteString.Char8 as S8
import Control.Exception (SomeException, try) import Control.Exception (SomeException, try, AsyncException(..))
import UnliftIO.Exception(finally) import UnliftIO.Exception(finally)
import Network.HTTP.Types (Status, mkStatus) import Network.HTTP.Types (Status, mkStatus)
import Data.ByteString.Builder (Builder, toLazyByteString) import Data.ByteString.Builder (Builder, toLazyByteString)
import Data.Monoid (mconcat) import Data.Monoid (mconcat)
import Data.Text (Text, pack) import Data.Text (Text, pack)
import Control.Monad (forM_) import Control.Monad (forM_)
import qualified Network.Wai.Handler.Warp as Warp
import qualified YesodCoreTest.ErrorHandling.CustomApp as Custom
import Control.Monad.Trans.State (StateT (..)) import Control.Monad.Trans.State (StateT (..))
import Control.Monad.Trans.Reader (ReaderT (..)) import Control.Monad.Trans.Reader (ReaderT (..))
import qualified UnliftIO.Exception as E import qualified UnliftIO.Exception as E
import System.Timeout(timeout)
data App = App data App = App
@ -52,7 +58,8 @@ mkYesod "App" [parseRoutes|
/only-plain-text OnlyPlainTextR GET /only-plain-text OnlyPlainTextR GET
/thread-killed ThreadKilledR GET /thread-killed ThreadKilledR GET
/async-session AsyncSessionR GET /connection-closed-by-peer ConnectionClosedPeerR GET
/sleep-sec SleepASecR GET
|] |]
overrideStatus :: Status overrideStatus :: Status
@ -125,15 +132,16 @@ getThreadKilledR = do
x <- liftIO Conc.myThreadId x <- liftIO Conc.myThreadId
liftIO $ Async.withAsync (Conc.killThread x) Async.wait liftIO $ Async.withAsync (Conc.killThread x) Async.wait
pure "unreachablle" pure "unreachablle"
getSleepASecR :: Handler Html
getSleepASecR = do
liftIO $ Conc.threadDelay 1000000
pure "slept a second"
getAsyncSessionR :: Handler Html getConnectionClosedPeerR :: Handler Html
getAsyncSessionR = do getConnectionClosedPeerR = do
setSession "jap" $ foldMap (pack . show) [0..999999999999999999999999] -- it's going to take a while to figure this one out
x <- liftIO Conc.myThreadId x <- liftIO Conc.myThreadId
liftIO $ forkIO $ do liftIO $ Async.withAsync (E.throwTo x Warp.ConnectionClosedByPeer) Async.wait
liftIO $ Conc.threadDelay 100000 pure "unreachablle"
Conc.killThread x
pure "reachable"
getErrorR :: Int -> Handler () getErrorR :: Int -> Handler ()
getErrorR 1 = setSession undefined "foo" getErrorR 1 = setSession undefined "foo"
@ -178,8 +186,10 @@ errorHandlingTest = describe "Test.ErrorHandling" $ do
it "accept CSS, permission denied -> 403" caseCssPermissionDenied it "accept CSS, permission denied -> 403" caseCssPermissionDenied
it "accept image, non-existent path -> 404" caseImageNotFound it "accept image, non-existent path -> 404" caseImageNotFound
it "accept video, bad method -> 405" caseVideoBadMethod it "accept video, bad method -> 405" caseVideoBadMethod
it "thread killed = 500" caseThreadKilled500 it "default config exception rethrows connection closed" caseDefaultConnectionCloseRethrows
it "async session exception = 500" asyncSessionKilled500 it "custom config rethrows an exception" caseCustomExceptionRethrows
it "thread killed rethrow" caseThreadKilledRethrow
it "can timeout a runner" canTimeoutARunner
runner :: Session a -> IO a runner :: Session a -> IO a
runner f = toWaiApp App >>= runSession f runner f = toWaiApp App >>= runSession f
@ -318,14 +328,49 @@ caseVideoBadMethod = runner $ do
} }
assertStatus 405 res assertStatus 405 res
caseThreadKilled500 :: IO () fromExceptionUnwrap :: E.Exception e => SomeException -> Maybe e
caseThreadKilled500 = runner $ do fromExceptionUnwrap se
res <- request defaultRequest { pathInfo = ["thread-killed"] } | Just (E.AsyncExceptionWrapper e) <- E.fromException se = cast e
assertStatus 500 res | Just (E.SyncExceptionWrapper e) <- E.fromException se = cast e
assertBodyContains "Internal Server Error" res | otherwise = E.fromException se
asyncSessionKilled500 :: IO ()
asyncSessionKilled500 = runner $ do caseThreadKilledRethrow :: IO ()
res <- request defaultRequest { pathInfo = ["async-session"] } caseThreadKilledRethrow =
assertStatus 500 res shouldThrow testcode $ \e -> case fromExceptionUnwrap e of
assertBodyContains "Internal Server Error" res (Just ThreadKilled) -> True
_ -> False
where
testcode = runner $ do
res <- request defaultRequest { pathInfo = ["thread-killed"] }
assertStatus 500 res
assertBodyContains "Internal Server Error" res
caseDefaultConnectionCloseRethrows :: IO ()
caseDefaultConnectionCloseRethrows =
shouldThrow testcode $ \e -> case fromExceptionUnwrap e of
Just Warp.ConnectionClosedByPeer -> True
_ -> False
where
testcode = runner $ do
_res <- request defaultRequest { pathInfo = ["connection-closed-by-peer"] }
pure ()
caseCustomExceptionRethrows :: IO ()
caseCustomExceptionRethrows =
shouldThrow testcode $ \case Custom.MkMyException -> True
where
testcode = customAppRunner $ do
_res <- request defaultRequest { pathInfo = ["throw-custom-exception"] }
pure ()
customAppRunner f = toWaiApp Custom.CustomApp >>= runSession f
canTimeoutARunner :: IO ()
canTimeoutARunner = do
res <- timeout 1000 $ runner $ do
res <- request defaultRequest { pathInfo = ["sleep-sec"] }
assertStatus 200 res -- if 500, it's catching the timeout exception
pure () -- it should've timeout by now, either being 500 or Nothing
res `shouldBe` Nothing -- make sure that pure statement didn't happen.

View File

@ -0,0 +1,41 @@
{-# LANGUAGE TypeFamilies, QuasiQuotes, TemplateHaskell, MultiParamTypeClasses, OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE ViewPatterns #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE DeriveAnyClass #-}
-- | a custom app that throws an exception
module YesodCoreTest.ErrorHandling.CustomApp
(CustomApp(..)
, MyException(..)
-- * unused
, Widget
, resourcesCustomApp
) where
import Yesod.Core.Types
import Yesod.Core
import qualified UnliftIO.Exception as E
data CustomApp = CustomApp
mkYesod "CustomApp" [parseRoutes|
/throw-custom-exception CustomHomeR GET
|]
getCustomHomeR :: Handler Html
getCustomHomeR =
E.throwIO MkMyException
data MyException = MkMyException
deriving (Show, E.Exception)
instance Yesod CustomApp where
-- something we couldn't do before, rethrow custom exceptions
catchHandlerExceptions _ action handler =
action `E.catch` \exception -> do
case E.fromException exception of
Just MkMyException -> E.throwIO MkMyException
Nothing -> handler exception

View File

@ -1,5 +1,5 @@
name: yesod-core name: yesod-core
version: 1.6.23.1 version: 1.6.24.0
license: MIT license: MIT
license-file: LICENSE license-file: LICENSE
author: Michael Snoyman <michael@snoyman.com> author: Michael Snoyman <michael@snoyman.com>
@ -146,6 +146,7 @@ test-suite tests
YesodCoreTest.Header YesodCoreTest.Header
YesodCoreTest.Csrf YesodCoreTest.Csrf
YesodCoreTest.ErrorHandling YesodCoreTest.ErrorHandling
YesodCoreTest.ErrorHandling.CustomApp
YesodCoreTest.Exceptions YesodCoreTest.Exceptions
YesodCoreTest.InternalRequest YesodCoreTest.InternalRequest
YesodCoreTest.JsLoader YesodCoreTest.JsLoader