Skip to content

Commit 8a7c23f

Browse files
miss-islingtonmaurycypablogsal
authored
[3.15] gh-151292: _remote_debugging: Do not corrupt the binary file when hitting OverflowError (GH-152892) (#158830)
gh-151292: `_remote_debugging`: Do not corrupt the binary file when hitting `OverflowError` (GH-152892) * the kolektor * test * better test * news * =Q, move const to the base, not self.running * gh-151292: Track binary writer finalization state --------- (cherry picked from commit f839c06) Co-authored-by: Maurycy Pawłowski-Wieroński <maurycy@maurycy.com> Co-authored-by: Pablo Galindo Salgado <Pablogsal@gmail.com>
1 parent 0281240 commit 8a7c23f

6 files changed

Lines changed: 213 additions & 14 deletions

File tree

‎Lib/profiling/sampling/binary_collector.py‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Thin Python wrapper around C binary writer for profiling data."""
22

3+
import sys
34
import time
45

56
import _remote_debugging
@@ -81,6 +82,7 @@ def __init__(self, filename, sample_interval_usec, *, skip_idle=False,
8182
self.filename = filename
8283
self.sample_interval_usec = sample_interval_usec
8384
self.skip_idle = skip_idle
85+
self.running = True
8486

8587
compression_type = _resolve_compression(compression)
8688
start_time_us = int(time.monotonic() * 1_000_000)
@@ -102,9 +104,19 @@ def collect(self, stack_frames, timestamp_us=None):
102104
timestamp_us: Optional timestamp in microseconds. If not provided,
103105
uses time.monotonic() to generate one.
104106
"""
107+
if not self.running:
108+
return
105109
if timestamp_us is None:
106110
timestamp_us = int(time.monotonic() * 1_000_000)
107-
self._writer.write_sample(stack_frames, timestamp_us)
111+
try:
112+
self._writer.write_sample(stack_frames, timestamp_us)
113+
except OverflowError as e:
114+
if not self._writer.limit_reached:
115+
raise
116+
self.running = False
117+
print(f"Warning: {e}; stopping early and keeping the data "
118+
"collected so far.",
119+
file=sys.stderr)
108120

109121
def collect_failed_sample(self):
110122
"""Record a failed sample attempt (no-op for binary format)."""
@@ -143,9 +155,5 @@ def __enter__(self):
143155
return self
144156

145157
def __exit__(self, exc_type, exc_val, exc_tb):
146-
"""Context manager exit - finalize unless there was an error."""
147-
if exc_type is None:
148-
self._writer.finalize()
149-
else:
150-
self._writer.close()
151-
return False
158+
"""Finalize if the writer can still produce a valid file."""
159+
return self._writer.__exit__(exc_type, exc_val, exc_tb)

‎Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import unittest
1010
from collections import defaultdict
1111

12+
from test.support import captured_stderr
13+
1214
try:
1315
import _remote_debugging
1416
from _remote_debugging import (
@@ -1031,6 +1033,142 @@ def test_writer_total_samples_after_close_returns_zero(self):
10311033
w.close()
10321034
self.assertEqual(w.total_samples, 0)
10331035

1036+
def test_binary_collector_stops_gracefully_on_overflow(self):
1037+
"""OverflowError from the writer stops collection via the running
1038+
protocol instead of propagating and corrupting the file.
1039+
See gh-151292."""
1040+
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
1041+
filename = f.name
1042+
self.temp_files.append(filename)
1043+
1044+
collector = BinaryCollector(filename, 1000, compression="none")
1045+
self.assertTrue(collector.running)
1046+
1047+
sample = [
1048+
make_interpreter(0, [make_thread(1, [make_frame("a.py", 1, "f")])])
1049+
]
1050+
1051+
# Collect real samples first, then hit the limit.
1052+
for i in range(3):
1053+
collector.collect(sample, timestamp_us=(i + 1) * 1000)
1054+
self.assertTrue(collector.running)
1055+
1056+
bad = [make_interpreter(2**32, sample[0].threads)]
1057+
with captured_stderr() as stderr:
1058+
collector.collect(bad, timestamp_us=4000)
1059+
collector.collect(sample, timestamp_us=5000)
1060+
1061+
self.assertFalse(collector.running)
1062+
self.assertTrue(collector._writer.limit_reached)
1063+
self.assertEqual(stderr.getvalue().count("Warning:"), 1)
1064+
self.assertIn("interpreter_id", stderr.getvalue())
1065+
1066+
collector.export(None)
1067+
1068+
self.assertEqual(collector.total_samples, 3)
1069+
1070+
reader_collector = RawCollector()
1071+
with BinaryReader(filename) as reader:
1072+
self.assertEqual(reader.replay_samples(reader_collector), 3)
1073+
1074+
def test_interpreter_id_overflow_rejected(self):
1075+
"""An interpreter_id wider than u32 raises OverflowError before any
1076+
writer state is mutated: subsequent valid samples are still accepted
1077+
and finalize produces a readable file."""
1078+
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
1079+
filename = f.name
1080+
self.temp_files.append(filename)
1081+
1082+
good = [
1083+
make_interpreter(0, [make_thread(1, [make_frame("a.py", 1, "f")])])
1084+
]
1085+
bad = [
1086+
make_interpreter(2**32, [make_thread(1, [make_frame("a.py", 1, "f")])])
1087+
]
1088+
1089+
writer = _remote_debugging.BinaryWriter(filename, 1000, 0, compression=0)
1090+
writer.write_sample(good, 1000)
1091+
with self.assertRaises(OverflowError):
1092+
writer.write_sample(bad, 2000)
1093+
writer.write_sample(good, 3000)
1094+
writer.finalize()
1095+
self.assertEqual(writer.total_samples, 2)
1096+
1097+
reader_collector = RawCollector()
1098+
with BinaryReader(filename) as reader:
1099+
self.assertEqual(reader.replay_samples(reader_collector), 2)
1100+
1101+
def test_writer_finalizes_after_format_limit(self):
1102+
for compression in (0, 1) if ZSTD_AVAILABLE else (0,):
1103+
with self.subTest(compression=compression):
1104+
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
1105+
filename = f.name
1106+
self.temp_files.append(filename)
1107+
good = [make_interpreter(0, [
1108+
make_thread(1, [make_frame("a.py", 1, "f")])
1109+
])]
1110+
bad = [make_interpreter(2**32, good[0].threads)]
1111+
writer = _remote_debugging.BinaryWriter(
1112+
filename, 1000, 0, compression=compression
1113+
)
1114+
with self.assertRaises(OverflowError):
1115+
with writer:
1116+
writer.write_sample(good, 1000)
1117+
writer.write_sample(good, 2000)
1118+
# The first interpreter is committed before the limit.
1119+
writer.write_sample(good + bad, 3000)
1120+
self.assertEqual(writer.total_samples, 3)
1121+
with BinaryReader(filename) as reader:
1122+
self.assertEqual(reader.replay_samples(RawCollector()), 3)
1123+
1124+
def test_collector_does_not_swallow_unrelated_overflow(self):
1125+
class BadStatus:
1126+
def __index__(self):
1127+
raise OverflowError("status conversion failed")
1128+
1129+
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
1130+
filename = f.name
1131+
self.temp_files.append(filename)
1132+
collector = BinaryCollector(filename, 1000, compression="none")
1133+
self.addCleanup(collector._writer.close)
1134+
sample = [make_interpreter(0, [make_thread(1, [], BadStatus())])]
1135+
with captured_stderr() as stderr:
1136+
with self.assertRaisesRegex(OverflowError, "status conversion failed"):
1137+
collector.collect(sample, timestamp_us=1000)
1138+
self.assertEqual(stderr.getvalue(), "")
1139+
self.assertFalse(collector._writer.limit_reached)
1140+
with self.assertRaisesRegex(ValueError, "broken"):
1141+
collector.export()
1142+
with self.assertRaisesRegex(ValueError, "broken"):
1143+
collector._writer.write_sample([], 2000)
1144+
# Closing a broken writer must not attempt to finalize it.
1145+
collector.__exit__(None, None, None)
1146+
1147+
def test_collector_finalizes_after_external_exception(self):
1148+
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
1149+
filename = f.name
1150+
self.temp_files.append(filename)
1151+
with self.assertRaisesRegex(RuntimeError, "sampling failed"):
1152+
with BinaryCollector(filename, 1000, compression="none") as collector:
1153+
collector.collect([make_interpreter(0, [make_thread(1, [])])])
1154+
raise RuntimeError("sampling failed")
1155+
self.assertEqual(collector.total_samples, 1)
1156+
with BinaryReader(filename) as reader:
1157+
self.assertEqual(reader.replay_samples(RawCollector()), 1)
1158+
1159+
@unittest.skipUnless(os.path.exists("/dev/full"), "requires /dev/full")
1160+
def test_finalize_failure_breaks_writer(self):
1161+
writer = _remote_debugging.BinaryWriter("/dev/full", 1000, 0)
1162+
self.addCleanup(writer.close)
1163+
writer.write_sample([make_interpreter(0, [make_thread(1, [])])], 1000)
1164+
with self.assertRaises(OSError):
1165+
writer.finalize()
1166+
self.assertFalse(writer.limit_reached)
1167+
with self.assertRaisesRegex(ValueError, "broken"):
1168+
writer.finalize()
1169+
with self.assertRaisesRegex(ValueError, "broken"):
1170+
writer.write_sample([], 2000)
1171+
10341172

10351173
class TestBinaryFormatValidation(BinaryFormatTestBase):
10361174
"""Tests for malformed binary files."""
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fix ``profiling.sampling --binary`` leaving unreadable profile files when
2+
the binary format reaches a size limit. Preserve collected samples when the
3+
writer can still finalize safely. Patch by Maurycy Pawłowski-Wieroński.

‎Modules/_remote_debugging/binary_io.h‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,9 +290,18 @@ typedef struct {
290290
size_t pending_rle_samples;
291291
} ThreadEntry;
292292

293+
/* Limit errors occur before emitting an incomplete sample. Other write
294+
* failures may leave partial records and must prevent finalization. */
295+
typedef enum {
296+
BINARY_WRITER_OPEN,
297+
BINARY_WRITER_LIMIT_REACHED,
298+
BINARY_WRITER_BROKEN,
299+
} BinaryWriterState;
300+
293301
/* Main binary writer structure */
294302
typedef struct {
295303
FILE *fp;
304+
BinaryWriterState state;
296305

297306
/* Write buffer for batched I/O */
298307
uint8_t *write_buffer;

‎Modules/_remote_debugging/binary_io_writer.c‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ writer_intern_string(BinaryWriter *writer, PyObject *string, uint32_t *index)
371371
}
372372

373373
if (writer->string_count >= UINT32_MAX) {
374+
writer->state = BINARY_WRITER_LIMIT_REACHED;
374375
PyErr_SetString(PyExc_OverflowError,
375376
"too many strings for binary format");
376377
return -1;
@@ -380,6 +381,9 @@ writer_intern_string(BinaryWriter *writer, PyObject *string, uint32_t *index)
380381
(void **)&writer->string_lengths,
381382
&writer->string_capacity,
382383
sizeof(char *), sizeof(size_t)) < 0) {
384+
if (PyErr_ExceptionMatches(PyExc_OverflowError)) {
385+
writer->state = BINARY_WRITER_LIMIT_REACHED;
386+
}
383387
return -1;
384388
}
385389
}
@@ -390,6 +394,7 @@ writer_intern_string(BinaryWriter *writer, PyObject *string, uint32_t *index)
390394
return -1;
391395
}
392396
if ((uintmax_t)str_len > UINT32_MAX) {
397+
writer->state = BINARY_WRITER_LIMIT_REACHED;
393398
PyErr_Format(PyExc_OverflowError,
394399
"string length %zd exceeds binary format maximum %u",
395400
str_len, UINT32_MAX);
@@ -438,12 +443,16 @@ writer_intern_frame(BinaryWriter *writer, const FrameEntry *entry, uint32_t *ind
438443
}
439444

440445
if (writer->frame_count >= UINT32_MAX) {
446+
writer->state = BINARY_WRITER_LIMIT_REACHED;
441447
PyErr_SetString(PyExc_OverflowError,
442448
"too many frames for binary format");
443449
return -1;
444450
}
445451
if (GROW_ARRAY(writer->frame_entries, writer->frame_count,
446452
writer->frame_capacity, FrameEntry) < 0) {
453+
if (PyErr_ExceptionMatches(PyExc_OverflowError)) {
454+
writer->state = BINARY_WRITER_LIMIT_REACHED;
455+
}
447456
return -1;
448457
}
449458

@@ -487,6 +496,7 @@ writer_get_or_create_thread_entry(BinaryWriter *writer, uint64_t thread_id,
487496
}
488497

489498
if (writer->thread_count >= UINT32_MAX) {
499+
writer->state = BINARY_WRITER_LIMIT_REACHED;
490500
PyErr_SetString(PyExc_OverflowError,
491501
"too many threads for binary format");
492502
return NULL;
@@ -496,6 +506,9 @@ writer_get_or_create_thread_entry(BinaryWriter *writer, uint64_t thread_id,
496506
&writer->thread_capacity,
497507
sizeof(ThreadEntry));
498508
if (!new_entries) {
509+
if (PyErr_ExceptionMatches(PyExc_OverflowError)) {
510+
writer->state = BINARY_WRITER_LIMIT_REACHED;
511+
}
499512
return NULL;
500513
}
501514
writer->thread_entries = new_entries;
@@ -928,6 +941,12 @@ static int
928941
process_thread_sample(BinaryWriter *writer, PyObject *thread_info,
929942
uint32_t interpreter_id, uint64_t timestamp_us)
930943
{
944+
if (writer->total_samples == UINT64_MAX) {
945+
writer->state = BINARY_WRITER_LIMIT_REACHED;
946+
PyErr_SetString(PyExc_OverflowError, "too many samples for binary format");
947+
return -1;
948+
}
949+
931950
PyObject *thread_id_obj = PyStructSequence_GET_ITEM(thread_info, 0);
932951
PyObject *status_obj = PyStructSequence_GET_ITEM(thread_info, 1);
933952
PyObject *frame_list = PyStructSequence_GET_ITEM(thread_info, 2);
@@ -950,7 +969,6 @@ process_thread_sample(BinaryWriter *writer, PyObject *thread_info,
950969

951970
/* Calculate timestamp delta */
952971
uint64_t delta = timestamp_us - entry->prev_timestamp;
953-
entry->prev_timestamp = timestamp_us;
954972

955973
/* Process frames and build current stack */
956974
uint32_t curr_stack[MAX_STACK_DEPTH];
@@ -1006,6 +1024,7 @@ process_thread_sample(BinaryWriter *writer, PyObject *thread_info,
10061024
entry->prev_stack_depth = curr_depth;
10071025
}
10081026

1027+
entry->prev_timestamp = timestamp_us;
10091028
writer->total_samples++;
10101029
return 0;
10111030
}
@@ -1025,15 +1044,16 @@ binary_writer_write_sample(BinaryWriter *writer, PyObject *stack_frames, uint64_
10251044
PyObject *interp_id_obj = PyStructSequence_GET_ITEM(interp_info, 0);
10261045
PyObject *threads = PyStructSequence_GET_ITEM(interp_info, 1);
10271046

1028-
unsigned long interp_id_long = PyLong_AsUnsignedLong(interp_id_obj);
1029-
if (interp_id_long == (unsigned long)-1 && PyErr_Occurred()) {
1047+
unsigned long long interp_id_long = PyLong_AsUnsignedLongLong(interp_id_obj);
1048+
if (interp_id_long == (unsigned long long)-1 && PyErr_Occurred()) {
10301049
return -1;
10311050
}
10321051
/* Bounds check: interpreter_id is stored as uint32_t in binary format */
10331052
if (interp_id_long > UINT32_MAX) {
1053+
writer->state = BINARY_WRITER_LIMIT_REACHED;
10341054
PyErr_Format(PyExc_OverflowError,
1035-
"interpreter_id %lu exceeds maximum value %lu",
1036-
interp_id_long, (unsigned long)UINT32_MAX);
1055+
"interpreter_id %llu exceeds maximum value %u",
1056+
interp_id_long, UINT32_MAX);
10371057
return -1;
10381058
}
10391059
uint32_t interpreter_id = (uint32_t)interp_id_long;

‎Modules/_remote_debugging/module.c‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1789,7 +1789,15 @@ _remote_debugging_BinaryWriter_write_sample_impl(BinaryWriterObject *self,
17891789
return NULL;
17901790
}
17911791

1792+
if (self->writer->state == BINARY_WRITER_BROKEN) {
1793+
PyErr_SetString(PyExc_ValueError, "Writer is broken");
1794+
return NULL;
1795+
}
1796+
self->writer->state = BINARY_WRITER_OPEN;
17921797
if (binary_writer_write_sample(self->writer, stack_frames, timestamp_us) < 0) {
1798+
if (self->writer->state != BINARY_WRITER_LIMIT_REACHED) {
1799+
self->writer->state = BINARY_WRITER_BROKEN;
1800+
}
17931801
return NULL;
17941802
}
17951803

@@ -1852,7 +1860,12 @@ _remote_debugging_BinaryWriter_set_stats_impl(BinaryWriterObject *self,
18521860
static int
18531861
binary_writer_finalize_and_cache(BinaryWriterObject *self)
18541862
{
1863+
if (self->writer->state == BINARY_WRITER_BROKEN) {
1864+
PyErr_SetString(PyExc_ValueError, "Writer is broken");
1865+
return -1;
1866+
}
18551867
if (binary_writer_finalize(self->writer) < 0) {
1868+
self->writer->state = BINARY_WRITER_BROKEN;
18561869
return -1;
18571870
}
18581871
self->cached_total_samples = self->writer->total_samples;
@@ -1933,8 +1946,7 @@ _remote_debugging_BinaryWriter___exit___impl(BinaryWriterObject *self,
19331946
/*[clinic end generated code: output=61831f47c72a53c6 input=12334ce1009af37f]*/
19341947
{
19351948
if (self->writer) {
1936-
/* Only finalize on normal exit (no exception) */
1937-
if (exc_type == Py_None) {
1949+
if (self->writer->state != BINARY_WRITER_BROKEN) {
19381950
if (binary_writer_finalize_and_cache(self) < 0) {
19391951
if (self->writer) {
19401952
binary_writer_destroy(self->writer);
@@ -1983,8 +1995,17 @@ BinaryWriter_get_total_samples(PyObject *op, void *closure)
19831995
return PyLong_FromUnsignedLongLong(self->writer->total_samples);
19841996
}
19851997

1998+
static PyObject *
1999+
BinaryWriter_get_limit_reached(PyObject *op, void *closure)
2000+
{
2001+
BinaryWriter *writer = BinaryWriter_CAST(op)->writer;
2002+
return PyBool_FromLong(writer && writer->state == BINARY_WRITER_LIMIT_REACHED);
2003+
}
2004+
19862005
static PyGetSetDef BinaryWriter_getset[] = {
19872006
{"total_samples", BinaryWriter_get_total_samples, NULL, "Total samples written", NULL},
2007+
{"limit_reached", BinaryWriter_get_limit_reached, NULL,
2008+
"A format limit was reached; the collected samples can still be finalized", NULL},
19882009
{NULL}
19892010
};
19902011

0 commit comments

Comments
 (0)