mirror of
https://github.com/netbirdio/ios-client.git
synced 2026-09-23 09:28:33 -07:00
* Capture Go panics in the main app and report them to Crashlytics A Go panic aborts the process with SIGABRT, and both the Apple crash report and Crashlytics stop at runtime.asmcgocall: the Go stack switch breaks the unwinder, so the panicking Go frames are never shown. The panic message and goroutine dump only go to stderr. The network extension already redirected stderr into netbird.err in the app group container, but the main app did not, so a panic in the Go code the app itself calls (login, preferences, profile management) left no trace anywhere. Move the redirect into a shared GoCrashCapture in NetbirdKit and call it from both processes before any Go SDK call. On launch, after Firebase is configured, the app forwards whatever netbird.err gained since the last report to Crashlytics: the full dump via log() and a non-fatal whose headline is the panic line. The file itself is never truncated, so the debug bundle keeps picking it up unchanged. * Keep netbird.err shared across processes and report backlogs one dump at a time Truncate the file in place when it passes the size cap instead of unlinking it: the other process may hold fd 2 on the same inode, and an unlink would send its later panic output into an orphan nobody reads. Advance the reported offset only past the chunk actually returned. The read is capped at 48 KB, so jumping to the end of the file skipped any second dump that landed behind the first one between two app launches.
121 lines
5.3 KiB
Swift
121 lines
5.3 KiB
Swift
//
|
|
// GoCrashCapture.swift
|
|
// NetBird
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// Keeps the Go runtime's crash output around after the process is gone.
|
|
///
|
|
/// The Go runtime writes panic messages and fatal-error goroutine dumps to fd 2
|
|
/// right before it aborts the process. Neither the main app nor the network
|
|
/// extension keeps stderr anywhere, and the resulting SIGABRT crash report stops
|
|
/// at the Go stack switch (`runtime.asmcgocall`) without the panicking Go frames —
|
|
/// so after a crash this file is the only place the panic reason can be recovered
|
|
/// from. `redirect()` points fd 2 at "netbird.err" in the app group container,
|
|
/// next to logfile.log, where the debug bundle generator already picks it up
|
|
/// (see BundleGenerator.addLogfile in netbird-core).
|
|
///
|
|
/// Both processes append to the same file. `takeUnreportedCrashOutput()` lets the
|
|
/// main app forward what was written since its previous report without truncating
|
|
/// the file, which the debug bundle still relies on.
|
|
public enum GoCrashCapture {
|
|
private static let fileName = "netbird.err"
|
|
private static let maxFileSize: UInt64 = 5 * 1024 * 1024
|
|
/// Crashlytics keeps at most 64 KB of logs per session; stay under it so the
|
|
/// panic header at the top of the dump is never the part that gets dropped.
|
|
private static let maxReportSize = 48 * 1024
|
|
private static let reportedOffsetKey = "io.netbird.goCrashCapture.reportedOffset"
|
|
private static let crashMarkers = ["panic:", "fatal error:"]
|
|
|
|
private static var fileURL: URL? {
|
|
FileManager.default
|
|
.containerURL(forSecurityApplicationGroupIdentifier: GlobalConstants.userPreferencesSuiteName)?
|
|
.appendingPathComponent(fileName)
|
|
}
|
|
|
|
private static let redirectOnce: Void = {
|
|
guard let errLogURL = fileURL else {
|
|
AppLogger.shared.log("stderr redirect: app group container unavailable")
|
|
return
|
|
}
|
|
|
|
let fd = open(errLogURL.path, O_WRONLY | O_CREAT | O_APPEND, 0o644)
|
|
guard fd >= 0 else {
|
|
AppLogger.shared.log("stderr redirect: failed to open \(errLogURL.path), errno=\(errno)")
|
|
return
|
|
}
|
|
|
|
var info = stat()
|
|
if fstat(fd, &info) == 0, info.st_size > 0 {
|
|
let size = UInt64(info.st_size)
|
|
// Surface a previous session's crash output before appending to it.
|
|
AppLogger.shared.log("stderr redirect: netbird.err has \(size) bytes from a previous session (possible crash dump)")
|
|
// Cap growth across sessions once it grows beyond 5 MB. Truncate in place
|
|
// rather than unlinking: the other process may hold fd 2 on this inode, and
|
|
// unlinking would send its future writes into an orphan nobody can read.
|
|
if size > maxFileSize {
|
|
AppLogger.shared.log("stderr redirect: netbird.err exceeds 5 MB cap, truncating")
|
|
if ftruncate(fd, 0) != 0 {
|
|
AppLogger.shared.log("stderr redirect: failed to truncate netbird.err, errno=\(errno)")
|
|
}
|
|
}
|
|
}
|
|
|
|
dup2(fd, STDERR_FILENO)
|
|
if fd != STDERR_FILENO {
|
|
close(fd)
|
|
}
|
|
|
|
let marker = "\n=== stderr redirect active pid=\(getpid()) at \(ISO8601DateFormatter().string(from: Date())) ===\n"
|
|
marker.withCString { _ = write(STDERR_FILENO, $0, strlen($0)) }
|
|
AppLogger.shared.log("stderr redirect: fd 2 -> netbird.err in app group container")
|
|
}()
|
|
|
|
/// Redirects this process's stderr into netbird.err. Safe to call repeatedly;
|
|
/// only the first call has an effect. Must run before any Go SDK call so a
|
|
/// panic during startup is captured too.
|
|
public static func redirect() {
|
|
_ = redirectOnce
|
|
}
|
|
|
|
/// Returns the next chunk of crash output appended to netbird.err since the
|
|
/// previous call, or nil when nothing new looks like a Go panic or fatal
|
|
/// error. The consumed range is remembered so the same dump is never reported
|
|
/// twice, even though the file itself is left untouched. Only the returned
|
|
/// chunk is consumed: anything after it stays unread for the next call, so a
|
|
/// backlog of several dumps is reported one launch at a time instead of being
|
|
/// skipped past.
|
|
public static func takeUnreportedCrashOutput() -> String? {
|
|
guard let errLogURL = fileURL,
|
|
let handle = try? FileHandle(forReadingFrom: errLogURL) else {
|
|
return nil
|
|
}
|
|
defer { try? handle.close() }
|
|
|
|
guard let fileSize = try? handle.seekToEnd() else { return nil }
|
|
let defaults = UserDefaults.standard
|
|
var offset = UInt64(max(0, defaults.integer(forKey: reportedOffsetKey)))
|
|
// The file was reset (or replaced) since the last report; start over.
|
|
if offset > fileSize {
|
|
offset = 0
|
|
}
|
|
defer { defaults.set(Int(offset), forKey: reportedOffsetKey) }
|
|
|
|
while offset < fileSize {
|
|
guard (try? handle.seek(toOffset: offset)) != nil,
|
|
let data = try? handle.read(upToCount: min(Int(fileSize - offset), maxReportSize)),
|
|
!data.isEmpty else {
|
|
return nil
|
|
}
|
|
offset += UInt64(data.count)
|
|
|
|
let output = String(decoding: data, as: UTF8.self)
|
|
if crashMarkers.contains(where: output.contains) {
|
|
return output
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
}
|