@goliapkg/sentori-react-native 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ios/SentoriHangWatchdog.swift +219 -0
- package/ios/SentoriModule.swift +19 -0
- package/package.json +1 -1
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// iOS hang detector — mirrors the Android ANR watchdog (Phase 22 sub-D).
|
|
4
|
+
///
|
|
5
|
+
/// A background thread posts a tick onto the main queue every
|
|
6
|
+
/// `intervalMs` and waits `timeoutMs` for it to run. If the main run
|
|
7
|
+
/// loop didn't drain the tick in time we capture the main thread's
|
|
8
|
+
/// call stack and write a Sentori event with `kind = "anr"` (the
|
|
9
|
+
/// dashboard already groups Android ANR + iOS hang under that kind —
|
|
10
|
+
/// the user-visible distinction lives in `tags.source`).
|
|
11
|
+
///
|
|
12
|
+
/// Single-shot per hang: once we report, we wait for the tick to
|
|
13
|
+
/// land before re-arming so a 30-second freeze doesn't dump six
|
|
14
|
+
/// events. Daemon thread (DispatchSourceTimer in a background
|
|
15
|
+
/// queue) so it can't keep the process alive on shutdown.
|
|
16
|
+
///
|
|
17
|
+
/// Disabled in DEBUG builds by default — the Xcode debugger
|
|
18
|
+
/// pauses the main thread routinely and we don't want a flood. The
|
|
19
|
+
/// host app can override via `start(force: true)`.
|
|
20
|
+
@objc public final class SentoriHangWatchdog: NSObject {
|
|
21
|
+
|
|
22
|
+
private static let pendingDirName = "sentori/pending"
|
|
23
|
+
private static let configKey = "com.sentori.config"
|
|
24
|
+
|
|
25
|
+
private static var running: Bool = false
|
|
26
|
+
private static var queue: DispatchQueue?
|
|
27
|
+
private static var timer: DispatchSourceTimer?
|
|
28
|
+
private static let lock = NSLock()
|
|
29
|
+
|
|
30
|
+
/// Start the watchdog. Idempotent.
|
|
31
|
+
@objc public static func start(timeoutMs: Int, intervalMs: Int, force: Bool) {
|
|
32
|
+
lock.lock()
|
|
33
|
+
defer { lock.unlock() }
|
|
34
|
+
if running { return }
|
|
35
|
+
if isDebug() && !force { return }
|
|
36
|
+
|
|
37
|
+
let q = DispatchQueue(label: "com.sentori.hangWatchdog", qos: .utility)
|
|
38
|
+
let t = DispatchSource.makeTimerSource(queue: q)
|
|
39
|
+
let interval = DispatchTimeInterval.milliseconds(intervalMs)
|
|
40
|
+
let timeoutNs = UInt64(max(0, timeoutMs)) * NSEC_PER_MSEC
|
|
41
|
+
|
|
42
|
+
// The "tick" path: post a Bool flip onto the main queue and
|
|
43
|
+
// record the time we did so. Read both fields in the worker
|
|
44
|
+
// tick to decide whether the main loop is alive.
|
|
45
|
+
let state = HangState()
|
|
46
|
+
|
|
47
|
+
t.schedule(deadline: .now() + interval, repeating: interval)
|
|
48
|
+
t.setEventHandler {
|
|
49
|
+
// If the previous tick is still pending after the timeout
|
|
50
|
+
// window, the main thread is wedged. Capture once, then
|
|
51
|
+
// hold off until the main loop catches up.
|
|
52
|
+
if state.armed.value, let armedAt = state.armedAt.value {
|
|
53
|
+
let elapsedNs = DispatchTime.now().uptimeNanoseconds &- armedAt.uptimeNanoseconds
|
|
54
|
+
if elapsedNs >= timeoutNs && !state.reportedThisHang.value {
|
|
55
|
+
state.reportedThisHang.value = true
|
|
56
|
+
captureHang(durationMs: Int(elapsedNs / NSEC_PER_MSEC))
|
|
57
|
+
}
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Main is responsive — re-arm.
|
|
62
|
+
state.armed.value = true
|
|
63
|
+
state.armedAt.value = DispatchTime.now()
|
|
64
|
+
state.reportedThisHang.value = false
|
|
65
|
+
DispatchQueue.main.async {
|
|
66
|
+
state.armed.value = false
|
|
67
|
+
state.armedAt.value = nil
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
t.resume()
|
|
71
|
+
timer = t
|
|
72
|
+
queue = q
|
|
73
|
+
running = true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@objc public static func stop() {
|
|
77
|
+
lock.lock()
|
|
78
|
+
defer { lock.unlock() }
|
|
79
|
+
timer?.cancel()
|
|
80
|
+
timer = nil
|
|
81
|
+
queue = nil
|
|
82
|
+
running = false
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private static func isDebug() -> Bool {
|
|
86
|
+
#if DEBUG
|
|
87
|
+
return true
|
|
88
|
+
#else
|
|
89
|
+
return false
|
|
90
|
+
#endif
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// MARK: - capture
|
|
94
|
+
|
|
95
|
+
private static func captureHang(durationMs: Int) {
|
|
96
|
+
let cfg = UserDefaults.standard.dictionary(forKey: configKey) ?? [:]
|
|
97
|
+
let release = (cfg["release"] as? String) ?? "unknown"
|
|
98
|
+
let environment = (cfg["environment"] as? String) ?? "prod"
|
|
99
|
+
|
|
100
|
+
// Thread.callStackSymbols on the main thread is what we want;
|
|
101
|
+
// cross-thread inspection requires Mach APIs that App Store
|
|
102
|
+
// review tends to flag. Posting onto main blocks if main is
|
|
103
|
+
// wedged — we want the wedged stack, but we can't get it
|
|
104
|
+
// without main cooperation. Best-effort: capture this thread's
|
|
105
|
+
// stack (the watchdog) which is at least informative about
|
|
106
|
+
// the timing path. Phase 22 sub-F gets a proper main-thread
|
|
107
|
+
// stack via thread_state_t when we sit down with mach.
|
|
108
|
+
let frames = Thread.callStackSymbols.map { sym -> [String: Any] in
|
|
109
|
+
let parts = sym.split(
|
|
110
|
+
separator: " ", omittingEmptySubsequences: true
|
|
111
|
+
).map(String.init)
|
|
112
|
+
let module = parts.count > 1 ? parts[1] : "<unknown>"
|
|
113
|
+
let function =
|
|
114
|
+
parts.count > 3
|
|
115
|
+
? parts.dropFirst(3).joined(separator: " ")
|
|
116
|
+
: "<anonymous>"
|
|
117
|
+
return [
|
|
118
|
+
"function": function,
|
|
119
|
+
"file": module,
|
|
120
|
+
"line": 0,
|
|
121
|
+
"inApp": !module.contains("UIKit")
|
|
122
|
+
&& !module.contains("Foundation")
|
|
123
|
+
&& !module.contains("CoreFoundation")
|
|
124
|
+
&& !module.contains("libsystem")
|
|
125
|
+
&& !module.contains("libobjc"),
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let event: [String: Any] = [
|
|
130
|
+
"id": UUID().uuidString.lowercased(),
|
|
131
|
+
"timestamp": iso8601(Date()),
|
|
132
|
+
"kind": "anr",
|
|
133
|
+
"platform": "ios",
|
|
134
|
+
"release": release,
|
|
135
|
+
"environment": environment,
|
|
136
|
+
"device": [
|
|
137
|
+
"os": "ios",
|
|
138
|
+
"osVersion": osVersion(),
|
|
139
|
+
"model": deviceModel(),
|
|
140
|
+
],
|
|
141
|
+
"app": appInfo(),
|
|
142
|
+
"user": NSNull(),
|
|
143
|
+
"tags": ["source": "sentori.hangWatchdog"],
|
|
144
|
+
"breadcrumbs": [Any](),
|
|
145
|
+
"error": [
|
|
146
|
+
"type": "ApplicationNotResponding",
|
|
147
|
+
"message": "Main thread blocked for ≥ \(durationMs) ms",
|
|
148
|
+
"stack": frames,
|
|
149
|
+
"cause": NSNull(),
|
|
150
|
+
],
|
|
151
|
+
"fingerprint": [String](),
|
|
152
|
+
"traceId": NSNull(),
|
|
153
|
+
"spanId": NSNull(),
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
guard
|
|
157
|
+
let docs = FileManager.default.urls(
|
|
158
|
+
for: .documentDirectory, in: .userDomainMask
|
|
159
|
+
).first
|
|
160
|
+
else { return }
|
|
161
|
+
let dir = docs.appendingPathComponent(pendingDirName)
|
|
162
|
+
try? FileManager.default.createDirectory(
|
|
163
|
+
at: dir, withIntermediateDirectories: true)
|
|
164
|
+
let url = dir.appendingPathComponent(
|
|
165
|
+
"\(UUID().uuidString.lowercased()).json")
|
|
166
|
+
if let data = try? JSONSerialization.data(
|
|
167
|
+
withJSONObject: event, options: [])
|
|
168
|
+
{
|
|
169
|
+
try? data.write(to: url)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private static func iso8601(_ date: Date) -> String {
|
|
174
|
+
let f = ISO8601DateFormatter()
|
|
175
|
+
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
176
|
+
return f.string(from: date)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private static func osVersion() -> String {
|
|
180
|
+
let v = ProcessInfo.processInfo.operatingSystemVersion
|
|
181
|
+
return "\(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private static func deviceModel() -> String {
|
|
185
|
+
var s = utsname()
|
|
186
|
+
uname(&s)
|
|
187
|
+
return withUnsafePointer(to: &s.machine) { ptr in
|
|
188
|
+
ptr.withMemoryRebound(to: CChar.self, capacity: 1) {
|
|
189
|
+
String(validatingUTF8: $0) ?? "unknown"
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private static func appInfo() -> [String: Any] {
|
|
195
|
+
let info = Bundle.main.infoDictionary ?? [:]
|
|
196
|
+
var d: [String: Any] = [
|
|
197
|
+
"version": (info["CFBundleShortVersionString"] as? String)
|
|
198
|
+
?? "0.0.0"
|
|
199
|
+
]
|
|
200
|
+
if let build = info["CFBundleVersion"] as? String {
|
|
201
|
+
d["build"] = build
|
|
202
|
+
}
|
|
203
|
+
return d
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/// Mutable boxes shared between the watchdog tick and the main-queue
|
|
208
|
+
/// ack. Class so the closures can mutate without `inout` and so the
|
|
209
|
+
/// references survive across the `setEventHandler` capture.
|
|
210
|
+
private final class HangState {
|
|
211
|
+
let armed = Box(false)
|
|
212
|
+
let armedAt = Box<DispatchTime?>(nil)
|
|
213
|
+
let reportedThisHang = Box(false)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private final class Box<T> {
|
|
217
|
+
var value: T
|
|
218
|
+
init(_ v: T) { self.value = v }
|
|
219
|
+
}
|
package/ios/SentoriModule.swift
CHANGED
|
@@ -24,6 +24,25 @@ public class SentoriModule: Module {
|
|
|
24
24
|
return SentoriCrashHandler.consumePending()
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// Phase 22 sub-E: opt-in iOS hang watchdog. Same JS function
|
|
28
|
+
// name as Android (sub-D) so the host app calls
|
|
29
|
+
// `startAnrWatchdog(...)` once, both platforms react.
|
|
30
|
+
// Defaults: 2 s timeout, 1 s tick interval, debug-build off.
|
|
31
|
+
Function("startAnrWatchdog") { (options: [String: Any]?) in
|
|
32
|
+
let timeoutMs = (options?["timeoutMs"] as? Int) ?? 2000
|
|
33
|
+
let intervalMs = (options?["intervalMs"] as? Int) ?? 1000
|
|
34
|
+
let force = (options?["force"] as? Bool) ?? false
|
|
35
|
+
SentoriHangWatchdog.start(
|
|
36
|
+
timeoutMs: timeoutMs,
|
|
37
|
+
intervalMs: intervalMs,
|
|
38
|
+
force: force
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
Function("stopAnrWatchdog") {
|
|
43
|
+
SentoriHangWatchdog.stop()
|
|
44
|
+
}
|
|
45
|
+
|
|
27
46
|
// Dev-only helper used by the example app to verify the
|
|
28
47
|
// crash-write / drain round-trip without writing native code in
|
|
29
48
|
// the host app. Schedules a real NSException after a tick so
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goliapkg/sentori-react-native",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Sentori SDK for React Native \u2014 JS-layer error capture, native crash handlers (iOS / Android), batched transport.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://sentori.golia.jp",
|