Swift 不完全函数第 2 部分:捕获前置条件错误

原文:Partial functions in Swift, Part 2: Catching precondition failures
作者:Matt Gallagher
译者:kmyhy

在上一部分,我讨论了“不完全函数”并建议不要使用它们。但就像文中所说的,有些情况下我们又不得不使用不完全函数。如果你准备写一个不完全函数,你需要测试它,在条件不满足时前置条件错误是否会发生。

有一个问题:前置条件错误使 xctest 工具崩溃,导致无法进行测试。在本文中,我将显示一个 Mach 异常处理器,用于捕获这些崩溃并重写线程状态,这就像 O-C 异常发生一样,使前置条件错误能够测试。

*内容:
背景
第一次测试
严重警告列表
编写 Mach 异常处理器
Mach 异常处理器:改写历史
创建 Mach 异常处理器
用法
结论
*

在 ARM CPU 中,一个致命错误用一条触发 EXC_BREAKPOINT 的中断指令来实现。我们只在模拟器中测试 iOS 代码,而它是 x86-64 架构,因此我们不用担心 EXC_BREAKPOINT。

在 Swift 标准库中,一个前置条件错误用 Builtin.int_trap() 实现,在 i386/x86-64 架构,这最终会编译为 ud2 指令。这条指令只会触发一个“invalid opcode”,操作系统会捕获这个错误,会导致一个运行时的 Mach 异常 EXC_BAC_INSTRUCTION。

只依靠 Swift 语言和标准库的功能,是没有办法从一个前置条件错误恢复的。

解决这种问题的传统的方法是在一个子进程中运行代码,然后从父进程中监视崩溃,或者用编译设置将致命错误转换成其它可捕获的对象。

Mach 异常处理器提供了一种不同的机制。通过 Mach 异常处理器,操作系统让我们有机会处理 Mach 异常,我们可以重写程序的历史就像 ud2 指令从来没有发生一样。

第一次测试

我们先写一个函数,名为 catchBadInstruction,用于测试:

func testCatchBadInstruction() {
#if arch(x86_64)
    // Test catching an assertion failure
    var reachedPoint1 = false
    var reachedPoint2 = false
    let exception1: BadInstructionException? = catchBadInstruction {
        // Must invoke this block
        reachedPoint1 = true

        // Fatal error raised
        precondition(false, "EXC_BAD_INSTRUCTION raised here")

        // Exception must be thrown so that this point is never reached
        reachedPoint2 = true
    }
    // We must get a valid BadInstructionException
    XCTAssert(exception1 != nil)
    XCTAssert(reachedPoint1)
    XCTAssert(!reachedPoint2)

    // Test without catching an assertion failure
    var reachedPoint3 = false
    let exception2: BadInstructionException? = catchBadInstruction {
        // Must invoke this block
        reachedPoint3 = true
    }
    // We must not get a BadInstructionException without an assertion
    XCTAssert(reachedPoint3)
    XCTAssert(exception2 == nil)
#endif
}

catchBadInstruction 函数运行传给它的闭包。如果发生 EXC_BAD_INSTRUCTION 异常,该函数会捕获这个异常,创建一个 BadInstructionException 对象(NSException 的子类) 并在异常出现的地方抛出该对象,然后就可以在子闭包外部捕获这个 BadInstructionException。当错误被抛出时会返回这个对象。

catchBadInstruction 函数能捕获任意 Swift 致命错误,包括 assert、assertionFailure、precondition、preconditionFailure、fatalError。通过捕获这些错误,我们可以测试那些需要针对特定的输入进行断言的函数是否被正确调用。

这些代码也会捕获其它无关的 Mach EXC_BAD_INSTRUCTION 异常,但那就比较罕见了,除非你的二进制文件损坏了(对于测试来说非常不可能,测试代码应该是非常少的)。

严重警告列表

我将本文标记为“骇客”。我用这个标签表示本文中的代码做了一些聪明的事情,但往往这些事情都不会跟安全和可维护编程扯上关系。说的更直白点:别在你的发布编译中运行这些代码。这些代码仅用于测试。

这段代码做了一件非常简单的事情:在你的 Swift 代码中抛出并捕获一个 O-C 异常。在 O-C 中,异常都是不安全的,你必须得极其小心。在 Swift 中情况甚至更糟,因为我们不能再让编译器去产生“异常-安全”自动引用计数。内存泄露几乎是必然的,并且其余代码也会由于中断的副作用或者构造不完全而表现异常。你可以让这些问题(与前置条件失败测试紧密相关的)最小化。在测试条件下,这些问题可以得到很好的控制。

创建多个 Mach 异常处理器会导致问题更加复杂。我没有测试过多个、嵌套甚至互斥的 Mach 异常处理器,我不认为在同一个线程中安装多个处理器后它们还能够相安无事。这完全违反了“测试,排它性”的原则,因此最好别这样干。

在这段测试代码中你可能注意到了:它仅仅运行在 arch(x86_64) 架构。这些代码将在 iOS/watchOS/tvOS 模拟器中运行,以及 Mac 本地运行,它不会在 iOS/watchOS/tvOS 设备上运行。因为这个 API 是用于捕获 Mach 异常的,它没有在 SDKs 中开放。 Landon Fuller 在 2013 年的时候提到,他提交了一个 radar 给苹果,要求在 iOS 中提供相应的接口,但没有得到任何结果。我只能假设这种情况仍然没有改变。

如果你使用 Swift 的 Linux 开源版本,则 Match 异常和 O-C 运行时都不可用。你可以参考下面的“更新”一节,关于“概念验证的” SIGILL 处理器。

编写 Mach 异常处理器

对于我而言,编写这样的代码比想象中的要难许多。因为某种原因,苹果没有在文档中介绍 Mach 异常处理器。在 OSX 中苹果并不讳言它的存在,而且“mach_exc.defs”文件在公共 API 中是存在的,但除了这个文件本身外,无论是 Xcode 文档参考、帮助手册还是苹果官网中却对此只字不提。

更糟糕的是,你可以轻易在第三方网站上找到示例及文档,但是都是 Mach 异常的 32 位版本,它们所用的函数和参数都有所不同。但凡能找到的 64 位 Mach 异常处理器的例子(比如在 plcrashreporter,lldb 或者 gdb),通常都是捕获整个程序异常的例子,而不是从一个进程中捕获指定线程的例子。

我用最原始 “摸着石头过河”的方法最终得到了这段代码,但这个过程实在是太慢了,要写一段编译成功的代码容易,但这根本没有意义,因为 mach_port_t 是使用只在 32 位下有效的标志配置的。

Mach 异常信息的处理使用的是完全不属于现代社会的技术。但这也不足为奇,有几个文件中甚至还标注着 “作者:Avadis Tevanian, Jr., 编写日期: 1985”。

Mach 消息的处理没有简单的 C 接口。你需要获得一个“MiG”(Mach Interface Generator)文件,然后用它生成一个 C 接口。接口生成文件通常用于生成多种语言的接口。好嘛,那么我可以生成 Swift 语言的接口吗?但对不起,你只能生成 C 语言接口。但既然这样为什么还要我生成接口文件?为什么不用一个库来实现并且提供一个 C 语言接口?我真的搞不明白。

生成的接口文件后,就能在你的代码中以某种类型签名来调用 C 函数了。在这里我们发现了 Swift 的一个限制:Swift(2.1版本) 无法将一个函数暴露成对应的 C 类型签名。你可以传递 @covention(c) 指针给 Swift 函数,但你无法通过头文件暴露同样的函数。当是,允许 O-C 调用 Swift 的、自动生成的 “[ProductName]-Swift.h” 文件则可以暴露公共 O-C 类(自由函数不能暴露)。因此,最简单的方式就是通过 O-C 来调用 Swift。

我想尽可能用 Swift 来编写 Mach 消息处理,因此我只能将真正的接口写在 O-C 文件里,然后通过 O-C 来调用 Swift 处理器函数。在捕获异常的时候也会用到一些 O-C 代码。

更新:另外一个替代方案是使用 POSIX 信号处理机制

我曾经被问到“既然 Mach 异常处理器这么难用,为什么不用 POSIX 信号处理器呢?”它们其实都是干一样的事情。我使用 Mach 异常处理器的原因是,尽管看起来它需要更多的步骤,但真正做起来的时候反而更简单。

在后面链接的 Github 项目中,我加入了一个“概念验证的” POSIX SIGILL 信号处理器代码,你看一下就知道我为什么会这样说了。它只有一个单个的 Swift 文件(而 Mach 异常处理器包含了 7 个文件,从 Swift 到 O-C 到 Mig),但比起 Mach 异常所支持的平台,它反而弱爆了。

最大的问题是:它不能捆绑 lldb 运行(lldb 用于捕获 EXC_BAD_INSTRUCTION),导致 SIGILL 不会发生(你只能在不捆绑任何调试器的情况下运行)。因为我是想“在 Xcode 中进行测试”(我一直用 Xcode 捆绑 lldb 运行),这样我完全就不可能使用信号处理器了。

其它原因还包括:

  • 信号处理器是个完整的进程(而不会将范围缩小到“catch”发生的线程)
  • 信号处理器不会“重入“,而 Mach 异常处理器在面对多个致命错误时保持了确定性
  • 信号处理器会重写”红区“,从技术上讲这和信号处理器自相矛盾(虽然这也不是什么问题)

Mach 异常处理:重写历史

这些代码由许多不同部分构成,但核心代码是 Mach 异常处理器。异常处理器把来自于异常发生的线程的”状态“提供给我们。我们可以返回一个该”状态“的修改后的版本,因为线程已经被挂起,我们还可以修改堆栈。这是代码:

// 读取旧的线程状态
var state = UnsafePointer<x86_thread_state64_t>(old_state).memory

// 1. 栈指针前移
state.__rsp -= __uint64_t(sizeof(Int))

// 2. 将旧的指令指针保存到栈.
UnsafeMutablePointer<__uint64_t>(bitPattern: UInt(state.__rsp)).memory = state.__rip

// 3. 将指令指针设为新的函数地址
var f: @convention(c) () -> () = raiseBadInstructionException
withUnsafePointer(&f) { state.__rip = UnsafePointer<__uint64_t>($0).memory }

// 写入新的线程状态
UnsafeMutablePointer<x86_thread_state64_t>(new_state).memory = state
new_stateCnt.memory = x86_THREAD_STATE64_COUNT

这 3 句代码等同于汇编语言的 call 指令。我们修改了线程状态,将 ud2 指令(这个指令触发 EXC_BAD_INSTRUCTION)替换成调用我们的 raiseBadInstructionException 函数。这样,当线程恢复它就会去运行:

private func raiseBadInstructionException() {
    BadInstructionException().raise()
}

直接抛出了一个 NSException 子类。

创建一个 Mach 异常处理器

接下来重点介绍的代码是 Mach 异常处理器的创建。这是因为:

  1. 相关函数的文档和例子真的很难找到,因此我将它放到这里
  2. 这是我第一次写 Swift 2 代码,Swift 的 defter、try、guard、throw 和 catch 快把我弄疯了;我不敢肯定结果是好还是坏,但至少不会出现错误。

我建议这些代码的每一个步骤你都能读一下注释,明白代码是做什么。请注意每一步的序号,记住:defer 语句的执行顺序和它们书写的顺序是相反的。

看代码:

func catchBadInstruction(block: () -> ()) -> BadInstructionException? {
    var context = exceptionContext()
    var result: BadInstructionException? = nil
    do {
        var handlerThread: pthread_t = nil
        defer {
            // 8. Wait for the thread to terminate *if* we actually made it to the creation point
            // The mach port should be destroyed *before* calling pthread_join to avoid a deadlock.
            if handlerThread != nil {
                pthread_join(handlerThread, nil)
            }
        }

        try kernCheck {
            // 1. Create the mach port
            mach_port_allocate(mach_task_self_, MACH_PORT_RIGHT_RECEIVE,
                &context.currentExceptionPort)
        }
        defer {
            // 7. Cleanup the mach port
            mach_port_destroy(mach_task_self_, context.currentExceptionPort)
        }

        try kernCheck {
            // 2. Configure the mach port
            mach_port_insert_right(mach_task_self_, context.currentExceptionPort,
                context.currentExceptionPort, MACH_MSG_TYPE_MAKE_SEND)
        }

        try kernCheck { withUnsafeMutablePointers(&context.masks, &context.ports, &context.behaviors) {
            (m, p, b) in withUnsafeMutablePointer(&context.flavors) {
            // 3. Apply the mach port as the handler for this thread
            thread_swap_exception_ports(mach_thread_self(), EXC_MASK_BAD_INSTRUCTION,
                context.currentExceptionPort, Int32(bitPattern: UInt32(EXCEPTION_STATE) |
                MACH_EXCEPTION_CODES), x86_THREAD_STATE64, UnsafeMutablePointer<exception_mask_t>(m),
                &context.count, UnsafeMutablePointer<mach_port_t>(p),
                UnsafeMutablePointer<exception_behavior_t>(b),
                UnsafeMutablePointer<thread_state_flavor_t>($0))
        } } }
        defer { withUnsafeMutablePointers(&context.masks, &context.ports, &context.behaviors) {
            (m, p, b) in withUnsafeMutablePointer(&context.flavors) {
            // 6. Unapply the mach port
            thread_swap_exception_ports(mach_thread_self(), EXC_MASK_BAD_INSTRUCTION, 0,
                EXCEPTION_DEFAULT, THREAD_STATE_NONE, UnsafeMutablePointer<exception_mask_t>(m),
                &context.count, UnsafeMutablePointer<mach_port_t>(p),
                UnsafeMutablePointer<exception_behavior_t>(b),
                UnsafeMutablePointer<thread_state_flavor_t>($0))
        } } }

        try withUnsafeMutablePointer(&context) { c throws in
            // 4. Create the thread
            guard pthread_create(&handlerThread, nil, machMessageHandler, c) == 0 else {
                throw PthreadError.Any
            }

            // 5. Run the block
            result = BadInstructionException.catchException(block)
        }
    } catch {
        // Should never be reached but this is testing code, don't try to recover, just abort
        fatalError("Mach port error: \(error)")
    }
    return result
}

kernCheck 函数是一个自定义函数,用于读取 Mach 函数的返回码,如果它没有错误的话,将返回结果转换成一个 Swift ErrorType 抛出。它相当于某种宏,用于转换 C 中的错误类型。

catchBadInstruction 函数负责完成除了 machMessageHandler 函数(用第 4 步的 pthread_create 方法调用)之外的所有事情。machMessageHandler 则负责监视和等待接收某个 Mach 消息。它的代码如下:

private func machMessageHandler(arg: UnsafeMutablePointer<Void>) -> UnsafeMutablePointer<Void> {
    let context = UnsafeMutablePointer<ExceptionContext>(arg).memory
    var request = request_mach_exception_raise_t()
    var reply = reply_mach_exception_raise_state_t()

    do {
        // Request the next mach message from the port
        request.Head.msgh_local_port = context.currentExceptionPort
        request.Head.msgh_size = UInt32(sizeofValue(request))
        try kernCheck { withUnsafeMutablePointer(&request) {
            mach_msg(UnsafeMutablePointer<mach_msg_header_t>($0), MACH_RCV_MSG | MACH_RCV_INTERRUPT, 
                0, request.Head.msgh_size, context.currentExceptionPort, 0, UInt32(MACH_PORT_NULL))
        } }

        // Prepare the reply structure
        reply.Head.msgh_bits = MACH_MSGH_BITS(MACH_MSGH_BITS_REMOTE(request.Head.msgh_bits), 0)
        reply.Head.msgh_local_port = UInt32(MACH_PORT_NULL)
        reply.Head.msgh_remote_port = request.Head.msgh_remote_port
        reply.Head.msgh_size = UInt32(sizeofValue(reply))
        reply.NDR = NDR_record

        // Use the MiG generated server to invoke our handler for the request and fill in
        // the rest of the reply structure
        guard withUnsafeMutablePointers(&request, &reply, {
            mach_exc_server(UnsafeMutablePointer<mach_msg_header_t>($0),
                UnsafeMutablePointer<mach_msg_header_t>($1))
        }) != 0 else { throw MachExcServer.Any }

        // Send the reply
        try kernCheck { withUnsafeMutablePointer(&reply) {
            mach_msg(UnsafeMutablePointer<mach_msg_header_t>($0), MACH_SEND_MSG, 
                reply.Head.msgh_size, 0, UInt32(MACH_PORT_NULL), 0,
                UInt32(MACH_PORT_NULL))
        } }
    } catch let error as NSError where (error.domain == NSMachErrorDomain && (error.code ==
        Int(MACH_RCV_PORT_CHANGED) || error.code == Int(MACH_RCV_INVALID_NAME))) {
        // Port was already closed before we started or closed while we were listening.
        // This means the block completed without raising an EXC_BAD_INSTRUCTION. Not a problem.
    } catch {
        // Should never be reached but this is testing code, don't try to recover, just abort
        fatalError("Mach message error: \(error)")
    }

    return nil
}

用法

项目代码位于 github:mattgallagher/CwlPreconditionTesting。

Readme.md 文件中描述了使用方式,简化步骤为:

  1. git clone https://github.com/mattgallagher/CwlPreconditionTesting.git
  2. 将 “CwlPreconditionTesting.xcodeproj” 文件拖到你的 Xcode 项目文件树中

  3. 打开测试目标的 Build Phase settings,找到”Target Dependencies“,点”+“按钮,然后选择对应的 “CwlPreconditionTesting” 目标 (根据你的测试目标所用的 SDK 比如 “_iOS” 或者 “_OSX”进行选择)
  4. 在要使用catchBadInstruction 函数进行测试的文件的顶部 import CwlPreconditionTesting (当你这样做的时候 Swift 会自动连接)
  5. 用 CwlCatchBadInstructionTests.swift 中的 catchBadInstruction 对文件进行测试

结论

CwlPreconditionTesting.catchBadInstruction 能够捕获 Swift 的 precondition 前置条件断言失败,因此我们能够准确地测试不完全函数。我想本文介绍的 Mach 异常处理器应该是有史以来包含 Swift 代码比例最高的 Mach 异常处理器了(我甚至怀疑不会有多少 Swift 写的 Mach 异常处理器)。

本文是“重返 Cocoa with Love,截然不同的选择”三部曲的最终章:

第一部: 再见,骇客。Cocoa with Love
第二部: 不要使用不完全函数
第三步: 用骇客技术测试不完全函数

这里,如果我们忽略本文上下文中的不同问题域(App 的实现、API 设计和测试)的话,这看起来似乎有点自相矛盾,当然这我的一个小玩笑。因为我们可以跨过这三个问题域使用 Swift、Xcode 和别的工具,我们极易忘记它们之间的差异。但这并不表示它们是相同的。在测试代码中正确的东西在发布版 App 确是错误的——反之亦然。

App 需要解决的是用户不确定和持续的改变。API 需要的是高效和可重用性。对测试,没有什么严格的限制,只要我们的测试易于编写,易于阅读和全面,别的都不重要。

你可能感兴趣的:(swift,不完全函数)