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
Conversation
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>
Contributor
Signed commits report3 of 3 commits between
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>
This was referenced Sep 7, 2026
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>
Contributor
Author
|
just a repro, nothing to merge |
This was referenced Sep 8, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft — investigation results for #37, not a fix.
Summary
free(): invalid pointer/ SIGSEGV in workers running the agent, reportedagainst 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:
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_structmaterializes aTout of bytesread from the profiled process:
T: Copydoes not mean every bit pattern is a validT. On Python 3.11py-spy's
_PyInterpreterFramehaspub is_entry: boolat offset 68 — the onlyniche in the struct — and rustc uses it as the discriminant of the returned
Result, because there is no room for a tag:Confirmed by disassembling the shipped wheel
(
pyroscope-io==1.2.1,_native.cpython-311-x86_64-linux-gnu.so):and the caller testing that same byte, in
py_spy::stack_trace::get_stack_trace: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:
So when the sampler reads a stale frame whose byte 68 is
2,copy_pointersilently returns
Err(remoteprocess::Error)whose payload is the 32interpreter bytes it just read. py-spy contexts it
(
"Failed to copy PyFrameObject") intoSample.sampling_errors, and whenpyroscope's consumer thread drops the
Sample,drop_glue<remoteprocess::Error>reads the first word as the enumdiscriminant. A CPython object starts with
ob_refcnt— a small integer —which selects a
String-owning variant, and the following words are freed asa 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:
free()arg{3, &PyCell_Type, 0x55d19f5c6250}ob_ref{3, <type>, 0x564fb95e91d0}{3, &PyList_Type, 41, ob_item}0x29= 41 = that list'sob_sizeWith a guarding global allocator the
ErrorImplallocation is live andintact (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 theremoteprocess::Errorhalf isCPython bytes — the
Okpayload was read as anErr.Typed frames from the debug build:
Why only Python 3.11, and why it is rare
is_entry: boolexists only in the 3.11 bindings; 3.12+ usec_charthere.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
Stringvariant — usually it is apointer, and the bogus error is silently harmless. Hence: bogus sampling
errors are routine, fatal frees are occasional.
Reproducers (in
repro37/)Deterministic —
poc_niche_confusion.pyparks a thread and points itsframe's
previousat a privatemalloc'd buffer with byte 68 = 2 and aStringpointer of0x29. Nothing in the interpreter is corrupted; CPythonnever reads the buffer.
Organic —
workload.py+runner.py, no ctypes: deep stacks, codeobjects 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–3process-hours.
PYTHONMALLOC=mallocis an amplifier, not the cause: it makesstale reads land on live CPython objects with small refcounts more often.
Fix directions (not included here)
u8/c_charinstead ofboolin py-spy'sbindings.
Result<T, Error>then gets a real tag and the confusion becomesimpossible;
is_entryis only used as a truthy flag.copy_struct's bound so only types valid for every bit pattern canbe read out of another process (e.g.
bytemuck::AnyBitPatterninstead ofT: Copy). This is the real fix — today any future binding with abool,char, enum, reference orNonNullreintroduces 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 theerror object was not corrupted. Behind the non-default
debug-allocfeature;
cargo checkpasses with and without it. Droppable if unwanted.Core dumps are gitignored;
ROOT_CAUSE.mdcites them by path with the decodedcontents inline.
Also noticed while looking (separate, not the cause of this issue):
py-spy's
copy_stringbuilds Rustchars out of target bytes viafrom_raw_parts(... as *const char), which is UB for non-scalar values andyields
Strings holding invalid UTF-8 (poc_hostile_string.pyexercises it);and
shorten_filename'sshort_filenamescache is unbounded.🤖 Generated with Claude Code