Repository: python/cpython
Pull request #133385: gh-91048: Add filename and line number to external inspection routines
Author: login: pablogsal
Labels: name: skip news
Created: 2025-05-04T16:29:48Z | Merged: 2025-05-04T21:33:37Z

<!--
Thanks for your contribution!
Please read this comment in its entirety. It's quite important.

# Pull Request title

It should be in the following format:

```
gh-NNNNNN: Summary of the changes made
```

Where: gh-NNNNNN refers to the GitHub issue number.

Most PRs will require an issue number. Trivial changes, like fixing a typo, do not need an issue.

# Backport Pull Request title

If this is a backport PR (PR made against branches other than `main`),
please ensure that the PR title is in the following format:

```
[X.Y] <title from the original PR> (GH-NNNNNN)
```

Where: [X.Y] is the branch name, for example: [3.13].

GH-NNNNNN refers to the PR number from `main`.

-->


<!-- gh-issue-number: gh-91048 -->
* Issue: gh-91048
<!-- /gh-issue-number -->



Diff:
diff --git a/Lib/asyncio/tools.py b/Lib/asyncio/tools.py
index 16440b594ad993..6c1f725e777fb9 100644
--- a/Lib/asyncio/tools.py
+++ b/Lib/asyncio/tools.py
@@ -27,6 +27,7 @@ def _index(result):
         for tid, tname, awaited in tasks:
             id2name[tid] = tname
             for stack, parent_id in awaited:
+                stack = [elem[0] if isinstance(elem, tuple) else elem for elem in stack]
                 awaits.append((parent_id, stack, tid))
     return id2name, awaits
 
@@ -151,6 +152,7 @@ def build_task_table(result):
                     ]
                 )
             for stack, awaiter_id in awaited:
+                stack = [elem[0] if isinstance(elem, tuple) else elem for elem in stack]
                 coroutine_chain = " -> ".join(stack)
                 awaiter_name = id2name.get(awaiter_id, "Unknown")
                 table.append(
diff --git a/Lib/test/test_external_inspection.py b/Lib/test/test_external_inspection.py
index 0fd704e698b90e..f787190b1ae4e0 100644
--- a/Lib/test/test_external_inspection.py
+++ b/Lib/test/test_external_inspection.py
@@ -4,6 +4,7 @@
 import importlib
 import sys
 import socket
+from asyncio import staggered, taskgroups
 from unittest.mock import ANY
 from test.support import os_helper, SHORT_TIMEOUT, busy_retry
 from test.support.script_helper import make_script
@@ -19,27 +20,33 @@
     from _remotedebugging import get_async_stack_trace
     from _remotedebugging import get_all_awaited_by
 except ImportError:
-    raise unittest.SkipTest(
-        "Test only runs when _remotedebuggingmodule is available")
+    raise unittest.SkipTest("Test only runs when _remotedebuggingmodule is available")
+
 
 def _make_test_script(script_dir, script_basename, source):
     to_return = make_script(script_dir, script_basename, source)
     importlib.invalidate_caches()
     return to_return
 
-skip_if_not_supported = unittest.skipIf((sys.platform != "darwin"
-                                         and sys.platform != "linux"
-                                         and sys.platform != "win32"),
-                                        "Test only runs on Linux, Windows and MacOS")
+
+skip_if_not_supported = unittest.skipIf(
+    (sys.platform != "darwin" and sys.platform != "linux" and sys.platform != "win32"),
+    "Test only runs on Linux, Windows and MacOS",
+)
+
+
 class TestGetStackTrace(unittest.TestCase):
 
     @skip_if_not_supported
-    @unittest.skipIf(sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED,
-                     "Test only runs on Linux with process_vm_readv support")
+    @unittest.skipIf(
+        sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED,
+        "Test only runs on Linux with process_vm_readv support",
+    )
     def test_remote_stack_trace(self):
         # Spawn a process with some realistic Python code
         port = find_unused_port()
-        script = textwrap.dedent(f"""\
+        script = textwrap.dedent(
+            f"""\
             import time, sys, socket
             # Connect to the test process
             sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
@@ -57,7 +64,8 @@ def foo():
                 time.sleep(1000)
 
             bar()
-            """)
+            """
+        )
         stack_trace = None
         with os_helper.temp_dir() as work_dir:
             script_dir = os.path.join(work_dir, "script_pkg")
@@ -66,11 +74,11 @@ def foo():
             # Create a socket server to communicate with the target process
             server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
             server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
-            server_socket.bind(('localhost', port))
+            server_socket.bind(("localhost", port))
             server_socket.settimeout(SHORT_TIMEOUT)
             server_socket.listen(1)
 
-            script_name = _make_test_script(script_dir, 'script', script)
+            script_name = _make_test_script(script_dir, "script", script)
             client_socket = None
             try:
                 p = subprocess.Popen([sys.executable, script_name])
@@ -88,22 +96,24 @@ def foo():
                 p.terminate()
                 p.wait(timeout=SHORT_TIMEOUT)
 
-
             expected_stack_trace = [
-                'foo',
-                'baz',
-                'bar',
-                '<module>'
+                ("foo", script_name, 15),
+                ("baz", script_name, 11),
+                ("bar", script_name, 9),
+                ("<module>", script_name, 17),
             ]
             self.assertEqual(stack_trace, expected_stack_trace)
 
     @skip_if_not_supported
-    @unittest.skipIf(sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED,
-                     "Test only runs on Linux with process_vm_readv support")
+    @unittest.skipIf(
+        sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED,
+        "Test only runs on Linux with process_vm_readv support",
+    )
     def test_async_remote_stack_trace(self):
         # Spawn a process with some realistic Python code
         port = find_unused_port()
-        script = textwrap.dedent(f"""\
+        script = textwrap.dedent(
+            f"""\
             import asyncio
             import time
             import sys
@@ -143,7 +153,8 @@ def new_eager_loop():
                 return loop
 
             asyncio.run(main(), loop_factory={{TASK_FACTORY}})
-            """)
+            """
+        )
         stack_trace = None
         for task_factory_variant in "asyncio.new_event_loop", "new_eager_loop":
             with (
@@ -154,25 +165,24 @@ def new_eager_loop():
                 os.mkdir(script_dir)
                 server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
                 server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
-                server_socket.bind(('localhost', port))
+                server_socket.bind(("localhost", port))
                 server_socket.settimeout(SHORT_TIMEOUT)
                 server_socket.listen(1)
                 script_name = _make_test_script(
-                    script_dir, 'script',
-                    script.format(TASK_FACTORY=task_factory_variant))
+                    script_dir,
+                    "script",
+                    script.format(TASK_FACTORY=task_factory_variant),
+                )
                 client_socket = None
                 try:
-                    p = subprocess.Popen(
-                        [sys.executable, script_name]
-                    )
+                    p = subprocess.Popen([sys.executable, script_name])
                     client_socket, _ = server_socket.accept()
                     server_socket.close()
                     response = client_socket.recv(1024)
                     self.assertEqual(response, b"ready")
                     stack_trace = get_async_stack_trace(p.pid)
                 except PermissionError:
-                    self.skipTest(
-                        "Insufficient permissions to read the stack trace")
+                    self.skipTest("Insufficient permissions to read the stack trace")
                 finally:
                     if client_socket is not None:
                         client_socket.close()
@@ -185,23 +195,91 @@ def new_eager_loop():
 
                 root_task = "Task-1"
                 expected_stack_trace = [
-                    ['c5', 'c4', 'c3', 'c2'],
-                    'c2_root',
                     [
-                        [['_aexit', '__aexit__', 'main'], root_task, []],
-                        [['c1'], 'sub_main_1', [[['_aexit', '__aexit__', 'main'], root_task, []]]],
-                        [['c1'], 'sub_main_2', [[['_aexit', '__aexit__', 'main'], root_task, []]]],
-                    ]
+                        ("c5", script_name, 11),
+                        ("c4", script_name, 15),
+                        ("c3", script_name, 18),
+                        ("c2", script_name, 21),
+                    ],
+                    "c2_root",
+                    [
+                        [
+                            [
+                                (
+                                    "TaskGroup._aexit",
+                                    taskgroups.__file__,
+                                    ANY,
+                                ),
+                                (
+                                    "TaskGroup.__aexit__",
+                                    taskgroups.__file__,
+                                    ANY,
+                                ),
+                                ("main", script_name, 27),
+                            ],
+                            "Task-1",
+                            [],
+                        ],
+                        [
+                            [("c1", script_name, 24)],
+                            "sub_main_1",
+                            [
+                                [
+                                    [
+                                        (
+                                            "TaskGroup._aexit",
+                                            taskgroups.__file__,
+                                            ANY,
+                                        ),
+                                        (
+                                            "TaskGroup.__aexit__",
+                                            taskgroups.__file__,
+                                            ANY,
+                                        ),
+                                        ("main", script_name, 27),
+                                    ],
+                                    "Task-1",
+                                    [],
+                                ]
+                            ],
+                        ],
+                        [
+                            [("c1", script_name, 24)],
+                            "sub_main_2",
+                            [
+                                [
+                                    [
+                                        (
+                                            "TaskGroup._aexit",
+                                            taskgroups.__file__,
+                                            ANY,
+                                        ),
+                                        (
+                                            "TaskGroup.__aexit__",
+                                            taskgroups.__file__,
+                                            ANY,
+                                        ),
+                                        ("main", script_name, 27),
+                                    ],
+                                    "Task-1",
+                                    [],
+                                ]
+                            ],
+                        ],
+                    ],
                 ]
                 self.assertEqual(stack_trace, expected_stack_trace)
 
     @skip_if_not_supported
-    @unittest.skipIf(sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED,
-                     "Test only runs on Linux with process_vm_readv support")
+    @unittest.skipIf(
+        sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED,
+        "Test only runs on Linux with process_vm_readv support",
+    )
     def test_asyncgen_remote_stack_trace(self):
         # Spawn a process with some realistic Python code
         port = find_unused_port()
-        script = textwrap.dedent(f"""\
+        script = textwrap.dedent(
+            f"""\
             import asyncio
             import time
             import sys
@@ -225,7 +303,8 @@ async def main():
                     pass
 
             asyncio.run(main())
-            """)
+            """
+        )
         stack_trace = None
         with os_helper.temp_dir() as work_dir:
             script_dir = os.path.join(work_dir, "script_pkg")
@@ -233,10 +312,10 @@ async def main():
             # Create a socket server to communicate with the target process
             server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
             server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
-            server_socket.bind(('localhost', port))
+            server_socket.bind(("localhost", port))
             server_socket.settimeout(SHORT_TIMEOUT)
             server_socket.listen(1)
-            script_name = _make_test_script(script_dir, 'script', script)
+            script_name = _make_test_script(script_dir, "script", script)
             client_socket = None
             try:
                 p = subprocess.Popen([sys.executable, script_name])
@@ -258,17 +337,26 @@ async def main():
             stack_trace[2].sort(key=lambda x: x[1])
 
             expected_stack_trace = [
-                ['gen_nested_call', 'gen', 'main'], 'Task-1', []
+                [
+                    ("gen_nested_call", script_name, 11),
+                    ("gen", script_name, 17),
+                    ("main", script_name, 20),
+                ],
+                "Task-1",
+                [],
             ]
             self.assertEqual(stack_trace, expected_stack_trace)
 
     @skip_if_not_supported
-    @unittest.skipIf(sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED,
-                     "Test only runs on Linux with process_vm_readv support")
+    @unittest.skipIf(
+        sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED,
+        "Test only runs on Linux with process_vm_readv support",
+    )
     def test_async_gather_remote_stack_trace(self):
         # Spawn a process with some realistic Python code
         port = find_unused_port()
-        script = textwrap.dedent(f"""\
+        script = textwrap.dedent(
+            f"""\
             import asyncio
             import time
             import sys
@@ -293,7 +381,8 @@ async def main():
                 await asyncio.gather(c1(), c2())
 
             asyncio.run(main())
-            """)
+            """
+        )
         stack_trace = None
         with os_helper.temp_dir() as work_dir:
             script_dir = os.path.join(work_dir, "script_pkg")
@@ -301,10 +390,10 @@ async def main():
             # Create a socket server to communicate with the target process
             server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
             server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
-            server_socket.bind(('localhost', port))
+            server_socket.bind(("localhost", port))
             server_socket.settimeout(SHORT_TIMEOUT)
             server_socket.listen(1)
-            script_name = _make_test_script(script_dir, 'script', script)
+            script_name = _make_test_script(script_dir, "script", script)
             client_socket = None
             try:
                 p = subprocess.Popen([sys.executable, script_name])
@@ -314,8 +403,7 @@ async def main():
                 self.assertEqual(response, b"ready")
                 stack_trace = get_async_stack_trace(p.pid)
             except PermissionError:
-                self.skipTest(
-                    "Insufficient permissions to read the stack trace")
+                self.skipTest("Insufficient permissions to read the stack trace")
             finally:
                 if client_socket is not None:
                     client_socket.close()
@@ -326,18 +414,23 @@ async def main():
             # sets are unordered, so we want to sort "awaited_by"s
             stack_trace[2].sort(key=lambda x: x[1])
 
-            expected_stack_trace =  [
-                ['deep', 'c1'], 'Task-2', [[['main'], 'Task-1', []]]
+            expected_stack_trace = [
+                [("deep", script_name, ANY), ("c1", script_name, 16)],
+                "Task-2",
+                [[[("main", script_name, 22)], "Task-1", []]],
             ]
             self.assertEqual(stack_trace, expected_stack_trace)
 
     @skip_if_not_supported
-    @unittest.skipIf(sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED,
-                     "Test only runs on Linux with process_vm_readv support")
+    @unittest.skipIf(
+        sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED,
+        "Test only runs on Linux with process_vm_readv support",
+    )
     def test_async_staggered_race_remote_stack_trace(self):
         # Spawn a process with some realistic Python code
         port = find_unused_port()
-        script = textwrap.dedent(f"""\
+        script = textwrap.dedent(
+            f"""\
             import asyncio.staggered
             import time
             import sys
@@ -365,7 +458,8 @@ async def main():
                 )
 
             asyncio.run(main())
-            """)
+            """
+        )
         stack_trace = None
         with os_helper.temp_dir() as work_dir:
             script_dir = os.path.join(work_dir, "script_pkg")
@@ -373,10 +467,10 @@ async def main():
             # Create a socket server to communicate with the target process
             server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
             server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
-            server_socket.bind(('localhost', port))
+            server_socket.bind(("localhost", port))
             server_socket.settimeout(SHORT_TIMEOUT)
             server_socket.listen(1)
-            script_name = _make_test_script(script_dir, 'script', script)
+            script_name = _make_test_script(script_dir, "script", script)
             client_socket = None
             try:
                 p = subprocess.Popen([sys.executable, script_name])
@@ -386,8 +480,7 @@ async def main():
                 self.assertEqual(response, b"ready")
                 stack_trace = get_async_stack_trace(p.pid)
             except PermissionError:
-                self.skipTest(
-                    "Insufficient permissions to read the stack trace")
+                self.skipTest("Insufficient permissions to read the stack trace")
             finally:
                 if client_socket is not None:
                     client_socket.close()
@@ -397,20 +490,35 @@ async def main():
 
             # sets are unordered, so we want to sort "awaited_by"s
             stack_trace[2].sort(key=lambda x: x[1])
-
             expected_stack_trace = [
-                ['deep', 'c1', 'run_one_coro'],
-                    'Task-2',
-                    [[['staggered_race', 'main'], 'Task-1', []]]
+                [
+                    ("deep", script_name, ANY),
+                    ("c1", script_name, 16),
+                    ("staggered_race.<locals>.run_one_coro", staggered.__file__, ANY),
+                ],
+                "Task-2",
+                [
+                    [
+                        [
+                            ("staggered_race", staggered.__file__, ANY),
+                            ("main", script_name, 22),
+                        ],
+                        "Task-1",
+                        [],
+                    ]
+                ],
             ]
             self.assertEqual(stack_trace, expected_stack_trace)
 
     @skip_if_not_supported
-    @unittest.skipIf(sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED,
-                     "Test only runs on Linux with process_vm_readv support")
+    @unittest.skipIf(
+        sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED,
+        "Test only runs on Linux with process_vm_readv support",
+    )
     def test_async_global_awaited_by(self):
         port = find_unused_port()
-        script = textwrap.dedent(f"""\
+        script = textwrap.dedent(
+            f"""\
             import asyncio
             import os
             import random
@@ -475,7 +583,8 @@ async def main():
                         tg.create_task(echo_client_spam(server), name="echo client spam")
 
             asyncio.run(main())
-            """)
+            """
+        )
         stack_trace = None
         with os_helper.temp_dir() as work_dir:
             script_dir = os.path.join(work_dir, "script_pkg")
@@ -483,10 +592,10 @@ async def main():
             # Create a socket server to communicate with the target process
             server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
             server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
-            server_socket.bind(('localhost', port))
+            server_socket.bind(("localhost", port))
             server_socket.settimeout(SHORT_TIMEOUT)
             server_socket.listen(1)
-            script_name = _make_test_script(script_dir, 'script', script)
+            script_name = _make_test_script(script_dir, "script", script)
             client_socket = None
             try:
                 p = subprocess.Popen([sys.executable, script_name])
@@ -506,7 +615,9 @@ async def main():
                         msg = str(re)
                         if msg.startswith("Task list appears corrupted"):
                             continue
-                        elif msg.startswith("Invalid linked list structure reading remote memory"):
+                        elif msg.startswith(
+                            "Invalid linked list structure reading remote memory"
+                        ):
                             continue
                         elif msg.startswith("Unknown error reading memory"):
                             continue
@@ -525,22 +636,62 @@ async def main():
                 # expected: at least 1000 pending tasks
                 self.assertGreaterEqual(len(entries), 1000)
                 # the first three tasks stem from the code structure
-                self.assertIn((ANY, 'Task-1', []), entries)
-                self.assertIn((ANY, 'server task', [[['_aexit', '__aexit__', 'main'], ANY]]), entries)
-                self.assertIn((ANY, 'echo client spam', [[['_aexit', '__aexit__', 'main'], ANY]]), entries)
+                self.assertIn((ANY, "Task-1", []), entries)
+                main_stack = [
+                    (
+                        "TaskGroup._aexit",
+                        taskgroups.__file__,
+                        ANY,
+                    ),
+                    (
+                        "TaskGroup.__aexit__",
+                        taskgroups.__file__,
+                        ANY,
+                    ),
+                    ("main", script_name, 60),
+                ]
+                self.assertIn(
+                    (ANY, "server task", [[main_stack, ANY]]),
+                    entries,
+                )
+                self.assertIn(
+                    (ANY, "echo client spam", [[main_stack, ANY]]),
+                    entries,
+                )
 
-                expected_stack = [[['_aexit', '__aexit__', 'echo_client_spam'], ANY]]
-                tasks_with_stack = [task for task in entries if task[2] == expected_stack]
+                expected_stack = [
+                    [
+                        [
+                            (
+                                "TaskGroup._aexit",
+                                taskgroups.__file__,
+                                ANY,
+                            ),
+                            (
+                                "TaskGroup.__aexit__",
+                                taskgroups.__file__,
+                                ANY,
+                            ),
+                            ("echo_client_spam", script_name, 41),
+                        ],
+                        ANY,
+                    ]
+                ]
+                tasks_with_stack = [
+                    task for task in entries if task[2] == expected_stack
+                ]
                 self.assertGreaterEqual(len(tasks_with_stack), 1000)
 
                 # the final task will have some random number, but it should for
                 # sure be one of the echo client spam horde (In windows this is not true
                 # for some reason)
                 if sys.platform != "win32":
-                    self.assertEqual([[['_aexit', '__aexit__', 'echo_client_spam'], ANY]], entries[-1][2])
+                    self.assertEqual(
+                        expected_stack,
+                        entries[-1][2],
+                    )
             except PermissionError:
-                self.skipTest(
-                    "Insufficient permissions to read the stack trace")
+                self.skipTest("Insufficient permissions to read the stack trace")
             finally:
                 if client_socket is not None:
                     client_socket.close()
@@ -549,11 +700,21 @@ async def main():
                 p.wait(timeout=SHORT_TIMEOUT)
 
     @skip_if_not_supported
-    @unittest.skipIf(sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED,
-                     "Test only runs on Linux with process_vm_readv support")
+    @unittest.skipIf(
+        sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED,
+        "Test only runs on Linux with process_vm_readv support",
+    )
     def test_self_trace(self):
         stack_trace = get_stack_trace(os.getpid())
-        self.assertEqual(stack_trace[0], "test_self_trace")
+        self.assertEqual(
+            stack_trace[0],
+            (
+                "TestGetStackTrace.test_self_trace",
+                __file__,
+                self.test_self_trace.__code__.co_firstlineno + 6,
+            ),
+        )
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/Modules/_remotedebuggingmodule.c b/Modules/_remotedebuggingmodule.c
index 0e055ae1604d5f..cffa9a38331dde 100644
--- a/Modules/_remotedebuggingmodule.c
+++ b/Modules/_remotedebuggingmodule.c
@@ -80,37 +80,6 @@ _Py_RemoteDebug_GetAsyncioDebugAddress(proc_handle_t* handle)
     return address;
 }
 
-static int
-read_string(
-    proc_handle_t *handle,
-    _Py_DebugOffsets* debug_offsets,
-    uintptr_t address,
-    char* buffer,
-    Py_ssize_t size
-) {
-    Py_ssize_t len;
-    int result = _Py_RemoteDebug_ReadRemoteMemory(
-        handle,
-        address + debug_offsets->unicode_object.length,
-        sizeof(Py_ssize_t),
-        &len
-    );
-    if (result < 0) {
-        return -1;
-    }
-    if (len >= size) {
-        PyErr_SetString(PyExc_RuntimeError, "Buffer too small");
-        return -1;
-    }
-    size_t offset = debug_offsets->unicode_object.asciiobject_size;
-    result = _Py_RemoteDebug_ReadRemoteMemory(handle, address + offset, len, buffer);
-    if (result < 0) {
-        return -1;
-    }
-    buffer[len] = '\0';
-    return 0;
-}
-
 static inline int
 read_ptr(proc_handle_t *handle, uintptr_t address, uintptr_t *ptr_addr)
 {
@@ -188,20 +157,34 @@ read_py_str(
     uintptr_t address,
     Py_ssize_t max_len
 ) {
-    assert(max_len > 0);
-
     PyObject *result = NULL;
+    char *buf = NULL;
+
+    Py_ssize_t len;
+    int res = _Py_RemoteDebug_ReadRemoteMemory(
+        handle,
+        address + debug_offsets->unicode_object.length,
+        sizeof(Py_ssize_t),
+        &len
+    );
+    if (res < 0) {
+        goto err;
+    }
 
-    char *buf = (char *)PyMem_RawMalloc(max_len);
+    buf = (char *)PyMem_RawMalloc(len+1);
     if (buf == NULL) {
         PyErr_NoMemory();
         return NULL;
     }
-    if (read_string(handle, debug_offsets, address, buf, max_len)) {
+
+    size_t offset = debug_offsets->unicode_object.asciiobject_size;
+    res = _Py_RemoteDebug_ReadRemoteMemory(handle, address + offset, len, buf);
+    if (res < 0) {
         goto err;
     }
+    buf[len] = '\0';
 
-    result = PyUnicode_FromString(buf);
+    result = PyUnicode_FromStringAndSize(buf, len);
     if (result == NULL) {
         goto err;
     }
@@ -211,10 +194,63 @@ read_py_str(
     return result;
 
 err:
+    if (buf != NULL) {
+        PyMem_RawFree(buf);
+    }
+    return NULL;
+}
+
+static PyObject *
+read_py_bytes(
+    proc_handle_t *handle,
+    _Py_DebugOffsets* debug_offsets,
+    uintptr_t address
+) {
+    PyObject *result = NULL;
+    char *buf = NULL;
+
+    Py_ssize_t len;
+    int res = _Py_RemoteDebug_ReadRemoteMemory(
+        handle,
+        address + debug_offsets->bytes_object.ob_size,
+        sizeof(Py_ssize_t),
+        &len
+    );
+    if (res < 0) {
+        goto err;
+    }
+
+    buf = (char *)PyMem_RawMalloc(len+1);
+    if (buf == NULL) {
+        PyErr_NoMemory();
+        return NULL;
+    }
+
+    size_t offset = debug_offsets->bytes_object.ob_sval;
+    res = _Py_RemoteDebug_ReadRemoteMemory(handle, address + offset, len, buf);
+    if (res < 0) {
+        goto err;
+    }
+    buf[len] = '\0';
+
+    result = PyBytes_FromStringAndSize(buf, len);
+    if (result == NULL) {
+        goto err;
+    }
+
     PyMem_RawFree(buf);
+    assert(result != NULL);
+    return result;
+
+err:
+    if (buf != NULL) {
+        PyMem_RawFree(buf);
+    }
     return NULL;
 }
 
+
+
 static long
 read_py_long(proc_handle_t *handle, _Py_DebugOffsets* offsets, uintptr_t address)
 {
@@ -332,6 +368,15 @@ parse_task_name(
     );
 }
 
+static int
+parse_frame_object(
+    proc_handle_t *handle,
+    PyObject** result,
+    struct _Py_DebugOffsets* offsets,
+    uintptr_t address,
+    uintptr_t* previous_frame
+);
+
 static int
 parse_coro_chain(
     proc_handle_t *handle,
@@ -351,22 +396,16 @@ parse_coro_chain(
         return -1;
     }
 
-    uintptr_t gen_name_addr;
-    err = read_py_ptr(
-        handle,
-        coro_address + offsets->gen_object.gi_name,
-        &gen_name_addr);
-    if (err) {
-        return -1;
-    }
-
-    PyObject *name = read_py_str(
-        handle,
-        offsets,
-        gen_name_addr,
-        255
-    );
-    if (name == NULL) {
+    PyObject* name = NULL;
+    uintptr_t prev_frame;
+    if (parse_frame_object(
+                handle,
+                &name,
+                offsets,
+                coro_address + offsets->gen_object.gi_iframe,
+                &prev_frame)
+        < 0)
+    {
         return -1;
     }
 
@@ -743,49 +782,204 @@ parse_task_awaited_by(
     return 0;
 }
 
+typedef struct
+{
+    int lineno;
+    int end_lineno;
+    int column;
+    int end_column;
+} LocationInfo;
+
+static int
+scan_varint(const uint8_t **ptr)
+{
+    unsigned int read = **ptr;
+    *ptr = *ptr + 1;
+    unsigned int val = read & 63;
+    unsigned int shift = 0;
+    while (read & 64) {
+        read = **ptr;
+        *ptr = *ptr + 1;
+        shift += 6;
+        val |= (read & 63) << shift;
+    }
+    return val;
+}
+
 static int
-parse_code_object(
-    proc_handle_t *handle,
-    PyObject* result,
-    struct _Py_DebugOffsets* offsets,
-    uintptr_t address,
-    uintptr_t* previous_frame
-) {
-    uintptr_t address_of_function_name;
-    int bytes_read = _Py_RemoteDebug_ReadRemoteMemory(
+scan_signed_varint(const uint8_t **ptr)
+{
+    unsigned int uval = scan_varint(ptr);
+    if (uval & 1) {
+        return -(int)(uval >> 1);
+    }
+    else {
+        return uval >> 1;
+    }
+}
+
+
+static bool
+parse_linetable(const uintptr_t addrq, const char* linetable, int firstlineno, LocationInfo* info)
+{
+    const uint8_t* ptr = (const uint8_t*)(linetable);
+    uint64_t addr = 0;
+    info->lineno = firstlineno;
+
+    while (*ptr != '\0') {
+        // See InternalDocs/code_objects.md for where these magic numbers are from
+        // and for the decoding algorithm.
+        uint8_t first_byte = *(ptr++);
+        uint8_t code = (first_byte >> 3) & 15;
+        size_t length = (first_byte & 7) + 1;
+        uintptr_t end_addr = addr + length;
+        switch (code) {
+            case PY_CODE_LOCATION_INFO_NONE: {
+                break;
+            }
+            case PY_CODE_LOCATION_INFO_LONG: {
+                int line_delta = scan_signed_varint(&ptr);
+                info->lineno += line_delta;
+                info->end_lineno = info->lineno + scan_varint(&ptr);
+                info->column = scan_varint(&ptr) - 1;
+                info->end_column = scan_varint(&ptr) - 1;
+                break;
+            }
+            case PY_CODE_LOCATION_INFO_NO_COLUMNS: {
+                int line_delta = scan_signed_varint(&ptr);
+                info->lineno += line_delta;
+                info->column = info->end_column = -1;
+                break;
+            }
+            case PY_CODE_LOCATION_INFO_ONE_LINE0:
+            case PY_CODE_LOCATION_INFO_ONE_LINE1:
+            case PY_CODE_LOCATION_INFO_ONE_LINE2: {
+                int line_delta = code - 10;
+                info->lineno += line_delta;
+                info->end_lineno = info->lineno;
+                info->column = *(ptr++);
+                info->end_column = *(ptr++);
+                break;
+            }
+            default: {
+                uint8_t second_byte = *(ptr++);
+                assert((second_byte & 128) == 0);
+                info->column = code << 3 | (second_byte >> 4);
+                info->end_column = info->column + (second_byte & 15);
+                break;
+            }
+        }
+        if (addr <= addrq && end_addr > addrq) {
+            return true;
+        }
+        addr = end_addr;
+    }
+    return false;
+}
+
+static int
+read_remote_pointer(proc_handle_t *handle, uintptr_t address, uintptr_t *out_ptr, const char *error_message)
+{
+    int bytes_read = _Py_RemoteDebug_ReadRemoteMemory(handle, address, sizeof(void *), out_ptr);
+    if (bytes_read < 0) {
+        return -1;
+    }
+
+    if ((void *)(*out_ptr) == NULL) {
+        PyErr_SetString(PyExc_RuntimeError, error_message);
+        return -1;
+    }
+
+    return 0;
+}
+
+static int
+read_instruction_ptr(proc_handle_t *handle, struct _Py_DebugOffsets *offsets,
+                     uintptr_t current_frame, uintptr_t *instruction_ptr)
+{
+    return read_remote_pointer(
         handle,
-        address + offsets->code_object.name,
-        sizeof(void*),
-        &address_of_function_name
+        current_frame + offsets->interpreter_frame.instr_ptr,
+        instruction_ptr,
+        "No instruction ptr found"
     );
-    if (bytes_read < 0) {
+}
+
+static int
+parse_code_object(proc_handle_t *handle,
+                  PyObject **result,
+                  struct _Py_DebugOffsets *offsets,
+                  uintptr_t address,
+                  uintptr_t current_frame,
+                  uintptr_t *previous_frame)
+{
+    uintptr_t addr_func_name, addr_file_name, addr_linetable, instruction_ptr;
+
+    if (read_remote_pointer(handle, address + offsets->code_object.qualname, &addr_func_name, "No function name found") < 0 ||
+        read_remote_pointer(handle, address + offsets->code_object.filename, &addr_file_name, "No file name found") < 0 ||
+        read_remote_pointer(handle, address + offsets->code_object.linetable, &addr_linetable, "No linetable found") < 0 ||
+        read_instruction_ptr(handle, offsets, current_frame, &instruction_ptr) < 0) {
         return -1;
     }
 
-    if ((void*)address_of_function_name == NULL) {
-        PyErr_SetString(PyExc_RuntimeError, "No function name found");
+    int firstlineno;
+    if (_Py_RemoteDebug_ReadRemoteMemory(handle,
+                                         address + offsets->code_object.firstlineno,
+                                         sizeof(int),
+                                         &firstlineno) < 0) {
         return -1;
     }
 
-    PyObject* py_function_name = read_py_str(
-        handle, offsets, address_of_function_name, 256);
-    if (py_function_name == NULL) {
+    PyObject *py_linetable = read_py_bytes(handle, offsets, addr_linetable);
+    if (!py_linetable) {
+        return -1;
+    }
+
+    uintptr_t addr_code_adaptive = address + offsets->code_object.co_code_adaptive;
+    ptrdiff_t addrq = (uint16_t *)instruction_ptr - (uint16_t *)addr_code_adaptive;
+
+    LocationInfo info;
+    parse_linetable(addrq, PyBytes_AS_STRING(py_linetable), firstlineno, &info);
+    Py_DECREF(py_linetable);  // Done with linetable
+
+    PyObject *py_line = PyLong_FromLong(info.lineno);
+    if (!py_line) {
         return -1;
     }
 
-    if (PyList_Append(result, py_function_name) == -1) {
-        Py_DECREF(py_function_name);
+    PyObject *py_func_name = read_py_str(handle, offsets, addr_func_name, 256);
+    if (!py_func_name) {
+        Py_DECREF(py_line);
         return -1;
     }
-    Py_DECREF(py_function_name);
 
+    PyObject *py_file_name = read_py_str(handle, offsets, addr_file_name, 256);
+    if (!py_file_name) {
+        Py_DECREF(py_line);
+        Py_DECREF(py_func_name);
+        return -1;
+    }
+
+    PyObject *result_tuple = PyTuple_New(3);
+    if (!result_tuple) {
+        Py_DECREF(py_line);
+        Py_DECREF(py_func_name);
+        Py_DECREF(py_file_name);
+        return -1;
+    }
+
+    PyTuple_SET_ITEM(result_tuple, 0, py_func_name);  // steals ref
+    PyTuple_SET_ITEM(result_tuple, 1, py_file_name);  // steals ref
+    PyTuple_SET_ITEM(result_tuple, 2, py_line);       // steals ref
+
+    *result = result_tuple;
     return 0;
 }
 
 static int
 parse_frame_object(
     proc_handle_t *handle,
-    PyObject* result,
+    PyObject** result,
     struct _Py_DebugOffsets* offsets,
     uintptr_t address,
     uintptr_t* previous_frame
@@ -826,13 +1020,13 @@ parse_frame_object(
     }
 
     return parse_code_object(
-        handle, result, offsets, address_of_code_object, previous_frame);
+        handle, result, offsets, address_of_code_object, address, previous_frame);
 }
 
 static int
 parse_async_frame_object(
     proc_handle_t *handle,
-    PyObject* result,
+    PyObject** result,
     struct _Py_DebugOffsets* offsets,
     uintptr_t address,
     uintptr_t* previous_frame,
@@ -882,7 +1076,7 @@ parse_async_frame_object(
     }
 
     if (parse_code_object(
-        handle, result, offsets, *code_object, previous_frame)) {
+        handle, result, offsets, *code_object, address, previous_frame)) {
         return -1;
     }
 
@@ -1353,9 +1547,10 @@ get_stack_trace(PyObject* self, PyObject* args)
     }
 
     while ((void*)address_of_current_frame != NULL) {
+        PyObject* frame_info = NULL;
         if (parse_frame_object(
                     handle,
-                    result,
+                    &frame_info,
                     &local_debug_offsets,
                     address_of_current_frame,
                     &address_of_current_frame)
@@ -1364,6 +1559,19 @@ get_stack_trace(PyObject* self, PyObject* args)
             Py_DECREF(result);
             goto result_err;
         }
+
+        if (!frame_info) {
+            continue;
+        }
+
+        if (PyList_Append(result, frame_info) == -1) {
+            Py_DECREF(result);
+            goto result_err;
+        }
+
+        Py_DECREF(frame_info);
+        frame_info = NULL;
+
     }
 
 result_err:
@@ -1485,9 +1693,10 @@ get_async_stack_trace(PyObject* self, PyObject* args)
 
     uintptr_t address_of_code_object;
     while ((void*)address_of_current_frame != NULL) {
+        PyObject* frame_info = NULL;
         int res = parse_async_frame_object(
             handle,
-            calls,
+            &frame_info,
             &local_debug_offsets,
             address_of_current_frame,
             &address_of_current_frame,
@@ -1499,6 +1708,18 @@ get_async_stack_trace(PyObject* self, PyObject* args)
             goto result_err;
         }
 
+        if (!frame_info) {
+            continue;
+        }
+
+        if (PyList_Append(calls, frame_info) == -1) {
+            Py_DECREF(calls);
+            goto result_err;
+        }
+
+        Py_DECREF(frame_info);
+        frame_info = NULL;
+
         if (address_of_code_object == address_of_running_task_code_obj) {
             break;
         }


Reviews:
user: login: 1st1
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-04T16:37:52Z
user: login: 1st1
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-04T16:39:29Z
user: login: 1st1
body: Merry x-mas! This is very cool. Left a couple of nit comments, looks great otherwise! <3
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-04T16:39:56Z
user: login: pablogsal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-04T16:44:59Z
user: login: pablogsal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-04T17:21:43Z
user: login: pablogsal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-04T17:32:29Z

Review comments:
diff_hunk: @@ -743,18 +799,113 @@ parse_task_awaited_by(
     return 0;
 }
 
+typedef struct 
+{
+    int lineno;
+    int end_lineno;
+    int column;
+    int end_column;
+} LocationInfo;
+
+static int
+scan_varint(const uint8_t **ptr)
+{
+    unsigned int read = **ptr;
+    *ptr = *ptr + 1;
+    unsigned int val = read & 63;
+    unsigned int shift = 0;
+    while (read & 64) {
+        read = **ptr;
+        *ptr = *ptr + 1;
+        shift += 6;
+        val |= (read & 63) << shift;
+    }
+    return val;
+}
+
+static int
+scan_signed_varint(const uint8_t **ptr)
+{
+    unsigned int uval = scan_varint(ptr);
+    if (uval & 1) {
+        return -(int)(uval >> 1);
+    }
+    else {
+        return uval >> 1;
+    }
+}
+
+
+static bool
+parse_linetable(const uintptr_t addrq, const char* linetable, int firstlineno, LocationInfo* info)
+{
+    const uint8_t* ptr = (const uint8_t*)(linetable);
+    uint64_t addr = 0;
+    info->lineno = firstlineno;
+
+    while (*ptr != '\0') {
+        uint8_t first_byte = *(ptr++);
+        uint8_t code = (first_byte >> 3) & 15;
path: Modules/_remotedebuggingmodule.c
user: login: 1st1
body: Having some comments here would be great -- why shifting `3` and what's `15`. Ditto for other "magic" numbers
created_at: 2025-05-04T16:37:52Z
author_association: MEMBER
diff_hunk: @@ -90,10 +91,10 @@ def foo():
 
 
             expected_stack_trace = [
-                'foo',
-                'baz',
-                'bar',
-                '<module>'
+                ('foo', script_name, 15),
path: Lib/test/test_external_inspection.py
user: login: 1st1
body: This will be painful to maintain. I suggest importing those functions in `test_external_inspection.py` and extracting their lineno here so `15` becomes `t.script_name.__code__.co_fileno` or `lineno(t.script_name)` if you fancy a helper function
created_at: 2025-05-04T16:39:29Z
author_association: MEMBER
diff_hunk: @@ -90,10 +91,10 @@ def foo():
 
 
             expected_stack_trace = [
-                'foo',
-                'baz',
-                'bar',
-                '<module>'
+                ('foo', script_name, 15),
path: Lib/test/test_external_inspection.py
user: login: pablogsal
body: But the functions are in some random temporary script that we execute in a subprocess. Importing it in the test suite is super painful :(
created_at: 2025-05-04T16:44:59Z
author_association: MEMBER
diff_hunk: @@ -743,18 +799,113 @@ parse_task_awaited_by(
     return 0;
 }
 
+typedef struct 
+{
+    int lineno;
+    int end_lineno;
+    int column;
+    int end_column;
+} LocationInfo;
+
+static int
+scan_varint(const uint8_t **ptr)
+{
+    unsigned int read = **ptr;
+    *ptr = *ptr + 1;
+    unsigned int val = read & 63;
+    unsigned int shift = 0;
+    while (read & 64) {
+        read = **ptr;
+        *ptr = *ptr + 1;
+        shift += 6;
+        val |= (read & 63) << shift;
+    }
+    return val;
+}
+
+static int
+scan_signed_varint(const uint8_t **ptr)
+{
+    unsigned int uval = scan_varint(ptr);
+    if (uval & 1) {
+        return -(int)(uval >> 1);
+    }
+    else {
+        return uval >> 1;
+    }
+}
+
+
+static bool
+parse_linetable(const uintptr_t addrq, const char* linetable, int firstlineno, LocationInfo* info)
+{
+    const uint8_t* ptr = (const uint8_t*)(linetable);
+    uint64_t addr = 0;
+    info->lineno = firstlineno;
+
+    while (*ptr != '\0') {
+        uint8_t first_byte = *(ptr++);
+        uint8_t code = (first_byte >> 3) & 15;
path: Modules/_remotedebuggingmodule.c
user: login: pablogsal
body: 👍 
created_at: 2025-05-04T17:21:42Z
author_association: MEMBER
diff_hunk: @@ -27,6 +27,7 @@ def _index(result):
         for tid, tname, awaited in tasks:
             id2name[tid] = tname
             for stack, parent_id in awaited:
+                stack = [elem[0] if isinstance(elem, tuple) else elem for elem in stack]
path: Lib/asyncio/tools.py
user: login: pablogsal
body: @ambv This code just returns the fully qualified name. Play a bit with this and decide how we want to format this in the tool
created_at: 2025-05-04T17:32:29Z
author_association: MEMBER

PR comments:
user: login: pablogsal
created_at: 2025-05-04T17:37:43Z
body: No ref leaks:


```
.venv ❯ sudo -E ./python.exe -m test test_external_inspection -R :
Using random seed: 1218919259
Raised RLIMIT_NOFILE: 256 -> 1024
0:00:00 load avg: 9.64 Run 1 test sequentially in a single process
0:00:00 load avg: 9.64 [1/1] test_external_inspection
beginning 9 repetitions. Showing number of leaks (. for 0 or less, X for 10 or more)
12345:6789
XX... ....
0:00:47 load avg: 6.37 [1/1] test_external_inspection passed in 47.2 sec

== Tests result: SUCCESS ==

1 test OK.

Total duration: 47.2 sec
Total tests: run=7
Total test files: run=1/1
Result: SUCCESS
```
author_association: MEMBER
user: login: bedevere-bot
created_at: 2025-05-04T17:38:05Z
body: :robot: New build scheduled with the buildbot fleet by @pablogsal for commit eabbf17bbe96393401c7a37bd892ec8888ec83a3 :robot:

Results will be shown at:

https://buildbot.python.org/all/#/grid?branch=refs%2Fpull%2F133385%2Fmerge

If you want to schedule another build, you need to add the <kbd>:hammer: test-with-buildbots</kbd> label again.

author_association: NONE