[llvm] [IR] Allow direct calls to carry ptrauth operand bundles (PR #204600)

Oskar Wirga via llvm-commits llvm-commits at lists.llvm.org
Wed Jul 29 10:53:15 PDT 2026


https://github.com/oskarwirga updated https://github.com/llvm/llvm-project/pull/204600

>From 62a4f1c35354db3e9ae303d5ab3f916f8479dd56 Mon Sep 17 00:00:00 2001
From: Oskar Wirga <oskar at wirga.com>
Date: Wed, 17 Jun 2026 15:48:22 -0400
Subject: [PATCH 1/2] [FunctionSpecialization] Strip ptrauth bundles from
 devirtualized calls

When building Kotlin/Native (and other arm64e frontends) for arm64e,
FunctionSpecialization fails the IR verifier with:

  Direct call cannot have a ptrauth bundle
    invoke void @"sym"() [ "ptrauth"(i32 0, i64 0) ]
            to label %52 unwind label %55

Root cause: when FunctionSpecialization clones a function with a constant
function-pointer argument substituted into the body, an indirect call

  invoke void %fn_ptr_param() [ "ptrauth"(i32 0, i64 0) ]  ; LEGAL indirect

becomes a direct call after IPSCCP's V->replaceAllUsesWith(Const):

  invoke void @"sym"()        [ "ptrauth"(i32 0, i64 0) ]  ; ILLEGAL direct

The verifier rejects: the ptrauth bundle is only meaningful on indirect
calls (it tells the backend which key/discriminator to use to authenticate
the loaded function pointer before branching). replaceAllUsesWith only
rewrites the called operand; it does not touch operand bundles.

Fix: in FunctionSpecializer::createSpecialization, after cloning the
function, walk the clone body and strip ptrauth operand bundles from any
CallBase whose callee is one of the formal Arguments being specialized
to a Function constant. This pre-empts the IPSCCP rewrite so the bundle
is gone before the indirect-to-direct transition. Other operand bundles
are preserved; non-PAC targets are unaffected (no calls carry ptrauth
bundles).

Test: llvm/test/Transforms/FunctionSpecialization/ptrauth-bundle-strip.ll.
Triggers via -passes="ipsccp<func-spec>" -force-specialization with
callers passing different Function constants (forces the FuncSpec clone
path rather than IPSCCP's in-place propagation). Without the fix, opt
aborts with the verifier error above. With the fix, specialized clones
contain direct calls with no ptrauth bundle; a sibling negative case
confirms bundles are preserved on still-indirect calls.

Note: a related variant of this bug also exists on the IPSCCP in-place
constant-propagation path (when all callers pass the same Function
constant, FuncSpec is bypassed and IPSCCP rewrites the original function
in-place). That path is not addressed here and would need a corresponding
fix in SCCPSolver. The Kotlin/Native trigger is the FuncSpec clone path.

Related to #159480 ([PtrAuth] Add ConstantPtrAuth comparator to
FunctionComparator.cpp), which fixed the sibling MergeFunctions crash for
the same family of arm64e builds.
---
 .../Transforms/IPO/FunctionSpecialization.cpp |  34 +++++
 .../ptrauth-bundle-strip.ll                   | 129 ++++++++++++++++++
 2 files changed, 163 insertions(+)
 create mode 100644 llvm/test/Transforms/FunctionSpecialization/ptrauth-bundle-strip.ll

diff --git a/llvm/lib/Transforms/IPO/FunctionSpecialization.cpp b/llvm/lib/Transforms/IPO/FunctionSpecialization.cpp
index a4844006319c9..a50fe15bfecaf 100644
--- a/llvm/lib/Transforms/IPO/FunctionSpecialization.cpp
+++ b/llvm/lib/Transforms/IPO/FunctionSpecialization.cpp
@@ -1071,6 +1071,40 @@ Function *FunctionSpecializer::createSpecialization(Function *F,
                                                     const SpecSig &S) {
   Function *Clone = cloneCandidateFunction(F, Specializations.size() + 1);
 
+  // IPSCCP will rewrite indirect calls in the clone to direct calls when it
+  // propagates the substituted Function constants via replaceAllUsesWith,
+  // which does not touch operand bundles. The verifier rejects direct calls
+  // carrying a ptrauth bundle, so strip those bundles from any call whose
+  // callee is a formal being specialized to a Function constant.
+  DenseSet<Argument *> ArgsToFunction;
+  for (const ArgInfo &A : S.Args)
+    if (isa<Function>(A.Actual->stripPointerCasts()))
+      ArgsToFunction.insert(Clone->getArg(A.Formal->getArgNo()));
+
+  if (!ArgsToFunction.empty()) {
+    for (BasicBlock &BB : *Clone) {
+      for (Instruction &I : make_early_inc_range(BB)) {
+        auto *CB = dyn_cast<CallBase>(&I);
+        if (!CB)
+          continue;
+        auto *ArgCallee = dyn_cast<Argument>(CB->getCalledOperand());
+        if (!ArgCallee || !ArgsToFunction.contains(ArgCallee))
+          continue;
+        if (!CB->getOperandBundle(LLVMContext::OB_ptrauth))
+          continue;
+
+        SmallVector<OperandBundleDef, 2> NewBundles;
+        CB->getOperandBundlesAsDefs(NewBundles);
+        llvm::erase_if(NewBundles, [](const OperandBundleDef &D) {
+          return D.getTag() == "ptrauth";
+        });
+        CallBase *NewCB = CallBase::Create(CB, NewBundles, CB->getIterator());
+        CB->replaceAllUsesWith(NewCB);
+        CB->eraseFromParent();
+      }
+    }
+  }
+
   // The original function does not neccessarily have internal linkage, but the
   // clone must.
   Clone->setLinkage(GlobalValue::InternalLinkage);
diff --git a/llvm/test/Transforms/FunctionSpecialization/ptrauth-bundle-strip.ll b/llvm/test/Transforms/FunctionSpecialization/ptrauth-bundle-strip.ll
new file mode 100644
index 0000000000000..95c95ec9c7a59
--- /dev/null
+++ b/llvm/test/Transforms/FunctionSpecialization/ptrauth-bundle-strip.ll
@@ -0,0 +1,129 @@
+; RUN: opt -S -passes="ipsccp<func-spec>" -force-specialization < %s | FileCheck %s
+
+;; FunctionSpecialization clones a function with a function-pointer constant
+;; substituted into the body. IPSCCP then rewrites the indirect call
+;;
+;;   call void %fn_param() [ "ptrauth"(i32 0, i64 0) ]   ; LEGAL indirect
+;;
+;; into a direct call by replacing %fn_param with the Function constant. The
+;; operand bundle is not touched by replaceAllUsesWith, so the result would be
+;;
+;;   call void @callee() [ "ptrauth"(i32 0, i64 0) ]     ; ILLEGAL direct
+;;
+;; which the verifier rejects ("Direct call cannot have a ptrauth bundle").
+;; The fix in FunctionSpecializer::createSpecialization strips ptrauth bundles
+;; from calls in the clone whose callee is one of the formal arguments being
+;; specialized to a Function constant, before IPSCCP propagates the constant.
+;;
+;; Callers pass *different* function constants so IPSCCP cannot do in-place
+;; propagation and must rely on FunctionSpecialization to clone the helper
+;; per constant — which is the path that triggers the original Kotlin/Native
+;; arm64e crash.
+
+target triple = "arm64e-apple-ios14.0.0"
+
+ at global_fn_ptr = external global ptr
+
+define void @callee1() {
+entry:
+  ret void
+}
+
+define void @callee2() {
+entry:
+  ret void
+}
+
+;; ---------------------------------------------------------------------------
+;; Positive (call form): bundle MUST be stripped from the devirtualized call.
+;; ---------------------------------------------------------------------------
+
+define internal void @helper_call(ptr %fn) {
+entry:
+  call void %fn() [ "ptrauth"(i32 0, i64 0) ]
+  ret void
+}
+
+define void @caller_call_a() {
+  call void @helper_call(ptr @callee1)
+  ret void
+}
+
+define void @caller_call_b() {
+  call void @helper_call(ptr @callee2)
+  ret void
+}
+
+;; FuncSpec's global .specialized.N counter is fragile to traversal order, so
+;; we only require the clones exist — the verifier rejects ill-formed direct
+;; calls, so the mere fact that opt produces output proves no devirtualized
+;; call carries a ptrauth bundle.
+; CHECK-DAG: @helper_call.specialized
+; CHECK-DAG: @helper_call.specialized
+
+;; ---------------------------------------------------------------------------
+;; Positive (invoke form): bundle MUST be stripped from the devirtualized
+;; invoke. This mirrors the original Kotlin/Native crash shape.
+;; ---------------------------------------------------------------------------
+
+declare i32 @__gxx_personality_v0(...)
+
+define internal void @helper_invoke(ptr %fn) personality ptr @__gxx_personality_v0 {
+entry:
+  invoke void %fn() [ "ptrauth"(i32 0, i64 0) ]
+          to label %cont unwind label %lpad
+
+cont:
+  ret void
+
+lpad:
+  %lp = landingpad { ptr, i32 } cleanup
+  resume { ptr, i32 } %lp
+}
+
+define void @caller_invoke_a() {
+  call void @helper_invoke(ptr @callee1)
+  ret void
+}
+
+define void @caller_invoke_b() {
+  call void @helper_invoke(ptr @callee2)
+  ret void
+}
+
+; CHECK-DAG: @helper_invoke.specialized
+; CHECK-DAG: @helper_invoke.specialized
+
+;; ---------------------------------------------------------------------------
+;; Negative: a ptrauth bundle on a still-indirect call (callee is loaded from
+;; a global, not from the substituted argument) MUST be preserved. Guards
+;; against an over-eager fix that strips bundles unconditionally.
+;; ---------------------------------------------------------------------------
+
+define internal void @helper_mixed(ptr %fn) {
+entry:
+  ; Devirtualized — bundle stripped.
+  call void %fn() [ "ptrauth"(i32 0, i64 0) ]
+  ; Still indirect — bundle preserved.
+  %loaded = load ptr, ptr @global_fn_ptr
+  call void %loaded() [ "ptrauth"(i32 0, i64 0) ]
+  ret void
+}
+
+define void @caller_mixed_a() {
+  call void @helper_mixed(ptr @callee1)
+  ret void
+}
+
+define void @caller_mixed_b() {
+  call void @helper_mixed(ptr @callee2)
+  ret void
+}
+
+; CHECK-DAG: @helper_mixed.specialized
+; CHECK-DAG: @helper_mixed.specialized
+
+;; The mixed helper's still-indirect call (through a loaded fn-pointer)
+;; retains its ptrauth bundle in every clone. Guards against an over-eager
+;; fix that strips bundles unconditionally.
+; CHECK-COUNT-2: call void %{{.*}}() [ "ptrauth"(i32 0, i64 0) ]

>From 7c1e0120cdb28b405a3ca92be70f929cc1395f16 Mon Sep 17 00:00:00 2001
From: Oskar Wirga <oskar at wirga.com>
Date: Thu, 18 Jun 2026 14:52:33 -0400
Subject: [PATCH 2/2] [LLVM] Allow direct calls to carry no-op ptrauth bundles

The verifier check "Direct call cannot have a ptrauth bundle" is invalid:
it can be triggered by Value::replaceAllUsesWith with a Function constant
(e.g. when IPSCCP devirtualizes an indirect call). RAUW with a constant
must not turn valid IR into invalid IR.

Remove the verifier check. In SelectionDAG, GlobalISel IRTranslator, and
CallLowering, fall through to a plain direct call when the callee is a
known Function -- the bundle has no semantic effect (no pointer to
authenticate) and is dropped at lowering. This mirrors the existing
ConstantPtrAuth look-through immediately above each touch site.

Revert the FunctionSpecialization workaround from the previous commit;
no longer needed.

Update PointerAuth.md to document the direct-call no-op semantics.

Tests:
  - llvm/test/Verifier/ptrauth-operand-bundles.ll: drop the direct-call
    rejection case, add CHECK-NOT.
  - Replace ptrauth-bundle-strip.ll with ptrauth-bundle-on-devirt.ll:
    asserts opt does not abort and devirtualized direct calls retain
    their (no-op) bundles.
  - New AArch64 codegen test ptrauth-call-direct-bundle.ll: confirms
    direct call with bundle lowers to `bl callee` across SDAG+GlobalISel
    x Darwin+ELF. CHECK lines via utils/update_llc_test_checks.py.
---
 llvm/docs/PointerAuth.md                      |   7 +
 llvm/lib/CodeGen/GlobalISel/CallLowering.cpp  |   5 +-
 llvm/lib/CodeGen/GlobalISel/IRTranslator.cpp  |   7 +-
 .../SelectionDAG/SelectionDAGBuilder.cpp      |   6 +-
 llvm/lib/IR/Verifier.cpp                      |   4 -
 .../Transforms/IPO/FunctionSpecialization.cpp |  34 -----
 .../AArch64/ptrauth-call-direct-bundle.ll     | 118 ++++++++++++++++
 .../ptrauth-bundle-on-devirt.ll               | 100 ++++++++++++++
 .../ptrauth-bundle-strip.ll                   | 129 ------------------
 llvm/test/Verifier/ptrauth-operand-bundles.ll |   6 +-
 10 files changed, 238 insertions(+), 178 deletions(-)
 create mode 100644 llvm/test/CodeGen/AArch64/ptrauth-call-direct-bundle.ll
 create mode 100644 llvm/test/Transforms/FunctionSpecialization/ptrauth-bundle-on-devirt.ll
 delete mode 100644 llvm/test/Transforms/FunctionSpecialization/ptrauth-bundle-strip.ll

diff --git a/llvm/docs/PointerAuth.md b/llvm/docs/PointerAuth.md
index 84e0af7577c7d..92d7c36b22428 100644
--- a/llvm/docs/PointerAuth.md
+++ b/llvm/docs/PointerAuth.md
@@ -289,6 +289,13 @@ define void @f(void ()* %fp) {
 but with the added guarantee that `%fp_i`, `%fp_auth`, and `%fp_auth_p`
 are not stored to (and reloaded from) memory.
 
+The bundle is well-formed on any call site, including a direct call to a
+`Function`, `GlobalAlias`, or `GlobalIFunc`. On a direct call there is no
+pointer to authenticate, so the bundle is a no-op and is dropped at
+lowering. This shape can arise when an optimization (for example IPSCCP)
+devirtualizes an indirect call by propagating a `Function` constant via
+`Value::replaceAllUsesWith`, which does not touch operand bundles.
+
 
 ### Function Attributes
 
diff --git a/llvm/lib/CodeGen/GlobalISel/CallLowering.cpp b/llvm/lib/CodeGen/GlobalISel/CallLowering.cpp
index a580b78462453..f441230b30717 100644
--- a/llvm/lib/CodeGen/GlobalISel/CallLowering.cpp
+++ b/llvm/lib/CodeGen/GlobalISel/CallLowering.cpp
@@ -165,9 +165,10 @@ bool CallLowering::lowerCall(MachineIRBuilder &MIRBuilder, const CallBase &CB,
   const Value *CalleeV = CB.getCalledOperand()->stripPointerCasts();
 
   // If IRTranslator chose to drop the ptrauth info, we can turn this into
-  // a direct call.
+  // a direct call. The callee is either a ConstantPtrAuth or a Function.
   if (!PAI && CB.countOperandBundlesOfType(LLVMContext::OB_ptrauth)) {
-    CalleeV = cast<ConstantPtrAuth>(CalleeV)->getPointer();
+    if (const auto *CPA = dyn_cast<ConstantPtrAuth>(CalleeV))
+      CalleeV = CPA->getPointer();
     assert(isa<Function>(CalleeV));
   }
 
diff --git a/llvm/lib/CodeGen/GlobalISel/IRTranslator.cpp b/llvm/lib/CodeGen/GlobalISel/IRTranslator.cpp
index 7a8f2a8431f9b..e226527fd20da 100644
--- a/llvm/lib/CodeGen/GlobalISel/IRTranslator.cpp
+++ b/llvm/lib/CodeGen/GlobalISel/IRTranslator.cpp
@@ -2797,11 +2797,10 @@ bool IRTranslator::translateCallBase(const CallBase &CB,
     }
   }
 
+  // Skip PAI for direct calls: the bundle is a no-op on a known Function.
   std::optional<CallLowering::PtrAuthInfo> PAI;
-  if (auto Bundle = CB.getOperandBundle(LLVMContext::OB_ptrauth)) {
-    // Functions should never be ptrauth-called directly.
-    assert(!CB.getCalledFunction() && "invalid direct ptrauth call");
-
+  if (auto Bundle = CB.getOperandBundle(LLVMContext::OB_ptrauth);
+      Bundle && !CB.getCalledFunction()) {
     const Value *Key = Bundle->Inputs[0];
     const Value *Discriminator = Bundle->Inputs[1];
 
diff --git a/llvm/lib/CodeGen/SelectionDAG/SelectionDAGBuilder.cpp b/llvm/lib/CodeGen/SelectionDAG/SelectionDAGBuilder.cpp
index c12998df1c445..eee2179dd122f 100644
--- a/llvm/lib/CodeGen/SelectionDAG/SelectionDAGBuilder.cpp
+++ b/llvm/lib/CodeGen/SelectionDAG/SelectionDAGBuilder.cpp
@@ -9953,8 +9953,10 @@ void SelectionDAGBuilder::LowerCallSiteWithPtrAuthBundle(
       return LowerCallTo(CB, getValue(CalleeCPA->getPointer()), CB.isTailCall(),
                          CB.isMustTailCall(), EHPadBB);
 
-  // Functions should never be ptrauth-called directly.
-  assert(!isa<Function>(CalleeV) && "invalid direct ptrauth call");
+  // Direct call to a known Function: the bundle is a no-op, drop it.
+  if (isa<Function>(CalleeV))
+    return LowerCallTo(CB, getValue(CalleeV), CB.isTailCall(),
+                       CB.isMustTailCall(), EHPadBB);
 
   // Otherwise, do an authenticated indirect call.
   TargetLowering::PtrAuthInfo PAI = {Key->getZExtValue(),
diff --git a/llvm/lib/IR/Verifier.cpp b/llvm/lib/IR/Verifier.cpp
index e247582bb57ea..119a86f3b0bae 100644
--- a/llvm/lib/IR/Verifier.cpp
+++ b/llvm/lib/IR/Verifier.cpp
@@ -4302,10 +4302,6 @@ void Verifier::visitCallBase(CallBase &Call) {
     }
   }
 
-  // Verify that callee and callsite agree on whether to use pointer auth.
-  Check(!(Call.getCalledFunction() && FoundPtrauthBundle),
-        "Direct call cannot have a ptrauth bundle", Call);
-
   // Verify that each inlinable callsite of a debug-info-bearing function in a
   // debug-info-bearing function has a debug location attached to it. Failure to
   // do so causes assertion failures when the inliner sets up inline scope info
diff --git a/llvm/lib/Transforms/IPO/FunctionSpecialization.cpp b/llvm/lib/Transforms/IPO/FunctionSpecialization.cpp
index a50fe15bfecaf..a4844006319c9 100644
--- a/llvm/lib/Transforms/IPO/FunctionSpecialization.cpp
+++ b/llvm/lib/Transforms/IPO/FunctionSpecialization.cpp
@@ -1071,40 +1071,6 @@ Function *FunctionSpecializer::createSpecialization(Function *F,
                                                     const SpecSig &S) {
   Function *Clone = cloneCandidateFunction(F, Specializations.size() + 1);
 
-  // IPSCCP will rewrite indirect calls in the clone to direct calls when it
-  // propagates the substituted Function constants via replaceAllUsesWith,
-  // which does not touch operand bundles. The verifier rejects direct calls
-  // carrying a ptrauth bundle, so strip those bundles from any call whose
-  // callee is a formal being specialized to a Function constant.
-  DenseSet<Argument *> ArgsToFunction;
-  for (const ArgInfo &A : S.Args)
-    if (isa<Function>(A.Actual->stripPointerCasts()))
-      ArgsToFunction.insert(Clone->getArg(A.Formal->getArgNo()));
-
-  if (!ArgsToFunction.empty()) {
-    for (BasicBlock &BB : *Clone) {
-      for (Instruction &I : make_early_inc_range(BB)) {
-        auto *CB = dyn_cast<CallBase>(&I);
-        if (!CB)
-          continue;
-        auto *ArgCallee = dyn_cast<Argument>(CB->getCalledOperand());
-        if (!ArgCallee || !ArgsToFunction.contains(ArgCallee))
-          continue;
-        if (!CB->getOperandBundle(LLVMContext::OB_ptrauth))
-          continue;
-
-        SmallVector<OperandBundleDef, 2> NewBundles;
-        CB->getOperandBundlesAsDefs(NewBundles);
-        llvm::erase_if(NewBundles, [](const OperandBundleDef &D) {
-          return D.getTag() == "ptrauth";
-        });
-        CallBase *NewCB = CallBase::Create(CB, NewBundles, CB->getIterator());
-        CB->replaceAllUsesWith(NewCB);
-        CB->eraseFromParent();
-      }
-    }
-  }
-
   // The original function does not neccessarily have internal linkage, but the
   // clone must.
   Clone->setLinkage(GlobalValue::InternalLinkage);
diff --git a/llvm/test/CodeGen/AArch64/ptrauth-call-direct-bundle.ll b/llvm/test/CodeGen/AArch64/ptrauth-call-direct-bundle.ll
new file mode 100644
index 0000000000000..e9cf91aafef5f
--- /dev/null
+++ b/llvm/test/CodeGen/AArch64/ptrauth-call-direct-bundle.ll
@@ -0,0 +1,118 @@
+; NOTE: Assertions have been autogenerated by utils/update_llc_test_checks.py
+; RUN: llc -mtriple arm64e-apple-darwin -o - %s -verify-machineinstrs \
+; RUN:   | FileCheck %s --check-prefixes=CHECK,DARWIN
+
+; RUN: llc -mtriple aarch64-linux-gnu -mattr=+pauth -o - %s -verify-machineinstrs \
+; RUN:   | FileCheck %s --check-prefixes=CHECK,ELF
+
+; RUN: llc -mtriple arm64e-apple-darwin -o - %s -verify-machineinstrs \
+; RUN:   -global-isel -global-isel-abort=1 \
+; RUN:   | FileCheck %s --check-prefixes=CHECK,DARWIN
+
+; RUN: llc -mtriple aarch64-linux-gnu -mattr=+pauth -o - %s -verify-machineinstrs \
+; RUN:   -global-isel -global-isel-abort=1 \
+; RUN:   | FileCheck %s --check-prefixes=CHECK,ELF
+
+;; A direct call carrying a ptrauth operand bundle must lower to a plain
+;; direct branch — the bundle is meaningless when the callee is statically
+;; known (no pointer to authenticate). This shape arises naturally when
+;; IPSCCP devirtualizes an indirect call by replacing the called operand
+;; with a Function constant via replaceAllUsesWith; RAUW does not touch
+;; operand bundles. See llvm/llvm-project#204600.
+
+declare void @callee()
+
+define void @test_direct_call_ia_0() {
+; DARWIN-LABEL: test_direct_call_ia_0:
+; DARWIN:       ; %bb.0:
+; DARWIN-NEXT:    stp x29, x30, [sp, #-16]! ; 16-byte Folded Spill
+; DARWIN-NEXT:    .cfi_def_cfa_offset 16
+; DARWIN-NEXT:    .cfi_offset w30, -8
+; DARWIN-NEXT:    .cfi_offset w29, -16
+; DARWIN-NEXT:    bl _callee
+; DARWIN-NEXT:    ldp x29, x30, [sp], #16 ; 16-byte Folded Reload
+; DARWIN-NEXT:    ret
+;
+; ELF-LABEL: test_direct_call_ia_0:
+; ELF:       // %bb.0:
+; ELF-NEXT:    str x30, [sp, #-16]! // 8-byte Folded Spill
+; ELF-NEXT:    .cfi_def_cfa_offset 16
+; ELF-NEXT:    .cfi_offset w30, -16
+; ELF-NEXT:    bl callee
+; ELF-NEXT:    ldr x30, [sp], #16 // 8-byte Folded Reload
+; ELF-NEXT:    ret
+  call void @callee() [ "ptrauth"(i32 0, i64 0) ]
+  ret void
+}
+
+define void @test_direct_call_ib_0() {
+; DARWIN-LABEL: test_direct_call_ib_0:
+; DARWIN:       ; %bb.0:
+; DARWIN-NEXT:    stp x29, x30, [sp, #-16]! ; 16-byte Folded Spill
+; DARWIN-NEXT:    .cfi_def_cfa_offset 16
+; DARWIN-NEXT:    .cfi_offset w30, -8
+; DARWIN-NEXT:    .cfi_offset w29, -16
+; DARWIN-NEXT:    bl _callee
+; DARWIN-NEXT:    ldp x29, x30, [sp], #16 ; 16-byte Folded Reload
+; DARWIN-NEXT:    ret
+;
+; ELF-LABEL: test_direct_call_ib_0:
+; ELF:       // %bb.0:
+; ELF-NEXT:    str x30, [sp, #-16]! // 8-byte Folded Spill
+; ELF-NEXT:    .cfi_def_cfa_offset 16
+; ELF-NEXT:    .cfi_offset w30, -16
+; ELF-NEXT:    bl callee
+; ELF-NEXT:    ldr x30, [sp], #16 // 8-byte Folded Reload
+; ELF-NEXT:    ret
+  call void @callee() [ "ptrauth"(i32 1, i64 0) ]
+  ret void
+}
+
+define void @test_direct_call_ia_imm() {
+; DARWIN-LABEL: test_direct_call_ia_imm:
+; DARWIN:       ; %bb.0:
+; DARWIN-NEXT:    stp x29, x30, [sp, #-16]! ; 16-byte Folded Spill
+; DARWIN-NEXT:    .cfi_def_cfa_offset 16
+; DARWIN-NEXT:    .cfi_offset w30, -8
+; DARWIN-NEXT:    .cfi_offset w29, -16
+; DARWIN-NEXT:    bl _callee
+; DARWIN-NEXT:    ldp x29, x30, [sp], #16 ; 16-byte Folded Reload
+; DARWIN-NEXT:    ret
+;
+; ELF-LABEL: test_direct_call_ia_imm:
+; ELF:       // %bb.0:
+; ELF-NEXT:    str x30, [sp, #-16]! // 8-byte Folded Spill
+; ELF-NEXT:    .cfi_def_cfa_offset 16
+; ELF-NEXT:    .cfi_offset w30, -16
+; ELF-NEXT:    bl callee
+; ELF-NEXT:    ldr x30, [sp], #16 // 8-byte Folded Reload
+; ELF-NEXT:    ret
+  call void @callee() [ "ptrauth"(i32 0, i64 42) ]
+  ret void
+}
+
+define void @test_direct_tailcall_ia_0() {
+; DARWIN-LABEL: test_direct_tailcall_ia_0:
+; DARWIN:       ; %bb.0:
+; DARWIN-NEXT:    b _callee
+;
+; ELF-LABEL: test_direct_tailcall_ia_0:
+; ELF:       // %bb.0:
+; ELF-NEXT:    b callee
+  tail call void @callee() [ "ptrauth"(i32 0, i64 0) ]
+  ret void
+}
+
+define void @test_direct_tailcall_ib_imm() {
+; DARWIN-LABEL: test_direct_tailcall_ib_imm:
+; DARWIN:       ; %bb.0:
+; DARWIN-NEXT:    b _callee
+;
+; ELF-LABEL: test_direct_tailcall_ib_imm:
+; ELF:       // %bb.0:
+; ELF-NEXT:    b callee
+  tail call void @callee() [ "ptrauth"(i32 1, i64 42) ]
+  ret void
+}
+;; NOTE: These prefixes are unused and the list is autogenerated. Do not add tests below this line:
+; CHECK: {{.*}}
diff --git a/llvm/test/Transforms/FunctionSpecialization/ptrauth-bundle-on-devirt.ll b/llvm/test/Transforms/FunctionSpecialization/ptrauth-bundle-on-devirt.ll
new file mode 100644
index 0000000000000..323c89903456f
--- /dev/null
+++ b/llvm/test/Transforms/FunctionSpecialization/ptrauth-bundle-on-devirt.ll
@@ -0,0 +1,100 @@
+; RUN: opt -S -passes="ipsccp<func-spec>" -force-specialization < %s | FileCheck %s
+
+;; After FunctionSpecialization clones a helper and IPSCCP RAUWs the called
+;; operand with a Function constant, the original ptrauth bundle remains on
+;; the now-direct call. The verifier must accept this and opt must not abort.
+;; Distinct constants per caller force the FuncSpec clone path.
+
+target triple = "arm64e-apple-ios14.0.0"
+
+ at global_fn_ptr = external global ptr
+
+define void @callee1() {
+entry:
+  ret void
+}
+
+define void @callee2() {
+entry:
+  ret void
+}
+
+;; Call form: devirtualized direct call retains its (no-op) bundle.
+
+define internal void @helper_call(ptr %fn) {
+entry:
+  call void %fn() [ "ptrauth"(i32 0, i64 0) ]
+  ret void
+}
+
+define void @caller_call_a() {
+  call void @helper_call(ptr @callee1)
+  ret void
+}
+
+define void @caller_call_b() {
+  call void @helper_call(ptr @callee2)
+  ret void
+}
+
+;; .specialized.N suffix is traversal-order-dependent; just check both exist.
+; CHECK-DAG: @helper_call.specialized
+; CHECK-DAG: @helper_call.specialized
+
+;; Invoke form: same shape with an unwind edge.
+
+declare i32 @__gxx_personality_v0(...)
+
+define internal void @helper_invoke(ptr %fn) personality ptr @__gxx_personality_v0 {
+entry:
+  invoke void %fn() [ "ptrauth"(i32 0, i64 0) ]
+          to label %cont unwind label %lpad
+
+cont:
+  ret void
+
+lpad:
+  %lp = landingpad { ptr, i32 } cleanup
+  resume { ptr, i32 } %lp
+}
+
+define void @caller_invoke_a() {
+  call void @helper_invoke(ptr @callee1)
+  ret void
+}
+
+define void @caller_invoke_b() {
+  call void @helper_invoke(ptr @callee2)
+  ret void
+}
+
+; CHECK-DAG: @helper_invoke.specialized
+; CHECK-DAG: @helper_invoke.specialized
+
+;; Mixed form: bundle on a still-indirect call must be preserved.
+
+define internal void @helper_mixed(ptr %fn) {
+entry:
+  call void %fn() [ "ptrauth"(i32 0, i64 0) ]
+  %loaded = load ptr, ptr @global_fn_ptr
+  call void %loaded() [ "ptrauth"(i32 0, i64 0) ]
+  ret void
+}
+
+define void @caller_mixed_a() {
+  call void @helper_mixed(ptr @callee1)
+  ret void
+}
+
+define void @caller_mixed_b() {
+  call void @helper_mixed(ptr @callee2)
+  ret void
+}
+
+; CHECK-DAG: @helper_mixed.specialized
+; CHECK-DAG: @helper_mixed.specialized
+
+;; The still-indirect call in each mixed clone retains its ptrauth bundle.
+;; (The devirtualized direct call's bundle is dropped from this CHECK
+;; pattern by the `%{{.*}}` matcher — direct calls use `@name`, not `%reg`.)
+; CHECK-COUNT-2: call void %{{.*}}() [ "ptrauth"(i32 0, i64 0) ]
diff --git a/llvm/test/Transforms/FunctionSpecialization/ptrauth-bundle-strip.ll b/llvm/test/Transforms/FunctionSpecialization/ptrauth-bundle-strip.ll
deleted file mode 100644
index 95c95ec9c7a59..0000000000000
--- a/llvm/test/Transforms/FunctionSpecialization/ptrauth-bundle-strip.ll
+++ /dev/null
@@ -1,129 +0,0 @@
-; RUN: opt -S -passes="ipsccp<func-spec>" -force-specialization < %s | FileCheck %s
-
-;; FunctionSpecialization clones a function with a function-pointer constant
-;; substituted into the body. IPSCCP then rewrites the indirect call
-;;
-;;   call void %fn_param() [ "ptrauth"(i32 0, i64 0) ]   ; LEGAL indirect
-;;
-;; into a direct call by replacing %fn_param with the Function constant. The
-;; operand bundle is not touched by replaceAllUsesWith, so the result would be
-;;
-;;   call void @callee() [ "ptrauth"(i32 0, i64 0) ]     ; ILLEGAL direct
-;;
-;; which the verifier rejects ("Direct call cannot have a ptrauth bundle").
-;; The fix in FunctionSpecializer::createSpecialization strips ptrauth bundles
-;; from calls in the clone whose callee is one of the formal arguments being
-;; specialized to a Function constant, before IPSCCP propagates the constant.
-;;
-;; Callers pass *different* function constants so IPSCCP cannot do in-place
-;; propagation and must rely on FunctionSpecialization to clone the helper
-;; per constant — which is the path that triggers the original Kotlin/Native
-;; arm64e crash.
-
-target triple = "arm64e-apple-ios14.0.0"
-
- at global_fn_ptr = external global ptr
-
-define void @callee1() {
-entry:
-  ret void
-}
-
-define void @callee2() {
-entry:
-  ret void
-}
-
-;; ---------------------------------------------------------------------------
-;; Positive (call form): bundle MUST be stripped from the devirtualized call.
-;; ---------------------------------------------------------------------------
-
-define internal void @helper_call(ptr %fn) {
-entry:
-  call void %fn() [ "ptrauth"(i32 0, i64 0) ]
-  ret void
-}
-
-define void @caller_call_a() {
-  call void @helper_call(ptr @callee1)
-  ret void
-}
-
-define void @caller_call_b() {
-  call void @helper_call(ptr @callee2)
-  ret void
-}
-
-;; FuncSpec's global .specialized.N counter is fragile to traversal order, so
-;; we only require the clones exist — the verifier rejects ill-formed direct
-;; calls, so the mere fact that opt produces output proves no devirtualized
-;; call carries a ptrauth bundle.
-; CHECK-DAG: @helper_call.specialized
-; CHECK-DAG: @helper_call.specialized
-
-;; ---------------------------------------------------------------------------
-;; Positive (invoke form): bundle MUST be stripped from the devirtualized
-;; invoke. This mirrors the original Kotlin/Native crash shape.
-;; ---------------------------------------------------------------------------
-
-declare i32 @__gxx_personality_v0(...)
-
-define internal void @helper_invoke(ptr %fn) personality ptr @__gxx_personality_v0 {
-entry:
-  invoke void %fn() [ "ptrauth"(i32 0, i64 0) ]
-          to label %cont unwind label %lpad
-
-cont:
-  ret void
-
-lpad:
-  %lp = landingpad { ptr, i32 } cleanup
-  resume { ptr, i32 } %lp
-}
-
-define void @caller_invoke_a() {
-  call void @helper_invoke(ptr @callee1)
-  ret void
-}
-
-define void @caller_invoke_b() {
-  call void @helper_invoke(ptr @callee2)
-  ret void
-}
-
-; CHECK-DAG: @helper_invoke.specialized
-; CHECK-DAG: @helper_invoke.specialized
-
-;; ---------------------------------------------------------------------------
-;; Negative: a ptrauth bundle on a still-indirect call (callee is loaded from
-;; a global, not from the substituted argument) MUST be preserved. Guards
-;; against an over-eager fix that strips bundles unconditionally.
-;; ---------------------------------------------------------------------------
-
-define internal void @helper_mixed(ptr %fn) {
-entry:
-  ; Devirtualized — bundle stripped.
-  call void %fn() [ "ptrauth"(i32 0, i64 0) ]
-  ; Still indirect — bundle preserved.
-  %loaded = load ptr, ptr @global_fn_ptr
-  call void %loaded() [ "ptrauth"(i32 0, i64 0) ]
-  ret void
-}
-
-define void @caller_mixed_a() {
-  call void @helper_mixed(ptr @callee1)
-  ret void
-}
-
-define void @caller_mixed_b() {
-  call void @helper_mixed(ptr @callee2)
-  ret void
-}
-
-; CHECK-DAG: @helper_mixed.specialized
-; CHECK-DAG: @helper_mixed.specialized
-
-;; The mixed helper's still-indirect call (through a loaded fn-pointer)
-;; retains its ptrauth bundle in every clone. Guards against an over-eager
-;; fix that strips bundles unconditionally.
-; CHECK-COUNT-2: call void %{{.*}}() [ "ptrauth"(i32 0, i64 0) ]
diff --git a/llvm/test/Verifier/ptrauth-operand-bundles.ll b/llvm/test/Verifier/ptrauth-operand-bundles.ll
index 7aa5a22f7816f..bb92f2947df5f 100644
--- a/llvm/test/Verifier/ptrauth-operand-bundles.ll
+++ b/llvm/test/Verifier/ptrauth-operand-bundles.ll
@@ -20,9 +20,9 @@ define void @test_ptrauth_bundle(i64 %arg0, i32 %arg1, ptr %arg2) {
 ; CHECK-NEXT: call void %arg2() [ "ptrauth"(i32 42, i32 120) ]
   call void %arg2() [ "ptrauth"(i32 42, i32 120) ]
 
-; CHECK: Direct call cannot have a ptrauth bundle
-; CHECK-NEXT: call void @g() [ "ptrauth"(i32 42, i64 120) ]
-  call void @g() [ "ptrauth"(i32 42, i64 120) ]
+; Direct call carrying a ptrauth bundle is legal IR; the backend drops it.
+; CHECK-NOT: Direct call cannot have a ptrauth bundle
+  call void @g() [ "ptrauth"(i32 42, i64 120) ]      ; OK
 
 ; CHECK-NOT: call void
   call void %arg2() [ "ptrauth"(i32 42, i64 120) ]   ; OK



More information about the llvm-commits mailing list