From 5ce30ec209fdc8a31396f309bb3045d4771a86d8 Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Sat, 15 Aug 2026 22:14:18 +0800 Subject: [PATCH 1/4] gh-155852: Do not cancel remaining Executor.map calls on a callable's TimeoutError A TimeoutError raised by the mapped callable was re-raised like the map(timeout=...) wait timeout, aborting the iteration and cancelling the remaining calls, unlike every other exception since gh-108518. A wait timeout only occurs while the future is still running, so a TimeoutError from an already-finished future is treated as the callable's own result. --- Lib/concurrent/futures/_base.py | 6 +++++- Lib/test/test_concurrent_futures/executor.py | 19 +++++++++++++++++++ ...-08-15-16-00-00.gh-issue-155852.MapTmo.rst | 3 +++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-15-16-00-00.gh-issue-155852.MapTmo.rst diff --git a/Lib/concurrent/futures/_base.py b/Lib/concurrent/futures/_base.py index cc335d9aa1ea55d..4cf52f87bdd761d 100644 --- a/Lib/concurrent/futures/_base.py +++ b/Lib/concurrent/futures/_base.py @@ -310,7 +310,11 @@ def _result_or_cancel(fut, timeout=None): try: try: return (fut.result(timeout), None) - except TimeoutError: + except TimeoutError as exc: + if fut.done(): + # The future already finished, so this is the callable's own + # TimeoutError, not the map() timeout waiting for the future. + return (None, exc) raise except BaseException as exc: return (None, exc) diff --git a/Lib/test/test_concurrent_futures/executor.py b/Lib/test/test_concurrent_futures/executor.py index 5d9f27c83bf9a81..e4f91f2067052fb 100644 --- a/Lib/test/test_concurrent_futures/executor.py +++ b/Lib/test/test_concurrent_futures/executor.py @@ -29,6 +29,13 @@ def raiser(exception, msg='std'): raise exception(msg) +# Used in test_map_timeout_from_callable +def timeout_on_one(x): + if x == 1: + raise TimeoutError + return x + + class FalseyBoolException(Exception): def __bool__(self): return False @@ -87,6 +94,18 @@ def test_map_exception(self): self.assertRaises(StopIteration, next, i) self.assertRaises(StopIteration, next, i) + @warnings_helper.ignore_fork_in_thread_deprecation_warnings() + def test_map_timeout_from_callable(self): + # A TimeoutError raised by the mapped callable must not be treated as a + # map() timeout: the remaining calls keep running, like any other + # exception. + i = self.executor.map(timeout_on_one, [0, 1, 2, 3]) + self.assertEqual(next(i), 0) + self.assertRaises(TimeoutError, next, i) + self.assertEqual(next(i), 2) + self.assertEqual(next(i), 3) + self.assertRaises(StopIteration, next, i) + @warnings_helper.ignore_fork_in_thread_deprecation_warnings() @support.requires_resource('walltime') def test_map_timeout(self): diff --git a/Misc/NEWS.d/next/Library/2026-08-15-16-00-00.gh-issue-155852.MapTmo.rst b/Misc/NEWS.d/next/Library/2026-08-15-16-00-00.gh-issue-155852.MapTmo.rst new file mode 100644 index 000000000000000..57be15bad739238 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-15-16-00-00.gh-issue-155852.MapTmo.rst @@ -0,0 +1,3 @@ +Fix :meth:`concurrent.futures.Executor.map` cancelling the remaining calls when +the mapped callable raises :exc:`TimeoutError`; such an exception is now +propagated like any other, without stopping the iteration. From 355dc7cec73c6ec862da450e1b5b0b58162bb4de Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Sat, 15 Aug 2026 22:29:07 +0800 Subject: [PATCH 2/4] Trim the regression test comment --- Lib/test/test_concurrent_futures/executor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Lib/test/test_concurrent_futures/executor.py b/Lib/test/test_concurrent_futures/executor.py index e4f91f2067052fb..1b1eac639453687 100644 --- a/Lib/test/test_concurrent_futures/executor.py +++ b/Lib/test/test_concurrent_futures/executor.py @@ -96,9 +96,7 @@ def test_map_exception(self): @warnings_helper.ignore_fork_in_thread_deprecation_warnings() def test_map_timeout_from_callable(self): - # A TimeoutError raised by the mapped callable must not be treated as a - # map() timeout: the remaining calls keep running, like any other - # exception. + # A TimeoutError from the callable is not the map() timeout. i = self.executor.map(timeout_on_one, [0, 1, 2, 3]) self.assertEqual(next(i), 0) self.assertRaises(TimeoutError, next, i) From 854047ae7b67f336dd649bc6f0dc1f86b2429424 Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Sun, 16 Aug 2026 20:52:45 +0800 Subject: [PATCH 3/4] Wait out the map() timeout separately from retrieving the result fut.done() could race: if the future finished after the wait timed out but before the check, a real map() timeout was taken for the call's exception. Future.exception() raises TimeoutError only for the wait, so a TimeoutError from the call itself now reaches fut.result() and is returned like any other. --- Lib/concurrent/futures/_base.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/Lib/concurrent/futures/_base.py b/Lib/concurrent/futures/_base.py index 4cf52f87bdd761d..fc2376147528ad0 100644 --- a/Lib/concurrent/futures/_base.py +++ b/Lib/concurrent/futures/_base.py @@ -309,15 +309,21 @@ def wait(fs, timeout=None, return_when=ALL_COMPLETED): def _result_or_cancel(fut, timeout=None): try: try: - return (fut.result(timeout), None) - except TimeoutError as exc: - if fut.done(): - # The future already finished, so this is the callable's own - # TimeoutError, not the map() timeout waiting for the future. + if timeout is not None: + # Wait out the timeout separately from retrieving the result, so + # that a TimeoutError raised by the call is not mistaken for the + # map() timeout. Future.exception() raises TimeoutError only + # when the wait itself times out, never for the call's own. + try: + fut.exception(timeout) + except TimeoutError: + raise + except CancelledError: + pass + try: + return (fut.result(), None) + except BaseException as exc: return (None, exc) - raise - except BaseException as exc: - return (None, exc) finally: fut.cancel() finally: From 01438f7a927aae69f4c4356db8cbb857f39101fd Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Mon, 17 Aug 2026 10:19:37 +0800 Subject: [PATCH 4/4] Address review: use fut.exception() result, drop the redundant handling, test the timeout case, remove NEWS --- Lib/concurrent/futures/_base.py | 21 +++++++------------ Lib/test/test_concurrent_futures/executor.py | 19 ++++++++++------- ...-08-15-16-00-00.gh-issue-155852.MapTmo.rst | 3 --- 3 files changed, 18 insertions(+), 25 deletions(-) delete mode 100644 Misc/NEWS.d/next/Library/2026-08-15-16-00-00.gh-issue-155852.MapTmo.rst diff --git a/Lib/concurrent/futures/_base.py b/Lib/concurrent/futures/_base.py index fc2376147528ad0..e728b8e0a91f744 100644 --- a/Lib/concurrent/futures/_base.py +++ b/Lib/concurrent/futures/_base.py @@ -309,21 +309,14 @@ def wait(fs, timeout=None, return_when=ALL_COMPLETED): def _result_or_cancel(fut, timeout=None): try: try: - if timeout is not None: - # Wait out the timeout separately from retrieving the result, so - # that a TimeoutError raised by the call is not mistaken for the - # map() timeout. Future.exception() raises TimeoutError only - # when the wait itself times out, never for the call's own. - try: - fut.exception(timeout) - except TimeoutError: - raise - except CancelledError: - pass - try: - return (fut.result(), None) - except BaseException as exc: + # fut.exception() returns the call's own error but raises + # TimeoutError only for a map() timeout. + exc = fut.exception(timeout) + if exc is not None: return (None, exc) + return (fut.result(), None) + except CancelledError as exc: + return (None, exc) finally: fut.cancel() finally: diff --git a/Lib/test/test_concurrent_futures/executor.py b/Lib/test/test_concurrent_futures/executor.py index 1b1eac639453687..ff7bd0db0c2199c 100644 --- a/Lib/test/test_concurrent_futures/executor.py +++ b/Lib/test/test_concurrent_futures/executor.py @@ -29,7 +29,6 @@ def raiser(exception, msg='std'): raise exception(msg) -# Used in test_map_timeout_from_callable def timeout_on_one(x): if x == 1: raise TimeoutError @@ -96,13 +95,17 @@ def test_map_exception(self): @warnings_helper.ignore_fork_in_thread_deprecation_warnings() def test_map_timeout_from_callable(self): - # A TimeoutError from the callable is not the map() timeout. - i = self.executor.map(timeout_on_one, [0, 1, 2, 3]) - self.assertEqual(next(i), 0) - self.assertRaises(TimeoutError, next, i) - self.assertEqual(next(i), 2) - self.assertEqual(next(i), 3) - self.assertRaises(StopIteration, next, i) + # A TimeoutError from the callable is not the map() timeout, whether + # or not a map() timeout is set. + for timeout in (None, support.SHORT_TIMEOUT): + with self.subTest(timeout=timeout): + i = self.executor.map(timeout_on_one, [0, 1, 2, 3], + timeout=timeout) + self.assertEqual(next(i), 0) + self.assertRaises(TimeoutError, next, i) + self.assertEqual(next(i), 2) + self.assertEqual(next(i), 3) + self.assertRaises(StopIteration, next, i) @warnings_helper.ignore_fork_in_thread_deprecation_warnings() @support.requires_resource('walltime') diff --git a/Misc/NEWS.d/next/Library/2026-08-15-16-00-00.gh-issue-155852.MapTmo.rst b/Misc/NEWS.d/next/Library/2026-08-15-16-00-00.gh-issue-155852.MapTmo.rst deleted file mode 100644 index 57be15bad739238..000000000000000 --- a/Misc/NEWS.d/next/Library/2026-08-15-16-00-00.gh-issue-155852.MapTmo.rst +++ /dev/null @@ -1,3 +0,0 @@ -Fix :meth:`concurrent.futures.Executor.map` cancelling the remaining calls when -the mapped callable raises :exc:`TimeoutError`; such an exception is now -propagated like any other, without stopping the iteration.