FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Make `asyncio` debug offsets reachable from `_PyRuntime` and give them a cookie, version and size · Issue #160107 · python/cpython · GitHub

Repository navigation

Make asyncio debug offsets reachable from _PyRuntime and give them a cookie, version and size #160107

Open

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 AsyncioDebug table. 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 _asyncio is now static on POSIX (build _asyncio module as static module #136669). The macOS filter (libpython*, then python*) seems to miss framework builds, where the table is in Python.framework/Versions/3.X/Python. I checked this against the loaded images of a Homebrew 3.15.0 process, not by attaching.
  • _remote_debugging reads 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 _ctypes imported #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_AsyncioDebug isn'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

  1. Add a pointer to _PyRuntimeState and 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's module_init() stores &_Py_AsyncioDebug there 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 _asyncio publishes it again.

  2. Start Py_AsyncioModuleDebugOffsets with 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;
  3. In _remote_debugging, follow the pointer instead of searching for the section. Reject the table if the cookie doesn't match, if version differs from _Py_DebugOffsets.version, or if size is smaller than the local struct. Add the new field to the runtime-state validation list and update the static_asserts added by Harden validation of remote debugging offset tables #148178.

  4. Keep the AsyncioDebug section 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 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 version and size.
  • 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 AsyncioDebug section 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:

Linked PRs

Activity

  1. added a commit that references this issue on Oct 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    • Status
      Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions


    Back | FazBrowse Home | New Git URL