[Git][ghc/ghc][wip/romes/step-out] debugger: Implement step-out feature

Rodrigo Mesquita pushed to branch wip/romes/step-out at Glasgow Haskell Compiler / GHC Commits: a043f051 by Rodrigo Mesquita at 2025-05-14T17:56:40+01:00 debugger: Implement step-out feature Implements support for stepping-out of a function (aka breaking right after returning from a function) in the interactive debugger. It also introduces a GHCi command :stepout to step-out of a function being debugged in the interpreter. The feature is described as: Stop at the first breakpoint immediately after returning from the current function scope. Known limitations: because a function tail-call does not push a stack frame, if step-out is used inside of a function that was tail-called, execution will not be returned to its caller, but rather its caller's first non-tail caller. In the following example: .. code-block:: none f = do a b <--- (1) set breakpoint then step in here c b = do ... d <--- (2) step-into this tail call d = do ... something <--- (3) step-out here ... Stepping-out will stop execution at the `c` invokation in `f`, rather than stopping at `b`. The key implementation bit is simple: When step-out is set and the interpreter hits a RETURN instruction, enable "stop at the immediate next breakpoint" (aka single-step). See also `Note [Debugger Step-out]` in `rts/Interpreter.c` Note [Debugger Step-out] ~~~~~~~~~~~~~~~~~~~~~~~~ When the global debugger step-out flag is set (`rts_stop_after_return`), the interpreter must yield execution right after the first RETURN. When stepping-out, we simply enable `rts_stop_next_breakpoint` when we hit a return instruction (in `do_return_pointer` and `do_return_nonpointer`). The step-out flag is cleared and must be re-enabled explicitly to step-out again. A limitation of this approach is that stepping-out of a function that was tail-called will skip its caller since no stack frame is pushed for a tail call (i.e. a tail call returns directly to its caller's first non-tail caller). Fixes #26042 - - - - - 10 changed files: - compiler/GHC/Driver/Config.hs - compiler/GHC/Runtime/Eval.hs - compiler/GHC/Runtime/Eval/Types.hs - docs/users_guide/ghci.rst - ghc/GHCi/UI.hs - libraries/ghci/GHCi/Message.hs - libraries/ghci/GHCi/Run.hs - rts/Interpreter.c - rts/RtsSymbols.c - rts/include/stg/MiscClosures.h Changes: ===================================== compiler/GHC/Driver/Config.hs ===================================== @@ -3,6 +3,7 @@ module GHC.Driver.Config ( initOptCoercionOpts , initSimpleOpts , initEvalOpts + , EvalStep(..) ) where @@ -28,13 +29,28 @@ initSimpleOpts dflags = SimpleOpts , so_inline = True } +-- | Instruct the interpreter evaluation to break... +data EvalStep + -- | ... at every breakpoint tick + = EvalStepSingle + -- | ... after every return stmt + | EvalStepOut + -- | ... only on explicit breakpoints + | EvalStepNone + -- | Extract GHCi options from DynFlags and step -initEvalOpts :: DynFlags -> Bool -> EvalOpts +initEvalOpts :: DynFlags -> EvalStep -> EvalOpts initEvalOpts dflags step = EvalOpts { useSandboxThread = gopt Opt_GhciSandbox dflags - , singleStep = step + , singleStep = singleStep + , stepOut = stepOut , breakOnException = gopt Opt_BreakOnException dflags , breakOnError = gopt Opt_BreakOnError dflags } + where + (singleStep, stepOut) = case step of + EvalStepSingle -> (True, False) + EvalStepOut -> (False, True) + EvalStepNone -> (False, False) ===================================== compiler/GHC/Runtime/Eval.hs ===================================== @@ -343,7 +343,12 @@ handleRunStatus step expr bindings final_ids status history0 = do setSession hsc_env2 return (ExecBreak names Nothing) - -- Just case: we stopped at a breakpoint + -- EvalBreak (Just ...) case: the interpreter stopped at a breakpoint + -- + -- The interpreter yields on a breakpoint if: + -- - the breakpoint was explicitly enabled (in @BreakArray@) + -- - or @singleStep = True@ in EvalOpts + -- - or @stopAfterRet = True@ in EvalOpts EvalBreak apStack_ref (Just eval_break) resume_ctxt ccs -> do let ibi = evalBreakpointToId eval_break tick_brks <- liftIO $ readModBreaks hsc_env (ibi_tick_mod ibi) @@ -351,13 +356,14 @@ handleRunStatus step expr bindings final_ids status history0 = do span = modBreaks_locs tick_brks ! ibi_tick_index ibi decl = intercalate "." $ modBreaks_decls tick_brks ! ibi_tick_index ibi + -- Was the breakpoint explicitly enabled? b <- liftIO $ breakpointStatus interp (modBreaks_flags tick_brks) (ibi_tick_index ibi) apStack_fhv <- liftIO $ mkFinalizedHValue interp apStack_ref resume_ctxt_fhv <- liftIO $ mkFinalizedHValue interp resume_ctxt - -- This breakpoint is explicitly enabled; we want to stop - -- instead of just logging it. + -- This breakpoint is enabled or we mean to break here; + -- we want to stop instead of just logging it. if b || breakHere step span then do -- This function only returns control to ghci with 'ExecBreak' when it is really meant to break. -- Specifically, for :steplocal or :stepmodule, don't return control @@ -1247,7 +1253,7 @@ compileParsedExprRemote expr@(L loc _) = withSession $ \hsc_env -> do _ -> panic "compileParsedExprRemote" updateFixityEnv fix_env - let eval_opts = initEvalOpts dflags False + let eval_opts = initEvalOpts dflags EvalStepNone status <- liftIO $ evalStmt interp eval_opts (EvalThis hvals_io) case status of EvalComplete _ (EvalSuccess [hval]) -> return hval ===================================== compiler/GHC/Runtime/Eval/Types.hs ===================================== @@ -17,6 +17,7 @@ import GHC.Prelude import GHCi.RemoteTypes import GHCi.Message (EvalExpr, ResumeContext) +import GHC.Driver.Config (EvalStep(..)) import GHC.Types.Id import GHC.Types.Name import GHC.Types.TyThing @@ -46,6 +47,9 @@ data SingleStep -- | :step [expr] | SingleStep + -- | :stepout [expr] + | StepOut + -- | :steplocal [expr] | LocalStep { breakAt :: SrcSpan } @@ -55,10 +59,12 @@ data SingleStep { breakAt :: SrcSpan } -- | Whether this 'SingleStep' mode requires instructing the interpreter to --- step at every breakpoint. -enableGhcStepMode :: SingleStep -> Bool -enableGhcStepMode RunToCompletion = False -enableGhcStepMode _ = True +-- step at every breakpoint or after every return (see @'EvalStep'@). +enableGhcStepMode :: SingleStep -> EvalStep +enableGhcStepMode RunToCompletion = EvalStepNone +enableGhcStepMode StepOut = EvalStepOut +-- for the remaining step modes we need to stop at every single breakpoint. +enableGhcStepMode _ = EvalStepSingle -- | Given a 'SingleStep' mode and the SrcSpan of a breakpoint we hit, return -- @True@ if based on the step-mode alone we should stop at this breakpoint. @@ -70,6 +76,7 @@ breakHere :: SingleStep -> SrcSpan -> Bool breakHere step break_span = case step of RunToCompletion -> False RunAndLogSteps -> False + StepOut -> True -- hmm. this function may not be a good abstraction? SingleStep -> True LocalStep span -> break_span `isSubspanOf` span ModuleStep span -> srcSpanFileName_maybe span == srcSpanFileName_maybe break_span ===================================== docs/users_guide/ghci.rst ===================================== @@ -2980,6 +2980,33 @@ commonly used commands. hit by an error (:ghc-flag:`-fbreak-on-error`) or an exception (:ghc-flag:`-fbreak-on-exception`). +.. ghci-cmd:: :stepout + + Stop at the first breakpoint immediately after returning from the current + function scope. + + Known limitations: because a function tail-call does not push a stack + frame, if step-out is used inside of a function that was tail-called, + execution will not be returned to its caller, but rather its caller's + first non-tail caller. In the following example: + + .. code-block:: none + + f = do + a + b <--- (1) set breakpoint then step in here + c + b = do + ... + d <--- (2) step-into this tail call + d = do + ... + something <--- (3) step-out here + ... + + Stepping-out will stop execution at the `c` invokation in `f`, rather than + stopping at `b`. + .. ghci-cmd:: :stepmodule Enable only breakpoints in the current module and resume evaluation ===================================== ghc/GHCi/UI.hs ===================================== @@ -247,6 +247,7 @@ ghciCommands = map mkCmd [ ("sprint", keepGoing sprintCmd, completeExpression), ("step", keepGoing stepCmd, completeIdentifier), ("steplocal", keepGoing stepLocalCmd, completeIdentifier), + ("stepout", keepGoing stepOutCmd, completeIdentifier), ("stepmodule",keepGoing stepModuleCmd, completeIdentifier), ("type", keepGoingMulti' typeOfExpr, completeExpression), ("trace", keepGoing traceCmd, completeExpression), @@ -407,6 +408,7 @@ defFullHelpText = " :step single-step after stopping at a breakpoint\n"++ " :step <expr> single-step into <expr>\n"++ " :steplocal single-step within the current top-level binding\n"++ + " :stepout stop at the first breakpoint after returning from the current scope\n"++ " :stepmodule single-step restricted to the current module\n"++ " :trace trace after stopping at a breakpoint\n"++ " :trace <expr> evaluate <expr> with tracing on (see :history)\n"++ @@ -3793,6 +3795,12 @@ stepCmd arg = withSandboxOnly ":step" $ step arg step [] = doContinue GHC.SingleStep step expression = runStmt expression GHC.SingleStep >> return () +stepOutCmd :: GhciMonad m => String -> m () +stepOutCmd arg = withSandboxOnly ":stepout" $ step arg + where + step [] = doContinue GHC.StepOut + step expression = stepCmd expression + stepLocalCmd :: GhciMonad m => String -> m () stepLocalCmd arg = withSandboxOnly ":steplocal" $ step arg where ===================================== libraries/ghci/GHCi/Message.hs ===================================== @@ -374,6 +374,7 @@ putTHMessage m = case m of data EvalOpts = EvalOpts { useSandboxThread :: Bool , singleStep :: Bool + , stepOut :: Bool , breakOnException :: Bool , breakOnError :: Bool } ===================================== libraries/ghci/GHCi/Run.hs ===================================== @@ -210,6 +210,7 @@ evalOptsSeq :: EvalOpts evalOptsSeq = EvalOpts { useSandboxThread = True , singleStep = False + , stepOut = False , breakOnException = False , breakOnError = False } @@ -333,6 +334,7 @@ withBreakAction opts breakMVar statusMVar act poke breakPointIOAction stablePtr when (breakOnException opts) $ poke exceptionFlag 1 when (singleStep opts) $ setStepFlag + when (breakOnException opts) $ poke stepOutFlag 1 return stablePtr -- Breaking on exceptions is not enabled by default, since it -- might be a bit surprising. The exception flag is turned off @@ -363,6 +365,7 @@ withBreakAction opts breakMVar statusMVar act resetBreakAction stablePtr = do poke breakPointIOAction noBreakStablePtr poke exceptionFlag 0 + poke stepOutFlag 0 resetStepFlag freeStablePtr stablePtr @@ -398,6 +401,7 @@ abandonStmt hvref = do foreign import ccall "&rts_stop_next_breakpoint" stepFlag :: Ptr CInt foreign import ccall "&rts_stop_on_exception" exceptionFlag :: Ptr CInt +foreign import ccall "&rts_stop_after_return" stepOutFlag :: Ptr CInt setStepFlag :: IO () setStepFlag = poke stepFlag 1 ===================================== rts/Interpreter.c ===================================== @@ -194,6 +194,24 @@ See also Note [Width of parameters] for some more motivation. #define WITHIN_CHUNK_BOUNDS(n, s) \ (RTS_LIKELY((StgWord*)(Sp_plusW(n)) < ((s)->stack + (s)->stack_size - sizeofW(StgUnderflowFrame)))) +// Note [Debugger Step-out] +// ~~~~~~~~~~~~~~~~~~~~~~~~ +// When the global debugger step-out flag is set (`rts_stop_after_return`), +// the interpreter must yield execution right after the first RETURN. +// +// When stepping-out, we simply enable `rts_stop_next_breakpoint` when we hit a +// return instruction (in `do_return_pointer` and `do_return_nonpointer`). +// The step-out flag is cleared and must be re-enabled explicitly to step-out again. +// +// A limitation of this approach is that stepping-out of a function that was +// tail-called will skip its caller since no stack frame is pushed for a tail +// call (i.e. a tail call returns directly to its caller's first non-tail caller). +#define CHECK_BRK_AFTER_RET() \ + if (rts_stop_after_return) \ + { \ + rts_stop_next_breakpoint = true; \ + rts_stop_after_return = false; \ + } \ /* Note [PUSH_L underflow] ~~~~~~~~~~~~~~~~~~~~~~~ @@ -245,6 +263,7 @@ allocate_NONUPD (Capability *cap, int n_words) int rts_stop_next_breakpoint = 0; int rts_stop_on_exception = 0; +int rts_stop_after_return = 0; #if defined(INTERP_STATS) @@ -734,6 +753,8 @@ do_return_pointer: IF_DEBUG(sanity,checkStackChunk(Sp, cap->r.rCurrentTSO->stackobj->stack+cap->r.rCurrentTSO->stackobj->stack_size)); + CHECK_BRK_AFTER_RET(); + switch (get_itbl((StgClosure *)Sp)->type) { case RET_SMALL: { @@ -883,6 +904,8 @@ do_return_nonpointer: // get the offset of the header of the next stack frame offset = stack_frame_sizeW((StgClosure *)Sp); + CHECK_BRK_AFTER_RET(); + switch (get_itbl((StgClosure*)(Sp_plusW(offset)))->type) { case RET_BCO: ===================================== rts/RtsSymbols.c ===================================== @@ -908,6 +908,7 @@ extern char **environ; SymI_NeedsDataProto(rts_breakpoint_io_action) \ SymI_NeedsDataProto(rts_stop_next_breakpoint) \ SymI_NeedsDataProto(rts_stop_on_exception) \ + SymI_NeedsDataProto(rts_stop_after_return) \ SymI_HasProto(stopTimer) \ SymI_HasProto(n_capabilities) \ SymI_HasProto(max_n_capabilities) \ ===================================== rts/include/stg/MiscClosures.h ===================================== @@ -619,6 +619,7 @@ RTS_FUN_DECL(stg_castFloatToWord32zh); // Interpreter.c extern StgWord rts_stop_next_breakpoint[]; extern StgWord rts_stop_on_exception[]; +extern StgWord rts_stop_after_return[]; extern StgWord rts_breakpoint_io_action[]; // Schedule.c View it on GitLab: https://gitlab.haskell.org/ghc/ghc/-/commit/a043f05102cb9357e58c30cb1a2cbb9f... -- View it on GitLab: https://gitlab.haskell.org/ghc/ghc/-/commit/a043f05102cb9357e58c30cb1a2cbb9f... You're receiving this email because of your account on gitlab.haskell.org.
participants (1)
-
Rodrigo Mesquita (@alt-romes)