diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py b/Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py index 66a9c68a61c47d..4b9e862c71d862 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py @@ -739,6 +739,34 @@ def test_unicode_funcnames(self): self.assertEqual(count, 1) self.assert_samples_equal(samples, collector) + def test_surrogates_in_frame_names(self): + cases = [ + ("\udce9", r"\udce9"), # A surrogate-escaped filename byte. + ("\uddd0", r"\uddd0"), # The surrogate from gh-160601. + ("\ud800", r"\ud800"), + ("\udfff", r"\udfff"), + ("\ud800\udfff", r"\ud800\udfff"), + ("caf\xe9\udce9\U0001f40d", "caf\xe9\\udce9\U0001f40d"), + ] + for name, escaped_name in cases: + with self.subTest(name=name): + frames = [ + make_frame(f"/path/{name}.py", 42, f"func_{name}"), + make_frame("test.py", 1, "main"), + ] + samples = [[make_interpreter(0, [make_thread(1, frames)])]] * 3 + collector, count = self.roundtrip(samples) + self.assertEqual(count, 3) + + expected_frames = [ + make_frame(f"/path/{escaped_name}.py", 42, + f"func_{escaped_name}"), + frames[1], + ] + expected = [[make_interpreter( + 0, [make_thread(1, expected_frames)])]] * 3 + self.assert_samples_equal(expected, collector) + def test_special_char_filenames(self): """Filenames with special characters roundtrip exactly.""" filenames = [ diff --git a/Misc/NEWS.d/next/Library/2026-10-24-18-00-00.gh-issue-160601.Utf8Bin.rst b/Misc/NEWS.d/next/Library/2026-10-24-18-00-00.gh-issue-160601.Utf8Bin.rst new file mode 100644 index 00000000000000..b0b5264ea341c4 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-24-18-00-00.gh-issue-160601.Utf8Bin.rst @@ -0,0 +1,3 @@ +Fix a :exc:`UnicodeEncodeError` in :mod:`profiling.sampling` binary mode +when a sampled filename or function name contains surrogate characters. +These characters are now written as backslash escapes in the binary file. diff --git a/Modules/_remote_debugging/binary_io_writer.c b/Modules/_remote_debugging/binary_io_writer.c index 1794017053da50..59dd953b6e88c4 100644 --- a/Modules/_remote_debugging/binary_io_writer.c +++ b/Modules/_remote_debugging/binary_io_writer.c @@ -393,12 +393,15 @@ writer_intern_string(BinaryWriter *writer, PyObject *string, uint32_t *index) } } - Py_ssize_t str_len; - const char *str_data = PyUnicode_AsUTF8AndSize(string, &str_len); - if (!str_data) { + /* Frame names can contain surrogates (e.g. from undecodable filenames). + * Escape them to keep the string table valid UTF-8. */ + PyObject *encoded = PyUnicode_AsEncodedString(string, "utf-8", "backslashreplace"); + if (encoded == NULL) { return -1; } + Py_ssize_t str_len = PyBytes_GET_SIZE(encoded); if ((uintmax_t)str_len > UINT32_MAX) { + Py_DECREF(encoded); writer->state = BINARY_WRITER_LIMIT_REACHED; PyErr_Format(PyExc_OverflowError, "string length %zd exceeds binary format maximum %u", @@ -408,10 +411,12 @@ writer_intern_string(BinaryWriter *writer, PyObject *string, uint32_t *index) char *str_copy = PyMem_Malloc(str_len + 1); if (!str_copy) { + Py_DECREF(encoded); PyErr_NoMemory(); return -1; } - memcpy(str_copy, str_data, str_len + 1); + memcpy(str_copy, PyBytes_AS_STRING(encoded), str_len + 1); + Py_DECREF(encoded); *index = (uint32_t)writer->string_count;