Skip to content

repro: reproducer and root cause for #37 (free(): invalid pointer in the CPU profiler) - #146

Closed
korniltsev-grafanista-yolo-vibecoder239 wants to merge 3 commits into
grafana:mainfrom
korniltsev-grafanista-yolo-vibecoder239:reproduce-issue-37-crash
Closed

repro: reproducer and root cause for #37 (free(): invalid pointer in the CPU profiler)#146
korniltsev-grafanista-yolo-vibecoder239 wants to merge 3 commits into
grafana:mainfrom
korniltsev-grafanista-yolo-vibecoder239:reproduce-issue-37-crash

Conversation

@korniltsev-grafanista-yolo-vibecoder239

Copy link
Copy Markdown
Contributor

Draft — investigation results for #37, not a fix.

Summary

free(): invalid pointer / SIGSEGV in workers running the agent, reported
against 0.8.11/0.8.14 (celery prefork) and again against 1.2.1 (gunicorn sync
workers, Python 3.11). Both reporters' stacks are the same, and reproduce here:

#4  __GI___libc_free (mem=0x18)
#5  anyhow::error::object_drop ()   from .../pyroscope/_native...so
#6  std::sys::backtrace::__rust_begin_short_backtrace ()
#7  core::ops::function::FnOnce::call_once{{vtable.shim}} ()
#8  std::sys::pal::unix::thread::Thread::new::thread_start ()

It is not an ABI mismatch (the issue's original hypothesis), not memory
profiling (predates it; everything here is CPU-only), and not corruption of
pyroscope's own bookkeeping — the heap is intact right up to the bogus free().

Root cause

remoteprocess::ProcessMemory::copy_struct materializes a T out of bytes
read from the profiled process:

fn copy_struct<T: Copy>(&self, addr: usize) -> Result<T, Error> {
    let mut data = vec![0; std::mem::size_of::<T>()];
    self.read(addr, &mut data)?;
    Ok(unsafe { std::ptr::read(data.as_ptr() as *const _) })
}

T: Copy does not mean every bit pattern is a valid T. On Python 3.11
py-spy's _PyInterpreterFrame has pub is_entry: bool at offset 68 — the only
niche in the struct — and rustc uses it as the discriminant of the returned
Result, because there is no room for a tag:

sizeof(Result<v3_11_0::_PyInterpreterFrame, remoteprocess::Error>) == 80
sizeof(v3_11_0::_PyInterpreterFrame)                               == 80

Confirmed by disassembling the shipped wheel
(pyroscope-io==1.2.1, _native.cpython-311-x86_64-linux-gnu.so):

0000000000900f80 <remoteprocess::ProcessMemory::copy_pointer::hfa7f1a9f30c1a49b>:
  900fbb: mov    $0x50,%r8d          ; len = 80 = sizeof(_PyInterpreterFrame)
  900fca: call   *0x29c460(%rip)     ; Process::read(addr, &mut data)
  900fd0: cmpl   $0xf,0x8(%rsp)      ; read() ok?
  900fd5: jne    901004              ;   no -> Err path
  900fd7..900fff: movups x5          ; Ok: copy all 80 target bytes verbatim,
                                     ;     byte 0x44 included, unvalidated
  901004..901012: movups x2          ; Err: 32-byte remoteprocess::Error into [0..32]
  901015: movb   $0x2,0x44(%rbx)     ; <<<< discriminant := 2, stored in `is_entry`

and the caller testing that same byte, in py_spy::stack_trace::get_stack_trace:

  904232: lea    0x100(%rsp),%rdi    ; sret slot for the Result
  904245: call   900f80 <...copy_pointer...>
  90424a: movzbl 0x144(%rsp),%eax    ; ONE byte at slot+0x44 (= 68 = is_entry)
  904252: cmp    $0x2,%al            ; the Err niche value
  904254: je     904be1              ;   -> error path
  90425a: mov    %al,0xb(%rsp)       ;   else keep it as the bool

For contrast, the next call in the same function copies a PyCodeObject
(no niche, 184 bytes) and gets a real tag word, so it cannot be confused:

  90428c: call   8fffd0 <...copy_pointer::h194f4e6da92eb3c4>
  904291: cmpl   $0x1,0x100(%rsp)

So when the sampler reads a stale frame whose byte 68 is 2, copy_pointer
silently returns Err(remoteprocess::Error) whose payload is the 32
interpreter bytes it just read
. py-spy contexts it
("Failed to copy PyFrameObject") into Sample.sampling_errors, and when
pyroscope's consumer thread drops the Sample,
drop_glue<remoteprocess::Error> reads the first word as the enum
discriminant. A CPython object starts with ob_refcnt — a small integer —
which selects a String-owning variant, and the following words are freed as
a heap pointer. That is free(mem=0x18).

Evidence

Four core dumps, three organic and one from the deterministic probe; in every
one the dropped error's payload is a recognizable CPython object:

core error payload free() arg
wheel 1.2.1 {3, &PyCell_Type, 0x55d19f5c6250} the cell's ob_ref
wheel 1.2.1 {3, <type>, 0x564fb95e91d0} ditto
guard build {3, &PyList_Type, 41, ob_item} 0x29 = 41 = that list's ob_size

With a guarding global allocator the ErrorImpl allocation is live and
intact
(magic + correct size, no invalid/double free reported), and its
sibling field is a valid &'static str (len = 0x1c = 28 =
"Failed to copy PyFrameObject"). Only the remoteprocess::Error half is
CPython bytes — the Ok payload was read as an Err.

Typed frames from the debug build:

#5  _native::debug_alloc::dealloc (ptr=0x29, layout=...)
#6  core::ptr::drop_glue<remoteprocess::Error> ()
#7  core::ptr::drop_glue<anyhow::error::ContextError<&str, remoteprocess::Error>> ()
#19 core::ptr::drop_glue<(i32, anyhow::Error)> ()
#23 alloc::vec::{impl#27}::drop<(i32, anyhow::Error), alloc::alloc::Global> ()
#31 std::panicking::catch_unwind<..., _native::pyspy_backend::{impl#1}::initialize::{closure_env#0}>

Why only Python 3.11, and why it is rare

is_entry: bool exists only in the 3.11 bindings; 3.12+ use c_char there.
The frame struct is copied for every frame of every sample, chasing pointers
that go stale constantly, so 3.11 is where this bites — matching both reports
(3.11.14) and matching Python 3.13 never crashing here in 3.3+ process-hours
of the same workload. The remaining niches on 3.12+ sit in interpreter-state
structs that are copied far less often.

Two conditions must coincide in one read: byte 68 must be exactly 2
(rustc picked a single niche value, ~1/256), and the first word must be a
small integer for the drop to select a String variant — usually it is a
pointer, and the bogus error is silently harmless. Hence: bogus sampling
errors are routine, fatal frees are occasional.

Reproducers (in repro37/)

  • Deterministicpoc_niche_confusion.py parks a thread and points its
    frame's previous at a private malloc'd buffer with byte 68 = 2 and a
    String pointer of 0x29. Nothing in the interpreter is corrupted; CPython
    never reads the buffer.

    $ python poc_niche_confusion.py        # 3.11 + pyroscope-io 1.2.1
    Segmentation fault                     # exit 139, seconds, 3/3 runs
    $ NO_PROFILER=1 python poc_niche_confusion.py
    survived                               # exit 0 — only the profiler frees it
    
  • Organicworkload.py + runner.py, no ctypes: deep stacks, code
    objects churned so sampled addresses go stale, freed memory reused with
    pointer-filled data, thread churn. Python 3.11 + unmodified 1.2.1 wheel, CPU
    profiler only, PYTHONMALLOC=malloc, 8–10 workers → ~1 crash per 1–3
    process-hours. PYTHONMALLOC=malloc is an amplifier, not the cause: it makes
    stale reads land on live CPython objects with small refcounts more often.

Fix directions (not included here)

  1. Remove the niche — emit u8/c_char instead of bool in py-spy's
    bindings. Result<T, Error> then gets a real tag and the confusion becomes
    impossible; is_entry is only used as a truthy flag.
  2. Tighten copy_struct's bound so only types valid for every bit pattern can
    be read out of another process (e.g. bytemuck::AnyBitPattern instead of
    T: Copy). This is the real fix — today any future binding with a bool,
    char, enum, reference or NonNull reintroduces the bug.

Both live upstream in py-spy / remoteprocess, so pyroscope would need a
patched pin in rust/Cargo.toml.

What is in this PR

  • repro37/ROOT_CAUSE.md — the full write-up: disassembly, decoded cores,
    per-version niche table, rarity analysis, ruled-out hypotheses, tooling.
  • repro37/ — the reproducers, Docker images (3.11 + 1.2.1, debug-info, ASAN,
    valgrind, guarding-allocator), ingest sinks, gunicorn/fork variants,
    analyze-core.sh.
  • rust/src/debug_alloc.rs — the guarding global allocator used to prove the
    error object was not corrupted. Behind the non-default debug-alloc
    feature; cargo check passes with and without it. Droppable if unwanted.

Core dumps are gitignored; ROOT_CAUSE.md cites them by path with the decoded
contents inline.

Also noticed while looking (separate, not the cause of this issue):
py-spy's copy_string builds Rust chars out of target bytes via
from_raw_parts(... as *const char), which is UB for non-scalar values and
yields Strings holding invalid UTF-8 (poc_hostile_string.py exercises it);
and shorten_filename's short_filenames cache is unbounded.

🤖 Generated with Claude Code

Random `free(): invalid pointer` / SIGSEGV crashes in processes running the
agent, reported against 0.8.11/0.8.14 (celery prefork) and 1.2.1 (gunicorn
sync workers, Python 3.11). Both reporters' stacks are the same:

    grafana#4  __GI___libc_free (mem=0x18)
    grafana#5  anyhow::error::object_drop ()   from pyroscope/_native...so
    grafana#6  std::sys::backtrace::__rust_begin_short_backtrace ()

Root cause: `remoteprocess::ProcessMemory::copy_struct` materializes a `T`
out of bytes read from the profiled process with `ptr::read`, and `T: Copy`
does not mean every bit pattern is a valid `T`. On Python 3.11 py-spy's
`_PyInterpreterFrame` has `is_entry: bool` at offset 68, which rustc uses as
the niche discriminant of the returned `Result<_PyInterpreterFrame,
remoteprocess::Error>` -- both are 80 bytes, so there is no separate tag. The
shipped wheel encodes `Err` as `movb $0x2,0x44(%rbx)` while the `Ok` path
copies all 80 target bytes verbatim, so a stale frame read whose byte 68 is 2
silently becomes an `Err` whose payload is 32 bytes of interpreter memory.
Dropping that error in the py-spy consumer thread then calls `free()` on
whatever those bytes contain.

repro37/ROOT_CAUSE.md has the full analysis: disassembly of both the shipped
wheel and a from-source build, four core dumps decoded, the per-version niche
table explaining why only 3.11 is affected in practice, why the failure is
rare, and fix directions.

repro37/ also contains the reproducer itself: an organic one (~1 crash per
1-3 process-hours on Python 3.11 + pyroscope-io 1.2.1, CPU profiler only) and
poc_niche_confusion.py, which plants the condition and crashes with exit 139
in seconds while a NO_PROFILER=1 control run survives.

rust/src/debug_alloc.rs is the guarding global allocator used during the
investigation to prove nothing corrupted the error object. It is behind the
non-default `debug-alloc` feature and can be dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Signed commits report

3 of 3 commits between main and reproduce-issue-37-crash could not be fully verified:

Commit Author Reason Message
be223ed2 Tolyan Korniltsev unsigned repro: add a reproducer and root-cause analysis for #37
5fb13e27 Tolyan Korniltsev unsigned docs: explain why c_char is not equivalent to bool for #37
0e32ab80 Tolyan Korniltsev unsigned docs: note that #[repr(C)] does not prevent the niche (#37)

This repository requires all commits to be signed. See GitHub docs on commit signature verification.

bool and c_char have identical size and alignment; what differs is the
validity invariant, and therefore whether rustc has spare bit patterns to use
as a niche discriminant. Adds the measured sizes (Result is 80 vs an 80-byte
frame on 3.11, but 88 vs 80 on 3.12 and 96 vs 88 on 3.14) and the generated
code for a non-3.11 frame, which stores the tag in its own word at offset 0
while the payload starts at 0x08.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bindgen emits #[repr(C)] for these structs, which is a natural objection to
the analysis. repr(C) pins the struct's own layout but does not change its
fields' validity invariants, and Result is a repr(Rust) enum whose niche
search descends into the payload recursively.

Adds niche_demo.rs: two repr(C) structs with identical 80-byte layouts,
differing only in bool vs u8, give Result sizes of 80 (niche) and 88
(dedicated tag), and an Ok whose byte 68 is 2 reads back as Err.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
korniltsev-grafanista-yolo-vibecoder239 pushed a commit to korniltsev-grafanista-yolo-vibecoder239/remoteprocess that referenced this pull request Sep 7, 2026
`copy_struct<T: Copy>` materializes a `T` out of bytes read from another
process, but `T: Copy` does not mean that every bit pattern is a valid `T`.
When `T` has a niche, rustc may store the discriminant of the returned
`Result<T, Error>` inside it - and if the niche leaves no room for a real tag,
it will. A stale read whose bytes happen to hit the niche value then comes back
as an `Err` whose payload is 32 bytes of target memory, and dropping that error
frees a pointer that came from the profiled process.

That is the root cause of a `free(): invalid pointer` crash in py-spy on Python
3.11, whose `_PyInterpreterFrame` binding declares `is_entry` as a `bool`: the
struct is 80 bytes with 3 bytes of tail padding, so
`Result<_PyInterpreterFrame, Error>` is 80 bytes too and the tag lives in
`is_entry`. See grafana/pyroscope-python#146 for the
disassembly and the decoded core dumps.

Assert at compile time that copied types have no niche, using the fact that a
niche-free type always makes `Option<T>` strictly larger than `T`. The bound
stays `T: Copy`, so this is not a breaking change: consumers that copy
niche-free types are unaffected, and the ones that aren't were already unsound.
`copy_pointer` is covered because it delegates to `copy_struct`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
korniltsev-grafanista-yolo-vibecoder239 pushed a commit to korniltsev-grafanista-yolo-vibecoder239/remoteprocess that referenced this pull request Sep 7, 2026
`copy_struct<T: Copy>` materializes a `T` out of bytes read from another
process, but `T: Copy` does not mean that every bit pattern is a valid `T`.
When `T` has a niche, rustc may store the discriminant of the returned
`Result<T, Error>` inside it - and if the niche leaves no room for a real tag,
it will. A stale read whose bytes happen to hit the niche value then comes back
as an `Err` whose payload is 32 bytes of target memory, and dropping that error
frees a pointer that came from the profiled process.

That is the root cause of a `free(): invalid pointer` crash in py-spy on Python
3.11, whose `_PyInterpreterFrame` binding declares `is_entry` as a `bool`: the
struct is 80 bytes with 3 bytes of tail padding, so
`Result<_PyInterpreterFrame, Error>` is 80 bytes too and the tag lives in
`is_entry`. See grafana/pyroscope-python#146 for the
disassembly and the decoded core dumps.

Assert at compile time that copied types have no niche, using the fact that a
niche-free type always makes `Option<T>` strictly larger than `T`. The bound
stays `T: Copy`, so this is not a breaking change: consumers that copy
niche-free types are unaffected, and the ones that aren't were already unsound.
`copy_pointer` is covered because it delegates to `copy_struct`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
korniltsev-grafanista-yolo-vibecoder239 pushed a commit to korniltsev-grafanista-yolo-vibecoder239/remoteprocess that referenced this pull request Sep 7, 2026
`copy_struct<T: Copy>` materializes a `T` out of bytes read from another
process, but `T: Copy` does not mean that every bit pattern is a valid `T`.
When `T` has a niche, rustc may store the discriminant of the returned
`Result<T, Error>` inside it - and if the niche leaves no room for a real tag,
it will. A stale read whose bytes happen to hit the niche value then comes back
as an `Err` whose payload is 32 bytes of target memory, and dropping that error
frees a pointer that came from the profiled process.

That is the root cause of a `free(): invalid pointer` crash in py-spy on Python
3.11, whose `_PyInterpreterFrame` binding declares `is_entry` as a `bool`: the
struct is 80 bytes with 3 bytes of tail padding, so
`Result<_PyInterpreterFrame, Error>` is 80 bytes too and the tag lives in
`is_entry`. See grafana/pyroscope-python#146 for the
disassembly and the decoded core dumps.

Assert at compile time that copied types have no niche, using the fact that a
niche-free type always makes `Option<T>` strictly larger than `T`. The bound
stays `T: Copy`, so this is not a breaking change: consumers that copy
niche-free types are unaffected, and the ones that aren't were already unsound.
`copy_pointer` is covered because it delegates to `copy_struct`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
korniltsev-grafanista-yolo-vibecoder239 added a commit to korniltsev-grafanista-yolo-vibecoder239/remoteprocess that referenced this pull request Sep 7, 2026
`copy_struct<T: Copy>` materializes a `T` out of bytes read from another
process, but `T: Copy` does not mean that every bit pattern is a valid `T`.
When `T` has a niche, rustc may store the discriminant of the returned
`Result<T, Error>` inside it - and if the niche leaves no room for a real tag,
it will. A stale read whose bytes happen to hit the niche value then comes back
as an `Err` whose payload is 32 bytes of target memory, and dropping that error
frees a pointer that came from the profiled process.

That is the root cause of a `free(): invalid pointer` crash in py-spy on Python
3.11, whose `_PyInterpreterFrame` binding declares `is_entry` as a `bool`: the
struct is 80 bytes with 3 bytes of tail padding, so
`Result<_PyInterpreterFrame, Error>` is 80 bytes too and the tag lives in
`is_entry`. See grafana/pyroscope-python#146 for the
disassembly and the decoded core dumps.

Assert at compile time that copied types have no niche, using the fact that a
niche-free type always makes `Option<T>` strictly larger than `T`. The bound
stays `T: Copy`, so this is not a breaking change: consumers that copy
niche-free types are unaffected, and the ones that aren't were already unsound.
`copy_pointer` is covered because it delegates to `copy_struct`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
korniltsev-grafanista-yolo-vibecoder239 added a commit to korniltsev-grafanista-yolo-vibecoder239/remoteprocess that referenced this pull request Sep 7, 2026
`copy_struct<T: Copy>` materializes a `T` out of bytes read from another
process, but `T: Copy` does not mean that every bit pattern is a valid `T`.
When `T` has a niche, rustc may store the discriminant of the returned
`Result<T, Error>` inside it - and if the niche leaves no room for a real tag,
it will. A stale read whose bytes happen to hit the niche value then comes back
as an `Err` whose payload is 32 bytes of target memory, and dropping that error
frees a pointer that came from the profiled process.

That is the root cause of a `free(): invalid pointer` crash in py-spy on Python
3.11, whose `_PyInterpreterFrame` binding declares `is_entry` as a `bool`: the
struct is 80 bytes with 3 bytes of tail padding, so
`Result<_PyInterpreterFrame, Error>` is 80 bytes too and the tag lives in
`is_entry`. See grafana/pyroscope-python#146 for the
disassembly and the decoded core dumps.

Assert at compile time that copied types have no niche, using the fact that a
niche-free type always makes `Option<T>` strictly larger than `T`. The bound
stays `T: Copy`, so this is not a breaking change: consumers that copy
niche-free types are unaffected, and the ones that aren't were already unsound.
`copy_pointer` is covered because it delegates to `copy_struct`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@korniltsev-grafanista-yolo-vibecoder239

Copy link
Copy Markdown
Contributor Author

just a repro, nothing to merge

korniltsev-grafanista added a commit to grafana/pyroscope-py-spy that referenced this pull request Sep 8, 2026
`_PyInterpreterFrame.is_entry` (py 3.11) and the `numpy.bool` payload are
read out of the profiled process, where the byte can hold any value. Only
0 and 1 are valid `bool` bit patterns, so materializing one is UB.

It also gave `_PyInterpreterFrame` a niche: the struct was 80 bytes with
`is_entry` its only niche, so rustc stored the `Err` tag of
`Result<_PyInterpreterFrame, remoteprocess::Error>` inside that byte. A
stale read whose byte was neither 0 nor 1 came back as an `Err` holding 32
bytes of target memory, and dropping it freed a pointer from the profiled
process. That is the `free(): invalid pointer` crash root-caused in
grafana/pyroscope-python#146.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
korniltsev-grafanista added a commit to grafana/pyroscope-py-spy that referenced this pull request Sep 8, 2026
`_PyInterpreterFrame.is_entry` (py 3.11) and the `numpy.bool` payload are
read out of the profiled process, where the byte can hold any value. Only
0 and 1 are valid `bool` bit patterns, so materializing one is UB.

It also gave `_PyInterpreterFrame` a niche: the struct was 80 bytes with
`is_entry` its only niche, so rustc stored the `Err` tag of
`Result<_PyInterpreterFrame, remoteprocess::Error>` inside that byte. A
stale read whose byte was neither 0 nor 1 came back as an `Err` holding 32
bytes of target memory, and dropping it freed a pointer from the profiled
process. That is the `free(): invalid pointer` crash root-caused in
grafana/pyroscope-python#146.

`numpy.bool` locals now render as 0/1 instead of true/false, so
`test_local_vars` is updated to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
korniltsev-grafanista added a commit to grafana/pyroscope-py-spy that referenced this pull request Sep 8, 2026
`_PyInterpreterFrame.is_entry` (py 3.11) and the `numpy.bool` payload are
read out of the profiled process, where the byte can hold any value. Only
0 and 1 are valid `bool` bit patterns, so materializing one is UB.

It also gave `_PyInterpreterFrame` a niche: the struct was 80 bytes with
`is_entry` its only niche, so rustc stored the `Err` tag of
`Result<_PyInterpreterFrame, remoteprocess::Error>` inside that byte. A
stale read whose byte was neither 0 nor 1 came back as an `Err` holding 32
bytes of target memory, and dropping it freed a pointer from the profiled
process. That is the `free(): invalid pointer` crash root-caused in
grafana/pyroscope-python#146.

`numpy.bool` locals now render as 0/1 instead of true/false, so
`test_local_vars` is updated to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
korniltsev-grafanista added a commit to grafana/pyroscope-py-spy that referenced this pull request Sep 8, 2026
…V) (#3)

* fix: don't read target-process bytes as `bool`

`_PyInterpreterFrame.is_entry` (py 3.11) and the `numpy.bool` payload are
read out of the profiled process, where the byte can hold any value. Only
0 and 1 are valid `bool` bit patterns, so materializing one is UB.

It also gave `_PyInterpreterFrame` a niche: the struct was 80 bytes with
`is_entry` its only niche, so rustc stored the `Err` tag of
`Result<_PyInterpreterFrame, remoteprocess::Error>` inside that byte. A
stale read whose byte was neither 0 nor 1 came back as an `Err` holding 32
bytes of target memory, and dropping it freed a pointer from the profiled
process. That is the `free(): invalid pointer` crash root-caused in
grafana/pyroscope-python#146.

`numpy.bool` locals now render as 0/1 instead of true/false, so
`test_local_vars` is updated to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* build: use grafana/pyroscope-remoteprocess with the niche guard

Carries benfred/remoteprocess#129, which rejects
niche-having types in `copy_struct`/`copy_vec` at compile time so the
previous commit's bug cannot come back.

Pinned by `rev` to the tip of the fork's still-open PR branch, so the rev
must move to the merge commit before this lands.

The fork is at 0.5.3, which needs `proc-maps ^0.5`, so the lockfile also
takes `libc 0.2.185 -> 0.2.189`, `anyhow 1.0.102 -> 1.0.104`,
`read-process-memory 0.1.6 -> 0.2.0`, and a second `proc-maps` alongside
py-spy's own 0.4.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
korniltsev-grafanista-yolo-vibecoder239 pushed a commit to korniltsev-grafanista-yolo-vibecoder239/pyroscope-python that referenced this pull request Sep 8, 2026
Repoints py-spy at grafana/pyroscope-py-spy@8065105, which carries the
fix for the `free(): invalid pointer` crash root-caused in grafana#146. The fork
stops materializing bytes read out of the profiled process as Rust
`bool`s:

- `v3_11_0::_PyInterpreterFrame.is_entry` becomes `c_char`. As a `bool`
  it was the struct's only niche, so rustc stored the `Err` tag of
  `Result<_PyInterpreterFrame, remoteprocess::Error>` inside it; a stale
  read whose byte was neither 0 nor 1 came back as an `Err` holding 32
  bytes of target memory, and dropping it freed a pointer from the
  profiled process.
- the `numpy.bool` local formatter reads a `u8` instead of a `bool`.

The fork also pins remoteprocess to grafana/pyroscope-remoteprocess,
which rejects niche-having types in `copy_struct`/`copy_vec` at compile
time so the bug cannot come back. That arrives transitively, hence the
lockfile taking `read-process-memory 0.1.6 -> 0.2.0` and a second
`proc-maps` (0.5.0) beside py-spy's direct 0.4.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
korniltsev-grafanista-yolo-vibecoder239 added a commit to korniltsev-grafanista-yolo-vibecoder239/pyroscope-python that referenced this pull request Sep 8, 2026
Repoints py-spy at grafana/pyroscope-py-spy@8065105, which carries the
fix for the `free(): invalid pointer` crash root-caused in grafana#146. The fork
stops materializing bytes read out of the profiled process as Rust
`bool`s:

- `v3_11_0::_PyInterpreterFrame.is_entry` becomes `c_char`. As a `bool`
  it was the struct's only niche, so rustc stored the `Err` tag of
  `Result<_PyInterpreterFrame, remoteprocess::Error>` inside it; a stale
  read whose byte was neither 0 nor 1 came back as an `Err` holding 32
  bytes of target memory, and dropping it freed a pointer from the
  profiled process.
- the `numpy.bool` local formatter reads a `u8` instead of a `bool`.

The fork also pins remoteprocess to grafana/pyroscope-remoteprocess,
which rejects niche-having types in `copy_struct`/`copy_vec` at compile
time so the bug cannot come back. That arrives transitively, hence the
lockfile taking `read-process-memory 0.1.6 -> 0.2.0` and a second
`proc-maps` (0.5.0) beside py-spy's direct 0.4.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
korniltsev-grafanista pushed a commit that referenced this pull request Sep 8, 2026
Repoints py-spy at grafana/pyroscope-py-spy@8065105, which carries the
fix for the `free(): invalid pointer` crash root-caused in #146. The fork
stops materializing bytes read out of the profiled process as Rust
`bool`s:

- `v3_11_0::_PyInterpreterFrame.is_entry` becomes `c_char`. As a `bool`
  it was the struct's only niche, so rustc stored the `Err` tag of
  `Result<_PyInterpreterFrame, remoteprocess::Error>` inside it; a stale
  read whose byte was neither 0 nor 1 came back as an `Err` holding 32
  bytes of target memory, and dropping it freed a pointer from the
  profiled process.
- the `numpy.bool` local formatter reads a `u8` instead of a `bool`.

The fork also pins remoteprocess to grafana/pyroscope-remoteprocess,
which rejects niche-having types in `copy_struct`/`copy_vec` at compile
time so the bug cannot come back. That arrives transitively, hence the
lockfile taking `read-process-memory 0.1.6 -> 0.2.0` and a second
`proc-maps` (0.5.0) beside py-spy's direct 0.4.0.

Co-authored-by: Tolya Korniltsev YOLO vibecoder <264712751+korniltsev-grafanista-yolo-vibecoder239@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants