Skip to content

fix: paths and time are two unwinders on one stack, and cannot both run - #23

Merged
sotashimozono merged 1 commit into
mainfrom
fix/paths-and-timing-cannot-both-run
Sep 6, 2026
Merged

fix: paths and time are two unwinders on one stack, and cannot both run#23
sotashimozono merged 1 commit into
mainfrom
fix/paths-and-timing-cannot-both-run

Conversation

@sotashimozono

Copy link
Copy Markdown
Member

record crashed. Not "returned a wrong number" — signal 11, no Julia backtrace, roughly one full
suite run in five.
It reached CI twice before it was caught, both times a few lines after the
threaded record in test_spec_profile.jl.

The cause is a conjunction, and each half was measured alone

Capturing a call path calls backtrace(), which unwinds the calling thread. The timing backend's
sampler unwinds those same threads from outside. Two unwinders on one stack.

1.12.7, 150 threaded records per run:

paths timing crashed
on off 0 / 9
off on 0 / 4
on on 2 / 4

The first stress written for this missed it: 600 records on -t4, but the work inside them
was single-threaded, so backtrace() was only ever called from the thread the sampler was least
likely to be walking. Adding Threads.@threads to the recorded block is what turned a guess into
a measurement — and it is why the earlier "Profile.init after stop_timer is a use-after-free"
hypothesis was dropped rather than shipped: it did not survive its own stress.

What changes

  • Asking for both is refused, with the measurement in the message rather than the word
    "unsupported".
  • The default moves to paths = true, timing = false — the instrument that needs no sampler, and
    so has no global side effect on Profile's sampling interval either.
  • record(f; paths = false, timing = true) is the other one.

The spec's timing claims pass that keyword explicitly now, and a new testset pins the refusal with
both single-instrument calls as controls: a refusal of the pair is not a refusal of either
half, and without the controls those two readings are the same test.

Nothing else in the package asked for both — stamp takes the default, assert_clean asks for
neither.

Ordering

PR #22 is red on the same crash, not on its own change. It should go green once this lands.

🤖 Generated with Claude Code

`record` crashed. Not "returned a wrong number" — signal 11, no Julia backtrace, roughly one full
suite run in five. It reached CI twice before it was caught, and both times the expression it died
in was in `test_spec_profile.jl`'s merge region, a few lines after the threaded record.

The cause is a **conjunction**, and each half was measured alone before the pair was refused.
Capturing a call path calls `backtrace()`, which unwinds the calling thread; the timing backend's
sampler unwinds those same threads from outside. Two unwinders on one stack. 1.12.7, 150 threaded
records per run:

    paths alone   0/9 crashed
    timing alone  0/4
    both          2/4   segmentation fault, no Julia backtrace

The first stress written for this missed it: it ran 600 records on `-t4` but the *work* inside
them was single-threaded, so `backtrace()` was only ever called from the thread the sampler was
least likely to be walking. Adding `Threads.@threads` to the recorded block is what made it a
measurement instead of a guess.

So the pair is refused rather than risked, and the refusal names the measurement rather than
saying "unsupported". The default moves to `paths = true, timing = false` — the instrument that
needs no sampler, and therefore has no global side effect on `Profile`'s sampling interval either.
`record(f; paths = false, timing = true)` is the other one.

The spec's timing claims now pass that keyword explicitly, and a new testset pins the refusal with
both single-instrument calls as controls: a refusal of the PAIR is not a refusal of either half,
and without the controls those two readings are the same test.

Nothing else in the package asked for both: `stamp` takes the default, `assert_clean` asks for
neither.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the bug Something isn't working label Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📚 Docs preview: https://codes.sota-shimozono.com/ExperimentalAPI.jl/previews/PR23/

(updates on each push to this PR)

@codecov

codecov Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@sotashimozono
sotashimozono merged commit 8bbf2f7 into main Sep 6, 2026
14 checks passed
@sotashimozono
sotashimozono deleted the fix/paths-and-timing-cannot-both-run branch September 6, 2026 04:27
sotashimozono added a commit that referenced this pull request Sep 6, 2026
)

`entered()` answers this about a whole process and `record(() -> f(x))` about one call. The macro
earns its place by knowing two things a closure cannot: the source text of the expression, and the
line it was written on.

    julia> ExperimentalAPI.@entered sweep(model; βs = 0.05:0.05:2.0)
    ┌ @entered sweep(model; βs = 0.05:0.05:2.0)   at sweep.jl:42
    │   MyPkg.energy       ×10000 — convergence not established below β ≈ 0.1
    │   MyPkg.correlator   ×  500 — edge cases at zero separation untested
    └ 15 of 17 observable marked definitions were not entered
    0.42713…

It returns the value of the expression, so it drops into existing code the way `@time` does. The
last line is the point: "entered nothing" and "nothing is marked anywhere" are different states,
and a package that has not adopted this yet is in the second one.

Pinned by tests that nothing else would catch: the expression is evaluated **exactly once**
(verified by mutation — splicing it twice makes the test read `2 == 1`, and the count is the
answer), the value comes back rather than the record, the location is the caller's, and recording
is not left on.

Two deliberate omissions, both in the docstring. Time, because capturing a path and running the
sampler cannot happen in one block (#23). And the route, which was implemented and then removed:
a captured path is a list of frame names and Base's higher-order functions are in it — three names
the reader wrote and seven they did not. Separating them needs `paths` to carry which module each
frame came from, which is a change to what `Hit.paths` means.

`public`, not exported — `@experimental` remains the only exported name.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant