From 4fd69ef97a67fc935e0d4f30662f2a1985ba6be9 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 27 Jun 2026 14:37:41 +0300 Subject: [PATCH 01/13] gh-70273: Document default class bindings in tkinter (GH-152389) Note in the Bindings and events section that every widget inherits Tk class bindings for its standard behavior, where they are documented, and how to suppress an unwanted one by returning "break" from a callback. Co-authored-by: Claude Opus 4.8 --- Doc/library/tkinter.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Doc/library/tkinter.rst b/Doc/library/tkinter.rst index 5c7227a21fc30e..c368141126403a 100644 --- a/Doc/library/tkinter.rst +++ b/Doc/library/tkinter.rst @@ -885,6 +885,20 @@ they are denoted in Tk, which can be useful when referring to the Tk man pages. | %d | detail | %D | delta | +----+---------------------+----+---------------------+ +The ``add`` parameter above only affects the bindings you make yourself. +Every widget also inherits *class bindings* +that implement its standard behavior -- +for example a :class:`Text` widget binds :kbd:`Control-t` +to transpose two characters. +These are described in the bindings section of the widget's Tk man page +(such as :manpage:`text(3tk)` or :manpage:`entry(3tk)`). + +Class bindings are processed separately from your own, +so binding an event yourself does not replace the default; both run. +To suppress an unwanted default binding, +bind the event on the widget +and return the string ``"break"`` from your callback. + The index parameter ^^^^^^^^^^^^^^^^^^^ From 3fa72d5c2e8188b5aca45e0c50349f9bdfda245f Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 27 Jun 2026 14:59:53 +0300 Subject: [PATCH 02/13] gh-152260: Fix flaky curses test_scr_dump on macOS (GH-152390) The screen dump embeds raw pointers that change after scr_restore(), so comparing dump bytes is unreliable. Test the round-trip functionally instead. Co-authored-by: Claude Opus 4.8 --- Lib/test/test_curses.py | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index 3df67c2bf0fafa..56a01b38ac0511 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -1120,9 +1120,10 @@ def test_putwin(self): def test_scr_dump(self): # Test scr_dump(), scr_restore(), scr_init() and scr_set(). # scr_dump() writes the virtual screen to a named file; the other three - # functions load it back. The dumped image is internal curses state, - # not a window, so the round-trip is checked by comparing dump files - # rather than reading cells. + # load it back. The dump is opaque internal curses state -- on some + # platforms (such as macOS) it embeds raw pointers that change whenever + # the screen is reallocated -- so the round-trip is exercised + # functionally rather than by comparing dump bytes. stdscr = self.stdscr stdscr.erase() stdscr.addstr(0, 0, 'screen dump test') @@ -1131,27 +1132,14 @@ def test_scr_dump(self): dump = os.path.join(d, 'dump') self.assertIsNone(curses.scr_dump(dump)) with open(dump, 'rb') as f: - image = f.read() - self.assertTrue(image) - # The dump format embeds raw pointers on some platforms (such as - # macOS), so two dumps of the same screen are not always identical. - # Only compare dump files when the format proves deterministic. - dump2 = os.path.join(d, 'dump2') - curses.scr_dump(dump2) - with open(dump2, 'rb') as f: - deterministic = f.read() == image - # scr_restore() reloads that virtual screen, so dumping it again - # reproduces the original file even after the screen has changed. + self.assertTrue(f.read()) + # scr_restore() reloads the saved virtual screen, even after the + # screen has changed. stdscr.erase() stdscr.addstr(0, 0, 'something else') stdscr.refresh() self.assertIsNone(curses.scr_restore(dump)) - if deterministic: - restored = os.path.join(d, 'restored') - curses.scr_dump(restored) - with open(restored, 'rb') as f: - self.assertEqual(f.read(), image) - # scr_init() and scr_set() accept a dump file and return None. + # scr_init() and scr_set() also accept a dump file and return None. self.assertIsNone(curses.scr_init(dump)) self.assertIsNone(curses.scr_set(dump)) # A bytes (path-like) filename is accepted too. From 7bf63facfd0ab15490f44ebfb03d010d73da1c4f Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 27 Jun 2026 15:42:41 +0300 Subject: [PATCH 03/13] gh-152275: Add integer overflow guards to the curses chtype and color-pair packing path (GH-152303) curses.color_pair() now raises OverflowError for a pair number too large to be packed, instead of silently masking it to a different pair. The attr argument of the character-cell and attribute methods (addch, addstr, attron, attrset and others) now goes through the checked attr converter, so an out-of-range or non-integer attribute is rejected rather than silently truncated. Co-Authored-By: Claude Opus 4.8 --- Doc/library/curses.rst | 11 +- Lib/test/test_curses.py | 14 +++ ...-06-26-17-18-01.gh-issue-152275.V64zKa.rst | 7 ++ Modules/_cursesmodule.c | 106 ++++++++++-------- Modules/clinic/_cursesmodule.c.h | 105 +++++++++-------- 5 files changed, 139 insertions(+), 104 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-26-17-18-01.gh-issue-152275.V64zKa.rst diff --git a/Doc/library/curses.rst b/Doc/library/curses.rst index 7b61a0ed5fa6ba..60c60a9edeec49 100644 --- a/Doc/library/curses.rst +++ b/Doc/library/curses.rst @@ -139,10 +139,13 @@ The module :mod:`!curses` defines the following functions: .. function:: color_pair(pair_number) Return the attribute value for displaying text in the specified color pair. - Only the first 256 color pairs are supported. This - attribute value can be combined with :const:`A_STANDOUT`, :const:`A_REVERSE`, - and the other :const:`!A_\*` attributes. :func:`pair_number` is the counterpart - to this function. + Only color pairs that fit in the color-pair field of the returned value can + be represented (usually the first 256); a larger *pair_number* raises + :exc:`OverflowError` rather than being silently masked to a different pair. + Use :meth:`~window.color_set` or :meth:`~window.attr_set` to display higher + pairs. This attribute value can be combined with :const:`A_STANDOUT`, + :const:`A_REVERSE`, and the other :const:`!A_\*` attributes. + :func:`pair_number` is the counterpart to this function. .. function:: curs_set(visibility) diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index 56a01b38ac0511..c17f4c87705c46 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -814,6 +814,10 @@ def test_argument_errors(self): # A character argument must be an int, a byte or a one-element string. self.assertRaises(TypeError, win.addch, []) self.assertRaises(OverflowError, win.addch, 2**64) + # The attribute argument is rejected, not truncated, when out of range. + self.assertRaises(OverflowError, win.addch, 'a', 2**64) + self.assertRaises(OverflowError, win.addstr, 'a', 2**64) + self.assertRaises(TypeError, win.addch, 'a', 'bold') # A string method rejects a non-string, non-bytes argument. self.assertRaises(TypeError, win.addstr, 5) self.assertRaises(TypeError, win.addstr) @@ -969,6 +973,11 @@ def test_attributes(self): self.assertRaises(OverflowError, win.attr_set, -1) self.assertRaises(OverflowError, win.attr_on, -1) self.assertRaises(OverflowError, win.attr_set, 1 << 64) + # attron()/attroff()/attrset() reject a bad attribute too. + self.assertRaises(OverflowError, win.attron, 1 << 64) + self.assertRaises(OverflowError, win.attroff, -1) + self.assertRaises(OverflowError, win.attrset, 1 << 64) + self.assertRaises(TypeError, win.attron, 'x') @requires_colors def test_attr_color_pair(self): @@ -1717,6 +1726,11 @@ def test_color_attrs(self): self.assertEqual(curses.pair_number(attr | curses.A_BOLD), pair) self.assertEqual(curses.color_pair(0), 0) self.assertEqual(curses.pair_number(0), 0) + # A pair too large to fit is rejected, not silently masked (gh-119138). + max_pair = curses.pair_number(curses.A_COLOR) + self.assertEqual(curses.pair_number(curses.color_pair(max_pair)), max_pair) + self.assertRaises(OverflowError, curses.color_pair, max_pair + 1) + self.assertRaises(OverflowError, curses.color_pair, -1) @requires_curses_func('use_default_colors') @requires_colors diff --git a/Misc/NEWS.d/next/Library/2026-06-26-17-18-01.gh-issue-152275.V64zKa.rst b/Misc/NEWS.d/next/Library/2026-06-26-17-18-01.gh-issue-152275.V64zKa.rst new file mode 100644 index 00000000000000..ec33586df1a738 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-26-17-18-01.gh-issue-152275.V64zKa.rst @@ -0,0 +1,7 @@ +The :mod:`curses` module now raises :exc:`OverflowError` instead of silently +truncating an out-of-range value: :func:`curses.color_pair` rejects a color +pair number that does not fit in the ``chtype`` color field, and the +``attr`` argument of the character-cell and attribute methods +(:meth:`~curses.window.addch`, :meth:`~curses.window.addstr`, +:meth:`~curses.window.attron`, :meth:`~curses.window.attrset` and others) is +checked against the ``chtype`` range. diff --git a/Modules/_cursesmodule.c b/Modules/_cursesmodule.c index 0a77469581432f..76e93784b94d46 100644 --- a/Modules/_cursesmodule.c +++ b/Modules/_cursesmodule.c @@ -692,7 +692,7 @@ typedef struct { obj may also be a complexchar, whose cell is used directly; it carries its own rendition, so supplying *attr* too (attr_given) is rejected. */ static int -PyCurses_ConvertToCell(PyCursesWindowObject *win, PyObject *obj, long attr, +PyCurses_ConvertToCell(PyCursesWindowObject *win, PyObject *obj, attr_t attr, int attr_given, const char *funcname, chtype *pch, cchar_t *pwc) { @@ -931,8 +931,9 @@ attr_converter(PyObject *arg, void *ptr) class attr_converter(CConverter): type = 'attr_t' converter = 'attr_converter' + c_ignored_default = '0' [python start generated code]*/ -/*[python end generated code: output=da39a3ee5e6b4b0d input=6132d3d99d3ec25a]*/ +/*[python end generated code: output=da39a3ee5e6b4b0d input=57b994c97cbd5e80]*/ #ifdef HAVE_NCURSESW /* -------------------------------------------------------*/ @@ -1835,7 +1836,7 @@ _curses.window.addch Character to add. [ - attr: long + attr: attr Attributes for the character. ] / @@ -1851,8 +1852,8 @@ current settings for the window object. static PyObject * _curses_window_addch_impl(PyCursesWindowObject *self, int group_left_1, int y, int x, PyObject *ch, int group_right_1, - long attr) -/*[clinic end generated code: output=00f4c37af3378f45 input=ab196a1dac3d354c]*/ + attr_t attr) +/*[clinic end generated code: output=3306e15a7059998f input=0a09ecdd04aa0a2d]*/ { int coordinates_group = group_left_1; int rtn; @@ -1908,7 +1909,7 @@ _curses_window_addch_impl(PyCursesWindowObject *self, int group_left_1, #endif static int -curses_wattrset(PyCursesWindowObject *self, long attr, const char *funcname) +curses_wattrset(PyCursesWindowObject *self, attr_t attr, const char *funcname) { if (wattrset(self->win, attr) == ERR) { curses_window_set_error(self, "wattrset", funcname); @@ -1931,7 +1932,7 @@ _curses.window.addstr String to add. [ - attr: long + attr: attr Attributes for characters. ] / @@ -1947,8 +1948,8 @@ current settings for the window object. static PyObject * _curses_window_addstr_impl(PyCursesWindowObject *self, int group_left_1, int y, int x, PyObject *str, int group_right_1, - long attr) -/*[clinic end generated code: output=65a928ea85ff3115 input=ff6cbb91448a22a3]*/ + attr_t attr) +/*[clinic end generated code: output=4942cdb202012076 input=0202b09895bcb472]*/ { int rtn; int strtype; @@ -2046,7 +2047,7 @@ _curses.window.addnstr Maximal number of characters. [ - attr: long + attr: attr Attributes for characters. ] / @@ -2062,8 +2063,8 @@ current settings for the window object. static PyObject * _curses_window_addnstr_impl(PyCursesWindowObject *self, int group_left_1, int y, int x, PyObject *str, int n, - int group_right_1, long attr) -/*[clinic end generated code: output=6d21cee2ce6876d9 input=72718415c2744a2a]*/ + int group_right_1, attr_t attr) +/*[clinic end generated code: output=356ce38504dabf1d input=147405505606cd08]*/ { int rtn; int strtype; @@ -2146,7 +2147,7 @@ _curses.window.bkgd ch: object Background character. [ - attr: long + attr: attr Background attributes. ] / @@ -2156,8 +2157,8 @@ Set the background property of the window. static PyObject * _curses_window_bkgd_impl(PyCursesWindowObject *self, PyObject *ch, - int group_right_1, long attr) -/*[clinic end generated code: output=73cb11ecca59612f input=a2129c1b709db432]*/ + int group_right_1, attr_t attr) +/*[clinic end generated code: output=4dc2599da3afa46a input=7aee8008ff8066a5]*/ { chtype bkgd; #ifdef HAVE_NCURSESW @@ -2183,15 +2184,15 @@ _curses_window_bkgd_impl(PyCursesWindowObject *self, PyObject *ch, /*[clinic input] _curses.window.attroff - attr: long + attr: attr / Remove attribute attr from the "background" set. [clinic start generated code]*/ static PyObject * -_curses_window_attroff_impl(PyCursesWindowObject *self, long attr) -/*[clinic end generated code: output=8a2fcd4df682fc64 input=786beedf06a7befe]*/ +_curses_window_attroff_impl(PyCursesWindowObject *self, attr_t attr) +/*[clinic end generated code: output=27c9e77df32fa5d3 input=a22d4035e962e9a7]*/ { int rtn = wattroff(self->win, (attr_t)attr); return curses_window_check_err(self, rtn, "wattroff", "attroff"); @@ -2200,15 +2201,15 @@ _curses_window_attroff_impl(PyCursesWindowObject *self, long attr) /*[clinic input] _curses.window.attron - attr: long + attr: attr / Add attribute attr to the "background" set. [clinic start generated code]*/ static PyObject * -_curses_window_attron_impl(PyCursesWindowObject *self, long attr) -/*[clinic end generated code: output=7afea43b237fa870 input=b57f824e1bf58326]*/ +_curses_window_attron_impl(PyCursesWindowObject *self, attr_t attr) +/*[clinic end generated code: output=150ff7c387068cc7 input=361b6389f4d08681]*/ { int rtn = wattron(self->win, (attr_t)attr); return curses_window_check_err(self, rtn, "wattron", "attron"); @@ -2217,15 +2218,15 @@ _curses_window_attron_impl(PyCursesWindowObject *self, long attr) /*[clinic input] _curses.window.attrset - attr: long + attr: attr / Set the "background" set of attributes. [clinic start generated code]*/ static PyObject * -_curses_window_attrset_impl(PyCursesWindowObject *self, long attr) -/*[clinic end generated code: output=84e379bff20c0433 input=42e400c0d0154ab5]*/ +_curses_window_attrset_impl(PyCursesWindowObject *self, attr_t attr) +/*[clinic end generated code: output=1b57b2a512603eb0 input=af748b1c18e35c34]*/ { int rtn = wattrset(self->win, (attr_t)attr); return curses_window_check_err(self, rtn, "wattrset", "attrset"); @@ -2356,7 +2357,7 @@ _curses.window.bkgdset ch: object Background character. [ - attr: long + attr: attr Background attributes. ] / @@ -2366,8 +2367,8 @@ Set the window's background. static PyObject * _curses_window_bkgdset_impl(PyCursesWindowObject *self, PyObject *ch, - int group_right_1, long attr) -/*[clinic end generated code: output=3c32f2de5685a482 input=1f0811b24af821ca]*/ + int group_right_1, attr_t attr) +/*[clinic end generated code: output=32f5117c9e45422a input=64cf7cd3562b379b]*/ { chtype bkgd; #ifdef HAVE_NCURSESW @@ -2756,7 +2757,7 @@ _curses.window.echochar Character to add. [ - attr: long + attr: attr Attributes for the character. ] / @@ -2766,8 +2767,8 @@ Add character ch with attribute attr, and refresh. static PyObject * _curses_window_echochar_impl(PyCursesWindowObject *self, PyObject *ch, - int group_right_1, long attr) -/*[clinic end generated code: output=f42da9e200c935e5 input=26e16855ec1b0e78]*/ + int group_right_1, attr_t attr) +/*[clinic end generated code: output=ab03afa580aa6a2a input=cd74c42aadcc7e30]*/ { chtype ch_; #ifdef HAVE_NCURSESW @@ -3194,7 +3195,7 @@ _curses.window.hline Line length. [ - attr: long + attr: attr Attributes for the characters. ] / @@ -3205,8 +3206,8 @@ Display a horizontal line. static PyObject * _curses_window_hline_impl(PyCursesWindowObject *self, int group_left_1, int y, int x, PyObject *ch, int n, - int group_right_1, long attr) -/*[clinic end generated code: output=c00d489d61fc9eef input=924f8c28521bc2ec]*/ + int group_right_1, attr_t attr) +/*[clinic end generated code: output=2c7489b8bd10c446 input=5d9f72ccba73975c]*/ { chtype ch_; #ifdef HAVE_NCURSESW @@ -3250,7 +3251,7 @@ _curses.window.insch Character to insert. [ - attr: long + attr: attr Attributes for the character. ] / @@ -3264,8 +3265,8 @@ right, with the rightmost characters on the line being lost. static PyObject * _curses_window_insch_impl(PyCursesWindowObject *self, int group_left_1, int y, int x, PyObject *ch, int group_right_1, - long attr) -/*[clinic end generated code: output=ade8cfe3a3bf3e34 input=47d2989159ae6ca7]*/ + attr_t attr) +/*[clinic end generated code: output=9d2576c0d8d982c4 input=f76641d529dbd8af]*/ { int rtn; chtype ch_ = 0; @@ -3598,7 +3599,7 @@ _curses.window.insstr String to insert. [ - attr: long + attr: attr Attributes for characters. ] / @@ -3615,8 +3616,8 @@ moving to y, x, if specified). static PyObject * _curses_window_insstr_impl(PyCursesWindowObject *self, int group_left_1, int y, int x, PyObject *str, int group_right_1, - long attr) -/*[clinic end generated code: output=c259a5265ad0b777 input=dbfbdd3892155ea6]*/ + attr_t attr) +/*[clinic end generated code: output=2c8ed843880619ab input=f4a9d26b270058c2]*/ { int rtn; int strtype; @@ -3710,7 +3711,7 @@ _curses.window.insnstr Maximal number of characters. [ - attr: long + attr: attr Attributes for characters. ] / @@ -3728,8 +3729,8 @@ does not change (after moving to y, x, if specified). static PyObject * _curses_window_insnstr_impl(PyCursesWindowObject *self, int group_left_1, int y, int x, PyObject *str, int n, - int group_right_1, long attr) -/*[clinic end generated code: output=971a32ea6328ec8b input=fd0a9b65b84b385f]*/ + int group_right_1, attr_t attr) +/*[clinic end generated code: output=4895829689f3bdd2 input=7412feb3910276bf]*/ { int rtn; int strtype; @@ -4290,7 +4291,7 @@ _curses.window.vline Line length. [ - attr: long + attr: attr Attributes for the character. ] / @@ -4301,8 +4302,8 @@ Display a vertical line. static PyObject * _curses_window_vline_impl(PyCursesWindowObject *self, int group_left_1, int y, int x, PyObject *ch, int n, - int group_right_1, long attr) -/*[clinic end generated code: output=287ad1cc8982217f input=1d4aa27ff0309bbc]*/ + int group_right_1, attr_t attr) +/*[clinic end generated code: output=18efd3ea37bb04f6 input=e8678752623197a1]*/ { chtype ch_; #ifdef HAVE_NCURSESW @@ -5272,7 +5273,20 @@ _curses_color_pair_impl(PyObject *module, int pair_number) PyCursesStatefulInitialised(module); PyCursesStatefulInitialisedColor(module); - return PyLong_FromLong(COLOR_PAIR(pair_number)); + /* COLOR_PAIR() packs the pair into a limited field; a pair too large to be + recovered by its inverse PAIR_NUMBER() would be masked to a different + one. Reject pairs that do not round-trip (this assumes only that the two + macros are inverses). color_set()/attr_set()/complexchar can still + display larger pairs. */ + chtype attr = COLOR_PAIR(pair_number); + if (pair_number < 0 || PAIR_NUMBER(attr) != pair_number) { + PyErr_Format(PyExc_OverflowError, + "color pair %d does not fit in a chtype " + "(color_pair() can encode only pairs 0 to %d)", + pair_number, (int)PAIR_NUMBER(A_COLOR)); + return NULL; + } + return PyLong_FromLong(attr); } /*[clinic input] diff --git a/Modules/clinic/_cursesmodule.c.h b/Modules/clinic/_cursesmodule.c.h index a677abe6037edf..a4a7791fa6adcd 100644 --- a/Modules/clinic/_cursesmodule.c.h +++ b/Modules/clinic/_cursesmodule.c.h @@ -220,7 +220,7 @@ PyDoc_STRVAR(_curses_window_addch__doc__, static PyObject * _curses_window_addch_impl(PyCursesWindowObject *self, int group_left_1, int y, int x, PyObject *ch, int group_right_1, - long attr); + attr_t attr); static PyObject * _curses_window_addch(PyObject *self, PyObject *args) @@ -231,7 +231,7 @@ _curses_window_addch(PyObject *self, PyObject *args) int x = 0; PyObject *ch; int group_right_1 = 0; - long attr = 0; + attr_t attr = 0; switch (PyTuple_GET_SIZE(args)) { case 1: @@ -240,7 +240,7 @@ _curses_window_addch(PyObject *self, PyObject *args) } break; case 2: - if (!PyArg_ParseTuple(args, "Ol:addch", &ch, &attr)) { + if (!PyArg_ParseTuple(args, "OO&:addch", &ch, attr_converter, &attr)) { goto exit; } group_right_1 = 1; @@ -252,7 +252,7 @@ _curses_window_addch(PyObject *self, PyObject *args) group_left_1 = 1; break; case 4: - if (!PyArg_ParseTuple(args, "iiOl:addch", &y, &x, &ch, &attr)) { + if (!PyArg_ParseTuple(args, "iiOO&:addch", &y, &x, &ch, attr_converter, &attr)) { goto exit; } group_right_1 = 1; @@ -292,7 +292,7 @@ PyDoc_STRVAR(_curses_window_addstr__doc__, static PyObject * _curses_window_addstr_impl(PyCursesWindowObject *self, int group_left_1, int y, int x, PyObject *str, int group_right_1, - long attr); + attr_t attr); static PyObject * _curses_window_addstr(PyObject *self, PyObject *args) @@ -303,7 +303,7 @@ _curses_window_addstr(PyObject *self, PyObject *args) int x = 0; PyObject *str; int group_right_1 = 0; - long attr = 0; + attr_t attr = 0; switch (PyTuple_GET_SIZE(args)) { case 1: @@ -312,7 +312,7 @@ _curses_window_addstr(PyObject *self, PyObject *args) } break; case 2: - if (!PyArg_ParseTuple(args, "Ol:addstr", &str, &attr)) { + if (!PyArg_ParseTuple(args, "OO&:addstr", &str, attr_converter, &attr)) { goto exit; } group_right_1 = 1; @@ -324,7 +324,7 @@ _curses_window_addstr(PyObject *self, PyObject *args) group_left_1 = 1; break; case 4: - if (!PyArg_ParseTuple(args, "iiOl:addstr", &y, &x, &str, &attr)) { + if (!PyArg_ParseTuple(args, "iiOO&:addstr", &y, &x, &str, attr_converter, &attr)) { goto exit; } group_right_1 = 1; @@ -366,7 +366,7 @@ PyDoc_STRVAR(_curses_window_addnstr__doc__, static PyObject * _curses_window_addnstr_impl(PyCursesWindowObject *self, int group_left_1, int y, int x, PyObject *str, int n, - int group_right_1, long attr); + int group_right_1, attr_t attr); static PyObject * _curses_window_addnstr(PyObject *self, PyObject *args) @@ -378,7 +378,7 @@ _curses_window_addnstr(PyObject *self, PyObject *args) PyObject *str; int n; int group_right_1 = 0; - long attr = 0; + attr_t attr = 0; switch (PyTuple_GET_SIZE(args)) { case 2: @@ -387,7 +387,7 @@ _curses_window_addnstr(PyObject *self, PyObject *args) } break; case 3: - if (!PyArg_ParseTuple(args, "Oil:addnstr", &str, &n, &attr)) { + if (!PyArg_ParseTuple(args, "OiO&:addnstr", &str, &n, attr_converter, &attr)) { goto exit; } group_right_1 = 1; @@ -399,7 +399,7 @@ _curses_window_addnstr(PyObject *self, PyObject *args) group_left_1 = 1; break; case 5: - if (!PyArg_ParseTuple(args, "iiOil:addnstr", &y, &x, &str, &n, &attr)) { + if (!PyArg_ParseTuple(args, "iiOiO&:addnstr", &y, &x, &str, &n, attr_converter, &attr)) { goto exit; } group_right_1 = 1; @@ -429,7 +429,7 @@ PyDoc_STRVAR(_curses_window_bkgd__doc__, static PyObject * _curses_window_bkgd_impl(PyCursesWindowObject *self, PyObject *ch, - int group_right_1, long attr); + int group_right_1, attr_t attr); static PyObject * _curses_window_bkgd(PyObject *self, PyObject *args) @@ -437,7 +437,7 @@ _curses_window_bkgd(PyObject *self, PyObject *args) PyObject *return_value = NULL; PyObject *ch; int group_right_1 = 0; - long attr = 0; + attr_t attr = 0; switch (PyTuple_GET_SIZE(args)) { case 1: @@ -446,7 +446,7 @@ _curses_window_bkgd(PyObject *self, PyObject *args) } break; case 2: - if (!PyArg_ParseTuple(args, "Ol:bkgd", &ch, &attr)) { + if (!PyArg_ParseTuple(args, "OO&:bkgd", &ch, attr_converter, &attr)) { goto exit; } group_right_1 = 1; @@ -471,16 +471,15 @@ PyDoc_STRVAR(_curses_window_attroff__doc__, {"attroff", (PyCFunction)_curses_window_attroff, METH_O, _curses_window_attroff__doc__}, static PyObject * -_curses_window_attroff_impl(PyCursesWindowObject *self, long attr); +_curses_window_attroff_impl(PyCursesWindowObject *self, attr_t attr); static PyObject * _curses_window_attroff(PyObject *self, PyObject *arg) { PyObject *return_value = NULL; - long attr; + attr_t attr; - attr = PyLong_AsLong(arg); - if (attr == -1 && PyErr_Occurred()) { + if (!attr_converter(arg, &attr)) { goto exit; } return_value = _curses_window_attroff_impl((PyCursesWindowObject *)self, attr); @@ -499,16 +498,15 @@ PyDoc_STRVAR(_curses_window_attron__doc__, {"attron", (PyCFunction)_curses_window_attron, METH_O, _curses_window_attron__doc__}, static PyObject * -_curses_window_attron_impl(PyCursesWindowObject *self, long attr); +_curses_window_attron_impl(PyCursesWindowObject *self, attr_t attr); static PyObject * _curses_window_attron(PyObject *self, PyObject *arg) { PyObject *return_value = NULL; - long attr; + attr_t attr; - attr = PyLong_AsLong(arg); - if (attr == -1 && PyErr_Occurred()) { + if (!attr_converter(arg, &attr)) { goto exit; } return_value = _curses_window_attron_impl((PyCursesWindowObject *)self, attr); @@ -527,16 +525,15 @@ PyDoc_STRVAR(_curses_window_attrset__doc__, {"attrset", (PyCFunction)_curses_window_attrset, METH_O, _curses_window_attrset__doc__}, static PyObject * -_curses_window_attrset_impl(PyCursesWindowObject *self, long attr); +_curses_window_attrset_impl(PyCursesWindowObject *self, attr_t attr); static PyObject * _curses_window_attrset(PyObject *self, PyObject *arg) { PyObject *return_value = NULL; - long attr; + attr_t attr; - attr = PyLong_AsLong(arg); - if (attr == -1 && PyErr_Occurred()) { + if (!attr_converter(arg, &attr)) { goto exit; } return_value = _curses_window_attrset_impl((PyCursesWindowObject *)self, attr); @@ -715,7 +712,7 @@ PyDoc_STRVAR(_curses_window_bkgdset__doc__, static PyObject * _curses_window_bkgdset_impl(PyCursesWindowObject *self, PyObject *ch, - int group_right_1, long attr); + int group_right_1, attr_t attr); static PyObject * _curses_window_bkgdset(PyObject *self, PyObject *args) @@ -723,7 +720,7 @@ _curses_window_bkgdset(PyObject *self, PyObject *args) PyObject *return_value = NULL; PyObject *ch; int group_right_1 = 0; - long attr = 0; + attr_t attr = 0; switch (PyTuple_GET_SIZE(args)) { case 1: @@ -732,7 +729,7 @@ _curses_window_bkgdset(PyObject *self, PyObject *args) } break; case 2: - if (!PyArg_ParseTuple(args, "Ol:bkgdset", &ch, &attr)) { + if (!PyArg_ParseTuple(args, "OO&:bkgdset", &ch, attr_converter, &attr)) { goto exit; } group_right_1 = 1; @@ -1027,7 +1024,7 @@ PyDoc_STRVAR(_curses_window_echochar__doc__, static PyObject * _curses_window_echochar_impl(PyCursesWindowObject *self, PyObject *ch, - int group_right_1, long attr); + int group_right_1, attr_t attr); static PyObject * _curses_window_echochar(PyObject *self, PyObject *args) @@ -1035,7 +1032,7 @@ _curses_window_echochar(PyObject *self, PyObject *args) PyObject *return_value = NULL; PyObject *ch; int group_right_1 = 0; - long attr = 0; + attr_t attr = 0; switch (PyTuple_GET_SIZE(args)) { case 1: @@ -1044,7 +1041,7 @@ _curses_window_echochar(PyObject *self, PyObject *args) } break; case 2: - if (!PyArg_ParseTuple(args, "Ol:echochar", &ch, &attr)) { + if (!PyArg_ParseTuple(args, "OO&:echochar", &ch, attr_converter, &attr)) { goto exit; } group_right_1 = 1; @@ -1361,7 +1358,7 @@ PyDoc_STRVAR(_curses_window_hline__doc__, static PyObject * _curses_window_hline_impl(PyCursesWindowObject *self, int group_left_1, int y, int x, PyObject *ch, int n, - int group_right_1, long attr); + int group_right_1, attr_t attr); static PyObject * _curses_window_hline(PyObject *self, PyObject *args) @@ -1373,7 +1370,7 @@ _curses_window_hline(PyObject *self, PyObject *args) PyObject *ch; int n; int group_right_1 = 0; - long attr = 0; + attr_t attr = 0; switch (PyTuple_GET_SIZE(args)) { case 2: @@ -1382,7 +1379,7 @@ _curses_window_hline(PyObject *self, PyObject *args) } break; case 3: - if (!PyArg_ParseTuple(args, "Oil:hline", &ch, &n, &attr)) { + if (!PyArg_ParseTuple(args, "OiO&:hline", &ch, &n, attr_converter, &attr)) { goto exit; } group_right_1 = 1; @@ -1394,7 +1391,7 @@ _curses_window_hline(PyObject *self, PyObject *args) group_left_1 = 1; break; case 5: - if (!PyArg_ParseTuple(args, "iiOil:hline", &y, &x, &ch, &n, &attr)) { + if (!PyArg_ParseTuple(args, "iiOiO&:hline", &y, &x, &ch, &n, attr_converter, &attr)) { goto exit; } group_right_1 = 1; @@ -1432,7 +1429,7 @@ PyDoc_STRVAR(_curses_window_insch__doc__, static PyObject * _curses_window_insch_impl(PyCursesWindowObject *self, int group_left_1, int y, int x, PyObject *ch, int group_right_1, - long attr); + attr_t attr); static PyObject * _curses_window_insch(PyObject *self, PyObject *args) @@ -1443,7 +1440,7 @@ _curses_window_insch(PyObject *self, PyObject *args) int x = 0; PyObject *ch; int group_right_1 = 0; - long attr = 0; + attr_t attr = 0; switch (PyTuple_GET_SIZE(args)) { case 1: @@ -1452,7 +1449,7 @@ _curses_window_insch(PyObject *self, PyObject *args) } break; case 2: - if (!PyArg_ParseTuple(args, "Ol:insch", &ch, &attr)) { + if (!PyArg_ParseTuple(args, "OO&:insch", &ch, attr_converter, &attr)) { goto exit; } group_right_1 = 1; @@ -1464,7 +1461,7 @@ _curses_window_insch(PyObject *self, PyObject *args) group_left_1 = 1; break; case 4: - if (!PyArg_ParseTuple(args, "iiOl:insch", &y, &x, &ch, &attr)) { + if (!PyArg_ParseTuple(args, "iiOO&:insch", &y, &x, &ch, attr_converter, &attr)) { goto exit; } group_right_1 = 1; @@ -1551,7 +1548,7 @@ PyDoc_STRVAR(_curses_window_insstr__doc__, static PyObject * _curses_window_insstr_impl(PyCursesWindowObject *self, int group_left_1, int y, int x, PyObject *str, int group_right_1, - long attr); + attr_t attr); static PyObject * _curses_window_insstr(PyObject *self, PyObject *args) @@ -1562,7 +1559,7 @@ _curses_window_insstr(PyObject *self, PyObject *args) int x = 0; PyObject *str; int group_right_1 = 0; - long attr = 0; + attr_t attr = 0; switch (PyTuple_GET_SIZE(args)) { case 1: @@ -1571,7 +1568,7 @@ _curses_window_insstr(PyObject *self, PyObject *args) } break; case 2: - if (!PyArg_ParseTuple(args, "Ol:insstr", &str, &attr)) { + if (!PyArg_ParseTuple(args, "OO&:insstr", &str, attr_converter, &attr)) { goto exit; } group_right_1 = 1; @@ -1583,7 +1580,7 @@ _curses_window_insstr(PyObject *self, PyObject *args) group_left_1 = 1; break; case 4: - if (!PyArg_ParseTuple(args, "iiOl:insstr", &y, &x, &str, &attr)) { + if (!PyArg_ParseTuple(args, "iiOO&:insstr", &y, &x, &str, attr_converter, &attr)) { goto exit; } group_right_1 = 1; @@ -1627,7 +1624,7 @@ PyDoc_STRVAR(_curses_window_insnstr__doc__, static PyObject * _curses_window_insnstr_impl(PyCursesWindowObject *self, int group_left_1, int y, int x, PyObject *str, int n, - int group_right_1, long attr); + int group_right_1, attr_t attr); static PyObject * _curses_window_insnstr(PyObject *self, PyObject *args) @@ -1639,7 +1636,7 @@ _curses_window_insnstr(PyObject *self, PyObject *args) PyObject *str; int n; int group_right_1 = 0; - long attr = 0; + attr_t attr = 0; switch (PyTuple_GET_SIZE(args)) { case 2: @@ -1648,7 +1645,7 @@ _curses_window_insnstr(PyObject *self, PyObject *args) } break; case 3: - if (!PyArg_ParseTuple(args, "Oil:insnstr", &str, &n, &attr)) { + if (!PyArg_ParseTuple(args, "OiO&:insnstr", &str, &n, attr_converter, &attr)) { goto exit; } group_right_1 = 1; @@ -1660,7 +1657,7 @@ _curses_window_insnstr(PyObject *self, PyObject *args) group_left_1 = 1; break; case 5: - if (!PyArg_ParseTuple(args, "iiOil:insnstr", &y, &x, &str, &n, &attr)) { + if (!PyArg_ParseTuple(args, "iiOiO&:insnstr", &y, &x, &str, &n, attr_converter, &attr)) { goto exit; } group_right_1 = 1; @@ -2238,7 +2235,7 @@ PyDoc_STRVAR(_curses_window_vline__doc__, static PyObject * _curses_window_vline_impl(PyCursesWindowObject *self, int group_left_1, int y, int x, PyObject *ch, int n, - int group_right_1, long attr); + int group_right_1, attr_t attr); static PyObject * _curses_window_vline(PyObject *self, PyObject *args) @@ -2250,7 +2247,7 @@ _curses_window_vline(PyObject *self, PyObject *args) PyObject *ch; int n; int group_right_1 = 0; - long attr = 0; + attr_t attr = 0; switch (PyTuple_GET_SIZE(args)) { case 2: @@ -2259,7 +2256,7 @@ _curses_window_vline(PyObject *self, PyObject *args) } break; case 3: - if (!PyArg_ParseTuple(args, "Oil:vline", &ch, &n, &attr)) { + if (!PyArg_ParseTuple(args, "OiO&:vline", &ch, &n, attr_converter, &attr)) { goto exit; } group_right_1 = 1; @@ -2271,7 +2268,7 @@ _curses_window_vline(PyObject *self, PyObject *args) group_left_1 = 1; break; case 5: - if (!PyArg_ParseTuple(args, "iiOil:vline", &y, &x, &ch, &n, &attr)) { + if (!PyArg_ParseTuple(args, "iiOiO&:vline", &y, &x, &ch, &n, attr_converter, &attr)) { goto exit; } group_right_1 = 1; @@ -5648,4 +5645,4 @@ _curses_has_extended_color_support(PyObject *module, PyObject *Py_UNUSED(ignored #ifndef _CURSES_ASSUME_DEFAULT_COLORS_METHODDEF #define _CURSES_ASSUME_DEFAULT_COLORS_METHODDEF #endif /* !defined(_CURSES_ASSUME_DEFAULT_COLORS_METHODDEF) */ -/*[clinic end generated code: output=db4cb7f72e1dc166 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=01cb1ecb396881c9 input=a9049054013a1b77]*/ From 109c59e25f558732bfde8b65bbbf305a6a8a05d8 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Sat, 27 Jun 2026 16:29:10 +0300 Subject: [PATCH 04/13] gh-152402: Use `support.nomemtest` in `test_pyexpat` (#152403) --- Lib/test/test_pyexpat.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/test/test_pyexpat.py b/Lib/test/test_pyexpat.py index 42309dd8f6c190..98be98a2527802 100644 --- a/Lib/test/test_pyexpat.py +++ b/Lib/test/test_pyexpat.py @@ -1057,8 +1057,7 @@ class ExternalEntityParserCreateErrorTest(unittest.TestCase): def setUpClass(cls): cls.testcapi = import_helper.import_module('_testcapi') - @unittest.skipIf(support.Py_TRACE_REFS, - 'Py_TRACE_REFS conflicts with testcapi.set_nomemory') + @support.nomemtest def test_error_path_no_crash(self): # When an allocation inside ExternalEntityParserCreate fails, # the partially-initialized subparser is deallocated. This From 0a21a248cc97eba3d75c7bd07c849ec645aea87b Mon Sep 17 00:00:00 2001 From: da-woods Date: Sat, 27 Jun 2026 14:45:04 +0100 Subject: [PATCH 05/13] Docs: Fix incomplete sentence in `tp_itemsize` documentation (GH-152381) --- Doc/c-api/type.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/c-api/type.rst b/Doc/c-api/type.rst index 48eb16bd90834b..6b68c0d085589a 100644 --- a/Doc/c-api/type.rst +++ b/Doc/c-api/type.rst @@ -639,7 +639,7 @@ but need extra remarks for use as slots: in the following situations: - The base is not variable-sized (its - :c:member:`~PyTypeObject.tp_itemsize`). + :c:member:`~PyTypeObject.tp_itemsize` is zero). - The requested :c:member:`PyType_Spec.basicsize` is positive, suggesting that the memory layout of the base class is known. - The requested :c:member:`PyType_Spec.basicsize` is zero, From a9fa8560143098168e0380386acbf4846c37472b Mon Sep 17 00:00:00 2001 From: sobolevn Date: Sat, 27 Jun 2026 18:00:34 +0300 Subject: [PATCH 06/13] gh-151126: Sets missing exceptions in `tkinter` and `socket` modules initializations (#152418) --- .../next/Library/2026-06-27-17-19-09.gh-issue-151126.huUyOM.rst | 2 ++ Modules/_tkinter.c | 2 +- Modules/socketmodule.c | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-27-17-19-09.gh-issue-151126.huUyOM.rst diff --git a/Misc/NEWS.d/next/Library/2026-06-27-17-19-09.gh-issue-151126.huUyOM.rst b/Misc/NEWS.d/next/Library/2026-06-27-17-19-09.gh-issue-151126.huUyOM.rst new file mode 100644 index 00000000000000..2e51fd45b59548 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-27-17-19-09.gh-issue-151126.huUyOM.rst @@ -0,0 +1,2 @@ +Fix two crashes in :mod:`tkinter` and :mod:`socket` modules initialization +under a memory pressure. Sets missing :exc:`MemoryError`. diff --git a/Modules/_tkinter.c b/Modules/_tkinter.c index 137eba40a762c0..8fa58d07096e30 100644 --- a/Modules/_tkinter.c +++ b/Modules/_tkinter.c @@ -3574,7 +3574,7 @@ PyInit__tkinter(void) tcl_lock = PyThread_allocate_lock(); if (tcl_lock == NULL) - return NULL; + return PyErr_NoMemory(); m = PyModule_Create(&_tkintermodule); if (m == NULL) diff --git a/Modules/socketmodule.c b/Modules/socketmodule.c index 3e82af3194d053..ada4fda6571049 100644 --- a/Modules/socketmodule.c +++ b/Modules/socketmodule.c @@ -9296,6 +9296,7 @@ socket_exec(PyObject *m) #if defined(USE_GETHOSTBYNAME_LOCK) netdb_lock = PyThread_allocate_lock(); if (netdb_lock == NULL) { + PyErr_NoMemory(); goto error; } #endif From 5733361fdd85519c931e67fd7cab0ff3a11b8ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?tonghuaroot=20=28=E7=AB=A5=E8=AF=9D=29?= Date: Sat, 27 Jun 2026 23:07:32 +0800 Subject: [PATCH 07/13] gh-152305: Fix `_pydatetime.time.strftime()` raising on year directives (#152306) Co-authored-by: Stan Ulbrych --- Lib/_pydatetime.py | 6 +++--- Lib/test/datetimetester.py | 5 +++++ .../Library/2026-06-26-15-41-34.gh-issue-152305.WnbbBc.rst | 2 ++ 3 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-26-15-41-34.gh-issue-152305.WnbbBc.rst diff --git a/Lib/_pydatetime.py b/Lib/_pydatetime.py index db4ea8d30c7064..c47f4e671b39de 100644 --- a/Lib/_pydatetime.py +++ b/Lib/_pydatetime.py @@ -272,12 +272,12 @@ def _wrap_strftime(object, format, timetuple): newformat.append(Zreplace) # Note that datetime(1000, 1, 1).strftime('%G') == '1000' so # year 1000 for %G can go on the fast path. - elif ((ch in 'YG' or ch in 'FC') and - object.year < 1000 and _need_normalize_century()): + elif (ch in 'YGFC' and timetuple[0] < 1000 and + _need_normalize_century()): if ch == 'G': year = int(_time.strftime("%G", timetuple)) else: - year = object.year + year = timetuple[0] if ch == 'C': push('{:02}'.format(year // 100)) else: diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 28c3ab2605c45d..192b22ff754003 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -4119,6 +4119,11 @@ def test_strftime_special(self): self.assertEqual(t.strftime('\0'*1000), '\0'*1000) self.assertEqual(t.strftime('\0%I%p%Z\0%X'), f'\0{s1}\0{s2}') self.assertEqual(t.strftime('%I%p%Z\0%X\0'), f'{s1}\0{s2}\0') + # gh-152305: the year directives must not raise on a time. + for directive, expected in (('%Y', '1900'), ('%G', '1900'), + ('%C', '19'), ('%F', '1900-01-01')): + with self.subTest(directive=directive): + self.assertEqual(t.strftime(directive), expected) def test_format(self): t = self.theclass(1, 2, 3, 4) diff --git a/Misc/NEWS.d/next/Library/2026-06-26-15-41-34.gh-issue-152305.WnbbBc.rst b/Misc/NEWS.d/next/Library/2026-06-26-15-41-34.gh-issue-152305.WnbbBc.rst new file mode 100644 index 00000000000000..4f27e2ed016d69 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-26-15-41-34.gh-issue-152305.WnbbBc.rst @@ -0,0 +1,2 @@ +Fix the pure-Python :meth:`datetime.time.strftime` implementation raising :exc:`AttributeError` for the +year directives. Patch by tonghuaroot. From 219f7a9453a2a89266f6e65d75df1606b4816043 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Sat, 27 Jun 2026 18:12:22 +0300 Subject: [PATCH 08/13] gh-152391: Improve `test_interpreters.test_stress` test (#152396) --- Lib/test/test_interpreters/test_stress.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_interpreters/test_stress.py b/Lib/test/test_interpreters/test_stress.py index 50d87a6ccd3cad..50d2444a4c72d3 100644 --- a/Lib/test/test_interpreters/test_stress.py +++ b/Lib/test/test_interpreters/test_stress.py @@ -25,6 +25,7 @@ def test_create_many_sequential(self): del alive support.gc_collect() + @threading_helper.requires_working_threading() @support.bigmemtest(size=200, memuse=32*2**20, dry_run=False) def test_create_many_threaded(self, size): alive = [] @@ -80,9 +81,12 @@ def test_create_interpreter_no_memory(self): import _testcapi assertion = self.assertRaises(InterpreterError) - _testcapi.set_nomemory(0, 1) - with assertion: - _interpreters.create() + try: + _testcapi.set_nomemory(0, 1) + with assertion: + _interpreters.create() + finally: + _testcapi.remove_mem_hooks() if __name__ == '__main__': From a69d0fc41ef339378022f1c0190a9692cb276a7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Sat, 27 Jun 2026 09:21:15 -0700 Subject: [PATCH 09/13] gh-151029: Fix sys.remote_exec() unable to find writable memory when libpython replaced on disk (#151032) Co-authored-by: Pablo Galindo --- Lib/test/test_sys.py | 94 ++++++++++++- ...-06-06-20-15-08.gh-issue-151029.A33CKK.rst | 2 + Python/remote_debug.h | 129 +++++++++++++++++- 3 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-06-06-20-15-08.gh-issue-151029.A33CKK.rst diff --git a/Lib/test/test_sys.py b/Lib/test/test_sys.py index f40da0b79aa479..9f686e289c8f3b 100644 --- a/Lib/test/test_sys.py +++ b/Lib/test/test_sys.py @@ -6,6 +6,7 @@ import operator import os import random +import shutil import socket import struct import subprocess @@ -2003,7 +2004,8 @@ def tearDown(self): test.support.reap_children() def _run_remote_exec_test(self, script_code, python_args=None, env=None, - prologue='', + python_executable=None, prologue='', + after_ready=None, script_path=os_helper.TESTFN + '_remote.py'): # Create the script that will be remotely executed self.addCleanup(os_helper.unlink, script_path) @@ -2051,7 +2053,10 @@ def _run_remote_exec_test(self, script_code, python_args=None, env=None, ''') # Start the target process and capture its output - cmd = [sys.executable] + if python_executable is None: + python_executable = sys.executable + + cmd = [python_executable] if python_args: cmd.extend(python_args) cmd.append(target) @@ -2076,6 +2081,9 @@ def _run_remote_exec_test(self, script_code, python_args=None, env=None, response = client_socket.recv(1024) self.assertEqual(response, b"ready") + if after_ready is not None: + after_ready(proc) + # Try remote exec on the target process sys.remote_exec(proc.pid, script_path) @@ -2098,6 +2106,19 @@ def _run_remote_exec_test(self, script_code, python_args=None, env=None, proc.terminate() proc.wait(timeout=SHORT_TIMEOUT) + def _run_remote_exec_with_deleted_mapping(self, deleted_path, **kwargs): + def delete_loaded_mapping(proc): + os_helper.unlink(deleted_path) + with open(f'/proc/{proc.pid}/maps', encoding='utf-8') as maps: + self.assertIn(f'{deleted_path} (deleted)', maps.read()) + + script = 'print("Remote script executed successfully!")' + returncode, stdout, stderr = self._run_remote_exec_test( + script, after_ready=delete_loaded_mapping, **kwargs) + self.assertEqual(returncode, 0) + self.assertIn(b"Remote script executed successfully!", stdout) + self.assertEqual(stderr, b"") + def test_remote_exec(self): """Test basic remote exec functionality""" script = 'print("Remote script executed successfully!")' @@ -2224,6 +2245,75 @@ def test_remote_exec_invalid_script_path(self): with self.assertRaises(OSError): sys.remote_exec(os.getpid(), "invalid_script_path") + @unittest.skipUnless(sys.platform == 'linux', 'Linux-only regression test') + @unittest.skipUnless( + sysconfig.get_config_var('Py_ENABLE_SHARED') == 1, + 'requires a shared libpython build') + def test_remote_exec_deleted_libpython(self): + """Test remote exec when the target libpython was deleted.""" + build_dir = sysconfig.get_config_var('abs_builddir') + ldlibrary = sysconfig.get_config_var('LDLIBRARY') + instsoname = sysconfig.get_config_var('INSTSONAME') + if not build_dir or not ldlibrary or not instsoname: + self.skipTest('cannot determine shared libpython location') + + source_libpython = os.path.join(build_dir, instsoname) + if not os.path.exists(source_libpython): + self.skipTest(f'{source_libpython!r} does not exist') + + with os_helper.temp_dir() as lib_dir: + copied_libpython = os.path.join(lib_dir, instsoname) + shutil.copy2(source_libpython, copied_libpython) + if ldlibrary != instsoname: + os.symlink(instsoname, os.path.join(lib_dir, ldlibrary)) + + env = os.environ.copy() + ld_library_path = env.get('LD_LIBRARY_PATH') + env['LD_LIBRARY_PATH'] = lib_dir if not ld_library_path else ( + lib_dir + os.pathsep + ld_library_path) + + self._run_remote_exec_with_deleted_mapping(copied_libpython, + env=env) + + @unittest.skipUnless(sys.platform == 'linux', 'Linux-only regression test') + @unittest.skipUnless( + sysconfig.get_config_var('Py_ENABLE_SHARED') == 0, + 'requires a static Python build') + def test_remote_exec_deleted_static_executable(self): + """Test remote exec when the target static executable was deleted.""" + build_dir = sysconfig.get_config_var('abs_builddir') + srcdir = sysconfig.get_config_var('srcdir') + if not build_dir or not srcdir: + self.skipTest('cannot determine build-tree locations') + + pybuilddir_txt = os.path.join(build_dir, 'pybuilddir.txt') + if not os.path.exists(pybuilddir_txt): + self.skipTest(f'{pybuilddir_txt!r} does not exist') + + with open(pybuilddir_txt, encoding='utf-8') as pybuilddir_file: + pybuilddir = pybuilddir_file.read().strip() + source_ext_dir = os.path.join(build_dir, pybuilddir) + if not os.path.isdir(source_ext_dir): + self.skipTest(f'{source_ext_dir!r} does not exist') + + with os_helper.temp_dir() as copied_root: + copied_build_dir = os.path.join(copied_root, 'build') + copied_pybuilddir = os.path.join(copied_build_dir, pybuilddir) + os.makedirs(os.path.dirname(copied_pybuilddir)) + os.symlink(os.path.join(srcdir, 'Lib'), + os.path.join(copied_root, 'Lib')) + os.symlink(source_ext_dir, copied_pybuilddir) + shutil.copy2(pybuilddir_txt, + os.path.join(copied_build_dir, 'pybuilddir.txt')) + + copied_python = os.path.join(copied_build_dir, + os.path.basename(sys.executable)) + shutil.copy2(sys.executable, copied_python) + + self._run_remote_exec_with_deleted_mapping( + copied_python, python_args=['-S'], + python_executable=copied_python) + def test_remote_exec_in_process_without_debug_fails_envvar(self): """Test remote exec in a process without remote debugging enabled""" script = os_helper.TESTFN + '_remote.py' diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-06-06-20-15-08.gh-issue-151029.A33CKK.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-06-20-15-08.gh-issue-151029.A33CKK.rst new file mode 100644 index 00000000000000..cbfe5952627ad8 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-06-20-15-08.gh-issue-151029.A33CKK.rst @@ -0,0 +1,2 @@ +On Linux, fix :func:`sys.remote_exec` unable to find remote writable memory +when ``libpython`` replaced on disk. diff --git a/Python/remote_debug.h b/Python/remote_debug.h index 6fecc23502b46e..7b7380d25bf496 100644 --- a/Python/remote_debug.h +++ b/Python/remote_debug.h @@ -781,6 +781,106 @@ search_elf_file_for_section( return result; } +static const char * +find_debug_cookie(const char *buffer, size_t len) +{ + const char *cookie = _Py_Debug_Cookie; + const size_t cookie_len = sizeof(_Py_Debug_Cookie) - 1; + if (len < cookie_len) { + return NULL; + } + + size_t pos = 0; + size_t last = len - cookie_len; + while (pos <= last) { + const char *candidate = memchr( + buffer + pos, cookie[0], last - pos + 1); + if (candidate == NULL) { + return NULL; + } + pos = (size_t)(candidate - buffer); + if (memcmp(candidate, cookie, cookie_len) == 0) { + return candidate; + } + pos++; + } + return NULL; +} + +static int +linux_map_path_is_deleted(const char *path) +{ + static const char deleted_suffix[] = " (deleted)"; + size_t path_len = strlen(path); + size_t suffix_len = sizeof(deleted_suffix) - 1; + return path_len >= suffix_len + && strcmp(path + path_len - suffix_len, deleted_suffix) == 0; +} + +static int +linux_map_perms_are_readwrite(const char *perms) +{ + return perms[0] == 'r' && perms[1] == 'w'; +} + +static uintptr_t +scan_linux_mapping_for_pyruntime_cookie( + proc_handle_t *handle, + uintptr_t start, + uintptr_t end) +{ + if (end <= start) { + return 0; + } + + const size_t cookie_len = sizeof(_Py_Debug_Cookie) - 1; + const size_t overlap = cookie_len - 1; + const size_t chunk_size = 1024 * 1024; + char *buffer = PyMem_Malloc(chunk_size); + if (buffer == NULL) { + PyErr_NoMemory(); + _set_debug_exception_cause(PyExc_MemoryError, + "Cannot allocate memory while scanning PID %d for PyRuntime cookie", + handle->pid); + return 0; + } + + uintptr_t retval = 0; + uintptr_t mapping_size = end - start; + uintptr_t offset = 0; + while (offset < mapping_size) { + uintptr_t remaining = mapping_size - offset; + size_t wanted = remaining > chunk_size + ? chunk_size : (size_t)remaining; + if (_Py_RemoteDebug_ReadRemoteMemory( + handle, start + offset, wanted, buffer) < 0) { + if (_Py_RemoteDebug_HasPermissionError()) { + goto exit; + } + // A candidate mapping can disappear or contain unreadable holes while + // the target process keeps running. Treat those as non-matches and + // keep scanning other candidate mappings. + PyErr_Clear(); + } + else { + const char *hit = find_debug_cookie(buffer, wanted); + if (hit != NULL) { + retval = start + offset + (uintptr_t)(hit - buffer); + goto exit; + } + } + + if (wanted <= overlap) { + break; + } + offset += wanted - overlap; + } + +exit: + PyMem_Free(buffer); + return retval; +} + static uintptr_t search_linux_map_for_section(proc_handle_t *handle, const char* secname, const char* substr, section_validator_t validator) @@ -835,16 +935,22 @@ search_linux_map_for_section(proc_handle_t *handle, const char* secname, const c linelen = 0; unsigned long start = 0; - unsigned long path_pos = 0; - sscanf(line, "%lx-%*x %*s %*s %*s %*s %ln", &start, &path_pos); + unsigned long end = 0; + int path_pos = 0; + char perms[5] = ""; + int fields = sscanf(line, "%lx-%lx %4s %*s %*s %*s %n", + &start, &end, perms, &path_pos); - if (!path_pos) { + if (fields < 3 || !path_pos) { // Line didn't match our format string. This shouldn't be // possible, but let's be defensive and skip the line. continue; } const char *path = line + path_pos; + if (path[0] == '\0') { + continue; + } if (path[0] == '[' && path[strlen(path)-1] == ']') { // Skip [heap], [stack], [anon:cpython:pymalloc], etc. continue; @@ -858,8 +964,21 @@ search_linux_map_for_section(proc_handle_t *handle, const char* secname, const c } if (strstr(filename, substr)) { - PyErr_Clear(); - retval = search_elf_file_for_section(handle, secname, start, path); + int deleted_pyruntime_mapping = + strcmp(secname, "PyRuntime") == 0 + && linux_map_path_is_deleted(path); + if (deleted_pyruntime_mapping + && linux_map_perms_are_readwrite(perms)) { + PyErr_Clear(); + retval = scan_linux_mapping_for_pyruntime_cookie( + handle, (uintptr_t)start, (uintptr_t)end); + } + if (!deleted_pyruntime_mapping + && retval == 0 && !PyErr_Occurred()) { + PyErr_Clear(); + retval = search_elf_file_for_section( + handle, secname, start, path); + } if (retval) { if (validator == NULL || validator(handle, retval)) { break; From 876c06cab9e824747d708a031c6b81b1f8a4f8dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Sat, 27 Jun 2026 17:55:57 +0100 Subject: [PATCH 10/13] gh-152434: Block --async-aware with --binary (#152444) The binary writer does not currently handle AwaitedInfo samples and crashes when running in --async-aware mode. --- Lib/profiling/sampling/cli.py | 4 +++- .../test_sampling_profiler/test_cli.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Lib/profiling/sampling/cli.py b/Lib/profiling/sampling/cli.py index a5d9573ae6b6dd..0330c15c014545 100644 --- a/Lib/profiling/sampling/cli.py +++ b/Lib/profiling/sampling/cli.py @@ -875,13 +875,15 @@ def _validate_args(args, parser): if hasattr(args, 'live') and args.live: parser.error("--subprocesses is incompatible with --live mode.") - # Async-aware mode is incompatible with --native, --no-gc, --mode, and --all-threads + # Async-aware mode is incompatible with options that need thread data. if getattr(args, 'async_aware', False): issues = [] if getattr(args, 'native', False): issues.append("--native") if not getattr(args, 'gc', True): issues.append("--no-gc") + if getattr(args, 'format', None) == "binary": + issues.append("--binary") if hasattr(args, 'mode') and args.mode != "wall": issues.append(f"--mode={args.mode}") if hasattr(args, 'all_threads') and args.all_threads: diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_cli.py b/Lib/test/test_profiling/test_sampling_profiler/test_cli.py index 9c0734ac804e1b..0181095ca21e37 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_cli.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_cli.py @@ -866,6 +866,23 @@ def test_async_aware_incompatible_with_all_threads(self): self.assertIn("--all-threads", error_msg) self.assertIn("incompatible with --async-aware", error_msg) + def test_async_aware_incompatible_with_binary(self): + """Test --async-aware is incompatible with --binary.""" + test_args = ["profiling.sampling.cli", "attach", "12345", + "--async-aware", "--binary"] + + with ( + mock.patch("sys.argv", test_args), + mock.patch("sys.stderr", io.StringIO()) as mock_stderr, + self.assertRaises(SystemExit) as cm, + ): + main() + + self.assertEqual(cm.exception.code, 2) # argparse error + error_msg = mock_stderr.getvalue() + self.assertIn("--binary", error_msg) + self.assertIn("incompatible with --async-aware", error_msg) + @unittest.skipIf(is_emscripten, "subprocess not available") def test_run_nonexistent_script_exits_cleanly(self): """Test that running a non-existent script exits with a clean error.""" From 8cda6ae2f1f86f2d26c29586ffc9687b410abfcf Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Sat, 27 Jun 2026 18:56:31 +0200 Subject: [PATCH 11/13] gh-151613: Fix remote debugging frame cache ABA (#151614) The remote debugging frame cache previously used only the last_profiled_frame address as its cache anchor. If a frame returned and a later frame reused the same _PyInterpreterFrame address, the profiler could accept a stale cache entry and splice parent frames from a different call chain into the current stack. This adds a last_profiled_frame_seq counter next to last_profiled_frame, increments it when the anchor advances, stores it in frame cache entries, and validates cache hits against both the frame address and the sequence. Cache miss walks now copy stack chunks before storing new cache entries so stored continuations come from a stable snapshot. The new regression test exercises alternating call chains and checks that cached stacks never contain frames from both branches. --- Include/cpython/pystate.h | 1 + Include/internal/pycore_debug_offsets.h | 2 + Include/internal/pycore_interpframe.h | 3 + InternalDocs/frames.md | 30 +++--- ...-06-13-11-57-48.gh-issue-151436.UEDowO.rst | 2 +- ...-06-17-22-31-57.gh-issue-151613.n0nua1.rst | 3 + Modules/_remote_debugging/_remote_debugging.h | 17 +++- .../debug_offsets_validation.h | 5 +- Modules/_remote_debugging/frame_cache.c | 96 ++++++++++++++++--- Modules/_remote_debugging/frames.c | 59 ++++++++---- Modules/_remote_debugging/module.c | 4 +- Modules/_remote_debugging/threads.c | 14 +-- Python/pystate.c | 2 + 13 files changed, 182 insertions(+), 56 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-17-22-31-57.gh-issue-151613.n0nua1.rst diff --git a/Include/cpython/pystate.h b/Include/cpython/pystate.h index a9d97e47e005df..f367146e262bfe 100644 --- a/Include/cpython/pystate.h +++ b/Include/cpython/pystate.h @@ -143,6 +143,7 @@ struct _ts { struct _PyInterpreterFrame *base_frame; struct _PyInterpreterFrame *last_profiled_frame; + uintptr_t last_profiled_frame_seq; Py_tracefunc c_profilefunc; Py_tracefunc c_tracefunc; diff --git a/Include/internal/pycore_debug_offsets.h b/Include/internal/pycore_debug_offsets.h index 18490f98a918a7..6e1eb573c8c2c8 100644 --- a/Include/internal/pycore_debug_offsets.h +++ b/Include/internal/pycore_debug_offsets.h @@ -104,6 +104,7 @@ typedef struct _Py_DebugOffsets { uint64_t current_frame; uint64_t base_frame; uint64_t last_profiled_frame; + uint64_t last_profiled_frame_seq; uint64_t thread_id; uint64_t native_thread_id; uint64_t datastack_chunk; @@ -294,6 +295,7 @@ typedef struct _Py_DebugOffsets { .current_frame = offsetof(PyThreadState, current_frame), \ .base_frame = offsetof(PyThreadState, base_frame), \ .last_profiled_frame = offsetof(PyThreadState, last_profiled_frame), \ + .last_profiled_frame_seq = offsetof(PyThreadState, last_profiled_frame_seq), \ .thread_id = offsetof(PyThreadState, thread_id), \ .native_thread_id = offsetof(PyThreadState, native_thread_id), \ .datastack_chunk = offsetof(PyThreadState, datastack_chunk), \ diff --git a/Include/internal/pycore_interpframe.h b/Include/internal/pycore_interpframe.h index 3608a7f3bce894..9809cd292995f0 100644 --- a/Include/internal/pycore_interpframe.h +++ b/Include/internal/pycore_interpframe.h @@ -319,12 +319,15 @@ _PyThreadState_GetFrame(PyThreadState *tstate) // This avoids corrupting the cache when transient frames (called and returned // between profiler samples) update last_profiled_frame to addresses the // profiler never saw. +// The sequence distinguishes this anchor from a later frame that reuses the +// same _PyInterpreterFrame address. #define _PyThreadState_UpdateLastProfiledFrame(tstate, frame, previous) \ do { \ PyThreadState *tstate_ = (tstate); \ _PyInterpreterFrame *frame_ = (frame); \ if (tstate_->last_profiled_frame == frame_) { \ tstate_->last_profiled_frame = (previous); \ + tstate_->last_profiled_frame_seq++; \ } \ } while (0) diff --git a/InternalDocs/frames.md b/InternalDocs/frames.md index 60ab2055afa7b1..475ca75e28e1a5 100644 --- a/InternalDocs/frames.md +++ b/InternalDocs/frames.md @@ -142,22 +142,26 @@ since the frame chain may have been in an inconsistent state due to concurrent u ### Remote Profiling Frame Cache -The `last_profiled_frame` field in `PyThreadState` supports an optimization for -remote profilers that sample call stacks from external processes. When a remote -profiler reads the call stack, it writes the current frame address to this field. -The eval loop then keeps this pointer valid by updating it to the parent frame -whenever a frame returns (in `_PyEval_FrameClearAndPop`). +The `last_profiled_frame` and `last_profiled_frame_seq` fields in +`PyThreadState` support an optimization for remote profilers that sample call +stacks from external processes. When a remote profiler reads the call stack, it +writes the current frame address to `last_profiled_frame`. The eval loop then +keeps this pointer valid by updating it to the parent frame whenever a frame +returns (in `_PyEval_FrameClearAndPop`) and increments the sequence. This creates a "high-water mark" that always points to a frame still on the stack. On subsequent samples, the profiler can walk from `current_frame` until it reaches -`last_profiled_frame`, knowing that frames from that point downward are unchanged -and can be retrieved from a cache. This significantly reduces the amount of remote -memory reads needed when call stacks are deep and stable at their base. - -The update in `_PyEval_FrameClearAndPop` is guarded: it only writes when -`last_profiled_frame` is non-NULL AND matches the frame being popped. This -prevents transient frames (called and returned between profiler samples) from -corrupting the cache pointer, while avoiding any overhead when profiling is inactive. +`last_profiled_frame`, then validate the pointer and sequence before using cached +callers. This prevents a later frame that reuses the same `_PyInterpreterFrame` +address from being mistaken for the sampled frame. The cache significantly +reduces the amount of remote memory reads needed when call stacks are deep and +stable at their base. + +The update in `_PyEval_FrameClearAndPop` is guarded: it only advances the +pointer and sequence when `last_profiled_frame` is non-NULL AND matches the +frame being popped. This prevents transient frames (called and returned between +profiler samples) from corrupting the cache anchor, while avoiding any overhead +when profiling is inactive. ### The Instruction Pointer diff --git a/Misc/NEWS.d/next/Library/2026-06-13-11-57-48.gh-issue-151436.UEDowO.rst b/Misc/NEWS.d/next/Library/2026-06-13-11-57-48.gh-issue-151436.UEDowO.rst index 1d1aadbf57be48..6b10b03ba02fdf 100644 --- a/Misc/NEWS.d/next/Library/2026-06-13-11-57-48.gh-issue-151436.UEDowO.rst +++ b/Misc/NEWS.d/next/Library/2026-06-13-11-57-48.gh-issue-151436.UEDowO.rst @@ -1,4 +1,4 @@ -Fix skewed stack trackes in the Tachyon profiler when caching is enabled and +Fix skewed stack traces in the Tachyon profiler when caching is enabled and when generators and coroutines are profiled, by updating ``tstate->last_profiled_frame`` at every frame-removal site. The issue resulted in total erasure of some callers. Patch by Maurycy Pawłowski-Wieroński. diff --git a/Misc/NEWS.d/next/Library/2026-06-17-22-31-57.gh-issue-151613.n0nua1.rst b/Misc/NEWS.d/next/Library/2026-06-17-22-31-57.gh-issue-151613.n0nua1.rst new file mode 100644 index 00000000000000..fbf3ce47ff2739 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-17-22-31-57.gh-issue-151613.n0nua1.rst @@ -0,0 +1,3 @@ +Fix another way the Tachyon profiler frame cache could produce impossible +mixed stack traces when ``_PyInterpreterFrame`` addresses are reused, by +validating cached frame anchors with a sequence counter. diff --git a/Modules/_remote_debugging/_remote_debugging.h b/Modules/_remote_debugging/_remote_debugging.h index 635e6e208902af..fa37fb7b2167ec 100644 --- a/Modules/_remote_debugging/_remote_debugging.h +++ b/Modules/_remote_debugging/_remote_debugging.h @@ -225,9 +225,15 @@ typedef struct { #define FRAME_CACHE_MAX_THREADS 32 #define FRAME_CACHE_MAX_FRAMES 1024 +typedef struct { + uintptr_t frame; + uintptr_t seq; +} FrameCacheAnchor; + typedef struct { uint64_t thread_id; // 0 = empty slot uintptr_t thread_state_addr; + uintptr_t last_profiled_frame_seq; // sequence paired with addrs[0] uintptr_t addrs[FRAME_CACHE_MAX_FRAMES]; Py_ssize_t num_addrs; PyObject *thread_id_obj; // owned reference, NULL if empty @@ -434,7 +440,7 @@ typedef struct { uintptr_t thread_state_addr; // Owning thread state address uintptr_t base_frame_addr; // Sentinel at bottom (for validation) uintptr_t gc_frame; // GC frame address (0 if not tracking) - uintptr_t last_profiled_frame; // Last cached frame (0 if no cache) + FrameCacheAnchor last_profiled; // Last cached frame anchor StackChunkList *chunks; // Pre-copied stack chunks int skip_first_frame; // Skip frame_addr itself (continue from its caller) RemoteReadPrefetch prefetch; // Optional already-read thread/frame buffers @@ -622,15 +628,21 @@ extern void frame_cache_cleanup(RemoteUnwinderObject *unwinder); extern FrameCacheEntry *frame_cache_find(RemoteUnwinderObject *unwinder, uint64_t thread_id); extern FrameCacheEntry *frame_cache_find_by_tstate(RemoteUnwinderObject *unwinder, uintptr_t tstate_addr); extern int clear_last_profiled_frames(RemoteUnwinderObject *unwinder); +extern int set_last_profiled_frame(RemoteUnwinderObject *unwinder, uintptr_t tstate_addr, uintptr_t frame_addr); extern void frame_cache_invalidate_stale(RemoteUnwinderObject *unwinder, PyObject *result); extern int frame_cache_lookup_and_extend( RemoteUnwinderObject *unwinder, uint64_t thread_id, - uintptr_t last_profiled_frame, + uintptr_t thread_state_addr, + FrameCacheAnchor anchor, PyObject *frame_info, uintptr_t *frame_addrs, Py_ssize_t *num_addrs, Py_ssize_t max_addrs); +extern int frame_cache_anchor_matches( + RemoteUnwinderObject *unwinder, + uintptr_t thread_state_addr, + FrameCacheAnchor anchor); // Returns: 1 = stored, 0 = not stored (graceful), -1 = error // Only stores complete stacks that reach base_frame_addr extern int frame_cache_store( @@ -640,6 +652,7 @@ extern int frame_cache_store( const uintptr_t *addrs, Py_ssize_t num_addrs, uintptr_t thread_state_addr, + uintptr_t last_profiled_frame_seq, uintptr_t base_frame_addr, uintptr_t last_frame_visited); diff --git a/Modules/_remote_debugging/debug_offsets_validation.h b/Modules/_remote_debugging/debug_offsets_validation.h index f070f03ac459dc..c0c01a0a639e19 100644 --- a/Modules/_remote_debugging/debug_offsets_validation.h +++ b/Modules/_remote_debugging/debug_offsets_validation.h @@ -31,7 +31,7 @@ #define FIELD_SIZE(type, member) sizeof(((type *)0)->member) enum { - PY_REMOTE_DEBUG_OFFSETS_TOTAL_SIZE = 880, + PY_REMOTE_DEBUG_OFFSETS_TOTAL_SIZE = 888, PY_REMOTE_ASYNC_DEBUG_OFFSETS_TOTAL_SIZE = 104, }; @@ -261,7 +261,8 @@ validate_fixed_field( APPLY(thread_state, next, sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size); \ APPLY(thread_state, current_frame, sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size); \ APPLY(thread_state, base_frame, sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size); \ - APPLY(thread_state, last_profiled_frame, sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size) + APPLY(thread_state, last_profiled_frame, sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size); \ + APPLY(thread_state, last_profiled_frame_seq, sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size) #define PY_REMOTE_DEBUG_INTERPRETER_STATE_FIELDS(APPLY, buffer_size) \ APPLY(interpreter_state, id, sizeof(int64_t), _Alignof(int64_t), buffer_size); \ diff --git a/Modules/_remote_debugging/frame_cache.c b/Modules/_remote_debugging/frame_cache.c index 19fc406bca9ac9..1b2d9a60f408b9 100644 --- a/Modules/_remote_debugging/frame_cache.c +++ b/Modules/_remote_debugging/frame_cache.c @@ -147,25 +147,87 @@ frame_cache_invalidate_stale(RemoteUnwinderObject *unwinder, PyObject *result) Py_CLEAR(unwinder->frame_cache[i].frame_list); unwinder->frame_cache[i].thread_id = 0; unwinder->frame_cache[i].thread_state_addr = 0; + unwinder->frame_cache[i].last_profiled_frame_seq = 0; unwinder->frame_cache[i].num_addrs = 0; STATS_INC(unwinder, stale_cache_invalidations); } } } +static int +read_last_profiled_anchor(RemoteUnwinderObject *unwinder, + uintptr_t thread_state_addr, + FrameCacheAnchor *anchor) +{ + uintptr_t frame_offset = (uintptr_t)unwinder->debug_offsets.thread_state.last_profiled_frame; + uintptr_t seq_offset = (uintptr_t)unwinder->debug_offsets.thread_state.last_profiled_frame_seq; + + // These fields are adjacent in PyThreadState. Read them together when the + // layout allows it so validation uses a pointer and sequence from the same + // remote-memory read. + if (seq_offset == frame_offset + sizeof(uintptr_t)) { + uintptr_t live_anchor[2]; + if (_Py_RemoteDebug_ReadRemoteMemory(&unwinder->handle, + thread_state_addr + frame_offset, + sizeof(live_anchor), + live_anchor) < 0) { + return -1; + } + anchor->frame = live_anchor[0]; + anchor->seq = live_anchor[1]; + return 0; + } + + if (read_ptr(unwinder, thread_state_addr + frame_offset, &anchor->frame) < 0) { + return -1; + } + return read_ptr(unwinder, thread_state_addr + seq_offset, &anchor->seq); +} + +static Py_ssize_t +find_cached_frame_addr(const FrameCacheEntry *entry, uintptr_t frame_addr, + uintptr_t *real_pops) +{ + *real_pops = 0; + for (Py_ssize_t i = 0; i < entry->num_addrs; i++) { + if (entry->addrs[i] == frame_addr) { + return i; + } + if (entry->addrs[i] != 0) { + (*real_pops)++; + } + } + return -1; +} + +int +frame_cache_anchor_matches( + RemoteUnwinderObject *unwinder, + uintptr_t thread_state_addr, + FrameCacheAnchor anchor) +{ + FrameCacheAnchor live_anchor = {0, 0}; + if (read_last_profiled_anchor(unwinder, thread_state_addr, &live_anchor) < 0) { + PyErr_Clear(); + return 0; + } + return live_anchor.frame == anchor.frame && live_anchor.seq == anchor.seq; +} + // Find last_profiled_frame in cache and extend frame_info with cached continuation // If frame_addrs is provided (not NULL), also extends it with cached addresses int frame_cache_lookup_and_extend( RemoteUnwinderObject *unwinder, uint64_t thread_id, - uintptr_t last_profiled_frame, + uintptr_t thread_state_addr, + FrameCacheAnchor anchor, PyObject *frame_info, uintptr_t *frame_addrs, Py_ssize_t *num_addrs, Py_ssize_t max_addrs) { - if (!unwinder->frame_cache || last_profiled_frame == 0) { + if (!unwinder->frame_cache || anchor.frame == 0) { return 0; } @@ -173,24 +235,31 @@ frame_cache_lookup_and_extend( if (!entry || !entry->frame_list) { return 0; } + if (entry->thread_state_addr != thread_state_addr) { + return 0; + } assert(entry->num_addrs >= 0 && entry->num_addrs <= FRAME_CACHE_MAX_FRAMES); - // Find the index where last_profiled_frame matches - Py_ssize_t start_idx = -1; - for (Py_ssize_t i = 0; i < entry->num_addrs; i++) { - if (entry->addrs[i] == last_profiled_frame) { - start_idx = i; - break; - } - } - + uintptr_t real_pops = 0; + Py_ssize_t start_idx = find_cached_frame_addr(entry, anchor.frame, &real_pops); if (start_idx < 0) { return 0; // Not found } assert(start_idx < entry->num_addrs); + // Synthetic marker frames (/) are stored as addr-0 entries but + // never increment last_profiled_frame_seq in the target (only real frame + // pops do). Count the real frames before start_idx so the sequence check is + // not thrown off by markers sitting between the leaf and the anchor. + if (entry->last_profiled_frame_seq + real_pops != anchor.seq) { + return 0; + } + Py_ssize_t num_frames = PyList_GET_SIZE(entry->frame_list); + if (start_idx >= num_frames) { + return 0; + } // Extend frame_info with frames ABOVE start_idx (not including it). // The frame at start_idx (last_profiled_frame) was the executing frame @@ -200,6 +269,9 @@ frame_cache_lookup_and_extend( if (cache_start >= num_frames) { return 0; // Nothing above last_profiled_frame to extend with } + if (!frame_cache_anchor_matches(unwinder, thread_state_addr, anchor)) { + return 0; + } PyObject *slice = PyList_GetSlice(entry->frame_list, cache_start, num_frames); if (!slice) { @@ -235,6 +307,7 @@ frame_cache_store( const uintptr_t *addrs, Py_ssize_t num_addrs, uintptr_t thread_state_addr, + uintptr_t last_profiled_frame_seq, uintptr_t base_frame_addr, uintptr_t last_frame_visited) { @@ -277,6 +350,7 @@ frame_cache_store( } entry->thread_id = thread_id; entry->thread_state_addr = thread_state_addr; + entry->last_profiled_frame_seq = last_profiled_frame_seq; if (entry->thread_id_obj == NULL) { entry->thread_id_obj = PyLong_FromUnsignedLongLong(thread_id); if (entry->thread_id_obj == NULL) { diff --git a/Modules/_remote_debugging/frames.c b/Modules/_remote_debugging/frames.c index e7d2a276439026..46968acc6ff1fe 100644 --- a/Modules/_remote_debugging/frames.c +++ b/Modules/_remote_debugging/frames.c @@ -416,7 +416,7 @@ process_frame_chain( Py_DECREF(frame); } - if (ctx->last_profiled_frame != 0 && frame_addr == ctx->last_profiled_frame) { + if (ctx->last_profiled.frame != 0 && frame_addr == ctx->last_profiled.frame) { ctx->stopped_at_cached_frame = 1; break; } @@ -437,14 +437,23 @@ process_frame_chain( return 0; } -// Clear last_profiled_frame for all threads in the target process. -// This must be called at the start of profiling to avoid stale values -// from previous profilers causing us to stop frame walking early. +int +set_last_profiled_frame(RemoteUnwinderObject *unwinder, uintptr_t tstate_addr, + uintptr_t frame_addr) +{ + uintptr_t lpf_addr = tstate_addr + + (uintptr_t)unwinder->debug_offsets.thread_state.last_profiled_frame; + return _Py_RemoteDebug_WriteRemoteMemory(&unwinder->handle, lpf_addr, + sizeof(uintptr_t), &frame_addr); +} + +// Clear the profiler anchor frame for all threads in the target process. The +// sequence is intentionally preserved: a zero frame disables cache lookup, and +// the next profiler-owned anchor should use the target's current generation. int clear_last_profiled_frames(RemoteUnwinderObject *unwinder) { uintptr_t current_interp = unwinder->interpreter_addr; - uintptr_t zero = 0; const size_t MAX_INTERPRETERS = 256; size_t interp_count = 0; @@ -467,11 +476,8 @@ clear_last_profiled_frames(RemoteUnwinderObject *unwinder) size_t thread_count = 0; while (tstate_addr != 0 && thread_count < MAX_THREADS_PER_INTERP) { thread_count++; - // Clear last_profiled_frame - uintptr_t lpf_addr = tstate_addr + unwinder->debug_offsets.thread_state.last_profiled_frame; - if (_Py_RemoteDebug_WriteRemoteMemory(&unwinder->handle, lpf_addr, - sizeof(uintptr_t), &zero) < 0) { - // Non-fatal: just continue + uintptr_t no_frame = 0; + if (set_last_profiled_frame(unwinder, tstate_addr, no_frame) < 0) { PyErr_Clear(); } @@ -512,10 +518,10 @@ try_full_cache_hit( const FrameWalkContext *ctx, uint64_t thread_id) { - if (!unwinder->frame_cache || ctx->last_profiled_frame == 0) { + if (!unwinder->frame_cache || ctx->last_profiled.frame == 0) { return 0; } - if (ctx->frame_addr != ctx->last_profiled_frame) { + if (ctx->frame_addr != ctx->last_profiled.frame) { return 0; } @@ -523,10 +529,16 @@ try_full_cache_hit( if (!entry || !entry->frame_list) { return 0; } + if (entry->thread_state_addr != ctx->thread_state_addr) { + return 0; + } if (entry->num_addrs == 0 || entry->addrs[0] != ctx->frame_addr) { return 0; } + if (entry->last_profiled_frame_seq != ctx->last_profiled.seq) { + return 0; + } PyObject *current_frame = NULL; uintptr_t code_object_addr = 0; @@ -544,6 +556,11 @@ try_full_cache_hit( if (parse_result < 0) { return -1; } + if (!frame_cache_anchor_matches(unwinder, ctx->thread_state_addr, + ctx->last_profiled)) { + Py_XDECREF(current_frame); + return 0; + } if (current_frame != NULL) { if (PyList_Append(ctx->frame_info, current_frame) < 0) { @@ -582,9 +599,12 @@ collect_frames_with_cache( assert(ctx->chunks != NULL); + // Cache misses copy stack chunks before walking. Frames found there are + // parsed from a stable snapshot, which keeps moving stacks from seeding the + // cache with an impossible parent chain. if (ctx->chunks->count == 0) { if (copy_stack_chunks(unwinder, ctx->thread_state_addr, ctx->chunks) < 0) { - PyErr_Clear(); + return -1; } } @@ -598,7 +618,9 @@ collect_frames_with_cache( if (ctx->stopped_at_cached_frame) { Py_ssize_t frames_before_cache = PyList_GET_SIZE(ctx->frame_info); - int cache_result = frame_cache_lookup_and_extend(unwinder, thread_id, ctx->last_profiled_frame, + int cache_result = frame_cache_lookup_and_extend(unwinder, thread_id, + ctx->thread_state_addr, + ctx->last_profiled, ctx->frame_info, ctx->frame_addrs, &ctx->num_addrs, ctx->max_addrs); if (cache_result < 0) { @@ -610,7 +632,7 @@ collect_frames_with_cache( // Continue walking from last_profiled_frame, skipping it (already processed) Py_ssize_t frames_before_walk = PyList_GET_SIZE(ctx->frame_info); FrameWalkContext continue_ctx = { - .frame_addr = ctx->last_profiled_frame, + .frame_addr = ctx->last_profiled.frame, .base_frame_addr = ctx->base_frame_addr, .gc_frame = ctx->gc_frame, .chunks = ctx->chunks, @@ -634,13 +656,14 @@ collect_frames_with_cache( STATS_ADD(unwinder, frames_read_from_cache, PyList_GET_SIZE(ctx->frame_info) - frames_before_cache); } } else { - if (ctx->last_profiled_frame == 0) { + if (ctx->last_profiled.frame == 0) { STATS_INC(unwinder, frame_cache_misses); } } - if (frame_cache_store(unwinder, thread_id, ctx->frame_info, ctx->frame_addrs, ctx->num_addrs, - ctx->thread_state_addr, ctx->base_frame_addr, + if (frame_cache_store(unwinder, thread_id, ctx->frame_info, ctx->frame_addrs, + ctx->num_addrs, ctx->thread_state_addr, + ctx->last_profiled.seq, ctx->base_frame_addr, ctx->last_frame_visited) < 0) { return -1; } diff --git a/Modules/_remote_debugging/module.c b/Modules/_remote_debugging/module.c index 36115f20d9d4cc..7979cd43c6a127 100644 --- a/Modules/_remote_debugging/module.c +++ b/Modules/_remote_debugging/module.c @@ -475,8 +475,8 @@ _remote_debugging_RemoteUnwinder___init___impl(RemoteUnwinderObject *self, return -1; } - // Clear stale last_profiled_frame values from previous profilers - // This prevents us from stopping frame walking early due to stale values + // Clear stale profiler anchors from previous profilers. This prevents us + // from stopping frame walking early due to stale frame pointers. if (cache_frames) { clear_last_profiled_frames(self); } diff --git a/Modules/_remote_debugging/threads.c b/Modules/_remote_debugging/threads.c index 81735e85395ac9..29f22f14c9b29f 100644 --- a/Modules/_remote_debugging/threads.c +++ b/Modules/_remote_debugging/threads.c @@ -503,7 +503,8 @@ unwind_stack_for_thread( goto error; } - // In cache mode, copying stack chunks is more expensive than direct memory reads + // Cache mode skips this for full hits, but cache misses copy chunks before + // walking so newly stored cache entries come from a stable stack snapshot. if (!unwinder->cache_frames) { if (copy_stack_chunks(unwinder, *current_tstate, &chunks) < 0) { set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to copy stack chunks"); @@ -528,18 +529,17 @@ unwind_stack_for_thread( if (unwinder->cache_frames) { // Use cache to avoid re-reading unchanged parent frames - ctx.last_profiled_frame = GET_MEMBER(uintptr_t, ts, + ctx.last_profiled.frame = GET_MEMBER(uintptr_t, ts, unwinder->debug_offsets.thread_state.last_profiled_frame); + ctx.last_profiled.seq = GET_MEMBER(uintptr_t, ts, + unwinder->debug_offsets.thread_state.last_profiled_frame_seq); if (collect_frames_with_cache(unwinder, &ctx, tid) < 0) { set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to collect frames"); goto error; } // Update last_profiled_frame for next sample if it changed - if (frame_addr != ctx.last_profiled_frame) { - uintptr_t lpf_addr = - *current_tstate + (uintptr_t)unwinder->debug_offsets.thread_state.last_profiled_frame; - if (_Py_RemoteDebug_WriteRemoteMemory(&unwinder->handle, lpf_addr, - sizeof(uintptr_t), &frame_addr) < 0) { + if (frame_addr != ctx.last_profiled.frame) { + if (set_last_profiled_frame(unwinder, *current_tstate, frame_addr) < 0) { PyErr_Clear(); // Non-fatal } } diff --git a/Python/pystate.c b/Python/pystate.c index fed1df0173bacf..29f13e92e0dd1f 100644 --- a/Python/pystate.c +++ b/Python/pystate.c @@ -1605,6 +1605,8 @@ init_threadstate(_PyThreadStateImpl *_tstate, tstate->current_frame = &_tstate->base_frame; // base_frame pointer for profilers to validate stack unwinding tstate->base_frame = &_tstate->base_frame; + tstate->last_profiled_frame = NULL; + tstate->last_profiled_frame_seq = 0; tstate->datastack_chunk = NULL; tstate->datastack_top = NULL; tstate->datastack_limit = NULL; From 0f501b9b74b1ecd508dbef0340004055feca6a79 Mon Sep 17 00:00:00 2001 From: Aniket <148300120+Aniketsy@users.noreply.github.com> Date: Sat, 27 Jun 2026 22:27:10 +0530 Subject: [PATCH 12/13] gh-146495: Improve `SyntaxError` message for `&&` and `||` operators (#150906) --- Grammar/python.gram | 16 + Lib/test/test_syntax.py | 44 ++ ...-06-04-15-02-35.gh-issue-146495.wWRRvx.rst | 1 + Parser/parser.c | 506 +++++++++++------- Parser/pegen.h | 6 + 5 files changed, 391 insertions(+), 182 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-06-04-15-02-35.gh-issue-146495.wWRRvx.rst diff --git a/Grammar/python.gram b/Grammar/python.gram index 9bf3a67939fcf3..a8adeb566aaf5d 100644 --- a/Grammar/python.gram +++ b/Grammar/python.gram @@ -819,6 +819,7 @@ is_bitwise_or[CmpopExprPair*]: 'is' a=bitwise_or { _PyPegen_cmpop_expr_pair(p, I bitwise_or[expr_ty]: | a=bitwise_or '|' b=bitwise_xor { _PyAST_BinOp(a, BitOr, b, EXTRA) } + | invalid_bitwise_or | bitwise_xor bitwise_xor[expr_ty]: @@ -827,6 +828,7 @@ bitwise_xor[expr_ty]: bitwise_and[expr_ty]: | a=bitwise_and '&' b=shift_expr { _PyAST_BinOp(a, BitAnd, b, EXTRA) } + | invalid_bitwise_and | shift_expr shift_expr[expr_ty]: @@ -1638,3 +1640,17 @@ invalid_type_params: RAISE_SYNTAX_ERROR_STARTING_FROM( token, "Type parameter list cannot be empty")} + +invalid_bitwise_and: + | a=bitwise_and b='&' c='&' { + _PyPegen_tokens_are_adjacent(b, c) + ? RAISE_SYNTAX_ERROR_KNOWN_RANGE(b, c, "invalid syntax. Maybe you meant 'and' or '&' instead of '&&'?") + : NULL + } + +invalid_bitwise_or: + | a=bitwise_or b='|' c='|' { + _PyPegen_tokens_are_adjacent(b, c) + ? RAISE_SYNTAX_ERROR_KNOWN_RANGE(b, c, "invalid syntax. Maybe you meant 'or' or '|' instead of '||'?") + : NULL + } diff --git a/Lib/test/test_syntax.py b/Lib/test/test_syntax.py index a3d485c998ac91..04e60c74ce1bea 100644 --- a/Lib/test/test_syntax.py +++ b/Lib/test/test_syntax.py @@ -3505,6 +3505,50 @@ def test_ifexp_body_stmt_else_stmt(self): ]: self._check_error(f"x = {lhs_stmt} if 1 else {rhs_stmt}", msg) + def test_double_ampersand(self): + self._check_error( + "a && b", + r"Maybe you meant 'and' or '&' instead of '&&'\?", + lineno=1, + end_lineno=1, + offset=3, + end_offset=5, + ) + self._check_error( + "a & & b", + "invalid syntax", + lineno=1, + end_lineno=1, + offset=5, + end_offset=6, + ) + self._check_error( + "(a &\n & b)", + "invalid syntax", + lineno=2, + end_lineno=2, + offset=5, + end_offset=6, + ) + + def test_double_pipe(self): + self._check_error( + "a || b", + r"Maybe you meant 'or' or '|' instead of '||'\?", + lineno=1, + end_lineno=1, + offset=3, + end_offset=5, + ) + self._check_error( + "a | | b", + "invalid syntax", + lineno=1, + end_lineno=1, + offset=5, + end_offset=6, + ) + class LazyImportRestrictionTestCase(SyntaxErrorTestCase): """Test syntax restrictions for lazy imports.""" diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-06-04-15-02-35.gh-issue-146495.wWRRvx.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-04-15-02-35.gh-issue-146495.wWRRvx.rst new file mode 100644 index 00000000000000..1d86bf5307ba99 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-04-15-02-35.gh-issue-146495.wWRRvx.rst @@ -0,0 +1 @@ +Improve :exc:`SyntaxError` message for ``&&`` and ``||`` operators, suggesting ``and``/``&`` and ``or``/``|`` respectively. diff --git a/Parser/parser.c b/Parser/parser.c index c55c081dfc3d8e..58b6dd77a38b26 100644 --- a/Parser/parser.c +++ b/Parser/parser.c @@ -362,186 +362,188 @@ static char *soft_keywords[] = { #define invalid_arithmetic_type 1273 #define invalid_factor_type 1274 #define invalid_type_params_type 1275 -#define _loop0_1_type 1276 -#define _loop1_2_type 1277 -#define _loop0_3_type 1278 -#define _gather_4_type 1279 -#define _tmp_5_type 1280 -#define _tmp_6_type 1281 -#define _tmp_7_type 1282 -#define _tmp_8_type 1283 -#define _tmp_9_type 1284 -#define _tmp_10_type 1285 -#define _tmp_11_type 1286 -#define _loop1_12_type 1287 -#define _loop0_13_type 1288 -#define _gather_14_type 1289 -#define _tmp_15_type 1290 -#define _tmp_16_type 1291 -#define _loop0_17_type 1292 -#define _loop1_18_type 1293 -#define _loop0_19_type 1294 -#define _gather_20_type 1295 -#define _tmp_21_type 1296 -#define _loop0_22_type 1297 -#define _gather_23_type 1298 -#define _loop1_24_type 1299 -#define _tmp_25_type 1300 -#define _tmp_26_type 1301 -#define _loop0_27_type 1302 -#define _loop0_28_type 1303 -#define _loop1_29_type 1304 -#define _loop1_30_type 1305 -#define _loop0_31_type 1306 -#define _loop1_32_type 1307 -#define _loop0_33_type 1308 -#define _gather_34_type 1309 -#define _tmp_35_type 1310 -#define _loop1_36_type 1311 -#define _loop1_37_type 1312 -#define _loop1_38_type 1313 -#define _loop0_39_type 1314 -#define _gather_40_type 1315 -#define _tmp_41_type 1316 -#define _tmp_42_type 1317 -#define _tmp_43_type 1318 -#define _loop0_44_type 1319 -#define _gather_45_type 1320 -#define _loop0_46_type 1321 -#define _gather_47_type 1322 -#define _tmp_48_type 1323 -#define _loop0_49_type 1324 -#define _gather_50_type 1325 -#define _loop0_51_type 1326 -#define _gather_52_type 1327 -#define _loop0_53_type 1328 -#define _gather_54_type 1329 -#define _loop1_55_type 1330 -#define _loop1_56_type 1331 -#define _loop0_57_type 1332 -#define _gather_58_type 1333 -#define _loop0_59_type 1334 -#define _gather_60_type 1335 -#define _loop1_61_type 1336 -#define _loop1_62_type 1337 -#define _loop1_63_type 1338 -#define _tmp_64_type 1339 -#define _loop0_65_type 1340 -#define _gather_66_type 1341 -#define _tmp_67_type 1342 -#define _tmp_68_type 1343 -#define _tmp_69_type 1344 -#define _tmp_70_type 1345 -#define _tmp_71_type 1346 -#define _loop0_72_type 1347 -#define _loop0_73_type 1348 -#define _loop1_74_type 1349 -#define _loop1_75_type 1350 -#define _loop0_76_type 1351 -#define _loop1_77_type 1352 -#define _loop0_78_type 1353 -#define _loop0_79_type 1354 -#define _loop0_80_type 1355 -#define _loop0_81_type 1356 -#define _loop1_82_type 1357 -#define _loop1_83_type 1358 -#define _tmp_84_type 1359 -#define _loop0_85_type 1360 -#define _gather_86_type 1361 -#define _loop1_87_type 1362 -#define _loop0_88_type 1363 -#define _tmp_89_type 1364 -#define _loop0_90_type 1365 -#define _gather_91_type 1366 -#define _tmp_92_type 1367 -#define _loop0_93_type 1368 -#define _gather_94_type 1369 -#define _loop0_95_type 1370 -#define _gather_96_type 1371 -#define _loop0_97_type 1372 -#define _loop0_98_type 1373 -#define _gather_99_type 1374 -#define _loop1_100_type 1375 -#define _tmp_101_type 1376 -#define _loop0_102_type 1377 -#define _gather_103_type 1378 -#define _loop0_104_type 1379 -#define _gather_105_type 1380 -#define _tmp_106_type 1381 -#define _tmp_107_type 1382 -#define _loop0_108_type 1383 -#define _gather_109_type 1384 -#define _tmp_110_type 1385 -#define _tmp_111_type 1386 -#define _tmp_112_type 1387 -#define _tmp_113_type 1388 -#define _tmp_114_type 1389 -#define _loop1_115_type 1390 -#define _tmp_116_type 1391 -#define _tmp_117_type 1392 -#define _tmp_118_type 1393 -#define _tmp_119_type 1394 -#define _tmp_120_type 1395 -#define _loop0_121_type 1396 -#define _loop0_122_type 1397 -#define _tmp_123_type 1398 -#define _tmp_124_type 1399 -#define _tmp_125_type 1400 -#define _tmp_126_type 1401 -#define _tmp_127_type 1402 -#define _tmp_128_type 1403 -#define _tmp_129_type 1404 -#define _tmp_130_type 1405 -#define _loop0_131_type 1406 -#define _gather_132_type 1407 -#define _tmp_133_type 1408 -#define _tmp_134_type 1409 -#define _tmp_135_type 1410 -#define _tmp_136_type 1411 -#define _loop0_137_type 1412 -#define _gather_138_type 1413 -#define _tmp_139_type 1414 -#define _loop0_140_type 1415 -#define _gather_141_type 1416 -#define _loop0_142_type 1417 -#define _gather_143_type 1418 -#define _tmp_144_type 1419 -#define _loop0_145_type 1420 -#define _tmp_146_type 1421 -#define _tmp_147_type 1422 -#define _tmp_148_type 1423 -#define _tmp_149_type 1424 -#define _tmp_150_type 1425 -#define _tmp_151_type 1426 -#define _tmp_152_type 1427 -#define _tmp_153_type 1428 -#define _tmp_154_type 1429 -#define _tmp_155_type 1430 -#define _tmp_156_type 1431 -#define _tmp_157_type 1432 -#define _tmp_158_type 1433 -#define _tmp_159_type 1434 -#define _tmp_160_type 1435 -#define _tmp_161_type 1436 -#define _tmp_162_type 1437 -#define _tmp_163_type 1438 -#define _tmp_164_type 1439 -#define _tmp_165_type 1440 -#define _tmp_166_type 1441 -#define _tmp_167_type 1442 -#define _tmp_168_type 1443 -#define _tmp_169_type 1444 -#define _tmp_170_type 1445 -#define _tmp_171_type 1446 -#define _tmp_172_type 1447 -#define _tmp_173_type 1448 -#define _loop0_174_type 1449 -#define _tmp_175_type 1450 -#define _tmp_176_type 1451 -#define _tmp_177_type 1452 -#define _tmp_178_type 1453 -#define _tmp_179_type 1454 -#define _tmp_180_type 1455 +#define invalid_bitwise_and_type 1276 // Left-recursive +#define invalid_bitwise_or_type 1277 // Left-recursive +#define _loop0_1_type 1278 +#define _loop1_2_type 1279 +#define _loop0_3_type 1280 +#define _gather_4_type 1281 +#define _tmp_5_type 1282 +#define _tmp_6_type 1283 +#define _tmp_7_type 1284 +#define _tmp_8_type 1285 +#define _tmp_9_type 1286 +#define _tmp_10_type 1287 +#define _tmp_11_type 1288 +#define _loop1_12_type 1289 +#define _loop0_13_type 1290 +#define _gather_14_type 1291 +#define _tmp_15_type 1292 +#define _tmp_16_type 1293 +#define _loop0_17_type 1294 +#define _loop1_18_type 1295 +#define _loop0_19_type 1296 +#define _gather_20_type 1297 +#define _tmp_21_type 1298 +#define _loop0_22_type 1299 +#define _gather_23_type 1300 +#define _loop1_24_type 1301 +#define _tmp_25_type 1302 +#define _tmp_26_type 1303 +#define _loop0_27_type 1304 +#define _loop0_28_type 1305 +#define _loop1_29_type 1306 +#define _loop1_30_type 1307 +#define _loop0_31_type 1308 +#define _loop1_32_type 1309 +#define _loop0_33_type 1310 +#define _gather_34_type 1311 +#define _tmp_35_type 1312 +#define _loop1_36_type 1313 +#define _loop1_37_type 1314 +#define _loop1_38_type 1315 +#define _loop0_39_type 1316 +#define _gather_40_type 1317 +#define _tmp_41_type 1318 +#define _tmp_42_type 1319 +#define _tmp_43_type 1320 +#define _loop0_44_type 1321 +#define _gather_45_type 1322 +#define _loop0_46_type 1323 +#define _gather_47_type 1324 +#define _tmp_48_type 1325 +#define _loop0_49_type 1326 +#define _gather_50_type 1327 +#define _loop0_51_type 1328 +#define _gather_52_type 1329 +#define _loop0_53_type 1330 +#define _gather_54_type 1331 +#define _loop1_55_type 1332 +#define _loop1_56_type 1333 +#define _loop0_57_type 1334 +#define _gather_58_type 1335 +#define _loop0_59_type 1336 +#define _gather_60_type 1337 +#define _loop1_61_type 1338 +#define _loop1_62_type 1339 +#define _loop1_63_type 1340 +#define _tmp_64_type 1341 +#define _loop0_65_type 1342 +#define _gather_66_type 1343 +#define _tmp_67_type 1344 +#define _tmp_68_type 1345 +#define _tmp_69_type 1346 +#define _tmp_70_type 1347 +#define _tmp_71_type 1348 +#define _loop0_72_type 1349 +#define _loop0_73_type 1350 +#define _loop1_74_type 1351 +#define _loop1_75_type 1352 +#define _loop0_76_type 1353 +#define _loop1_77_type 1354 +#define _loop0_78_type 1355 +#define _loop0_79_type 1356 +#define _loop0_80_type 1357 +#define _loop0_81_type 1358 +#define _loop1_82_type 1359 +#define _loop1_83_type 1360 +#define _tmp_84_type 1361 +#define _loop0_85_type 1362 +#define _gather_86_type 1363 +#define _loop1_87_type 1364 +#define _loop0_88_type 1365 +#define _tmp_89_type 1366 +#define _loop0_90_type 1367 +#define _gather_91_type 1368 +#define _tmp_92_type 1369 +#define _loop0_93_type 1370 +#define _gather_94_type 1371 +#define _loop0_95_type 1372 +#define _gather_96_type 1373 +#define _loop0_97_type 1374 +#define _loop0_98_type 1375 +#define _gather_99_type 1376 +#define _loop1_100_type 1377 +#define _tmp_101_type 1378 +#define _loop0_102_type 1379 +#define _gather_103_type 1380 +#define _loop0_104_type 1381 +#define _gather_105_type 1382 +#define _tmp_106_type 1383 +#define _tmp_107_type 1384 +#define _loop0_108_type 1385 +#define _gather_109_type 1386 +#define _tmp_110_type 1387 +#define _tmp_111_type 1388 +#define _tmp_112_type 1389 +#define _tmp_113_type 1390 +#define _tmp_114_type 1391 +#define _loop1_115_type 1392 +#define _tmp_116_type 1393 +#define _tmp_117_type 1394 +#define _tmp_118_type 1395 +#define _tmp_119_type 1396 +#define _tmp_120_type 1397 +#define _loop0_121_type 1398 +#define _loop0_122_type 1399 +#define _tmp_123_type 1400 +#define _tmp_124_type 1401 +#define _tmp_125_type 1402 +#define _tmp_126_type 1403 +#define _tmp_127_type 1404 +#define _tmp_128_type 1405 +#define _tmp_129_type 1406 +#define _tmp_130_type 1407 +#define _loop0_131_type 1408 +#define _gather_132_type 1409 +#define _tmp_133_type 1410 +#define _tmp_134_type 1411 +#define _tmp_135_type 1412 +#define _tmp_136_type 1413 +#define _loop0_137_type 1414 +#define _gather_138_type 1415 +#define _tmp_139_type 1416 +#define _loop0_140_type 1417 +#define _gather_141_type 1418 +#define _loop0_142_type 1419 +#define _gather_143_type 1420 +#define _tmp_144_type 1421 +#define _loop0_145_type 1422 +#define _tmp_146_type 1423 +#define _tmp_147_type 1424 +#define _tmp_148_type 1425 +#define _tmp_149_type 1426 +#define _tmp_150_type 1427 +#define _tmp_151_type 1428 +#define _tmp_152_type 1429 +#define _tmp_153_type 1430 +#define _tmp_154_type 1431 +#define _tmp_155_type 1432 +#define _tmp_156_type 1433 +#define _tmp_157_type 1434 +#define _tmp_158_type 1435 +#define _tmp_159_type 1436 +#define _tmp_160_type 1437 +#define _tmp_161_type 1438 +#define _tmp_162_type 1439 +#define _tmp_163_type 1440 +#define _tmp_164_type 1441 +#define _tmp_165_type 1442 +#define _tmp_166_type 1443 +#define _tmp_167_type 1444 +#define _tmp_168_type 1445 +#define _tmp_169_type 1446 +#define _tmp_170_type 1447 +#define _tmp_171_type 1448 +#define _tmp_172_type 1449 +#define _tmp_173_type 1450 +#define _loop0_174_type 1451 +#define _tmp_175_type 1452 +#define _tmp_176_type 1453 +#define _tmp_177_type 1454 +#define _tmp_178_type 1455 +#define _tmp_179_type 1456 +#define _tmp_180_type 1457 static mod_ty file_rule(Parser *p); static mod_ty interactive_rule(Parser *p); @@ -819,6 +821,8 @@ static void *invalid_string_tstring_concat_rule(Parser *p); static void *invalid_arithmetic_rule(Parser *p); static void *invalid_factor_rule(Parser *p); static void *invalid_type_params_rule(Parser *p); +static void *invalid_bitwise_and_rule(Parser *p); +static void *invalid_bitwise_or_rule(Parser *p); static asdl_seq *_loop0_1_rule(Parser *p); static asdl_seq *_loop1_2_rule(Parser *p); static asdl_seq *_loop0_3_rule(Parser *p); @@ -13503,7 +13507,7 @@ is_bitwise_or_rule(Parser *p) } // Left-recursive -// bitwise_or: bitwise_or '|' bitwise_xor | bitwise_xor +// bitwise_or: bitwise_or '|' bitwise_xor | invalid_bitwise_or | bitwise_xor static expr_ty bitwise_or_raw(Parser *); static expr_ty bitwise_or_rule(Parser *p) @@ -13599,6 +13603,25 @@ bitwise_or_raw(Parser *p) D(fprintf(stderr, "%*c%s bitwise_or[%d-%d]: %s failed!\n", p->level, ' ', p->error_indicator ? "ERROR!" : "-", _mark, p->mark, "bitwise_or '|' bitwise_xor")); } + if (p->call_invalid_rules) { // invalid_bitwise_or + if (p->error_indicator) { + p->level--; + return NULL; + } + D(fprintf(stderr, "%*c> bitwise_or[%d-%d]: %s\n", p->level, ' ', _mark, p->mark, "invalid_bitwise_or")); + void *invalid_bitwise_or_var; + if ( + (invalid_bitwise_or_var = invalid_bitwise_or_rule(p)) // invalid_bitwise_or + ) + { + D(fprintf(stderr, "%*c+ bitwise_or[%d-%d]: %s succeeded!\n", p->level, ' ', _mark, p->mark, "invalid_bitwise_or")); + _res = invalid_bitwise_or_var; + goto done; + } + p->mark = _mark; + D(fprintf(stderr, "%*c%s bitwise_or[%d-%d]: %s failed!\n", p->level, ' ', + p->error_indicator ? "ERROR!" : "-", _mark, p->mark, "invalid_bitwise_or")); + } { // bitwise_xor if (p->error_indicator) { p->level--; @@ -13747,7 +13770,7 @@ bitwise_xor_raw(Parser *p) } // Left-recursive -// bitwise_and: bitwise_and '&' shift_expr | shift_expr +// bitwise_and: bitwise_and '&' shift_expr | invalid_bitwise_and | shift_expr static expr_ty bitwise_and_raw(Parser *); static expr_ty bitwise_and_rule(Parser *p) @@ -13843,6 +13866,25 @@ bitwise_and_raw(Parser *p) D(fprintf(stderr, "%*c%s bitwise_and[%d-%d]: %s failed!\n", p->level, ' ', p->error_indicator ? "ERROR!" : "-", _mark, p->mark, "bitwise_and '&' shift_expr")); } + if (p->call_invalid_rules) { // invalid_bitwise_and + if (p->error_indicator) { + p->level--; + return NULL; + } + D(fprintf(stderr, "%*c> bitwise_and[%d-%d]: %s\n", p->level, ' ', _mark, p->mark, "invalid_bitwise_and")); + void *invalid_bitwise_and_var; + if ( + (invalid_bitwise_and_var = invalid_bitwise_and_rule(p)) // invalid_bitwise_and + ) + { + D(fprintf(stderr, "%*c+ bitwise_and[%d-%d]: %s succeeded!\n", p->level, ' ', _mark, p->mark, "invalid_bitwise_and")); + _res = invalid_bitwise_and_var; + goto done; + } + p->mark = _mark; + D(fprintf(stderr, "%*c%s bitwise_and[%d-%d]: %s failed!\n", p->level, ' ', + p->error_indicator ? "ERROR!" : "-", _mark, p->mark, "invalid_bitwise_and")); + } { // shift_expr if (p->error_indicator) { p->level--; @@ -28434,6 +28476,106 @@ invalid_type_params_rule(Parser *p) return _res; } +// Left-recursive +// invalid_bitwise_and: bitwise_and '&' '&' +static void * +invalid_bitwise_and_rule(Parser *p) +{ + if (p->level++ == MAXSTACK || _Py_ReachedRecursionLimitWithMargin(PyThreadState_Get(), 1)) { + _Pypegen_stack_overflow(p); + } + if (p->error_indicator) { + p->level--; + return NULL; + } + void * _res = NULL; + int _mark = p->mark; + { // bitwise_and '&' '&' + if (p->error_indicator) { + p->level--; + return NULL; + } + D(fprintf(stderr, "%*c> invalid_bitwise_and[%d-%d]: %s\n", p->level, ' ', _mark, p->mark, "bitwise_and '&' '&'")); + expr_ty a; + Token * b; + Token * c; + if ( + (a = bitwise_and_rule(p)) // bitwise_and + && + (b = _PyPegen_expect_token(p, 19)) // token='&' + && + (c = _PyPegen_expect_token(p, 19)) // token='&' + ) + { + D(fprintf(stderr, "%*c+ invalid_bitwise_and[%d-%d]: %s succeeded!\n", p->level, ' ', _mark, p->mark, "bitwise_and '&' '&'")); + _res = _PyPegen_tokens_are_adjacent ( b , c ) ? RAISE_SYNTAX_ERROR_KNOWN_RANGE ( b , c , "invalid syntax. Maybe you meant 'and' or '&' instead of '&&'?" ) : NULL; + if ((_res == NULL || p->error_indicator) && PyErr_Occurred()) { + p->error_indicator = 1; + p->level--; + return NULL; + } + goto done; + } + p->mark = _mark; + D(fprintf(stderr, "%*c%s invalid_bitwise_and[%d-%d]: %s failed!\n", p->level, ' ', + p->error_indicator ? "ERROR!" : "-", _mark, p->mark, "bitwise_and '&' '&'")); + } + _res = NULL; + done: + p->level--; + return _res; +} + +// Left-recursive +// invalid_bitwise_or: bitwise_or '|' '|' +static void * +invalid_bitwise_or_rule(Parser *p) +{ + if (p->level++ == MAXSTACK || _Py_ReachedRecursionLimitWithMargin(PyThreadState_Get(), 1)) { + _Pypegen_stack_overflow(p); + } + if (p->error_indicator) { + p->level--; + return NULL; + } + void * _res = NULL; + int _mark = p->mark; + { // bitwise_or '|' '|' + if (p->error_indicator) { + p->level--; + return NULL; + } + D(fprintf(stderr, "%*c> invalid_bitwise_or[%d-%d]: %s\n", p->level, ' ', _mark, p->mark, "bitwise_or '|' '|'")); + expr_ty a; + Token * b; + Token * c; + if ( + (a = bitwise_or_rule(p)) // bitwise_or + && + (b = _PyPegen_expect_token(p, 18)) // token='|' + && + (c = _PyPegen_expect_token(p, 18)) // token='|' + ) + { + D(fprintf(stderr, "%*c+ invalid_bitwise_or[%d-%d]: %s succeeded!\n", p->level, ' ', _mark, p->mark, "bitwise_or '|' '|'")); + _res = _PyPegen_tokens_are_adjacent ( b , c ) ? RAISE_SYNTAX_ERROR_KNOWN_RANGE ( b , c , "invalid syntax. Maybe you meant 'or' or '|' instead of '||'?" ) : NULL; + if ((_res == NULL || p->error_indicator) && PyErr_Occurred()) { + p->error_indicator = 1; + p->level--; + return NULL; + } + goto done; + } + p->mark = _mark; + D(fprintf(stderr, "%*c%s invalid_bitwise_or[%d-%d]: %s failed!\n", p->level, ' ', + p->error_indicator ? "ERROR!" : "-", _mark, p->mark, "bitwise_or '|' '|'")); + } + _res = NULL; + done: + p->level--; + return _res; +} + // _loop0_1: NEWLINE static asdl_seq * _loop0_1_rule(Parser *p) diff --git a/Parser/pegen.h b/Parser/pegen.h index 5c461e82a7f0fa..4a4f7536db2da7 100644 --- a/Parser/pegen.h +++ b/Parser/pegen.h @@ -208,6 +208,12 @@ RAISE_ERROR_KNOWN_LOCATION(Parser *p, PyObject *errtype, RAISE_ERROR_KNOWN_LOCATION(p, PyExc_SyntaxError, (a)->lineno, (a)->col_offset, CURRENT_POS, CURRENT_POS, msg, ##__VA_ARGS__) #define RAISE_SYNTAX_ERROR_INVALID_TARGET(type, e) _RAISE_SYNTAX_ERROR_INVALID_TARGET(p, type, e) +Py_LOCAL_INLINE(int) +_PyPegen_tokens_are_adjacent(Token *a, Token *b) +{ + return (a->end_lineno == b->lineno) && (a->end_col_offset == b->col_offset); +} + Py_LOCAL_INLINE(void *) CHECK_CALL(Parser *p, void *result) { From 860f8a5addcb556c251b8b9caa95d80518928067 Mon Sep 17 00:00:00 2001 From: ivonastojanovic <80911834+ivonastojanovic@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:09:49 +0100 Subject: [PATCH 13/13] gh-145306: Fix browser open after empty export (#150017) --- Lib/profiling/sampling/binary_collector.py | 1 + Lib/profiling/sampling/cli.py | 26 +++++++++++++------ Lib/profiling/sampling/collector.py | 6 ++++- Lib/profiling/sampling/gecko_collector.py | 1 + Lib/profiling/sampling/heatmap_collector.py | 3 ++- Lib/profiling/sampling/jsonl_collector.py | 1 + Lib/profiling/sampling/pstats_collector.py | 1 + Lib/profiling/sampling/stack_collector.py | 4 ++- .../test_sampling_profiler/test_cli.py | 22 ++++++++++++++++ .../test_sampling_profiler/test_collectors.py | 21 +++++++++++++-- 10 files changed, 73 insertions(+), 13 deletions(-) diff --git a/Lib/profiling/sampling/binary_collector.py b/Lib/profiling/sampling/binary_collector.py index 64afe632fae175..afbbc829269067 100644 --- a/Lib/profiling/sampling/binary_collector.py +++ b/Lib/profiling/sampling/binary_collector.py @@ -94,6 +94,7 @@ def export(self, filename=None): filename: Ignored (binary files are written incrementally) """ self._writer.finalize() + return True @property def total_samples(self): diff --git a/Lib/profiling/sampling/cli.py b/Lib/profiling/sampling/cli.py index 0330c15c014545..466b0aceae2dcc 100644 --- a/Lib/profiling/sampling/cli.py +++ b/Lib/profiling/sampling/cli.py @@ -117,6 +117,9 @@ def __call__(self, parser, namespace, values, option_string=None): "binary": BinaryCollector, } +BROWSER_COMPATIBLE_FORMATS = ("flamegraph", "diff_flamegraph", "heatmap") + + def _setup_child_monitor(args, parent_pid): # Build CLI args for child profilers (excluding --subprocesses to avoid recursion) child_cli_args = _build_child_profiler_args(args) @@ -528,8 +531,12 @@ def _add_format_options(parser, include_compression=True, include_binary=True): output_group.add_argument( "--browser", action="store_true", - help="Automatically open HTML output (flamegraph, heatmap) in browser. " - "When using `--subprocesses`, only the main process opens the browser", + help=( + "Automatically open HTML output " + f"({', '.join('--' + f.replace('_', '-') for f in BROWSER_COMPATIBLE_FORMATS)}) " + "in browser. " + "When using `--subprocesses`, only the main process opens the browser" + ), ) @@ -789,13 +796,12 @@ def progress_callback(current, total): args.outfile or _generate_output_filename(args.format, os.getpid()) ) - collector.export(filename) + export_ok = collector.export(filename) # Auto-open browser for HTML output if --browser flag is set if ( - args.format in ( - 'flamegraph', 'diff_flamegraph', 'heatmap' - ) + export_ok + and args.format in BROWSER_COMPATIBLE_FORMATS and getattr(args, 'browser', False) ): _open_in_browser(filename) @@ -840,10 +846,14 @@ def _handle_output(collector, args, pid, mode): filename = os.path.join(args.outfile, _generate_output_filename(args.format, pid)) else: filename = args.outfile or _generate_output_filename(args.format, pid) - collector.export(filename) + export_ok = collector.export(filename) # Auto-open browser for HTML output if --browser flag is set - if args.format in ('flamegraph', 'diff_flamegraph', 'heatmap') and getattr(args, 'browser', False): + if ( + export_ok + and args.format in BROWSER_COMPATIBLE_FORMATS + and getattr(args, 'browser', False) + ): _open_in_browser(filename) diff --git a/Lib/profiling/sampling/collector.py b/Lib/profiling/sampling/collector.py index 8e0f0c44c4f8f3..1dc3656e0ebe97 100644 --- a/Lib/profiling/sampling/collector.py +++ b/Lib/profiling/sampling/collector.py @@ -163,7 +163,11 @@ def collect_failed_sample(self): @abstractmethod def export(self, filename): - """Export collected data to a file.""" + """Export collected data. + + Returns: + bool: True if output was generated, False if there was no data to export. + """ @staticmethod def _filter_internal_frames(frames): diff --git a/Lib/profiling/sampling/gecko_collector.py b/Lib/profiling/sampling/gecko_collector.py index 361f6037f216fd..2bb5bd2f664d59 100644 --- a/Lib/profiling/sampling/gecko_collector.py +++ b/Lib/profiling/sampling/gecko_collector.py @@ -756,6 +756,7 @@ def spin(): print( f"Open in Firefox Profiler: https://profiler.firefox.com/" ) + return True def _build_marker_schema(self): """Build marker schema definitions for Firefox Profiler.""" diff --git a/Lib/profiling/sampling/heatmap_collector.py b/Lib/profiling/sampling/heatmap_collector.py index 78f1e39f6a002d..220b6b8150ac98 100644 --- a/Lib/profiling/sampling/heatmap_collector.py +++ b/Lib/profiling/sampling/heatmap_collector.py @@ -596,7 +596,7 @@ def export(self, output_path): """ if not self.file_samples: print("Warning: No heatmap data to export") - return + return False try: output_dir = self._prepare_output_directory(output_path) @@ -610,6 +610,7 @@ def export(self, output_path): self._generate_index_html(output_dir / 'index.html', file_stats) self._print_export_summary(output_dir, file_stats) + return True except Exception as e: print(f"Error: Failed to export heatmap: {e}") diff --git a/Lib/profiling/sampling/jsonl_collector.py b/Lib/profiling/sampling/jsonl_collector.py index 5aa42ef09024dc..41b0456d6f56d3 100644 --- a/Lib/profiling/sampling/jsonl_collector.py +++ b/Lib/profiling/sampling/jsonl_collector.py @@ -165,6 +165,7 @@ def export(self, filename): ) self._write_message(output, self._build_end_record()) print(f"JSONL profile written to {filename}") + return True def _build_meta_record(self): record = { diff --git a/Lib/profiling/sampling/pstats_collector.py b/Lib/profiling/sampling/pstats_collector.py index 43b1daf2a119d4..7132cffd58f094 100644 --- a/Lib/profiling/sampling/pstats_collector.py +++ b/Lib/profiling/sampling/pstats_collector.py @@ -63,6 +63,7 @@ def collect(self, stack_frames, timestamps_us=None): def export(self, filename): self.create_stats() self._dump_stats(filename) + return True def _dump_stats(self, file): stats_with_marker = dict(self.stats) diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index 42281dc6454c83..eb1a3fba93cf33 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -64,6 +64,7 @@ def export(self, filename): for stack, count in lines: f.write(f"{stack} {count}\n") print(f"Collapsed stack output written to {filename}") + return True class FlamegraphCollector(StackTraceCollector): @@ -161,7 +162,7 @@ def export(self, filename): print( "Warning: No functions found in profiling data. Check if sampling captured any data." ) - return + return False html_content = self._create_flamegraph_html(flamegraph_data) @@ -169,6 +170,7 @@ def export(self, filename): f.write(html_content) print(f"Flamegraph saved to: {filename}") + return True @staticmethod @functools.lru_cache(maxsize=None) diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_cli.py b/Lib/test/test_profiling/test_sampling_profiler/test_cli.py index 0181095ca21e37..3448258eca5d6c 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_cli.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_cli.py @@ -7,6 +7,7 @@ import sys import tempfile import unittest +from types import SimpleNamespace from unittest import mock try: @@ -26,6 +27,7 @@ FORMAT_EXTENSIONS, _create_collector, _generate_output_filename, + _handle_output, main, ) from profiling.sampling.constants import ( @@ -727,6 +729,26 @@ def test_async_aware_flag_defaults_to_running(self): call_kwargs = mock_sample.call_args[1] self.assertEqual(call_kwargs.get("async_aware"), "running") + def test_handle_output_browser_not_opened_when_export_fails(self): + for format_type in ("flamegraph", "diff_flamegraph", "heatmap"): + with self.subTest(format=format_type): + collector = mock.MagicMock() + collector.export.return_value = False + args = SimpleNamespace( + format=format_type, + outfile="profile.html", + browser=True, + ) + + with ( + mock.patch("profiling.sampling.cli.os.path.isdir", return_value=False), + mock.patch("profiling.sampling.cli._open_in_browser") as mock_open, + ): + _handle_output(collector, args, pid=12345, mode=0) + + collector.export.assert_called_once_with("profile.html") + mock_open.assert_not_called() + def test_async_aware_with_async_mode_all(self): """Test --async-aware with --async-mode all.""" test_args = ["profiling.sampling.cli", "attach", "12345", "--async-aware", "--async-mode", "all"] diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py index 1ab31af67fec52..56f3fe5e1c2605 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py @@ -539,9 +539,10 @@ def test_flamegraph_collector_export(self): # Export flamegraph with captured_stdout(), captured_stderr(): - collector.export(flamegraph_out.name) + export_ok = collector.export(flamegraph_out.name) # Verify file was created and contains valid data + self.assertTrue(export_ok) self.assertTrue(os.path.exists(flamegraph_out.name)) self.assertGreater(os.path.getsize(flamegraph_out.name), 0) @@ -560,6 +561,21 @@ def test_flamegraph_collector_export(self): self.assertIn('"value":', content) self.assertIn('"children":', content) + def test_flamegraph_collector_empty_export_fails(self): + """Test empty flamegraph export reports no output.""" + flamegraph_out = tempfile.NamedTemporaryFile( + suffix=".html", delete=False + ) + self.addCleanup(close_and_unlink, flamegraph_out) + + collector = FlamegraphCollector(1000) + + with captured_stdout(), captured_stderr(): + export_ok = collector.export(flamegraph_out.name) + + self.assertFalse(export_ok) + self.assertEqual(os.path.getsize(flamegraph_out.name), 0) + def test_gecko_collector_basic(self): """Test basic GeckoCollector functionality.""" collector = GeckoCollector(1000) @@ -1666,8 +1682,9 @@ def test_diff_flamegraph_export(self): self.addCleanup(close_and_unlink, flamegraph_out) with captured_stdout(), captured_stderr(): - diff.export(flamegraph_out.name) + export_ok = diff.export(flamegraph_out.name) + self.assertTrue(export_ok) self.assertTrue(os.path.exists(flamegraph_out.name)) self.assertGreater(os.path.getsize(flamegraph_out.name), 0)