In the event of a fatal error caused by Swift code, there is no direct way to get the error message and context from Swift without out-of-process log parsing. Fatal errors "fall through" to signal handlers at which point the crash context is lost. The goal of this proposal is to provide a native Swift cleanup callback for fatal errors without the complexity of signal handlers nor allowing attempted recovery. This context could be written to disk or logged in a custom format or aggregated for later analysis.
Proposed solution
Add an onFatalError
function which takes a closure as an argument. The closure expects a message and optionally a file and line number, similar to the semantics of the various types _assertionFailure()
. The onFatalError
closure is invoked by any call to fatalError()
, preconditionFailure()
and assertionFailure()
providing a cleanup opportunity before the app is ultimately terminated by trap()
.
The handler is active globally, similar to facilities in other languages like Rust's panic::set_hook
, Python's sys.excepthook
, and NSSetUncaughtExceptionHandler
.
The onFatalError
function returns the existing fatal error handler (if any) to allow handler chaining if needed. The last registration of onFatalError
"wins". This is analogous to NSSetUncaughtExceptionHandler
.
Trivial usage example with handler chaining:
onFatalError { message, file, line in
print("This is a custom callback. Received error: '\(message)'")
if let file = file, let line = line {
print("The error occurred in \(file):\(line)")
}
}
var prevHandler: AssertionFailureCallback? = nil
prevHandler = onFatalError { message, file, line in
print("This is the second handler. Received error: '\(message)'")
if let prevHandler = prevHandler {
prevHandler(message, file, line)
}
}
// Examples of fatal errors:
let text: String? = nil
print(text!)
let items = [1, 2, 3]
print("The fourth item is \(items[4])")
fatalError("Damage report!")
Apps would typically set a fatal handler at the end of the launch lifecycle and use the handler to add custom state to debug issues which arise.
App code example (eliding some helper functions)
// AppDelegate.swift
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
var gameWorld: World?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let world = World()
// Wrap fatal error handler to provide a reference to interesting
// state for debugging
let didCrash = FatalHandler.install(world)
if didCrash {
// load empty world and prompt user to say what's happening and why
} else {
world.loadFromSave()
}
gameWorld = world
return true
}
}
// FatalHandler.swift
import Foundation
var crashInfoPath: String?
var crashedLastLaunch = false
var crashedThisLaunch = false
var worldContext: World?
class FatalHandler {
// checks and returns crash state
public class func install(_ world: World) -> Bool {
var didCrash = false
// Make context available in a non-capturing scope for signal handlers
worldContext = world
// Store crash context in app cache
let cacheDirs = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true)
if let cacheDir = cacheDirs.first {
crashInfoPath = cacheDir + "/crashinfo"
if access(crashInfoPath!, F_OK) != -1 {
didCrash = true
// (...) read file, handle crashing conditions, keep track of
// which worlds have repeated errors, etc
// Delete when done
unlink(crashInfoPath!)
}
onFatalError { message, file, line in
crashedThisLaunch = true
// Open a file passing the file handler to a closure
openFile(path: crashInfoPath) { fd in
// Writes a simple structured file to a pre-configured path
// Format:
// date
// message
// world seed (int)
// file:line
writeCrashInfo(fd: fd, message: message.description, seed: worldContext?.seed, file: file, line: line)
}
}
// (...) Install signal handlers to also write info in case of other
// types of crashes if crashedThisLaunch is false
installSignalHandler(SIGABRT)
installSignalHandler(SIGSEGV)
installSignalHandler(SIGFPE)
installSignalHandler(SIGILL)
installSignalHandler(SIGTRAP)
}
return didCrash
}
}
Simple scripts would set a handler near the beginning of the file, flushing ongoing work and state to disk.
Script code example
import Darwin
let crashInfoPath = "\(UUID().uuidString).crashlog"
var itemsProcessed = 0
onFatalError { message, file, line in
openFile(path: crashInfoPath) { fd in
writeCrashInfo(fd, message, itemsProcessed, file, line)
}
}
// (...) Do some work, incrementing itemsProcessed as needed
More complex scripts or servers which manage multiple processes would install a handler at the beginning of a work unit, aggregating failed job output for later analysis. Combinations of message, file, line number, and additional state indicating what work was happening at the time surfaces potentially interesting code paths which could use more testing and review.
Additional discussion
Alternatives
-
Supporting multiple handlers which are executed sequentially based on order of registration. This reduces the overhead of managing previous handlers though removes the option of uninstalling handlers when using the lowest-level constructs. This would be similar to Ruby's
at_exit
.onFatalError { message, file, line in print("Runs next!") } onFatalError { message, file, line in print("Runs first!") }
-
Custom file logging option. I haven't pursued this one deeply, but most cases I can imagine for using a cleanup callback involve writing the fatal error context to a file in a structured format, so the standard interface could instead involve writing the message and file/line info to a custom file path for later analysis.
registerFatalErrorLog(URL(fileURLWithPath: "/path/to/log"))
However, this style of interface would remove the ability to capture custom application state when the fatal error occurs.
-
Enhancing
signal()
to provide Swift-specific context, if any, through either a change in closure arguments or a signal-safe, Swift fatal error context.signal(SIGILL) { sig, errmsg, file, line in // ... }
signal(SIGILL) { sig in if FatalError.set { let message = fatalError.message } }
On naming
The callback is named onFatalError
, however it runs for fatalError()
, assertionFailure()
, and preconditionFailure()
. onAssertionFailure()
could be a better name because all three methods funnel through _assertionFailure()
, though that's not obvious without looking through the source code. There's also the option of getting rid of the "error" part altogether in favor of something like onFatal()
.