diff --git a/README.md b/README.md index 6b8e2a2..131c162 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,16 @@ $ swift build -c release -Xswiftc -g When your app crashes, a stacktrace will be printed to `stderr`. +### Formats + +The library comes with two built-in stack trace formats: +- `.full`: default format, prints everything in one long line per stack entry +- `.colored`: lighter format with newlines and colors, easier to read but takes up more vertical space + +Use `Backtrace.install(format:)` to specify which format you want. + +You also have the option to specify your own format using `.custom(formatter: Formatter, skip: Int)`. The `Formatter` closure is executed for every line of the stack trace and prints the returned string. + ## Security Please see [SECURITY.md](SECURITY.md) for details on the security process. diff --git a/Sources/Backtrace/Backtrace.swift b/Sources/Backtrace/Backtrace.swift index 792eb3d..fdc02fa 100644 --- a/Sources/Backtrace/Backtrace.swift +++ b/Sources/Backtrace/Backtrace.swift @@ -23,32 +23,37 @@ typealias CBacktraceSyminfoCallback = @convention(c) (_ data: UnsafeMutableRawPo private let state = backtrace_create_state(nil, /* BACKTRACE_SUPPORTS_THREADS */ 1, nil, nil) -private let fullCallback: CBacktraceFullCallback? = { - _, pc, filename, lineno, function in +private var installedFormat: Format = .default - var str = "0x" - str.append(String(pc, radix: 16)) +private let callback: CBacktraceFullCallback? = { _, pc, filename, lineno, function in + let formattedPc = "0x\(String(pc, radix: 16))" + + let demangledFunction: String? if let function = function { - str.append(", ") var fn = String(cString: function) if fn.hasPrefix("$s") || fn.hasPrefix("$S") { fn = _stdlib_demangleName(fn) } - str.append(fn) + demangledFunction = fn + } else { + demangledFunction = nil } + + let file: (fileName: String, line: Int)? if let filename = filename { - str.append(" at ") - str.append(String(cString: filename)) - str.append(":") - str.append(String(lineno)) + file = (fileName: String(cString: filename), line: Int(lineno)) + } else { + file = nil } - str.append("\n") - str.withCString { ptr in - _ = withVaList([ptr]) { vaList in - vfprintf(stderr, "%s", vaList) + if let line = installedFormat.formatter(formattedPc, demangledFunction, file) { + line.withCString { ptr in + _ = withVaList([ptr]) { vaList in + vfprintf(stderr, "%s", vaList) + } } } + return 0 } @@ -63,18 +68,20 @@ private let errorCallback: CBacktraceErrorCallback? = { private func printBacktrace(signal: CInt) { _ = fputs("Received signal \(signal). Backtrace:\n", stderr) - backtrace_full(state, /* skip */ 0, fullCallback, errorCallback, nil) + backtrace_full(state, Int32(installedFormat.skip), callback, errorCallback, nil) fflush(stderr) } public enum Backtrace { /// Install the backtrace handler on default signals: `SIGILL`, `SIGSEGV`, `SIGBUS`, `SIGFPE`. - public static func install() { - Backtrace.install(signals: [SIGILL, SIGSEGV, SIGBUS, SIGFPE]) + public static func install(format: Format = .default) { + Backtrace.install(signals: [SIGILL, SIGSEGV, SIGBUS, SIGFPE], format: format) } /// Install the backtrace handler when any of `signals` happen. - public static func install(signals: [CInt]) { + public static func install(signals: [CInt], format: Format = .default) { + installedFormat = format + for signal in signals { self.setupHandler(signal: signal) { signal in printBacktrace(signal: signal) @@ -85,7 +92,7 @@ public enum Backtrace { @available(*, deprecated, message: "This method will be removed in the next major version.") public static func print() { - backtrace_full(state, /* skip */ 0, fullCallback, errorCallback, nil) + backtrace_full(state, Int32(installedFormat.skip), callback, errorCallback, nil) } private static func setupHandler(signal: Int32, handler: @escaping @convention(c) (CInt) -> Void) { @@ -105,10 +112,14 @@ public enum Backtrace { #if swift(<5.4) #error("unsupported Swift version") #else +import Foundation + @_implementationOnly import CRT @_implementationOnly import WinSDK #endif +private var installedFormat: Format = .default + public enum Backtrace { private static var MachineType: DWORD { #if arch(arm) @@ -126,12 +137,14 @@ public enum Backtrace { /// Signal selection unavailable on Windows. Use ``install()-484jy``. @available(*, deprecated, message: "signal selection unavailable on Windows") - public static func install(signals: [CInt]) { - Backtrace.install() + public static func install(signals: [CInt], format: Format = .default) { + Backtrace.install(format: format) } /// Install the backtrace handler on default signals. - public static func install() { + public static func install(format: Format = .default) { + installedFormat = format + // Install a last-chance vectored exception handler to capture the error // before the termination and report the stack trace. It is unlikely // that this will be recovered at this point by a SEH handler. @@ -188,9 +201,15 @@ public enum Backtrace { capacity: 1) let hThread: HANDLE = GetCurrentThread() + var toSkip = installedFormat.skip while StackWalk64(Backtrace.MachineType, hProcess, hThread, &Frame, &cxr, nil, SymFunctionTableAccess64, SymGetModuleBase64, nil) { + if toSkip > 0 { + toSkip -= 1 + continue + } + var qwModuleBase: DWORD64 = SymGetModuleBase64(hProcess, Frame.AddrPC.Offset) @@ -229,29 +248,40 @@ public enum Backtrace { _ = SymGetLineFromAddr64(hProcess, Frame.AddrPC.Offset, &Displacement, &Line) - var details: String = "" + #if arch(arm64) || arch(x86_64) + let formattedPc = String(format: "%#016x", Frame.AddrPC.Offset) + #else + let formattedPc = String(format: "%#08x", Frame.AddrPC.Offset) + #endif + + let demangledFunction: String? if !symbol.isEmpty { // Truncate the module path to the filename. The // `PathFindFileNameW` call will return the beginning of the // string if a path separator character is not found. if let pszModule = module.withCString(encodedAs: UTF16.self, PathFindFileNameW) { - details.append(", \(String(decodingCString: pszModule, as: UTF16.self))!\(symbol)") + demangledFunction = "\(String(decodingCString: pszModule, as: UTF16.self))!\(symbol)" + } else { + demangledFunction = nil } + } else { + demangledFunction = nil } + let file: (fileName: String, line: Int)? if let szFileName = Line.FileName { - details.append(" at \(String(cString: szFileName)):\(Line.LineNumber)") + file = (fileName: String(cString: szFileName), line: Int(Line.LineNumber)) + } else { + file = nil } - _ = details.withCString { pszDetails in - withVaList([Frame.AddrPC.Offset, pszDetails]) { - #if arch(arm64) || arch(x86_64) - vfprintf(stderr, "%#016x%s\n", $0) - #else - vfprintf(stderr, "%#08x%s\n", $0) - #endif + if let line = installedFormat.formatter(formattedPc, demangledFunction, file) { + _ = line.withCString { pszDetails in + withVaList([pszDetails]) { + vfprintf(stderr, "%s", $0) + } } } } @@ -267,10 +297,10 @@ public enum Backtrace { #else public enum Backtrace { /// Install the backtrace handler on default signals. Available on Windows and Linux only. - public static func install() {} + public static func install(format: Format = .default) {} /// Install the backtrace handler on specific signals. Available on Linux only. - public static func install(signals: [CInt]) {} + public static func install(signals: [CInt], format: Format = .default) {} @available(*, deprecated, message: "This method will be removed in the next major version.") public static func print() {} diff --git a/Sources/Backtrace/Format.swift b/Sources/Backtrace/Format.swift new file mode 100644 index 0000000..41e67f4 --- /dev/null +++ b/Sources/Backtrace/Format.swift @@ -0,0 +1,107 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftLinuxBacktrace open source project +// +// Copyright (c) 2019-2022 Apple Inc. and the SwiftLinuxBacktrace project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftLinuxBacktrace project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// Formatter for one backtrace line. +/// +/// - Parameter pc: String formated value for PC register ("0x123456789AB"). +/// - Parameter function: full unmangled function name if available. +/// - Parameter fileName: tuple containing `(full path, line)` if available. +/// - Returns: The formatted line to print, or `nil` if it should be skipped. +public typealias Formatter = (_ pc: String, _ function: String?, _ file: (String, Int)?) -> String? + +/// Different built-in formats for backtraces. +public enum Format { + /// Displays all information on one line. + /// Contains all backtrace lines, including those from Backtrace itself. + /// Useful for debugging full dumps. + case full + + /// Formats the information on multiple lines with colors, without PC register. + /// Top backtrace lines from Backtrace itself are ignored. + /// More readable than ``Format/full`` when shown in a short terminal but + /// takes up more vertical space. + case colored + + /// Runs the given formatter to format each line individually. + /// - Parameter formatter: The formatter to run. + /// - Parameter skip: How many backtrace lines to skip at the top. + case custom(formatter: Formatter, skip: Int) + + /// Default format. + public static let `default` = Format.full + + internal var skip: Int { + switch self { + case .full: return 0 + case .colored: return 4 // low enough to be safe on Linux and Windows but still reduce output noise + case .custom(_, let skip): return skip + } + } + + internal var formatter: Formatter { + switch self { + case .full: return fullFormatter + case .colored: return coloredFormatter + case .custom(let formatter, _): return formatter + } + } +} + +let fullFormatter: Formatter = { (_ pc: String, _ function: String?, _ file: (String, Int)?) -> String? in + var str = pc + + if let function = function { + str.append(", ") + str.append(function) + } + + if let (fileName, line) = file { + str.append(" at ") + str.append(fileName) + str.append(":") + str.append(String(line)) + } + + str.append("\n") + + return str +} + +let coloredFormatter: Formatter = { (_ pc: String, _ function: String?, _ file: (String, Int)?) -> String? in + let red = "\u{001B}[91m" + let reset = "\u{001B}[0m" + + var str = "" + + if let function = function { + str.append(" at ") + str.append(red) + str.append(function) + str.append(reset) + } else { + str.append(" at ") + } + + str.append("\n") + + if let (fileName, line) = file { + str.append(" ") + str.append(fileName) + str.append(":") + str.append(String(line)) + str.append("\n") + } + + return str +} diff --git a/Sources/Sample/main.swift b/Sources/Sample/main.swift index b6a0b39..6f39845 100644 --- a/Sources/Sample/main.swift +++ b/Sources/Sample/main.swift @@ -17,6 +17,14 @@ import Backtrace import Darwin #elseif os(Linux) import Glibc +#elseif os(Windows) +@_implementationOnly import CRT +@_implementationOnly import WinSDK + +let SIGILL: Int32 = 4 +let SIGSEGV: Int32 = 11 +let SIGBUS: Int32 = 10 +let SIGFPE: Int32 = 8 #endif Backtrace.install() diff --git a/Tests/BacktraceTests/FormatsTests+XCTest.swift b/Tests/BacktraceTests/FormatsTests+XCTest.swift new file mode 100644 index 0000000..09dacca --- /dev/null +++ b/Tests/BacktraceTests/FormatsTests+XCTest.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftLinuxBacktrace open source project +// +// Copyright (c) 2019-2020 Apple Inc. and the SwiftLinuxBacktrace project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftLinuxBacktrace project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +// +// FormatsTests+XCTest.swift +// +import XCTest + +/// +/// NOTE: This file was generated by generate_linux_tests.rb +/// +/// Do NOT edit this file directly as it will be regenerated automatically when needed. +/// + +extension FormatsTests { + public static var allTests: [(String, (FormatsTests) -> () throws -> Void)] { + return [ + ("testFullFormat", testFullFormat), + ("testFullFormatNoFunction", testFullFormatNoFunction), + ("testFullFormatNoFile", testFullFormatNoFile), + ("testFullFormatNoFileNoFunction", testFullFormatNoFileNoFunction), + ("testColoredFormat", testColoredFormat), + ("testColoredFormatNoFunction", testColoredFormatNoFunction), + ("testColoredFormatNoFile", testColoredFormatNoFile), + ("testColoredFormatNoFileNoFunction", testColoredFormatNoFileNoFunction), + ] + } +} diff --git a/Tests/BacktraceTests/FormatsTests.swift b/Tests/BacktraceTests/FormatsTests.swift new file mode 100644 index 0000000..246abb6 --- /dev/null +++ b/Tests/BacktraceTests/FormatsTests.swift @@ -0,0 +1,99 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftLinuxBacktrace open source project +// +// Copyright (c) 2019-2022 Apple Inc. and the SwiftLinuxBacktrace project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftLinuxBacktrace project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import XCTest + +@testable import Backtrace + +public final class FormatsTests: XCTestCase { + func testFullFormat() { + let line = fullFormatter( + "0x123456789AB", + "FormatsTests.testFullFormat()", + (file: "Tests/BacktraceTests/FormatsTests.swift", line: 24) + ) + + XCTAssertEqual(line, "0x123456789AB, FormatsTests.testFullFormat() at Tests/BacktraceTests/FormatsTests.swift:24\n") + } + + func testFullFormatNoFunction() { + let line = fullFormatter( + "0x123456789AB", + nil, + (file: "Tests/BacktraceTests/FormatsTests.swift", line: 24) + ) + + XCTAssertEqual(line, "0x123456789AB at Tests/BacktraceTests/FormatsTests.swift:24\n") + } + + func testFullFormatNoFile() { + let line = fullFormatter( + "0x123456789AB", + "FormatsTests.testFullFormat()", + nil + ) + + XCTAssertEqual(line, "0x123456789AB, FormatsTests.testFullFormat()\n") + } + + func testFullFormatNoFileNoFunction() { + let line = fullFormatter( + "0x123456789AB", + nil, + nil + ) + + XCTAssertEqual(line, "0x123456789AB\n") + } + + func testColoredFormat() { + let line = coloredFormatter( + "0x123456789AB", + "FormatsTests.testFullFormat()", + (file: "Tests/BacktraceTests/FormatsTests.swift", line: 24) + ) + + XCTAssertEqual(line, " at \u{001B}[91mFormatsTests.testFullFormat()\u{001B}[0m\n Tests/BacktraceTests/FormatsTests.swift:24\n") + } + + func testColoredFormatNoFunction() { + let line = coloredFormatter( + "0x123456789AB", + nil, + (file: "Tests/BacktraceTests/FormatsTests.swift", line: 24) + ) + + XCTAssertEqual(line, " at \n Tests/BacktraceTests/FormatsTests.swift:24\n") + } + + func testColoredFormatNoFile() { + let line = coloredFormatter( + "0x123456789AB", + "FormatsTests.testFullFormat()", + nil + ) + + XCTAssertEqual(line, " at \u{001B}[91mFormatsTests.testFullFormat()\u{001B}[0m\n") + } + + func testColoredFormatNoFileNoFunction() { + let line = coloredFormatter( + "0x123456789AB", + nil, + nil + ) + + XCTAssertEqual(line, " at \n") + } +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index 6d90d1f..876fa1a 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -27,5 +27,6 @@ import BacktraceTests XCTMain([ testCase(BacktraceTests.allTests), + testCase(FormatsTests.allTests), ]) #endif