Skip to content

Commit 9de4476

Browse files
committed
gh-151292: Track binary writer finalization state
1 parent 5057295 commit 9de4476

6 files changed

Lines changed: 146 additions & 42 deletions

File tree

‎Lib/profiling/sampling/binary_collector.py‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ def collect(self, stack_frames, timestamp_us=None):
111111
try:
112112
self._writer.write_sample(stack_frames, timestamp_us)
113113
except OverflowError as e:
114+
if not self._writer.limit_reached:
115+
raise
114116
self.running = False
115117
print(f"Warning: {e}; stopping early and keeping the data "
116118
"collected so far.",
@@ -153,9 +155,5 @@ def __enter__(self):
153155
return self
154156

155157
def __exit__(self, exc_type, exc_val, exc_tb):
156-
"""Context manager exit - finalize unless there was an error."""
157-
if exc_type is None:
158-
self._writer.finalize()
159-
else:
160-
self._writer.close()
161-
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: 83 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -146,12 +146,6 @@ def samples_to_by_thread(samples):
146146
class BinaryFormatTestBase(unittest.TestCase):
147147
"""Base class with common setup/teardown for binary format tests."""
148148

149-
HDR_OFF_SAMPLES = 28
150-
HDR_OFF_THREADS = 36
151-
HDR_OFF_STR_TABLE = 40
152-
HDR_OFF_FRAME_TABLE = 48
153-
FILE_HEADER_PLACEHOLDER_SIZE = 64
154-
155149
def setUp(self):
156150
self.temp_files = []
157151

@@ -1059,33 +1053,19 @@ def test_binary_collector_stops_gracefully_on_overflow(self):
10591053
collector.collect(sample, timestamp_us=(i + 1) * 1000)
10601054
self.assertTrue(collector.running)
10611055

1062-
real_writer = collector._writer
1063-
1064-
class _OverflowingWriter:
1065-
def write_sample(self, stack_frames, timestamp_us):
1066-
raise OverflowError("too many samples for binary format")
1067-
1068-
collector._writer = _OverflowingWriter()
1056+
bad = [make_interpreter(2**32, sample[0].threads)]
10691057
with captured_stderr() as stderr:
1070-
collector.collect(sample, timestamp_us=4000)
1058+
collector.collect(bad, timestamp_us=4000)
1059+
collector.collect(sample, timestamp_us=5000)
10711060

10721061
self.assertFalse(collector.running)
1073-
self.assertIn("too many samples", stderr.getvalue())
1062+
self.assertTrue(collector._writer.limit_reached)
1063+
self.assertEqual(stderr.getvalue().count("Warning:"), 1)
1064+
self.assertIn("interpreter_id", stderr.getvalue())
10741065

1075-
# The real writer can still be finalized into a valid file that
1076-
# keeps the samples collected before the limit was hit.
1077-
collector._writer = real_writer
10781066
collector.export(None)
10791067

1080-
with open(filename, "rb") as f:
1081-
header = f.read(self.FILE_HEADER_PLACEHOLDER_SIZE)
1082-
magic, version = struct.unpack_from("=II", header, 0)
1083-
self.assertEqual(magic, 0x54414348) # "TACH"
1084-
self.assertEqual(version, 1)
1085-
(sample_count,) = struct.unpack_from(
1086-
"=Q", header, self.HDR_OFF_SAMPLES
1087-
)
1088-
self.assertEqual(sample_count, 3)
1068+
self.assertEqual(collector.total_samples, 3)
10891069

10901070
reader_collector = RawCollector()
10911071
with BinaryReader(filename) as reader:
@@ -1118,10 +1098,86 @@ def test_interpreter_id_overflow_rejected(self):
11181098
with BinaryReader(filename) as reader:
11191099
self.assertEqual(reader.replay_samples(reader_collector), 2)
11201100

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+
11211172

11221173
class TestBinaryFormatValidation(BinaryFormatTestBase):
11231174
"""Tests for malformed binary files."""
11241175

1176+
HDR_OFF_SAMPLES = 28
1177+
HDR_OFF_THREADS = 36
1178+
HDR_OFF_STR_TABLE = 40
1179+
HDR_OFF_FRAME_TABLE = 48
1180+
FILE_HEADER_PLACEHOLDER_SIZE = 64
11251181
FILE_FOOTER_SIZE = 32
11261182
FTR_OFF_STRINGS = 0
11271183
FTR_OFF_FRAMES = 4
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
Fix ``profiling.sampling --binary`` leaving unreadable profile files when
2-
the ``_remote_debugging`` binary writer raises :exc:`OverflowError`. Patch
3-
by Maurycy Pawłowski-Wieroński.
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
@@ -1791,7 +1791,15 @@ _remote_debugging_BinaryWriter_write_sample_impl(BinaryWriterObject *self,
17911791
return NULL;
17921792
}
17931793

1794+
if (self->writer->state == BINARY_WRITER_BROKEN) {
1795+
PyErr_SetString(PyExc_ValueError, "Writer is broken");
1796+
return NULL;
1797+
}
1798+
self->writer->state = BINARY_WRITER_OPEN;
17941799
if (binary_writer_write_sample(self->writer, stack_frames, timestamp_us) < 0) {
1800+
if (self->writer->state != BINARY_WRITER_LIMIT_REACHED) {
1801+
self->writer->state = BINARY_WRITER_BROKEN;
1802+
}
17951803
return NULL;
17961804
}
17971805

@@ -1854,7 +1862,12 @@ _remote_debugging_BinaryWriter_set_stats_impl(BinaryWriterObject *self,
18541862
static int
18551863
binary_writer_finalize_and_cache(BinaryWriterObject *self)
18561864
{
1865+
if (self->writer->state == BINARY_WRITER_BROKEN) {
1866+
PyErr_SetString(PyExc_ValueError, "Writer is broken");
1867+
return -1;
1868+
}
18571869
if (binary_writer_finalize(self->writer) < 0) {
1870+
self->writer->state = BINARY_WRITER_BROKEN;
18581871
return -1;
18591872
}
18601873
self->cached_total_samples = self->writer->total_samples;
@@ -1935,8 +1948,7 @@ _remote_debugging_BinaryWriter___exit___impl(BinaryWriterObject *self,
19351948
/*[clinic end generated code: output=61831f47c72a53c6 input=12334ce1009af37f]*/
19361949
{
19371950
if (self->writer) {
1938-
/* Only finalize on normal exit (no exception) */
1939-
if (exc_type == Py_None) {
1951+
if (self->writer->state != BINARY_WRITER_BROKEN) {
19401952
if (binary_writer_finalize_and_cache(self) < 0) {
19411953
if (self->writer) {
19421954
binary_writer_destroy(self->writer);
@@ -1985,8 +1997,17 @@ BinaryWriter_get_total_samples(PyObject *op, void *closure)
19851997
return PyLong_FromUnsignedLongLong(self->writer->total_samples);
19861998
}
19871999

2000+
static PyObject *
2001+
BinaryWriter_get_limit_reached(PyObject *op, void *closure)
2002+
{
2003+
BinaryWriter *writer = BinaryWriter_CAST(op)->writer;
2004+
return PyBool_FromLong(writer && writer->state == BINARY_WRITER_LIMIT_REACHED);
2005+
}
2006+
19882007
static PyGetSetDef BinaryWriter_getset[] = {
19892008
{"total_samples", BinaryWriter_get_total_samples, NULL, "Total samples written", NULL},
2009+
{"limit_reached", BinaryWriter_get_limit_reached, NULL,
2010+
"A format limit was reached; the collected samples can still be finalized", NULL},
19902011
{NULL}
19912012
};
19922013

0 commit comments

Comments
 (0)