Skip to content

Commit 8054daf

Browse files
authored
fix(debugging): handle multiple injection points [backport #6759 to 1.18] (#6761)
Backport of #6759 to 1.18 Some code constructs might be translated into non-linear bytecode, meaning that some line numbers might appear multiple time, as code blocks might be copied in multiple place (e.g. finally blocks). ## Checklist - [x] Change(s) are motivated and described in the PR description. - [x] Testing strategy is described if automated tests are not included in the PR. - [x] Risk is outlined (performance impact, potential for breakage, maintainability, etc). - [x] Change is maintainable (easy to change, telemetry, documentation). - [x] [Library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) are followed. If no release note is required, add label `changelog/no-changelog`. - [x] Documentation is included (in-code, generated user docs, [public corp docs](https://github.com/DataDog/documentation/)). - [x] Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Title is accurate. - [x] No unnecessary changes are introduced. - [x] Description motivates each change. - [x] Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes unless absolutely necessary. - [x] Testing strategy adequately addresses listed risk(s). - [x] Change is maintainable (easy to change, telemetry, documentation). - [x] Release note makes sense to a user of the library. - [x] Reviewer has explicitly acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment. - [x] Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) - [x] If this PR touches code that signs or publishes builds or packages, or handles credentials of any kind, I've requested a review from `@DataDog/security-design-and-guidance`. - [x] This PR doesn't touch any of that.
1 parent f9bf68c commit 8054daf

File tree

5 files changed

+98
-45
lines changed

5 files changed

+98
-45
lines changed

ddtrace/internal/injection.py

Lines changed: 53 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
from collections import deque
12
from types import FunctionType
23
from typing import Any
34
from typing import Callable
5+
from typing import Deque
46
from typing import List
57
from typing import Tuple
68

@@ -30,52 +32,63 @@ def _inject_hook(code, hook, lineno, arg):
3032
identifier for the hook itself. This should be kept in case the hook needs
3133
to be removed.
3234
"""
35+
# DEV: In general there are no guarantees for bytecode to be "linear",
36+
# meaning that a line number can occur multiple times. We need to find all
37+
# occurrences and inject the hook at each of them. An example of when this
38+
# happens is with finally blocks, which are duplicated at the end of the
39+
# bytecode.
40+
locs = deque() # type: Deque[int]
41+
last_lineno = None
3342
for i, instr in enumerate(code):
3443
try:
44+
if instr.lineno == last_lineno:
45+
continue
46+
last_lineno = instr.lineno
3547
if instr.lineno == lineno:
36-
# gotcha!
37-
break
48+
locs.appendleft(i)
3849
except AttributeError:
3950
# pseudo-instruction (e.g. label)
4051
pass
41-
else:
52+
53+
if not locs:
4254
raise InvalidLine("Line %d does not exist or is either blank or a comment" % lineno)
4355

4456
# DEV: This is the bytecode equivalent of
4557
# >>> hook(arg)
4658
# Additionally, we must discard the return value (top of the stack) to
4759
# restore the stack to the state prior to the call.
48-
if PY < (3, 11):
49-
code[i:i] = Bytecode(
50-
[
51-
Instr("LOAD_CONST", hook, lineno=lineno),
52-
Instr("LOAD_CONST", arg, lineno=lineno),
53-
Instr("CALL_FUNCTION", 1, lineno=lineno),
54-
Instr("POP_TOP", lineno=lineno),
55-
]
56-
)
57-
elif PY >= (3, 12):
58-
code[i:i] = Bytecode(
59-
[
60-
Instr("PUSH_NULL", lineno=lineno),
61-
Instr("LOAD_CONST", hook, lineno=lineno),
62-
Instr("LOAD_CONST", arg, lineno=lineno),
63-
Instr("CALL", 1, lineno=lineno),
64-
Instr("POP_TOP", lineno=lineno),
65-
]
66-
)
67-
else:
68-
# Python 3.11
69-
code[i:i] = Bytecode(
70-
[
71-
Instr("PUSH_NULL", lineno=lineno),
72-
Instr("LOAD_CONST", hook, lineno=lineno),
73-
Instr("LOAD_CONST", arg, lineno=lineno),
74-
Instr("PRECALL", 1, lineno=lineno),
75-
Instr("CALL", 1, lineno=lineno),
76-
Instr("POP_TOP", lineno=lineno),
77-
]
78-
)
60+
for i in locs:
61+
if PY < (3, 11):
62+
code[i:i] = Bytecode(
63+
[
64+
Instr("LOAD_CONST", hook, lineno=lineno),
65+
Instr("LOAD_CONST", arg, lineno=lineno),
66+
Instr("CALL_FUNCTION", 1, lineno=lineno),
67+
Instr("POP_TOP", lineno=lineno),
68+
]
69+
)
70+
elif PY >= (3, 12):
71+
code[i:i] = Bytecode(
72+
[
73+
Instr("PUSH_NULL", lineno=lineno),
74+
Instr("LOAD_CONST", hook, lineno=lineno),
75+
Instr("LOAD_CONST", arg, lineno=lineno),
76+
Instr("CALL", 1, lineno=lineno),
77+
Instr("POP_TOP", lineno=lineno),
78+
]
79+
)
80+
else:
81+
# Python 3.11
82+
code[i:i] = Bytecode(
83+
[
84+
Instr("PUSH_NULL", lineno=lineno),
85+
Instr("LOAD_CONST", hook, lineno=lineno),
86+
Instr("LOAD_CONST", arg, lineno=lineno),
87+
Instr("PRECALL", 1, lineno=lineno),
88+
Instr("CALL", 1, lineno=lineno),
89+
Instr("POP_TOP", lineno=lineno),
90+
]
91+
)
7992

8093

8194
# Default to Python 3.11 opcodes
@@ -96,6 +109,7 @@ def _eject_hook(code, hook, line, arg):
96109
The hook is identified by its argument. This ensures that only the right
97110
hook is ejected.
98111
"""
112+
locs = deque() # type: Deque[int]
99113
for i, instr in enumerate(code):
100114
try:
101115
# DEV: We look at the expected opcode pattern to match the injected
@@ -106,17 +120,18 @@ def _eject_hook(code, hook, line, arg):
106120
and code[i + _INJECT_ARG_OPCODE_POS].arg is arg
107121
and [code[_].name for _ in range(i, i + len(_INJECT_HOOK_OPCODES))] == _INJECT_HOOK_OPCODES
108122
):
109-
# gotcha!
110-
break
123+
locs.appendleft(i)
111124
except AttributeError:
112125
# pseudo-instruction (e.g. label)
113126
pass
114127
except IndexError:
115128
pass
116-
else:
129+
130+
if not locs:
117131
raise InvalidLine("Line %d does not contain a hook" % line)
118132

119-
del code[i : i + len(_INJECT_HOOK_OPCODES)]
133+
for i in locs:
134+
del code[i : i + len(_INJECT_HOOK_OPCODES)]
120135

121136

122137
def _function_with_new_code(f, abstract_code):

ddtrace/internal/utils/inspection.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99
def linenos(f):
1010
# type: (FunctionType) -> Set[int]
1111
"""Get the line numbers of a function."""
12-
ls = {instr.lineno for instr in Bytecode.from_code(f.__code__) if hasattr(instr, "lineno")}
12+
ls = {
13+
_
14+
for _ in (instr.lineno for instr in Bytecode.from_code(f.__code__) if hasattr(instr, "lineno"))
15+
if _ is not None
16+
}
1317
if PY >= (3, 11):
1418
# Python 3.11 has introduced some no-op instructions that are used as
1519
# part of the specialisation process. These new opcodes appear on a
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
fixes:
3+
- |
4+
dynamic instrumentation: fixed an issue that prevented line probes from
5+
being injected in some finally blocks.

tests/internal/test_injection.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
from contextlib import contextmanager
22
from random import shuffle
3-
import sys
43

54
import mock
65
import pytest
76
from six import PY2
87

8+
from ddtrace.debugging._function.discovery import UnboundMethodType
99
from ddtrace.internal.injection import InvalidLine
1010
from ddtrace.internal.injection import eject_hook
1111
from ddtrace.internal.injection import eject_hooks
@@ -15,18 +15,21 @@
1515

1616

1717
@contextmanager
18-
def injected_hook(f, hook, arg):
18+
def injected_hook(f, hook, arg, line=None):
19+
if PY2 and isinstance(f, UnboundMethodType):
20+
f = f.__func__
21+
1922
code = f.__code__
20-
line = min(linenos(f))
23+
24+
if line is None:
25+
line = min(linenos(f))
2126

2227
inject_hook(f, hook, line, arg)
2328

2429
yield f
2530

2631
eject_hook(f, hook, line, arg)
2732

28-
if sys.version_info[:2] not in {(3, 5), (3, 6)} and sys.version_info < (3, 11):
29-
assert f.__code__ == code
3033
assert f.__code__ is not code
3134

3235

@@ -234,7 +237,7 @@ def test_inject_in_multiline():
234237

235238

236239
def test_property():
237-
stuff = sys.modules["tests.submod.stuff"]
240+
import tests.submod.stuff as stuff
238241

239242
f = stuff.Stuff.propertystuff.fget
240243

@@ -245,3 +248,17 @@ def test_property():
245248
stuff.Stuff().propertystuff
246249

247250
hook.assert_called_once_with(arg)
251+
252+
253+
def test_finally():
254+
import tests.submod.stuff as stuff
255+
256+
f = stuff.finallystuff
257+
258+
hook, arg = mock.Mock(), mock.Mock()
259+
260+
with injected_hook(f, hook, arg, line=157):
261+
f()
262+
f()
263+
264+
hook.assert_called_once_with(arg)

tests/submod/stuff.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,15 @@ def age_checker(people, age, name=None):
144144

145145
def caller(f, *args, **kwargs):
146146
return f(*args, **kwargs)
147+
148+
149+
def finallystuff():
150+
a = 0
151+
try:
152+
if a == 0:
153+
Exception("Hello", "world!", 42)
154+
except Exception:
155+
return a
156+
finally:
157+
a = 42
158+
return a

0 commit comments

Comments
 (0)