iOS
Swift

fatalErrorから復帰させる不思議なSwiftライブラリFortifyを読み解く

Swiftにおいて、fatalError()preconditionFailure(), Optionalの!try!などで発生したエラー、すなわちSwiftのエラーの分類で言うところのUniversal error, Logic failureは基本的には発生したその時点でプログラムが終了してしまいます。

guard i > 0 else {
    fatalError("error") // ここでプログラムは落ちる
}

参考: Swiftのエラー4分類が素晴らしすぎるのでみんなに知ってほしい - Qiita

しかし先日TwitterでFortifyというライブラリを見かけて、Swiftを使い始めて以来最大の衝撃を受けました。Fortifyはたった150行の1つのSwiftファイルからなるシンプルなライブラリですが、なんとUniversal error, Logic failureから復帰する機能を提供しているのです!

// FortifyのREADMEより

import Fortify

do {
    try Fortify.exec {
        var a: String!
        a = a! // 普通はここで落ちるはず
    }
}
catch {
    // が、catchできるErrorに変換されてここで拾える
    NSLog("Unwrap error: \(error)")
}

この記事ではFortifyがいかにしてこの挙動を実現しているのかを解説してみたいと思います。

落ちるコードから復帰するには?

この挙動を実現するためには、ざっくり2つの壁を乗り越える必要がありそうです。

  1. プログラムが落ちたことをプログラムから拾う
  2. どこか動くところに戻る

もちろんSwift言語自体にはそんなトリッキーな制御フローを実現する機能はありません。なので、OSやC言語の力を借りてこれらをなんとか実現していきます

fatalErrorを呼んだら何が起こる?

まずはfatalError() を例に落ちた時になにが起こるかをみてみます。環境はmacOS / x86_64 / Swift 4.1です。

fatal.swift
fatalError()
$ swiftc fatal.swift
$ lldb -- ./fatal

libswiftCore.dylib の中で、ud2という命令で止まっています。

(lldb) run
(lldb) c
...
->  0x10027fd00 <+144>: ud2

Intelのマニュアルによると、ud2によりInvalid opecode exception(無効オペコード例外) が発生します。

Executed a UD2 instruction. Note that even though it is the execution of the UD2 instruction that causes the invalid opcode exception, the saved instruction pointer will still points at the UD2 instruction.

通常OSはこのような例外をカーネルで処理できない場合には、元のプロセスにシグナルを送ります。macOSでは、Invalid opecode exceptionをSIGILLとしてプロセスに送るようです。

見るべきところがあっているかはちょっと怪しいですが、こことかここ にそれっぽいコードがあります。

case T_INVALID_OPCODE:
    exception = EXC_BAD_INSTRUCTION;
case EXC_BAD_INSTRUCTION:
     *ux_signal = SIGILL;

ここではfatalErrorを例に見ましたが、だいたい他のクラッシュする例も同じくud2からのSIGILLが発生します。確認していませんが、Linuxカーネルでも同じはずです。

シグナル / シグナルハンドラ

カーネルからプロセスに送られてきたシグナルは、シグナルハンドラを使ってプログラムで拾うことができます。これで1つ目の「プログラムが落ちたことをプログラムから拾う」を達成できそうです!

Fotifyの実装をのぞいてみましょう。Fortify.exec を呼び出すと、中でシグナルハンドラが設定されます。上で確認したSIGILLの他に、SIGABRTに対してもハンドラを設定しています。

public static let installHandlerOnce: Void = {
    _ = signal(SIGILL, { (signo: Int32) in
        Fortify.escape(msg: "Signal \(signo)")
    })
    _ = signal(SIGABRT, { (signo: Int32) in
        Fortify.escape(msg: "Signal \(signo)")
    })

    disableExclusivityChecking()
}()

signalはCで提供されているシグナルハンドラを設定するための関数ですが、ClangImporterによりSwiftのコードからも呼び出すことができます

実際にSwiftから見える定義はこんな感じです。

public func signal(_: Int32, _: (@convention(c) (Int32) -> Swift.Void)!) -> (@convention(c) (Int32) -> Swift.Void)!

余談ですが、@convention(c)が付いているのでselfなどをキャプチャできないため、classメソッドとしてFortify.escape等を定義しています。

setjmp / longjmp

さて、シグナルを拾うことでまた自分のプロセスに処理が戻ってきましたが、どうやってそこから元の正常な処理に戻るのでしょうか?

実はCで提供されている関数にsetjmp / longjmpという恐ろしい(?) ペアが存在します。
簡単にいうと setjmpを呼んだあとでlongjmpを呼べば、最初にsetjmpを呼んだ位置に無理やり戻れるという代物です。

これによって「どこか動くところに戻る」ことができるのです!
実際にFortifyの実装を見てみましょう。

Fortify.exec の中で setjumpを呼び出しています。最初に呼び出したときには0が返るのでifの中には入らず、execの引数に渡されたクロージャを実行します。

if setjump(stack! + (local.stack.count-1)) != 0 {
    throw local.error ?? NSError(domain: "Error not available", code: -1, userInfo: nil)
}

return try block()

先程シグナルハンドラの中で呼び出していたFortify.execの中でlongjumpを呼び出しています。2番目の引数に1を渡していて、これがsetjumpの場所に戻った際の返り値になります。

open class func escape(withError error: Error) -> Never {
...
    let stack = local.stack.withUnsafeMutableBufferPointer { $0.baseAddress }
    longjump(stack! + (local.stack.count-1), 1)
}

そうすれば今度はifの中に入りthrowされる、というわけです。
これで目的達成です!

@_silgen_nameでシンボル名を指定してClangImporterの制限を回避する

Cで定義されているのはsetjmp / longjmp ですが、上で見たSwiftのコードに出てきた関数名は setjump / longjumpでした。
signalと同じように、ClangImporterの働きによってsetjmplongjmpも使えそうなものですが、実装を見てみるとわざわざSwift側にこんな宣言をおいて名前をつけなおしています。なんでこんなことをしているのでしょうか?

@_silgen_name ("setjmp")
public func setjump(_: UnsafeMutablePointer<jmp_buf>!) -> Int32

@_silgen_name ("longjmp")
public func longjump(_: UnsafeMutablePointer<jmp_buf>!, _: Int32) -> Never

実は一部のCの関数はSwiftでは安全に扱えない可能性があるために、使おうとするとClangImporterがエラーにしてしまいます
setjmp / longjmpもその対象で、有名なところだとforkなんかもSwiftからは呼び出せません。

if (decl->hasAttr<clang::ReturnsTwiceAttr>()) {
    // The Clang 'returns_twice' attribute is used for functions like
    // 'vfork' or 'setjmp'. Because these functions may return control flow
    // of a Swift program to an arbitrary point, Swift's guarantees of
    // definitive initialization of variables cannot be upheld. As a result,
    // functions like these cannot be used in Swift.
    Impl.markUnavailable(
      result,
      "Functions that may return more than one time (annotated with the "
      "'returns_twice' attribute) are unavailable in Swift");
}

これを回避するためにわざわざ別名を付けておいて、@_silgen_nameで本当の名前を指定することでうまくリンクされて使える、みたいな遠回りをしています。

その他の参考になるテクニック

150行のコードの中にそのほかにも「Swiftからでもこんなことできるのか….」と思わせられるいろいろなテクニックが使われています。例えば

  • dlopen / dlsymを使ってSwift Runtimeのフラグを書き換えてLoEをオフにする。
  • pthreadを使ってスレッドローカルなオブジェクト作成

などなどです。詳細は是非読んでみてください。

Fortifyの使い所

なかなかトリッキーなことをやっているのでiOSアプリで使うのは厳しいとは思いますが、それでも使い所はあると思っています。

1つはREADMEや紹介記事にかかれている通りサーバーサイドSwiftで使う、もう一つはテストで使うです。

guardelseの中でfatalErrorで落とすようなパターンは非常によく使われますが、テストをしようとするとその時点でクラッシュしてしまうので、正常系しかユニットテストを書くことができません。Rustの#[should_panic]ように言語レベルでサポートがあればよいのですが、残念ながらSwiftにはありません。。

しかしFortifyを使えばそのようなケースもテストできます!

実際にこんなライブラリを作ってみたので良かったら使ってみてください。

GitHub - ukitaka/XCTAssertUnrecoverable: Make it possible to test that universal error / logic failure occurs.

image.png

fail.png

おわりに

たった150行のSwiftライブラリですが、本当に多くのテクニックが詰まっていてとても勉強になりました。
Swift4.0まではSwift Runtimeにパッチを当てないと使えませんでしたが、Swift4.1からはパッチ無しで動かせるようになったようなのでみなさんもぜひ使ってみてください!