Skip to content

Hand back raw return addresses from the unwind helpers - #831

Open
rkennke wants to merge 5 commits into
mainfrom
fix/prof-16033-helper-contract
Open

rkennke wants to merge 5 commits into
mainfrom
fix/prof-16033-helper-contract

Conversation

@rkennke

@rkennke rkennke commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Addresses PROF-16033. Split out of PROF-15955; unblocked by PROF-16059.

The defect

unwindStub, unwindPrologue, unwindEpilogue and unwindCompiled recover a sender pc from a stack slot or the link register. x86_64 subtracted one from it inside the helper; aarch64 did not. Nothing in the signature said which, so walkVM guessed from the target architecture — that is what recordUnwoundPc and its #ifdef were.

The guess was wrong in a way that mattered. On x86_64 the pc reaching the top of the walk loop was already decremented, so these three — all comparing for exact equality against genuine return addresses — could never match:

  • VMStructs::isContReturnBarrier
  • VMStructs::isContEntryReturnPc
  • VMNMethod::isEntryFrame

Continuation boundaries and entry frames stopped being detected on x86_64. Silently: worse unwinding, no visible failure.

x86_64 was not even self-consistent. unwindPrologue's isFrameComplete arm and unwindCompiled's entry-barrier arm returned the address unadjusted while their siblings subtracted one, so no single rule described what a caller had been handed. That inconsistency is why PROF-15955 could not simply flag these results as return addresses — it would have double-adjusted the majority of branches.

The change

All eleven folded adjustments removed. Every helper returns the address exactly as it sat in the slot; walkVM applies the attribution adjustment itself via the WalkPc it already carries.

lookups (findLibraryByAddress, findFrameDesc) exact-equality consumers
x86_64 before RA-1 ✅ RA-1 ❌
x86_64 after RA-1 ✅ (unchanged) RA ✅ fixed
aarch64 before/after RA-1 ✅ RA ✅

So lookup behaviour is identical everywhere, and the only behavioural delta is x86_64's exact-equality comparisons starting to match.

recordUnwoundPc and its #ifdef __aarch64__ are deleted — the last place the two architectures disagreed. Net −38 lines of production code.

Please push back on this one

unwindCompiled is reached only from getJavaTraceAsync (the legacy AsyncGetCallTrace path). That path now receives raw addresses on x86_64 where it previously got adjusted ones. aarch64 already handed it raw, so this makes the two agree rather than creating a new split — but it is a behaviour change on a path no test covers, and it was the open question when PROF-16033 was first split out. I went with convergence for coherence; a reviewer who knows AGCT should decide whether that is right.

Testing

unwindHelperContract_ut.cpp (new, 5 tests) drives the helpers directly — a fabricated frame, a few bytes of synthetic code, and a 32-byte buffer standing in for an nmethod. No JVM is started, and no __linux__ gate.

That is deliberate. Reaching these helpers through walkVM means getting past vm_thread == NULL (hotspotSupport.cpp:509, which sits above every consumer) and then faking a JavaThread and a CodeHeap range as well. I mapped that cascade before writing this; the further such a fixture drifts from a real JVM layout, the likelier it is to pass for the wrong reason.

test helper branch sender pc read from
UnwindStubAtEntryRecoversTheRawSenderPc unwindStub ip == entry sp[0] / link register
HelperNeverAppliesTheAdjustmentItself unwindStub — states the property arch-independently
UnwindPrologueAtEntryRecoversTheRawSenderPc unwindPrologue ip <= entry sp[0] / link register
UnwindPrologueAfterFrameSetupRecoversTheRawSenderPc unwindPrologue past the push %rbp / stp x29,x30 sp[1], both arches
UnwindEpilogueAtReturnRecoversTheRawSenderPc unwindEpilogue on the ret sp[0] / link register

The AfterFrameSetup one is the interesting branch: it is where x86_64 read a different stack slot than its sibling, and both arches happen to source from sp[1] and pop two, so a single assertion covers both. The adjacent slot holds a distinct sentinel, so an off-by-one-slot fails too, not just an off-by-one-byte.

The two nmethod-taking helpers need entry() and frameSize(), so the fixture points _frame_size_offset / _nmethod_entry_address at its own layout for the duration of the test and restores them in TearDown, via a per-TU VMStructsTestAccessor — the same idiom as hotspotMethodId_ut.cpp and hotspot_crash_protection_ut.cpp.

Mutation-checked: each of the five sites was mutated by reintroducing a folded - 1 there. Every mutation was caught, each by exactly one test.

What is not covered

  • The x86_64 fix is not exercised locally. I develop on aarch64, which is precisely the arch this does not change; my green run proves only that the unchanged path still works. gtest-release-amd64 and the amd64 sanitizer jobs are what actually test this.
  • The symptom, end to end — that a pc from these helpers subsequently matches isEntryFrame/isContReturnBarrier — still needs the full walkVM cascade above. Worth recording on the ticket rather than building for.
  • unwindPrologue's isFrameComplete arm is not covered. That one additionally needs frameCompleteOffset() and a plausible instruction stream, i.e. a good deal more fixture for one more branch.
  • unwindCompiled has no test at all. Found incidentally while mutation-checking: a - 1 reintroduced at hotspotStackFrame_aarch64.cpp:157 — the same branch shape the prologue test covers — is caught by nothing. It is the same signature, so it is reachable with the fixture this PR adds. I left it out because hotspotStackFrame.h:90 marks unwindCompiled for removal once vm becomes the default walking mode, and it is also the helper flagged under Please push back on this one above. Happy to add it if a reviewer would rather have the net there before that removal lands.
  • The nmethod fixture pins the pre-JDK-23 field shape. entry() branches on _nmethod_entry_offset != -1, and the older arm is a single indirection rather than a code_offset/verified_entry_offset pair. What is under test is the addressing convention, not the field layout, but the tests say so explicitly so nobody reads them as covering both nmethod shapes.

Local: full gtestDebug and gtestRelease suites green on aarch64.

🤖 Generated with Claude Code

unwindStub, unwindPrologue, unwindEpilogue and unwindCompiled recover a sender
pc from a stack slot or the link register. x86_64 subtracted one from it
inside the helper; aarch64 did not. Nothing in the signature said which, so
walkVM could only guess from the target architecture, which it did through
recordUnwoundPc and an #ifdef.

The guess was wrong in a way that mattered. On x86_64 the pc reaching the top
of the walk loop was already decremented, so isContReturnBarrier,
isContEntryReturnPc and isEntryFrame -- all comparing for exact equality
against genuine return addresses -- could never match. Continuation boundaries
and entry frames simply stopped being detected there, quietly, producing worse
unwinding rather than any visible failure.

x86_64 was not even self-consistent: unwindPrologue's isFrameComplete arm and
unwindCompiled's entry-barrier arm returned the address unadjusted while their
siblings subtracted one, so no single rule described what a caller had been
handed.

All eleven adjustments are gone. Every helper now returns the address exactly
as it sat in the slot, and walkVM applies the attribution adjustment itself
through the WalkPc it already carries -- so the lookups keep seeing pc - 1 and
the exact-address consumers finally see the real return address.

aarch64 is unchanged: its helpers already returned raw and walkVM already
recorded them as return addresses. The whole behavioural delta is on x86_64.

unwindCompiled is reached only from getJavaTraceAsync, so that path now sees
raw addresses on x86_64 where it saw adjusted ones before. aarch64 already
handed it raw, so this makes the two agree rather than introducing a new
split; calling it out because it is a behaviour change on a legacy path that
no test covers.

unwindHelperContract_ut.cpp pins the contract directly, with no JVM, no
VMStructs and no nmethod -- reaching these helpers through walkVM would need a
fake JavaThread and CodeHeap, and a fixture that far from a real layout starts
passing for the wrong reason. Reintroducing a folded adjustment on either arch
fails both tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rkennke
rkennke requested a review from a team as a code owner September 29, 2026 11:56

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7c21727a7f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread ddprof-lib/src/main/cpp/hotspot/hotspotStackFrame_x64.cpp

@datadog-prod-us1-5 datadog-prod-us1-5 Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bits Code Review: FAIL

The x86_64 AGCT recovery path now retries HotSpot with an unadjusted sender return address. Because that path bypasses WalkPc, recovered callers can be attributed to the instruction after the call and receive the wrong bytecode.

Open Bits AI session

🤖 Bits Code Review · Commit 7c21727 · @DataDog review to ask questions

Comment thread ddprof-lib/src/main/cpp/hotspot/hotspotStackFrame_x64.cpp
@datadog-prod-us1-5

datadog-prod-us1-5 Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Pipelines

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 4ee10c4 | Docs | View more details | Give us feedback!

@dd-octo-sts

dd-octo-sts Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

CI Test Results

Run: #36724788720 | Commit: 85b0d5a | Duration: 16m 0s (longest job)

❌ 1 of 32 test jobs failed

Status Overview

JDK glibc-aarch64/debug glibc-amd64/debug musl-aarch64/debug musl-amd64/debug
8 - ✅ - -
8-ibm - ✅ - -
8-j9 ✅ ✅ - -
8-librca - - ✅ ✅
8-orcl - ✅ - -
11 - ✅ - -
11-j9 ✅ ✅ - -
11-librca - - ✅ ✅
17 ❌ ✅ - -
17-graal ✅ ✅ - -
17-j9 ✅ ✅ - -
17-librca - - ✅ ✅
21 ✅ ✅ - -
21-graal ✅ ✅ - -
21-librca - - ✅ ✅
25 ✅ ✅ - -
25-graal ✅ ✅ - -
25-librca - - ✅ ✅

Legend: ✅ passed | ❌ failed | ⚪ skipped | 🚫 cancelled

Failed Jobs

Summary: Total: 32 | Passed: 31 | Failed: 1


Updated: 2026-09-30 14:11:55 UTC

@dd-octo-sts

dd-octo-sts Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

✅ All 40 integration tests passed

📊 Dashboard · 👷 Pipeline · 📦 4ee10c47

rkennke and others added 4 commits September 29, 2026 14:42
The unwind helper contract tests reached unwindStub only. unwindPrologue
and unwindEpilogue take a VMNMethod*, which left the two helpers that
carried most of the folded `- 1` adjustments untested.

A 32-byte buffer standing in for an nmethod closes that. Only entry() and
frameSize() are read on the branches under test, so the fixture points
VMStructs' field offsets at its own layout for the duration of the test
and restores them afterwards, via a per-TU VMStructsTestAccessor like the
ones in hotspotMethodId_ut.cpp and hotspot_crash_protection_ut.cpp.

Three branches are pinned: pc on the method's entry instruction, pc one
instruction past the push/stp that saved the caller's fp (the branch where
x86_64 read a different stack slot than its sibling), and pc on the `ret`.
Each of the three was mutation-checked by reintroducing the adjustment at
that site; each failure was caught by exactly one test.

The fixture selects entry()'s pre-JDK-23 arm, since that one is a single
indirection. What is under test is the addressing convention rather than
the field layout, but the tests say so, so nobody reads them as covering
both nmethod shapes.

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

The register-less unwindStub/unwindCompiled overloads write the sender
pc into the real ucontext and hand it straight to AsyncGetCallTrace,
which has no WalkPc to apply the attribution adjustment. Now that the
helpers return raw return addresses, subtract one in those overloads on
x86_64, as the helpers used to.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Explain why the helpers return raw return addresses (exact-address
consumers compare them, symbolizers derive the attribution address from
them) instead of describing earlier behavior.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@jbachorik

Copy link
Copy Markdown
Collaborator

Nice reconciliation!

@kaahos kaahos left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks good to me, thanks for the changes!

This branch has not been deployed

No deployments
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.

3 participants