-
Notifications
You must be signed in to change notification settings - Fork 19
FrameLocalsProxy subscript reads one locals-plus slot; executioncontext reverse_debugging fold #1373
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
FrameLocalsProxy subscript reads one locals-plus slot; executioncontext reverse_debugging fold #1373
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
2550294
interpreter: rewrite \r\n and lone \r to \n before compiling a source
youknowone 4fe033f
jitprof: name the two producers of the force-quasiimmut abort tally
youknowone 2aa3831
bench/synth: correct what the getframe fixture says would lower its g…
youknowone fcbc29f
executioncontext: fold the reverse_debugging arms and drop sys_exc_in…
youknowone 550da0f
pyframe: read one locals-plus slot for a FrameLocalsProxy subscript
youknowone 9bc830c
extra_tests: compare a FrameLocalsProxy subscript's misses against cp…
youknowone 4414d7e
pyframe: hash a FrameLocalsProxy key before the locals-plus scan
youknowone 32d986d
extra_tests: compare FrameLocalsProxy keys that are not exact strings
youknowone 3a3ca41
pyframe: resolve a FrameLocalsProxy delete and pop through the key scan
youknowone 0aa5449
extra_tests: compare FrameLocalsProxy deletes and a hidden slot again…
youknowone a6c24b3
jitprof: name the gate and the reachable producer of the force-quasii…
youknowone b8e08d9
jitprof: state that the mapdict producer's gate is an env-var presenc…
youknowone File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
50 changes: 50 additions & 0 deletions
50
pyre/extra_tests/parity_tests/framelocalsproxy_delete_slot.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| # CPython-suite gap: `test_frame` deletes through the proxy only to check that | ||
| # a fast local refuses, so nothing in the suite pins what `del` and `pop` | ||
| # report for a key that names no slot, or for a key that cannot be hashed. | ||
| # | ||
| # `framelocalsproxy_setitem` with no value, and `framelocalsproxy_pop`, both | ||
| # resolve the key through the same scan the subscript uses, and that scan | ||
| # hashes the key before it looks at any name. Probing a materialized snapshot | ||
| # first instead gets the refusal right and everything else wrong: the miss | ||
| # arrives as the snapshot's `KeyError`, an unhashable key arrives in the dict's | ||
| # terms, and for `pop` the discarded probe turns the hash's `TypeError` into a | ||
| # `KeyError` naming the unhashable key. | ||
| # | ||
| # parity-tests reason: every line below is exception text, which is only worth | ||
| # anything next to the runtime it has to agree with. | ||
| import sys | ||
|
|
||
|
|
||
| def report(label, call): | ||
| try: | ||
| print(label, "->", call()) | ||
| except Exception as exc: | ||
| print(label, "->", type(exc).__name__, exc.args) | ||
|
|
||
|
|
||
| def delete_and_pop(): | ||
| bound = 1 # noqa: F841 - named through the proxy below | ||
| proxy = sys._getframe(0).f_locals | ||
| # A key the frame has a slot for is refused whichever way it is asked. | ||
| report("del local", lambda: proxy.__delitem__("bound")) | ||
| report("pop local", lambda: proxy.pop("bound")) | ||
| # The hash runs before the scan, so neither of these reaches the extras | ||
| # dict to be described in its terms. | ||
| report("del unhashable", lambda: proxy.__delitem__(["unhashable"])) | ||
| report("pop unhashable", lambda: proxy.pop(["unhashable"])) | ||
| # A frame with no extras dict yet reports the key itself, or the default. | ||
| report("del absent", lambda: proxy.__delitem__("absent")) | ||
| report("pop absent", lambda: proxy.pop("absent")) | ||
| report("pop absent default", lambda: proxy.pop("absent", "fallback")) | ||
| # Once the extras dict exists it answers both, and a second delete of the | ||
| # same name is a miss again. | ||
| proxy["extra"] = 7 | ||
| report("pop extra", lambda: proxy.pop("extra")) | ||
| proxy["extra"] = 8 | ||
| report("del extra", lambda: proxy.__delitem__("extra")) | ||
| report("del extra again", lambda: proxy.__delitem__("extra")) | ||
| print("bound untouched", bound) | ||
|
|
||
|
|
||
| delete_and_pop() | ||
| print("OK") |
145 changes: 145 additions & 0 deletions
145
pyre/extra_tests/parity_tests/framelocalsproxy_getitem_slot.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| # CPython-suite gap: `test_frame` reads `frame.f_locals[name]` only for names | ||
| # that are bound, so nothing in the suite pins what the proxy reports for an | ||
| # unbound slot, for a name it does not carry at all, or for a key that cannot | ||
| # be hashed. | ||
| # | ||
| # `framelocalsproxy_getitem` resolves the key to a locals-plus index and reads | ||
| # that one slot. Answering the same lookup by materializing the whole mapping | ||
| # and subscripting it gets the value right and everything else wrong: the miss | ||
| # arrives as the mapping's own `KeyError(key)`, and an unhashable key arrives | ||
| # as the mapping's key-flavoured `TypeError` rather than the one the hash | ||
| # raises before any slot is examined. | ||
| # | ||
| # The same scan answers a write, and it hashes the key before it looks at any | ||
| # name: a key that hashes like nothing the frame carries names no slot, however | ||
| # it compares. | ||
| # | ||
| # parity-tests reason: the value a hit returns is identical either way, so a | ||
| # snippet that reads a bound local cannot see the difference. What separates | ||
| # the two shapes is exception text, which is only worth anything next to the | ||
| # runtime it has to agree with. | ||
| import sys | ||
|
|
||
|
|
||
| def scalar_slots(): | ||
| bound = 1 | ||
| if bound == 0: | ||
| unbound = 2 # noqa: F841 - compiled into a slot that is never bound | ||
| proxy = sys._getframe(0).f_locals | ||
| print("bound", proxy["bound"]) | ||
| for name in ("unbound", "absent"): | ||
| try: | ||
| proxy[name] | ||
| except KeyError as exc: | ||
| print(name, "KeyError", exc.args) | ||
| try: | ||
| proxy[["unhashable"]] | ||
| except TypeError as exc: | ||
| print("unhashable", "TypeError", exc) | ||
| # A name the frame has no slot for is stored in, and read back from, the | ||
| # frame's separate extras mapping. | ||
| proxy["extra"] = 7 | ||
| print("extra", proxy["extra"]) | ||
|
|
||
|
|
||
| class SameHash: | ||
| """A non-`str` key that both hashes and compares like the name it holds.""" | ||
|
|
||
| def __init__(self, name): | ||
| self.name = name | ||
|
|
||
| def __hash__(self): | ||
| return hash(self.name) | ||
|
|
||
| def __eq__(self, other): | ||
| return other == self.name | ||
|
|
||
| def __repr__(self): | ||
| # The miss below reports the key with `%R`, so the default repr would | ||
| # put this object's address in the message. | ||
| return f"<key {self.name}>" | ||
|
|
||
|
|
||
| class OtherHash(SameHash): | ||
| """The same key, hashing like nothing the frame carries.""" | ||
|
|
||
| def __hash__(self): | ||
| return hash(self.name) ^ 1 | ||
|
|
||
|
|
||
| def non_str_keys(): | ||
| bound = 1 # noqa: F841 - read through the proxy below | ||
| proxy = sys._getframe(0).f_locals | ||
| print("same-hash", proxy[SameHash("bound")]) | ||
| # The scan compares a name only when its hash matches the key's, so a key | ||
| # that claims equality with a name it does not hash like never reaches the | ||
| # comparison and reads as absent. | ||
| key = OtherHash("bound") | ||
| print("hashes differ", hash(key) != hash(key.name)) | ||
| try: | ||
| proxy[key] | ||
| except KeyError as exc: | ||
| print("other-hash", "KeyError", exc.args) | ||
|
|
||
|
|
||
| def non_str_key_writes(): | ||
| bound = 1 | ||
| proxy = sys._getframe(0).f_locals | ||
| # The scan hashes the key before looking at any name, so the key never | ||
| # reaches the extras dict to be reported in its terms. | ||
| try: | ||
| proxy[["unhashable"]] = 1 | ||
| except TypeError as exc: | ||
| print("write unhashable", "TypeError", exc) | ||
| proxy[SameHash("bound")] = 2 | ||
| print("same-hash write", bound, proxy["bound"]) | ||
| # This one is filtered out by the hash before the comparison, so it names | ||
| # no slot and is stored in the extras dict under the key object itself. | ||
| proxy[OtherHash("bound")] = 3 | ||
| print("other-hash write", bound, proxy["bound"]) | ||
| print("extras keys", sorted(repr(key) for key in proxy if not isinstance(key, str))) | ||
|
|
||
|
|
||
| def hidden_slot(): | ||
| # PEP 709 inlines a comprehension into its enclosing scope, and in a class | ||
| # body the iteration variable becomes a hidden slot. Hidden is a property | ||
| # of the write direction only: the scan skips such a slot when it is | ||
| # looking for somewhere to store, so the assignment below goes to the | ||
| # extras dict, but the read that follows still reports the live slot. | ||
| def probe(): | ||
| proxy = sys._getframe(1).f_locals | ||
| before = proxy["i"] | ||
| proxy["i"] = 99 | ||
| return before, proxy["i"] | ||
|
|
||
| class Body: | ||
| seen = [probe() for i in range(2)] | ||
|
|
||
| print("hidden", Body.seen, "leaked" if "i" in Body.__dict__ else "not leaked") | ||
|
|
||
|
|
||
| def cell_and_free_slots(): | ||
| captured = "cell" | ||
|
|
||
| def inner(): | ||
| own = "own" | ||
| proxy = sys._getframe(0).f_locals | ||
| # `captured` has to be named in this body for the compiler to make it a | ||
| # freevar; reading it only through the proxy would leave it out of the | ||
| # locals-plus table entirely. | ||
| print("free", proxy["captured"], captured, "local", proxy["own"]) | ||
| return own | ||
|
|
||
| # `captured` is a varname of this frame AND a cellvar, so the slot holds | ||
| # the cell and a reader has to dereference it; in `inner` the same name is | ||
| # a freevar, which lands past every varname in the locals-plus order. | ||
| print("cell", sys._getframe(0).f_locals["captured"]) | ||
| inner() | ||
|
|
||
|
|
||
| scalar_slots() | ||
| non_str_keys() | ||
| non_str_key_writes() | ||
| hidden_slot() | ||
| cell_and_free_slots() | ||
| print("OK") | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| # pyre-check: gate=1 | ||
| """A source's line terminators are all `\n` by the time the tokenizer sees it. | ||
|
|
||
| `pytokenizer.py:654-662` universal_newline rewrites a line ending in `\r\n` or | ||
| in a lone `\r` to one ending in `\n`, and `generate_tokens` calls it on every | ||
| line it takes from `splitlines(True)` (`pyparse.py:202`). So the rewrite is not | ||
| string syntax and not a property of one entry point: it reaches a file, a | ||
| `compile()` argument, `ast.parse` and `-c` alike, and the text of a | ||
| triple-quoted literal along with the code around it. | ||
|
|
||
| Neither rewrite moves a line boundary, so the line a statement reports is the | ||
| one it reported before. | ||
| """ | ||
|
|
||
| import ast | ||
| import os | ||
| import subprocess | ||
| import sys | ||
| import tempfile | ||
|
|
||
| CRLF = "x = \"\"\"a\r\nb\"\"\"" | ||
| LONE_CR = "x = \"\"\"a\rb\"\"\"" | ||
|
|
||
|
|
||
| def value_of(source, name="x"): | ||
| namespace = {} | ||
| exec(compile(source, "<test>", "exec"), namespace) | ||
| return namespace[name] | ||
|
|
||
|
|
||
| # The literal spans the terminator, so its own text carries the rewrite. | ||
| assert value_of(CRLF) == "a\nb", repr(value_of(CRLF)) | ||
| assert value_of(LONE_CR) == "a\nb", repr(value_of(LONE_CR)) | ||
|
|
||
| # A terminator is not an escape, so being raw or bytes changes nothing. | ||
| assert value_of("x = r\"\"\"a\rb\"\"\"") == "a\nb", repr(value_of("x = r\"\"\"a\rb\"\"\"")) | ||
| assert value_of("x = b\"\"\"a\r\nb\"\"\"") == b"a\nb", repr(value_of("x = b\"\"\"a\r\nb\"\"\"")) | ||
|
|
||
| # `ast.parse` reads the same rewritten source, so the constant it carries and | ||
| # the segment `end_col_offset` describes agree with it. | ||
| tree = ast.parse(CRLF) | ||
| assert tree.body[0].value.value == "a\nb", repr(tree.body[0].value.value) | ||
| assert ast.parse(LONE_CR).body[0].value.value == "a\nb" | ||
|
|
||
| # Statements still land on the lines they were written on. | ||
| namespace = {} | ||
| exec(compile("x = 1\r\ny = 2\r\nz = 3\r", "<test>", "exec"), namespace) | ||
| assert (namespace["x"], namespace["y"], namespace["z"]) == (1, 2, 3), namespace | ||
| try: | ||
| exec(compile("x = 1\rraise ValueError('boom')", "<test>", "exec"), {}) | ||
| except ValueError as exc: | ||
| assert exc.__traceback__.tb_next.tb_lineno == 2, exc.__traceback__.tb_next.tb_lineno | ||
| else: | ||
| raise AssertionError("the raise did not run") | ||
|
|
||
| # A failed compile reports the offending line, and that line came from the | ||
| # rewritten source too. How the reference spells the terminator is its own | ||
| # business -- CPython drops it and PyPy keeps `\n` -- so what is pinned here is | ||
| # only that no carriage return survives into it. | ||
| try: | ||
| compile("x = 1\r\ny = (\r\nz = 3\r\n", "<test>", "exec") | ||
| except SyntaxError as exc: | ||
| assert exc.lineno == 2, exc.lineno | ||
| assert exc.text is not None and "\r" not in exc.text, repr(exc.text) | ||
| assert exc.text.rstrip("\r\n") == "y = (", repr(exc.text) | ||
| else: | ||
| raise AssertionError("the unclosed paren did not raise") | ||
|
|
||
| # A lone `\r` reaches the same slicing. Before the rewrite ran on the string | ||
| # the report is sliced from, this source held no `\n` at all, so the offending | ||
| # line came back as `None` rather than as the second line. | ||
| try: | ||
| compile("a = 1\rb = (\r", "<test>", "exec") | ||
| except SyntaxError as exc: | ||
| assert exc.lineno == 2, exc.lineno | ||
| assert exc.text is not None and "\r" not in exc.text, repr(exc.text) | ||
| assert exc.text.rstrip("\r\n") == "b = (", repr(exc.text) | ||
| else: | ||
| raise AssertionError("the unclosed paren did not raise") | ||
|
|
||
| # The same source reaching the compiler as a `-c` argument, and as a file. | ||
| PROGRAM = CRLF + "\nprint(repr(x))\n" | ||
|
|
||
| completed = subprocess.run( | ||
| [sys.executable, "-c", PROGRAM], | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
| assert completed.returncode == 0, completed.stderr | ||
| assert completed.stdout.strip() == "'a\\nb'", completed.stdout | ||
|
|
||
| # Written as bytes so the carriage returns reach the file itself rather than | ||
| # whatever the platform's text mode would spell them as. | ||
| handle, path = tempfile.mkstemp(suffix=".py") | ||
| os.write(handle, PROGRAM.encode()) | ||
| os.close(handle) | ||
| try: | ||
| completed = subprocess.run([sys.executable, path], capture_output=True, text=True) | ||
| assert completed.returncode == 0, completed.stderr | ||
| assert completed.stdout.strip() == "'a\\nb'", completed.stdout | ||
| finally: | ||
| os.unlink(path) | ||
|
|
||
| print("OK") |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.