Repository navigation
Make asyncio debug offsets reachable from _PyRuntime and give them a cookie, version and size #160107
Description
Feature or enhancement
Disclosure
- I work on Datadog's Python profiler. This proposal came out of fix(profiling): safely read Python 3.14 asyncio task offsets DataDog/dd-trace-py#19802, where I wanted a better way for debuggers and profilers to read the
AsyncioDebugtable. dd-trace-py would only use this on 3.16+ and still has to support older versions the current way, so the main benefit is for CPython's own tools and other readers going forward. - I used AI to help write this proposal. I have reviewed all of the content.
Problem
_Py_AsyncioDebug (the AsyncioDebug section) describes asyncio task state to tools outside the _asyncio module. In CPython it is read by _remote_debugging, which backs Tachyon and python -m asyncio ps/pstree. Third-party profilers are starting to use it too, for example the in-process sampler in DataDog/dd-trace-py#19802. Two things make it harder to use safely than _Py_DebugOffsets.
1. It can only be found by searching binaries. _Py_DebugOffsets sits at the start of _PyRuntime, which is an exported symbol. Nothing there points to the asyncio table, so readers have to find the AsyncioDebug section themselves: pick loaded files by name, then parse their section headers. This is fragile:
- The file-name filters have to follow build changes. 3.14 looks in
_asyncio.cpython*; main looks in*python*because_asynciois now static on POSIX (build_asynciomodule as static module #136669). The macOS filter (libpython*, thenpython*) seems to miss framework builds, where the table is inPython.framework/Versions/3.X/Python. I checked this against the loaded images of a Homebrew 3.15.0 process, not by attaching. _remote_debuggingreads the section headers from files on disk, which can fail when a file is deleted or replaced (sys.remote_exec() is unable to find writable memory when libpython got deleted/replaced on disk #151029). Choosing among mappings has its own edge cases (tachyon fails to attach with_ctypesimported #144563). The proposed pointer removes this additional asyncio lookup; it does not remove any disk dependency involved in initially finding_PyRuntime.- In-process tools can't use
dlsym(), because_Py_AsyncioDebugisn't exported, so they reimplement ELF, Mach-O and PE section lookup.
2. The table can't be identified. _Py_DebugOffsets starts with a cookie and the Python version, so a reader can check that it found the right table for the running interpreter. Py_AsyncioModuleDebugOffsets has neither, and no size. A reader that finds an AsyncioDebug section can't confirm that it is an asyncio table, or associate it with the selected runtime. The validation added in gh-148178 checks sizes, bounds and alignment, including local buffer limits, but does not identify the table or associate it with that runtime. A wrong table with plausible values can still pass. _remote_debugging shows the gap: it checks the cookie of every PyRuntime candidate, but takes the first AsyncioDebug section it finds.
Proposal
-
Add a pointer to
_PyRuntimeStateand publish its offset:struct pyruntimestate { _Py_DebugOffsets debug_offsets; /* Address of the _asyncio debug offsets table, or NULL until the module is initialized. */ void *asyncio_debug_offsets; ... }; struct _runtime_state { uint64_t size; uint64_t finalizing; uint64_t interpreters_head; uint64_t asyncio_debug_offsets; /* new */ } runtime_state;
_asyncio'smodule_init()stores&_Py_AsyncioDebugthere with a release store. Every interpreter stores the same address. CPython does not unload the extension library, so the table remains valid across interpreter teardown within the initialized runtime. Runtime reinitialization resets the published field to NULL; importing_asynciopublishes it again. -
Start
Py_AsyncioModuleDebugOffsetswith a header:typedef struct _Py_AsyncioModuleDebugOffsets { char cookie[8]; /* "xasyncpy" */ uint64_t version; /* PY_VERSION_HEX */ uint64_t size; /* sizeof(Py_AsyncioModuleDebugOffsets) */ struct _asyncio_task_object { ... } asyncio_task_object; ... } Py_AsyncioModuleDebugOffsets;
-
In
_remote_debugging, follow the pointer instead of searching for the section. Reject the table if the cookie doesn't match, ifversiondiffers from_Py_DebugOffsets.version, or ifsizeis smaller than the local struct. Add the new field to the runtime-state validation list and update thestatic_asserts added by Harden validation of remote debugging offset tables #148178. -
Keep the
AsyncioDebugsection as an alternative discovery mechanism. Both table layouts change in 3.16, so readers must be updated for that version; this proposal is not intended for backporting.
Following the pointer associates the table with the selected runtime. The cookie, Python version and size checks detect some invalid or incompatible tables, but do not establish exact build identity: different builds of the same release share PY_VERSION_HEX.
Once a reader has located _PyRuntime, the discovery sequence is, in pseudocode:
off = read_memory(runtime, debug_offsets_layout)
validate_runtime_offsets(off) # cookie, version compatibility and bounds
pointer_address = runtime + off.runtime_state.asyncio_debug_offsets
table_address = read_pointer(pointer_address)
if table_address == NULL:
return unavailable # _asyncio not initialized yet; try again later
header = read_memory(table_address, asyncio_header_layout)
validate_asyncio_header(header, off.version) # cookie, version and size
# Read the supported table fields and validate their sizes, bounds and alignment.
In-process readers should load the pointer with acquire ordering. This works for both a static _asyncio (the POSIX default since 3.15) and a shared one (Windows _asyncio.pyd).
Why _asyncio has its own section, and what changed
When the table was added in gh-91048 (GH-124640), _asyncio was a shared extension module by default and asyncio-specific state was kept out of the core on purpose, so a section in _asyncio was the natural place for it. Since then:
- the task lists moved into core structures,
PyInterpreterState.asyncio_tasks_headand_PyThreadStateImpl.asyncio_*(Audit asyncio thread safety #128002); _asynciois built as a static module on POSIX (build_asynciomodule as static module #136669), so the table already lives in the same image as_PyRuntime.
The change adds one pointer to the runtime, which the core never reads, and the table itself stays in _asyncio.
Open questions
- Is the cookie needed? Following the pointer already associates the table with the runtime, but the cookie provides an additional sanity check on bytes successfully read and helps section-scanning readers identify a candidate. It does not make dereferencing an arbitrary invalid pointer safe. Without it the header would be just
versionandsize. - Should the size check be
>=or exact?>=would allow fields to be appended to the table in a patch release, provided the existing layout and meanings are preserved. Modifying offsets of existing members in _gc_runtime_state is probably a bad idea #149242 discusses the related principle of preserving existing field offsets in_gc_runtime_state, rather than extending a debug-offset table. - Can the
AsyncioDebugsection eventually be removed once readers have switched to the pointer?
I have an implementation on this branch, with a ctypes test that follows the pointer from _PyRuntime. The existing test_external_inspection asyncio tests pass with it on Linux. I'm happy to open a PR.
Has this already been discussed elsewhere?
This is a minor feature, which does not need previous discussion elsewhere
Links to previous discussion of this feature:
- Async Call-Stack Reconstruction #91048 / GH-91048: Add utils for capturing async call stack for asyncio programs and enable profiling #124640: introduced the asyncio debug offsets and the
AsyncioDebugsection - Harden validation of remote debugging offset tables #148178 / gh-148178: Validate remote debug offset tables on load #148187: validation of the remote debug offset tables
- Audit asyncio thread safety #128002 / gh-128002: use per threads tasks linked list in asyncio #128869: per-thread and per-interpreter asyncio task lists
- build
_asynciomodule as static module #136669 / gh-136669: build_asyncioas static module #136670: build_asyncioas a static module - The compiler may optimise away globals with debug offsets #129223: the compiler optimizing away the debug offset globals
- fix(profiling): safely read Python 3.14 asyncio task offsets DataDog/dd-trace-py#19802: in-process reader that motivated this
Linked PRs
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsTodo
Activity
pythongh-160107: Make AsyncioDebug reachable from _PyRuntime and add …