Skip to content

Commit 6ff8ddc

Browse files
committed
fixup! Merge branch 'main' into remote-debugger-empty-stack-exc
1 parent 3af0f69 commit 6ff8ddc

5 files changed

Lines changed: 156 additions & 47 deletions

File tree

Lib/profiling/sampling/binary_collector.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,6 @@ def collect(self, stack_frames, timestamp_us=None):
8181
"""
8282
if timestamp_us is None:
8383
timestamp_us = int(time.monotonic() * 1_000_000)
84-
if any(
85-
not thread_info.frame_info
86-
for interpreter_info in stack_frames
87-
for thread_info in interpreter_info.threads
88-
):
89-
return
9084
self._writer.write_sample(stack_frames, timestamp_us)
9185

9286
def collect_failed_sample(self):

Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py

Lines changed: 113 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -810,34 +810,84 @@ def test_invalid_file_path(self):
810810
with BinaryReader("/nonexistent/path/file.bin") as reader:
811811
reader.replay_samples(RawCollector())
812812

813-
def test_binary_collector_skips_samples_with_empty_stacks(self):
814-
"""BinaryCollector skips samples that contain empty stacks."""
815-
frame = make_frame("a.py", 1, "f")
816-
samples = [
817-
[
818-
make_interpreter(
819-
0, [make_thread(99, [], status=THREAD_STATUS_UNKNOWN)]
820-
)
821-
],
822-
[
823-
make_interpreter(
824-
0,
825-
[
826-
make_thread(1, [frame]),
827-
make_thread(99, [], status=THREAD_STATUS_UNKNOWN),
828-
],
829-
)
830-
],
831-
[make_interpreter(0, [make_thread(1, [frame])])],
813+
def test_writer_handles_empty_stack_first_sample(self):
814+
"""BinaryWriter.write_sample tolerates an empty stack on a fresh thread.
815+
816+
Regression test for the C-level RLE bug in process_thread_sample: a
817+
freshly-created ThreadEntry has prev_stack_depth == 0, so an empty
818+
curr_stack compares as STACK_REPEAT against the zero-initialized
819+
previous stack. Before the fix, this fell through the
820+
`&& !is_new_thread` guard into write_sample_with_encoding, which had
821+
no handler for STACK_REPEAT and raised
822+
RuntimeError("Invalid stack encoding type"). Goes through
823+
BinaryWriter.write_sample directly so the test cannot be masked by
824+
any Python-level filtering.
825+
"""
826+
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
827+
filename = f.name
828+
self.temp_files.append(filename)
829+
830+
writer = _remote_debugging.BinaryWriter(filename, 1000, 0, compression=0)
831+
empty_sample = [
832+
make_interpreter(
833+
0, [make_thread(99, [], status=THREAD_STATUS_UNKNOWN)]
834+
)
832835
]
833-
collector, count = self.roundtrip(samples)
834-
self.assertEqual(count, 1)
835-
self.assert_samples_equal(
836-
[[make_interpreter(0, [make_thread(1, [frame])])]], collector
837-
)
836+
# First sample for a fresh thread has empty frame_info — the exact
837+
# scenario that exposes the bug.
838+
writer.write_sample(empty_sample, 1000)
839+
writer.write_sample(empty_sample, 2000)
840+
# Mix in a real sample to exercise the transition out of the
841+
# empty-stack RLE buffer.
842+
real_sample = [
843+
make_interpreter(0, [make_thread(1, [make_frame("a.py", 1, "f")])])
844+
]
845+
writer.write_sample(real_sample, 3000)
846+
writer.finalize()
847+
848+
reader_collector = RawCollector()
849+
with BinaryReader(filename) as reader:
850+
count = reader.replay_samples(reader_collector)
851+
# Empty-stack samples are recorded as STACK_REPEAT records with
852+
# depth-0 stacks; the file must replay all three samples.
853+
self.assertEqual(count, 3)
854+
855+
def test_writer_handles_mixed_empty_and_real_first_sample(self):
856+
"""First sample with one empty + one real thread roundtrips through C."""
857+
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
858+
filename = f.name
859+
self.temp_files.append(filename)
860+
861+
writer = _remote_debugging.BinaryWriter(filename, 1000, 0, compression=0)
862+
sample = [
863+
make_interpreter(
864+
0,
865+
[
866+
make_thread(1, [make_frame("a.py", 1, "f")]),
867+
make_thread(99, [], status=THREAD_STATUS_UNKNOWN),
868+
],
869+
)
870+
]
871+
# Two samples so RLE state is exercised.
872+
writer.write_sample(sample, 1000)
873+
writer.write_sample(sample, 2000)
874+
writer.finalize()
875+
876+
# Replay must succeed without raising RuntimeError, and the real
877+
# thread's frames must round-trip.
878+
reader_collector = RawCollector()
879+
with BinaryReader(filename) as reader:
880+
reader.replay_samples(reader_collector)
881+
self.assertIn((0, 1), reader_collector.by_thread)
882+
self.assertEqual(len(reader_collector.by_thread[(0, 1)]), 2)
838883

839884
def test_writer_total_samples_after_finalize_matches_reader(self):
840885
"""BinaryWriter.total_samples after finalize() matches the reader's count."""
886+
# Five IDENTICAL samples force every sample beyond the first into the
887+
# per-thread RLE buffer. Regression for the cached_total_samples
888+
# ordering bug: capturing the cache BEFORE binary_writer_finalize()
889+
# missed the buffered samples that flush_pending_rle() counts. Keep
890+
# the samples identical to preserve coverage. See gh-149342.
841891
samples = [
842892
[make_interpreter(0, [make_thread(1, [make_frame("a.py", 1, "f")])])]
843893
] * 5
@@ -848,6 +898,45 @@ def test_writer_total_samples_after_finalize_matches_reader(self):
848898
self.assertEqual(writer_collector.total_samples, len(samples))
849899
self.assertEqual(writer_collector.total_samples, replayed)
850900

901+
def test_writer_total_samples_after_context_manager_matches_reader(self):
902+
"""total_samples after `with BinaryWriter(...)` matches the reader's count.
903+
904+
Regression for the asymmetry between finalize() and __exit__ in
905+
module.c: __exit__ also calls binary_writer_finalize and must
906+
preserve cached_total_samples like finalize() does, otherwise the
907+
getter returns 0 once self->writer is NULL.
908+
"""
909+
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
910+
filename = f.name
911+
self.temp_files.append(filename)
912+
913+
sample = [
914+
make_interpreter(0, [make_thread(1, [make_frame("a.py", 1, "f")])])
915+
]
916+
with _remote_debugging.BinaryWriter(filename, 1000, 0, compression=0) as w:
917+
for i in range(5):
918+
w.write_sample(sample, i * 1000)
919+
self.assertEqual(w.total_samples, 5)
920+
921+
reader_collector = RawCollector()
922+
with BinaryReader(filename) as reader:
923+
self.assertEqual(reader.replay_samples(reader_collector), 5)
924+
925+
def test_writer_total_samples_after_close_returns_zero(self):
926+
"""close() discards data; total_samples reflects no cached count."""
927+
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
928+
filename = f.name
929+
self.temp_files.append(filename)
930+
931+
w = _remote_debugging.BinaryWriter(filename, 1000, 0, compression=0)
932+
sample = [
933+
make_interpreter(0, [make_thread(1, [make_frame("a.py", 1, "f")])])
934+
]
935+
for i in range(5):
936+
w.write_sample(sample, i * 1000)
937+
w.close()
938+
self.assertEqual(w.total_samples, 0)
939+
851940

852941
class TestBinaryEncodings(BinaryFormatTestBase):
853942
"""Tests specifically targeting different stack encodings."""
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
1-
Fix :mod:`!_remote_debugging` binary writing for threads with an empty stack.
1+
Fix :mod:`!_remote_debugging` binary writing so that sampling a thread
2+
whose Python frame stack is empty (for example while it is in a C call or
3+
mid-syscall) no longer raises ``RuntimeError("Invalid stack encoding
4+
type")``, and so that ``BinaryWriter.total_samples`` after :meth:`!finalize`
5+
or context-manager exit includes samples flushed from the RLE buffer.
26
Patch by Maurycy Pawłowski-Wieroński.

Modules/_remote_debugging/binary_io_writer.c

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,7 @@ writer_get_or_create_thread_entry(BinaryWriter *writer, uint64_t thread_id,
487487
entry->prev_stack_capacity = MAX_STACK_DEPTH;
488488
entry->pending_rle_capacity = INITIAL_RLE_CAPACITY;
489489

490-
entry->prev_stack = PyMem_Malloc(entry->prev_stack_capacity * sizeof(uint32_t));
490+
entry->prev_stack = PyMem_Calloc(entry->prev_stack_capacity, sizeof(uint32_t));
491491
if (!entry->prev_stack) {
492492
PyErr_NoMemory();
493493
return NULL;
@@ -941,9 +941,8 @@ process_thread_sample(BinaryWriter *writer, PyObject *thread_info,
941941
}
942942
uint8_t status = (uint8_t)status_long;
943943

944-
int is_new_thread = 0;
945944
ThreadEntry *entry = writer_get_or_create_thread_entry(
946-
writer, thread_id, interpreter_id, &is_new_thread);
945+
writer, thread_id, interpreter_id, NULL);
947946
if (!entry) {
948947
return -1;
949948
}
@@ -967,7 +966,14 @@ process_thread_sample(BinaryWriter *writer, PyObject *thread_info,
967966
&shared_count, &pop_count, &push_count);
968967

969968
if (encoding == STACK_REPEAT) {
970-
/* Buffer this sample for RLE */
969+
/* Buffer this sample for RLE.
970+
*
971+
* STACK_REPEAT also covers the "first sample for a fresh thread,
972+
* empty stack" case: a new ThreadEntry has prev_stack_depth == 0
973+
* and a zero-initialized prev_stack, so compare_stacks() returns
974+
* STACK_REPEAT against an empty curr_stack (depth 0). Buffering
975+
* it here is correct; the RLE flush path emits it as a normal
976+
* STACK_REPEAT record. */
971977
if (GROW_ARRAY(entry->pending_rle, entry->pending_rle_count,
972978
entry->pending_rle_capacity, PendingRLESample) < 0) {
973979
return -1;

Modules/_remote_debugging/module.c

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1552,6 +1552,24 @@ Finalize and close the binary file.
15521552
Writes string/frame tables, footer, and updates header.
15531553
[clinic start generated code]*/
15541554

1555+
/* Finalize the writer, cache total_samples, and destroy it.
1556+
*
1557+
* The cache assignment must happen AFTER binary_writer_finalize(): finalize
1558+
* flushes pending RLE samples via flush_pending_rle(), which increments
1559+
* writer->total_samples for each one. Caching before finalize would lose
1560+
* those trailing samples. */
1561+
static int
1562+
binary_writer_finalize_and_cache(BinaryWriterObject *self)
1563+
{
1564+
if (binary_writer_finalize(self->writer) < 0) {
1565+
return -1;
1566+
}
1567+
self->cached_total_samples = self->writer->total_samples;
1568+
binary_writer_destroy(self->writer);
1569+
self->writer = NULL;
1570+
return 0;
1571+
}
1572+
15551573
static PyObject *
15561574
_remote_debugging_BinaryWriter_finalize_impl(BinaryWriterObject *self)
15571575
/*[clinic end generated code: output=3534b88c6628de88 input=c02191750682f6a2]*/
@@ -1561,16 +1579,10 @@ _remote_debugging_BinaryWriter_finalize_impl(BinaryWriterObject *self)
15611579
return NULL;
15621580
}
15631581

1564-
if (binary_writer_finalize(self->writer) < 0) {
1582+
if (binary_writer_finalize_and_cache(self) < 0) {
15651583
return NULL;
15661584
}
15671585

1568-
/* Preserve total_samples before destroying the writer */
1569-
self->cached_total_samples = self->writer->total_samples;
1570-
1571-
binary_writer_destroy(self->writer);
1572-
self->writer = NULL;
1573-
15741586
Py_RETURN_NONE;
15751587
}
15761588

@@ -1624,14 +1636,18 @@ _remote_debugging_BinaryWriter___exit___impl(BinaryWriterObject *self,
16241636
if (self->writer) {
16251637
/* Only finalize on normal exit (no exception) */
16261638
if (exc_type == Py_None) {
1627-
if (binary_writer_finalize(self->writer) < 0) {
1628-
binary_writer_destroy(self->writer);
1629-
self->writer = NULL;
1639+
if (binary_writer_finalize_and_cache(self) < 0) {
1640+
if (self->writer) {
1641+
binary_writer_destroy(self->writer);
1642+
self->writer = NULL;
1643+
}
16301644
return NULL;
16311645
}
16321646
}
1633-
binary_writer_destroy(self->writer);
1634-
self->writer = NULL;
1647+
else {
1648+
binary_writer_destroy(self->writer);
1649+
self->writer = NULL;
1650+
}
16351651
}
16361652
Py_RETURN_FALSE;
16371653
}

0 commit comments

Comments
 (0)