llvm / llvm/llvm-project

[lldb] A scripted thread plan's should_step is ignored; a step onto a breakpoint site loses control of the process

Open
#215,189 2 comments 0 reactions 0 assignees View on GitHub
lldb
Dominant language
LLVM
Stars
40.5k
Forks
18.7k
PR merge metrics
PR metrics pending

Description

Two defects: one in the scripted-thread-plan path, one in core thread-plan stop
handling. Both are reproduced below on unmodified upstream builds, and neither
function has changed on `main`.

## Environment

Linux x86_64. Both reproducers were run against the official
`LLVM-19.1.0-Linux-X64` and `LLVM-20.1.8-Linux-X64` release binaries — each ships
an `lldb` module for Python 3.10, and a CPython 3.10 drove it — and against
source builds of `llvmorg-21.1.7`
(`292dc2b86f66e39f4b85ec8b185fd8b60f5213ce`) and `llvmorg-22.1.8`
(`ca7933e47d3a3451d81e72ac174dcb5aa28b59d1`) — `Release`,
`LLVM_ENABLE_ASSERTIONS=ON`, `LLVM_ENABLE_PROJECTS="clang;lldb"`,
`LLVM_TARGETS_TO_BUILD=X86`, Python 3.12. The official 18.1.8 Linux x86_64
tarball ships an LLDB built without Python, so that arm is `apt.llvm.org`'s
`llvm-toolchain-18` `1:18.1.8~++20240731024944+3b5b5c1ec4a3` (jammy amd64; that
revision is the `llvmorg-18.1.8` commit), also on Python 3.10. Output was
character-identical over five runs of each reproducer on each of the five.

## Shared test program

`main.c`:

```c
int f(int x) { return x * 2; }

int main() {
int a = 0;
a += f(1); // start here
a += f(2);
a += f(3); // should run to here
return a;
}
```

```console
$ clang -g -O0 main.c -o repro
```

The one binary is used for both reproducers and on every build below. Save
`main.c`, `bug1.py` and `bug2.py` in one directory: each script imports itself by
path, so the file names have to match the `"bug1."` and `"bug2."` class strings.

---

## (1) `should_step` is ignored

`ScriptedThreadPlanPythonInterface::GetRunState()`
(`lldb/source/Plugins/ScriptInterpreter/Python/Interfaces/ScriptedThreadPlanPythonInterface.cpp`)
reads the script's `should_step` result with `GetUnsignedIntegerValue()`. A Python
`bool` arrives as a `StructuredData::Boolean` — `PythonObject::GetObjectType()`
tests `PythonBoolean::Check` before `PythonInteger::Check`
(`lldb/source/Plugins/ScriptInterpreter/Python/PythonDataObjects.cpp`) — and
`GetUnsignedIntegerValue()` returns its fail value for anything that is not an
`UnsignedInteger`. Every scripted plan whose `should_step` returns a `bool`
therefore reports `eStateStepping`, and a plan returning `False` behaves exactly
like one returning `True`. It should resume the process instead: *"Return `True`
if you want lldb to instruction step one instruction, or False to continue till
the next breakpoint is hit"* (`lldb/docs/use/tutorials/automating-stepping-logic.md`).

### Reproducer

`bug1.py` — the same plan twice, changing only what `should_step` returns.

```python
import os, sys, lldb

class Base:
def __init__(self, thread_plan, args_data):
self.thread_plan = thread_plan

def explains_stop(self, event):
return True

def should_stop(self, event):
self.thread_plan.SetPlanComplete(True)
return True

class StepPlan(Base):
def should_step(self):
return True

class RunPlan(Base):
def should_step(self):
return False

if __name__ == "__main__":
for cls in ("StepPlan", "RunPlan"):
dbg = lldb.SBDebugger.Create()
dbg.SetAsync(False)
dbg.HandleCommand("command script import " + os.path.abspath(__file__))
target = dbg.CreateTarget(sys.argv[1])
target.BreakpointCreateByLocation("main.c", 5) # "start here"
target.BreakpointCreateByLocation("main.c", 7) # "should run to here"
process = target.LaunchSimple(None, None, None)
thread = process.GetSelectedThread()
pc0 = thread.GetFrameAtIndex(0).GetPC()

thread.StepUsingScriptedThreadPlan("bug1." + cls, True)

frame = thread.GetFrameAtIndex(0)
print("%-9s stopped on line %d, pc advanced %2d bytes"
% (cls, frame.GetLineEntry().GetLine(), frame.GetPC() - pc0))
process.Kill()
lldb.SBDebugger.Destroy(dbg)
```

```console
$ PYTHONPATH=$(lldb -P) python3 bug1.py ./repro
```

### Actual — identical on 19.1.0, 20.1.8, 21.1.7 and 22.1.8

```
StepPlan stopped on line 5, pc advanced 5 bytes
RunPlan stopped on line 5, pc advanced 5 bytes
```

### Expected

```
StepPlan stopped on line 5, pc advanced 5 bytes
RunPlan stopped on line 7, pc advanced 32 bytes
```

That is what 18.1.8 prints.

### Versions

| 18.1.8 | 19.1.0 | 20.1.8 | 21.1.7 | 22.1.8 |
|---|---|---|---|---|
| works | broken | broken | broken | broken |

Introduced by `9a9ec228cdcf` (*"[lldb] Make use of Scripted{Python,}Interface for
ScriptedThreadPlan"*, #96868, 2024-06-27), first released in `llvmorg-19.1.0` —
where the table turns. It replaced a `bool`-returning bridge that mapped the
value explicitly. The function body is unchanged on `main`, and `git log -L` over
it returns exactly that one commit.

---

## (2) A step onto a breakpoint site loses control of the process

`ThreadPlanStepOverBreakpoint::DoPlanExplainsStop()`
(`lldb/source/Target/ThreadPlanStepOverBreakpoint.cpp`) decides from the
StopReason. Since #126988 a thread that stops *at* an enabled but unexecuted
`BreakpointSite` is no longer reported as a breakpoint hit — after a single step
the reason stays `eStopReasonTrace`, and the arrival is recorded separately by
`Thread::SetThreadStoppedAtUnexecutedBP()`. The plan therefore takes the
`eStopReasonTrace` arm, returns true without clearing its auto-continue, and —
being the top plan — leaves `Thread::ShouldStop()` in the `override_stop` path,
which discards the vote of every plan below it. Those plans have already been
popped, so nothing is left to stop the process and it runs to exit. It should stay
stopped: the run-to-address plan below votes to stop, and that vote should stand.

### Reproducer

`bug2.py` — a scripted plan that queues a `ThreadPlanRunToAddress` sub-plan for
the next instruction, run twice: once from a stop on a hit breakpoint site, then
once after a single `StepInstruction` has moved the pc off the site. Nothing else
in the script differs between the two runs.

```python
import os, sys, lldb

class RunToNextInstruction:
def __init__(self, thread_plan, args_data):
self.thread_plan = thread_plan
target = thread_plan.GetThread().GetProcess().GetTarget()
addr = lldb.SBAddress(int(os.environ["TARGET_ADDR"]), target)
self.sub_plan = thread_plan.QueueThreadPlanForRunToAddress(addr, lldb.SBError())

def explains_stop(self, event):
return False

def should_stop(self, event):
self.thread_plan.SetPlanComplete(True)
return True

def run(step_off_the_breakpoint_site):
dbg = lldb.SBDebugger.Create()
dbg.SetAsync(False)
dbg.HandleCommand("command script import " + os.path.abspath(__file__))
target = dbg.CreateTarget(sys.argv[1])
target.BreakpointCreateByLocation("main.c", 4)
process = target.LaunchSimple(None, None, None)
thread = process.GetSelectedThread()
if step_off_the_breakpoint_site:
thread.StepInstruction(False)
pc = thread.GetFrameAtIndex(0).GetPCAddress()
nxt = target.ReadInstructions(pc, 2)[1].GetAddress().GetLoadAddress(target)
os.environ["TARGET_ADDR"] = str(nxt) # lldb imports this file as its own module

thread.StepUsingScriptedThreadPlan("bug2.RunToNextInstruction", True)

if process.GetState() == lldb.eStateStopped:
outcome = ("stopped at 0x%x, run-to target was 0x%x"
% (thread.GetFrameAtIndex(0).GetPC(), nxt))
else:
outcome = "process exited, status %d" % process.GetExitStatus()
print("pc on a breakpoint site at the resume: %-3s -> %s"
% ("no" if step_off_the_breakpoint_site else "yes", outcome))
lldb.SBDebugger.Destroy(dbg)

if __name__ == "__main__":
run(False) # the defect: resuming from a stop on a breakpoint site
run(True) # the control: same plan, pc one instruction past the site
```

```console
$ PYTHONPATH=$(lldb -P) python3 bug2.py ./repro
```

### Actual — identical on 21.1.7 and 22.1.8

```
pc on a breakpoint site at the resume: yes -> process exited, status 12
pc on a breakpoint site at the resume: no -> stopped at 0x55555555515b, run-to target was 0x55555555515b
```

### Expected

```
pc on a breakpoint site at the resume: yes -> stopped at 0x555555555156, run-to target was 0x555555555156
pc on a breakpoint site at the resume: no -> stopped at 0x55555555515b, run-to target was 0x55555555515b
```

That is what 18.1.8, 19.1.0 and 20.1.8 print. The addresses belong to this
compiled binary, and the `yes` and `no` runs target different instructions
because the second steps once before queueing the plan; what reproduces is that
both runs stop at the address the plan was given.

### Versions

| 18.1.8 | 19.1.0 | 20.1.8 | 21.1.7 | 22.1.8 |
|---|---|---|---|---|
| works | works | works | broken | broken |

Introduced by `b666ac3b63e0` (*"[lldb] Change lldb's breakpoint handling behavior,
reland"*, #126988, 2025-02-13), a reland of `05f0e86cc895` (#96260, 2024-07-19,
reverted the same day by `52c08d7ffd38`); first released in `llvmorg-21.1.0`.
Neither commit touches
`lldb/source/Target/ThreadPlanStepOverBreakpoint.cpp` — what changed is the stop
reason the plan tests for, not the plan. `DoPlanExplainsStop()` is unchanged on
`main`. The table is a release boundary, not a bisect; what ties the defect to
that commit is the mechanism above.

Contributor guide

Open the contributing guide

Research direction

Run the supplied bug1.py and bug2.py reproducers against LLDB first. Inspect GetRunState() in lldb/source/Plugins/ScriptInterpreter/Python/Interfaces/ScriptedThreadPlanPythonInterface.cpp and DoPlanExplainsStop() in lldb/source/Target/ThreadPlanStepOverBreakpoint.cpp. Done means boolean should_step values produce different behavior and both breakpoint-site cases stop at the run-to target.

Written by the indexing model from the issue text.

Assessment

Tech stack
c, python
Domain
compilers, devtools
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.