https://github.com/python/cpython/commit/39f123c587a86ee5a3ee80e228c2ef04821e24fc
commit: 39f123c587a86ee5a3ee80e228c2ef04821e24fc
branch: main
author: Sergey Miryanov <[email protected]>
committer: pablogsal <[email protected]>
date: 2026-05-02T15:04:18Z
summary:

GH-146527: Add get_gc_stats function to _remote_debugging (#148071)

files:
A Lib/test/test_gc_stats.py
A 
Misc/NEWS.d/next/Core_and_Builtins/2026-04-10-23-13-19.gh-issue-146527.P3Xv4Q.rst
A Modules/_remote_debugging/gc_stats.c
A Modules/_remote_debugging/gc_stats.h
A Modules/_remote_debugging/interpreters.c
M Doc/whatsnew/3.15.rst
M Include/internal/pycore_global_objects_fini_generated.h
M Include/internal/pycore_global_strings.h
M Include/internal/pycore_runtime_init_generated.h
M Include/internal/pycore_unicodeobject_generated.h
M Makefile.pre.in
M Modules/Setup
M Modules/Setup.stdlib.in
M Modules/_remote_debugging/_remote_debugging.h
M Modules/_remote_debugging/clinic/module.c.h
M Modules/_remote_debugging/debug_offsets_validation.h
M Modules/_remote_debugging/module.c
M PCbuild/_remote_debugging.vcxproj
M PCbuild/_remote_debugging.vcxproj.filters
M Python/gc.c
M Python/gc_free_threading.c
M Tools/c-analyzer/cpython/_parser.py

diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst
index b63e7a4790e9af..588624c59cf74b 100644
--- a/Doc/whatsnew/3.15.rst
+++ b/Doc/whatsnew/3.15.rst
@@ -700,6 +700,16 @@ arguments (:pep:`791`).
 Improved modules
 ================
 
+_remote_debugging
+-----------------
+
+* Added :class:`!GCMonitor` and :func:`!get_gc_stats` to the
+  :mod:`!_remote_debugging` module to read garbage collection statistics
+  from a running Python process without constructing a 
:class:`!RemoteUnwinder`.
+  Results are returned as :class:`!GCStatsInfo` objects.
+  (Contributed by Sergey Miryanov and Pablo Galindo in :gh:`146527`.)
+
+
 argparse
 --------
 
diff --git a/Include/internal/pycore_global_objects_fini_generated.h 
b/Include/internal/pycore_global_objects_fini_generated.h
index 4d6d5ce9c5ea26..f7d3dcd440aaf1 100644
--- a/Include/internal/pycore_global_objects_fini_generated.h
+++ b/Include/internal/pycore_global_objects_fini_generated.h
@@ -1582,6 +1582,7 @@ _PyStaticObjects_CheckRefcnt(PyInterpreterState *interp) {
     _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(alias));
     _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(align));
     _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(all));
+    _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(all_interpreters));
     _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(all_threads));
     _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(allow_code));
     _PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(alphabet));
diff --git a/Include/internal/pycore_global_strings.h 
b/Include/internal/pycore_global_strings.h
index 20dcf81ccf15fa..22494b1798cc53 100644
--- a/Include/internal/pycore_global_strings.h
+++ b/Include/internal/pycore_global_strings.h
@@ -305,6 +305,7 @@ struct _Py_global_strings {
         STRUCT_FOR_ID(alias)
         STRUCT_FOR_ID(align)
         STRUCT_FOR_ID(all)
+        STRUCT_FOR_ID(all_interpreters)
         STRUCT_FOR_ID(all_threads)
         STRUCT_FOR_ID(allow_code)
         STRUCT_FOR_ID(alphabet)
diff --git a/Include/internal/pycore_runtime_init_generated.h 
b/Include/internal/pycore_runtime_init_generated.h
index 1ce91dc51ea0b7..892c3cdd9623a2 100644
--- a/Include/internal/pycore_runtime_init_generated.h
+++ b/Include/internal/pycore_runtime_init_generated.h
@@ -1580,6 +1580,7 @@ extern "C" {
     INIT_ID(alias), \
     INIT_ID(align), \
     INIT_ID(all), \
+    INIT_ID(all_interpreters), \
     INIT_ID(all_threads), \
     INIT_ID(allow_code), \
     INIT_ID(alphabet), \
diff --git a/Include/internal/pycore_unicodeobject_generated.h 
b/Include/internal/pycore_unicodeobject_generated.h
index c7c23494845e01..f0fc3c4f5b0900 100644
--- a/Include/internal/pycore_unicodeobject_generated.h
+++ b/Include/internal/pycore_unicodeobject_generated.h
@@ -1000,6 +1000,10 @@ _PyUnicode_InitStaticStrings(PyInterpreterState *interp) 
{
     _PyUnicode_InternStatic(interp, &string);
     assert(_PyUnicode_CheckConsistency(string, 1));
     assert(PyUnicode_GET_LENGTH(string) != 1);
+    string = &_Py_ID(all_interpreters);
+    _PyUnicode_InternStatic(interp, &string);
+    assert(_PyUnicode_CheckConsistency(string, 1));
+    assert(PyUnicode_GET_LENGTH(string) != 1);
     string = &_Py_ID(all_threads);
     _PyUnicode_InternStatic(interp, &string);
     assert(_PyUnicode_CheckConsistency(string, 1));
diff --git a/Lib/test/test_gc_stats.py b/Lib/test/test_gc_stats.py
new file mode 100644
index 00000000000000..59365ad45b32c9
--- /dev/null
+++ b/Lib/test/test_gc_stats.py
@@ -0,0 +1,350 @@
+import gc
+import os
+import textwrap
+import time
+import unittest
+
+from test.support import (
+    Py_GIL_DISABLED,
+    import_helper,
+    requires_gil_enabled,
+    requires_remote_subprocess_debugging,
+)
+from test.test_profiling.test_sampling_profiler.helpers import test_subprocess
+
+try:
+    import _remote_debugging  # noqa: F401
+except ImportError:
+    raise unittest.SkipTest(
+        "Test only runs when _remote_debugging is available"
+    )
+
+
+GC_STATS_FIELDS = (
+    "gen", "iid", "ts_start", "ts_stop", "collections", "collected",
+    "uncollectable", "candidates", "duration")
+
+
+def get_interpreter_identifiers(gc_stats) -> tuple[int,...]:
+    return tuple(sorted({s.iid for s in gc_stats}))
+
+
+def get_generations(gc_stats) -> tuple[int,int,int]:
+    generations = set()
+    for s in gc_stats:
+        generations.add(s.gen)
+
+    return tuple(sorted(generations))
+
+
+def get_last_item(gc_stats, generation: int, iid: int):
+    item = None
+    for s in gc_stats:
+        if s.gen == generation and s.iid == iid:
+            if item is None or item.ts_start < s.ts_start:
+                item = s
+
+    return item
+
+
+def has_local_process_debugging():
+    try:
+        return _remote_debugging.is_python_process(os.getpid())
+    except Exception:
+        return False
+
+
+def check_gc_stats_fields(testcase, stats):
+    testcase.assertIsInstance(stats, list)
+    testcase.assertGreater(len(stats), 0)
+    for item in stats:
+        testcase.assertIsInstance(item, _remote_debugging.GCStatsInfo)
+        testcase.assertEqual(type(item).__match_args__, GC_STATS_FIELDS)
+        testcase.assertEqual(len(item), len(GC_STATS_FIELDS))
+
+
+def gc_stats_counters_advanced(before_stats, after_stats, generations, iid):
+    for generation in generations:
+        before = get_last_item(before_stats, generation, iid)
+        after = get_last_item(after_stats, generation, iid)
+        if after is None or before is None:
+            return False
+        if after.duration <= before.duration:
+            return False
+        if after.candidates <= before.candidates:
+            return False
+    return True
+
+
[email protected](
+    has_local_process_debugging(), "requires local process debugging")
+class TestLocalGCStats(unittest.TestCase):
+
+    _main_iid = 0  # main interpreter ID
+
+    def test_gc_stats_fields(self):
+        monitor = _remote_debugging.GCMonitor(os.getpid(), debug=True)
+        stats = monitor.get_gc_stats(all_interpreters=False)
+        check_gc_stats_fields(self, stats)
+
+    def test_module_get_gc_stats_fields(self):
+        stats = _remote_debugging.get_gc_stats(
+            os.getpid(), all_interpreters=False)
+        check_gc_stats_fields(self, stats)
+
+    def test_all_interpreters_filter_for_local_process(self):
+        interpreters = import_helper.import_module("concurrent.interpreters")
+        source = """
+            import gc
+            objects = []
+            obj = []
+            obj.append(obj)
+            objects.append(obj)
+            gc.collect(0)
+            gc.collect(1)
+            gc.collect(2)
+        """
+        interp = interpreters.create()
+        try:
+            interp.exec(textwrap.dedent(source))
+            for generation in range(3):
+                gc.collect(generation)
+
+            main_stats = _remote_debugging.get_gc_stats(
+                os.getpid(), all_interpreters=False)
+            all_stats = _remote_debugging.get_gc_stats(
+                os.getpid(), all_interpreters=True)
+        finally:
+            interp.close()
+
+        self.assertEqual(get_interpreter_identifiers(main_stats), (0,))
+        self.assertIn(0, get_interpreter_identifiers(all_stats))
+        self.assertGreater(len(get_interpreter_identifiers(all_stats)), 1)
+        self.assertEqual(get_generations(main_stats), (0, 1, 2))
+        self.assertEqual(get_generations(all_stats), (0, 1, 2))
+        for iid in get_interpreter_identifiers(all_stats):
+            for generation in range(3):
+                self.assertIsNotNone(get_last_item(all_stats, generation, iid))
+
+    @unittest.skipUnless(Py_GIL_DISABLED, "requires free-threaded GC")
+    def test_gc_stats_counters_for_main_interpreter_free_threaded(self):
+        generations = (0, 1, 2)
+        before_stats = _remote_debugging.get_gc_stats(
+            os.getpid(), all_interpreters=False)
+        for generation in generations:
+            self.assertIsNotNone(
+                get_last_item(before_stats, generation, self._main_iid))
+
+        objects = []
+        for _ in range(1000):
+            obj = []
+            obj.append(obj)
+            objects.append(obj)
+        for generation in generations:
+            gc.collect(generation)
+
+        after_stats = _remote_debugging.get_gc_stats(
+            os.getpid(), all_interpreters=False)
+        self.assertTrue(
+            gc_stats_counters_advanced(
+                before_stats, after_stats, generations, self._main_iid),
+            (before_stats, after_stats)
+        )
+
+
+@requires_remote_subprocess_debugging()
+class TestGCStats(unittest.TestCase):
+
+    @classmethod
+    def setUpClass(cls):
+        cls._main_iid = 0 # main interpreter ID
+        cls._main_interpreter_script = '''
+            import gc
+            import time
+
+            gc.collect(0)
+            gc.collect(1)
+            gc.collect(2)
+
+            _test_sock.sendall(b"working")
+            objects = []
+            while True:
+                if len(objects) > 100:
+                    objects = []
+
+                obj = []
+                obj.append(obj)
+                objects.append(obj)
+
+                time.sleep(0.1)
+                gc.collect(0)
+                gc.collect(1)
+                gc.collect(2)
+            '''
+        cls._script = '''
+            import concurrent.interpreters as interpreters
+            import gc
+            import time
+
+            source = """if True:
+                import gc
+
+                if "objects" not in globals():
+                    objects = []
+                if len(objects) > 100:
+                    objects = []
+
+                obj = []
+                obj.append(obj)
+                objects.append(obj)
+
+                gc.collect(0)
+                gc.collect(1)
+                gc.collect(2)
+            """
+
+            if {0}:
+                interp = interpreters.create()
+                interp.exec(source)
+
+            gc.collect(0)
+            gc.collect(1)
+            gc.collect(2)
+
+            _test_sock.sendall(b"working")
+            objects = []
+            while True:
+                if len(objects) > 100:
+                    objects = []
+
+                obj = []
+                obj.append(obj)
+                objects.append(obj)
+
+                time.sleep(0.1)
+                if {0}:
+                    interp.exec(source)
+                gc.collect(0)
+                gc.collect(1)
+                gc.collect(2)
+            '''
+
+    def _gc_stats_advanced(self, before_stats, after_stats, generations):
+        for generation in generations:
+            before = get_last_item(before_stats, generation, self._main_iid)
+            after = get_last_item(after_stats, generation, self._main_iid)
+            if after is None or before is None:
+                return False
+            if after.ts_stop <= before.ts_stop:
+                return False
+        return True
+
+    def _collect_gc_stats(self, script: str, all_interpreters: bool,
+                          generations=(2,)):
+        with (test_subprocess(script, wait_for_working=True) as subproc):
+            monitor = _remote_debugging.GCMonitor(subproc.process.pid, 
debug=True)
+            before_stats = 
monitor.get_gc_stats(all_interpreters=all_interpreters)
+            for generation in generations:
+                before = get_last_item(before_stats, generation, 
self._main_iid)
+                self.assertIsNotNone(before)
+
+            after_stats = before_stats
+            for _ in range(10):
+                time.sleep(0.5)
+                after_stats = 
monitor.get_gc_stats(all_interpreters=all_interpreters)
+                if self._gc_stats_advanced(before_stats, after_stats, 
generations):
+                    break
+            else:
+                self.fail(
+                    f"GC stats for generations {generations!r} did not "
+                    f"advance: {before_stats!r} -> {after_stats!r}"
+                )
+
+        return before_stats, after_stats
+
+    def _check_gc_stats(self, before, after):
+        self.assertIsNotNone(before)
+        self.assertIsNotNone(after)
+
+        self.assertGreater(after.collections, before.collections, (before, 
after))
+        self.assertGreater(after.ts_start, before.ts_start, (before, after))
+        self.assertGreater(after.ts_stop, before.ts_stop, (before, after))
+        self.assertGreater(after.duration, before.duration, (before, after))
+
+        self.assertGreater(after.candidates, before.candidates, (before, 
after))
+
+        # may not grow
+        self.assertGreaterEqual(after.collected, before.collected, (before, 
after))
+        self.assertGreaterEqual(after.uncollectable, before.uncollectable, 
(before, after))
+
+    def _check_interpreter_gc_stats(self, before_stats, after_stats):
+        before_iids = get_interpreter_identifiers(before_stats)
+        after_iids = get_interpreter_identifiers(after_stats)
+
+        self.assertEqual(before_iids, after_iids)
+
+        self.assertEqual(get_generations(before_stats), (0, 1, 2))
+        self.assertEqual(get_generations(after_stats), (0, 1, 2))
+
+        for iid in after_iids:
+            with self.subTest(f"interpreter id={iid}"):
+                before_last_items = (get_last_item(before_stats, 0, iid),
+                                     get_last_item(before_stats, 1, iid),
+                                     get_last_item(before_stats, 2, iid))
+
+                after_last_items = (get_last_item(after_stats, 0, iid),
+                                    get_last_item(after_stats, 1, iid),
+                                    get_last_item(after_stats, 2, iid))
+
+                for before, after in zip(before_last_items, after_last_items):
+                    self._check_gc_stats(before, after)
+
+    def test_gc_stats_timestamps_for_main_interpreter(self):
+        script = textwrap.dedent(self._main_interpreter_script)
+        before_stats, after_stats = self._collect_gc_stats(
+            script, False, generations=(0, 1, 2))
+
+        for generation in range(3):
+            with self.subTest(generation=generation):
+                before = get_last_item(before_stats, generation, 
self._main_iid)
+                after = get_last_item(after_stats, generation, self._main_iid)
+
+                self.assertIsNotNone(before)
+                self.assertIsNotNone(after)
+                self.assertGreater(
+                    after.collections, before.collections,
+                    (before, after))
+                self.assertGreater(
+                    after.ts_start, before.ts_start,
+                    (before, after))
+                self.assertGreater(
+                    after.ts_stop, before.ts_stop,
+                    (before, after))
+
+    @requires_gil_enabled()
+    def test_gc_stats_for_main_interpreter(self):
+        script = textwrap.dedent(self._script.format(False))
+        before_stats, after_stats = self._collect_gc_stats(script, False)
+
+        self._check_interpreter_gc_stats(before_stats, after_stats)
+
+    @requires_gil_enabled()
+    def test_gc_stats_for_main_interpreter_if_subinterpreter_exists(self):
+        script = textwrap.dedent(self._script.format(True))
+        before_stats, after_stats = self._collect_gc_stats(script, False)
+
+        self._check_interpreter_gc_stats(before_stats, after_stats)
+
+    @requires_gil_enabled()
+    def test_gc_stats_for_all_interpreters(self):
+        script = textwrap.dedent(self._script.format(True))
+        before_stats, after_stats = self._collect_gc_stats(script, True)
+
+        before_iids = get_interpreter_identifiers(before_stats)
+        after_iids = get_interpreter_identifiers(after_stats)
+
+        self.assertGreater(len(before_iids), 1)
+        self.assertGreater(len(after_iids), 1)
+        self.assertEqual(before_iids, after_iids)
+
+        self._check_interpreter_gc_stats(before_stats, after_stats)
diff --git a/Makefile.pre.in b/Makefile.pre.in
index fc44399434fe95..3c166470b6c143 100644
--- a/Makefile.pre.in
+++ b/Makefile.pre.in
@@ -3496,7 +3496,7 @@ MODULE__DECIMAL_DEPS=@LIBMPDEC_INTERNAL@
 MODULE__ELEMENTTREE_DEPS=$(srcdir)/Modules/pyexpat.c @LIBEXPAT_INTERNAL@
 MODULE__HASHLIB_DEPS=$(srcdir)/Modules/hashlib.h
 MODULE__IO_DEPS=$(srcdir)/Modules/_io/_iomodule.h
-MODULE__REMOTE_DEBUGGING_DEPS=$(srcdir)/Modules/_remote_debugging/_remote_debugging.h
+MODULE__REMOTE_DEBUGGING_DEPS=$(srcdir)/Modules/_remote_debugging/_remote_debugging.h
 $(srcdir)/Modules/_remote_debugging/gc_stats.h
 
 # HACL*-based cryptographic primitives
 MODULE__MD5_DEPS=$(srcdir)/Modules/hashlib.h $(LIBHACL_MD5_HEADERS) 
$(LIBHACL_MD5_LIB_@LIBHACL_LDEPS_LIBTYPE@)
diff --git 
a/Misc/NEWS.d/next/Core_and_Builtins/2026-04-10-23-13-19.gh-issue-146527.P3Xv4Q.rst
 
b/Misc/NEWS.d/next/Core_and_Builtins/2026-04-10-23-13-19.gh-issue-146527.P3Xv4Q.rst
new file mode 100644
index 00000000000000..e83b66f71da0ca
--- /dev/null
+++ 
b/Misc/NEWS.d/next/Core_and_Builtins/2026-04-10-23-13-19.gh-issue-146527.P3Xv4Q.rst
@@ -0,0 +1,5 @@
+Add a ``GCMonitor`` class with a ``get_gc_stats`` method to the
+:mod:`!_remote_debugging` module to allow reading GC statistics from an
+external Python process without requiring the full ``RemoteUnwinder``
+functionality.
+Patch by Sergey Miryanov and Pablo Galindo.
diff --git a/Modules/Setup b/Modules/Setup
index 7d816ead8432ef..33737c21cb4066 100644
--- a/Modules/Setup
+++ b/Modules/Setup
@@ -285,7 +285,7 @@ PYTHONPATH=$(COREPYTHONPATH)
 
 #*shared*
 #_ctypes_test _ctypes/_ctypes_test.c
-#_remote_debugging _remote_debugging/module.c 
_remote_debugging/object_reading.c _remote_debugging/code_objects.c 
_remote_debugging/frames.c _remote_debugging/threads.c 
_remote_debugging/asyncio.c
+#_remote_debugging _remote_debugging/module.c 
_remote_debugging/object_reading.c _remote_debugging/code_objects.c 
_remote_debugging/frames.c _remote_debugging/threads.c 
_remote_debugging/asyncio.c _remote_debugging/interpreters.c
 #_testcapi _testcapimodule.c
 #_testimportmultiple _testimportmultiple.c
 #_testmultiphase _testmultiphase.c
diff --git a/Modules/Setup.stdlib.in b/Modules/Setup.stdlib.in
index 0d520684c795d6..a274c312d99313 100644
--- a/Modules/Setup.stdlib.in
+++ b/Modules/Setup.stdlib.in
@@ -41,7 +41,7 @@
 @MODULE__PICKLE_TRUE@_pickle _pickle.c
 @MODULE__QUEUE_TRUE@_queue _queuemodule.c
 @MODULE__RANDOM_TRUE@_random _randommodule.c
-@MODULE__REMOTE_DEBUGGING_TRUE@_remote_debugging _remote_debugging/module.c 
_remote_debugging/object_reading.c _remote_debugging/code_objects.c 
_remote_debugging/frames.c _remote_debugging/frame_cache.c 
_remote_debugging/threads.c _remote_debugging/asyncio.c 
_remote_debugging/binary_io_writer.c _remote_debugging/binary_io_reader.c 
_remote_debugging/subprocess.c
+@MODULE__REMOTE_DEBUGGING_TRUE@_remote_debugging _remote_debugging/module.c 
_remote_debugging/gc_stats.c _remote_debugging/object_reading.c 
_remote_debugging/code_objects.c _remote_debugging/frames.c 
_remote_debugging/frame_cache.c _remote_debugging/threads.c 
_remote_debugging/asyncio.c _remote_debugging/binary_io_writer.c 
_remote_debugging/binary_io_reader.c _remote_debugging/subprocess.c 
_remote_debugging/interpreters.c
 @MODULE__STRUCT_TRUE@_struct _struct.c
 
 # build supports subinterpreters
diff --git a/Modules/_remote_debugging/_remote_debugging.h 
b/Modules/_remote_debugging/_remote_debugging.h
index 07738d45e42d24..7369cd1514c296 100644
--- a/Modules/_remote_debugging/_remote_debugging.h
+++ b/Modules/_remote_debugging/_remote_debugging.h
@@ -260,8 +260,10 @@ typedef struct {
     PyTypeObject *ThreadInfo_Type;
     PyTypeObject *InterpreterInfo_Type;
     PyTypeObject *AwaitedInfo_Type;
+    PyTypeObject *GCStatsInfo_Type;
     PyTypeObject *BinaryWriter_Type;
     PyTypeObject *BinaryReader_Type;
+    PyTypeObject *GCMonitor_Type;
 } RemoteDebuggingState;
 
 enum _ThreadState {
@@ -346,6 +348,13 @@ typedef struct {
     size_t count;
 } StackChunkList;
 
+typedef struct {
+    proc_handle_t handle;
+    uintptr_t runtime_start_address;
+    struct _Py_DebugOffsets debug_offsets;
+    int debug;
+} RuntimeOffsets;
+
 /*
  * Context for frame chain traversal operations.
  */
@@ -376,6 +385,13 @@ typedef struct {
     int32_t tlbc_index;             // Thread-local bytecode index 
(free-threading)
 } CodeObjectContext;
 
+typedef struct {
+    PyObject_HEAD
+    RuntimeOffsets offsets;
+} GCMonitorObject;
+
+#define GCMonitor_CAST(op) ((GCMonitorObject *)(op))
+
 /* Function pointer types for iteration callbacks */
 typedef int (*thread_processor_func)(
     RemoteUnwinderObject *unwinder,
@@ -390,6 +406,14 @@ typedef int (*set_entry_processor_func)(
     void *context
 );
 
+typedef int (*interpreter_processor_func)(
+    RuntimeOffsets *offsets,
+    uintptr_t interpreter_state_addr,
+    int64_t iid,
+    void *context
+);
+
+
 /* ============================================================================
  * STRUCTSEQ DESCRIPTORS (extern declarations)
  * 
============================================================================ */
@@ -401,6 +425,7 @@ extern PyStructSequence_Desc CoroInfo_desc;
 extern PyStructSequence_Desc ThreadInfo_desc;
 extern PyStructSequence_Desc InterpreterInfo_desc;
 extern PyStructSequence_Desc AwaitedInfo_desc;
+extern PyStructSequence_Desc GCStatsInfo_desc;
 
 /* ============================================================================
  * UTILITY FUNCTION DECLARATIONS
@@ -588,6 +613,17 @@ extern void 
_Py_RemoteDebug_InitThreadsState(RemoteUnwinderObject *unwinder, _Py
 extern int _Py_RemoteDebug_StopAllThreads(RemoteUnwinderObject *unwinder, 
_Py_RemoteDebug_ThreadsState *st);
 extern void _Py_RemoteDebug_ResumeAllThreads(RemoteUnwinderObject *unwinder, 
_Py_RemoteDebug_ThreadsState *st);
 
+/* ============================================================================
+ * INTERPRETER FUNCTION DECLARATIONS
+ * 
============================================================================ */
+
+extern int
+iterate_interpreters(
+    RuntimeOffsets *offsets,
+    interpreter_processor_func processor,
+    void *context
+);
+
 /* ============================================================================
  * ASYNCIO FUNCTION DECLARATIONS
  * 
============================================================================ */
diff --git a/Modules/_remote_debugging/clinic/module.c.h 
b/Modules/_remote_debugging/clinic/module.c.h
index 15df48fabb56b2..179a7b97dd4e2f 100644
--- a/Modules/_remote_debugging/clinic/module.c.h
+++ b/Modules/_remote_debugging/clinic/module.c.h
@@ -495,6 +495,181 @@ _remote_debugging_RemoteUnwinder_resume_threads(PyObject 
*self, PyObject *Py_UNU
     return return_value;
 }
 
+PyDoc_STRVAR(_remote_debugging_GCMonitor___init____doc__,
+"GCMonitor(pid, *, debug=False)\n"
+"--\n"
+"\n"
+"Initialize a new GCMonitor object for monitoring GC events from remote 
process.\n"
+"\n"
+"Args:\n"
+"    pid: Process ID of the target Python process to monitor\n"
+"    debug: If True, chain exceptions to explain the sequence of events that\n"
+"           lead to the exception.\n"
+"\n"
+"The GCMonitor provides functionality to read GC statistics from a running\n"
+"Python process.\n"
+"\n"
+"Raises:\n"
+"    PermissionError: If access to the target process is denied\n"
+"    OSError: If unable to attach to the target process or access its memory\n"
+"    RuntimeError: If unable to read debug information from the target 
process");
+
+static int
+_remote_debugging_GCMonitor___init___impl(GCMonitorObject *self, int pid,
+                                          int debug);
+
+static int
+_remote_debugging_GCMonitor___init__(PyObject *self, PyObject *args, PyObject 
*kwargs)
+{
+    int return_value = -1;
+    #if defined(Py_BUILD_CORE) && !defined(Py_BUILD_CORE_MODULE)
+
+    #define NUM_KEYWORDS 2
+    static struct {
+        PyGC_Head _this_is_not_used;
+        PyObject_VAR_HEAD
+        Py_hash_t ob_hash;
+        PyObject *ob_item[NUM_KEYWORDS];
+    } _kwtuple = {
+        .ob_base = PyVarObject_HEAD_INIT(&PyTuple_Type, NUM_KEYWORDS)
+        .ob_hash = -1,
+        .ob_item = { &_Py_ID(pid), &_Py_ID(debug), },
+    };
+    #undef NUM_KEYWORDS
+    #define KWTUPLE (&_kwtuple.ob_base.ob_base)
+
+    #else  // !Py_BUILD_CORE
+    #  define KWTUPLE NULL
+    #endif  // !Py_BUILD_CORE
+
+    static const char * const _keywords[] = {"pid", "debug", NULL};
+    static _PyArg_Parser _parser = {
+        .keywords = _keywords,
+        .fname = "GCMonitor",
+        .kwtuple = KWTUPLE,
+    };
+    #undef KWTUPLE
+    PyObject *argsbuf[2];
+    PyObject * const *fastargs;
+    Py_ssize_t nargs = PyTuple_GET_SIZE(args);
+    Py_ssize_t noptargs = nargs + (kwargs ? PyDict_GET_SIZE(kwargs) : 0) - 1;
+    int pid;
+    int debug = 0;
+
+    fastargs = _PyArg_UnpackKeywords(_PyTuple_CAST(args)->ob_item, nargs, 
kwargs, NULL, &_parser,
+            /*minpos*/ 1, /*maxpos*/ 1, /*minkw*/ 0, /*varpos*/ 0, argsbuf);
+    if (!fastargs) {
+        goto exit;
+    }
+    pid = PyLong_AsInt(fastargs[0]);
+    if (pid == -1 && PyErr_Occurred()) {
+        goto exit;
+    }
+    if (!noptargs) {
+        goto skip_optional_kwonly;
+    }
+    debug = PyObject_IsTrue(fastargs[1]);
+    if (debug < 0) {
+        goto exit;
+    }
+skip_optional_kwonly:
+    return_value = _remote_debugging_GCMonitor___init___impl((GCMonitorObject 
*)self, pid, debug);
+
+exit:
+    return return_value;
+}
+
+PyDoc_STRVAR(_remote_debugging_GCMonitor_get_gc_stats__doc__,
+"get_gc_stats($self, /, all_interpreters=False)\n"
+"--\n"
+"\n"
+"Get garbage collector statistics from external Python process.\n"
+"\n"
+"  all_interpreters\n"
+"    If True, return GC statistics from all interpreters.\n"
+"    If False, return only from main interpreter.\n"
+"\n"
+"Returns a list of GCStatsInfo objects with GC statistics data.\n"
+"\n"
+"Returns:\n"
+"    list of GCStatsInfo: A list of stats samples containing:\n"
+"        - gen: GC generation number.\n"
+"        - iid: Interpreter ID.\n"
+"        - ts_start: Raw timestamp at collection start.\n"
+"        - ts_stop: Raw timestamp at collection stop.\n"
+"        - collections: Total number of collections.\n"
+"        - collected: Total number of collected objects.\n"
+"        - uncollectable: Total number of uncollectable objects.\n"
+"        - candidates: Total objects considered and traversed.\n"
+"        - duration: Total collection time, in seconds.\n"
+"\n"
+"Raises:\n"
+"    RuntimeError: If the target process cannot be inspected or if its\n"
+"        debug offsets or GC stats layout are incompatible.");
+
+#define _REMOTE_DEBUGGING_GCMONITOR_GET_GC_STATS_METHODDEF    \
+    {"get_gc_stats", 
_PyCFunction_CAST(_remote_debugging_GCMonitor_get_gc_stats), 
METH_FASTCALL|METH_KEYWORDS, _remote_debugging_GCMonitor_get_gc_stats__doc__},
+
+static PyObject *
+_remote_debugging_GCMonitor_get_gc_stats_impl(GCMonitorObject *self,
+                                              int all_interpreters);
+
+static PyObject *
+_remote_debugging_GCMonitor_get_gc_stats(PyObject *self, PyObject *const 
*args, Py_ssize_t nargs, PyObject *kwnames)
+{
+    PyObject *return_value = NULL;
+    #if defined(Py_BUILD_CORE) && !defined(Py_BUILD_CORE_MODULE)
+
+    #define NUM_KEYWORDS 1
+    static struct {
+        PyGC_Head _this_is_not_used;
+        PyObject_VAR_HEAD
+        Py_hash_t ob_hash;
+        PyObject *ob_item[NUM_KEYWORDS];
+    } _kwtuple = {
+        .ob_base = PyVarObject_HEAD_INIT(&PyTuple_Type, NUM_KEYWORDS)
+        .ob_hash = -1,
+        .ob_item = { &_Py_ID(all_interpreters), },
+    };
+    #undef NUM_KEYWORDS
+    #define KWTUPLE (&_kwtuple.ob_base.ob_base)
+
+    #else  // !Py_BUILD_CORE
+    #  define KWTUPLE NULL
+    #endif  // !Py_BUILD_CORE
+
+    static const char * const _keywords[] = {"all_interpreters", NULL};
+    static _PyArg_Parser _parser = {
+        .keywords = _keywords,
+        .fname = "get_gc_stats",
+        .kwtuple = KWTUPLE,
+    };
+    #undef KWTUPLE
+    PyObject *argsbuf[1];
+    Py_ssize_t noptargs = nargs + (kwnames ? PyTuple_GET_SIZE(kwnames) : 0) - 
0;
+    int all_interpreters = 0;
+
+    args = _PyArg_UnpackKeywords(args, nargs, NULL, kwnames, &_parser,
+            /*minpos*/ 0, /*maxpos*/ 1, /*minkw*/ 0, /*varpos*/ 0, argsbuf);
+    if (!args) {
+        goto exit;
+    }
+    if (!noptargs) {
+        goto skip_optional_pos;
+    }
+    all_interpreters = PyObject_IsTrue(args[0]);
+    if (all_interpreters < 0) {
+        goto exit;
+    }
+skip_optional_pos:
+    Py_BEGIN_CRITICAL_SECTION(self);
+    return_value = 
_remote_debugging_GCMonitor_get_gc_stats_impl((GCMonitorObject *)self, 
all_interpreters);
+    Py_END_CRITICAL_SECTION();
+
+exit:
+    return return_value;
+}
+
 PyDoc_STRVAR(_remote_debugging_BinaryWriter___init____doc__,
 "BinaryWriter(filename, sample_interval_us, start_time_us, *,\n"
 "             compression=0)\n"
@@ -1296,4 +1471,96 @@ _remote_debugging_is_python_process(PyObject *module, 
PyObject *const *args, Py_
 exit:
     return return_value;
 }
-/*[clinic end generated code: output=34f50b18f317b9b6 input=a9049054013a1b77]*/
+
+PyDoc_STRVAR(_remote_debugging_get_gc_stats__doc__,
+"get_gc_stats($module, /, pid, *, all_interpreters=False)\n"
+"--\n"
+"\n"
+"Get garbage collector statistics from external Python process.\n"
+"\n"
+"  all_interpreters\n"
+"    If True, return GC statistics from all interpreters.\n"
+"    If False, return only from main interpreter.\n"
+"\n"
+"Returns:\n"
+"    list of GCStatsInfo: A list of stats samples containing:\n"
+"        - gen: GC generation number.\n"
+"        - iid: Interpreter ID.\n"
+"        - ts_start: Raw timestamp at collection start.\n"
+"        - ts_stop: Raw timestamp at collection stop.\n"
+"        - collections: Total number of collections.\n"
+"        - collected: Total number of collected objects.\n"
+"        - uncollectable: Total number of uncollectable objects.\n"
+"        - candidates: Total objects considered and traversed.\n"
+"        - duration: Total collection time, in seconds.\n"
+"\n"
+"Raises:\n"
+"    RuntimeError: If the target process cannot be inspected or if its\n"
+"        debug offsets or GC stats layout are incompatible.");
+
+#define _REMOTE_DEBUGGING_GET_GC_STATS_METHODDEF    \
+    {"get_gc_stats", _PyCFunction_CAST(_remote_debugging_get_gc_stats), 
METH_FASTCALL|METH_KEYWORDS, _remote_debugging_get_gc_stats__doc__},
+
+static PyObject *
+_remote_debugging_get_gc_stats_impl(PyObject *module, int pid,
+                                    int all_interpreters);
+
+static PyObject *
+_remote_debugging_get_gc_stats(PyObject *module, PyObject *const *args, 
Py_ssize_t nargs, PyObject *kwnames)
+{
+    PyObject *return_value = NULL;
+    #if defined(Py_BUILD_CORE) && !defined(Py_BUILD_CORE_MODULE)
+
+    #define NUM_KEYWORDS 2
+    static struct {
+        PyGC_Head _this_is_not_used;
+        PyObject_VAR_HEAD
+        Py_hash_t ob_hash;
+        PyObject *ob_item[NUM_KEYWORDS];
+    } _kwtuple = {
+        .ob_base = PyVarObject_HEAD_INIT(&PyTuple_Type, NUM_KEYWORDS)
+        .ob_hash = -1,
+        .ob_item = { &_Py_ID(pid), &_Py_ID(all_interpreters), },
+    };
+    #undef NUM_KEYWORDS
+    #define KWTUPLE (&_kwtuple.ob_base.ob_base)
+
+    #else  // !Py_BUILD_CORE
+    #  define KWTUPLE NULL
+    #endif  // !Py_BUILD_CORE
+
+    static const char * const _keywords[] = {"pid", "all_interpreters", NULL};
+    static _PyArg_Parser _parser = {
+        .keywords = _keywords,
+        .fname = "get_gc_stats",
+        .kwtuple = KWTUPLE,
+    };
+    #undef KWTUPLE
+    PyObject *argsbuf[2];
+    Py_ssize_t noptargs = nargs + (kwnames ? PyTuple_GET_SIZE(kwnames) : 0) - 
1;
+    int pid;
+    int all_interpreters = 0;
+
+    args = _PyArg_UnpackKeywords(args, nargs, NULL, kwnames, &_parser,
+            /*minpos*/ 1, /*maxpos*/ 1, /*minkw*/ 0, /*varpos*/ 0, argsbuf);
+    if (!args) {
+        goto exit;
+    }
+    pid = PyLong_AsInt(args[0]);
+    if (pid == -1 && PyErr_Occurred()) {
+        goto exit;
+    }
+    if (!noptargs) {
+        goto skip_optional_kwonly;
+    }
+    all_interpreters = PyObject_IsTrue(args[1]);
+    if (all_interpreters < 0) {
+        goto exit;
+    }
+skip_optional_kwonly:
+    return_value = _remote_debugging_get_gc_stats_impl(module, pid, 
all_interpreters);
+
+exit:
+    return return_value;
+}
+/*[clinic end generated code: output=1151e58683dab9f4 input=a9049054013a1b77]*/
diff --git a/Modules/_remote_debugging/debug_offsets_validation.h 
b/Modules/_remote_debugging/debug_offsets_validation.h
index 53cd1adeb07d5b..1507026306192e 100644
--- a/Modules/_remote_debugging/debug_offsets_validation.h
+++ b/Modules/_remote_debugging/debug_offsets_validation.h
@@ -372,6 +372,12 @@ _PyRemoteDebug_ValidateDebugOffsetsLayout(struct 
_Py_DebugOffsets *debug_offsets
         sizeof(uintptr_t),
         _Alignof(uintptr_t),
         SIZEOF_GC_RUNTIME_STATE);
+    PY_REMOTE_DEBUG_VALIDATE_FIELD(
+        gc,
+        generation_stats,
+        sizeof(uintptr_t),
+        _Alignof(uintptr_t),
+        SIZEOF_GC_RUNTIME_STATE);
     PY_REMOTE_DEBUG_VALIDATE_NESTED_FIELD(
         interpreter_state,
         gc,
@@ -380,6 +386,14 @@ _PyRemoteDebug_ValidateDebugOffsetsLayout(struct 
_Py_DebugOffsets *debug_offsets
         sizeof(uintptr_t),
         _Alignof(uintptr_t),
         INTERP_STATE_BUFFER_SIZE);
+    PY_REMOTE_DEBUG_VALIDATE_NESTED_FIELD(
+        interpreter_state,
+        gc,
+        gc,
+        generation_stats,
+        sizeof(uintptr_t),
+        _Alignof(uintptr_t),
+        INTERP_STATE_BUFFER_SIZE);
 
     PY_REMOTE_DEBUG_VALIDATE_SECTION(interpreter_frame);
     PY_REMOTE_DEBUG_INTERPRETER_FRAME_FIELDS(
diff --git a/Modules/_remote_debugging/gc_stats.c 
b/Modules/_remote_debugging/gc_stats.c
new file mode 100644
index 00000000000000..852dc866153192
--- /dev/null
+++ b/Modules/_remote_debugging/gc_stats.c
@@ -0,0 +1,153 @@
+/******************************************************************************
+ * Remote Debugging Module - GC Stats Functions
+ *
+ * This file contains functions for reading GC stats from interpreter state.
+ 
******************************************************************************/
+
+#include "gc_stats.h"
+
+typedef struct {
+    PyObject *result;
+    PyTypeObject *gc_stats_info_type;
+    bool all_interpreters;
+} GetGCStatsContext;
+
+static int
+read_gc_stats(struct gc_stats *stats, int64_t iid, PyObject *result,
+              PyTypeObject *gc_stats_info_type)
+{
+#define SET_FIELD(converter, expr) do { \
+    PyObject *value = converter(expr); \
+    if (value == NULL) { \
+        goto error; \
+    } \
+    PyStructSequence_SetItem(item, field++, value); \
+} while (0)
+
+    PyObject *item = NULL;
+
+    for (unsigned long gen = 0; gen < NUM_GENERATIONS; gen++) {
+        struct gc_generation_stats *items;
+        int size;
+        if (gen == 0) {
+            items = (struct gc_generation_stats *)stats->young.items;
+            size = GC_YOUNG_STATS_SIZE;
+        }
+        else {
+            items = (struct gc_generation_stats *)stats->old[gen-1].items;
+            size = GC_OLD_STATS_SIZE;
+        }
+        for (int i = 0; i < size; i++, items++) {
+            item = PyStructSequence_New(gc_stats_info_type);
+            if (item == NULL) {
+                goto error;
+            }
+            Py_ssize_t field = 0;
+
+            SET_FIELD(PyLong_FromUnsignedLong, gen);
+            SET_FIELD(PyLong_FromInt64, iid);
+
+            SET_FIELD(PyLong_FromInt64, items->ts_start);
+            SET_FIELD(PyLong_FromInt64, items->ts_stop);
+            SET_FIELD(PyLong_FromSsize_t, items->collections);
+            SET_FIELD(PyLong_FromSsize_t, items->collected);
+            SET_FIELD(PyLong_FromSsize_t, items->uncollectable);
+            SET_FIELD(PyLong_FromSsize_t, items->candidates);
+
+            SET_FIELD(PyFloat_FromDouble, items->duration);
+
+            int rc = PyList_Append(result, item);
+            Py_CLEAR(item);
+            if (rc < 0) {
+                goto error;
+            }
+        }
+    }
+
+#undef SET_FIELD
+
+    return 0;
+
+error:
+    Py_XDECREF(item);
+
+    return -1;
+}
+
+static int
+get_gc_stats_from_interpreter_state(RuntimeOffsets *offsets,
+                                    uintptr_t interpreter_state_addr,
+                                    int64_t iid,
+                                    void *context)
+{
+    GetGCStatsContext *ctx = (GetGCStatsContext *)context;
+    if (!ctx->all_interpreters && iid > 0) {
+        return 0;
+    }
+
+    uintptr_t gc_stats_addr = 0;
+    uintptr_t gc_stats_pointer_address = interpreter_state_addr
+        + offsets->debug_offsets.interpreter_state.gc
+        + offsets->debug_offsets.gc.generation_stats;
+    if (_Py_RemoteDebug_ReadRemoteMemory(&offsets->handle,
+                                         gc_stats_pointer_address,
+                                         sizeof(gc_stats_addr),
+                                         &gc_stats_addr) < 0) {
+        set_exception_cause(offsets, PyExc_RuntimeError, "Failed to read GC 
state address");
+        return -1;
+    }
+    if (gc_stats_addr == 0) {
+        PyErr_SetString(PyExc_RuntimeError, "GC state address is NULL");
+        return -1;
+    }
+
+    struct gc_stats stats;
+    if (_Py_RemoteDebug_ReadRemoteMemory(&offsets->handle,
+                                         gc_stats_addr,
+                                         sizeof(stats),
+                                         &stats) < 0) {
+        set_exception_cause(offsets, PyExc_RuntimeError, "Failed to read GC 
state");
+        return -1;
+    }
+
+    if (read_gc_stats(&stats, iid, ctx->result,
+                      ctx->gc_stats_info_type) < 0) {
+        set_exception_cause(offsets, PyExc_RuntimeError, "Failed to populate 
GC stats result");
+        return -1;
+    }
+
+    return 0;
+}
+
+PyObject *
+get_gc_stats(RuntimeOffsets *offsets, bool all_interpreters,
+             PyTypeObject *gc_stats_info_type)
+{
+    uint64_t gc_stats_size = offsets->debug_offsets.gc.generation_stats_size;
+    if (gc_stats_size != sizeof(struct gc_stats)) {
+        PyErr_Format(PyExc_RuntimeError,
+                     "Remote gc_stats size (%llu) does not match "
+                     "local size (%zu)",
+                     (unsigned long long)gc_stats_size,
+                     sizeof(struct gc_stats));
+        set_exception_cause(offsets, PyExc_RuntimeError, "Remote gc_stats size 
mismatch");
+        return NULL;
+    }
+
+    PyObject *result = PyList_New(0);
+    if (result == NULL) {
+        return NULL;
+    }
+    GetGCStatsContext ctx = {
+        .result = result,
+        .gc_stats_info_type = gc_stats_info_type,
+        .all_interpreters = all_interpreters,
+    };
+    if (iterate_interpreters(offsets, get_gc_stats_from_interpreter_state,
+                             &ctx) < 0) {
+        Py_CLEAR(result);
+        return NULL;
+    }
+
+    return result;
+}
diff --git a/Modules/_remote_debugging/gc_stats.h 
b/Modules/_remote_debugging/gc_stats.h
new file mode 100644
index 00000000000000..959a49c59dcee1
--- /dev/null
+++ b/Modules/_remote_debugging/gc_stats.h
@@ -0,0 +1,24 @@
+/******************************************************************************
+ * Remote Debugging Module - GC Stats Functions
+ *
+ * This file contains declarations for reading GC stats from interpreter state.
+ 
******************************************************************************/
+
+#ifndef Py_REMOTE_DEBUGGING_GC_STATS_H
+#define Py_REMOTE_DEBUGGING_GC_STATS_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#include "_remote_debugging.h"
+
+PyObject *
+get_gc_stats(RuntimeOffsets *offsets, bool all_interpreters,
+             PyTypeObject *gc_stats_info_type);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif  /* Py_REMOTE_DEBUGGING_GC_STATS_H */
diff --git a/Modules/_remote_debugging/interpreters.c 
b/Modules/_remote_debugging/interpreters.c
new file mode 100644
index 00000000000000..55ebcb1d7816a6
--- /dev/null
+++ b/Modules/_remote_debugging/interpreters.c
@@ -0,0 +1,66 @@
+/******************************************************************************
+ * Remote Debugging Module - Interpreters Functions
+ *
+ * This file contains function for iterating interpreters.
+ 
******************************************************************************/
+
+#include "_remote_debugging.h"
+
+int
+iterate_interpreters(
+    RuntimeOffsets *offsets,
+    interpreter_processor_func processor,
+    void *context
+) {
+    uintptr_t interpreters_head_addr =
+        offsets->runtime_start_address
+        + (uintptr_t)offsets->debug_offsets.runtime_state.interpreters_head;
+    uintptr_t interpreter_id_offset =
+        (uintptr_t)offsets->debug_offsets.interpreter_state.id;
+    uintptr_t interpreter_next_offset =
+        (uintptr_t)offsets->debug_offsets.interpreter_state.next;
+
+    uintptr_t interpreter_state_addr;
+    if (_Py_RemoteDebug_ReadRemoteMemory(&offsets->handle,
+                                         interpreters_head_addr,
+                                         sizeof(void*),
+                                         &interpreter_state_addr) < 0) {
+        set_exception_cause(offsets, PyExc_RuntimeError, "Failed to read 
interpreter state address");
+        return -1;
+    }
+
+    if (interpreter_state_addr == 0) {
+        PyErr_SetString(PyExc_RuntimeError, "No interpreter state found");
+        return -1;
+    }
+
+    int64_t iid = 0;
+    static_assert(
+        sizeof((((PyInterpreterState*)NULL)->id)) == sizeof(iid),
+        "Sizeof of PyInterpreterState.id mismatch with local iid value");
+    while (interpreter_state_addr != 0) {
+        if (_Py_RemoteDebug_ReadRemoteMemory(
+                    &offsets->handle,
+                    interpreter_state_addr + interpreter_id_offset,
+                    sizeof(iid),
+                    &iid) < 0) {
+            set_exception_cause(offsets, PyExc_RuntimeError, "Failed to read 
interpreter id");
+            return -1;
+        }
+
+        if (processor(offsets, interpreter_state_addr, iid, context) < 0) {
+            return -1;
+        }
+
+        if (_Py_RemoteDebug_ReadRemoteMemory(
+                    &offsets->handle,
+                    interpreter_state_addr + interpreter_next_offset,
+                    sizeof(void*),
+                    &interpreter_state_addr) < 0) {
+            set_exception_cause(offsets, PyExc_RuntimeError, "Failed to read 
next interpreter state");
+            return -1;
+        }
+    }
+
+    return 0;
+}
diff --git a/Modules/_remote_debugging/module.c 
b/Modules/_remote_debugging/module.c
index 32f2cbacf2143b..c840c59971c478 100644
--- a/Modules/_remote_debugging/module.c
+++ b/Modules/_remote_debugging/module.c
@@ -8,6 +8,7 @@
 #include "_remote_debugging.h"
 #include "binary_io.h"
 #include "debug_offsets_validation.h"
+#include "gc_stats.h"
 
 /* Forward declarations for clinic-generated code */
 typedef struct {
@@ -132,6 +133,27 @@ PyStructSequence_Desc AwaitedInfo_desc = {
     2
 };
 
+// GCStatsInfo structseq type
+static PyStructSequence_Field GCStatsInfo_fields[] = {
+    {"gen", "GC generation number"},
+    {"iid", "Interpreter ID"},
+    {"ts_start", "Raw timestamp at collection start"},
+    {"ts_stop", "Raw timestamp at collection stop"},
+    {"collections", "Total number of collections"},
+    {"collected", "Total number of collected objects"},
+    {"uncollectable", "Total number of uncollectable objects"},
+    {"candidates", "Total objects considered and traversed"},
+    {"duration", "Total collection time, in seconds"},
+    {NULL}
+};
+
+PyStructSequence_Desc GCStatsInfo_desc = {
+    "_remote_debugging.GCStatsInfo",
+    "Information about a garbage collector stats sample",
+    GCStatsInfo_fields,
+    9
+};
+
 /* ============================================================================
  * UTILITY FUNCTIONS
  * 
============================================================================ */
@@ -1100,6 +1122,159 @@ static PyType_Spec RemoteUnwinder_spec = {
     .slots = RemoteUnwinder_slots,
 };
 
+/* ============================================================================
+ * GCMONITOR CLASS IMPLEMENTATION
+ * 
============================================================================ */
+
+static void
+cleanup_runtime_offsets(RuntimeOffsets *offsets)
+{
+    if (offsets->handle.pid != 0) {
+        _Py_RemoteDebug_ClearCache(&offsets->handle);
+        _Py_RemoteDebug_CleanupProcHandle(&offsets->handle);
+    }
+}
+
+static int
+init_runtime_offsets(RuntimeOffsets *offsets, int pid, int debug)
+{
+    offsets->debug = debug;
+    if (_Py_RemoteDebug_InitProcHandle(&offsets->handle, pid) < 0) {
+        set_exception_cause(offsets, PyExc_RuntimeError, "Failed to initialize 
process handle");
+        return -1;
+    }
+    offsets->runtime_start_address = 
_Py_RemoteDebug_GetPyRuntimeAddress(&offsets->handle);
+    if (offsets->runtime_start_address == 0) {
+        set_exception_cause(offsets, PyExc_RuntimeError, "Failed to get Python 
runtime address");
+        goto error;
+    }
+    if (_Py_RemoteDebug_ReadDebugOffsets(&offsets->handle,
+                                         &offsets->runtime_start_address,
+                                         &offsets->debug_offsets) < 0)
+    {
+        set_exception_cause(offsets, PyExc_RuntimeError, "Failed to read debug 
offsets");
+        goto error;
+    }
+    if (validate_debug_offsets(&offsets->debug_offsets) == -1) {
+        set_exception_cause(offsets, PyExc_RuntimeError, "Invalid debug 
offsets found");
+        goto error;
+    }
+    return 0;
+
+error:
+    cleanup_runtime_offsets(offsets);
+    return -1;
+}
+
+/*[clinic input]
+class _remote_debugging.GCMonitor "GCMonitorObject *" "&GCMonitor_Type"
+[clinic start generated code]*/
+/*[clinic end generated code: output=da39a3ee5e6b4b0d input=ebc229325a5e5154]*/
+
+/*[clinic input]
+@permit_long_summary
+@permit_long_docstring_body
+_remote_debugging.GCMonitor.__init__
+    pid: int
+    *
+    debug: bool = False
+
+Initialize a new GCMonitor object for monitoring GC events from remote process.
+
+Args:
+    pid: Process ID of the target Python process to monitor
+    debug: If True, chain exceptions to explain the sequence of events that
+           lead to the exception.
+
+The GCMonitor provides functionality to read GC statistics from a running
+Python process.
+
+Raises:
+    PermissionError: If access to the target process is denied
+    OSError: If unable to attach to the target process or access its memory
+    RuntimeError: If unable to read debug information from the target process
+[clinic start generated code]*/
+
+static int
+_remote_debugging_GCMonitor___init___impl(GCMonitorObject *self, int pid,
+                                          int debug)
+/*[clinic end generated code: output=2cdf351c2f6335db input=1185a48535b808be]*/
+{
+    return init_runtime_offsets(&self->offsets, pid, debug);
+}
+
+/*[clinic input]
+@critical_section
+_remote_debugging.GCMonitor.get_gc_stats
+
+    all_interpreters: bool = False
+        If True, return GC statistics from all interpreters.
+        If False, return only from main interpreter.
+
+Get garbage collector statistics from external Python process.
+
+Returns a list of GCStatsInfo objects with GC statistics data.
+
+Returns:
+    list of GCStatsInfo: A list of stats samples containing:
+        - gen: GC generation number.
+        - iid: Interpreter ID.
+        - ts_start: Raw timestamp at collection start.
+        - ts_stop: Raw timestamp at collection stop.
+        - collections: Total number of collections.
+        - collected: Total number of collected objects.
+        - uncollectable: Total number of uncollectable objects.
+        - candidates: Total objects considered and traversed.
+        - duration: Total collection time, in seconds.
+
+Raises:
+    RuntimeError: If the target process cannot be inspected or if its
+        debug offsets or GC stats layout are incompatible.
+[clinic start generated code]*/
+
+static PyObject *
+_remote_debugging_GCMonitor_get_gc_stats_impl(GCMonitorObject *self,
+                                              int all_interpreters)
+/*[clinic end generated code: output=f73f365725224f7a input=09e647719c65f9e4]*/
+{
+    RemoteDebuggingState *st = RemoteDebugging_GetStateFromType(Py_TYPE(self));
+    return get_gc_stats(&self->offsets, all_interpreters, 
st->GCStatsInfo_Type);
+}
+
+static PyMethodDef GCMonitor_methods[] = {
+    _REMOTE_DEBUGGING_GCMONITOR_GET_GC_STATS_METHODDEF
+    {NULL, NULL}
+};
+
+static void
+GCMonitor_dealloc(PyObject *op)
+{
+    GCMonitorObject *self = GCMonitor_CAST(op);
+    PyTypeObject *tp = Py_TYPE(self);
+
+    cleanup_runtime_offsets(&self->offsets);
+    PyObject_Del(self);
+    Py_DECREF(tp);
+}
+
+static PyType_Slot GCMonitor_slots[] = {
+    {Py_tp_doc, (void *)"GCMonitor(pid): Monitor GC events of a remote Python 
process."},
+    {Py_tp_methods, GCMonitor_methods},
+    {Py_tp_init, _remote_debugging_GCMonitor___init__},
+    {Py_tp_dealloc, GCMonitor_dealloc},
+    {0, NULL}
+};
+
+static PyType_Spec GCMonitor_spec = {
+    .name = "_remote_debugging.GCMonitor",
+    .basicsize = sizeof(GCMonitorObject),
+    .flags = (
+        Py_TPFLAGS_DEFAULT
+        | Py_TPFLAGS_IMMUTABLETYPE
+    ),
+    .slots = GCMonitor_slots,
+};
+
 /* Forward declarations for type specs defined later */
 static PyType_Spec BinaryWriter_spec;
 static PyType_Spec BinaryReader_spec;
@@ -1126,6 +1301,11 @@ _remote_debugging_exec(PyObject *m)
         return -1;
     }
 
+    CREATE_TYPE(m, st->GCMonitor_Type, &GCMonitor_spec);
+    if (PyModule_AddType(m, st->GCMonitor_Type) < 0) {
+        return -1;
+    }
+
     // Initialize structseq types
     st->TaskInfo_Type = PyStructSequence_NewType(&TaskInfo_desc);
     if (st->TaskInfo_Type == NULL) {
@@ -1183,6 +1363,14 @@ _remote_debugging_exec(PyObject *m)
         return -1;
     }
 
+    st->GCStatsInfo_Type = PyStructSequence_NewType(&GCStatsInfo_desc);
+    if (st->GCStatsInfo_Type == NULL) {
+        return -1;
+    }
+    if (PyModule_AddType(m, st->GCStatsInfo_Type) < 0) {
+        return -1;
+    }
+
     // Create BinaryWriter and BinaryReader types
     CREATE_TYPE(m, st->BinaryWriter_Type, &BinaryWriter_spec);
     if (PyModule_AddType(m, st->BinaryWriter_Type) < 0) {
@@ -1240,8 +1428,10 @@ remote_debugging_traverse(PyObject *mod, visitproc 
visit, void *arg)
     Py_VISIT(state->ThreadInfo_Type);
     Py_VISIT(state->InterpreterInfo_Type);
     Py_VISIT(state->AwaitedInfo_Type);
+    Py_VISIT(state->GCStatsInfo_Type);
     Py_VISIT(state->BinaryWriter_Type);
     Py_VISIT(state->BinaryReader_Type);
+    Py_VISIT(state->GCMonitor_Type);
     return 0;
 }
 
@@ -1257,8 +1447,10 @@ remote_debugging_clear(PyObject *mod)
     Py_CLEAR(state->ThreadInfo_Type);
     Py_CLEAR(state->InterpreterInfo_Type);
     Py_CLEAR(state->AwaitedInfo_Type);
+    Py_CLEAR(state->GCStatsInfo_Type);
     Py_CLEAR(state->BinaryWriter_Type);
     Py_CLEAR(state->BinaryReader_Type);
+    Py_CLEAR(state->GCMonitor_Type);
     return 0;
 }
 
@@ -1837,10 +2029,57 @@ _remote_debugging_is_python_process_impl(PyObject 
*module, int pid)
     Py_RETURN_TRUE;
 }
 
+/*[clinic input]
+_remote_debugging.get_gc_stats
+
+    pid: int
+    *
+    all_interpreters: bool = False
+        If True, return GC statistics from all interpreters.
+        If False, return only from main interpreter.
+
+Get garbage collector statistics from external Python process.
+
+Returns:
+    list of GCStatsInfo: A list of stats samples containing:
+        - gen: GC generation number.
+        - iid: Interpreter ID.
+        - ts_start: Raw timestamp at collection start.
+        - ts_stop: Raw timestamp at collection stop.
+        - collections: Total number of collections.
+        - collected: Total number of collected objects.
+        - uncollectable: Total number of uncollectable objects.
+        - candidates: Total objects considered and traversed.
+        - duration: Total collection time, in seconds.
+
+Raises:
+    RuntimeError: If the target process cannot be inspected or if its
+        debug offsets or GC stats layout are incompatible.
+[clinic start generated code]*/
+
+static PyObject *
+_remote_debugging_get_gc_stats_impl(PyObject *module, int pid,
+                                    int all_interpreters)
+/*[clinic end generated code: output=d9dce5f7add149bb input=a2a08a45a8f0b119]*/
+{
+    RuntimeOffsets offsets;
+    if (init_runtime_offsets(&offsets, pid, /*debug=*/1) < 0) {
+        return NULL;
+    }
+
+    RemoteDebuggingState *st = RemoteDebugging_GetState(module);
+    PyObject *result = get_gc_stats(&offsets, all_interpreters,
+                                    st->GCStatsInfo_Type);
+
+    cleanup_runtime_offsets(&offsets);
+    return result;
+}
+
 static PyMethodDef remote_debugging_methods[] = {
     _REMOTE_DEBUGGING_ZSTD_AVAILABLE_METHODDEF
     _REMOTE_DEBUGGING_GET_CHILD_PIDS_METHODDEF
     _REMOTE_DEBUGGING_IS_PYTHON_PROCESS_METHODDEF
+    _REMOTE_DEBUGGING_GET_GC_STATS_METHODDEF
     {NULL, NULL, 0, NULL},
 };
 
diff --git a/PCbuild/_remote_debugging.vcxproj 
b/PCbuild/_remote_debugging.vcxproj
index 0e86ce9f4c918c..3a9b4033a697ad 100644
--- a/PCbuild/_remote_debugging.vcxproj
+++ b/PCbuild/_remote_debugging.vcxproj
@@ -99,6 +99,7 @@
 </ItemDefinitionGroup>
   <ItemGroup>
     <ClCompile Include="..\Modules\_remote_debugging\module.c" />
+    <ClCompile Include="..\Modules\_remote_debugging\gc_stats.c" />
     <ClCompile Include="..\Modules\_remote_debugging\object_reading.c" />
     <ClCompile Include="..\Modules\_remote_debugging\code_objects.c" />
     <ClCompile Include="..\Modules\_remote_debugging\frames.c" />
@@ -108,10 +109,12 @@
     <ClCompile Include="..\Modules\_remote_debugging\binary_io_writer.c" />
     <ClCompile Include="..\Modules\_remote_debugging\binary_io_reader.c" />
     <ClCompile Include="..\Modules\_remote_debugging\subprocess.c" />
+    <ClCompile Include="..\Modules\_remote_debugging\interpreters.c" />
   </ItemGroup>
   <ItemGroup>
     <ClInclude Include="..\Modules\_remote_debugging\_remote_debugging.h" />
     <ClInclude Include="..\Modules\_remote_debugging\binary_io.h" />
+    <ClInclude Include="..\Modules\_remote_debugging\gc_stats.h" />
   </ItemGroup>
   <ItemGroup>
     <ResourceCompile Include="..\PC\python_nt.rc" />
diff --git a/PCbuild/_remote_debugging.vcxproj.filters 
b/PCbuild/_remote_debugging.vcxproj.filters
index 59d4d5c5c335fb..001f214805f93f 100644
--- a/PCbuild/_remote_debugging.vcxproj.filters
+++ b/PCbuild/_remote_debugging.vcxproj.filters
@@ -15,6 +15,9 @@
     <ClCompile Include="..\Modules\_remote_debugging\module.c">
       <Filter>Source Files</Filter>
     </ClCompile>
+    <ClCompile Include="..\Modules\_remote_debugging\gc_stats.c">
+      <Filter>Source Files</Filter>
+    </ClCompile>
     <ClCompile Include="..\Modules\_remote_debugging\object_reading.c">
       <Filter>Source Files</Filter>
     </ClCompile>
@@ -42,6 +45,9 @@
     <ClCompile Include="..\Modules\_remote_debugging\subprocess.c">
       <Filter>Source Files</Filter>
     </ClCompile>
+    <ClCompile Include="..\Modules\_remote_debugging\interpreters.c">
+      <Filter>Source Files</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <ClInclude Include="..\Modules\_remote_debugging\_remote_debugging.h">
@@ -50,6 +56,9 @@
     <ClInclude Include="..\Modules\_remote_debugging\binary_io.h">
       <Filter>Header Files</Filter>
     </ClInclude>
+    <ClInclude Include="..\Modules\_remote_debugging\gc_stats.h">
+      <Filter>Header Files</Filter>
+    </ClInclude>
   </ItemGroup>
   <ItemGroup>
     <ResourceCompile Include="..\PC\python_nt.rc">
diff --git a/Python/gc.c b/Python/gc.c
index 59bed10c1fb230..de485320a4b6d4 100644
--- a/Python/gc.c
+++ b/Python/gc.c
@@ -1405,7 +1405,6 @@ add_stats(GCState *gcstate, int gen, struct 
gc_generation_stats *stats)
     memcpy(cur_stats, prev_stats, sizeof(struct gc_generation_stats));
 
     cur_stats->ts_start = stats->ts_start;
-    cur_stats->ts_stop = stats->ts_stop;
 
     cur_stats->collections += 1;
     cur_stats->collected += stats->collected;
@@ -1413,6 +1412,9 @@ add_stats(GCState *gcstate, int gen, struct 
gc_generation_stats *stats)
     cur_stats->candidates += stats->candidates;
 
     cur_stats->duration += stats->duration;
+    /* Publish ts_stop last so remote readers do not select a partially
+       updated stats record as the latest collection. */
+    cur_stats->ts_stop = stats->ts_stop;
 }
 
 /* This is the main function.  Read this to understand how the
diff --git a/Python/gc_free_threading.c b/Python/gc_free_threading.c
index 4b46ca04f56b20..b4fcd365592aa5 100644
--- a/Python/gc_free_threading.c
+++ b/Python/gc_free_threading.c
@@ -2492,6 +2492,8 @@ gc_collect_main(PyThreadState *tstate, int generation, 
_PyGC_Reason reason)
 
     /* Update stats */
     struct gc_generation_stats *stats = get_stats(gcstate, generation);
+    stats->ts_start = start;
+    stats->ts_stop = stop;
     stats->collections++;
     stats->collected += m;
     stats->uncollectable += n;
diff --git a/Tools/c-analyzer/cpython/_parser.py 
b/Tools/c-analyzer/cpython/_parser.py
index a16d5773d5544c..fc3cbf3779db26 100644
--- a/Tools/c-analyzer/cpython/_parser.py
+++ b/Tools/c-analyzer/cpython/_parser.py
@@ -318,6 +318,7 @@ def format_tsv_lines(lines):
     _abs('Modules/_hacl/*.c'): (200_000, 500),
     _abs('Modules/posixmodule.c'): (20_000, 500),
     _abs('Modules/termios.c'): (10_000, 800),
+    _abs('Modules/_remote_debugging/debug_offsets_validation.h'): (25_000, 
1000),
     _abs('Modules/_remote_debugging/*.h'): (20_000, 1000),
     _abs('Modules/_testcapimodule.c'): (20_000, 400),
     _abs('Modules/expat/expat.h'): (10_000, 400),
@@ -346,7 +347,7 @@ def format_tsv_lines(lines):
     _abs('Modules/_ssl_data_300.h'): (80_000, 10_000),
     _abs('Modules/_ssl_data_111.h'): (80_000, 10_000),
     _abs('Modules/cjkcodecs/mappings_*.h'): (160_000, 2_000),
-    _abs('Modules/clinic/_testclinic.c.h'): (120_000, 5_000),
+    _abs('Modules/clinic/_testclinic.c.h'): (125_000, 5_000),
     _abs('Modules/unicodedata_db.h'): (180_000, 3_000),
     _abs('Modules/unicodename_db.h'): (1_200_000, 15_000),
     _abs('Objects/unicodetype_db.h'): (240_000, 3_000),

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to