Rodrigo Mesquita pushed to branch wip/romes/multi-threaded-debugger-p1 at Glasgow Haskell Compiler / GHC Commits: aab472ba by Rodrigo Mesquita at 2026-09-22T11:54:27+01:00 Build EvalOpts once per exec rather than per breakpoint stop in interpreter_steplocal the allocations increased because now we're updating `eval_opts` after constructing them in `handleRunStatus` (which is run many times since in step-local most often we don't stop, just check). So drop the update to the eval_opts, and, in fact, pull their construction out. the loop always uses the same eval_opts, so no point in constructing them inside from scratch every time. -3.6% from this branch which had the regressions in interpreter_steplocal - - - - - a3ebc544 by Rodrigo Mesquita at 2026-09-22T16:20:35+01:00 Look up a thread's ResumeContext without taking a lock modifyMVar is unnecessarily expensive and blocking when most often we just care about reading without modifying. optimize for default case (thread context already exists). It is called every single time a breakpoint is hit (regardless of whether we stop there, e.g. in interpreter_steplocal we do not stop there often) - - - - - cd553e96 by Rodrigo Mesquita at 2026-09-22T18:15:21+01:00 Every thread resume-context keeps a var for its EvalStatus Kill the global mapping from thread id to EvalStatus Instead, keep the EvalStatus in the ResumeContext associated to a thread in the threadContextVar (where entries are created on breakpoints being hit and when writing an EvalComplete result) Before, every breakpoint hit meant reading the global map TVar and deleting a map entry which did a lot of allocations. Now, we have a Nothing variable which we overwrite to "take" rather than deleting from a Map and allocating the GlobalDbgMap too. Note that for every breakpoint hit we'd have to insert the ThreadId entry in the map again (because we deleted it last time we resumed), again allocating unnecessarily. Now it's only a look up on the map and an update on the variable directly. And a LOT of breakpoints were hit on interpreter_steplocal, stress testing the cost of hitting a breakpoint The isolated threads move to their own top-level TVar Set. - - - - - 64836f8c by Rodrigo Mesquita at 2026-09-23T16:18:18+01:00 Move whether a thread is isolated to the same global map Stops us from reconstructing the set every time this changed instead to being a per thread variable just toggled on/off, by default non-isolated like before (before it was Set.empty, Set.insert for True, Set.delete for FALSE). This is more straightforward -- there's always an isolation mode per thread and we can toggle it directly - - - - - 5 changed files: - compiler/GHC/Driver/Config.hs - compiler/GHC/Runtime/Eval.hs - libraries/ghci/GHCi/Message.hs - libraries/ghci/GHCi/Run.hs - libraries/ghci/GHCi/Run/Breakpoints.hs Changes: ===================================== compiler/GHC/Driver/Config.hs ===================================== @@ -41,15 +41,15 @@ data EvalStep | EvalStepNone -- | Extract GHCi options from DynFlags and step -initEvalOpts :: DynFlags -> EvalStep -> EvalOpts -initEvalOpts dflags step = +initEvalOpts :: DynFlags -> EvalStep -> Bool -> EvalOpts +initEvalOpts dflags step isolate_breaks = EvalOpts { useSandboxThread = gopt Opt_GhciSandbox dflags , singleStep = singleStep , stepOut = stepOut , breakOnException = gopt Opt_BreakOnException dflags , breakOnError = gopt Opt_BreakOnError dflags - , isolateThreadBreaks = True + , isolateThreadBreaks = isolate_breaks -- Must opt-in to listening to all thread's breakpoints. -- (e.g. for running 'main' in a debugger) } ===================================== compiler/GHC/Runtime/Eval.hs ===================================== @@ -230,18 +230,16 @@ execStmt' stmt stmt_text ExecOptions{..} = do Just (ids, hval, fix_env) -> do updateFixityEnv fix_env - status <- - liftIO $ do - let eval_opts = (initEvalOpts idflags' (enableGhcStepMode execSingleStep)) - { isolateThreadBreaks = enableIsolateThreadBreaks execIsolateMode } - evalStmt interp eval_opts (execWrap hval) + let eval_opts = initEvalOpts idflags' (enableGhcStepMode execSingleStep) + (enableIsolateThreadBreaks execIsolateMode) + status <- liftIO $ evalStmt interp eval_opts (execWrap hval) let ic = hsc_IC hsc_env bindings = (ic_tythings ic, ic_gre_cache ic) size = ghciHistSize idflags' - handleRunStatus execSingleStep execIsolateMode stmt_text bindings ids + handleRunStatus execSingleStep eval_opts stmt_text bindings ids status (emptyHistory size) runDecls :: GhcMonad m => String -> m [TyThing] @@ -293,18 +291,22 @@ emptyHistory size = nilBL size -- or :stepmodule, rather than :step, we only care about certain breakpoints). handleRunStatus :: GhcMonad m => SingleStep - -> ThreadBreaksIsolationMode + -- ^ The kind of step we're doing. 'handleRunStatus' may resume + -- the evaluation automatically if the step kind indicates to + -- ignore this breakpoint (e.g. when doing 'ModuleStep' but the + -- breakpoint just hit belongs to a different module) + -> EvalOpts + -- ^ The evaluation options in case the evaluation is resumed. -> String -> ResumeBindings -> [Id] -> EvalStatus_ [ForeignHValue] [HValueRef] -> BoundedList History -> m ExecResult -handleRunStatus step isolateMode expr bindings final_ids status history0 = do +handleRunStatus step eval_opts expr bindings final_ids status history0 = do hsc_env <- getSession let interp = hscInterp hsc_env - dflags = hsc_dflags hsc_env case status of -- Completed successfully @@ -396,15 +398,13 @@ handleRunStatus step isolateMode expr bindings final_ids status history0 = do return (ExecBreak break_ids (Just ibi) resume) else do -- resume with the same step type - let eval_opts = (initEvalOpts dflags (enableGhcStepMode step)) - { isolateThreadBreaks = enableIsolateThreadBreaks isolateMode } status <- liftIO $ GHCi.resumeStmt interp eval_opts resume_ctxt_fhv history <- if not tracing then pure history0 else do history1 <- liftIO $ mkHistory hug apStack_fhv ibi let !history' = history1 `consBL` history0 -- history is strict, otherwise our BoundedList is pointless. return history' - handleRunStatus step isolateMode expr bindings final_ids status history + handleRunStatus step eval_opts expr bindings final_ids status history where tracing | RunAndLogSteps <- step = True | otherwise = False @@ -452,8 +452,8 @@ resumeExec step isolateMode mbCnt r = do (Just brkpt, Just cnt) -> setupBreakpoint interp brkpt cnt _ -> return () - let eval_opts = (initEvalOpts dflags (enableGhcStepMode step)) - { isolateThreadBreaks = enableIsolateThreadBreaks isolateMode } + let eval_opts = initEvalOpts dflags (enableGhcStepMode step) + (enableIsolateThreadBreaks isolateMode) status <- liftIO $ GHCi.resumeStmt interp eval_opts fhv let prevHistoryLst = fromListBL 50 hist hug = hsc_HUG hsc_env @@ -464,7 +464,7 @@ resumeExec step isolateMode mbCnt r = do hist1 <- liftIO (mkHistory hug apStack bi) return $ hist1 `consBL` fromListBL 50 hist | otherwise -> pure prevHistoryLst - handleRunStatus step isolateMode expr bindings final_ids status =<< hist' + handleRunStatus step eval_opts expr bindings final_ids status =<< hist' setupBreakpoint :: GhcMonad m => Interp -> InternalBreakpointId -> Int -> m () -- #19157 setupBreakpoint interp ibi cnt = do @@ -1274,7 +1274,7 @@ compileParsedExprRemote expr@(L loc _) = withSession $ \hsc_env -> do _ -> panic "compileParsedExprRemote" updateFixityEnv fix_env - let eval_opts = initEvalOpts dflags EvalStepNone + let eval_opts = initEvalOpts dflags EvalStepNone True status <- liftIO $ evalStmt interp eval_opts (EvalThis hvals_io) case status of EvalComplete _ (EvalSuccess [hval]) -> return hval ===================================== libraries/ghci/GHCi/Message.hs ===================================== @@ -395,7 +395,7 @@ data EvalOpts , stepOut :: Bool , breakOnException :: Bool , breakOnError :: Bool - , isolateThreadBreaks :: Bool + , isolateThreadBreaks :: !Bool -- ^ If @isolateThreadBreaks == True@, any breakpoint hit by the thread -- forked~[1] to execute this expression is only seen by a caller observing -- this thread's result explicitly. That is, this thread's 'EvalStatus' can ===================================== libraries/ghci/GHCi/Run.hs ===================================== @@ -405,10 +405,11 @@ sandboxIO opts io -- rather than just "main" being observed. This should be used for the main -- debuggee execution of a debugger. withBreakAction :: EvalOpts -> ThreadId -> (IO (EvalStatus [HValueRef]) -> IO a) -> IO a -withBreakAction opts tid@(ThreadId tid#) act - = bracket setBreakAction resetBreakAction (\_ -> act waitForResult) +withBreakAction opts tid@(ThreadId tid#) act = do + ctx <- getThreadResumeContext tid + bracket (setBreakAction ctx) (resetBreakAction ctx) (\_ -> act (waitForResult ctx)) where - setBreakAction = do + setBreakAction ctx = do poke breakPointIOAction globalBreakStablePtr -- TODO: This is poke thread unsafe, as one thread might be accessing this -- global variable while this thread tries to overwrite it. We should @@ -422,32 +423,34 @@ withBreakAction opts tid@(ThreadId tid#) act runIf# singleStep rts_enableStopNextBreakpoint runIf# stepOut rts_enableStopAfterReturn - runIf isolateThreadBreaks setIsolatedThread - resetBreakAction () = do + runIfCtx ctx isolateThreadBreaks setIsolatedCtx + + resetBreakAction ctx () = do poke exceptionFlag 0 rts_disableStopAfterReturn tid# rts_disableStopNextBreakpoint tid# - runIf isolateThreadBreaks unsetIsolatedThread + runIfCtx ctx isolateThreadBreaks unsetIsolatedCtx -- freeStablePtr stablePtr TODO: we never free the global stablePtr for -- global action. Maybe we should have a de-init for clean up. -- TODO: don't set it in `setBreakAction` either. Should be on -- interpreter initialization. - runIf pred what = when (pred opts) (what tid) - runIf# pred what = when (pred opts) (what tid#) + runIf pred what = when (pred opts) (what tid) + runIf# pred what = when (pred opts) (what tid#) + runIfCtx ctx pred what = when (pred opts) (what ctx) - waitForResult + waitForResult ctx | isolateThreadBreaks opts = atomically $ - readThreadEvalStatus tid + readCtxEvalStatus ctx | otherwise = atomically $ (EvalPaused <$> readAnyThreadEvalBreak) `orElse` - readThreadEvalStatus tid + readCtxEvalStatus ctx -- While we're waiting for the sandbox thread to return a result, if -- the current thread receives an asynchronous exception we re-throw ===================================== libraries/ghci/GHCi/Run/Breakpoints.hs ===================================== @@ -8,10 +8,12 @@ module GHCi.Run.Breakpoints -- * Per-thread breakpoints , readThreadEvalStatus, readAnyThreadEvalBreak , writeThreadEvalStatus + , readCtxEvalStatus, writeCtxEvalStatus -- ** Isolating thread breakpoints , withIsolatedThread , setIsolatedThread, unsetIsolatedThread + , setIsolatedCtx, unsetIsolatedCtx -- ** Querying the global thread breakpoint map , listOtherPausedThreads @@ -29,18 +31,15 @@ import GHCi.Message import GHCi.RemoteTypes import qualified Data.Map as Map -import qualified Data.Set as Set import Control.Concurrent import Control.Exception import qualified Data.ByteString.Short.Internal as BS -#if defined(PROFILING) -import GHC.Data.ShortByteString -#endif import GHC.Exts import GHC.Stack import Foreign hiding (void) import GHC.Conc.Sync import GHC.IO hiding ( bracket ) +import Control.Monad -------------------------------------------------------------------------------- -- * Global break action @@ -72,7 +71,7 @@ globalBreakStablePtr = unsafePerformIO $ newStablePtr globalBreakAction globalBreakAction :: BreakpointCallback globalBreakAction info_mod# info_mod_uid# infox# is_exception apStack = do tid <- myThreadId - ResumeContext{..} <- getThreadResumeContext tid + ctx@ResumeContext{..} <- getThreadResumeContext tid resume_r <- mkRemoteRef tid apStack_r <- mkRemoteRef apStack ccs <- toRemotePtr <$> getCCSOf apStack @@ -83,7 +82,7 @@ globalBreakAction info_mod# info_mod_uid# infox# is_exception apStack = do info_mod <- BS.packCString (Ptr info_mod#) info_mod_uid <- BS.packCString (Ptr info_mod_uid#) pure (Just (EvalBreakpoint info_mod info_mod_uid (I# infox#))) - writeThreadEvalStatus tid $ EvalBreak apStack_r mb_breakpoint resume_r ccs + writeCtxEvalStatus ctx $ EvalBreak apStack_r mb_breakpoint resume_r ccs -- Block until this thread is resumed (by the thread which took the -- `ResumeContext` from the `statusMVar`). @@ -109,14 +108,19 @@ globalBreakAction info_mod# info_mod_uid# infox# is_exception apStack = do -- Succeeds when the given thread writes to the global debugger map an eval result. readThreadEvalStatus :: ThreadId -> STM (EvalStatus [HValueRef]) readThreadEvalStatus tid = do - GlobalDbgStatus{..} <- readTVar globalDbgStatusVar - case Map.lookup tid threadEvalStatuses of + ctxs <- readTVar threadContextsVar + case Map.lookup tid ctxs of + Just ctx -> readCtxEvalStatus ctx + Nothing -> retry -- to write a thread break or result, the + -- 'threadContextsVar' entry will be created + +-- | Like 'readThreadEvalStatus', but when the 'ResumeContext' is already +-- available (to avoid looking it up unnecessarily). +readCtxEvalStatus :: ResumeContext -> STM (EvalStatus [HValueRef]) +readCtxEvalStatus ResumeContext{resumeEvalStatus} = + readTVar resumeEvalStatus >>= \case Just r -> do - writeTVar globalDbgStatusVar - GlobalDbgStatus - { threadEvalStatuses = Map.delete tid threadEvalStatuses -- clear it - , isolatedThreads - } + writeTVar resumeEvalStatus Nothing -- clear it pure r Nothing -> retry -- until someone writes the result for this thread @@ -128,36 +132,38 @@ readThreadEvalStatus tid = do -- Succeeds when any thread hits a breakpoint. readAnyThreadEvalBreak :: STM EvalBreak readAnyThreadEvalBreak = do - dbgs <- readTVar globalDbgStatusVar + ctxs <- readTVar threadContextsVar -- try reading any thread's 'EvalBreak' and retry if none have yielded one yet - foldr (\x next -> readStatus dbgs x `orElse` next) retry (Map.toList (threadEvalStatuses dbgs)) + foldr (\ctx next -> readBreak ctx `orElse` next) retry (Map.elems ctxs) where - readStatus GlobalDbgStatus{..} (tid, _) -- don't look at isolated threads - | Set.member tid isolatedThreads - = retry - - readStatus GlobalDbgStatus{..} (tid, status) - = do - case status of - EvalComplete{} -> retry -- successes can be read with `readThreadEvalStatus` - EvalPaused b -> do - writeTVar globalDbgStatusVar - GlobalDbgStatus - { threadEvalStatuses = Map.delete tid threadEvalStatuses - , isolatedThreads - } - pure b + readBreak ResumeContext{resumeEvalStatus, resumeIsolated} = do + isolated <- readTVar resumeIsolated + if isolated + then retry -- don't look at isolated threads + else readTVar resumeEvalStatus >>= \case + Just (EvalPaused b) -> do + writeTVar resumeEvalStatus Nothing + pure b + Just EvalComplete{} -> + retry -- successes can be read with `readThreadEvalStatus` + Nothing -> + retry -- no status for this thread yet writeThreadEvalStatus :: ThreadId -> EvalStatus [HValueRef] -> IO () -writeThreadEvalStatus tid status = +writeThreadEvalStatus tid status = do + -- get or create a 'ResumeContext' for the thread (e.g. we may write the + -- EvalComplete without breakpoints occurring, so the map won't have an entry): + ctx <- getThreadResumeContext tid + writeCtxEvalStatus ctx status + +-- | Like 'writeThreadEvalStatus', but for when the 'ResumeContext' is already +-- available +writeCtxEvalStatus :: ResumeContext -> EvalStatus [HValueRef] -> IO () +writeCtxEvalStatus ResumeContext{resumeEvalStatus} status = atomically $ do - GlobalDbgStatus{..} <- readTVar globalDbgStatusVar - writeTVar globalDbgStatusVar $ - GlobalDbgStatus - { threadEvalStatuses = - Map.insertWith (\_ _ -> error "writeThreadEvalStatus: should be impossible") - tid status threadEvalStatuses - , isolatedThreads } + readTVar resumeEvalStatus >>= \case + Just{} -> error "writeCtxEvalStatus: should be impossible" + Nothing -> writeTVar resumeEvalStatus (Just status) -- ** Isolating thread breakpoints --------------------------------------------- @@ -174,23 +180,15 @@ withIsolatedThread tid = bracket_ (setIsolatedThread tid) (unsetIsolatedThread t -- expression to run should not be seen at all by the debugger (e.g. when -- evaluating an expr to :force a variable) setIsolatedThread :: ThreadId -> IO () -setIsolatedThread tid = atomically $ do - GlobalDbgStatus{..} <- readTVar globalDbgStatusVar - writeTVar globalDbgStatusVar - GlobalDbgStatus - { threadEvalStatuses - , isolatedThreads = Set.insert tid isolatedThreads - } +setIsolatedThread = getThreadResumeContext >=> setIsolatedCtx -- | Undoes 'setIsolatedThread' unsetIsolatedThread :: ThreadId -> IO () -unsetIsolatedThread tid = atomically $ do - GlobalDbgStatus{..} <- readTVar globalDbgStatusVar - writeTVar globalDbgStatusVar - GlobalDbgStatus - { threadEvalStatuses - , isolatedThreads = Set.delete tid isolatedThreads - } +unsetIsolatedThread = getThreadResumeContext >=> unsetIsolatedCtx + +setIsolatedCtx, unsetIsolatedCtx :: ResumeContext -> IO () +setIsolatedCtx ctx = atomically $ writeTVar (resumeIsolated ctx) True +unsetIsolatedCtx ctx = atomically $ writeTVar (resumeIsolated ctx) False -- ** Querying the global thread breakpoint map -------------------------------- @@ -201,64 +199,74 @@ unsetIsolatedThread tid = atomically $ do -- the global thread mapping) and are asking about which other threads are -- paused to list them. listOtherPausedThreads :: IO [ThreadId] -listOtherPausedThreads = do - m <- atomically $ readTVar globalDbgStatusVar - pure $ Map.keys $ Map.filter isBreak (threadEvalStatuses m) +listOtherPausedThreads = atomically $ do + ctxs <- readTVar threadContextsVar + paused <- mapM isBreak (Map.toList ctxs) + pure [tid | (tid, True) <- paused] where - isBreak EvalBreak{} = True - isBreak EvalComplete{} = False - --- ** Global thread exec status map -------------------------------------------- - --- | When we launch a new thread using the debugger, we map ... TODO ... all --- threads launched by that main stmt may further spawn threads, and any of --- them might hit a breakpoint (break array toggled, or per-thread step in --- might be used, or pause button, etc...). --- --- When we launch that main thread we also write its result to this map. So --- this map will only contain the results of threads we explicitly care to --- observe the result of. Not all threads will write their results here, --- certainly not ones spawned unknowningly by the main stmt. --- ... --- todo words -globalDbgStatusVar :: TVar GlobalDbgStatus -globalDbgStatusVar = unsafePerformIO $ newTVarIO $ GlobalDbgStatus Map.empty Set.empty -{-# NOINLINE globalDbgStatusVar #-} - -data GlobalDbgStatus = GlobalDbgStatus - { threadEvalStatuses :: !(Map.Map ThreadId (EvalStatus [HValueRef])) - -- ^ Whenever a thread hits a breakpoint it inserts in this map its 'EvalBreak'. - -- We can additionally observe a thread's result by calling - -- `writeThreadEvalStatus tid (EvalComplete ...)` when the main expression - -- finishes evaluating, see e.g. 'sandboxIO'. - , isolatedThreads :: !(Set.Set ThreadId) - -- ^ Threads in this map are meant to be ignored by 'readAnyThreadEvalBreak'. - -- That is, even if one of the threads in this set hits a breakpoint, we - -- can only read its 'EvalBreak' with @'readThreadEvalStatus' tid@ directly. - } + isBreak (tid, ResumeContext{resumeEvalStatus}) = do + status <- readTVar resumeEvalStatus + pure (tid, case status of + Just EvalBreak{} -> True + Just EvalComplete{} -> False + Nothing -> False) -------------------------------------------------------------------------------- -- * Per-thread resume-ing -------------------------------------------------------------------------------- getThreadResumeContext :: ThreadId -> IO ResumeContext -getThreadResumeContext tid = modifyMVar globalBreakActionMap $ \gbm0 -> do - case Map.lookup tid gbm0 of +getThreadResumeContext tid = do + ctxs0 <- readTVarIO threadContextsVar + case Map.lookup tid ctxs0 of + Just ctx -> pure ctx -- common case: hence read without transaction Nothing -> do - r <- ResumeContext <$> newEmptyMVar - let !gbm1 = Map.insert tid r gbm0 - pure (gbm1, r) - Just ctx -> do - pure (gbm0, ctx) + new_ctx <- ResumeContext <$> newEmptyMVar + <*> newTVarIO Nothing + <*> newTVarIO False + atomically $ do -- write new thread context atomically + ctxs1 <- readTVar threadContextsVar + case Map.lookup tid ctxs1 of + Just ctx -> pure ctx -- already exists + Nothing -> do + let !ctxs2 = Map.insert tid new_ctx ctxs1 + writeTVar threadContextsVar ctxs2 + pure new_ctx --- | A global mapping from thread ids to the MVars on which they block when stopped. -globalBreakActionMap :: MVar (Map.Map ThreadId ResumeContext) -globalBreakActionMap = unsafePerformIO $ newMVar Map.empty -{-# NOINLINE globalBreakActionMap #-} +-- | A global mapping from threads to their per-thread state 'ResumeContext' +-- (that e.g. contain the MVars on which they block when stopped) +threadContextsVar :: TVar (Map.Map ThreadId ResumeContext) +threadContextsVar = unsafePerformIO $ newTVarIO Map.empty +{-# NOINLINE threadContextsVar #-} -newtype ResumeContext = ResumeContext - { resumeBreakMVar :: MVar () +-- | Global context for a thread, which is created and inserted in the global +-- 'threadContextsVar' when a thread hits a breakpoint (and, thus, the +-- 'globalBreakAction') is run, or when 'withBreakAction' sets the thread +-- isolation (e.g. in 'sandboxIO'), or in other calls to +-- 'getThreadResumeContext', the function which gets or creates a thread +-- 'ResumeContext'. +-- +-- TODO: Rename ResumeContext to something like ThreadContext or +-- ThreadDbgContext or (Thread)BreakContext, and fields to something like +-- dbgEvalStatus, threadIsolated, ... +data ResumeContext = ResumeContext + { resumeBreakMVar :: !(MVar ()) -- ^ A thread that hits a breakpoint blocks reading its corresponding MVar - -- (gotten from the 'globalBreakActionMap'). + -- (gotten from the 'threadContextsVar'). -- The debugger can unblock that thread by signaling its MVar. + , resumeEvalStatus :: !(TVar (Maybe (EvalStatus [HValueRef]))) + -- ^ When a thread hits a breakpoint, the 'globalBreakAction' is run and + -- writes an 'EvalBreak' in this @TVar@ (which was found in 'threadContextsVar'). + -- + -- Additionally, the 'EvalComplete' result of a thread may be written here + -- using `writeThreadEvalStatus tid (EvalComplete ...)` after the main + -- expression finishes evaluating, see e.g. 'sandboxIO'. + -- + -- The variable should be emptied (i.e. set to 'Nothing') by whoever + -- reads the status. + , resumeIsolated :: !(TVar Bool) + -- ^ When set, this thread is meant to be ignored by 'readAnyThreadEvalBreak'. + -- That is, if a thread with resumeIsolated=True hits a breakpoint, its + -- 'EvalBreak' can only be read with @'readThreadEvalStatus' tid@ for this + -- thread's @tid@ directly. } View it on GitLab: https://gitlab.haskell.org/ghc/ghc/-/compare/c0876759757f2ef4c2e8d7ea2de65ec... -- View it on GitLab: https://gitlab.haskell.org/ghc/ghc/-/compare/c0876759757f2ef4c2e8d7ea2de65ec... You're receiving this email because of your account on gitlab.haskell.org. Manage all notifications: https://gitlab.haskell.org/-/profile/notifications | Help: https://gitlab.haskell.org/help