diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8d898ce347..63c1e0a930 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,14 +1,11 @@ -name: Test Matrix - +name: test on: pull_request: push: branches: - master - jobs: - - Linux: + linux: runs-on: ubuntu-latest strategy: fail-fast: false @@ -38,13 +35,13 @@ jobs: uses: actions/checkout@v2 - name: Run tests with Thread Sanitizer run: swift test --enable-test-discovery --sanitize=thread - macOS: runs-on: macos-latest steps: - name: Select latest available Xcode uses: maxim-lobanov/setup-xcode@1.0 - with: { 'xcode-version': 'latest' } + with: + xcode-version: latest - name: Check out code uses: actions/checkout@v2 - name: Run tests with Thread Sanitizer diff --git a/Sources/Vapor/Error/Abort.swift b/Sources/Vapor/Error/Abort.swift index 5e6ce81abc..d73f8226b0 100644 --- a/Sources/Vapor/Error/Abort.swift +++ b/Sources/Vapor/Error/Abort.swift @@ -3,7 +3,7 @@ /// /// throw Abort(.badRequest, reason: "Something's not quite right...") /// -public struct Abort: AbortError { +public struct Abort: AbortError, DebuggableError { /// Creates a redirecting `Abort` error. /// /// throw Abort.redirect(to: "https://vapor.codes")" @@ -28,9 +28,11 @@ public struct Abort: AbortError { /// See `AbortError` public var reason: String - /// Wrap this error's source location into a usable struct for - /// the `AbortError` protocol. - public var source: ErrorSource + /// Source location where this error was created. + public var source: ErrorSource? + + /// Stack trace at point of error creation. + public var stackTrace: StackTrace? /// Create a new `Abort`, capturing current source location info. public init( @@ -43,7 +45,8 @@ public struct Abort: AbortError { function: String = #function, line: UInt = #line, column: UInt = #column, - range: Range? = nil + range: Range? = nil, + stackTrace: StackTrace? = .capture(skip: 1) ) { self.identifier = identifier ?? status.code.description self.headers = headers @@ -56,5 +59,6 @@ public struct Abort: AbortError { column: column, range: range ) + self.stackTrace = stackTrace } } diff --git a/Sources/Vapor/Error/StackTrace.swift b/Sources/Vapor/Error/StackTrace.swift index baf0c8f671..c623bc8dcc 100644 --- a/Sources/Vapor/Error/StackTrace.swift +++ b/Sources/Vapor/Error/StackTrace.swift @@ -1,56 +1,90 @@ +#if os(Linux) +import Backtrace +import CBacktrace +#endif + +extension Optional where Wrapped == StackTrace { + public static func capture(skip: Int = 0) -> Self { + StackTrace.capture(skip: 1 + skip) + } +} + public struct StackTrace { public static var isCaptureEnabled: Bool = true - public static func capture() -> Self? { + public static func capture(skip: Int = 0) -> Self? { guard Self.isCaptureEnabled else { return nil } - return .init(raw: Thread.callStackSymbols) + let frames = Self.captureRaw().dropFirst(1 + skip) + return .init(rawFrames: .init(frames)) } - public var frames: [Frame] { - self.raw.dropFirst(2).map { line in - let file: String - let function: String - #if os(Linux) - let parts = line.split( - separator: " ", - maxSplits: 1, - omittingEmptySubsequences: true - ) - let fileParts = parts[0].split(separator: "(") - file = String(fileParts[0]) - switch fileParts.count { - case 2: - let mangledName = String(fileParts[1].dropLast().split(separator: "+")[0]) - function = _stdlib_demangleName(mangledName) - default: - function = String(parts[1]) + #if os(Linux) + private static let state = backtrace_create_state(CommandLine.arguments[0], /* supportThreading: */ 1, nil, nil) + #endif + + static func captureRaw() -> [RawFrame] { + #if os(Linux) + final class Context { + var frames: [RawFrame] + init() { + self.frames = [] } - #else + } + var context = Context() + backtrace_full(self.state, /* skip: */ 1, { data, pc, filename, lineno, function in + let frame = RawFrame( + file: filename.flatMap { String(cString: $0) } ?? "unknown", + mangledFunction: function.flatMap { String(cString: $0) } ?? "unknown" + ) + data!.assumingMemoryBound(to: Context.self) + .pointee.frames.append(frame) + return 0 + }, { _, cMessage, _ in + let message = cMessage.flatMap { String(cString: $0) } ?? "unknown" + fatalError("Failed to capture Linux stacktrace: \(message)") + }, &context) + return context.frames + #else + return Thread.callStackSymbols.dropFirst(1).map { line in let parts = line.split( separator: " ", maxSplits: 3, omittingEmptySubsequences: true ) - file = String(parts[1]) + let file = String(parts[1]) let functionParts = parts[3].split(separator: "+") - let mangledName = String(functionParts[0]).trimmingCharacters(in: .whitespaces) - function = _stdlib_demangleName(mangledName) - #endif - return Frame(file: file, function: function) + let mangledFunction = String(functionParts[0]) + .trimmingCharacters(in: .whitespaces) + return .init(file: file, mangledFunction: mangledFunction) } + #endif } public struct Frame { + public var file: String + public var function: String + } + + public var frames: [Frame] { + self.rawFrames.map { frame in + Frame( + file: frame.file, + function: _stdlib_demangleName(frame.mangledFunction) + ) + } + } + + struct RawFrame { var file: String - var function: String + var mangledFunction: String } - let raw: [String] + let rawFrames: [RawFrame] public func description(max: Int = 16) -> String { - self.frames[...min(self.frames.count, max)].readable + return self.frames[...min(self.frames.count, max)].readable } } diff --git a/Tests/VaporTests/ErrorTests.swift b/Tests/VaporTests/ErrorTests.swift index 4ed80c6dd7..ef08b3cdc8 100644 --- a/Tests/VaporTests/ErrorTests.swift +++ b/Tests/VaporTests/ErrorTests.swift @@ -130,6 +130,40 @@ final class ErrorTests: XCTestCase { XCTAssertEqual(abort.reason, "After decode") }) } + + func testAbortDebuggable() throws { + func foo() throws { + try bar() + } + func bar() throws { + try baz() + } + func baz() throws { + throw Abort(.internalServerError, reason: "Oops") + } + do { + try foo() + } catch let error as DebuggableError { + XCTAssertContains(error.stackTrace?.frames[0].function, "baz") + XCTAssertContains(error.stackTrace?.frames[1].function, "bar") + XCTAssertContains(error.stackTrace?.frames[2].function, "foo") + } + } +} + +func XCTAssertContains( + _ haystack: String?, + _ needle: String, + file: StaticString = #file, + line: UInt = #line +) { + guard let haystack = haystack else { + XCTFail("\(needle) not found in: nil", file: file, line: line) + return + } + if !haystack.contains(needle) { + XCTFail("\(needle) not found in: \(haystack)", file: file, line: line) + } } private struct Foo: Content {