From d125f009dc2a698de62deb11a0c50d3ef8e33d62 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Mon, 7 Sep 2026 03:17:11 -0600 Subject: [PATCH 1/3] gh-157048: check for buffer errors before mutating in BytesIO.__init__ (#157049) --- Lib/test/test_io/test_memoryio.py | 12 ++++++++++++ .../2026-09-06-15-23-24.gh-issue-157048.UtFyiJ.rst | 2 ++ Modules/_io/bytesio.c | 9 +++++---- 3 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-06-15-23-24.gh-issue-157048.UtFyiJ.rst diff --git a/Lib/test/test_io/test_memoryio.py b/Lib/test/test_io/test_memoryio.py index e934e3fb2bdf124..624fd78327cae10 100644 --- a/Lib/test/test_io/test_memoryio.py +++ b/Lib/test/test_io/test_memoryio.py @@ -1025,6 +1025,18 @@ def test_cow_mutable(self): memio = self.ioclass(ba) self.assertEqual(sys.getrefcount(ba), old_rc) + def test_write_with_export(self): + memio = self.ioclass(b"abcd") + memio.seek(2) + with memio.getbuffer() as view: + self.assertRaises(BufferError, memio.__init__, b"replacement") + self.assertEqual(memio.tell(), 2) + self.assertEqual(memio.getvalue(), b"abcd") + self.assertEqual(bytes(view), b"abcd") + memio.write(b"X") + self.assertEqual(memio.getvalue(), b"abXd") + + class CStringIOTest(PyStringIOTest): ioclass = io.StringIO UnsupportedOperation = io.UnsupportedOperation diff --git a/Misc/NEWS.d/next/Library/2026-09-06-15-23-24.gh-issue-157048.UtFyiJ.rst b/Misc/NEWS.d/next/Library/2026-09-06-15-23-24.gh-issue-157048.UtFyiJ.rst new file mode 100644 index 000000000000000..1638968e475ca4a --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-06-15-23-24.gh-issue-157048.UtFyiJ.rst @@ -0,0 +1,2 @@ +Preserve the position and contents of :class:`io.BytesIO` when +:meth:`!BytesIO.__init__` fails because a buffer is exported. diff --git a/Modules/_io/bytesio.c b/Modules/_io/bytesio.c index f7ba68bc637b888..ab54df8c76758f9 100644 --- a/Modules/_io/bytesio.c +++ b/Modules/_io/bytesio.c @@ -1126,15 +1126,16 @@ static int _io_BytesIO___init___impl(bytesio *self, PyObject *initvalue) /*[clinic end generated code: output=65c0c51e24c5b621 input=3da5a74ee4c4f1ac]*/ { - /* In case, __init__ is called multiple times. */ - self->string_size = 0; - self->pos = 0; - if (FT_ATOMIC_LOAD_SSIZE_RELAXED(self->exports) > 0) { PyErr_SetString(PyExc_BufferError, "Existing exports of data: object cannot be re-sized"); return -1; } + + /* In case, __init__ is called multiple times. */ + self->string_size = 0; + self->pos = 0; + if (initvalue && initvalue != Py_None) { if (PyBytes_CheckExact(initvalue)) { Py_XSETREF(self->buf, Py_NewRef(initvalue)); From 7a1d2c462bb544eb3de7f04d1fc5d09a327f301c Mon Sep 17 00:00:00 2001 From: lpyuu Date: Mon, 7 Sep 2026 19:10:24 +0800 Subject: [PATCH 2/3] gh-157025: Fix transport cleanup when cancelling asyncio native sendfile (#157027) --- Lib/asyncio/proactor_events.py | 2 +- Lib/asyncio/selector_events.py | 2 +- Lib/test/test_asyncio/test_sendfile.py | 41 +++++++++++++++++++ ...-09-06-15-12-18.gh-issue-157025.ZvFpFT.rst | 2 + 4 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-06-15-12-18.gh-issue-157025.ZvFpFT.rst diff --git a/Lib/asyncio/proactor_events.py b/Lib/asyncio/proactor_events.py index 7adb09f3fce5de7..6717f067caa08bb 100644 --- a/Lib/asyncio/proactor_events.py +++ b/Lib/asyncio/proactor_events.py @@ -766,8 +766,8 @@ async def _sock_sendfile_native(self, sock, file, offset, count): async def _sendfile_native(self, transp, file, offset, count): resume_reading = transp.is_reading() transp.pause_reading() - await transp._make_empty_waiter() try: + await transp._make_empty_waiter() return await self.sock_sendfile(transp._sock, file, offset, count, fallback=False) finally: diff --git a/Lib/asyncio/selector_events.py b/Lib/asyncio/selector_events.py index a682063d9d3d6e3..bf20f053ded4353 100644 --- a/Lib/asyncio/selector_events.py +++ b/Lib/asyncio/selector_events.py @@ -739,8 +739,8 @@ async def _sendfile_native(self, transp, file, offset, count): del self._transports[transp._sock_fd] resume_reading = transp.is_reading() transp.pause_reading() - await transp._make_empty_waiter() try: + await transp._make_empty_waiter() return await self.sock_sendfile(transp._sock, file, offset, count, fallback=False) finally: diff --git a/Lib/test/test_asyncio/test_sendfile.py b/Lib/test/test_asyncio/test_sendfile.py index c8d429c3d1651f0..91bbbc2e6993dae 100644 --- a/Lib/test/test_asyncio/test_sendfile.py +++ b/Lib/test/test_asyncio/test_sendfile.py @@ -376,6 +376,47 @@ def test_sendfile(self): self.assertEqual(srv_proto.data, self.DATA) self.assertEqual(self.file.tell(), len(self.DATA)) + def test_sendfile_cancel_empty_waiter(self): + for reading in (True, False): + with self.subTest(reading=reading): + srv_proto, cli_proto = self.prepare_sendfile() + transport = cli_proto.transport + if not reading: + transport.pause_reading() + waiter = self.loop.create_future() + + def make_empty_waiter(): + transport._empty_waiter = waiter + return waiter + + with mock.patch.object(transport, '_make_empty_waiter', + side_effect=make_empty_waiter): + task = self.loop.create_task( + self.loop.sendfile(transport, self.file)) + test_utils.run_briefly(self.loop) + self.assertIs(transport._empty_waiter, waiter) + self.assertFalse(waiter.done()) + self.assertFalse(transport.is_reading()) + task.cancel() + with self.assertRaises(asyncio.CancelledError): + self.run_loop(task) + + try: + self.assertIsNone(transport._empty_waiter) + self.assertEqual(transport.is_reading(), reading) + if isinstance(self.loop, asyncio.SelectorEventLoop): + self.assertIs( + self.loop._transports[transport._sock_fd], + transport) + finally: + transport._reset_empty_waiter() + + ret = self.run_loop(self.loop.sendfile(transport, self.file)) + transport.close() + self.run_loop(srv_proto.done) + self.assertEqual(ret, len(self.DATA)) + self.assertEqual(srv_proto.data, self.DATA) + def test_sendfile_force_fallback(self): srv_proto, cli_proto = self.prepare_sendfile() diff --git a/Misc/NEWS.d/next/Library/2026-09-06-15-12-18.gh-issue-157025.ZvFpFT.rst b/Misc/NEWS.d/next/Library/2026-09-06-15-12-18.gh-issue-157025.ZvFpFT.rst new file mode 100644 index 000000000000000..fd1a6d7c5f47527 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-06-15-12-18.gh-issue-157025.ZvFpFT.rst @@ -0,0 +1,2 @@ +Fix transport cleanup when cancelling :meth:`asyncio.loop.sendfile` while +waiting for the write buffer to drain in the native implementation. From 3b564385e4c966de7f2da9ff8bfbc5e50296dc3d Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Mon, 7 Sep 2026 14:21:20 +0300 Subject: [PATCH 3/3] gh-154357: Fix tkinter, ttk and IDLE tests depending on the window manager (GH-154370) The window manager can take the focus from the application, ignore lift() and resize a toplevel on its own. * Hide the root window in the dialog tests, so that it does not compete for the focus. * Take the focus right before generating a key event. * Tolerate additional focus events. * Do not check focus_get() and focus_displayof() without the focus. * Use override-redirect toplevels on X11 in test_wm_stackorder. * Resize the toplevel to fit its content in wait_until_mapped(). Co-authored-by: Claude Opus 4.8 (1M context) --- Lib/idlelib/idle_test/test_configdialog.py | 4 +-- Lib/test/test_tkinter/support.py | 16 ++++++++++++ Lib/test/test_tkinter/test_filedialog.py | 12 +++++---- Lib/test/test_tkinter/test_misc.py | 26 ++++++++++++++----- Lib/test/test_tkinter/test_simpledialog.py | 30 +++++++++++----------- Lib/test/test_ttk/test_widgets.py | 6 ++++- 6 files changed, 65 insertions(+), 29 deletions(-) diff --git a/Lib/idlelib/idle_test/test_configdialog.py b/Lib/idlelib/idle_test/test_configdialog.py index f3e1c785a92674c..24d47854dddd9e1 100644 --- a/Lib/idlelib/idle_test/test_configdialog.py +++ b/Lib/idlelib/idle_test/test_configdialog.py @@ -150,8 +150,8 @@ def test_fontlist_key(self): font = d.fontlist.get('active') # Test Down key. - fontlist.focus_force() fontlist.update() + fontlist.focus_force() fontlist.event_generate('') fontlist.event_generate('') @@ -160,8 +160,8 @@ def test_fontlist_key(self): self.assertIn(d.font_name.get(), down_font.lower()) # Test Up key. - fontlist.focus_force() fontlist.update() + fontlist.focus_force() fontlist.event_generate('') fontlist.event_generate('') diff --git a/Lib/test/test_tkinter/support.py b/Lib/test/test_tkinter/support.py index 31feee2b8a40ee6..8d4ad2345a16510 100644 --- a/Lib/test/test_tkinter/support.py +++ b/Lib/test/test_tkinter/support.py @@ -61,6 +61,16 @@ def require_mapped(self, widget, timeout=None): f'(timed out after {timeout:g}s)') +class AbstractDialogTest(AbstractTkTest): + # Tk delivers generated keyboard events to the focused window. Hide the + # root window, otherwise the window manager can take the focus back from + # the dialog (gh-154357). + + def setUp(self): + super().setUp() + self.root.withdraw() + + class AbstractDefaultRootTest: def setUp(self): @@ -112,6 +122,7 @@ def wait_until_mapped(widget, timeout=None, *, full_size=False): timeout = support.LOOPBACK_TIMEOUT deadline = time.monotonic() + timeout widget.update_idletasks() + reset = False while True: widget.update() # drain pending Map/Configure events if widget.winfo_ismapped(): @@ -123,6 +134,11 @@ def wait_until_mapped(widget, timeout=None, *, full_size=False): h_ok = widget.winfo_height() > 1 if w_ok and h_ok: return True + if full_size and not reset: + # Tk no longer resizes the toplevel to fit its content if + # the window manager has resized it. Undo this. + widget.winfo_toplevel().wm_geometry('') + reset = True if time.monotonic() >= deadline: return False time.sleep(0.01) diff --git a/Lib/test/test_tkinter/test_filedialog.py b/Lib/test/test_tkinter/test_filedialog.py index f65ed19895a1ed4..2df77c0e23e92d8 100644 --- a/Lib/test/test_tkinter/test_filedialog.py +++ b/Lib/test/test_tkinter/test_filedialog.py @@ -6,7 +6,7 @@ from tkinter.commondialog import Dialog from test.support import requires, swap_attr from test.test_tkinter.support import setUpModule # noqa: F401 -from test.test_tkinter.support import AbstractTkTest +from test.test_tkinter.support import AbstractDialogTest, AbstractTkTest requires('gui') @@ -72,7 +72,7 @@ def test_results_preserved(self): ('/a', '/b')) -class FileDialogTest(AbstractTkTest, unittest.TestCase): +class FileDialogTest(AbstractDialogTest, unittest.TestCase): # The pure-Python FileDialog runs its own modal loop in go(); its logic is # exercised here without entering the loop. @@ -164,8 +164,8 @@ def test_alt_key(self): d = self.open() invoked = [] d.cancel_button.configure(command=lambda: invoked.append(True)) - d.top.focus_force() d.top.update() + d.top.focus_force() d.top.event_generate('') # "&Cancel" d.top.update() self.assertTrue(invoked) @@ -174,8 +174,8 @@ def test_escape_cancels(self): # The Escape key cancels the dialog. d = self.open() d.how = 'spam' - d.top.focus_force() d.top.update() + d.top.focus_force() d.top.event_generate('') d.top.update() self.assertIsNone(d.how) @@ -195,8 +195,10 @@ def test_type_ahead(self): d.files.delete(0, 'end') for name in ('alpha', 'bravo', 'charlie'): d.files.insert('end', name) - d.files.focus_force() d.top.update() + # Force the focus right before generating the event: the window + # manager can take it back. + d.files.focus_force() d.files.event_generate('', keysym='c') d.top.update() sel = d.files.curselection() diff --git a/Lib/test/test_tkinter/test_misc.py b/Lib/test/test_tkinter/test_misc.py index a225079dd99091b..c9d402e11a8826f 100644 --- a/Lib/test/test_tkinter/test_misc.py +++ b/Lib/test/test_tkinter/test_misc.py @@ -17,7 +17,8 @@ from test.test_tkinter.support import setUpModule # noqa: F401 from test.test_tkinter.support import (AbstractTkTest, AbstractDefaultRootTest, requires_tk, get_tk_patchlevel, - tcl_version, tk_version) + tcl_version, tk_version, + wait_until_mapped) support.requires('gui') @@ -508,15 +509,20 @@ def test_focus_methods(self): self.root.update_idletasks() f.focus_force() self.root.update() - self.assertIs(self.root.focus_get(), f) - self.assertIs(self.root.focus_displayof(), f) + # The window manager can take the focus away, and then focus_get() + # and focus_displayof() return None. + if self.root.focus_displayof() is not None: + self.assertIs(self.root.focus_get(), f) + self.assertIs(self.root.focus_displayof(), f) self.assertIs(f.focus_lastfor(), f) b = tkinter.Button(f) b.pack() self.root.update() b.focus_set() self.root.update() - self.assertIs(self.root.focus_get(), b) + if self.root.focus_displayof() is not None: + self.assertIs(self.root.focus_get(), b) + self.assertIs(f.focus_lastfor(), b) def test_focus_methods_unresolvable(self): # The focus may be on a widget that tkinter did not create and so @@ -1319,9 +1325,15 @@ def test_wm_transient(self): def test_wm_stackorder(self): t1 = tkinter.Toplevel(self.root) t2 = tkinter.Toplevel(self.root) + if self.root._windowingsystem == 'x11': + # Bypass the window manager, which may ignore lift() or reorder + # the windows while they are being mapped. + t1.overrideredirect(True) + t2.overrideredirect(True) t1.deiconify() t2.deiconify() - self.root.update() + wait_until_mapped(t1) + wait_until_mapped(t2) t1.lift(t2) # Raise t1 above t2. self.root.update() order = self.root.wm_stackorder() @@ -1361,7 +1373,9 @@ def test_focus(self): f.focus_force() self.root.update() - self.assertEqual(len(events), 1, events) + # The window manager can take the focus away and give it back, + # which makes Tk generate additional focus events. + self.assertGreaterEqual(len(events), 1, events) e = events[0] self.assertIs(e.type, tkinter.EventType.FocusIn) self.assertIs(e.widget, f) diff --git a/Lib/test/test_tkinter/test_simpledialog.py b/Lib/test/test_tkinter/test_simpledialog.py index be0be8d3f546311..943d6e113ed34cd 100644 --- a/Lib/test/test_tkinter/test_simpledialog.py +++ b/Lib/test/test_tkinter/test_simpledialog.py @@ -3,7 +3,7 @@ from tkinter import messagebox, ttk from test.support import requires, swap_attr from test.test_tkinter.support import setUpModule # noqa: F401 -from test.test_tkinter.support import AbstractDefaultRootTest, AbstractTkTest +from test.test_tkinter.support import AbstractDefaultRootTest, AbstractDialogTest from tkinter.simpledialog import (Dialog, SimpleDialog, askinteger, askfloat, askstring, _QueryInteger, _QueryFloat, _QueryString, @@ -12,7 +12,7 @@ requires('gui') -class SimpleDialogTest(AbstractTkTest, unittest.TestCase): +class SimpleDialogTest(AbstractDialogTest, unittest.TestCase): # SimpleDialog's modal loop is in go(); its bindings are exercised here by # generating events on the constructed dialog, without entering the loop. @@ -45,8 +45,8 @@ def test_use_ttk(self): ttk.Style(d.root).lookup('.', 'background')) # The bindings work with the themed buttons too. self.require_mapped(d.root) - d._buttons[0].focus_force() d.root.update() + d._buttons[0].focus_force() d.root.event_generate('') d.root.update() self.assertEqual(d.num, 0) @@ -144,8 +144,8 @@ def test_alt_key(self): # the matching button (cf. tk::AmpWidget in tk::MessageBox). d = self.create(buttons=['Yes', {'text': 'No', 'underline': 0}]) self.require_mapped(d.root) - d._buttons[0].focus_force() d.root.update() + d._buttons[0].focus_force() d.root.event_generate('') # "No" -> underline 0 -> "N" d.root.update() self.assertEqual(d.num, 1) @@ -155,8 +155,8 @@ def test_return_invokes_focused_button(self): # default and the focus was not moved by keyboard traversal. d = self.create(buttons=['Yes', 'No']) # default 0 self.require_mapped(d.root) - d._buttons[1].focus_force() d.root.update() + d._buttons[1].focus_force() d.root.event_generate('') d.root.update() self.assertEqual(d.num, 1) @@ -165,8 +165,8 @@ def test_focus_next_then_return(self): # moves the focus to the next button; invokes it. d = self.create(buttons=['Yes', 'No']) self.require_mapped(d.root) - d._buttons[0].focus_force() d.root.update() + d._buttons[0].focus_force() d._buttons[0].event_generate('') d.root.update() d.root.event_generate('') @@ -177,8 +177,8 @@ def test_focus_prev_then_return(self): # moves the focus to the previous button. d = self.create(buttons=['Yes', 'No']) self.require_mapped(d.root) - d._buttons[1].focus_force() d.root.update() + d._buttons[1].focus_force() d._buttons[1].event_generate('') d.root.update() d.root.event_generate('') @@ -189,8 +189,8 @@ def test_return_activates_default(self): # with the focus off the buttons invokes the default button. d = self.create() # default 0 self.require_mapped(d.root) - d.root.focus_force() # the dialog, not a button, has the focus d.root.update() + d.root.focus_force() # the dialog, not a button, has the focus d.root.event_generate('') d.root.update() self.assertEqual(d.num, 0) @@ -246,7 +246,7 @@ def test_go(self): self.assertEqual(d.go(), 0) -class DialogTest(AbstractTkTest, unittest.TestCase): +class DialogTest(AbstractDialogTest, unittest.TestCase): # Dialog's button box is modelled on tk::MessageBox. def open(self, **kw): @@ -279,8 +279,8 @@ def test_use_classic(self): invoked = [] cancel = d.children['cancel'] cancel.configure(command=lambda: invoked.append(True)) - cancel.focus_force() d.update() + cancel.focus_force() d.event_generate('') d.update() self.assertTrue(invoked) @@ -359,8 +359,8 @@ def test_alt_key(self): invoked = [] cancel = d.children['cancel'] # "&Cancel" cancel.configure(command=lambda: invoked.append(True)) - d.focus_force() d.update() + d.focus_force() d.event_generate('') d.update() self.assertTrue(invoked) @@ -371,8 +371,8 @@ def test_return_invokes_focused_button(self): invoked = [] cancel = d.children['cancel'] cancel.configure(command=lambda: invoked.append(True)) - cancel.focus_force() d.update() + cancel.focus_force() d.event_generate('') d.update() self.assertEqual(invoked, [True]) @@ -384,8 +384,8 @@ def test_focus_next_then_return(self): for name in ('ok', 'cancel'): d.children[name].configure(command=lambda name=name: invoked.append(name)) ok = d.children['ok'] - ok.focus_force() d.update() + ok.focus_force() ok.event_generate('') # OK -> Cancel d.update() d.event_generate('') @@ -399,8 +399,8 @@ def test_focus_prev_then_return(self): for name in ('ok', 'cancel'): d.children[name].configure(command=lambda name=name: invoked.append(name)) cancel = d.children['cancel'] - cancel.focus_force() d.update() + cancel.focus_force() cancel.event_generate('') # Cancel -> OK d.update() d.event_generate('') @@ -432,7 +432,7 @@ def mock_wait_window(w): self.assertRaises(RuntimeError, askinteger, "Go To Line", "Line number") -class QueryDialogTest(AbstractTkTest, unittest.TestCase): +class QueryDialogTest(AbstractDialogTest, unittest.TestCase): # The query dialogs are modal: their __init__ blocks in wait_window(). # Mock that out so the dialog stays alive and can be driven with generated # events, exercising the / bindings and the validation. diff --git a/Lib/test/test_ttk/test_widgets.py b/Lib/test/test_ttk/test_widgets.py index 4fa6a032e97c5f8..6ed593e3e43bb07 100644 --- a/Lib/test/test_ttk/test_widgets.py +++ b/Lib/test/test_ttk/test_widgets.py @@ -2024,19 +2024,23 @@ def test_virtual_events(self): lambda e: selects.append(self.tv.selection())) self.tv.bind('<>', lambda e: opens.append(self.tv.focus())) self.tv.bind('<>', lambda e: closes.append(self.tv.focus())) - self.tv.focus_force() self.tv.focus(parent) self.tv.selection_set(parent) self.tv.update() + # Force the focus right before generating the event: the window + # manager can take it back. + self.tv.focus_force() self.tv.event_generate('') # Open the focused parent. self.tv.update() self.assertEqual(opens, [parent]) + self.tv.focus_force() self.tv.event_generate('') # Close it again. self.tv.update() self.assertEqual(closes, [parent]) + self.tv.focus_force() self.tv.event_generate('') # Move the selection. self.tv.update() self.assertEqual(self.tv.selection(), (item2,))