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つの壁を乗り越える必要がありそうです。
- プログラムが落ちたことをプログラムから拾う
- どこか動くところに戻る
もちろんSwift言語自体にはそんなトリッキーな制御フローを実現する機能はありません。なので、OSやC言語の力を借りてこれらをなんとか実現していきます。
fatalErrorを呼んだら何が起こる?
まずはfatalError()
を例に落ちた時になにが起こるかをみてみます。環境はmacOS / x86_64 / Swift 4.1です。
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の働きによってsetjmp
やlongjmp
も使えそうなものですが、実装を見てみるとわざわざ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で使う、もう一つはテストで使うです。
guard
のelse
の中でfatalError
で落とすようなパターンは非常によく使われますが、テストをしようとするとその時点でクラッシュしてしまうので、正常系しかユニットテストを書くことができません。Rustの#[should_panic]
ように言語レベルでサポートがあればよいのですが、残念ながらSwiftにはありません。。
しかしFortifyを使えばそのようなケースもテストできます!
実際にこんなライブラリを作ってみたので良かったら使ってみてください。
おわりに
たった150行のSwiftライブラリですが、本当に多くのテクニックが詰まっていてとても勉強になりました。
Swift4.0まではSwift Runtimeにパッチを当てないと使えませんでしたが、Swift4.1からはパッチ無しで動かせるようになったようなのでみなさんもぜひ使ってみてください!
“Fortify" converts fatal #swiftlang unwrap and casting errors into catchable exceptions for use in server apps: https://t.co/FiBNVm477U - now works with standard Swift 4.1 release.
— Injection for Xcode (@Injection4Xcode) 2018年4月26日