@logbrew/react-native 0.1.10 → 0.1.12

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.
Files changed (72) hide show
  1. package/LogBrewReactNative.podspec +25 -3
  2. package/README.md +174 -4
  3. package/android/src/main/java/co/logbrew/reactnative/EventRecordStore.java +611 -0
  4. package/android/src/main/java/co/logbrew/reactnative/FatalStoreModuleImpl.java +129 -0
  5. package/android/src/newarch/java/co/logbrew/reactnative/FatalStoreModule.java +26 -0
  6. package/android/src/oldarch/java/co/logbrew/reactnative/FatalStoreModule.java +26 -0
  7. package/apple-native-diagnostics.d.ts +52 -0
  8. package/apple-native-diagnostics.js +286 -0
  9. package/expo.cjs +46 -0
  10. package/expo.d.ts +16 -0
  11. package/expo.js +5 -0
  12. package/fatal-replay.cjs +12 -1
  13. package/global-errors.cjs +3 -1
  14. package/index.cjs +86 -2
  15. package/index.d.cts +7 -0
  16. package/index.d.ts +7 -0
  17. package/index.js +86 -2
  18. package/index.native.d.ts +47 -1
  19. package/index.native.js +81 -1
  20. package/ios/AppleDiagnostics/LBRNAppleDiagnosticsModule.h +4 -0
  21. package/ios/AppleDiagnostics/LBRNAppleDiagnosticsModule.mm +56 -0
  22. package/ios/AppleDiagnostics/LBRNAppleNativeDiagnostics.swift +293 -0
  23. package/ios/GeneratedAppleDiagnostics/LogBrew/AutomaticTelemetryContext.swift +72 -0
  24. package/ios/GeneratedAppleDiagnostics/LogBrew/DeliveryEngine.swift +270 -0
  25. package/ios/GeneratedAppleDiagnostics/LogBrew/DeliveryEngineAutomatic.swift +331 -0
  26. package/ios/GeneratedAppleDiagnostics/LogBrew/DeliveryEngineDurable.swift +148 -0
  27. package/ios/GeneratedAppleDiagnostics/LogBrew/DeliveryEngineQueue.swift +343 -0
  28. package/ios/GeneratedAppleDiagnostics/LogBrew/DeliveryLifecycle.swift +70 -0
  29. package/ios/GeneratedAppleDiagnostics/LogBrew/DurableDeliveryStore.swift +258 -0
  30. package/ios/GeneratedAppleDiagnostics/LogBrew/DurableDeliveryStoreRecovery.swift +330 -0
  31. package/ios/GeneratedAppleDiagnostics/LogBrew/EventEncoding.swift +114 -0
  32. package/ios/GeneratedAppleDiagnostics/LogBrew/IssueDiagnostics.swift +385 -0
  33. package/ios/GeneratedAppleDiagnostics/LogBrew/LifecycleTrace.swift +138 -0
  34. package/ios/GeneratedAppleDiagnostics/LogBrew/LogBrewClient.swift +358 -0
  35. package/ios/GeneratedAppleDiagnostics/LogBrew/LogBrewLogger.swift +262 -0
  36. package/ios/GeneratedAppleDiagnostics/LogBrew/LogBrewTrace.swift +357 -0
  37. package/ios/GeneratedAppleDiagnostics/LogBrew/Metadata.swift +86 -0
  38. package/ios/GeneratedAppleDiagnostics/LogBrew/NativeStackFrame.swift +27 -0
  39. package/ios/GeneratedAppleDiagnostics/LogBrew/ProductTimeline.swift +228 -0
  40. package/ios/GeneratedAppleDiagnostics/LogBrew/PublicTypes.swift +334 -0
  41. package/ios/GeneratedAppleDiagnostics/LogBrew/TelemetryContext.swift +197 -0
  42. package/ios/GeneratedAppleDiagnostics/LogBrew/TelemetryContextValidation.swift +337 -0
  43. package/ios/GeneratedAppleDiagnostics/LogBrew/TraceEvidence.swift +79 -0
  44. package/ios/GeneratedAppleDiagnostics/LogBrew/Transport.swift +204 -0
  45. package/ios/GeneratedAppleDiagnostics/LogBrew/URLSessionTrace.swift +277 -0
  46. package/ios/GeneratedAppleDiagnostics/LogBrew/URLSessionTracer.swift +151 -0
  47. package/ios/GeneratedAppleDiagnostics/LogBrew/Validation.swift +165 -0
  48. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/CrashEngine.swift +125 -0
  49. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/CrashReportSanitizer.swift +139 -0
  50. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/CrashStorageDirectory.swift +124 -0
  51. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeArtifactIdentity.swift +137 -0
  52. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeCrashCapture.swift +229 -0
  53. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeCrashPublic.swift +386 -0
  54. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeCrashReplay.swift +317 -0
  55. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeHangIncidentStore.swift +321 -0
  56. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeHangWatchdog.swift +386 -0
  57. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeHangWatchdogContracts.swift +84 -0
  58. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeHangWatchdogSystem.swift +139 -0
  59. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeMainThreadStackCapture.swift +84 -0
  60. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeStackFrameSanitizer.swift +170 -0
  61. package/ios/GeneratedAppleDiagnostics/SOURCE-MANIFEST.json +43 -0
  62. package/ios/LBRNEventRecordStore.h +29 -0
  63. package/ios/LBRNEventRecordStore.m +746 -0
  64. package/ios/LBRNFatalStoreModule.mm +159 -14
  65. package/ios/LBRNPrivateStorage.h +7 -0
  66. package/ios/LBRNPrivateStorage.m +38 -0
  67. package/package.json +47 -4
  68. package/persistent-delivery.native.js +282 -0
  69. package/promise-rejections.cjs +3 -1
  70. package/scripts/sync-apple-native-sources.mjs +93 -0
  71. package/src/NativeLogBrewAppleDiagnostics.ts +12 -0
  72. package/src/NativeLogBrewFatalStore.ts +9 -0
@@ -0,0 +1,317 @@
1
+ // Generated by scripts/sync-apple-native-sources.mjs.
2
+ // Edit the canonical Swift source, then regenerate this package boundary.
3
+ import Foundation
4
+
5
+ extension NativeCrashCapture {
6
+ func beginReplay() throws {
7
+ lock.lock()
8
+ defer { lock.unlock() }
9
+ try verifyProcessLocked()
10
+ guard store != nil, lifecycle != .stopped else {
11
+ throw NativeCrashError(.notInstalled)
12
+ }
13
+ guard !replaying else {
14
+ throw NativeCrashError(.replayBusy)
15
+ }
16
+ replaying = true
17
+ lifecycle = .replaying
18
+ }
19
+
20
+ func performReplay(_ handler: (NativeCrashRecord) -> Bool) throws -> NativeCrashReplayResult {
21
+ var attempted = 0
22
+ var accepted = 0
23
+ var discardedDuringReplay = 0
24
+ replayLoop: while true {
25
+ switch try nextReplayItem() {
26
+ case .none:
27
+ break replayLoop
28
+ case .discarded:
29
+ discardedDuringReplay += 1
30
+ case let .record(record):
31
+ attempted += 1
32
+ guard handler(record) else {
33
+ recordRetained()
34
+ break replayLoop
35
+ }
36
+ try acknowledge(record)
37
+ accepted += 1
38
+ }
39
+ }
40
+
41
+ return try NativeCrashReplayResult(
42
+ attempted: attempted,
43
+ acknowledged: accepted,
44
+ discarded: discardedDuringReplay,
45
+ pending: finishReplay(),
46
+ )
47
+ }
48
+
49
+ func nextReplayItem() throws -> NativeCrashReplayItem {
50
+ lock.lock()
51
+ defer { lock.unlock() }
52
+ guard let store else {
53
+ throw NativeCrashError(.notInstalled)
54
+ }
55
+ try verifyStorageLocked()
56
+ let reportIDs = try validatedReportIDs(in: store)
57
+ let hangItem = try nextHangReplayItem()
58
+ let engineItem = try nextEngineReplayItem(
59
+ reportIDs: reportIDs,
60
+ store: store,
61
+ )
62
+ if hangItem?.isDiscarded == true || engineItem?.isDiscarded == true {
63
+ return .discarded
64
+ }
65
+ return oldestRecord(in: [hangItem, engineItem])
66
+ }
67
+
68
+ func acknowledge(_ record: NativeCrashRecord) throws {
69
+ lock.lock()
70
+ defer { lock.unlock() }
71
+ guard record.ownerNonce == ownerNonce, let store else {
72
+ throw NativeCrashError(.reportChanged)
73
+ }
74
+ try verifyStorageLocked()
75
+ switch record.source {
76
+ case let .engine(reportID):
77
+ try acknowledgeEngineRecord(record, reportID: reportID, store: store)
78
+ case let .hang(eventID):
79
+ try acknowledgeHangRecord(record, eventID: eventID)
80
+ }
81
+ acknowledged += 1
82
+ lastOutcome = .acknowledged
83
+ }
84
+
85
+ func pendingReportsLocked() throws -> [NativeCrashRecord] {
86
+ guard let store else {
87
+ throw NativeCrashError(.notInstalled)
88
+ }
89
+ try verifyStorageLocked()
90
+ let reportIDs = try validatedReportIDs(in: store)
91
+
92
+ var records: [NativeCrashRecord] = []
93
+ if let incident = try hangStore?.read() {
94
+ try records.append(incident.makeRecord(ownerNonce: ownerNonce))
95
+ }
96
+ try records.append(contentsOf: reportIDs.map { reportID in
97
+ guard let rawReport = store.report(for: reportID) else {
98
+ throw NativeCrashError(.reportChanged)
99
+ }
100
+ return try sanitizer.makeRecord(reportID: reportID, rawReport: rawReport)
101
+ })
102
+ try verifyReportIDs(reportIDs, in: store)
103
+ return records.sorted(by: NativeCrashRecord.occursBefore)
104
+ }
105
+
106
+ func pendingCountLocked() throws -> Int {
107
+ guard let store else {
108
+ throw NativeCrashError(.notInstalled)
109
+ }
110
+ try verifyStorageLocked()
111
+ let reportIDs = try validatedReportIDs(in: store)
112
+ return try reportIDs.count + ((hangStore?.read()) == nil ? 0 : 1)
113
+ }
114
+
115
+ func failReplay() {
116
+ lock.lock()
117
+ replaying = false
118
+ lifecycle = .installed
119
+ lastOutcome = .failed
120
+ lock.unlock()
121
+ }
122
+ }
123
+
124
+ private extension NativeCrashCapture {
125
+ func nextHangReplayItem() throws -> NativeCrashReplayItem? {
126
+ guard let hangStore else {
127
+ return nil
128
+ }
129
+ do {
130
+ guard let incident = try hangStore.read() else {
131
+ return nil
132
+ }
133
+ return try .record(incident.makeRecord(ownerNonce: ownerNonce))
134
+ } catch let error as NativeCrashError where error.code == .reportCorrupt {
135
+ try hangStore.purge()
136
+ recordDiscardedLocked()
137
+ return .discarded
138
+ }
139
+ }
140
+
141
+ func nextEngineReplayItem(
142
+ reportIDs: [Int64],
143
+ store: any CrashReportStoring,
144
+ ) throws -> NativeCrashReplayItem? {
145
+ var records: [NativeCrashRecord] = []
146
+ for reportID in reportIDs {
147
+ let item = try engineReplayItem(
148
+ reportID: reportID,
149
+ expectedReportIDs: reportIDs,
150
+ store: store,
151
+ )
152
+ switch item {
153
+ case .none:
154
+ continue
155
+ case .discarded:
156
+ return .discarded
157
+ case let .record(record):
158
+ records.append(record)
159
+ }
160
+ }
161
+ return records.min(by: NativeCrashRecord.occursBefore).map(NativeCrashReplayItem.record)
162
+ }
163
+
164
+ func oldestRecord(in items: [NativeCrashReplayItem?]) -> NativeCrashReplayItem {
165
+ let records = items.compactMap { item -> NativeCrashRecord? in
166
+ guard case let .record(record) = item else {
167
+ return nil
168
+ }
169
+ return record
170
+ }
171
+ return records.min(by: NativeCrashRecord.occursBefore).map(NativeCrashReplayItem.record)
172
+ ?? .none
173
+ }
174
+
175
+ func engineReplayItem(
176
+ reportID: Int64,
177
+ expectedReportIDs: [Int64],
178
+ store: any CrashReportStoring,
179
+ ) throws -> NativeCrashReplayItem {
180
+ guard let rawReport = store.report(for: reportID) else {
181
+ throw NativeCrashError(.reportChanged)
182
+ }
183
+ do {
184
+ let record = try sanitizer.makeRecord(reportID: reportID, rawReport: rawReport)
185
+ try verifyReportIDs(expectedReportIDs, in: store)
186
+ return .record(record)
187
+ } catch let error as NativeCrashError where error.code == .reportCorrupt {
188
+ try verifyReportIDs(expectedReportIDs, in: store)
189
+ store.deleteReport(with: reportID)
190
+ guard !store.reportIDs.contains(reportID) else {
191
+ throw NativeCrashError(.reportDeletionFailed)
192
+ }
193
+ recordDiscardedLocked()
194
+ return .discarded
195
+ }
196
+ }
197
+
198
+ func validatedReportIDs(in store: any CrashReportStoring) throws -> [Int64] {
199
+ let reportIDs = store.reportIDs.sorted()
200
+ guard reportIDs.count <= configuration.maxStoredReports,
201
+ Set(reportIDs).count == reportIDs.count,
202
+ reportIDs.allSatisfy({ $0 > 0 })
203
+ else {
204
+ throw NativeCrashError(.reportCorrupt)
205
+ }
206
+ return reportIDs
207
+ }
208
+
209
+ func verifyReportIDs(
210
+ _ expectedReportIDs: [Int64],
211
+ in store: any CrashReportStoring,
212
+ ) throws {
213
+ guard store.reportIDs.sorted() == expectedReportIDs else {
214
+ throw NativeCrashError(.reportChanged)
215
+ }
216
+ }
217
+
218
+ func acknowledgeEngineRecord(
219
+ _ record: NativeCrashRecord,
220
+ reportID: Int64,
221
+ store: any CrashReportStoring,
222
+ ) throws {
223
+ guard store.reportIDs.contains(reportID),
224
+ let rawReport = store.report(for: reportID),
225
+ try sanitizer.digest(rawReport) == record.digest
226
+ else {
227
+ throw NativeCrashError(.reportChanged)
228
+ }
229
+ store.deleteReport(with: reportID)
230
+ guard !store.reportIDs.contains(reportID) else {
231
+ throw NativeCrashError(.reportDeletionFailed)
232
+ }
233
+ }
234
+
235
+ func acknowledgeHangRecord(
236
+ _ record: NativeCrashRecord,
237
+ eventID: String,
238
+ ) throws {
239
+ guard let hangStore,
240
+ let incident = try hangStore.read(),
241
+ incident.eventID == eventID,
242
+ try incident.digest() == record.digest
243
+ else {
244
+ throw NativeCrashError(.reportChanged)
245
+ }
246
+ try hangStore.delete(eventID: eventID)
247
+ }
248
+
249
+ func recordDiscardedLocked() {
250
+ if discarded < Int.max {
251
+ discarded += 1
252
+ }
253
+ lastOutcome = .discarded
254
+ }
255
+
256
+ func recordRetained() {
257
+ lock.lock()
258
+ lastOutcome = .retained
259
+ lock.unlock()
260
+ }
261
+
262
+ func finishReplay() throws -> Int {
263
+ lock.lock()
264
+ defer { lock.unlock() }
265
+ let pending = try pendingCountLocked()
266
+ replaying = false
267
+ lifecycle = .installed
268
+ return pending
269
+ }
270
+
271
+ var sanitizer: CrashReportSanitizer {
272
+ CrashReportSanitizer(
273
+ maxReplayBytes: configuration.maxReplayBytes,
274
+ ownerNonce: ownerNonce,
275
+ )
276
+ }
277
+ }
278
+
279
+ enum NativeCrashReplayItem {
280
+ case none
281
+ case discarded
282
+ case record(NativeCrashRecord)
283
+
284
+ var isDiscarded: Bool {
285
+ if case .discarded = self {
286
+ return true
287
+ }
288
+ return false
289
+ }
290
+ }
291
+
292
+ private extension NativeCrashRecord {
293
+ static func occursBefore(_ lhs: NativeCrashRecord, _ rhs: NativeCrashRecord) -> Bool {
294
+ let lhsTimestamp = NativeCrashTimestamp(lhs.timestamp)
295
+ let rhsTimestamp = NativeCrashTimestamp(rhs.timestamp)
296
+ guard let lhsTimestamp, let rhsTimestamp else {
297
+ return lhs.source.occursBefore(rhs.source)
298
+ }
299
+ if lhsTimestamp != rhsTimestamp {
300
+ return lhsTimestamp < rhsTimestamp
301
+ }
302
+ return lhs.source.occursBefore(rhs.source)
303
+ }
304
+ }
305
+
306
+ private extension NativeCrashRecordSource {
307
+ func occursBefore(_ other: NativeCrashRecordSource) -> Bool {
308
+ switch (self, other) {
309
+ case let (.engine(lhsReportID), .engine(rhsReportID)):
310
+ lhsReportID < rhsReportID
311
+ case (.engine, .hang):
312
+ true
313
+ case (.hang, .engine), (.hang, .hang):
314
+ false
315
+ }
316
+ }
317
+ }
@@ -0,0 +1,321 @@
1
+ // Generated by scripts/sync-apple-native-sources.mjs.
2
+ // Edit the canonical Swift source, then regenerate this package boundary.
3
+ import CryptoKit
4
+ import Darwin
5
+ import Foundation
6
+
7
+ enum NativeHangIncidentState: String, Codable {
8
+ case ongoing
9
+ case recovered
10
+ }
11
+
12
+ enum NativeHangDuration {
13
+ static let maxMilliseconds = 24 * 60 * 60 * 1000.0
14
+
15
+ static func validated(_ milliseconds: Double) throws -> Double {
16
+ guard milliseconds.isFinite,
17
+ milliseconds >= 0,
18
+ milliseconds <= maxMilliseconds
19
+ else {
20
+ throw NativeCrashError(.reportCorrupt)
21
+ }
22
+ return milliseconds
23
+ }
24
+
25
+ static func milliseconds(
26
+ from monotonicStart: TimeInterval,
27
+ through monotonicEnd: TimeInterval,
28
+ ) -> Double? {
29
+ guard monotonicStart.isFinite,
30
+ monotonicEnd.isFinite,
31
+ monotonicEnd >= monotonicStart
32
+ else {
33
+ return nil
34
+ }
35
+ return try? validated((monotonicEnd - monotonicStart) * 1000)
36
+ }
37
+ }
38
+
39
+ struct NativeHangIncident: Codable, Equatable {
40
+ private let version: Int
41
+ let eventID: String
42
+ let timestamp: String
43
+ var state: NativeHangIncidentState
44
+ let artifactIdentity: NativeArtifactIdentityValue
45
+ let nativeStackFrames: [NativeStackFrame]
46
+ var durationMs: Double?
47
+
48
+ init(
49
+ eventID: String,
50
+ timestamp: String,
51
+ state: NativeHangIncidentState,
52
+ identity: NativeArtifactIdentity,
53
+ nativeStackFrames: [NativeStackFrame],
54
+ durationMs: Double? = nil,
55
+ ) {
56
+ version = 1
57
+ self.eventID = eventID
58
+ self.timestamp = timestamp
59
+ self.state = state
60
+ artifactIdentity = NativeArtifactIdentityValue(identity)
61
+ self.nativeStackFrames = nativeStackFrames
62
+ self.durationMs = durationMs
63
+ }
64
+
65
+ func validated() throws -> NativeHangIncident {
66
+ guard version == 1,
67
+ UUID(uuidString: eventID)?.uuidString.lowercased() == eventID,
68
+ normalizedTimestamp(timestamp) == timestamp,
69
+ (1 ... 32).contains(nativeStackFrames.count),
70
+ nativeStackFrames.allSatisfy(validFrame)
71
+ else {
72
+ throw NativeCrashError(.reportCorrupt)
73
+ }
74
+ do {
75
+ _ = try artifactIdentity.validatedIdentity()
76
+ if let durationMs {
77
+ _ = try NativeHangDuration.validated(durationMs)
78
+ }
79
+ } catch {
80
+ throw NativeCrashError(.reportCorrupt)
81
+ }
82
+ return self
83
+ }
84
+
85
+ func digest() throws -> Data {
86
+ try Data(SHA256.hash(data: encoded()))
87
+ }
88
+
89
+ func makeRecord(ownerNonce: UUID) throws -> NativeCrashRecord {
90
+ try NativeCrashRecord(
91
+ eventID: eventID,
92
+ timestamp: timestamp,
93
+ mechanism: .deadlock,
94
+ nativeStackFrames: nativeStackFrames,
95
+ artifactIdentity: artifactIdentity.validatedIdentity(),
96
+ hangState: state,
97
+ hangDurationMs: durationMs,
98
+ source: .hang(eventID: eventID),
99
+ digest: digest(),
100
+ ownerNonce: ownerNonce,
101
+ )
102
+ }
103
+
104
+ func encoded() throws -> Data {
105
+ let encoder = JSONEncoder()
106
+ encoder.outputFormatting = [.sortedKeys]
107
+ let data = try encoder.encode(self)
108
+ guard data.count <= NativeHangIncidentFileStore.maxBytes else {
109
+ throw NativeCrashError(.reportCorrupt)
110
+ }
111
+ return data
112
+ }
113
+
114
+ private func validFrame(_ frame: NativeStackFrame) -> Bool {
115
+ UUID(uuidString: frame.imageUuid)?.uuidString.lowercased() == frame.imageUuid
116
+ && frame.instructionOffset.utf8.count == 16
117
+ && frame.instructionOffset.utf8.allSatisfy {
118
+ ($0 >= 48 && $0 <= 57) || ($0 >= 97 && $0 <= 102)
119
+ }
120
+ }
121
+
122
+ private func normalizedTimestamp(_ value: String) -> String? {
123
+ let formatter = ISO8601DateFormatter()
124
+ formatter.formatOptions = [.withInternetDateTime]
125
+ guard let date = formatter.date(from: value) else {
126
+ return nil
127
+ }
128
+ return formatter.string(from: date)
129
+ }
130
+ }
131
+
132
+ protocol HangIncidentStoring: AnyObject, Sendable {
133
+ func read() throws -> NativeHangIncident?
134
+ func write(_ incident: NativeHangIncident) throws
135
+ func markRecovered(eventID: String, durationMs: Double) throws
136
+ func delete(eventID: String) throws
137
+ func purge() throws
138
+ }
139
+
140
+ final class NativeHangIncidentFileStore: HangIncidentStoring, @unchecked Sendable {
141
+ static let recordName = "hang-v1.record"
142
+ static let maxBytes = 16 * 1024
143
+
144
+ private let directory: URL
145
+ private let recordURL: URL
146
+ private let temporaryURL: URL
147
+ private let lease: CrashStorageLease
148
+ private let lock = NSLock()
149
+
150
+ init(directory: URL) throws {
151
+ self.directory = CrashStorageDirectory.normalized(directory)
152
+ lease = try CrashStorageDirectory.prepare(self.directory)
153
+ recordURL = self.directory.appendingPathComponent(Self.recordName, isDirectory: false)
154
+ temporaryURL = self.directory.appendingPathComponent("\(Self.recordName).tmp", isDirectory: false)
155
+ try removeStaleTemporary()
156
+ }
157
+
158
+ func read() throws -> NativeHangIncident? {
159
+ lock.lock()
160
+ defer { lock.unlock() }
161
+ return try readLocked()
162
+ }
163
+
164
+ func write(_ incident: NativeHangIncident) throws {
165
+ lock.lock()
166
+ defer { lock.unlock() }
167
+ guard try readLocked() == nil else {
168
+ throw NativeCrashError(.reportChanged)
169
+ }
170
+ try writeLocked(incident.validated())
171
+ }
172
+
173
+ func markRecovered(eventID: String, durationMs: Double) throws {
174
+ lock.lock()
175
+ defer { lock.unlock() }
176
+ guard var incident = try readLocked(), incident.eventID == eventID else {
177
+ throw NativeCrashError(.reportChanged)
178
+ }
179
+ let recoveredDuration = try NativeHangDuration.validated(durationMs)
180
+ guard incident.durationMs.map({ recoveredDuration >= $0 }) ?? true else {
181
+ throw NativeCrashError(.reportChanged)
182
+ }
183
+ incident.state = .recovered
184
+ incident.durationMs = recoveredDuration
185
+ try writeLocked(incident.validated())
186
+ }
187
+
188
+ func delete(eventID: String) throws {
189
+ lock.lock()
190
+ defer { lock.unlock() }
191
+ guard let incident = try readLocked(), incident.eventID == eventID else {
192
+ throw NativeCrashError(.reportChanged)
193
+ }
194
+ try lease.verify()
195
+ guard unlink(recordURL.path) == 0 else {
196
+ throw NativeCrashError(.reportDeletionFailed)
197
+ }
198
+ try lease.synchronize()
199
+ guard try readLocked() == nil else {
200
+ throw NativeCrashError(.reportDeletionFailed)
201
+ }
202
+ }
203
+
204
+ func purge() throws {
205
+ lock.lock()
206
+ defer { lock.unlock() }
207
+ try lease.verify()
208
+ if unlink(recordURL.path) != 0, errno != ENOENT {
209
+ throw NativeCrashError(.reportDeletionFailed)
210
+ }
211
+ try lease.synchronize()
212
+ }
213
+
214
+ private func readLocked() throws -> NativeHangIncident? {
215
+ try lease.verify()
216
+ let descriptor = open(recordURL.path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC)
217
+ if descriptor < 0 {
218
+ if errno == ENOENT {
219
+ return nil
220
+ }
221
+ throw NativeCrashError(.storageUnsupported)
222
+ }
223
+ defer { close(descriptor) }
224
+
225
+ var information = stat()
226
+ guard fstat(descriptor, &information) == 0,
227
+ information.st_mode & S_IFMT == S_IFREG,
228
+ information.st_mode & 0o077 == 0,
229
+ information.st_size > 0,
230
+ information.st_size <= Self.maxBytes
231
+ else {
232
+ throw NativeCrashError(.reportCorrupt)
233
+ }
234
+ var data = Data(count: Int(information.st_size))
235
+ let count = data.withUnsafeMutableBytes { buffer -> Int in
236
+ guard let base = buffer.baseAddress else {
237
+ return -1
238
+ }
239
+ var consumed = 0
240
+ while consumed < buffer.count {
241
+ let result = Darwin.read(descriptor, base.advanced(by: consumed), buffer.count - consumed)
242
+ if result <= 0 {
243
+ return -1
244
+ }
245
+ consumed += result
246
+ }
247
+ return consumed
248
+ }
249
+ guard count == data.count else {
250
+ throw NativeCrashError(.reportCorrupt)
251
+ }
252
+ do {
253
+ return try JSONDecoder().decode(NativeHangIncident.self, from: data).validated()
254
+ } catch let error as NativeCrashError {
255
+ throw error
256
+ } catch {
257
+ throw NativeCrashError(.reportCorrupt)
258
+ }
259
+ }
260
+
261
+ private func writeLocked(_ incident: NativeHangIncident) throws {
262
+ try lease.verify()
263
+ let data = try incident.encoded()
264
+ try removeStaleTemporary()
265
+ let descriptor = open(
266
+ temporaryURL.path,
267
+ O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC,
268
+ S_IRUSR | S_IWUSR,
269
+ )
270
+ guard descriptor >= 0 else {
271
+ throw NativeCrashError(.storageUnsupported)
272
+ }
273
+
274
+ var succeeded = false
275
+ defer {
276
+ close(descriptor)
277
+ if !succeeded {
278
+ unlink(temporaryURL.path)
279
+ }
280
+ }
281
+ let written = data.withUnsafeBytes { buffer -> Bool in
282
+ guard let base = buffer.baseAddress else {
283
+ return false
284
+ }
285
+ var consumed = 0
286
+ while consumed < buffer.count {
287
+ let result = Darwin.write(descriptor, base.advanced(by: consumed), buffer.count - consumed)
288
+ if result <= 0 {
289
+ return false
290
+ }
291
+ consumed += result
292
+ }
293
+ return true
294
+ }
295
+ guard written,
296
+ fsync(descriptor) == 0,
297
+ rename(temporaryURL.path, recordURL.path) == 0
298
+ else {
299
+ throw NativeCrashError(.storageUnsupported)
300
+ }
301
+ try lease.synchronize()
302
+ succeeded = true
303
+ }
304
+
305
+ private func removeStaleTemporary() throws {
306
+ var information = stat()
307
+ if lstat(temporaryURL.path, &information) != 0 {
308
+ if errno == ENOENT {
309
+ return
310
+ }
311
+ throw NativeCrashError(.storageUnsupported)
312
+ }
313
+ guard information.st_mode & S_IFMT == S_IFREG,
314
+ information.st_mode & 0o077 == 0,
315
+ unlink(temporaryURL.path) == 0
316
+ else {
317
+ throw NativeCrashError(.storageUnsupported)
318
+ }
319
+ try lease.synchronize()
320
+ }
321
+ }