SE-0419: Swift Backtracing API

Hello Swift community,

The review of "SE-0419 Swift Backtracing API" begins now and runs through February 6, 2024. The proposal is available here:

swift-evolution/proposals/0419-backtrace-api.md at main · apple/swift-evolution · GitHub

Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to the review manager by email. When emailing the review manager directly, please include "SE-0419" in the subject line.

Trying it out

This is implemented on main under the placeholder module name _Backtracing.

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at

swift-evolution/process.md at main · apple/swift-evolution · GitHub

Thank you all for your contributions to Swift,

—Steve Canon
Review Manager

18 Likes

Keen to have backtrace functions, particularly ones that understand async tasks correctly.

Some of the decisions in the proposed API seem strange to me.

Address is deliberately opaque, and generally used as any Address. I presume that this is so that backtraces can be portable between platforms (eg. in a distributed actors scenario?), and that (possibly even within a single backtrace?), addresses might vary in their details. Yet Image returns values as some Address, meaning that there can only be a single Address type valid for Image on any given platform. Why are addresses designed for portability, but images aren't? To me it would make sense if Image was generic on Address, for example, but then you'd probably have to make bits of Backtrace generic on Address, and make explicit ranges of frames with different address types? Or maybe Image just needs to return any Address?

Address is designed to be convertible to & from an integer type, but deliberately avoids telling you statically which integer type it's convertible to. This means that everybody who wants to interact numerically with any Address has to do a big switch address.bitWidth and support all the known integral types, as well as have a fallback when none of the known integral types work. Would it not be better to have Address have an associatedtype IntegerRepresentation: BinaryInteger or whatever, and allow the existential to be opened to process the value as an integer? It seems like it would allow for much more concise and reliable code for the consumer. You could still leave the bitWidth in place to allow both options.

Speaking of integer-convertibility, what about platforms where pointers are not integers? I know Rust's had some fun with that: Rust's Unsafe Pointer Types Need An Overhaul - Faultlore. I don't know whether it's ever applicable to backtraces (in JITted code, perhaps?) but it seems like it should be considered — maybe we shouldn't have the integer-convertibility functionality at all, and only allow string conversions?

I don't love exposing UUID as [UInt8]. Seems like a good sign that UUID should move from Foundation to the stdlib? Or the stdlib could get its own GUID type to avoid the problem... either way, a fixed-size struct, some utility functions to convert to strings in standard formats, etc. would be very welcome.

SharedCacheInfo seems very specific to Apple OSes, and maybe it should be named as such? And why is it both optional, and contains a boolean property to say whether it's present? Surely it can always return nil if absent?

5 Likes

It would be immensely useful to allow initializing an instance of Backtrace from arbitrary sequences of addresses (subject to whatever reasonable constraints might apply, of course.) swift-testing currently needs to capture backtraces and we'd like to take advantage of the symbolication work here, but the backtraces in question may not come from Backtrace.capture().

Codability support would also be very useful!

5 Likes

This is an interesting proposal. A couple of high-level questions first, then specific questions about API.

First, it is often necessary to forge an Address and capture a backtrace from that. This is the case if capturing a backtrace in, say, another thread. This might tie into @grynspan's comments where you can import your own backtrace into Swift's API and have it operate on it as if it were native.

While I am more than happy to have explicit API for identifying the shared cache and calling into CoreSymbolication, these seem like a lot of Mac-specific APIs in something that is oestensibly cross-platform. I would expect symbolication, which is very hard to do, to be modular. For example (I did a very quick search) I saw no references to debuginfod in the repo, which is what I would want to use on Linux. Perhaps consider a pluggable API here, of which CoreSymbolication might be part of whatever provider Apple hands you by default, and someone can use their own implementations if they so choose. I will also note that some APIs will often have options that you can set–for example (coming back to Apple again) the "time" that is pervasive for the CoreSymbolication API. I don't think it will be possible to provide "one" symbolicator that makes people happy.

One general question I have is how this plans to fare for "strange" backtraces. What happens when calling through JIT-compiled code, or when frame pointers are not available? Do we consider use of this library undefined, or will it stop early when it doesn't understand what's going on? Does this mean every frame pointer walk will do a check of pointers using vm_region_64 or parsing /proc/self/maps before dereferencing anything?

Some of these APIs definitely need to be a little more general to handle all kinds of crazy things that people actually do. I expect to see a lot more optionals. Here are something things I think might be worth supporting that might be problematic right now, just off the top of my head:

  • A frame may be synthetic or may not have an address
  • Images might not have filenames (macOS used to do in-memory linking, etc)
  • Images might not have names (code thunk/JIT code)
  • Images may have more than one code segment (__TEXT/__TEXT_EXEC)
  • An image may no longer be loaded
  • Only some source location might be available (perhaps just file+line number, or just object name)

We can definitely decide that we don't want to support every edge case but I would really like to see APIs that can be used piecemeal and with partial information. We shouldn't make something that is like "oh if you have a full frame pointer chain and debugging symbols in this exact location the API works" and if you have any deviation from that then you can't use any functionality at all.

(Final thought since this is mostly just a braindump: this is a Swift API but people are going to use it in apps/programs with multiple languages in it. We should also put at least a little bit of thought into whether we should do anything there. If Google somehow decides they want to bring safety to Android by adopting Swift wholeheartedly, and invent a FFI layer to make this work, should we do a stack walk across Java code? Maybe not, but we should at least go "yeah, that's too wild for us to consider here".)

5 Likes

regarding

  /// Represents a symbol we've located
  public class Symbol: CustomStringConvertible {

i understand this type would be very complex to copy and store if it were a struct and not a class. but it is extremely awkward to use non-Sendable types in any application that uses actors or concurrency, even if the Symbols themselves are never being used concurrently.

i think it would be vastly preferable to either make this a struct anyway (anyone who is truly getting weighed down by retaining its fields can just wrap it in a class), or make all the properties immutable so they can be used in actors.

1 Like

It's a bit tangential, but can you clarify whether this is just hypothetical or a genuine interest? If the latter, is it for architectures like x86-64 and arm64, or are you more just thinking 'embedded' systems and the like?

Note, importantly, that the API presented here is not async-signal-safe , and it is not an appropriate tool with which to build a general purpose crash reporter . The intended use case for this functionality is the programmatic capture of backtraces during normal execution.

Has anyone tried to envision what would be needed to build a general-purpose crash reporter in Swift? If not, maybe we should do that first; otherwise, this proposal may be shortsighted.

3 Likes

Really exciting to see this and in general a +1 on solving this problem. I have a few open questions though:

  • Are we sure we want to make the Frame enum frozen? Are we never ever expecting to add another case to it? What are the benefits we gain from freezing it right now?
  • I agree with @taylorswift comment around Image being a class. There doesn't seem to be anything on the public API that represents reference semantics. Additionally, we really should make this Sendable. Similar comment about class Symbol
  • Should the Address protocol also require Sendable conformance?
  • Should the SymbolicatedBacktrace and SymbolicatedBacktrace.Frame be Sendable?
  • Can we make the var frames: FrameSequence a var frames: some Sequence<Frame> instead?

I can't wait to see what this new API is going to unlock w.r.t. in-process tooling that we can develop. I would love to see an in-process profiler similar to what Go provides for getting CPU/Memory metrics out of running applications with minimal overhead.

8 Likes

The proposal calls out the ability to cope with backtraces from other platforms. How is this supposed to be possible? Neither Backtrace nor SymbolicateBacktrace conform to Codable. The usage of any Address makes it very hard (without any restrictions actually impossible) to implement a proper Codable conformance.

Is there a reason anyone should be able to conform their own types to Address outside of the Runtime module? If not, we should model the Address type as a non-frozen struct which is backed internally by an enum. This makes it possible to extend the storage in the future and conform to Codable. It also fixes the issue mentioned above with the usage of some Address mentioned above:

If Address should really allow to cope with different addresses from different platforms we can't use some Address here.

1 Like

Lots of people compile without frame pointers. While Apple requires it for Apple silicon, things usually don’t break if you disable them so there are some applications that do this. For everything else (macOS on Intel, Linux, …) this is a supported, if generally shortsighted, optimization that sees real use even outside of embedded contexts.

1 Like

There's compiling without establishing normal stack frame, and then there's using the platform frame pointer register as a GPR. The first is very common, and generally doesn't mess with debugging tools--you might be missing some frames, but you can still recover a somewhat useful backtrace. The latter is more niche, and generally one shouldn't expect backtrace tools to do anything useful when doing it. I've definitely done both from time to time, but doing the later was always an explicit tradeoff of "the usual tools will not work once I do this, and I'm OK with that."

2 Likes

It's true that most Linux distributions are not building their system libraries with frame pointers at the moment but this seems to be changing. Fedora has already changed to building everything with frame pointers. Similarly, Ubuntu is enabling frame pointers by default starting with Ubuntu 24.04. Both posts outline the motivation quite strongly in that they aim to enable high performance profiling. Additionally, usage data has shown that enabling frame pointers has negligible performance impacts and the utilities of performance profiling outweigh those easily.
In line with this, we have already enabled building the Swift runtime libraries with frame pointers and we have made Swift PM build with frame pointers by default as well.

Of course there are still environments out there were frame pointers are not available but we do see a large trend towards making them a default in most major distributions.

3 Likes

Yeah, that's all about what I expected, @scanon & @FranzBusch. I was assuming that from watches to servers these days - and frankly most embedded, since it's mostly ARM - everything has a more modern ISA with sufficiently many GPRs to negate any meaningful benefit from omitting frame pointers.

I suspect it's merely some inertia that's prolonging getting people off of that old crutch. I'm not remotely surprised that some big Linux distros are in this bucket - they tend to be absurdly conservative and slow to change.

I was asking because back in the brief window of time when i386 was a thing for the Mac (32-bit Intel, e.g. Core Duos), I was at Apple in the Performance Tools teams (Shark & Instruments), and it was a frustration of ours that -fomit-frame-pointer was a noticeable performance-booster on the register-starved i386 architecture¹, so it was hard to just bluntly tell people not to use it… yet, by breaking the ability to profile their code, people who used it often left even bigger performance gains on the table (or otherwise had to invest much more labour into identifying & resolving performance problems).

At one point there was even an Apple-internal debate about whether to abandon kernel-based profiling in favour of user-space profiling² because implementing unwind without frame pointers is possible but incredibly expensive and requires masses of debug metadata, making it highly unpalatable to put in the kernel. Thankfully there were too many obvious problems with user-space profiling, so that notion never really got its legs, and then x86-64 finally arrived³ and it was mooted.


¹ It's funny how the Intel transition is now heralded as being amazing and how much better Intel Macs were than PPC Macs, but for a while there we lost a lot of things, like a 64-bit architecture, a competent SIMD implementation, or the notion of more than [effectively] six GPRs. :stuck_out_tongue_closed_eyes:

² There were at the time already some Apple developer tools that did user-space profiling, most notably Sampler (now a niche feature in Activity Monitor) and early versions of Instruments (in fact Instruments still has the Sampler plug-in which does this, although I can't really fathom why anyone would ever willing use it over the Time Profile plug-in).

³ In the sense of all Macs adopting it, not just the Mac Pro. It was easy to ignore i386 at that point because it was then all but officially a dead architecture as far as Apple were concerned.

4 Likes

This got me thinking further but is a bit tangential here, so I reposted & elaborated on my website.

Yes. There is a crash catcher built into Swift 5.9 that sits on the current version of this code, albeit with quite a bit of additional SPI that I didn't want to turn into public API at this point.

It isn't our intention that this should form the basis of a third-party attempt to generate backtraces for actual crashes. Doing that in a way that is robust is very difficult. The API is specifically for cases like testing frameworks where there is a desire to capture a backtrace from the current location.

4 Likes

That's an omission. I would like them to be Codable, I think.

That's an interesting idea that I hadn't considered. One additional point of note here is that the underlying storage might not be the same as the type presented at the interface (for instance, it may be desirable to store delta packed addresses).

Agreed, that's an error. It should have said any, not some, there. But maybe you're right and we can use a non-frozen struct instead. It'd be nice to get rid of the existentials.

4 Likes

That isn't a supported use-case. The API is specifically for capturing backtraces from the current point of execution. Capturing backtraces from other execution contexts is explicitly not supported here, at least not as API. (There is some SPI for this already, since the same code forms the basis for the Swift 5.9 backtrace-on-crash feature, which has to do just that.)

I'm curious what else you feel is Mac specific. The shared cache certainly is, but I think it's an important feature in this context and if there were things on the other major platforms of a similar nature I think it would be right to include those too. Ideally in a relatively generic manner such that if another platform chose to support a similar feature we'd be able to re-use the API for that.

The API as proposed doesn't provide control over this, and the current implementation dereferences pointers with relatively few checks. There is already SPI that provides a bit more control, as well as an ability to do safe memory reads, but I wasn't proposing to make that part of the API at this point.

Synthetic or foreign frames are not something I'd considered modelling here, but I think they may be out of scope for this particular API drop. That does make me think that maybe declaring the Frame enum as @frozen is an error, even though not doing makes it less pleasant to use in a switch statement in user code, since it would be nice to be able to support e.g. mixed Swift/Python or Swift/Java backtraces at some future point.

There's nothing fundamental precluding e.g. Images with an empty path or an empty name. I'm not sure making those optional would be an improvement. Likewise the baseAddress and endOfText members are there solely to allow for formatting a nice crash report; Image isn't, and doesn't seek to be, a generic mechanism for inspecting images in arbitrary object formats — it's just there to supply enough detail to generate a reasonable crash report.

Additionally, some frames may just not have symbols or images associated with them — that's something we can already model here, and I think likely covers the JIT case in at least some cases.

I actually can't see why we couldn't make them immutable, in which case it could be Sendable.

Originally I didn't, and then in the original pitch thread there was a complaint that that made it hard to use, so I changed it to @frozen. I now think maybe that's the wrong call, since it precludes some potentially interesting features in future (foreign frames, for instance). So no, we are not sure :slight_smile:

I think I broadly agree that a few more Sendable conformances could be useful.

I thought I had a reason for doing it this way. The main point is that it is a Sequence<Frame> and not necessarily e.g. an Array or some such.

The intent is that we will support unwinding using DWARF EH information (we don't right now), which should address that in some cases. It isn't a panacea though; processing DWARF is relatively slow and there have been cases in the past where DWARF EH data was wrong (Linux removed a DWARF unwinder from the kernel, as I recall, because it was generating incorrect backtraces).

1 Like

Maybe Backtrace as a whole should be generic over Address and any other type that may depend on the platform, in order to avoid unnecessary type erasure, like each Frame instance potentially having a different Address type. A generalized existential type like any<A: Address> Backtrace<A> can then be used to represent an arbitrary backtrace from any platform.

2 Likes

Very cool proposal.

The SharedCacheInfo parts make me a bit uncomfortable because it is so platform-specific but is expressed as a required part of the API.

Instead, there could be a protocol PlatformMetadata: Codable where each platform can describe whatever extra information is relevant to their platform. (Alternatively, it could also just be a basic userInfo-style dictionary.)

(Note I'm not an expert in this area, so maybe there the need for extensibility here is premature)

1 Like

I don't really want to make it generic over Address, because that makes using it awkward for the 99% of users who will never need to do any Address to integer conversion of any sort. Having an opaque type of some sort is clearly better on that front than requiring people to start using generalized existentials.

I also think it's easy to overstate how awkward (and how common) conversions would be in practice. You don't need to convert to integer for formatting, and there's also no need to convert into the smallest possible integer — today, a conversion to a 64-bit integer would always succeed (32-bit addresses, after all, fit just fine into 64 bits). Making Address opaque is really about future-proofing the API so we don't have to change it for e.g. CHERI.

The cases where integer conversion is useful are largely to do with legacy code (either someone already has a buffer of addresses, held as integers, or they want to pass a buffer of addresses to some other code that expects a backtrace in that format), and in those cases the size of the integers that are supported is going to be fixed already. New code could just pass the Backtrace or SymbolicatedBacktrace.

2 Likes