From 985b47a8826c7adda409ba76befd37373cb69138 Mon Sep 17 00:00:00 2001 From: Sergey Miryanov Date: Mon, 30 Mar 2026 00:58:03 +0500 Subject: [PATCH 01/11] Initial version --- .../pycore_global_objects_fini_generated.h | 1 + Include/internal/pycore_global_strings.h | 1 + .../internal/pycore_runtime_init_generated.h | 1 + .../internal/pycore_unicodeobject_generated.h | 4 + Modules/Setup | 2 +- Modules/Setup.stdlib.in | 2 +- Modules/_remote_debugging/_remote_debugging.h | 25 +++ Modules/_remote_debugging/clinic/module.c.h | 78 +++++++++- Modules/_remote_debugging/gc_stats.h | 143 ++++++++++++++++++ Modules/_remote_debugging/interpreters.c | 82 ++++++++++ Modules/_remote_debugging/module.c | 88 +++++++++++ PCbuild/_remote_debugging.vcxproj | 2 + PCbuild/_remote_debugging.vcxproj.filters | 6 + 13 files changed, 432 insertions(+), 3 deletions(-) create mode 100644 Modules/_remote_debugging/gc_stats.h create mode 100644 Modules/_remote_debugging/interpreters.c diff --git a/Include/internal/pycore_global_objects_fini_generated.h b/Include/internal/pycore_global_objects_fini_generated.h index 4b1e289c6ff468..c538d446256525 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 6ee649b59a5c37..c4a9d7dc53ae48 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 778db946c2a3aa..9f8115ff5bd8a0 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 bd8f50ff0ee732..c5563e8f96eac0 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/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..0305bf23cc3756 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/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 3722273dfd2998..c7942ca72589dc 100644 --- a/Modules/_remote_debugging/_remote_debugging.h +++ b/Modules/_remote_debugging/_remote_debugging.h @@ -345,6 +345,12 @@ typedef struct { size_t count; } StackChunkList; +typedef struct { + proc_handle_t handle; + uintptr_t runtime_start_address; + struct _Py_DebugOffsets debug_offsets; +} RuntimeOffsets; + /* * Context for frame chain traversal operations. */ @@ -389,6 +395,14 @@ typedef int (*set_entry_processor_func)( void *context ); +typedef int (*interpreter_processor_func)( + RuntimeOffsets *offsets, + uintptr_t interpreter_state_addr, + unsigned long iid, + void *context +); + + /* ============================================================================ * STRUCTSEQ DESCRIPTORS (extern declarations) * ============================================================================ */ @@ -586,6 +600,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..9b46a1d464f6e1 100644 --- a/Modules/_remote_debugging/clinic/module.c.h +++ b/Modules/_remote_debugging/clinic/module.c.h @@ -1296,4 +1296,80 @@ _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 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."); + +#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=674d05c5ec0e3aca input=a9049054013a1b77]*/ diff --git a/Modules/_remote_debugging/gc_stats.h b/Modules/_remote_debugging/gc_stats.h new file mode 100644 index 00000000000000..e2b0ff57904318 --- /dev/null +++ b/Modules/_remote_debugging/gc_stats.h @@ -0,0 +1,143 @@ +/****************************************************************************** + * Remote Debugging Module - GC Stats Functions + * + * This file contains function for read 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" + +static int +read_gc_stats(struct gc_stats *stats, unsigned long iid, PyObject *result) +{ +#define ADD_LOCAL_ULONG(name) do { \ + val = PyLong_FromUnsignedLong(name); \ + if (!val || PyDict_SetItemString(item, #name, val) < 0) { \ + goto error; \ + } \ + Py_DECREF(val); \ +} while(0) + +#define ADD_STATS_SSIZE(name) do { \ + val = PyLong_FromSsize_t(stats_item->name); \ + if (!val || PyDict_SetItemString(item, #name, val) < 0) { \ + goto error; \ + } \ + Py_DECREF(val); \ +} while(0) + +#define ADD_STATS_INT64(name) do { \ + val = PyLong_FromInt64(stats_item->name); \ + if (!val || PyDict_SetItemString(item, #name, val) < 0) { \ + goto error; \ + } \ + Py_DECREF(val); \ +} while(0) + +#define ADD_STATS_DOUBLE(name) do { \ + val = PyFloat_FromDouble(stats_item->name); \ + if (!val || PyDict_SetItemString(item, #name, val) < 0) { \ + goto error; \ + } \ + Py_DECREF(val); \ +} while(0) + + PyObject *item = NULL; + PyObject *val = 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++) { + struct gc_generation_stats *stats_item = items; + item = PyDict_New(); + if (item == NULL) { + goto error; + } + + ADD_LOCAL_ULONG(gen); + ADD_LOCAL_ULONG(iid); + + ADD_STATS_INT64(ts_start); + ADD_STATS_INT64(ts_stop); + ADD_STATS_SSIZE(heap_size); + ADD_STATS_SSIZE(work_to_do); + ADD_STATS_SSIZE(collections); + ADD_STATS_SSIZE(object_visits); + ADD_STATS_SSIZE(collected); + ADD_STATS_SSIZE(uncollectable); + ADD_STATS_SSIZE(candidates); + ADD_STATS_SSIZE(objects_transitively_reachable); + ADD_STATS_SSIZE(objects_not_transitively_reachable); + + ADD_STATS_DOUBLE(duration); + val = NULL; + + int rc = PyList_Append(result, item); + Py_CLEAR(item); + if (rc < 0) { + goto error; + } + } + } + +#undef ADD_LOCAL_ULONG +#undef ADD_STATS_SSIZE +#undef ADD_STATS_INT64 +#undef ADD_STATS_DOUBLE + + return 0; + +error: + Py_XDECREF(val); + Py_XDECREF(item); + + return -1; +} + +static int +get_gc_stats_from_interpreter_state(RuntimeOffsets *offsets, + uintptr_t interpreter_state_addr, + unsigned long iid, + void *context) +{ + struct gc_stats stats; + uintptr_t gc_stats_address = interpreter_state_addr + + offsets->debug_offsets.interpreter_state.gc + + offsets->debug_offsets.gc.generation_stats; + uint64_t gc_stats_size = offsets->debug_offsets.gc.generation_stats_size; + if (_Py_RemoteDebug_ReadRemoteMemory(&offsets->handle, + gc_stats_address, + gc_stats_size, + &stats) < 0) { + PyErr_SetString(PyExc_RuntimeError, "Failed to read GC state"); + return -1; + } + + PyObject *result = context; + if (read_gc_stats(&stats, iid, result) < 0) { + return -1; + } + + return 0; +} + +#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..f48d1870a61831 --- /dev/null +++ b/Modules/_remote_debugging/interpreters.c @@ -0,0 +1,82 @@ +/****************************************************************************** + * Remote Debugging Module - Interpreters Functions + * + * This file contains function for iterating interpreters. + ******************************************************************************/ + +#include "_remote_debugging.h" + +#ifndef MS_WINDOWS +#include +#endif + +#ifdef __linux__ +#include +#include +#include +#endif + +/* ============================================================================ + * INTERPRETERS ITERATION FUNCTION + * ============================================================================ */ + +int +iterate_interpreters( + RuntimeOffsets *offsets, + interpreter_processor_func processor, + void *context +) { + + uintptr_t interpreter_state_list_head = + (uintptr_t)offsets->debug_offsets.runtime_state.interpreters_head; + uintptr_t interpreter_state_offset = + offsets->runtime_start_address + interpreter_state_list_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; + + unsigned long iid = 0; + uintptr_t interpreter_state_addr; + if (_Py_RemoteDebug_ReadRemoteMemory(&offsets->handle, + interpreter_state_offset, + sizeof(void*), + &interpreter_state_addr) < 0) { + _set_debug_exception_cause(PyExc_RuntimeError, "Failed to read interpreter state address"); + return -1; + } + + if (interpreter_state_addr == 0) { + _set_debug_exception_cause(PyExc_RuntimeError, "No interpreter state found"); + return -1; + } + + while (interpreter_state_addr != 0) { + + if (0 > _Py_RemoteDebug_ReadRemoteMemory( + &offsets->handle, + interpreter_state_addr + interpreter_id_offset, + sizeof(iid), + &iid)) { + _set_debug_exception_cause(PyExc_RuntimeError, "Failed to read next interpreter state"); + return -1; + } + + + // Call the processor function for this interpreter + if (processor(offsets, interpreter_state_addr, iid, context) < 0) { + return -1; + } + + if (0 > _Py_RemoteDebug_ReadRemoteMemory( + &offsets->handle, + interpreter_state_addr + interpreter_next_offset, + sizeof(void*), + &interpreter_state_addr)) { + _set_debug_exception_cause(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 f86bbf8ce5526e..66005a4be408ff 100644 --- a/Modules/_remote_debugging/module.c +++ b/Modules/_remote_debugging/module.c @@ -7,6 +7,7 @@ #include "_remote_debugging.h" #include "binary_io.h" +#include "gc_stats.h" /* Forward declarations for clinic-generated code */ typedef struct { @@ -1832,10 +1833,97 @@ _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 dicts. + dict: A dictionary containing: + - total_samples: Total number of get_stack_trace calls + - frame_cache_hits: Full cache hits (entire stack unchanged) + - frame_cache_misses: Cache misses requiring full walk + - frame_cache_partial_hits: Partial hits (stopped at cached frame) + - frames_read_from_cache: Total frames retrieved from cache + - frames_read_from_memory: Total frames read from remote memory + - memory_reads: Total remote memory read operations + - memory_bytes_read: Total bytes read from remote memory + - code_object_cache_hits: Code object cache hits + - code_object_cache_misses: Code object cache misses + - stale_cache_invalidations: Times stale cache entries were cleared + - frame_cache_hit_rate: Percentage of samples that hit the cache + - code_object_cache_hit_rate: Percentage of code object lookups that hit cache + +Raises: + RuntimeError: If stats collection was not enabled (stats=False) +[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=82045b510b1a849c]*/ +{ + RuntimeOffsets offsets; + + PyObject *result = NULL; + + if (_Py_RemoteDebug_InitProcHandle(&offsets.handle, pid) < 0) { + _set_debug_exception_cause(PyExc_RuntimeError, "Failed to initialize process handle"); + return NULL; + } + + offsets.runtime_start_address = _Py_RemoteDebug_GetPyRuntimeAddress(&offsets.handle); + if (offsets.runtime_start_address == 0) { + _set_debug_exception_cause(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_debug_exception_cause(PyExc_RuntimeError, "Failed to read debug offsets"); + goto error; + } + + // Validate that the debug offsets are valid + if (validate_debug_offsets(&offsets.debug_offsets) == -1) { + _set_debug_exception_cause(PyExc_RuntimeError, "Invalid debug offsets found"); + goto error; + } + + result = PyList_New(0); + if (result == NULL) { + goto error; + } + if (0 > iterate_interpreters(&offsets, get_gc_stats_from_interpreter_state, result)) { + goto error; + } + + goto done; + +error: + Py_CLEAR(result); + +done: + _Py_RemoteDebug_ClearCache(&offsets.handle); + _Py_RemoteDebug_CleanupProcHandle(&offsets.handle); + + 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..688ac44d83d9ed 100644 --- a/PCbuild/_remote_debugging.vcxproj +++ b/PCbuild/_remote_debugging.vcxproj @@ -108,10 +108,12 @@ + + diff --git a/PCbuild/_remote_debugging.vcxproj.filters b/PCbuild/_remote_debugging.vcxproj.filters index 59d4d5c5c335fb..e3252a4eadde07 100644 --- a/PCbuild/_remote_debugging.vcxproj.filters +++ b/PCbuild/_remote_debugging.vcxproj.filters @@ -42,6 +42,9 @@ Source Files + + Source Files + @@ -50,6 +53,9 @@ Header Files + + Header Files + From 64f49f0d78e56f0ea64801a14242d25b5b9e4083 Mon Sep 17 00:00:00 2001 From: Sergey Miryanov Date: Sat, 4 Apr 2026 12:43:59 +0500 Subject: [PATCH 02/11] Write ts_stop at the end of the add_stats to determine that stats properly updated --- Python/gc.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Python/gc.c b/Python/gc.c index 7bca40f6e3f58e..32392ff12ce3bc 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -1435,7 +1435,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->heap_size = stats->heap_size; cur_stats->work_to_do = stats->work_to_do; @@ -1449,6 +1448,7 @@ add_stats(GCState *gcstate, int gen, struct gc_generation_stats *stats) cur_stats->objects_not_transitively_reachable += stats->objects_not_transitively_reachable; cur_stats->duration += stats->duration; + cur_stats->ts_stop = stats->ts_stop; } static void From e403750653e3ee8f41a88fce933f59860b2db327 Mon Sep 17 00:00:00 2001 From: Sergey Miryanov Date: Sat, 4 Apr 2026 12:56:00 +0500 Subject: [PATCH 03/11] AC --- Modules/_remote_debugging/clinic/module.c.h | 27 ++++++++++++++++-- Modules/_remote_debugging/module.c | 31 +++++++++++---------- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/Modules/_remote_debugging/clinic/module.c.h b/Modules/_remote_debugging/clinic/module.c.h index 9b46a1d464f6e1..0176bad5f9a34b 100644 --- a/Modules/_remote_debugging/clinic/module.c.h +++ b/Modules/_remote_debugging/clinic/module.c.h @@ -1301,11 +1301,32 @@ PyDoc_STRVAR(_remote_debugging_get_gc_stats__doc__, "get_gc_stats($module, /, pid, *, all_interpreters=False)\n" "--\n" "\n" -"Get garbage statistics from external Python process.\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."); +" If False, return only from main interpreter.\n" +"\n" +"Returns:\n" +" List of dicts.\n" +" dict: A dictionary containing:\n" +" - gen:\n" +" - iid:\n" +" - ts_start:\n" +" - ts_stop:\n" +" - heap_size:\n" +" - work_to_do:\n" +" - collections:\n" +" - object_visits:\n" +" - collected:\n" +" - uncollectable:\n" +" - candidates:\n" +" - objects_transitively_reachable:\n" +" - objects_not_transitively_reachable:\n" +" - duration:\n" +"\n" +"Raises:\n" +" RuntimeError:"); #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__}, @@ -1372,4 +1393,4 @@ _remote_debugging_get_gc_stats(PyObject *module, PyObject *const *args, Py_ssize exit: return return_value; } -/*[clinic end generated code: output=674d05c5ec0e3aca input=a9049054013a1b77]*/ +/*[clinic end generated code: output=bdd3092b9cbc4313 input=a9049054013a1b77]*/ diff --git a/Modules/_remote_debugging/module.c b/Modules/_remote_debugging/module.c index 66005a4be408ff..d6005c6f5c9010 100644 --- a/Modules/_remote_debugging/module.c +++ b/Modules/_remote_debugging/module.c @@ -1847,28 +1847,29 @@ Get garbage collector statistics from external Python process. Returns: List of dicts. dict: A dictionary containing: - - total_samples: Total number of get_stack_trace calls - - frame_cache_hits: Full cache hits (entire stack unchanged) - - frame_cache_misses: Cache misses requiring full walk - - frame_cache_partial_hits: Partial hits (stopped at cached frame) - - frames_read_from_cache: Total frames retrieved from cache - - frames_read_from_memory: Total frames read from remote memory - - memory_reads: Total remote memory read operations - - memory_bytes_read: Total bytes read from remote memory - - code_object_cache_hits: Code object cache hits - - code_object_cache_misses: Code object cache misses - - stale_cache_invalidations: Times stale cache entries were cleared - - frame_cache_hit_rate: Percentage of samples that hit the cache - - code_object_cache_hit_rate: Percentage of code object lookups that hit cache + - gen: + - iid: + - ts_start: + - ts_stop: + - heap_size: + - work_to_do: + - collections: + - object_visits: + - collected: + - uncollectable: + - candidates: + - objects_transitively_reachable: + - objects_not_transitively_reachable: + - duration: Raises: - RuntimeError: If stats collection was not enabled (stats=False) + RuntimeError: [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=82045b510b1a849c]*/ +/*[clinic end generated code: output=d9dce5f7add149bb input=8f05aee4d4230428]*/ { RuntimeOffsets offsets; From 0886a0725b3523cdd704d0344d7a2404d4d37818 Mon Sep 17 00:00:00 2001 From: Sergey Miryanov Date: Fri, 10 Apr 2026 01:31:02 +0500 Subject: [PATCH 04/11] Fix reading gc_stats according last changes --- Modules/_remote_debugging/gc_stats.h | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/Modules/_remote_debugging/gc_stats.h b/Modules/_remote_debugging/gc_stats.h index e2b0ff57904318..ee1a72816df1c4 100644 --- a/Modules/_remote_debugging/gc_stats.h +++ b/Modules/_remote_debugging/gc_stats.h @@ -115,13 +115,22 @@ get_gc_stats_from_interpreter_state(RuntimeOffsets *offsets, unsigned long iid, void *context) { - struct gc_stats stats; - uintptr_t gc_stats_address = interpreter_state_addr + uintptr_t gc_stats_addr; + 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) { + PyErr_SetString(PyExc_RuntimeError, "Failed to read GC state address"); + return -1; + } + + struct gc_stats stats; uint64_t gc_stats_size = offsets->debug_offsets.gc.generation_stats_size; if (_Py_RemoteDebug_ReadRemoteMemory(&offsets->handle, - gc_stats_address, + gc_stats_addr, gc_stats_size, &stats) < 0) { PyErr_SetString(PyExc_RuntimeError, "Failed to read GC state"); From a09b474509c05601ca5e17273d3c941c9a2efc1f Mon Sep 17 00:00:00 2001 From: Sergey Miryanov Date: Fri, 10 Apr 2026 18:21:24 +0500 Subject: [PATCH 05/11] Tests for get_gc_stats for main interpreter --- Lib/test/test_get_gc_stats.py | 123 ++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 Lib/test/test_get_gc_stats.py diff --git a/Lib/test/test_get_gc_stats.py b/Lib/test/test_get_gc_stats.py new file mode 100644 index 00000000000000..e560cb96f3c7cd --- /dev/null +++ b/Lib/test/test_get_gc_stats.py @@ -0,0 +1,123 @@ +import gc +import json +import os +import subprocess +import sys +import textwrap +import unittest + +from test.support import ( + SHORT_TIMEOUT, + requires_remote_subprocess_debugging, +) + + +def get_interpreter_identifiers(gc_stats: tuple[dict[str, str|int|float]]) -> list[str]: + return [s["iid"] for s in gc_stats] + + +def get_generations(gc_stats: tuple[dict[str, str|int|float]]) -> tuple[int,int,int]: + generations = set() + for s in gc_stats: + generations.add(s["gen"]) + + return tuple(sorted(generations)) + + +def get_last_item_for_generation(gc_stats: tuple[dict[str, str|int|float]], + generation:int) -> dict[str, str|int|float] | None: + item = None + for s in gc_stats: + if s["gen"] == generation: + if item is None or item["ts_start"] < s["ts_start"]: + item = s + + return item + + + +@requires_remote_subprocess_debugging() +class TestGetStackTrace(unittest.TestCase): + + def run_child_process(self): + # Run the test in a subprocess to avoid side effects + script = textwrap.dedent("""\ + import json + import os + import sys + import _remote_debugging + + pid = int(sys.argv[1]) + gc_stats = _remote_debugging.get_gc_stats(pid, all_interpreters=False) + print(json.dumps(gc_stats, indent=1)) + """) + + gc.collect(0) + gc.collect(1) + gc.collect(2) + + result = subprocess.run( + [sys.executable, "-c", script, str(os.getpid())], + capture_output=True, + text=True, + timeout=SHORT_TIMEOUT, + ) + self.assertEqual( + result.returncode, 0, + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + return result + + def test_get_gc_stats_for_main_interpreter(self): + """Test that RemoteUnwinder works on the same process after _ctypes import. + + When _ctypes is imported, it may call dlopen on the libpython shared + library, creating a duplicate mapping in the process address space. + The remote debugging code must skip these uninitialized duplicate + mappings and find the real PyRuntime. See gh-144563. + """ + + # Skip the test if the _ctypes module is missing. + + before_stats = json.loads(self.run_child_process().stdout) + after_stats = json.loads(self.run_child_process().stdout) + + before_iids = get_interpreter_identifiers(before_stats) + after_iids = get_interpreter_identifiers(after_stats) + + self.assertTrue(all([0 == iid for iid in before_iids])) + self.assertTrue(all([0 == iid for iid in after_iids])) + + before_gens = get_generations(before_stats) + after_gens = get_generations(after_stats) + + self.assertEqual(before_gens, (0, 1, 2)) + self.assertEqual(after_gens, (0, 1, 2)) + + before_last_items = (get_last_item_for_generation(before_stats, 0), + get_last_item_for_generation(before_stats, 1), + get_last_item_for_generation(before_stats, 2)) + + after_last_items = (get_last_item_for_generation(after_stats, 0), + get_last_item_for_generation(after_stats, 1), + get_last_item_for_generation(after_stats, 2)) + + for before, after in zip(before_last_items, after_last_items): + 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["object_visits"], before["object_visits"], (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)) + + if before["gen"] == 1: + self.assertGreaterEqual(after["objects_transitively_reachable"], before["objects_transitively_reachable"], (before, after)) + self.assertGreaterEqual(after["objects_not_transitively_reachable"], before["objects_not_transitively_reachable"], (before, after)) From 1b641f57870ffe3d0176a91c3c543e2e57dfdb1e Mon Sep 17 00:00:00 2001 From: Sergey Miryanov Date: Fri, 10 Apr 2026 22:58:27 +0500 Subject: [PATCH 06/11] Add tests for subinterpreters --- Lib/test/test_get_gc_stats.py | 160 +++++++++++++++++++++++++--------- 1 file changed, 119 insertions(+), 41 deletions(-) diff --git a/Lib/test/test_get_gc_stats.py b/Lib/test/test_get_gc_stats.py index e560cb96f3c7cd..b6bd877871be4a 100644 --- a/Lib/test/test_get_gc_stats.py +++ b/Lib/test/test_get_gc_stats.py @@ -7,13 +7,14 @@ import unittest from test.support import ( + import_helper, SHORT_TIMEOUT, requires_remote_subprocess_debugging, ) -def get_interpreter_identifiers(gc_stats: tuple[dict[str, str|int|float]]) -> list[str]: - return [s["iid"] for s in gc_stats] +def get_interpreter_identifiers(gc_stats: tuple[dict[str, str|int|float]]) -> tuple[str,...]: + return tuple(sorted({s["iid"] for s in gc_stats})) def get_generations(gc_stats: tuple[dict[str, str|int|float]]) -> tuple[int,int,int]: @@ -24,31 +25,31 @@ def get_generations(gc_stats: tuple[dict[str, str|int|float]]) -> tuple[int,int, return tuple(sorted(generations)) -def get_last_item_for_generation(gc_stats: tuple[dict[str, str|int|float]], - generation:int) -> dict[str, str|int|float] | None: +def get_last_item(gc_stats: tuple[dict[str, str|int|float]], + generation:int, + iid:int) -> dict[str, str|int|float] | None: item = None for s in gc_stats: - if s["gen"] == generation: + if s["gen"] == generation and s["iid"] == iid: if item is None or item["ts_start"] < s["ts_start"]: item = s return item - @requires_remote_subprocess_debugging() -class TestGetStackTrace(unittest.TestCase): +class TestGetGCStats(unittest.TestCase): - def run_child_process(self): + def _run_child_process(self, all_interpreters): # Run the test in a subprocess to avoid side effects - script = textwrap.dedent("""\ + script = textwrap.dedent(f"""\ import json import os import sys import _remote_debugging pid = int(sys.argv[1]) - gc_stats = _remote_debugging.get_gc_stats(pid, all_interpreters=False) + gc_stats = _remote_debugging.get_gc_stats(pid, all_interpreters={all_interpreters}) print(json.dumps(gc_stats, indent=1)) """) @@ -68,25 +69,49 @@ def run_child_process(self): ) return result - def test_get_gc_stats_for_main_interpreter(self): - """Test that RemoteUnwinder works on the same process after _ctypes import. + def _run_in_interpreter(self, interp): + source = f"""if True: + import gc - When _ctypes is imported, it may call dlopen on the libpython shared - library, creating a duplicate mapping in the process address space. - The remote debugging code must skip these uninitialized duplicate - mappings and find the real PyRuntime. See gh-144563. + gc.collect(0) + gc.collect(1) + gc.collect(2) """ + interp.exec(source) - # Skip the test if the _ctypes module is missing. + def _check_gc_state(self, before, after): + self.assertIsNotNone(before) + self.assertIsNotNone(after) - before_stats = json.loads(self.run_child_process().stdout) - after_stats = json.loads(self.run_child_process().stdout) + 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["object_visits"], before["object_visits"], (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)) + + if before["gen"] == 1: + self.assertGreaterEqual(after["objects_transitively_reachable"], + before["objects_transitively_reachable"], + (before, after)) + self.assertGreaterEqual(after["objects_not_transitively_reachable"], + before["objects_not_transitively_reachable"], + (before, after)) + + def test_get_gc_stats_for_main_interpreter(self): + before_stats = json.loads(self._run_child_process(False).stdout) + after_stats = json.loads(self._run_child_process(False).stdout) before_iids = get_interpreter_identifiers(before_stats) after_iids = get_interpreter_identifiers(after_stats) - self.assertTrue(all([0 == iid for iid in before_iids])) - self.assertTrue(all([0 == iid for iid in after_iids])) + self.assertEqual(before_iids, (0,)) + self.assertEqual(after_iids, (0,)) before_gens = get_generations(before_stats) after_gens = get_generations(after_stats) @@ -94,30 +119,83 @@ def test_get_gc_stats_for_main_interpreter(self): self.assertEqual(before_gens, (0, 1, 2)) self.assertEqual(after_gens, (0, 1, 2)) - before_last_items = (get_last_item_for_generation(before_stats, 0), - get_last_item_for_generation(before_stats, 1), - get_last_item_for_generation(before_stats, 2)) + iid = 0 # main interpreter ID + 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_for_generation(after_stats, 0), - get_last_item_for_generation(after_stats, 1), - get_last_item_for_generation(after_stats, 2)) + 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.assertIsNotNone(before) - self.assertIsNotNone(after) + self._check_gc_state(before, 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)) + def test_get_gc_stats_for_all_interpreters(self): + interpreters = import_helper.import_module("concurrent.interpreters") + interp = interpreters.create() - self.assertGreater(after["object_visits"], before["object_visits"], (before, after)) - self.assertGreater(after["candidates"], before["candidates"], (before, after)) + self._run_in_interpreter(interp) # ensure that subinterpeter have GC stats + before_stats = json.loads(self._run_child_process(True).stdout) + self._run_in_interpreter(interp) # ensure that GC stats in subinterpreter changed + after_stats = json.loads(self._run_child_process(True).stdout) + interp.close() - # may not grow - self.assertGreaterEqual(after["collected"], before["collected"], (before, after)) - self.assertGreaterEqual(after["uncollectable"], before["uncollectable"], (before, after)) + before_iids = get_interpreter_identifiers(before_stats) + after_iids = get_interpreter_identifiers(after_stats) + + self.assertEqual(before_iids, (0, interp.id)) + self.assertEqual(after_iids, (0, interp.id)) + + before_gens = get_generations(before_stats) + after_gens = get_generations(after_stats) + + self.assertEqual(before_gens, (0, 1, 2)) + self.assertEqual(after_gens, (0, 1, 2)) - if before["gen"] == 1: - self.assertGreaterEqual(after["objects_transitively_reachable"], before["objects_transitively_reachable"], (before, after)) - self.assertGreaterEqual(after["objects_not_transitively_reachable"], before["objects_not_transitively_reachable"], (before, after)) + for iid in after_iids: + with self.subTest(f"iid={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_state(before, after) + + def test_get_gc_stats_for_main_interpreter_if_subinterpreter_exists(self): + interpreters = import_helper.import_module("concurrent.interpreters") + interp = interpreters.create() + + self._run_in_interpreter(interp) # ensure that subinterpeter have GC stats + before_stats = json.loads(self._run_child_process(False).stdout) + self._run_in_interpreter(interp) # ensure that GC stats in subinterpreter changed + after_stats = json.loads(self._run_child_process(False).stdout) + interp.close() + + before_iids = get_interpreter_identifiers(before_stats) + after_iids = get_interpreter_identifiers(after_stats) + + self.assertEqual(before_iids, (0, )) + self.assertEqual(after_iids, (0, )) + + before_gens = get_generations(before_stats) + after_gens = get_generations(after_stats) + + self.assertEqual(before_gens, (0, 1, 2)) + self.assertEqual(after_gens, (0, 1, 2)) + + iid = 0 # main interpreter ID + 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_state(before, after) From 1f5ff688cb9745e50ce704e314623c8c72af4f7e Mon Sep 17 00:00:00 2001 From: Sergey Miryanov Date: Fri, 10 Apr 2026 22:58:49 +0500 Subject: [PATCH 07/11] Fix all_subinterpreters handling --- Modules/_remote_debugging/gc_stats.h | 13 +++++++++++-- Modules/_remote_debugging/interpreters.c | 2 -- Modules/_remote_debugging/module.c | 6 +++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/Modules/_remote_debugging/gc_stats.h b/Modules/_remote_debugging/gc_stats.h index ee1a72816df1c4..75d0cec86acfa4 100644 --- a/Modules/_remote_debugging/gc_stats.h +++ b/Modules/_remote_debugging/gc_stats.h @@ -13,6 +13,11 @@ extern "C" { #include "_remote_debugging.h" +typedef struct { + PyObject *result; + bool all_interpreters; +} ReadGCStatsContext; + static int read_gc_stats(struct gc_stats *stats, unsigned long iid, PyObject *result) { @@ -115,6 +120,11 @@ get_gc_stats_from_interpreter_state(RuntimeOffsets *offsets, unsigned long iid, void *context) { + ReadGCStatsContext *ctx = (ReadGCStatsContext *)context; + if (!ctx->all_interpreters && iid > 0) { + return 0; + } + uintptr_t gc_stats_addr; uintptr_t gc_stats_pointer_address = interpreter_state_addr + offsets->debug_offsets.interpreter_state.gc @@ -137,8 +147,7 @@ get_gc_stats_from_interpreter_state(RuntimeOffsets *offsets, return -1; } - PyObject *result = context; - if (read_gc_stats(&stats, iid, result) < 0) { + if (read_gc_stats(&stats, iid, ctx->result) < 0) { return -1; } diff --git a/Modules/_remote_debugging/interpreters.c b/Modules/_remote_debugging/interpreters.c index f48d1870a61831..83e5f8aee8140c 100644 --- a/Modules/_remote_debugging/interpreters.c +++ b/Modules/_remote_debugging/interpreters.c @@ -52,7 +52,6 @@ iterate_interpreters( } while (interpreter_state_addr != 0) { - if (0 > _Py_RemoteDebug_ReadRemoteMemory( &offsets->handle, interpreter_state_addr + interpreter_id_offset, @@ -62,7 +61,6 @@ iterate_interpreters( return -1; } - // Call the processor function for this interpreter if (processor(offsets, interpreter_state_addr, iid, context) < 0) { return -1; diff --git a/Modules/_remote_debugging/module.c b/Modules/_remote_debugging/module.c index d6005c6f5c9010..ac4d836505be90 100644 --- a/Modules/_remote_debugging/module.c +++ b/Modules/_remote_debugging/module.c @@ -1904,7 +1904,11 @@ _remote_debugging_get_gc_stats_impl(PyObject *module, int pid, if (result == NULL) { goto error; } - if (0 > iterate_interpreters(&offsets, get_gc_stats_from_interpreter_state, result)) { + ReadGCStatsContext ctx = { + .result = result, + .all_interpreters = all_interpreters, + }; + if (0 > iterate_interpreters(&offsets, get_gc_stats_from_interpreter_state, &ctx)) { goto error; } From 5efa991f33d59f97e718d140fc42de6e2450773c Mon Sep 17 00:00:00 2001 From: Sergey Miryanov Date: Fri, 10 Apr 2026 23:13:28 +0500 Subject: [PATCH 08/11] Add news --- .../2026-04-10-23-13-19.gh-issue-146527.P3Xv4Q.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-04-10-23-13-19.gh-issue-146527.P3Xv4Q.rst 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..b34489cdff5619 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-04-10-23-13-19.gh-issue-146527.P3Xv4Q.rst @@ -0,0 +1,2 @@ +Add internal function ``get_gc_stats`` to the :mod:`!_remote_debugging` +module to allow read GC statistics from an external Python process. From 189854ff70ea0a76271e0983673aa72da3c6d0e3 Mon Sep 17 00:00:00 2001 From: Sergey Miryanov Date: Fri, 10 Apr 2026 23:51:49 +0500 Subject: [PATCH 09/11] Update news --- .../2026-04-10-23-13-19.gh-issue-146527.P3Xv4Q.rst | 1 + 1 file changed, 1 insertion(+) 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 index b34489cdff5619..f5989932cd8b1f 100644 --- 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 @@ -1,2 +1,3 @@ Add internal function ``get_gc_stats`` to the :mod:`!_remote_debugging` module to allow read GC statistics from an external Python process. +Patch by Sergey Miryanov. From 66f8e9602c5c27d6c0dbedabd884f9e3be82ab9a Mon Sep 17 00:00:00 2001 From: Sergey Miryanov Date: Fri, 10 Apr 2026 23:52:13 +0500 Subject: [PATCH 10/11] Try to fix tests --- Lib/test/test_get_gc_stats.py | 36 ++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/Lib/test/test_get_gc_stats.py b/Lib/test/test_get_gc_stats.py index b6bd877871be4a..d9dcfb80b6ca08 100644 --- a/Lib/test/test_get_gc_stats.py +++ b/Lib/test/test_get_gc_stats.py @@ -47,10 +47,18 @@ def _run_child_process(self, all_interpreters): import os import sys import _remote_debugging - - pid = int(sys.argv[1]) - gc_stats = _remote_debugging.get_gc_stats(pid, all_interpreters={all_interpreters}) - print(json.dumps(gc_stats, indent=1)) + try: + from _remote_debugging import PROCESS_VM_READV_SUPPORTED + supported = True + except ImportError: + supported = False + + if supported: + pid = int(sys.argv[1]) + gc_stats = _remote_debugging.get_gc_stats(pid, all_interpreters={all_interpreters}) + print(json.dumps(gc_stats, indent=1)) + else: + print(json.dumps(dict([("error", "not supported")]))) """) gc.collect(0) @@ -67,7 +75,13 @@ def _run_child_process(self, all_interpreters): result.returncode, 0, f"stdout: {result.stdout}\nstderr: {result.stderr}" ) - return result + data = json.loads(result.stdout) + if isinstance(data, dict) and "error" in data: + if sys.platform == "linux": + self.skipTest("Testing on Linux requires process_vm_readv support") + else: + self.assertTrue(False, f"Unexpected error: {data}") + return data def _run_in_interpreter(self, interp): source = f"""if True: @@ -104,8 +118,8 @@ def _check_gc_state(self, before, after): (before, after)) def test_get_gc_stats_for_main_interpreter(self): - before_stats = json.loads(self._run_child_process(False).stdout) - after_stats = json.loads(self._run_child_process(False).stdout) + before_stats = self._run_child_process(False) + after_stats = self._run_child_process(False) before_iids = get_interpreter_identifiers(before_stats) after_iids = get_interpreter_identifiers(after_stats) @@ -136,9 +150,9 @@ def test_get_gc_stats_for_all_interpreters(self): interp = interpreters.create() self._run_in_interpreter(interp) # ensure that subinterpeter have GC stats - before_stats = json.loads(self._run_child_process(True).stdout) + before_stats = self._run_child_process(True) self._run_in_interpreter(interp) # ensure that GC stats in subinterpreter changed - after_stats = json.loads(self._run_child_process(True).stdout) + after_stats = self._run_child_process(True) interp.close() before_iids = get_interpreter_identifiers(before_stats) @@ -171,9 +185,9 @@ def test_get_gc_stats_for_main_interpreter_if_subinterpreter_exists(self): interp = interpreters.create() self._run_in_interpreter(interp) # ensure that subinterpeter have GC stats - before_stats = json.loads(self._run_child_process(False).stdout) + before_stats = self._run_child_process(False) self._run_in_interpreter(interp) # ensure that GC stats in subinterpreter changed - after_stats = json.loads(self._run_child_process(False).stdout) + after_stats = self._run_child_process(False) interp.close() before_iids = get_interpreter_identifiers(before_stats) From 7c835179c979838d17b43e3c9d5cd047dc6abaaf Mon Sep 17 00:00:00 2001 From: Sergey Miryanov Date: Sat, 11 Apr 2026 16:01:51 +0500 Subject: [PATCH 11/11] Disable tests for FT-builds --- Lib/test/test_get_gc_stats.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Lib/test/test_get_gc_stats.py b/Lib/test/test_get_gc_stats.py index d9dcfb80b6ca08..769c6c45b90f68 100644 --- a/Lib/test/test_get_gc_stats.py +++ b/Lib/test/test_get_gc_stats.py @@ -9,6 +9,7 @@ from test.support import ( import_helper, SHORT_TIMEOUT, + requires_gil_enabled, requires_remote_subprocess_debugging, ) @@ -37,6 +38,7 @@ def get_last_item(gc_stats: tuple[dict[str, str|int|float]], return item +@requires_gil_enabled() @requires_remote_subprocess_debugging() class TestGetGCStats(unittest.TestCase):