Marge Bot pushed to branch master at Glasgow Haskell Compiler / GHC
Commits:
-
7df3ba2f
by Andrea Vezzosi at 2026-10-14T06:15:48-04:00
14 changed files:
- + changelog.d/T4162
- compiler/GHC.hs
- compiler/GHC/Utils/Panic.hs
- ghc/Main.hs
- linters/lint-codes/LintCodes/Static.hs
- + testsuite/tests/ghc-api/T4162.hs
- + testsuite/tests/ghc-api/T4162.stdout
- testsuite/tests/ghc-api/all.T
- utils/check-exact/Parsers.hs
- utils/check-ppr/Main.hs
- utils/count-deps/Main.hs
- utils/dump-decls/Main.hs
- utils/haddock/haddock-api/src/Haddock.hs
- utils/haddock/haddock-api/test/Haddock/Backends/Hyperlinker/ParserSpec.hs
Changes:
| 1 | +section: compiler
|
|
| 2 | +issues: #4162
|
|
| 3 | +mrs: !16774
|
|
| 4 | +synopsis:
|
|
| 5 | + `runGhc/runGhcT` no longer hijack process signals, old behaviour provided by
|
|
| 6 | + `runGhcWithSignalHandlers`.
|
|
| 7 | +description:
|
|
| 8 | + Functions `runGhc/runGhcT` no longer go out of their way to redirect process
|
|
| 9 | + signals as exceptions to them.
|
|
| 10 | + |
|
| 11 | + The installed handlers make it so other threads are unaware of any signals,
|
|
| 12 | + which can cause the process to become unresponsive whenever a crash of the GHC
|
|
| 13 | + session(s) does not cascade into a shutdown of the process itself.
|
|
| 14 | + |
|
| 15 | + The old behaviour is available through `runGhcWithSignalHandlers` or by
|
|
| 16 | + calling `withSignalHandlers`. Only intended to be used from the main thread of
|
|
| 17 | + your process, see the documentation. |
| ... | ... | @@ -23,6 +23,7 @@ module GHC ( |
| 23 | 23 | -- * GHC Monad
|
| 24 | 24 | Ghc, GhcT, GhcMonad(..), HscEnv,
|
| 25 | 25 | runGhc, runGhcT, initGhcMonad,
|
| 26 | + runGhcWithSignalHandlers,
|
|
| 26 | 27 | printException,
|
| 27 | 28 | handleSourceError,
|
| 28 | 29 | |
| ... | ... | @@ -555,10 +556,26 @@ runGhc :: Maybe FilePath -- ^ See argument to 'initGhcMonad'. |
| 555 | 556 | runGhc mb_top_dir ghc = do
|
| 556 | 557 | ref <- newIORef (panic "empty session")
|
| 557 | 558 | let session = Session ref
|
| 558 | - flip unGhc session $ withSignalHandlers $ do -- catch ^C
|
|
| 559 | + flip unGhc session $ do
|
|
| 559 | 560 | initGhcMonad mb_top_dir
|
| 560 | 561 | withCleanupSession ghc
|
| 561 | 562 | |
| 563 | +-- | Like 'runGhc' but uses 'withSignalHandlers' to redirect selected signals to
|
|
| 564 | +-- this thread to trigger 'withCleanupSession', and so that interactive
|
|
| 565 | +-- evaluation receives 'UserInterrupt' exceptions.
|
|
| 566 | +--
|
|
| 567 | +-- __WARNING__: Only intended to be called from the __main thread__ of your
|
|
| 568 | +-- process, see 'withSignalHandlers' documentation for more details.
|
|
| 569 | +--
|
|
| 570 | +-- More complex multi-threaded programs are recommended to use 'runGhc' or
|
|
| 571 | +-- 'runGhcT', and handle signals as appropriate for their control flow.
|
|
| 572 | + |
|
| 573 | +runGhcWithSignalHandlers :: Maybe FilePath -- ^ See argument to 'initGhcMonad'.
|
|
| 574 | + -> Ghc a -- ^ The action to perform.
|
|
| 575 | + -> IO a
|
|
| 576 | +runGhcWithSignalHandlers mb_top_dir ghc = runGhc mb_top_dir $
|
|
| 577 | + withSignalHandlers $ ghc
|
|
| 578 | + |
|
| 562 | 579 | -- | Run function for 'GhcT' monad transformer.
|
| 563 | 580 | --
|
| 564 | 581 | -- It initialises the GHC session and warnings via 'initGhcMonad'. Each call
|
| ... | ... | @@ -572,7 +589,7 @@ runGhcT :: ExceptionMonad m => |
| 572 | 589 | runGhcT mb_top_dir ghct = do
|
| 573 | 590 | ref <- liftIO $ newIORef (panic "empty session")
|
| 574 | 591 | let session = Session ref
|
| 575 | - flip unGhcT session $ withSignalHandlers $ do -- catch ^C
|
|
| 592 | + flip unGhcT session $ do
|
|
| 576 | 593 | initGhcMonad mb_top_dir
|
| 577 | 594 | withCleanupSession ghct
|
| 578 | 595 |
| ... | ... | @@ -234,8 +234,21 @@ signalHandlersRefCount :: MVar (Word, Maybe S.Handler) |
| 234 | 234 | signalHandlersRefCount = unsafePerformIO $ newMVar (0,Nothing)
|
| 235 | 235 | |
| 236 | 236 | |
| 237 | --- | Temporarily install standard signal handlers for catching ^C, which just
|
|
| 238 | --- throw an exception in the current thread.
|
|
| 237 | +-- | Temporarily install signal handlers to redirect the following signals to
|
|
| 238 | +-- the calling thread as async exceptions: @INT@, @QUIT@, @HUP@, @TERM@ on unix, and
|
|
| 239 | +-- @CtrlC@, @Break@ on windows. The original signal handlers are restored when all
|
|
| 240 | +-- concurrent calls of this function are exited.
|
|
| 241 | +--
|
|
| 242 | +-- @INT@, @QUIT@, @CtrlC@, and @Break@ are thrown as @'UserInterrupt'@.
|
|
| 243 | +--
|
|
| 244 | +-- @HUP@, and @TERM@ are thrown as @'Panic' <signal number>@.
|
|
| 245 | +--
|
|
| 246 | +-- __WARNING__: Only intended to be called from the __main thread__ of your
|
|
| 247 | +-- process, as otherwise it can swallow signals the process should react to in
|
|
| 248 | +-- other ways. Nested calls from a single thread are fine, but concurrent calls
|
|
| 249 | +-- from different threads will have the exceptions delivered to only one of
|
|
| 250 | +-- them. If that thread is not @main@, extra care is needed to e.g. actually
|
|
| 251 | +-- terminate the process when it receives @TERM@.
|
|
| 239 | 252 | withSignalHandlers :: ExceptionMonad m => m a -> m a
|
| 240 | 253 | #if !defined(HAVE_SIGNAL_H)
|
| 241 | 254 | -- No signal functionality exist on the host platform (e.g. on
|
| ... | ... | @@ -141,7 +141,7 @@ main = do |
| 141 | 141 | ShowOptions isInteractive -> showOptions isInteractive
|
| 142 | 142 | Right postStartupMode ->
|
| 143 | 143 | -- start our GHC session
|
| 144 | - GHC.runGhc mbMinusB $ do
|
|
| 144 | + GHC.runGhcWithSignalHandlers mbMinusB $ do
|
|
| 145 | 145 | |
| 146 | 146 | dflags <- GHC.getSessionDynFlags
|
| 147 | 147 |
| ... | ... | @@ -31,7 +31,7 @@ import GHC.Types.Error.Codes |
| 31 | 31 | |
| 32 | 32 | -- ghc (API usage)
|
| 33 | 33 | import GHC
|
| 34 | - ( runGhc, parseDynamicFlags
|
|
| 34 | + ( runGhcWithSignalHandlers, parseDynamicFlags
|
|
| 35 | 35 | , getSessionDynFlags, setSessionDynFlags
|
| 36 | 36 | , getSession, getLogger
|
| 37 | 37 | , noLoc
|
| ... | ... | @@ -145,7 +145,7 @@ newtype LibDir = LibDir { libDir :: FilePath } |
| 145 | 145 | -- | Look up the 'GhcDiagnosticCode' type family using the GHC API.
|
| 146 | 146 | ghcDiagnosticCodeTyCon :: Maybe LibDir -> IO TyCon
|
| 147 | 147 | ghcDiagnosticCodeTyCon mb_libDir =
|
| 148 | - runGhc (libDir <$> mb_libDir)
|
|
| 148 | + runGhcWithSignalHandlers (libDir <$> mb_libDir)
|
|
| 149 | 149 | |
| 150 | 150 | -- STEP 1: start a GHC API session with "-package ghc"
|
| 151 | 151 | do { dflags1 <- getSessionDynFlags
|
| 1 | +{-# LANGUAGE CPP #-}
|
|
| 2 | +{-# LANGUAGE LambdaCase #-}
|
|
| 3 | + |
|
| 4 | +-- | Test for #4162: 'runGhc' must not install signal handlers on behalf of its
|
|
| 5 | +-- caller.
|
|
| 6 | +--
|
|
| 7 | +-- 'withSignalHandlers' redirects signals to the thread that called it. While
|
|
| 8 | +-- 'runGhc' did that implicitly, a GHC session running in a 'forkIO' thread
|
|
| 9 | +-- would capture signals aimed at the process as a whole and deliver them to a
|
|
| 10 | +-- thread that may not be in a position to act on them. Here that thread sits
|
|
| 11 | +-- under 'uninterruptibleMask', so the exception can never be delivered and the
|
|
| 12 | +-- signal is swallowed outright.
|
|
| 13 | +--
|
|
| 14 | +-- The worker process parks a 'forkIO' thread inside 'runGhc' under
|
|
| 15 | +-- 'uninterruptibleMask' and blocks its main thread waiting for that thread to
|
|
| 16 | +-- finish. The driver process waits for the worker to report that the thread is
|
|
| 17 | +-- inside 'runGhc', signals it, and checks that it dies promptly:
|
|
| 18 | +--
|
|
| 19 | +-- * on unix, SIGTERM has no handler, so the kernel kills the process;
|
|
| 20 | +-- * on Windows, CTRL_BREAK is handled by GHC.TopHandler, which throws
|
|
| 21 | +-- UserInterrupt at the main thread, which exits with 252.
|
|
| 22 | +--
|
|
| 23 | +-- Before #4162 both were swallowed by the masked 'forkIO' thread and the worker
|
|
| 24 | +-- carried on running.
|
|
| 25 | +module Main (main) where
|
|
| 26 | + |
|
| 27 | +import Control.Concurrent
|
|
| 28 | +import Control.Exception
|
|
| 29 | +import Control.Monad
|
|
| 30 | +import Control.Monad.IO.Class (liftIO)
|
|
| 31 | +import GHC (runGhc)
|
|
| 32 | +import System.Environment
|
|
| 33 | +import System.Exit
|
|
| 34 | +import System.IO
|
|
| 35 | +import System.Process
|
|
| 36 | +import System.Timeout (timeout)
|
|
| 37 | + |
|
| 38 | +#if !defined(mingw32_HOST_OS)
|
|
| 39 | +import System.Posix.Signals (sigKILL, sigTERM, signalProcess)
|
|
| 40 | +#endif
|
|
| 41 | + |
|
| 42 | +main :: IO ()
|
|
| 43 | +main = getArgs >>= \case
|
|
| 44 | + [libdir] -> driver libdir
|
|
| 45 | + [libdir, "--worker"] -> worker libdir
|
|
| 46 | + args -> die ("FAILED: unexpected arguments: " ++ show args)
|
|
| 47 | + |
|
| 48 | +-- | The process under test: a GHC session pinned in a 'forkIO' thread, with the
|
|
| 49 | +-- main thread waiting on it.
|
|
| 50 | +worker :: FilePath -> IO ()
|
|
| 51 | +worker libdir = do
|
|
| 52 | + hSetBuffering stdout LineBuffering
|
|
| 53 | + done <- newEmptyMVar
|
|
| 54 | + _ <- forkIO $
|
|
| 55 | + flip finally (putMVar done ()) $
|
|
| 56 | + uninterruptibleMask_ $
|
|
| 57 | + runGhc (Just libdir) $ liftIO $ do
|
|
| 58 | + putStrLn ready
|
|
| 59 | + forever (threadDelay oneSecond)
|
|
| 60 | + takeMVar done
|
|
| 61 | + -- Only reachable if the session thread died on its own; the signal was
|
|
| 62 | + -- supposed to take the whole process down first.
|
|
| 63 | + putStrLn "worker: session thread finished"
|
|
| 64 | + |
|
| 65 | +-- | Wait for the worker to reach 'runGhc', signal it, and require a prompt exit.
|
|
| 66 | +driver :: FilePath -> IO ()
|
|
| 67 | +driver libdir = do
|
|
| 68 | + self <- getExecutablePath
|
|
| 69 | + let cp = (proc self [libdir, "--worker"])
|
|
| 70 | + { std_out = CreatePipe
|
|
| 71 | +#if defined(mingw32_HOST_OS)
|
|
| 72 | + -- GenerateConsoleCtrlEvent can only target a process group, so the
|
|
| 73 | + -- worker needs one of its own.
|
|
| 74 | + , create_group = True
|
|
| 75 | +#endif
|
|
| 76 | + }
|
|
| 77 | + bracket (createProcess cp) kill $ \(_, Just out, _, ph) -> do
|
|
| 78 | + handshake <- timeout handshakeTimeout
|
|
| 79 | + (try (hGetLine out) :: IO (Either IOException String))
|
|
| 80 | + case handshake of
|
|
| 81 | + Just (Right l) | l == ready -> return ()
|
|
| 82 | + _ -> die ("FAILED: worker never reached runGhc: " ++ show handshake)
|
|
| 83 | + interruptWorker ph
|
|
| 84 | + waitForExit ph >>= \case
|
|
| 85 | + Just code | code == expectedExit -> putStrLn "worker terminated promptly"
|
|
| 86 | + other -> die ("FAILED: expected " ++ show expectedExit
|
|
| 87 | + ++ " from the worker, got " ++ show other)
|
|
| 88 | + |
|
| 89 | +-- | Deliver the signal that the worker must not be able to swallow.
|
|
| 90 | +interruptWorker :: ProcessHandle -> IO ()
|
|
| 91 | +#if defined(mingw32_HOST_OS)
|
|
| 92 | +-- CTRL_BREAK_EVENT rather than CTRL_C_EVENT: GenerateConsoleCtrlEvent cannot
|
|
| 93 | +-- deliver CTRL+C to a specific process group. Both reach the same handler, in
|
|
| 94 | +-- GHC.TopHandler now and in withSignalHandlers before #4162.
|
|
| 95 | +interruptWorker = interruptProcessGroupOf
|
|
| 96 | +#else
|
|
| 97 | +interruptWorker ph = getPid ph >>= \case
|
|
| 98 | + Just pid -> signalProcess sigTERM pid
|
|
| 99 | + Nothing -> die "FAILED: worker exited before it could be signalled"
|
|
| 100 | +#endif
|
|
| 101 | + |
|
| 102 | +-- | How the worker is expected to die once signalled.
|
|
| 103 | +expectedExit :: ExitCode
|
|
| 104 | +#if defined(mingw32_HOST_OS)
|
|
| 105 | +-- UserInterrupt reaches the top handler, which calls exitInterrupted.
|
|
| 106 | +expectedExit = ExitFailure 252
|
|
| 107 | +#else
|
|
| 108 | +expectedExit = ExitFailure (negate (fromIntegral sigTERM))
|
|
| 109 | +#endif
|
|
| 110 | + |
|
| 111 | +-- | Poll for the worker's exit, giving up after 'terminationTimeout'. Polling
|
|
| 112 | +-- rather than 'waitForProcess' so that a worker that ignored the signal fails
|
|
| 113 | +-- the test quickly instead of hanging it until the testsuite timeout.
|
|
| 114 | +waitForExit :: ProcessHandle -> IO (Maybe ExitCode)
|
|
| 115 | +waitForExit ph = go (terminationTimeout `div` pollInterval)
|
|
| 116 | + where
|
|
| 117 | + go :: Int -> IO (Maybe ExitCode)
|
|
| 118 | + go n
|
|
| 119 | + | n <= 0 = return Nothing
|
|
| 120 | + | otherwise = getProcessExitCode ph >>= \case
|
|
| 121 | + Just code -> return (Just code)
|
|
| 122 | + Nothing -> threadDelay pollInterval >> go (n - 1)
|
|
| 123 | + |
|
| 124 | +-- | Keep a worker that ignored the signal from outliving the test and holding
|
|
| 125 | +-- the inherited stderr open. Note that 'terminateProcess' is only SIGTERM on
|
|
| 126 | +-- unix, which is precisely what such a worker swallows.
|
|
| 127 | +kill :: (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle) -> IO ()
|
|
| 128 | +kill (_, _, _, ph) = do
|
|
| 129 | +#if defined(mingw32_HOST_OS)
|
|
| 130 | + terminateProcess ph
|
|
| 131 | +#else
|
|
| 132 | + getPid ph >>= mapM_ (signalProcess sigKILL)
|
|
| 133 | +#endif
|
|
| 134 | + void (waitForProcess ph)
|
|
| 135 | + |
|
| 136 | +-- | Printed by the worker from inside 'runGhc', so that by the time the driver
|
|
| 137 | +-- sees it any handlers 'runGhc' might install are already in place.
|
|
| 138 | +ready :: String
|
|
| 139 | +ready = "ready"
|
|
| 140 | + |
|
| 141 | +oneSecond, pollInterval, handshakeTimeout, terminationTimeout :: Int
|
|
| 142 | +oneSecond = 1000000
|
|
| 143 | +pollInterval = 100000 -- 0.1s
|
|
| 144 | +handshakeTimeout = 60 * oneSecond -- starting a GHC session can be slow
|
|
| 145 | +terminationTimeout = 10 * oneSecond |
| 1 | +worker terminated promptly |
| ... | ... | @@ -91,3 +91,13 @@ test('T27240', [extra_run_opts(f'"{config.libdir}"')], compile_and_run, ['-packa |
| 91 | 91 | test('T27273', [extra_run_opts(f'"{config.libdir}"')],
|
| 92 | 92 | compile_and_run,
|
| 93 | 93 | ['-package ghc'])
|
| 94 | + |
|
| 95 | +# runGhc must not install signal handlers on behalf of its caller, or a session
|
|
| 96 | +# running off the main thread makes the process unresponsive to signals.
|
|
| 97 | +test('T4162', [ extra_run_opts(f'"{config.libdir}"')
|
|
| 98 | + # the test re-executes itself, so it has to be a real executable
|
|
| 99 | + , only_ways(['normal'])
|
|
| 100 | + , req_process
|
|
| 101 | + # no subprocesses or signals
|
|
| 102 | + , when(arch('wasm32') or arch('javascript'), skip)
|
|
| 103 | + ], compile_and_run, ['-package ghc']) |
| ... | ... | @@ -231,7 +231,7 @@ parseModuleEpAnnsWithCpp libdir cppOptions file = ghcWrapper libdir $ do |
| 231 | 231 | ghcWrapper :: LibDir -> GHC.Ghc a -> IO a
|
| 232 | 232 | ghcWrapper libdir a =
|
| 233 | 233 | GHC.defaultErrorHandler GHC.defaultFatalMessager GHC.defaultFlushOut
|
| 234 | - $ GHC.runGhc (Just libdir) a
|
|
| 234 | + $ GHC.runGhcWithSignalHandlers (Just libdir) a
|
|
| 235 | 235 | |
| 236 | 236 | -- | Internal function. Exposed if you want to muck with DynFlags
|
| 237 | 237 | -- before parsing.
|
| ... | ... | @@ -81,7 +81,7 @@ testOneFile libdir fileName = do |
| 81 | 81 | |
| 82 | 82 | parseOneFile :: FilePath -> FilePath -> IO ParsedModule
|
| 83 | 83 | parseOneFile libdir fileName = do
|
| 84 | - runGhc (Just libdir) $ do
|
|
| 84 | + runGhcWithSignalHandlers (Just libdir) $ do
|
|
| 85 | 85 | dflags <- getSessionDynFlags
|
| 86 | 86 | let dflags2 = dflags `gopt_set` Opt_KeepRawTokenStream
|
| 87 | 87 | _ <- setSessionDynFlags dflags2
|
| ... | ... | @@ -51,7 +51,7 @@ printDeps libdir modName dot = do |
| 51 | 51 | calcDeps :: String -> FilePath -> IO (Map.Map ModuleName [ModuleName])
|
| 52 | 52 | calcDeps modName libdir =
|
| 53 | 53 | defaultErrorHandler defaultFatalMessager defaultFlushOut $ do
|
| 54 | - runGhc (Just libdir) $ do
|
|
| 54 | + runGhcWithSignalHandlers (Just libdir) $ do
|
|
| 55 | 55 | df <- getSessionDynFlags
|
| 56 | 56 | logger <- getLogger
|
| 57 | 57 | (df, _, _) <- parseDynamicFlags logger df [noLoc "-package=ghc"]
|
| ... | ... | @@ -32,7 +32,7 @@ main = do |
| 32 | 32 | mapM_ (run ghcRoot) pkg_names
|
| 33 | 33 | |
| 34 | 34 | run :: FilePath -> String -> IO ()
|
| 35 | -run root pkg_nm = runGhc (Just root) $ do
|
|
| 35 | +run root pkg_nm = runGhcWithSignalHandlers (Just root) $ do
|
|
| 36 | 36 | let args = map noLoc
|
| 37 | 37 | [ "-package=" ++ pkg_nm
|
| 38 | 38 | , "-dppr-cols=1000"
|
| ... | ... | @@ -196,7 +196,7 @@ handleGhcExceptions = |
| 196 | 196 | --
|
| 197 | 197 | -- > main = getArgs >>= haddock
|
| 198 | 198 | haddock :: [String] -> IO ()
|
| 199 | -haddock args = haddockWithGhc withGhc args
|
|
| 199 | +haddock args = withSignalHandlers $ haddockWithGhc withGhc args
|
|
| 200 | 200 | |
| 201 | 201 | haddockWithGhc :: (forall a. [Flag] -> Ghc a -> IO a) -> [String] -> IO ()
|
| 202 | 202 | haddockWithGhc ghc args = handleTopExceptions $ do
|
| ... | ... | @@ -20,7 +20,7 @@ import Haddock.Backends.Hyperlinker.Types |
| 20 | 20 | withDynFlags :: (DynFlags -> IO ()) -> IO ()
|
| 21 | 21 | withDynFlags cont = do
|
| 22 | 22 | libDir <- fmap snd (getGhcDirs [])
|
| 23 | - runGhc libDir $ do
|
|
| 23 | + runGhcWithSignalHandlers libDir $ do
|
|
| 24 | 24 | dflags <- getSessionDynFlags
|
| 25 | 25 | liftIO $ cont dflags
|
| 26 | 26 |