Skip to content

sys.monitoring: INSTRUCTION event skipped when another tool's LINE callback returns DISABLE at the same instruction #158121

Description

@pschanely

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    extension-modulesC modules in the Modules dirinterpreter-core(Objects, Python, Grammar, and Parser dirs)type-bugAn unexpected behavior, bug, or error

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions