Skip to content

[lldb] Fix stepping into Objective-C interop ctors #10697

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: swift/release/6.2
Choose a base branch
from

Conversation

felipepiovezan
Copy link

@felipepiovezan felipepiovezan commented May 16, 2025

The first commit is just hoisting helper functions for re-use. The second commit actually solves the problem.

Please read each commit in isolation, especially the message on the second commit.

rdar://146886271

These will be useful to reuse code in upcoming commits.
@felipepiovezan felipepiovezan requested a review from a team as a code owner May 16, 2025 22:18
When constructing an Objective C object of type `Foo` from Swift, this
sequence of function calls is used:

```
  * frame #0: 0x000000010000147c test.out`-[Foo initWithString:](self=0x00006000023ec000, _cmd="initWithString:", value=@"Bar") -[Foo initWithString:]  at Foo.m:9:21
    frame swiftlang#1: 0x00000001000012bc test.out`@nonobjc Foo.init(string:) $sSo3FooC6stringABSS_tcfcTO  at <compiler-generated>:0
    frame swiftlang#2: 0x0000000100001170 test.out`Foo.__allocating_init(string:) $sSo3FooC6stringABSS_tcfC  at Foo.h:0
    frame swiftlang#3: 0x0000000100000ed8 test.out`work() $s4test4workyyF  at main.swift:5:18
```

Frames 1 and 2 are common with pure Swift classes, and LLDB has a Thread
Plan to go from `Foo.allocating_init` -> `Foo.init`.

In the case of Objcetive C interop, `Foo.init` has no user code, and is
annotated with `@nonobjc`. The debugger needs a plan to go from that
code to the Objective C implementation. This is what this patch attempts
to fix by creating a plan that runs to any symbol matching `Foo init`
(this will match all the :withBlah suffixes).

This seems to be the only possible fix for this. While Objective C
constructors are not necessarily called init, the interop layer seems to
assume this.

The only other alternative has some obstacles that could not be easily
overcome. Here's the main idea for that. The assembly for `@nonobjc
Foo.init` looks like (deleted all non branches):

```
test.out`@nonobjc Foo.init(string:):
...
    0x1000012a0 <+20>: bl     0x100001618    ; symbol stub for: Swift.String._bridgeToObjectiveC() -> __C.NSString
...
    0x1000012b8 <+44>: bl     0x100001630    ; symbol stub for: objc_msgSend
...
    0x1000012e8 <+92>: ret
```

If we had more String arguments, there would be more calls to
`_bridgeToObjectiveC`. The call to `objc_msgSend` is the important one,
and LLDB knows how to go from that to the target of the message, LLDB
has ThreadPlans for that. However, setting a breakpoint on
`objc_msgSend` would fail: the calls to `_bridgeToObjectiveC` may also
call `objc_msgSend`, so LLDB would end up in the wrong `objc_msgSend`.
This is not entirely bad, LLDB would step back to `Foo.init`.

Here's the catch: the language runtime refuses to create other plans if
PC is not at the start of the function, which makes sense, as it would
not be able to distinguish if its job was already done previously or
not, unless it had a stateful plan (which it doesn't today).
@felipepiovezan felipepiovezan force-pushed the felipe/wip_step_into_objectivec_interop branch from 75bd162 to 6d84e65 Compare May 16, 2025 22:22
@felipepiovezan
Copy link
Author

@swift-ci test

modules.FindFunctionSymbols(ConstString(target_func), eFunctionNameTypeFull,
sc_list);
if (sc_list.GetSize() != 1 || sc_list[0].symbol == nullptr)
return nullptr;

Choose a reason for hiding this comment

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

Suggested change
return nullptr;
return {};

Copy link
Author

Choose a reason for hiding this comment

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

I'm a bit confused by this suggestion, the function returns a pointer

Choose a reason for hiding this comment

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

It returns a ThreadPlanSP (the success return returns make_shared). Apparently C++ is smart enough to turn nullptr into a default constructed shared pointer, but I agree with Adrian, that's a little magical for my taste.


/// Demangle `symbol_name` and extracts the text at the node described by
/// `node_path`, if it exists.
static std::optional<std::string>

Choose a reason for hiding this comment

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

Is there any value in the optional or can you just use the empty string as null value?

Copy link
Author

Choose a reason for hiding this comment

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

We can change this to implicitly use the empty string as a "failed" value

Choose a reason for hiding this comment

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

Swift doesn't have any "anonymous" entities like C & C++ do?

Choose a reason for hiding this comment

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

Hopefully nobody has anonymous classes!

@felipepiovezan
Copy link
Author

From the test failure:

16:53:31      self.assertIn("-[Foo initWithString:]", thread.frames[0].GetFunctionName())
16:53:31  AssertionError: '-[Foo initWithString:]' not found in 'generic specialization <serialized, Swift.UInt8> of Swift.UnsafeBufferPointer.init(start: Swift.Optional<Swift.UnsafePointer<τ_0_0>>, count: Swift.Int) -> Swift.UnsafeBufferPointer<τ_0_0>'

Standard library with debug info strikes again.

SymbolContextList sc_list;
modules.FindFunctionSymbols(ConstString(target_func), eFunctionNameTypeFull,
sc_list);
if (sc_list.GetSize() != 1 || sc_list[0].symbol == nullptr)

Choose a reason for hiding this comment

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

Why do we care that there's only one? For instance, if this is an ObjC method, two shared libraries can have implementations of the same ObjC class. The runtime will pick which one to use, but we don't actually know from symbols which one that is.
And since this is a thread specific breakpoint, the only way setting a breakpoint on the "wrong" function as well as the "right" would cause trouble is if running from the thunk to the target function called the wrong function, which seems unlikely.

NodePointer demangled_node =
SwiftLanguageRuntime::DemangleSymbolAsNode(symbol_name, ctx);

NodePointer class_node = childAtPath(demangled_node, node_path);

Choose a reason for hiding this comment

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

Even if childAtPath handles null NodePointer arguments, still seem easier to follow if you checked the return here.


/// If sc_list is non-empty, returns a plan that runs to any of its addresses.
/// Otherwise, returns nullptr.
static ThreadPlanSP CreateThreadPlanRunToAnySc(Thread &thread,

Choose a reason for hiding this comment

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

I'd abbreviate SymbolContext to SC not Sc, the latter looks weird.

Choose a reason for hiding this comment

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

Also, this is a generally useful, not swift specific bit of functionality, so it's wrong to have it in a Swift-specific file.

Choose a reason for hiding this comment

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

RunToAnySc is also a really ambitious function name. Maybe RunToSCInList?

bool stop_others) {
std::vector<addr_t> load_addresses;
Target &target = thread.GetProcess()->GetTarget();
for (const SymbolContext &ctor_sc : sc_list) {

Choose a reason for hiding this comment

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

I wouldn't call this ctor_sc, nowhere in this function do you assume the symbol is a ctor, so that's just confusing.

return CreateRunToAddressPlan(thunk_target, thread, stop_others);
}
case ThunkAction::RunToObjcCInteropCtor: {
LLDB_LOG(log, "SwiftLanguageRuntime: running to "

Choose a reason for hiding this comment

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

Can you wait the log output till you've got the class name? It seems useful to know what class we thought we were supposed to run to.

Choose a reason for hiding this comment

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

You could also then log the case where somebody passes you a demangled name you couldn't find the class it should have targeted, which would also be nice to see.

@jimingham
Copy link

This seems like a fine strategy, I had some nit picks but nothing serious.

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