Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Fix frame cache miss handling in RemoteUnwinder to return complete st…
…acks

When the frame walker stopped at a cached frame boundary but the cache lookup failed to find the continuation (e.g., when a new unwinder instance encounters stale last_profiled_frame values from a previous profiler), the code would silently return an incomplete stack. Now when a cache lookup misses, the walker continues from that point to collect the remaining frames, ensuring complete stack traces are always returned.
  • Loading branch information
pablogsal committed Dec 1, 2025
commit d2aa8a45dcee02ecf122c89f82b4d99f79999d3a
53 changes: 53 additions & 0 deletions Lib/test/test_external_inspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -2559,6 +2559,59 @@ def get_thread_frames(target_funcs):
# No cross-contamination
self.assertNotIn("blech1", t2_blech)

@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",
)
def test_new_unwinder_with_stale_last_profiled_frame(self):
"""Test that a new unwinder returns complete stack when cache lookup misses."""
script_body = """\
def level4():
sock.sendall(b"sync1")
sock.recv(16)
sock.sendall(b"sync2")
sock.recv(16)

def level3():
level4()

def level2():
level3()

def level1():
level2()

level1()
"""

with self._target_process(script_body) as (p, client_socket, make_unwinder):
expected = {"level1", "level2", "level3", "level4"}

# First unwinder samples - this sets last_profiled_frame in target
unwinder1 = make_unwinder(cache_frames=True)
frames1 = self._sample_frames(client_socket, unwinder1, b"sync1", b"ack", expected)

# Create NEW unwinder (empty cache) and sample
# The target still has last_profiled_frame set from unwinder1
unwinder2 = make_unwinder(cache_frames=True)
frames2 = self._sample_frames(client_socket, unwinder2, b"sync2", b"done", expected)

self.assertIsNotNone(frames1)
self.assertIsNotNone(frames2)

funcs1 = [f.funcname for f in frames1]
funcs2 = [f.funcname for f in frames2]

# Both should have all levels
for level in ["level1", "level2", "level3", "level4"]:
self.assertIn(level, funcs1, f"{level} missing from first sample")
self.assertIn(level, funcs2, f"{level} missing from second sample")

# Should have same stack depth
self.assertEqual(len(frames1), len(frames2),
"New unwinder should return complete stack despite stale last_profiled_frame")


if __name__ == "__main__":
unittest.main()
8 changes: 8 additions & 0 deletions Modules/_remote_debugging/frames.c
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,14 @@ collect_frames_with_cache(
Py_DECREF(frame_addresses);
return -1;
}
if (cache_result == 0) {
// Cache miss - continue walking from last_profiled_frame to get the rest
if (process_frame_chain(unwinder, last_profiled_frame, chunks, frame_info, gc_frame,
0, NULL, frame_addresses) < 0) {
Py_DECREF(frame_addresses);
return -1;
}
}
}

// Convert frame_addresses (list of PyLong) to C array for efficient cache storage
Expand Down