Bug report
Bug description:
With two sys.monitoring tools active, one listening for INSTRUCTION and another for LINE: if the LINE callback returns sys.monitoring.DISABLE, the INSTRUCTION tool does not receive the event for that line's first instruction. This happens on the execution where DISABLE is returned. Later executions deliver the event again.
DISABLE is documented as only affecting the tool that returns it, so the INSTRUCTION tool should see every instruction regardless of what the LINE tool returns.
import dis
import sys
mon = sys.monitoring
INSTRUCTION_TOOL = mon.DEBUGGER_ID
LINE_TOOL = mon.COVERAGE_ID
def f(x):
a = x + 1
b = (
a * 2
)
return b
def instruction_offsets_seen(line_callback_result):
calls = []
def on_instruction(code, offset):
if code is f.__code__:
calls[-1].append(offset)
def on_line(code, line_number):
return line_callback_result
mon.use_tool_id(INSTRUCTION_TOOL, "instruction-tool")
mon.register_callback(INSTRUCTION_TOOL, mon.events.INSTRUCTION, on_instruction)
mon.set_events(INSTRUCTION_TOOL, mon.events.INSTRUCTION)
mon.use_tool_id(LINE_TOOL, "line-tool")
mon.register_callback(LINE_TOOL, mon.events.LINE, on_line)
mon.set_events(LINE_TOOL, mon.events.LINE)
try:
for _ in range(2):
calls.append([])
f(1)
finally:
for tool in (LINE_TOOL, INSTRUCTION_TOOL):
mon.set_events(tool, 0)
mon.free_tool_id(tool)
mon.restart_events()
return calls
print(sys.version)
print("line-start offsets:", [i.offset for i in dis.get_instructions(f) if i.starts_line])
for label, result in [("None", None), ("DISABLE", mon.DISABLE)]:
first, second = instruction_offsets_seen(result)
print(f"LINE callback returns {label}:")
print(" INSTRUCTION offsets, 1st call:", first)
print(" INSTRUCTION offsets, 2nd call:", second)
Output on main (3.16.0a0, bee3031):
line-start offsets: [0, 4, 22, 38, 40]
LINE callback returns None:
INSTRUCTION offsets, 1st call: [4, 6, 8, 20, 22, 24, 26, 38, 40, 42]
INSTRUCTION offsets, 2nd call: [4, 6, 8, 20, 22, 24, 26, 38, 40, 42]
LINE callback returns DISABLE:
INSTRUCTION offsets, 1st call: [6, 8, 20, 24, 26, 42]
INSTRUCTION offsets, 2nd call: [4, 6, 8, 20, 22, 24, 26, 38, 40, 42]
Expected: the 1st call with DISABLE reports the same offsets as the other three runs. Instead, every line-start offset after the initial RESUME (4, 22, 38, 40) is missing. 3.12, 3.13, 3.14 and 3.15 behave the same way, with only the bytecode offsets differing.
Likely cause (from reading Python/instrumentation.c): when a line has both LINE and INSTRUCTION instrumentation, the saved original opcode for that line is INSTRUMENTED_INSTRUCTION. When the LINE callback returns DISABLE, remove_line_tools() calls de_instrument_line(), which overwrites that saved opcode with per_instruction_opcodes[i], the underlying opcode. _Py_call_instrumentation_line() then reaches done: and returns the overwritten value, so this execution dispatches straight to the underlying opcode and never reaches INSTRUMENTED_INSTRUCTION. The bytecode itself is correctly left as INSTRUMENTED_INSTRUCTION, which is why later executions work. Returning the instruction's current opcode after de-instrumentation, or not overwriting the saved opcode, would likely fix it.
Impact: a tool that uses INSTRUCTION events to act on the instruction after one it intercepted (for example, to put a value back on the stack) silently misses that step whenever a coverage tool that disables LINE events is running. We hit this in CrossHair running alongside Hypothesis's coverage collection: an f-string evaluated to ''.
Possibly related, but a different trigger: gh-157900 (a first tool's INSTRUCTION callbacks are dropped when a second tool is added; here, the LINE -> None control shows no loss).
CPython versions tested on:
CPython main branch, 3.15, 3.14, 3.13, 3.12
Operating systems tested on:
Linux
Bug report
Bug description:
With two
sys.monitoringtools active, one listening forINSTRUCTIONand another forLINE: if theLINEcallback returnssys.monitoring.DISABLE, theINSTRUCTIONtool does not receive the event for that line's first instruction. This happens on the execution whereDISABLEis returned. Later executions deliver the event again.DISABLEis documented as only affecting the tool that returns it, so theINSTRUCTIONtool should see every instruction regardless of what theLINEtool returns.Output on
main(3.16.0a0, bee3031):Expected: the 1st call with
DISABLEreports the same offsets as the other three runs. Instead, every line-start offset after the initialRESUME(4, 22, 38, 40) is missing. 3.12, 3.13, 3.14 and 3.15 behave the same way, with only the bytecode offsets differing.Likely cause (from reading
Python/instrumentation.c): when a line has bothLINEandINSTRUCTIONinstrumentation, the saved original opcode for that line isINSTRUMENTED_INSTRUCTION. When theLINEcallback returnsDISABLE,remove_line_tools()callsde_instrument_line(), which overwrites that saved opcode withper_instruction_opcodes[i], the underlying opcode._Py_call_instrumentation_line()then reachesdone:and returns the overwritten value, so this execution dispatches straight to the underlying opcode and never reachesINSTRUMENTED_INSTRUCTION. The bytecode itself is correctly left asINSTRUMENTED_INSTRUCTION, which is why later executions work. Returning the instruction's current opcode after de-instrumentation, or not overwriting the saved opcode, would likely fix it.Impact: a tool that uses
INSTRUCTIONevents to act on the instruction after one it intercepted (for example, to put a value back on the stack) silently misses that step whenever a coverage tool that disablesLINEevents is running. We hit this in CrossHair running alongside Hypothesis's coverage collection: an f-string evaluated to''.Possibly related, but a different trigger: gh-157900 (a first tool's
INSTRUCTIONcallbacks are dropped when a second tool is added; here, theLINE -> Nonecontrol shows no loss).CPython versions tested on:
CPython main branch, 3.15, 3.14, 3.13, 3.12
Operating systems tested on:
Linux