From 13ab4c2861442f3c558abdb5971716261bf15964 Mon Sep 17 00:00:00 2001 From: Omar Sandoval Date: Tue, 25 Aug 2026 10:47:38 -0700 Subject: [PATCH 1/7] Fix various issues in the 'Timer File Descriptors' documentation (#156027) Co-authored-by: Stan Ulbrych --- Doc/library/os.rst | 24 ++++++++++++------------ Modules/clinic/posixmodule.c.h | 10 +++++----- Modules/posixmodule.c | 14 +++++++------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/Doc/library/os.rst b/Doc/library/os.rst index 0f033efefd209c..596597b1ab223e 100644 --- a/Doc/library/os.rst +++ b/Doc/library/os.rst @@ -4577,16 +4577,17 @@ Naturally, they are all only available on Linux. - :const:`time.CLOCK_BOOTTIME` (Since Linux 3.15 for timerfd_create) If *clockid* is :const:`time.CLOCK_REALTIME`, a settable system-wide - real-time clock is used. If system clock is changed, timer setting need - to be updated. To cancel timer when system clock is changed, see + real-time clock is used. If the system clock is changed, the timer setting + needs to be updated. To cancel the timer when the system clock is changed, see :const:`TFD_TIMER_CANCEL_ON_SET`. If *clockid* is :const:`time.CLOCK_MONOTONIC`, a non-settable monotonically increasing clock is used. Even if the system clock is changed, the timer setting will not be affected. - If *clockid* is :const:`time.CLOCK_BOOTTIME`, same as :const:`time.CLOCK_MONOTONIC` - except it includes any time that the system is suspended. + If *clockid* is :const:`time.CLOCK_BOOTTIME`, it is the same as + :const:`time.CLOCK_MONOTONIC` except it includes any time that the system + is suspended. The file descriptor's behaviour can be modified by specifying a *flags* value. Any of the following variables may be used, combined using bitwise OR @@ -4597,8 +4598,8 @@ Naturally, they are all only available on Linux. If :const:`TFD_NONBLOCK` is not set as a flag, :func:`read` blocks until the timer expires. If it is set as a flag, :func:`read` doesn't block, but - If there hasn't been an expiration since the last call to read, - :func:`read` raises :class:`OSError` with ``errno`` is set to + if there hasn't been an expiration since the last call to read, + :func:`read` raises :class:`OSError` with ``errno`` set to :const:`errno.EAGAIN`. :const:`TFD_CLOEXEC` is always set by Python automatically. @@ -4613,7 +4614,7 @@ Naturally, they are all only available on Linux. .. versionadded:: 3.13 -.. function:: timerfd_settime(fd, /, *, flags=flags, initial=0.0, interval=0.0) +.. function:: timerfd_settime(fd, /, *, flags=0, initial=0.0, interval=0.0) Alter a timer file descriptor's internal timer. This function operates the same interval timer as :func:`timerfd_settime_ns`. @@ -4628,12 +4629,11 @@ Naturally, they are all only available on Linux. - :const:`TFD_TIMER_CANCEL_ON_SET` The timer is disabled by setting *initial* to zero (``0``). - If *initial* is equal to or greater than zero, the timer is enabled. + If *initial* is greater than zero, the timer is enabled. If *initial* is less than zero, it raises an :class:`OSError` exception - with ``errno`` set to :const:`errno.EINVAL` + with ``errno`` set to :const:`errno.EINVAL`. By default the timer will fire when *initial* seconds have elapsed. - (If *initial* is zero, timer will fire immediately.) However, if the :const:`TFD_TIMER_ABSTIME` flag is set, the timer will fire when the timer's clock @@ -4644,13 +4644,13 @@ Naturally, they are all only available on Linux. If *interval* is greater than zero, the timer fires every time *interval* seconds have elapsed since the previous expiration. If *interval* is less than zero, it raises :class:`OSError` with ``errno`` - set to :const:`errno.EINVAL` + set to :const:`errno.EINVAL`. If the :const:`TFD_TIMER_CANCEL_ON_SET` flag is set along with :const:`TFD_TIMER_ABSTIME` and the clock for this timer is :const:`time.CLOCK_REALTIME`, the timer is marked as cancelable if the real-time clock is changed discontinuously. Reading the descriptor is - aborted with the error ECANCELED. + aborted with the error :const:`errno.ECANCELED`. Linux manages system clock as UTC. A daylight-savings time transition is done by changing time offset only and doesn't cause discontinuous system diff --git a/Modules/clinic/posixmodule.c.h b/Modules/clinic/posixmodule.c.h index 6278eb81481402..7c8171c04c0727 100644 --- a/Modules/clinic/posixmodule.c.h +++ b/Modules/clinic/posixmodule.c.h @@ -7077,9 +7077,9 @@ PyDoc_STRVAR(os_timerfd_settime_ns__doc__, " flags\n" " 0 or a bit mask of TFD_TIMER_ABSTIME or TFD_TIMER_CANCEL_ON_SET.\n" " initial\n" -" initial expiration timing in seconds.\n" +" initial expiration timing in nanoseconds.\n" " interval\n" -" interval for the timer in seconds."); +" interval for the timer in nanoseconds."); #define OS_TIMERFD_SETTIME_NS_METHODDEF \ {"timerfd_settime_ns", _PyCFunction_CAST(os_timerfd_settime_ns), METH_FASTCALL|METH_KEYWORDS, os_timerfd_settime_ns__doc__}, @@ -7175,7 +7175,7 @@ PyDoc_STRVAR(os_timerfd_gettime__doc__, "timerfd_gettime($module, fd, /)\n" "--\n" "\n" -"Return a tuple of a timer file descriptor\'s (interval, next expiration) in float seconds.\n" +"Return a tuple of a timer file descriptor\'s (next expiration, interval) in float seconds.\n" "\n" " fd\n" " A timer file descriptor."); @@ -7210,7 +7210,7 @@ PyDoc_STRVAR(os_timerfd_gettime_ns__doc__, "timerfd_gettime_ns($module, fd, /)\n" "--\n" "\n" -"Return a tuple of a timer file descriptor\'s (interval, next expiration) in nanoseconds.\n" +"Return a tuple of a timer file descriptor\'s (next expiration, interval) in nanoseconds.\n" "\n" " fd\n" " A timer file descriptor."); @@ -13747,4 +13747,4 @@ os__emscripten_log(PyObject *module, PyObject *const *args, Py_ssize_t nargs, Py #ifndef OS__EMSCRIPTEN_LOG_METHODDEF #define OS__EMSCRIPTEN_LOG_METHODDEF #endif /* !defined(OS__EMSCRIPTEN_LOG_METHODDEF) */ -/*[clinic end generated code: output=a5bea01c02d27152 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=d4e858cbdf280235 input=a9049054013a1b77]*/ diff --git a/Modules/posixmodule.c b/Modules/posixmodule.c index a9375e48a11d89..a114a617918f7c 100644 --- a/Modules/posixmodule.c +++ b/Modules/posixmodule.c @@ -11484,9 +11484,9 @@ os.timerfd_settime_ns flags: int = 0 0 or a bit mask of TFD_TIMER_ABSTIME or TFD_TIMER_CANCEL_ON_SET. initial: long_long = 0 - initial expiration timing in seconds. + initial expiration timing in nanoseconds. interval: long_long = 0 - interval for the timer in seconds. + interval for the timer in nanoseconds. Alter a timer file descriptor's internal timer in nanoseconds. [clinic start generated code]*/ @@ -11494,7 +11494,7 @@ Alter a timer file descriptor's internal timer in nanoseconds. static PyObject * os_timerfd_settime_ns_impl(PyObject *module, int fd, int flags, long long initial, long long interval) -/*[clinic end generated code: output=6273ec7d7b4cc0b3 input=261e105d6e42f5bc]*/ +/*[clinic end generated code: output=6273ec7d7b4cc0b3 input=94bdcea7292157eb]*/ { struct itimerspec new_value; struct itimerspec old_value; @@ -11524,12 +11524,12 @@ os.timerfd_gettime A timer file descriptor. / -Return a tuple of a timer file descriptor's (interval, next expiration) in float seconds. +Return a tuple of a timer file descriptor's (next expiration, interval) in float seconds. [clinic start generated code]*/ static PyObject * os_timerfd_gettime_impl(PyObject *module, int fd) -/*[clinic end generated code: output=ec5a94a66cfe6ab4 input=05f7d568a4820dc6]*/ +/*[clinic end generated code: output=ec5a94a66cfe6ab4 input=7b0a7cc61ea9e31a]*/ { struct itimerspec curr_value; int result; @@ -11551,12 +11551,12 @@ os.timerfd_gettime_ns A timer file descriptor. / -Return a tuple of a timer file descriptor's (interval, next expiration) in nanoseconds. +Return a tuple of a timer file descriptor's (next expiration, interval) in nanoseconds. [clinic start generated code]*/ static PyObject * os_timerfd_gettime_ns_impl(PyObject *module, int fd) -/*[clinic end generated code: output=580633a4465f39fe input=d0de95b9782179c5]*/ +/*[clinic end generated code: output=580633a4465f39fe input=89702268455fa93b]*/ { struct itimerspec curr_value; int result; From 98bd716479dacb1e618a4271df10b2b67c010c4c Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 25 Aug 2026 14:05:54 -0400 Subject: [PATCH 2/7] Docs(build): linklint is now called sphinx_linklint (#156373) * Docs(build): linklint is now called sphinx_linklint * Docs(build): move the lock command to the Makefile * Update Doc/Makefile Co-authored-by: Stan Ulbrych * Add a comment clarify cooldown (and excludes) and add to `make help` --------- Co-authored-by: Stan Ulbrych --- Doc/Makefile | 14 ++++++++++++++ Doc/conf.py | 2 +- Doc/pylock.toml | 14 +++++++------- Doc/requirements.txt | 5 ++--- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/Doc/Makefile b/Doc/Makefile index d77ece1e681bcf..ef6ef8c42e3636 100644 --- a/Doc/Makefile +++ b/Doc/Makefile @@ -32,6 +32,7 @@ help: @echo "Please use \`make ' where is one of" @echo " clean to remove build files" @echo " venv to create a venv with necessary tools" + @echo " lock to regenerate the pinned dependencies in $(REQUIREMENTS)" @echo " html to make standalone HTML files" @echo " gettext to generate POT files" @echo " htmlview to open the index page built by the html target in your browser" @@ -185,6 +186,19 @@ venv: echo "The venv has been created in the $(VENVDIR) directory"; \ fi +.PHONY: lock +lock: +# Dependencies have a 14 day cooldown period to mitigate supply chain attacks, +# except for sphinx_linklint and python-docs-theme, which are maintained by +# core team members. + uv pip compile requirements.txt \ + --exclude-newer P14D \ + --exclude-newer-package sphinx_linklint=PT0S \ + --exclude-newer-package python-docs-theme=PT0S \ + --no-cache --output-file $(REQUIREMENTS) \ + --python-version 3.12 --universal \ + --custom-compile-command="make lock" + .PHONY: dist-no-html dist-no-html: dist-text dist-epub dist-texinfo diff --git a/Doc/conf.py b/Doc/conf.py index 9b103a594b235c..c768e6fd676a5a 100644 --- a/Doc/conf.py +++ b/Doc/conf.py @@ -41,7 +41,7 @@ # Skip if downstream redistributors haven't installed them _OPTIONAL_EXTENSIONS = ( - 'linklint.ext', + 'sphinx_linklint.ext', 'notfound.extension', 'sphinxext.opengraph', 'sphinxcontrib.rsvgconverter', diff --git a/Doc/pylock.toml b/Doc/pylock.toml index af4627c2ad31d4..94b7d9d48d646e 100644 --- a/Doc/pylock.toml +++ b/Doc/pylock.toml @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile Doc/requirements.txt --exclude-newer P14D --exclude-newer-package linklint=PT0S --exclude-newer-package python-docs-theme=PT0S --exclude-newer-package pygments=PT0S --no-cache --output-file Doc/pylock.toml --python-version 3.12 --universal +# make lock lock-version = "1.0" created-by = "uv" requires-python = ">=3.12" @@ -119,12 +119,6 @@ version = "3.1.6" sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", upload-time = 2025-03-05T20:05:02Z, size = 245115, hashes = { sha256 = "0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" } } wheels = [{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", upload-time = 2025-03-05T20:05:00Z, size = 134899, hashes = { sha256 = "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" } }] -[[packages]] -name = "linklint" -version = "1.0.1" -sdist = { url = "https://files.pythonhosted.org/packages/62/22/c65a97b2192c3c317c472058bf52d7ca88e421998fa8d5c183b2280ab5d4/linklint-1.0.1.tar.gz", upload-time = 2026-06-09T12:30:47Z, size = 22412, hashes = { sha256 = "10201b4366edecfbeb3281883aff857dbe77d40bb56f16f212d372f597342d5f" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/7b/d8/b2f1708f55f4dd5358b48d28be07ff8e0dadd6d23e41ead2ed1424e90256/linklint-1.0.1-py3-none-any.whl", upload-time = 2026-06-09T12:30:46Z, size = 12828, hashes = { sha256 = "c931f110482f1d17808aa2df78b116adc44d7a14e9e79922c57bf9a7cba40a2d" } }] - [[packages]] name = "markupsafe" version = "2.1.5" @@ -190,6 +184,12 @@ version = "8.2.3" sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", upload-time = 2025-03-02T22:31:59Z, size = 8321876, hashes = { sha256 = "398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348" } } wheels = [{ url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", upload-time = 2025-03-02T22:31:56Z, size = 3589741, hashes = { sha256 = "4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3" } }] +[[packages]] +name = "sphinx-linklint" +version = "2.0.0" +sdist = { url = "https://files.pythonhosted.org/packages/f6/99/f9947b30fd11e782855a13de1aac7d08634edba1c193a0ee01bf5338531c/sphinx_linklint-2.0.0.tar.gz", upload-time = 2026-08-25T12:47:33Z, size = 23297, hashes = { sha256 = "e7ea4d3b1bd83665e9c2ab9fd92c6c645c805956d2e8ca6b0f528f3a9638910d" } } +wheels = [{ url = "https://files.pythonhosted.org/packages/d7/3d/6b96af48e139357e1e041a710d943c4bb79ad149c7aab074df784b4395ec/sphinx_linklint-2.0.0-py3-none-any.whl", upload-time = 2026-08-25T12:47:32Z, size = 13390, hashes = { sha256 = "12aa64f3b3faaa6e8a979a7d1f2c38617866652b670e3d19fa0f60a6ce3657ef" } }] + [[packages]] name = "sphinx-notfound-page" version = "1.0.4" diff --git a/Doc/requirements.txt b/Doc/requirements.txt index edaec74546b3c1..b9072b4af54222 100644 --- a/Doc/requirements.txt +++ b/Doc/requirements.txt @@ -13,13 +13,12 @@ pygments>=2.21 blurb -sphinxext-opengraph~=0.13.0 +sphinx-linklint sphinx-notfound-page~=1.0.0 +sphinxext-opengraph~=0.13.0 # The theme used by the documentation is stored separately, so we need # to install that as well. python-docs-theme>=2023.3.1,!=2023.7 -linklint - -c constraints.txt From 05eac890c2f2846870daf2a1bfe726547856abe3 Mon Sep 17 00:00:00 2001 From: Hood Chatham Date: Tue, 25 Aug 2026 15:50:13 -0600 Subject: [PATCH 3/7] gh-156243: Indicate that emscripten_runtime depends on stringToNewUTF8 (#156335) Add an explicit declaration an Emscripten dependency on the stringToNewUTF8 library function. --- Python/sysmodule.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Python/sysmodule.c b/Python/sysmodule.c index 5051dfc3ec0f86..10087c1ccaac17 100644 --- a/Python/sysmodule.c +++ b/Python/sysmodule.c @@ -3868,6 +3868,8 @@ static PyStructSequence_Desc emscripten_info_desc = { 4 }; +EM_JS_DEPS(_Py_emscripten_runtime, "$stringToNewUTF8") + EM_JS(char *, _Py_emscripten_runtime, (void), { var info; if (typeof process === "object") { From 53381bcd742dc2a1b0ac8f6013aaac971d0cab24 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Tue, 25 Aug 2026 23:32:21 +0100 Subject: [PATCH 4/7] gh-154085: Avoid duplicating diff line values (#154099) --- Lib/profiling/sampling/stack_collector.py | 51 +++++++- .../test_sampling_profiler/test_collectors.py | 117 ++++++++++++++++++ ...-07-19-12-00-00.gh-issue-154085.Qm8fLd.rst | 2 + 3 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-19-12-00-00.gh-issue-154085.Qm8fLd.rst diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index 796a900e084676..97fe5535a6764c 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -660,9 +660,33 @@ def _add_diff_data_to_node(self, node, path, current_stats, baseline_stats, scal current_data = current_stats.get(path_key, {"total": 0, "self": 0}) baseline_data = baseline_stats.get(path_key, {"total": 0, "self": 0}) - current_self = current_data["self"] - baseline_self = baseline_data["self"] * scale - baseline_total = baseline_data["total"] * scale + current_self = node.get("self", 0) + current_total = node.get("value", 0) + + current_nonself = current_total - current_self + aggregate_nonself = current_data["total"] - current_data["self"] + + # Allocate self and descendant samples separately. Line-number + # changes can split one function path into several rendered nodes, + # and using independent weights for self and inclusive totals could + # otherwise assign a node more self samples than total samples. + self_weight = self._sample_weight( + current_self, + current_data["self"], + current_total, + current_data["total"], + ) + nonself_weight = self._sample_weight( + current_nonself, + aggregate_nonself, + current_total, + current_data["total"], + ) + baseline_self = baseline_data["self"] * scale * self_weight + baseline_nonself = ( + baseline_data["total"] - baseline_data["self"] + ) * scale * nonself_weight + baseline_total = baseline_self + baseline_nonself diff = current_self - baseline_self if baseline_self > 0: @@ -682,6 +706,14 @@ def _add_diff_data_to_node(self, node, path, current_stats, baseline_stats, scal for child in node["children"]: self._add_diff_data_to_node(child, path_key, current_stats, baseline_stats, scale) + @staticmethod + def _sample_weight(value, aggregate, fallback_value, fallback_aggregate): + if aggregate > 0: + return value / aggregate + if fallback_aggregate > 0: + return fallback_value / fallback_aggregate + return 0 + def _is_promoted_root(self, data): """Check if the data represents a promoted root node.""" return "filename" in data and "funcname" in data @@ -758,6 +790,9 @@ def _extract_elided_nodes(self, node, path): # elided nodes keep their original value to preserve self-samples if elided_children and not is_elided: node["value"] = total_value + node["self"] = 0 + node.pop("opcodes", None) + node.pop("thread_opcodes", None) # Keep this node if it's elided or has elided descendants return is_elided or bool(node.get("children")) @@ -773,9 +808,13 @@ def _add_elided_metadata(self, node, baseline_stats, scale, path): baseline_self = 0 baseline_total = 0 if func_key and current_path in baseline_stats: - baseline_data = baseline_stats[current_path] - baseline_self = baseline_data["self"] * scale - baseline_total = baseline_data["total"] * scale + baseline_total = node.get("value", 0) * scale + + # Matched nodes are retained only as structural ancestors. Their + # own samples are still present in the current profile and must + # not be reported as disappeared. + if current_path in self._elided_paths: + baseline_self = node.get("self", 0) * scale node["baseline"] = baseline_self node["baseline_total"] = baseline_total 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 c27ad6663df1c8..543ffc6fdd88d0 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py @@ -1748,6 +1748,123 @@ def test_diff_flamegraph_function_matched_despite_line_change(self): self.assertAlmostEqual(child["diff"], 0.0, places=1) self.assertAlmostEqual(child["diff_pct"], 0.0, places=1) + def test_diff_flamegraph_does_not_duplicate_line_values(self): + """Function aggregates are apportioned across line nodes.""" + def sample(line): + return [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", line, "func"), + MockFrameInfo("file.py", 1, "caller"), + ]) + ]) + ] + + diff = make_diff_collector_with_mock_baseline( + [sample(10), sample(20)] + ) + diff.collect(sample(10)) + diff.collect(sample(20)) + + data = diff._convert_to_flamegraph_format() + children = data["children"] + self.assertEqual(sum(node["self"] for node in children), 2) + self.assertEqual(sum(node["self_time"] for node in children), 2) + self.assertEqual(sum(node["baseline"] for node in children), 2) + for node in children: + self.assertEqual(node["self"], 1) + self.assertEqual(node["self_time"], 1) + self.assertAlmostEqual(node["baseline"], 1.0) + self.assertAlmostEqual(node["diff"], 0.0) + + def test_diff_flamegraph_line_totals_include_allocated_self(self): + """A line's baseline self time cannot exceed its inclusive time.""" + def sample(*frames): + return [ + MockInterpreterInfo(0, [MockThreadInfo(1, list(frames))]) + ] + + target_10 = MockFrameInfo("file.py", 10, "target") + target_20 = MockFrameInfo("file.py", 20, "target") + child = MockFrameInfo("file.py", 30, "child") + + diff = make_diff_collector_with_mock_baseline( + [sample(target_10)] * 100 + ) + for _ in range(10): + diff.collect(sample(target_10)) + for _ in range(90): + diff.collect(sample(child, target_20)) + + data = diff._convert_to_flamegraph_format() + nodes = data["children"] + self.assertEqual(sum(node["baseline"] for node in nodes), 100) + self.assertEqual(sum(node["baseline_total"] for node in nodes), 100) + for node in nodes: + self.assertGreaterEqual(node["baseline"], 0) + self.assertLessEqual(node["baseline"], node["baseline_total"]) + + def test_diff_flamegraph_does_not_duplicate_elided_line_values(self): + """Elided metadata uses each rendered line node's samples.""" + def sample(line, funcname="old_func"): + return [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", line, funcname), + MockFrameInfo("file.py", 1, "caller"), + ]) + ]) + ] + + diff = make_diff_collector_with_mock_baseline( + [sample(10), sample(20)] + ) + diff.collect(sample(30, "new_func")) + + data = diff._convert_to_flamegraph_format() + elided = data["stats"]["elided_flamegraph"] + children = elided["children"] + scale = data["stats"]["baseline_scale"] + self.assertEqual(sum(node["self"] for node in children), 2) + self.assertEqual(sum(node["baseline"] for node in children), 2 * scale) + for node in children: + self.assertEqual(node["self"], 1) + self.assertAlmostEqual(node["baseline"], scale) + self.assertAlmostEqual(node["diff"], -scale) + + def test_diff_flamegraph_elided_ancestors_have_no_lost_self_time(self): + """Matched ancestors only carry inclusive elided geometry.""" + root = MockFrameInfo("file.py", 10, "root") + common = MockFrameInfo("file.py", 20, "common", opcode=100) + old = MockFrameInfo("file.py", 30, "old") + + common_sample = [ + MockInterpreterInfo(0, [MockThreadInfo(1, [common, root])]) + ] + old_sample = [ + MockInterpreterInfo(0, [MockThreadInfo(1, [old, common, root])]) + ] + + diff = make_diff_collector_with_mock_baseline( + [common_sample] * 3 + [old_sample] + ) + diff.collect(common_sample) + + data = diff._convert_to_flamegraph_format() + elided_root = data["stats"]["elided_flamegraph"] + common_node = elided_root["children"][0] + old_node = common_node["children"][0] + + for ancestor in (elided_root, common_node): + self.assertEqual(ancestor["self"], 0) + self.assertEqual(ancestor["baseline"], 0) + self.assertNotIn("opcodes", ancestor) + self.assertLessEqual( + ancestor["baseline"], ancestor["baseline_total"] + ) + self.assertEqual(old_node["self"], 1) + self.assertEqual(old_node["baseline"], old_node["baseline_total"]) + def test_diff_flamegraph_empty_current(self): """Empty current profile still produces differential metadata and elided paths.""" baseline_frames = [ diff --git a/Misc/NEWS.d/next/Library/2026-07-19-12-00-00.gh-issue-154085.Qm8fLd.rst b/Misc/NEWS.d/next/Library/2026-07-19-12-00-00.gh-issue-154085.Qm8fLd.rst new file mode 100644 index 00000000000000..40ab41a2f29699 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-19-12-00-00.gh-issue-154085.Qm8fLd.rst @@ -0,0 +1,2 @@ +Prevent differential flamegraphs from duplicating self time across line +nodes for the same function. From b22d175c053b56fa534232715ff761a8cba9d636 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Tue, 25 Aug 2026 23:32:34 +0100 Subject: [PATCH 5/7] gh-154088: Count elided stacks consistently (#154093) --- Lib/profiling/sampling/stack_collector.py | 19 +++-- .../test_sampling_profiler/test_collectors.py | 70 ++++++++++++++++++- ...-07-19-11-30-00.gh-issue-154088.pC7nRa.rst | 2 + 3 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-19-11-30-00.gh-issue-154088.pC7nRa.rst diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index 97fe5535a6764c..dd86d5d54a9aaa 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -212,7 +212,7 @@ def _get_module_name(self, filename, path_info): self._module_cache[filename] = module_name return module_name - def _convert_to_flamegraph_format(self): + def _convert_to_flamegraph_format(self, *, min_samples=None): if self._total_samples == 0: return { "name": self._string_table.intern("No Data"), @@ -302,7 +302,8 @@ def convert_children(children, min_samples, path_info): # Filter out very small functions (less than 0.1% of total samples) total_samples = self._total_samples - min_samples = max(1, int(total_samples * 0.001)) + if min_samples is None: + min_samples = max(1, int(total_samples * 0.001)) path_info = get_python_path_info() root_children = convert_children(self._root["children"], min_samples, path_info) @@ -722,7 +723,15 @@ def _add_elided_flamegraph(self, current_flamegraph, current_stats, baseline_sta """Calculate elided paths and add elided flamegraph to stats.""" self._elided_paths = baseline_stats.keys() - current_stats.keys() - current_flamegraph["stats"]["elided_count"] = len(self._elided_paths) + # A sampled stack can end at an internal path that also has elided + # descendants. Count every disappeared path with self samples, not + # just the leaves of the elided path tree. + elided_stacks = { + path + for path in self._elided_paths + if baseline_stats[path]["self"] > 0 + } + current_flamegraph["stats"]["elided_count"] = len(elided_stacks) if self._elided_paths: elided_flamegraph = self._build_elided_flamegraph(baseline_stats, scale) @@ -745,7 +754,9 @@ def _build_elided_flamegraph(self, baseline_stats, scale): orig_get_source = self._baseline_collector._get_source_lines self._baseline_collector._get_source_lines = lambda func: None try: - baseline_data = self._baseline_collector._convert_to_flamegraph_format() + baseline_data = self._baseline_collector._convert_to_flamegraph_format( + min_samples=1 + ) finally: self._baseline_collector._get_source_lines = orig_get_source 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 543ffc6fdd88d0..13e112f78014b9 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py @@ -1665,7 +1665,7 @@ def test_diff_flamegraph_elided_stacks(self): data = diff._convert_to_flamegraph_format() - self.assertGreater(data["stats"]["elided_count"], 0) + self.assertEqual(data["stats"]["elided_count"], 1) self.assertIn("elided_flamegraph", data["stats"]) elided = data["stats"]["elided_flamegraph"] self.assertTrue(elided["stats"]["is_differential"]) @@ -1681,6 +1681,74 @@ def test_diff_flamegraph_elided_stacks(self): self.assertGreater(child["baseline"], 0) self.assertAlmostEqual(child["diff"], -child["baseline"]) + def test_diff_flamegraph_counts_elided_stacks_not_paths(self): + """Internal and leaf stack endings are counted separately.""" + internal_stack = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 20, "old_mid"), + MockFrameInfo("file.py", 10, "root"), + ]) + ]) + ] + leaf_stack = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 30, "old_leaf"), + MockFrameInfo("file.py", 20, "old_mid"), + MockFrameInfo("file.py", 10, "root"), + ]) + ]) + ] + current_frames = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [MockFrameInfo("file.py", 10, "root")]) + ]) + ] + + diff = make_diff_collector_with_mock_baseline( + [internal_stack, leaf_stack] + ) + diff.collect(current_frames) + + data = diff._convert_to_flamegraph_format() + self.assertEqual(data["stats"]["elided_count"], 2) + + def test_diff_flamegraph_renders_small_elided_stack(self): + """Elided stacks are not removed by the significance filter.""" + common_frames = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 20, "common"), + MockFrameInfo("file.py", 10, "root"), + ]) + ]) + ] + old_frames = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 30, "old_tiny"), + MockFrameInfo("file.py", 10, "root"), + ]) + ]) + ] + + diff = make_diff_collector_with_mock_baseline( + [common_frames] * 1999 + [old_frames] + ) + for _ in range(1999): + diff.collect(common_frames) + + data = diff._convert_to_flamegraph_format() + self.assertEqual(data["stats"]["elided_count"], 1) + self.assertIn("elided_flamegraph", data["stats"]) + + elided = data["stats"]["elided_flamegraph"] + strings = elided["strings"] + self.assertIsNotNone( + find_child_by_name(elided.get("children", []), strings, "old_tiny") + ) + def test_diff_flamegraph_elided_top_level_root(self): """Elided top-level roots do not crash metadata generation.""" baseline_frames_1 = [ diff --git a/Misc/NEWS.d/next/Library/2026-07-19-11-30-00.gh-issue-154088.pC7nRa.rst b/Misc/NEWS.d/next/Library/2026-07-19-11-30-00.gh-issue-154088.pC7nRa.rst new file mode 100644 index 00000000000000..b9747589a2f1c8 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-19-11-30-00.gh-issue-154088.pC7nRa.rst @@ -0,0 +1,2 @@ +Make Tachyon count and render elided stacks consistently in differential +flamegraphs. From cf7e9108bdb18f52507909f2f9d659f951a4a1fe Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Tue, 25 Aug 2026 23:33:18 +0100 Subject: [PATCH 6/7] gh-154089: Hide unavailable replay efficiency (#154092) --- Lib/profiling/sampling/_flamegraph_assets/flamegraph.js | 4 ++-- .../Library/2026-07-19-11-20-00.gh-issue-154089.Vp2qHx.rst | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-19-11-20-00.gh-issue-154089.Vp2qHx.rst diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js index e055801a405e36..d66da0402e52ca 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js @@ -955,8 +955,8 @@ function populateProfileSummary(data) { : (stats.total_samples ?? data.value ?? 0); const duration = stats.duration_sec || 0; const sampleRate = stats.sample_rate || (duration > 0 ? totalSamples / duration : 0); - const errorRate = stats.error_rate || 0; - const missedSamples= stats.missed_samples || 0; + const errorRate = stats.error_rate; + const missedSamples = stats.missed_samples; const samplesEl = document.getElementById('stat-total-samples'); if (samplesEl) samplesEl.textContent = formatNumber(totalSamples); diff --git a/Misc/NEWS.d/next/Library/2026-07-19-11-20-00.gh-issue-154089.Vp2qHx.rst b/Misc/NEWS.d/next/Library/2026-07-19-11-20-00.gh-issue-154089.Vp2qHx.rst new file mode 100644 index 00000000000000..ba6ec436954e8e --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-19-11-20-00.gh-issue-154089.Vp2qHx.rst @@ -0,0 +1,2 @@ +Do not show unavailable sampling efficiency statistics in replayed Tachyon +flamegraphs. From 369f04a1bcc26ddd05e2910d32d7008132b81a3c Mon Sep 17 00:00:00 2001 From: Zachary Ware Date: Tue, 25 Aug 2026 17:51:59 -0500 Subject: [PATCH 7/7] gh-156369: Update CI to use latest SSL library versions (#156381) --- .github/workflows/build.yml | 16 ++++++++-------- .github/workflows/reusable-san.yml | 2 +- .github/workflows/reusable-ubuntu.yml | 2 +- Tools/ssl/multissltests.py | 12 ++++++------ 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d6d61029b89d90..7fdc81ae0ade1d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -268,13 +268,13 @@ jobs: # unsupported as it most resembles other 1.1.1-work-a-like ssl APIs # supported by important vendors such as AWS-LC. - { name: openssl, version: 1.1.1w } - - { name: openssl, version: 3.0.21 } - - { name: openssl, version: 3.4.6 } - - { name: openssl, version: 3.5.7 } - - { name: openssl, version: 3.6.3 } - - { name: openssl, version: 4.0.1 } + - { name: openssl, version: 3.0.22 } + - { name: openssl, version: 3.4.7 } + - { name: openssl, version: 3.5.8 } + - { name: openssl, version: 3.6.4 } + - { name: openssl, version: 4.0.2 } ## AWS-LC - - { name: aws-lc, version: 5.0.0 } + - { name: aws-lc, version: 5.5.0 } env: SSLLIB_VER: ${{ matrix.ssllib.version }} MULTISSL_DIR: ${{ github.workspace }}/multissl @@ -379,7 +379,7 @@ jobs: needs: build-context if: needs.build-context.outputs.run-ubuntu == 'true' env: - OPENSSL_VER: 3.5.7 + OPENSSL_VER: 3.5.8 PYTHONSTRICTEXTENSIONBUILD: 1 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -487,7 +487,7 @@ jobs: matrix: os: [ubuntu-26.04] env: - OPENSSL_VER: 3.5.7 + OPENSSL_VER: 3.5.8 PYTHONSTRICTEXTENSIONBUILD: 1 ASAN_OPTIONS: detect_leaks=0:allocator_may_return_null=1:handle_segv=0 steps: diff --git a/.github/workflows/reusable-san.yml b/.github/workflows/reusable-san.yml index 133b8ff8d7579b..449bddadf8b2c7 100644 --- a/.github/workflows/reusable-san.yml +++ b/.github/workflows/reusable-san.yml @@ -17,7 +17,7 @@ permissions: env: FORCE_COLOR: 1 - OPENSSL_VER: 3.5.7 + OPENSSL_VER: 3.5.8 jobs: build-san-reusable: diff --git a/.github/workflows/reusable-ubuntu.yml b/.github/workflows/reusable-ubuntu.yml index 2129f2f6d6e926..97a0c22517e32c 100644 --- a/.github/workflows/reusable-ubuntu.yml +++ b/.github/workflows/reusable-ubuntu.yml @@ -35,7 +35,7 @@ jobs: runs-on: ${{ inputs.os }} timeout-minutes: 60 env: - OPENSSL_VER: 3.5.7 + OPENSSL_VER: 3.5.8 PYTHONSTRICTEXTENSIONBUILD: 1 TERM: linux steps: diff --git a/Tools/ssl/multissltests.py b/Tools/ssl/multissltests.py index d5e38993d971df..037f1852180b62 100755 --- a/Tools/ssl/multissltests.py +++ b/Tools/ssl/multissltests.py @@ -50,11 +50,11 @@ ] OPENSSL_RECENT_VERSIONS = [ - "3.0.21", - "3.4.6", - "3.5.7", - "3.6.3", - "4.0.1", + "3.0.22", + "3.4.7", + "3.5.8", + "3.6.4", + "4.0.2", # See make_ssl_data.py for notes on adding a new version. ] @@ -65,7 +65,7 @@ ] AWSLC_RECENT_VERSIONS = [ - "5.0.0", + "5.5.0", ] # store files in ../multissl