react-native-nitro-logger 0.1.0

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 (205) hide show
  1. package/LICENSE +20 -0
  2. package/NitroLogger.podspec +29 -0
  3. package/README.md +214 -0
  4. package/android/CMakeLists.txt +24 -0
  5. package/android/build.gradle +124 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/cpp/cpp-adapter.cpp +11 -0
  8. package/android/src/main/java/com/margelo/nitro/nitrologger/AndroidPlatformIo.kt +100 -0
  9. package/android/src/main/java/com/margelo/nitro/nitrologger/HybridFileSink.kt +292 -0
  10. package/android/src/main/java/com/margelo/nitro/nitrologger/HybridNativeConsoleSink.kt +23 -0
  11. package/android/src/main/java/com/margelo/nitro/nitrologger/LogFileWriter.kt +1328 -0
  12. package/android/src/main/java/com/margelo/nitro/nitrologger/LogRotationPolicy.kt +186 -0
  13. package/android/src/main/java/com/margelo/nitro/nitrologger/LogSecureFile.kt +80 -0
  14. package/android/src/main/java/com/margelo/nitro/nitrologger/LogWriterRegistry.kt +420 -0
  15. package/android/src/main/java/com/margelo/nitro/nitrologger/NativeConsoleWriter.kt +237 -0
  16. package/android/src/main/java/com/margelo/nitro/nitrologger/NitroLoggerPackage.kt +22 -0
  17. package/android/src/main/java/com/margelo/nitro/nitrologger/PlatformIo.kt +165 -0
  18. package/docs/PARITY.md +330 -0
  19. package/docs/PRIVACY.md +341 -0
  20. package/eslint-plugin/README.md +238 -0
  21. package/eslint-plugin/index.js +62 -0
  22. package/eslint-plugin/rules/literal-subsystem.js +102 -0
  23. package/eslint-plugin/rules/no-computed-metadata-key.js +174 -0
  24. package/eslint-plugin/rules/no-derived-correlation.js +171 -0
  25. package/eslint-plugin/rules/no-dynamic-message.js +60 -0
  26. package/eslint-plugin/shared.js +1948 -0
  27. package/ios/Gzip.swift +203 -0
  28. package/ios/HybridFileSink.swift +291 -0
  29. package/ios/HybridNativeConsoleSink.swift +20 -0
  30. package/ios/LogFileWriter.swift +1280 -0
  31. package/ios/LogSecureFile.swift +296 -0
  32. package/ios/LogWriterRegistry.swift +444 -0
  33. package/ios/NativeConsoleWriter.swift +180 -0
  34. package/lib/module/Logger.js +381 -0
  35. package/lib/module/Logger.js.map +1 -0
  36. package/lib/module/ScopedLogger.js +70 -0
  37. package/lib/module/ScopedLogger.js.map +1 -0
  38. package/lib/module/config.js +23 -0
  39. package/lib/module/config.js.map +1 -0
  40. package/lib/module/destinations/Batcher.js +647 -0
  41. package/lib/module/destinations/Batcher.js.map +1 -0
  42. package/lib/module/destinations/ConsoleDestination.js +51 -0
  43. package/lib/module/destinations/ConsoleDestination.js.map +1 -0
  44. package/lib/module/destinations/FileDestination.js +360 -0
  45. package/lib/module/destinations/FileDestination.js.map +1 -0
  46. package/lib/module/destinations/NativeConsoleDestination.js +170 -0
  47. package/lib/module/destinations/NativeConsoleDestination.js.map +1 -0
  48. package/lib/module/destinations/types.js +4 -0
  49. package/lib/module/destinations/types.js.map +1 -0
  50. package/lib/module/formatters/DefaultFormatter.js +136 -0
  51. package/lib/module/formatters/DefaultFormatter.js.map +1 -0
  52. package/lib/module/formatters/JsonLinesFormatter.js +162 -0
  53. package/lib/module/formatters/JsonLinesFormatter.js.map +1 -0
  54. package/lib/module/formatters/timestamp.js +15 -0
  55. package/lib/module/formatters/timestamp.js.map +1 -0
  56. package/lib/module/formatters/types.js +4 -0
  57. package/lib/module/formatters/types.js.map +1 -0
  58. package/lib/module/index.js +31 -0
  59. package/lib/module/index.js.map +1 -0
  60. package/lib/module/integrations/appState.js +81 -0
  61. package/lib/module/integrations/appState.js.map +1 -0
  62. package/lib/module/integrations/errorHandler.js +166 -0
  63. package/lib/module/integrations/errorHandler.js.map +1 -0
  64. package/lib/module/integrations/sanitizeError.js +238 -0
  65. package/lib/module/integrations/sanitizeError.js.map +1 -0
  66. package/lib/module/levels.js +35 -0
  67. package/lib/module/levels.js.map +1 -0
  68. package/lib/module/metadata.js +52 -0
  69. package/lib/module/metadata.js.map +1 -0
  70. package/lib/module/package.json +1 -0
  71. package/lib/module/privacy.js +349 -0
  72. package/lib/module/privacy.js.map +1 -0
  73. package/lib/module/specs/FileSink.nitro.js +4 -0
  74. package/lib/module/specs/FileSink.nitro.js.map +1 -0
  75. package/lib/module/specs/NativeConsoleSink.nitro.js +4 -0
  76. package/lib/module/specs/NativeConsoleSink.nitro.js.map +1 -0
  77. package/lib/module/types.js +4 -0
  78. package/lib/module/types.js.map +1 -0
  79. package/lib/module/utf8.js +42 -0
  80. package/lib/module/utf8.js.map +1 -0
  81. package/lib/typescript/package.json +1 -0
  82. package/lib/typescript/src/Logger.d.ts +122 -0
  83. package/lib/typescript/src/Logger.d.ts.map +1 -0
  84. package/lib/typescript/src/ScopedLogger.d.ts +36 -0
  85. package/lib/typescript/src/ScopedLogger.d.ts.map +1 -0
  86. package/lib/typescript/src/config.d.ts +12 -0
  87. package/lib/typescript/src/config.d.ts.map +1 -0
  88. package/lib/typescript/src/destinations/Batcher.d.ts +244 -0
  89. package/lib/typescript/src/destinations/Batcher.d.ts.map +1 -0
  90. package/lib/typescript/src/destinations/ConsoleDestination.d.ts +27 -0
  91. package/lib/typescript/src/destinations/ConsoleDestination.d.ts.map +1 -0
  92. package/lib/typescript/src/destinations/FileDestination.d.ts +200 -0
  93. package/lib/typescript/src/destinations/FileDestination.d.ts.map +1 -0
  94. package/lib/typescript/src/destinations/NativeConsoleDestination.d.ts +86 -0
  95. package/lib/typescript/src/destinations/NativeConsoleDestination.d.ts.map +1 -0
  96. package/lib/typescript/src/destinations/types.d.ts +19 -0
  97. package/lib/typescript/src/destinations/types.d.ts.map +1 -0
  98. package/lib/typescript/src/formatters/DefaultFormatter.d.ts +22 -0
  99. package/lib/typescript/src/formatters/DefaultFormatter.d.ts.map +1 -0
  100. package/lib/typescript/src/formatters/JsonLinesFormatter.d.ts +73 -0
  101. package/lib/typescript/src/formatters/JsonLinesFormatter.d.ts.map +1 -0
  102. package/lib/typescript/src/formatters/timestamp.d.ts +6 -0
  103. package/lib/typescript/src/formatters/timestamp.d.ts.map +1 -0
  104. package/lib/typescript/src/formatters/types.d.ts +27 -0
  105. package/lib/typescript/src/formatters/types.d.ts.map +1 -0
  106. package/lib/typescript/src/index.d.ts +34 -0
  107. package/lib/typescript/src/index.d.ts.map +1 -0
  108. package/lib/typescript/src/integrations/appState.d.ts +33 -0
  109. package/lib/typescript/src/integrations/appState.d.ts.map +1 -0
  110. package/lib/typescript/src/integrations/errorHandler.d.ts +55 -0
  111. package/lib/typescript/src/integrations/errorHandler.d.ts.map +1 -0
  112. package/lib/typescript/src/integrations/sanitizeError.d.ts +89 -0
  113. package/lib/typescript/src/integrations/sanitizeError.d.ts.map +1 -0
  114. package/lib/typescript/src/levels.d.ts +9 -0
  115. package/lib/typescript/src/levels.d.ts.map +1 -0
  116. package/lib/typescript/src/metadata.d.ts +23 -0
  117. package/lib/typescript/src/metadata.d.ts.map +1 -0
  118. package/lib/typescript/src/privacy.d.ts +103 -0
  119. package/lib/typescript/src/privacy.d.ts.map +1 -0
  120. package/lib/typescript/src/specs/FileSink.nitro.d.ts +131 -0
  121. package/lib/typescript/src/specs/FileSink.nitro.d.ts.map +1 -0
  122. package/lib/typescript/src/specs/NativeConsoleSink.nitro.d.ts +21 -0
  123. package/lib/typescript/src/specs/NativeConsoleSink.nitro.d.ts.map +1 -0
  124. package/lib/typescript/src/types.d.ts +37 -0
  125. package/lib/typescript/src/types.d.ts.map +1 -0
  126. package/lib/typescript/src/utf8.d.ts +15 -0
  127. package/lib/typescript/src/utf8.d.ts.map +1 -0
  128. package/nitro.json +33 -0
  129. package/nitrogen/generated/android/c++/JAppendResult.hpp +79 -0
  130. package/nitrogen/generated/android/c++/JClearOutcome.hpp +88 -0
  131. package/nitrogen/generated/android/c++/JFlushOutcome.hpp +81 -0
  132. package/nitrogen/generated/android/c++/JHybridFileSinkSpec.cpp +120 -0
  133. package/nitrogen/generated/android/c++/JHybridFileSinkSpec.hpp +69 -0
  134. package/nitrogen/generated/android/c++/JHybridNativeConsoleSinkSpec.cpp +71 -0
  135. package/nitrogen/generated/android/c++/JHybridNativeConsoleSinkSpec.hpp +64 -0
  136. package/nitrogen/generated/android/c++/JRejectReason.hpp +64 -0
  137. package/nitrogen/generated/android/c++/JRotationConfig.hpp +77 -0
  138. package/nitrogen/generated/android/c++/JSinkStatus.hpp +69 -0
  139. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/AppendResult.kt +76 -0
  140. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/ClearOutcome.kt +66 -0
  141. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/FlushOutcome.kt +81 -0
  142. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/HybridFileSinkSpec.kt +80 -0
  143. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/HybridNativeConsoleSinkSpec.kt +58 -0
  144. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/RejectReason.kt +25 -0
  145. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/RotationConfig.kt +76 -0
  146. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/SinkStatus.kt +66 -0
  147. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/nitrologgerOnLoad.kt +35 -0
  148. package/nitrogen/generated/android/nitrologger+autolinking.cmake +83 -0
  149. package/nitrogen/generated/android/nitrologger+autolinking.gradle +27 -0
  150. package/nitrogen/generated/android/nitrologgerOnLoad.cpp +70 -0
  151. package/nitrogen/generated/android/nitrologgerOnLoad.hpp +34 -0
  152. package/nitrogen/generated/ios/NitroLogger+autolinking.rb +62 -0
  153. package/nitrogen/generated/ios/NitroLogger-Swift-Cxx-Bridge.cpp +50 -0
  154. package/nitrogen/generated/ios/NitroLogger-Swift-Cxx-Bridge.hpp +216 -0
  155. package/nitrogen/generated/ios/NitroLogger-Swift-Cxx-Umbrella.hpp +69 -0
  156. package/nitrogen/generated/ios/NitroLoggerAutolinking.mm +41 -0
  157. package/nitrogen/generated/ios/NitroLoggerAutolinking.swift +38 -0
  158. package/nitrogen/generated/ios/c++/HybridFileSinkSpecSwift.cpp +11 -0
  159. package/nitrogen/generated/ios/c++/HybridFileSinkSpecSwift.hpp +150 -0
  160. package/nitrogen/generated/ios/c++/HybridNativeConsoleSinkSpecSwift.cpp +11 -0
  161. package/nitrogen/generated/ios/c++/HybridNativeConsoleSinkSpecSwift.hpp +87 -0
  162. package/nitrogen/generated/ios/swift/AppendResult.swift +60 -0
  163. package/nitrogen/generated/ios/swift/ClearOutcome.swift +50 -0
  164. package/nitrogen/generated/ios/swift/FlushOutcome.swift +59 -0
  165. package/nitrogen/generated/ios/swift/HybridFileSinkSpec.swift +61 -0
  166. package/nitrogen/generated/ios/swift/HybridFileSinkSpec_cxx.swift +227 -0
  167. package/nitrogen/generated/ios/swift/HybridNativeConsoleSinkSpec.swift +56 -0
  168. package/nitrogen/generated/ios/swift/HybridNativeConsoleSinkSpec_cxx.swift +148 -0
  169. package/nitrogen/generated/ios/swift/RejectReason.swift +48 -0
  170. package/nitrogen/generated/ios/swift/RotationConfig.swift +93 -0
  171. package/nitrogen/generated/ios/swift/SinkStatus.swift +44 -0
  172. package/nitrogen/generated/shared/c++/AppendResult.hpp +105 -0
  173. package/nitrogen/generated/shared/c++/ClearOutcome.hpp +96 -0
  174. package/nitrogen/generated/shared/c++/FlushOutcome.hpp +107 -0
  175. package/nitrogen/generated/shared/c++/HybridFileSinkSpec.cpp +28 -0
  176. package/nitrogen/generated/shared/c++/HybridFileSinkSpec.hpp +84 -0
  177. package/nitrogen/generated/shared/c++/HybridNativeConsoleSinkSpec.cpp +22 -0
  178. package/nitrogen/generated/shared/c++/HybridNativeConsoleSinkSpec.hpp +64 -0
  179. package/nitrogen/generated/shared/c++/RejectReason.hpp +84 -0
  180. package/nitrogen/generated/shared/c++/RotationConfig.hpp +103 -0
  181. package/nitrogen/generated/shared/c++/SinkStatus.hpp +95 -0
  182. package/package.json +151 -0
  183. package/src/Logger.ts +417 -0
  184. package/src/ScopedLogger.ts +94 -0
  185. package/src/config.ts +25 -0
  186. package/src/destinations/Batcher.ts +805 -0
  187. package/src/destinations/ConsoleDestination.ts +60 -0
  188. package/src/destinations/FileDestination.ts +473 -0
  189. package/src/destinations/NativeConsoleDestination.ts +220 -0
  190. package/src/destinations/types.ts +19 -0
  191. package/src/formatters/DefaultFormatter.ts +150 -0
  192. package/src/formatters/JsonLinesFormatter.ts +195 -0
  193. package/src/formatters/timestamp.ts +12 -0
  194. package/src/formatters/types.ts +29 -0
  195. package/src/index.tsx +106 -0
  196. package/src/integrations/appState.ts +111 -0
  197. package/src/integrations/errorHandler.ts +223 -0
  198. package/src/integrations/sanitizeError.ts +287 -0
  199. package/src/levels.ts +35 -0
  200. package/src/metadata.ts +52 -0
  201. package/src/privacy.ts +389 -0
  202. package/src/specs/FileSink.nitro.ts +144 -0
  203. package/src/specs/NativeConsoleSink.nitro.ts +21 -0
  204. package/src/types.ts +45 -0
  205. package/src/utf8.ts +39 -0
@@ -0,0 +1,1280 @@
1
+ import Foundation
2
+
3
+ // MARK: - Configuration
4
+
5
+ /// Rotation and retention for one log file.
6
+ ///
7
+ /// Every field is clamped on the way in. These numbers arrive from JavaScript,
8
+ /// where `NaN`, `Infinity`, and `-1` are all ordinary values, and an unchecked
9
+ /// cast of any of them into a Swift integer traps — taking the app down from
10
+ /// inside the logger.
11
+ public struct LogRotationPolicy: Sendable, Equatable {
12
+ /// Soft threshold. Overshoot is bounded by one batch, because the size is
13
+ /// checked after a write rather than before.
14
+ public var maxFileSizeBytes: UInt64
15
+ /// Archives kept by count. Zero keeps none.
16
+ public var maxArchivedFilesCount: Int
17
+ /// Rotate the current file once this old regardless of size. Measured from
18
+ /// the file's creation date, so it survives a restart.
19
+ public var maxFileAgeSeconds: TimeInterval?
20
+ public var compressArchives: Bool
21
+ /// Delete archives older than this even when under the count cap.
22
+ public var maxArchiveAgeSeconds: TimeInterval?
23
+ /// Bound on the current file and all archives together.
24
+ public var maxTotalLogBytes: UInt64?
25
+
26
+ public init(
27
+ maxFileSizeBytes: Double = 10_485_760,
28
+ maxArchivedFilesCount: Double = 5,
29
+ maxFileAgeSeconds: Double? = nil,
30
+ compressArchives: Bool = false,
31
+ maxArchiveAgeSeconds: Double? = nil,
32
+ maxTotalLogBytes: Double? = nil
33
+ ) {
34
+ self.maxFileSizeBytes = LogRotationPolicy.bytes(maxFileSizeBytes, fallback: 10_485_760)
35
+ self.maxArchivedFilesCount = LogRotationPolicy.count(maxArchivedFilesCount, fallback: 5)
36
+ self.maxFileAgeSeconds = LogRotationPolicy.seconds(maxFileAgeSeconds)
37
+ self.compressArchives = compressArchives
38
+ self.maxArchiveAgeSeconds = LogRotationPolicy.seconds(maxArchiveAgeSeconds)
39
+ self.maxTotalLogBytes = LogRotationPolicy.optionalBytes(maxTotalLogBytes)
40
+ }
41
+
42
+ /// Effectively "no limit" while still fitting in the integer types below.
43
+ private static let byteCeiling: UInt64 = 1_000_000_000_000_000
44
+ private static let countCeiling = 10_000
45
+
46
+ /// `Infinity` clamps UP, to the ceiling. It reads as "never rotate", and
47
+ /// folding it into the default — or into zero — would turn a request for no
48
+ /// rotation into constant rotation, or into deleting every archive. Only
49
+ /// `NaN` and non-positive values fall back, because those carry no intent to
50
+ /// honour.
51
+ private static func bytes(_ value: Double, fallback: UInt64) -> UInt64 {
52
+ if value.isNaN || value < 1 { return fallback }
53
+ if value.isInfinite { return byteCeiling }
54
+ return UInt64(min(value, Double(byteCeiling)))
55
+ }
56
+
57
+ /// Same rule as `bytes`, and for a sharper reason: this limit is what pruning
58
+ /// deletes down to.
59
+ ///
60
+ /// A literal zero is a real instruction — "keep no archives" — so it is
61
+ /// honoured. `NaN` and negatives are not instructions at all, and folding them
62
+ /// into zero would make one malformed number from JavaScript delete every
63
+ /// rotated file on the next sweep. They fall back to the default instead:
64
+ /// retaining five archives nobody asked for is recoverable, and deleting the
65
+ /// lot is not.
66
+ private static func count(_ value: Double, fallback: Int) -> Int {
67
+ if value.isNaN || value < 0 { return fallback }
68
+ if value.isInfinite { return countCeiling }
69
+ return Int(min(value, Double(countCeiling)))
70
+ }
71
+
72
+ /// Optional limits read `Infinity` as absence: "no age cap" and "no cap at
73
+ /// all" are the same instruction, and `nil` is how this type spells it.
74
+ private static func seconds(_ value: Double?) -> TimeInterval? {
75
+ guard let value, value.isFinite, value > 0 else { return nil }
76
+ return min(value, 1e9)
77
+ }
78
+
79
+ private static func optionalBytes(_ value: Double?) -> UInt64? {
80
+ guard let value, value.isFinite, value >= 1 else { return nil }
81
+ return UInt64(min(value, Double(byteCeiling)))
82
+ }
83
+ }
84
+
85
+ /// Payload-free record of what has stopped working.
86
+ ///
87
+ /// A bitmask rather than messages on purpose: these cross into JavaScript and
88
+ /// eventually into a log file, and a path or an `errno` description is exactly
89
+ /// the kind of thing that carries a username in it.
90
+ public struct LogDegradation: OptionSet, Sendable {
91
+ public let rawValue: Int
92
+ public init(rawValue: Int) { self.rawValue = rawValue }
93
+
94
+ public static let rotation = LogDegradation(rawValue: 1 << 0)
95
+ public static let gzip = LogDegradation(rawValue: 1 << 1)
96
+ public static let prune = LogDegradation(rawValue: 1 << 2)
97
+ public static let sidecar = LogDegradation(rawValue: 1 << 3)
98
+ public static let protection = LogDegradation(rawValue: 1 << 4)
99
+ }
100
+
101
+ // MARK: - Results
102
+
103
+ public struct LogSinkStatus: Sendable {
104
+ public var queuedBytes: Int
105
+ public var lostBytes: Int
106
+ public var lostEntries: Int
107
+ public var degraded: Int
108
+ }
109
+
110
+ public enum LogRejectReason: String, Sendable {
111
+ case full
112
+ case staleGeneration
113
+ case closed
114
+ case failed
115
+ }
116
+
117
+ public struct LogAppendResult: Sendable {
118
+ public var accepted: Bool
119
+ public var rejectReason: LogRejectReason?
120
+ public var status: LogSinkStatus
121
+ }
122
+
123
+ public struct LogFlushOutcome: Sendable {
124
+ public var durable: Bool
125
+ public var timedOut: Bool
126
+ public var pendingBytes: Int
127
+ public var status: LogSinkStatus
128
+ }
129
+
130
+ public struct LogClearOutcome: Sendable {
131
+ public var deletedCount: Int
132
+ public var failedPaths: [String]
133
+ /// Every artifact is gone. This is the compliance question.
134
+ public var durable: Bool
135
+ /// The writer has a usable file again. Separate from `durable` because a
136
+ /// complete deletion can still be followed by a failed reopen, and a caller
137
+ /// that resumes on `durable` alone writes into a writer with nowhere to put
138
+ /// anything.
139
+ public var rebound: Bool = false
140
+ }
141
+
142
+ public enum LogWriterError: Error, Equatable {
143
+ case openFailed(String)
144
+ case configConflict
145
+ case symlinkEscape
146
+ /// A previous writer for this path has not finished shutting down. Retrying
147
+ /// is reasonable; opening a second writer alongside it is not.
148
+ case stillClosing
149
+ }
150
+
151
+ // MARK: - Writer
152
+
153
+ /// The thing that actually writes, one per canonical path.
154
+ ///
155
+ /// Handles for the same file share it — see `LogWriterRegistry`. Batching,
156
+ /// drop policy, and loss notices all live in JavaScript; this side appends
157
+ /// pre-batched text, rotates, compresses, prunes, and keeps counters. It is
158
+ /// deliberately the dumb end.
159
+ ///
160
+ /// **Two synchronisation domains, and the split is the whole design.**
161
+ /// `stateLock` guards counters and the handle swap and is never held across
162
+ /// I/O; the serial `queue` owns every byte that touches the disk. That is what
163
+ /// makes `status()` answerable while the writer thread is stalled on a disk
164
+ /// that has stopped responding — which the JavaScript side depends on, because
165
+ /// its backpressure loop polls exactly then.
166
+ ///
167
+ /// **Accepted bytes stay reserved until the write terminally completes.** The
168
+ /// cap is on bytes in flight, not bytes enqueued, so a burst cannot queue a
169
+ /// gigabyte behind a slow disk by virtue of each individual batch fitting.
170
+ public final class LogWriter {
171
+ /// Hard cap on payload bytes in flight. Documented as a payload bound: the
172
+ /// process still holds `Data` copies and queue blocks on top of it.
173
+ static let hardCapBytes = 1_048_576
174
+ /// Reads used to find the last record boundary during a startup trim.
175
+ private static let tailScanWindow = 1 << 20
176
+ /// Consecutive `EINTR`/`EAGAIN` retries before a write is called failed.
177
+ private static let maxWriteRetries = 16
178
+ /// Successful writes between descriptor-liveness checks.
179
+ private static let healthCheckStride = 8
180
+ /// How long a failed rotation waits before being attempted again.
181
+ ///
182
+ /// Milliseconds on the monotonic clock, not `Date`. A backoff asks "has
183
+ /// enough time passed since the last failure", which an NTP correction or a
184
+ /// user changing the date must never re-answer: a backward step of an hour
185
+ /// would wedge rotation — and reopening, below — for an hour. File AGE still
186
+ /// uses `Date`, and has to, because it is measured against a creation time
187
+ /// recorded on a previous run. Android already splits these two clocks; this
188
+ /// is the same split, and the Kotlin comment claiming iOS had it is now true.
189
+ private static let rotationBackoffMs: Int64 = 5_000
190
+ /// How long a failed reopen waits. Monotonic, for the reason above.
191
+ private static let reopenBackoffMs: Int64 = 1_000
192
+ /// Longest any deadline-bounded call will wait. Well short of the watchdog
193
+ /// window a synchronous crash-path flush has to live inside.
194
+ private static let MAX_DEADLINE_MS = 30_000
195
+ /// How long `logFilePaths()` will wait for the queue before answering with the
196
+ /// active path alone. Short: it takes no deadline of its own, and collecting
197
+ /// support logs is not worth blocking the JS thread over.
198
+ private static let pathsDeadlineMs = 2_000
199
+
200
+ /// The raw write, injectable so short writes and hard failures can be tested
201
+ /// without a real disk that misbehaves on demand. Production passes `nil`.
202
+ public typealias RawWrite = (Int32, UnsafeRawPointer, Int) -> Int
203
+
204
+ /// Archive compression, injectable for the same reason: the interesting case
205
+ /// is the one where it fails, and a real gzip failure needs a full disk.
206
+ public typealias Compressor = (URL, URL) -> Bool
207
+
208
+ public let fileURL: URL
209
+ public let canonicalPath: String
210
+ let policy: LogRotationPolicy
211
+ let lineFramed: Bool
212
+
213
+ private let queue: DispatchQueue
214
+ private let queueKey = DispatchSpecificKey<Bool>()
215
+ private let rawWrite: RawWrite
216
+ private let compressor: Compressor
217
+
218
+ // MARK: State behind `stateLock` — cheap, never held across I/O
219
+
220
+ private let stateLock = NSLock()
221
+ /// Held for the whole of `clearLogs`, so purges cannot interleave.
222
+ private let purgeLock = NSLock()
223
+ private var reservedBytes = 0
224
+ private var generation: UInt64 = 1
225
+ private var closed = false
226
+ private var degraded = LogDegradation()
227
+ private var loss: [UInt64: (entries: Int, bytes: Int)] = [:]
228
+ private var lastSyncSucceeded = true
229
+ /// Handles currently holding this writer. The registry evicts at zero.
230
+ private(set) var refCount = 0
231
+
232
+ // MARK: State confined to `queue`
233
+
234
+ private var handle: FileHandle?
235
+ private var descriptor: Int32 = -1
236
+ private var currentFileSize: UInt64 = 0
237
+ private var currentFileStart = Date()
238
+ /// Monotonic ms of the last reopen attempt; nil until one has been made, so
239
+ /// the very first attempt is never held back by an uninitialised timestamp.
240
+ private var lastReopenAttempt: Int64?
241
+ /// Monotonic ms before which rotation will not be retried.
242
+ private var rotationBlockedUntil: Int64 = 0
243
+ private var writesSinceHealthCheck = 0
244
+ /// Set by the close barrier, on this queue. Everything enqueued before the
245
+ /// barrier still writes; everything after it is refused.
246
+ private var terminated = false
247
+ /// Rotations that got past the backoff guard. Test support for asserting the
248
+ /// backoff actually backs off.
249
+ private var rotationAttempts = 0
250
+
251
+ init(
252
+ fileURL: URL,
253
+ canonicalPath: String,
254
+ policy: LogRotationPolicy,
255
+ lineFramed: Bool,
256
+ rawWrite: RawWrite? = nil,
257
+ compressor: Compressor? = nil
258
+ ) throws {
259
+ self.fileURL = fileURL
260
+ self.canonicalPath = canonicalPath
261
+ self.policy = policy
262
+ self.lineFramed = lineFramed
263
+ self.rawWrite = rawWrite ?? { fd, buffer, count in write(fd, buffer, count) }
264
+ self.compressor = compressor ?? { source, destination in
265
+ #if canImport(Compression)
266
+ return Gzip.compressFile(at: source, to: destination)
267
+ #else
268
+ return false
269
+ #endif
270
+ }
271
+ self.queue = DispatchQueue(label: "com.nitrologger.filewriter")
272
+ queue.setSpecific(key: queueKey, value: true)
273
+
274
+ let shortfall = try LogSecureFile.createDirectory(at: fileURL.deletingLastPathComponent())
275
+ if !shortfall.isEmpty { degraded.insert(.protection) }
276
+ guard let opened = Self.openForAppending(at: fileURL) else {
277
+ throw LogWriterError.openFailed("could not open the log file")
278
+ }
279
+ if !opened.shortfall.isEmpty { degraded.insert(.protection) }
280
+ handle = opened.handle
281
+ descriptor = opened.descriptor
282
+ currentFileSize = Self.size(of: opened.descriptor)
283
+ currentFileStart = Self.creationDate(of: fileURL)
284
+
285
+ trimTornTailIfFramed()
286
+ sweepRetention()
287
+ }
288
+
289
+ deinit {
290
+ let live = handle
291
+ handle = nil
292
+ try? live?.synchronize()
293
+ try? live?.close()
294
+ }
295
+
296
+ // MARK: - Reference counting (registry-owned, called under the registry lock)
297
+
298
+ func retain() { refCount += 1 }
299
+ func releaseOne() -> Int {
300
+ refCount -= 1
301
+ return refCount
302
+ }
303
+
304
+ // MARK: - Opening
305
+
306
+ /// Opens for appending, creating if needed. Every open funnels through here.
307
+ ///
308
+ /// `O_APPEND` makes each write land atomically at the current end of file
309
+ /// whatever else wrote in between. Without it a second descriptor on the
310
+ /// same file — a replaced destination draining late, a stale handle after a
311
+ /// rotation — writes from its own stale offset and silently overwrites. It
312
+ /// also means a straggler that missed a rotation appends to the archive
313
+ /// rather than corrupting the fresh file.
314
+ ///
315
+ /// **`O_NOFOLLOW` and `O_CREAT` in one call is the point.** Checking the path
316
+ /// with `lstat` and opening it afterwards leaves a window: something can
317
+ /// replace the file with a symlink in between, and the writer then follows it
318
+ /// out of the directory the registry believes it owns — writing the app's log
319
+ /// somewhere no purge will ever clean. One syscall closes the window, and the
320
+ /// `fstat` afterwards rejects anything that is not a regular file, so a FIFO
321
+ /// left in place cannot wedge the writer on open either.
322
+ ///
323
+ /// The mode is applied through the descriptor rather than the path for the
324
+ /// same reason — `fchmod` acts on the file already held.
325
+ ///
326
+ /// **`O_NONBLOCK` is not optional here.** `O_NOFOLLOW` refuses a symlink but
327
+ /// says nothing about a FIFO, and opening a FIFO for writing blocks until
328
+ /// someone opens the read end — forever, in practice, taking the app's whole
329
+ /// startup with it, since this runs during `init`. Opening non-blocking makes
330
+ /// the call return so the `fstat` below can reject anything that is not a
331
+ /// regular file. The flag is cleared afterwards because it has no useful
332
+ /// meaning for the regular file that survives the check.
333
+ private static func openForAppending(
334
+ at url: URL
335
+ ) -> (handle: FileHandle, descriptor: Int32, shortfall: LogSecureFile.Shortfall)? {
336
+ // `O_RDWR`, not `O_WRONLY`: the startup tail scan reads through this exact
337
+ // descriptor so that the offsets it computes and the `ftruncate` it applies
338
+ // are guaranteed to concern the same inode. `pread` on a write-only
339
+ // descriptor fails with `EBADF`, which would silently skip crash recovery.
340
+ let fd = Darwin.open(
341
+ url.path,
342
+ O_RDWR | O_APPEND | O_CREAT | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC,
343
+ mode_t(LogSecureFile.fileMode)
344
+ )
345
+ guard fd >= 0 else { return nil }
346
+
347
+ var info = stat()
348
+ guard fstat(fd, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG else {
349
+ Darwin.close(fd)
350
+ return nil
351
+ }
352
+
353
+ let flags = fcntl(fd, F_GETFL)
354
+ if flags >= 0 { _ = fcntl(fd, F_SETFL, flags & ~O_NONBLOCK) }
355
+
356
+ let shortfall = LogSecureFile.secure(descriptor: fd, at: url)
357
+
358
+ // The descriptor must still be reachable under the name it was opened by.
359
+ // Everything downstream addresses this file by path — rotation renames it,
360
+ // retention counts it, purge deletes it — so a descriptor whose name has
361
+ // been taken over or unlinked is one writing into a file no part of this
362
+ // package can ever find again. That is worth refusing on its own, entirely
363
+ // apart from whether the protections stuck: `secure(descriptor:)` answers
364
+ // that question through the descriptor now, and cannot be fooled by a name.
365
+ guard LogSecureFile.namesSameFile(url, as: info) else {
366
+ Darwin.close(fd)
367
+ return nil
368
+ }
369
+ return (FileHandle(fileDescriptor: fd, closeOnDealloc: true), fd, shortfall)
370
+ }
371
+
372
+ private static func size(of descriptor: Int32) -> UInt64 {
373
+ var info = stat()
374
+ guard descriptor >= 0, fstat(descriptor, &info) == 0 else { return 0 }
375
+ return UInt64(max(0, info.st_size))
376
+ }
377
+
378
+ private static func creationDate(of url: URL) -> Date {
379
+ let attributes = try? FileManager.default.attributesOfItem(atPath: url.path)
380
+ return (attributes?[.creationDate] as? Date) ?? Date()
381
+ }
382
+
383
+ /// Cuts a torn trailing record left by a crash — but only when the producer
384
+ /// has declared that records are newline-framed.
385
+ ///
386
+ /// Without that declaration the trailing bytes are indistinguishable from a
387
+ /// record that simply contains newlines, and trimming would eat good data to
388
+ /// tidy up after a crash that may not have happened. Absent the guarantee the
389
+ /// file is left exactly as it was found.
390
+ /// Reads through `descriptor` — the very one it will truncate.
391
+ ///
392
+ /// Opening the path again for reading would mean deriving offsets from
393
+ /// whatever the name points at now and applying them to the file the writer
394
+ /// holds. A rename in between and `ftruncate` cuts the log at an offset
395
+ /// computed from something else entirely. `pread` also needs no seeking, so
396
+ /// it cannot disturb the append position.
397
+ private func trimTornTailIfFramed() {
398
+ guard lineFramed, descriptor >= 0, currentFileSize > 0 else { return }
399
+
400
+ var tail = [UInt8](repeating: 0, count: 1)
401
+ guard pread(descriptor, &tail, 1, off_t(currentFileSize - 1)) == 1 else { return }
402
+ if tail[0] == 0x0A { return } // already on a record boundary
403
+
404
+ // Scan backwards a window at a time rather than giving up after one.
405
+ // A single fixed window is wrong in both directions: a record larger than
406
+ // the window hides its own preceding boundary, and a file whose only
407
+ // content is one incomplete record has no boundary at all.
408
+ let window = UInt64(Self.tailScanWindow)
409
+ var end = currentFileSize
410
+ var keep: UInt64?
411
+ var buffer = [UInt8](repeating: 0, count: Self.tailScanWindow)
412
+
413
+ while end > 0 {
414
+ let start = end > window ? end - window : 0
415
+ let wanted = Int(end - start)
416
+ let read = pread(descriptor, &buffer, wanted, off_t(start))
417
+ guard read == wanted else { return }
418
+ if let offset = buffer[0..<wanted].lastIndex(of: 0x0A) {
419
+ keep = start + UInt64(offset) + 1
420
+ break
421
+ }
422
+ end = start
423
+ }
424
+
425
+ // No newline in the whole file. Under a *declared* framing contract that is
426
+ // not ambiguous — every record ends in one, so a file without any contains
427
+ // no complete record and all of it is torn. (Without the declaration this
428
+ // routine never runs, which is where the ambiguity actually lives.)
429
+ let target = keep ?? 0
430
+ guard target < currentFileSize else { return }
431
+ guard ftruncate(descriptor, off_t(target)) == 0 else { return }
432
+ currentFileSize = target
433
+ }
434
+
435
+ // MARK: - Appending
436
+
437
+ /// Accept or refuse a batch, then write it on the queue.
438
+ ///
439
+ /// The decision is made entirely under `stateLock` and before anything is
440
+ /// enqueued, so the cap holds no matter how many threads are calling: two
441
+ /// batches that each fit but do not fit together cannot both be accepted.
442
+ func append(
443
+ handleID: UInt64,
444
+ handleGeneration: UInt64,
445
+ batch: String,
446
+ entryCount: Int
447
+ ) -> LogAppendResult {
448
+ let data = Data(batch.utf8)
449
+ let bytes = data.count
450
+
451
+ stateLock.lock()
452
+ if closed {
453
+ return rejectUnlocking(.closed, handleID)
454
+ }
455
+ if handleGeneration != generation {
456
+ return rejectUnlocking(.staleGeneration, handleID)
457
+ }
458
+ // Validated on both sides of the bridge. An entry count that disagrees
459
+ // with the payload makes every loss number downstream a guess.
460
+ if entryCount < 0 || entryCount > 1_000_000 {
461
+ return rejectUnlocking(.failed, handleID)
462
+ }
463
+ if (bytes == 0) != (entryCount == 0) {
464
+ return rejectUnlocking(.failed, handleID)
465
+ }
466
+ if bytes == 0 {
467
+ // Nothing to do, and nothing to complain about.
468
+ let status = statusLocked(handleID)
469
+ stateLock.unlock()
470
+ return LogAppendResult(accepted: true, rejectReason: nil, status: status)
471
+ }
472
+ if reservedBytes + bytes > Self.hardCapBytes {
473
+ return rejectUnlocking(.full, handleID)
474
+ }
475
+ reservedBytes += bytes
476
+ let status = statusLocked(handleID)
477
+ stateLock.unlock()
478
+
479
+ queue.async { [self] in
480
+ performWrite(data, handleID: handleID, entryCount: entryCount, generation: handleGeneration)
481
+ }
482
+ return LogAppendResult(accepted: true, rejectReason: nil, status: status)
483
+ }
484
+
485
+ private func rejectUnlocking(_ reason: LogRejectReason, _ handleID: UInt64) -> LogAppendResult {
486
+ let status = statusLocked(handleID)
487
+ stateLock.unlock()
488
+ return LogAppendResult(accepted: false, rejectReason: reason, status: status)
489
+ }
490
+
491
+ private func performWrite(_ data: Data, handleID: UInt64, entryCount: Int, generation: UInt64) {
492
+ defer { release(bytes: data.count) }
493
+
494
+ if terminated {
495
+ // Past the close barrier. Anything reaching here was accepted, missed
496
+ // the flush, and has no descriptor left to go to — reopening one would
497
+ // resurrect a writer the caller has finished with. It is counted lost
498
+ // rather than dropped silently, which matches the `durable: false` and
499
+ // non-zero `pendingBytes` that close already reported.
500
+ //
501
+ // The flag is set BY the close barrier, on this queue, rather than by
502
+ // `close` up front: the serial queue then guarantees that every batch
503
+ // enqueued before the barrier still writes normally. Checking `closed`
504
+ // here instead would discard exactly the records a close is supposed to
505
+ // flush out.
506
+ record(loss: entryCount, bytes: data.count, for: handleID)
507
+ return
508
+ }
509
+
510
+ stateLock.lock()
511
+ let stale = generation != self.generation
512
+ stateLock.unlock()
513
+ if stale {
514
+ // A purge landed between acceptance and here. These bytes belong to a
515
+ // file that was deliberately deleted, so they are dropped WITHOUT being
516
+ // counted as loss: writing them into the fresh file would resurrect
517
+ // pre-purge data, and reporting them would describe a gap the user asked
518
+ // for. The JavaScript side baselines its own cursors across a purge for
519
+ // the same reason.
520
+ return
521
+ }
522
+
523
+ guard writableHandle() != nil else {
524
+ record(loss: entryCount, bytes: data.count, for: handleID)
525
+ return
526
+ }
527
+
528
+ // The true end of file, not a tracked counter: it is what a partial write
529
+ // has to be rolled back to, and being wrong about it means truncating
530
+ // somebody else's bytes.
531
+ let offsetBefore = Self.size(of: descriptor)
532
+ do {
533
+ try writeAll(data)
534
+ currentFileSize = offsetBefore + UInt64(data.count)
535
+ healthCheckPeriodically()
536
+ rotateIfNeeded()
537
+ } catch {
538
+ // Roll back to the record boundary the batch started at. A half-written
539
+ // batch is a half-written record, and a half-written record makes the
540
+ // rest of the file unparseable from that point on — the loss would
541
+ // spread from one batch to everything after it.
542
+ if ftruncate(descriptor, off_t(offsetBefore)) == 0 {
543
+ currentFileSize = offsetBefore
544
+ }
545
+ record(loss: entryCount, bytes: data.count, for: handleID)
546
+ invalidateHandleIfUnlinked()
547
+ }
548
+ }
549
+
550
+ /// Writes every byte or throws.
551
+ ///
552
+ /// `write(2)` is allowed to write less than it was asked for, and treating a
553
+ /// short write as success is how a log file ends up with a record missing its
554
+ /// second half. `EINTR` and `EAGAIN` are retried a bounded number of times;
555
+ /// anything else is terminal.
556
+ private func writeAll(_ data: Data) throws {
557
+ var written = 0
558
+ var retries = 0
559
+ try data.withUnsafeBytes { raw in
560
+ guard let base = raw.baseAddress else { return }
561
+ while written < data.count {
562
+ let n = rawWrite(descriptor, base + written, data.count - written)
563
+ if n > 0 {
564
+ written += n
565
+ retries = 0
566
+ continue
567
+ }
568
+ if n < 0 && (errno == EINTR || errno == EAGAIN) {
569
+ retries += 1
570
+ if retries > Self.maxWriteRetries {
571
+ throw LogWriterError.openFailed("write kept being interrupted")
572
+ }
573
+ continue
574
+ }
575
+ throw LogWriterError.openFailed("write failed")
576
+ }
577
+ }
578
+ }
579
+
580
+ // MARK: - Handle liveness (queue only)
581
+
582
+ /// The live descriptor, reopening if the backoff allows.
583
+ ///
584
+ /// `ignoringBackoff` is for the explicit-durability paths — `flush` and
585
+ /// `close`. A caller there is asking for what is buffered to be on storage
586
+ /// NOW, and a degraded writer sitting inside its reopen backoff would
587
+ /// otherwise report failure and hand back nothing, with no second chance
588
+ /// coming. That is exactly the `applicationWillTerminate` case, where the
589
+ /// records being given up on are the ones explaining the shutdown.
590
+ ///
591
+ /// Ported back from SwiftLogger, whose `FileDestination` has carried this
592
+ /// parameter since the crash-path work; the port dropped it.
593
+ private func writableHandle(ignoringBackoff: Bool = false) -> FileHandle? {
594
+ if let handle { return handle }
595
+ if !ignoringBackoff, let last = lastReopenAttempt,
596
+ Self.steadyMillis() - last < Self.reopenBackoffMs {
597
+ return nil
598
+ }
599
+ attemptReopen()
600
+ return handle
601
+ }
602
+
603
+ /// Monotonic milliseconds since boot.
604
+ ///
605
+ /// `DispatchTime` rather than `Date` for every elapsed-time question in this
606
+ /// file. It cannot be moved by the user or by NTP, which is the whole point:
607
+ /// a clock that jumps backwards turns a one-second backoff into an outage.
608
+ private static func steadyMillis() -> Int64 {
609
+ Int64(DispatchTime.now().uptimeNanoseconds / 1_000_000)
610
+ }
611
+
612
+ @discardableResult
613
+ private func attemptReopen() -> Bool {
614
+ lastReopenAttempt = Self.steadyMillis()
615
+ closeCurrentHandle()
616
+
617
+ if let shortfall = try? LogSecureFile.createDirectory(at: fileURL.deletingLastPathComponent()) {
618
+ if !shortfall.isEmpty { note(.protection) }
619
+ } else {
620
+ return false
621
+ }
622
+ guard let opened = Self.openForAppending(at: fileURL) else {
623
+ // Reset the rotation triggers even though nothing opened. Leaving the
624
+ // size above the threshold makes every later write re-enter rotation,
625
+ // which archives the file again and again until pruning has eaten every
626
+ // real archive.
627
+ currentFileSize = 0
628
+ currentFileStart = Date()
629
+ writesSinceHealthCheck = 0
630
+ return false
631
+ }
632
+ if !opened.shortfall.isEmpty { note(.protection) }
633
+ handle = opened.handle
634
+ descriptor = opened.descriptor
635
+ currentFileSize = Self.size(of: opened.descriptor)
636
+ currentFileStart = Self.creationDate(of: fileURL)
637
+ writesSinceHealthCheck = 0
638
+ return true
639
+ }
640
+
641
+ private func closeCurrentHandle() {
642
+ if let stale = handle { try? stale.close() }
643
+ handle = nil
644
+ descriptor = -1
645
+ }
646
+
647
+ /// Confirms the descriptor still points at a file anyone can read.
648
+ ///
649
+ /// Writes to an unlinked inode succeed forever and land nowhere. No error is
650
+ /// raised, so without this an externally deleted log file means silent loss
651
+ /// for the rest of the process's life. `st_nlink == 0` catches the plain
652
+ /// delete and the delete-then-recreate that a path-existence check misses.
653
+ private func healthCheckPeriodically() {
654
+ writesSinceHealthCheck += 1
655
+ guard writesSinceHealthCheck >= Self.healthCheckStride else { return }
656
+ writesSinceHealthCheck = 0
657
+ invalidateHandleIfUnlinked()
658
+ }
659
+
660
+ private func invalidateHandleIfUnlinked() {
661
+ guard descriptor >= 0 else { return }
662
+ var info = stat()
663
+ if fstat(descriptor, &info) == 0 && info.st_nlink > 0 { return }
664
+ attemptReopen()
665
+ }
666
+
667
+ // MARK: - Status, flush, close
668
+
669
+ func status(handleID: UInt64) -> LogSinkStatus {
670
+ stateLock.lock()
671
+ defer { stateLock.unlock() }
672
+ return statusLocked(handleID)
673
+ }
674
+
675
+ private func statusLocked(_ handleID: UInt64) -> LogSinkStatus {
676
+ let totals = loss[handleID] ?? (entries: 0, bytes: 0)
677
+ return LogSinkStatus(
678
+ queuedBytes: reservedBytes,
679
+ lostBytes: totals.bytes,
680
+ lostEntries: totals.entries,
681
+ degraded: degraded.rawValue
682
+ )
683
+ }
684
+
685
+ /// Drains and fsyncs, bounded by wall clock.
686
+ ///
687
+ /// The barrier is enqueued behind whatever the queue is already doing, so a
688
+ /// writer wedged mid-write times out here rather than blocking the caller
689
+ /// forever — which on the crash path is the difference between a partial log
690
+ /// and a watchdog kill.
691
+ func flush(handleID: UInt64, deadlineMs: Double) -> LogFlushOutcome {
692
+ flush(handleID: handleID, deadline: .now() + .milliseconds(Self.clampDeadline(deadlineMs)))
693
+ }
694
+
695
+ /// The absolute-deadline form, so a caller that has to do more than one
696
+ /// bounded wait can spend a single budget across all of them.
697
+ private func flush(handleID: UInt64, deadline: DispatchTime) -> LogFlushOutcome {
698
+ var timedOut = false
699
+
700
+ if DispatchQueue.getSpecific(key: queueKey) == true {
701
+ // Already on the write queue. Enqueueing and waiting here would be a
702
+ // deadlock against ourselves — the barrier could never run until we
703
+ // returned. Run it inline instead; ordering is preserved either way,
704
+ // because nothing enqueued after us has started.
705
+ syncNow()
706
+ } else {
707
+ let group = DispatchGroup()
708
+ group.enter()
709
+ queue.async { [self] in
710
+ syncNow()
711
+ group.leave()
712
+ }
713
+ timedOut = group.wait(timeout: deadline) == .timedOut
714
+ }
715
+
716
+ stateLock.lock()
717
+ let status = statusLocked(handleID)
718
+ let pending = reservedBytes
719
+ let synced = lastSyncSucceeded
720
+ stateLock.unlock()
721
+
722
+ return LogFlushOutcome(
723
+ durable: !timedOut && pending == 0 && synced,
724
+ timedOut: timedOut,
725
+ pendingBytes: pending,
726
+ status: status
727
+ )
728
+ }
729
+
730
+ /// Queue-confined. Records whether the data actually reached storage, which
731
+ /// is the difference between `durable` and "we asked".
732
+ ///
733
+ /// The reopen ignores the backoff. This runs only for `flush` and the close
734
+ /// barrier, where the caller is asking for durability now and there is no
735
+ /// later attempt to fall back on — refusing to try because a failed reopen
736
+ /// happened half a second ago would give up on the buffer at exactly the
737
+ /// moment it matters most.
738
+ private func syncNow() {
739
+ guard let handle = writableHandle(ignoringBackoff: true) else {
740
+ stateLock.lock(); lastSyncSucceeded = false; stateLock.unlock()
741
+ return
742
+ }
743
+ let ok = (try? handle.synchronize()) != nil
744
+ stateLock.lock(); lastSyncSucceeded = ok; stateLock.unlock()
745
+ }
746
+
747
+ /// Flushes, then closes the descriptor — both inside ONE budget.
748
+ ///
749
+ /// The deadline is computed once, at entry. Giving the flush the full budget
750
+ /// and then the close barrier the full budget again means a stalled writer
751
+ /// blocks for twice what the caller asked for, which at the 30-second ceiling
752
+ /// is a minute of a synchronous teardown doing nothing.
753
+ /// `onTerminated` runs on the write queue once the descriptor is really shut,
754
+ /// **regardless of whether this call waited that long**.
755
+ ///
756
+ /// The two are deliberately decoupled. The caller's deadline bounds how long
757
+ /// it blocks; the callback reports when the writer actually stopped. The
758
+ /// registry needs the second, not the first — releasing a path because a
759
+ /// close *gave up waiting* would let a replacement writer open the same file
760
+ /// while this one still has a write executing.
761
+ func close(
762
+ handleID: UInt64,
763
+ deadlineMs: Double,
764
+ onTerminated: (() -> Void)? = nil
765
+ ) -> LogFlushOutcome {
766
+ let deadline = DispatchTime.now() + .milliseconds(Self.clampDeadline(deadlineMs))
767
+
768
+ stateLock.lock()
769
+ closed = true
770
+ stateLock.unlock()
771
+
772
+ let outcome = flush(handleID: handleID, deadline: deadline)
773
+
774
+ let group = DispatchGroup()
775
+ group.enter()
776
+ queue.async { [self] in
777
+ terminated = true
778
+ closeCurrentHandle()
779
+ onTerminated?()
780
+ group.leave()
781
+ }
782
+ // Whatever the flush left of the budget, and nothing more. A deadline
783
+ // already in the past makes this return at once rather than wait afresh.
784
+ _ = group.wait(timeout: deadline)
785
+ return outcome
786
+ }
787
+
788
+ /// `Infinity` means "wait as long as you are allowed to", which is the
789
+ /// ceiling — not zero. `NaN` and non-positive values mean no waiting, which
790
+ /// is also what a caller passing `0` deliberately asks for.
791
+ static func clampDeadline(_ value: Double) -> Int {
792
+ if value.isNaN || value <= 0 { return 0 }
793
+ if value.isInfinite { return MAX_DEADLINE_MS }
794
+ return Int(min(value, Double(MAX_DEADLINE_MS)))
795
+ }
796
+
797
+ private func release(bytes: Int) {
798
+ stateLock.lock()
799
+ reservedBytes = max(0, reservedBytes - bytes)
800
+ stateLock.unlock()
801
+ }
802
+
803
+ private func record(loss entries: Int, bytes: Int, for handleID: UInt64) {
804
+ guard entries > 0 || bytes > 0 else { return }
805
+ stateLock.lock()
806
+ var totals = loss[handleID] ?? (entries: 0, bytes: 0)
807
+ totals.entries += entries
808
+ totals.bytes += bytes
809
+ loss[handleID] = totals
810
+ stateLock.unlock()
811
+ }
812
+
813
+ private func note(_ flag: LogDegradation) {
814
+ stateLock.lock()
815
+ degraded.insert(flag)
816
+ stateLock.unlock()
817
+ }
818
+
819
+ // MARK: - Rotation (queue only)
820
+
821
+ private func rotateIfNeeded() {
822
+ guard let live = handle else { return }
823
+ guard Self.steadyMillis() >= rotationBlockedUntil else { return }
824
+ let tooBig = currentFileSize >= policy.maxFileSizeBytes
825
+ let tooOld = policy.maxFileAgeSeconds.map {
826
+ Date().timeIntervalSince(currentFileStart) >= $0
827
+ } ?? false
828
+ guard tooBig || tooOld else { return }
829
+ rotationAttempts += 1
830
+
831
+ try? live.synchronize()
832
+ closeCurrentHandle()
833
+
834
+ let archiveURL = fileURL
835
+ .deletingLastPathComponent()
836
+ .appendingPathComponent("\(fileURL.lastPathComponent).\(Self.rotationStamp())")
837
+
838
+ do {
839
+ try FileManager.default.moveItem(at: fileURL, to: archiveURL)
840
+ } catch {
841
+ // Back off. A rotation that fails on every write — a read-only volume,
842
+ // a directory someone removed — would otherwise retry on every single
843
+ // batch, turning a degraded log into a busy one.
844
+ note(.rotation)
845
+ rotationBlockedUntil = Self.steadyMillis() + Self.rotationBackoffMs
846
+ attemptReopen()
847
+ return
848
+ }
849
+ if !LogSecureFile.secure(archiveURL).isEmpty { note(.protection) }
850
+
851
+ if policy.compressArchives {
852
+ compress(archiveURL)
853
+ }
854
+ sweepRetention()
855
+ attemptReopen()
856
+ }
857
+
858
+ /// Replaces `url` with a gzipped copy, keeping the original if anything goes
859
+ /// wrong: a bigger archive beats a lost one.
860
+ ///
861
+ /// Compression writes to a `.part` staging name and renames on success, so
862
+ /// an interruption leaves something the purge recognises rather than a
863
+ /// plausible-looking `.gz` that no tool can open.
864
+ private func compress(_ url: URL) {
865
+ let finalURL = URL(fileURLWithPath: url.path + ".gz")
866
+ let stagingURL = URL(fileURLWithPath: finalURL.path + ".part")
867
+ try? FileManager.default.removeItem(at: stagingURL)
868
+
869
+ guard compressor(url, stagingURL) else {
870
+ try? FileManager.default.removeItem(at: stagingURL)
871
+ note(.gzip)
872
+ return
873
+ }
874
+ if !LogSecureFile.secure(stagingURL).isEmpty { note(.protection) }
875
+ do {
876
+ try FileManager.default.moveItem(at: stagingURL, to: finalURL)
877
+ } catch {
878
+ try? FileManager.default.removeItem(at: stagingURL)
879
+ note(.gzip)
880
+ return
881
+ }
882
+ if !LogSecureFile.secure(finalURL).isEmpty { note(.protection) }
883
+ do {
884
+ try FileManager.default.removeItem(at: url)
885
+ } catch {
886
+ // The plaintext original survived alongside the compressed copy. Remove
887
+ // the compressed one instead of leaving two: a plaintext log the caller
888
+ // believes was compressed away is a file nobody remembers to delete.
889
+ try? FileManager.default.removeItem(at: finalURL)
890
+ note(.gzip)
891
+ }
892
+ }
893
+
894
+ /// `<base>.YYYYMMDDTHHMMSSZ_<8 hex>`.
895
+ ///
896
+ /// Spelled out rather than left to `ISO8601DateFormatter` so the produced
897
+ /// name and `isArchiveName` cannot drift apart across OS versions — a purge
898
+ /// that stops recognising its own archives is a silent compliance failure.
899
+ /// The random suffix disambiguates rotations inside the same second, which
900
+ /// the one-second stamp cannot.
901
+ private static let stampFormatter: DateFormatter = {
902
+ let formatter = DateFormatter()
903
+ formatter.locale = Locale(identifier: "en_US_POSIX")
904
+ formatter.timeZone = TimeZone(identifier: "UTC")
905
+ formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
906
+ return formatter
907
+ }()
908
+
909
+ private static func rotationStamp() -> String {
910
+ let suffix = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8).lowercased()
911
+ return "\(stampFormatter.string(from: Date()))_\(suffix)"
912
+ }
913
+
914
+ // MARK: - Retention (queue only)
915
+
916
+ /// Applies all three retention limits. Runs at open and after each rotation.
917
+ ///
918
+ /// No background timer: an idle process writes nothing, so there is nothing
919
+ /// to sweep, and a timer that fires in the background is a wakeup the app
920
+ /// pays for. An active process rotates, and rotation sweeps.
921
+ private func sweepRetention() {
922
+ let directory = fileURL.deletingLastPathComponent()
923
+ let baseName = fileURL.lastPathComponent
924
+ var archives = Self.archives(in: directory, baseName: baseName)
925
+ let fm = FileManager.default
926
+ var failed = false
927
+
928
+ func remove(_ entry: Artifact) {
929
+ do { try fm.removeItem(at: entry.url) } catch { failed = true }
930
+ }
931
+
932
+ // Orphaned compressions first. A `.part` is a gzip that was interrupted —
933
+ // by a crash, or by a process that died mid-rotation — and nothing will
934
+ // ever finish it. Compression runs on this same queue, so a staging file
935
+ // seen from here is never one being written.
936
+ for name in ((try? fm.contentsOfDirectory(atPath: directory.path)) ?? [])
937
+ where Self.isStagingName(name, baseName: baseName) {
938
+ do { try fm.removeItem(at: directory.appendingPathComponent(name)) } catch { failed = true }
939
+ }
940
+
941
+ // Oldest first for age, then count, then total size — each pass works on
942
+ // what the previous one left.
943
+ if let maxAge = policy.maxArchiveAgeSeconds {
944
+ let cutoff = Date().addingTimeInterval(-maxAge)
945
+ let expired = archives.filter { $0.modified < cutoff }
946
+ expired.forEach(remove)
947
+ archives.removeAll { entry in expired.contains { $0.url == entry.url } }
948
+ }
949
+
950
+ if archives.count > policy.maxArchivedFilesCount {
951
+ let excess = Array(archives[policy.maxArchivedFilesCount...])
952
+ excess.forEach(remove)
953
+ archives.removeAll { entry in excess.contains { $0.url == entry.url } }
954
+ }
955
+
956
+ if let cap = policy.maxTotalLogBytes {
957
+ var total = currentFileSize + archives.reduce(0) { $0 + $1.size }
958
+ // Newest-first order, so dropping from the end sheds the oldest.
959
+ while total > cap, let oldest = archives.popLast() {
960
+ remove(oldest)
961
+ total = total > oldest.size ? total - oldest.size : 0
962
+ }
963
+ }
964
+
965
+ if failed { note(.prune) }
966
+ }
967
+
968
+ struct Artifact {
969
+ let url: URL
970
+ let modified: Date
971
+ let size: UInt64
972
+ }
973
+
974
+ /// Archives for `baseName`, newest first.
975
+ ///
976
+ /// Ordered by modification date rather than by name. The name's timestamp
977
+ /// has one-second resolution, so a burst of rotations inside the same second
978
+ /// all share it and only the random suffix differs — sorting by name would
979
+ /// keep an arbitrary subset and delete newer archives than it kept. Names
980
+ /// break exact date ties so the order is still deterministic.
981
+ static func archives(in directory: URL, baseName: String) -> [Artifact] {
982
+ let keys: [URLResourceKey] = [.contentModificationDateKey, .fileSizeKey]
983
+ guard let contents = try? FileManager.default.contentsOfDirectory(
984
+ at: directory, includingPropertiesForKeys: keys
985
+ ) else { return [] }
986
+
987
+ return contents
988
+ .filter { isArchiveName($0.lastPathComponent, baseName: baseName) }
989
+ .map { url -> Artifact in
990
+ let values = try? url.resourceValues(forKeys: Set(keys))
991
+ return Artifact(
992
+ url: url,
993
+ modified: values?.contentModificationDate ?? .distantPast,
994
+ size: UInt64(values?.fileSize ?? 0)
995
+ )
996
+ }
997
+ .sorted { a, b in
998
+ if a.modified != b.modified { return a.modified > b.modified }
999
+ return a.url.lastPathComponent > b.url.lastPathComponent
1000
+ }
1001
+ }
1002
+
1003
+ /// A finished archive: `<base>.<stamp>` or `<base>.<stamp>.gz`.
1004
+ ///
1005
+ /// Deliberately excludes `.part`. A staging file is a compression that was
1006
+ /// interrupted — counting it as an archive would let it occupy a retention
1007
+ /// slot that a real archive should have, and would hand a truncated gzip to
1008
+ /// anyone calling `getLogFilePaths()` to collect logs for support.
1009
+ static func isArchiveName(_ name: String, baseName: String) -> Bool {
1010
+ matches(name, baseName: baseName, pattern: #"^\d{8}T\d{6}Z_[a-f0-9]{8}(\.gz)?$"#)
1011
+ }
1012
+
1013
+ /// A gzip staging file, `<base>.<stamp>.gz.part`.
1014
+ static func isStagingName(_ name: String, baseName: String) -> Bool {
1015
+ matches(name, baseName: baseName, pattern: #"^\d{8}T\d{6}Z_[a-f0-9]{8}\.gz\.part$"#)
1016
+ }
1017
+
1018
+ /// Everything this writer can ever put on disk under its directory: the
1019
+ /// active file, the sidecar, every archive, every gzip staging file.
1020
+ ///
1021
+ /// This predicate IS the naming scheme, and `clearLogs` is the reason it is
1022
+ /// written down in one place. A purge that recognises fewer names than
1023
+ /// rotation can produce leaves survivors — and an interrupted compression
1024
+ /// leaving a plaintext orphan that outlives a compliance purge is exactly the
1025
+ /// failure this is here to prevent.
1026
+ static func isArtifactName(_ name: String, baseName: String) -> Bool {
1027
+ if name == baseName { return true }
1028
+ if name == baseName + ".meta" { return true }
1029
+ return isArchiveName(name, baseName: baseName) || isStagingName(name, baseName: baseName)
1030
+ }
1031
+
1032
+ private static func matches(_ name: String, baseName: String, pattern: String) -> Bool {
1033
+ let prefix = baseName + "."
1034
+ guard name.hasPrefix(prefix) else { return false }
1035
+ let suffix = String(name.dropFirst(prefix.count))
1036
+ return suffix.range(of: pattern, options: .regularExpression) != nil
1037
+ }
1038
+
1039
+ /// The active file and every archive, newest first — read **on the queue**.
1040
+ ///
1041
+ /// Rotation, compression, retention and purge all mutate these names, and all
1042
+ /// of them run on the queue. Enumerating from the caller's thread would race
1043
+ /// every one of them: the honest failure is handing back a `.gz` that is
1044
+ /// mid-rename, or an archive that pruning removed a microsecond later, to a
1045
+ /// caller whose whole purpose is to open those files.
1046
+ ///
1047
+ /// Bounded, because this is reachable from the JS thread and the queue may be
1048
+ /// wedged on storage that has stopped answering. On timeout the active path is
1049
+ /// returned alone: it is the one name this writer owns unconditionally and can
1050
+ /// state without reading the directory, whereas a partial archive list would
1051
+ /// be indistinguishable from a complete one.
1052
+ func logFilePaths() -> [String] {
1053
+ let directory = fileURL.deletingLastPathComponent()
1054
+ let baseName = fileURL.lastPathComponent
1055
+
1056
+ if DispatchQueue.getSpecific(key: queueKey) == true {
1057
+ return [fileURL.path] + Self.archives(in: directory, baseName: baseName).map(\.url.path)
1058
+ }
1059
+
1060
+ var snapshot: [String]?
1061
+ let group = DispatchGroup()
1062
+ group.enter()
1063
+ queue.async {
1064
+ snapshot = [self.fileURL.path]
1065
+ + Self.archives(in: directory, baseName: baseName).map(\.url.path)
1066
+ group.leave()
1067
+ }
1068
+ guard group.wait(timeout: .now() + .milliseconds(Self.pathsDeadlineMs)) != .timedOut,
1069
+ let paths = snapshot
1070
+ else {
1071
+ return [fileURL.path]
1072
+ }
1073
+ return paths
1074
+ }
1075
+
1076
+ // MARK: - Purge
1077
+
1078
+ /// Deletes every artifact and fences every handle.
1079
+ ///
1080
+ /// The generation bumps FIRST and unconditionally. Anything still in flight
1081
+ /// is dropped when it reaches the queue, and any handle that has not rebound
1082
+ /// is refused — so a deletion still running cannot race a fresh write, and a
1083
+ /// partial deletion leaves everyone fenced rather than half of them writing
1084
+ /// into files that are about to disappear.
1085
+ ///
1086
+ /// Only a complete deletion reopens the file. The caller rebinds on
1087
+ /// `durable && rebound`, and on anything else stays fenced until it retries.
1088
+ func clearLogs(deadlineMs: Double) -> (outcome: LogClearOutcome, generation: UInt64) {
1089
+ // The budget starts HERE, before waiting for anything. Computing it after
1090
+ // acquiring the lock would let a caller asking for 100 ms sit behind
1091
+ // another purge's full 30 seconds and still believe it was given 100.
1092
+ let budget = Self.clampDeadline(deadlineMs)
1093
+ let deadline = DispatchTime.now() + .milliseconds(budget)
1094
+
1095
+ // One purge at a time per writer. Two overlapping purges each bump the
1096
+ // generation, and the first to finish would otherwise report success for a
1097
+ // fence the second has already moved — handing its caller permission to
1098
+ // write while a deletion is still in flight.
1099
+ guard purgeLock.lock(before: Date().addingTimeInterval(Double(budget) / 1000)) else {
1100
+ stateLock.lock()
1101
+ let current = generation
1102
+ stateLock.unlock()
1103
+ return (
1104
+ LogClearOutcome(deletedCount: 0, failedPaths: [fileURL.path], durable: false),
1105
+ current
1106
+ )
1107
+ }
1108
+ defer { purgeLock.unlock() }
1109
+
1110
+ stateLock.lock()
1111
+ generation &+= 1
1112
+ let fenced = generation
1113
+ loss.removeAll()
1114
+ degraded = LogDegradation()
1115
+ stateLock.unlock()
1116
+
1117
+ var outcome = LogClearOutcome(deletedCount: 0, failedPaths: [], durable: false)
1118
+ let group = DispatchGroup()
1119
+ group.enter()
1120
+ queue.async { [self] in
1121
+ defer { group.leave() }
1122
+ closeCurrentHandle()
1123
+
1124
+ let fm = FileManager.default
1125
+ let directory = fileURL.deletingLastPathComponent()
1126
+ let baseName = fileURL.lastPathComponent
1127
+
1128
+ // An unreadable directory is NOT an empty one. Swallowing the error and
1129
+ // sweeping an empty list would report a durable purge while every
1130
+ // artifact sat untouched behind a permissions or I/O failure — the worst
1131
+ // possible lie for this particular call to tell.
1132
+ let names: [String]
1133
+ var directoryAbsent = false
1134
+ do {
1135
+ names = try fm.contentsOfDirectory(atPath: directory.path)
1136
+ } catch {
1137
+ // `fileExists` returning false is not proof of absence — it answers
1138
+ // false for a permissions failure, an I/O error, and a dozen other
1139
+ // lookups that never got far enough to tell. Only `ENOENT` actually
1140
+ // means "there is nothing here"; treating anything else as empty is
1141
+ // how a purge reports success over artifacts it never even saw.
1142
+ var info = stat()
1143
+ if lstat(directory.path, &info) != 0 && errno == ENOENT {
1144
+ names = []
1145
+ directoryAbsent = true
1146
+ } else {
1147
+ outcome = LogClearOutcome(
1148
+ deletedCount: 0, failedPaths: [directory.path], durable: false)
1149
+ return
1150
+ }
1151
+ }
1152
+
1153
+ var deleted = 0
1154
+ var failures: [String] = []
1155
+ for name in names where Self.isArtifactName(name, baseName: baseName) {
1156
+ let target = directory.appendingPathComponent(name)
1157
+ do {
1158
+ try fm.removeItem(at: target)
1159
+ deleted += 1
1160
+ } catch {
1161
+ // The path is this package's own artifact name, not user content.
1162
+ failures.append(target.path)
1163
+ }
1164
+ }
1165
+
1166
+ guard failures.isEmpty else {
1167
+ outcome = LogClearOutcome(
1168
+ deletedCount: deleted, failedPaths: failures, durable: false)
1169
+ return
1170
+ }
1171
+
1172
+ // `unlink` returning success only means the change is in the directory's
1173
+ // in-memory state. Until the directory itself is synced, a crash or a
1174
+ // power loss can bring every one of those names back — and this call
1175
+ // exists precisely to promise they are gone.
1176
+ //
1177
+ // Skipped when the directory was already absent: there is nothing to
1178
+ // open and nothing whose removal needs committing, and failing to sync a
1179
+ // directory that does not exist would report a survivor that cannot be
1180
+ // there.
1181
+ if !directoryAbsent, !Self.syncDirectory(directory) {
1182
+ outcome = LogClearOutcome(
1183
+ deletedCount: deleted, failedPaths: [directory.path], durable: false)
1184
+ return
1185
+ }
1186
+
1187
+ // Reopen only on a clean, durable sweep, and only into the generation
1188
+ // this purge established.
1189
+ stateLock.lock()
1190
+ let current = generation
1191
+ stateLock.unlock()
1192
+ guard current == fenced else {
1193
+ outcome = LogClearOutcome(
1194
+ deletedCount: deleted, failedPaths: [], durable: false)
1195
+ return
1196
+ }
1197
+
1198
+ currentFileSize = 0
1199
+ currentFileStart = Date()
1200
+ // Deletion succeeded whether or not a fresh file could be opened, and
1201
+ // `durable` describes the deletion — that is what a compliance caller
1202
+ // asked about. Whether the writer is usable again is a separate fact,
1203
+ // reported separately, because a handle that rebinds onto a writer with
1204
+ // no descriptor would accept records and then lose them.
1205
+ //
1206
+ // A purge that lands after the close barrier still deletes — that is the
1207
+ // whole point of the call — but it must not reopen. The barrier already
1208
+ // released this writer's descriptor, and opening a fresh one behind it
1209
+ // would leak the descriptor for the lifetime of the process and leave an
1210
+ // empty file where a purge had just promised none.
1211
+ outcome = LogClearOutcome(
1212
+ deletedCount: deleted, failedPaths: [], durable: true,
1213
+ rebound: terminated ? false : attemptReopen())
1214
+ }
1215
+
1216
+ if group.wait(timeout: deadline) == .timedOut {
1217
+ return (
1218
+ LogClearOutcome(deletedCount: 0, failedPaths: [fileURL.path], durable: false),
1219
+ fenced
1220
+ )
1221
+ }
1222
+ return (outcome, fenced)
1223
+ }
1224
+
1225
+ /// Forces the directory's own metadata to storage, so the removals that just
1226
+ /// returned survive a crash.
1227
+ private static func syncDirectory(_ url: URL) -> Bool {
1228
+ let fd = Darwin.open(url.path, O_RDONLY | O_CLOEXEC)
1229
+ guard fd >= 0 else { return false }
1230
+ defer { Darwin.close(fd) }
1231
+ return fsync(fd) == 0
1232
+ }
1233
+
1234
+ /// The generation a handle must rebind to after a durable purge.
1235
+ var currentGeneration: UInt64 {
1236
+ stateLock.lock()
1237
+ defer { stateLock.unlock() }
1238
+ return generation
1239
+ }
1240
+
1241
+ var isClosed: Bool {
1242
+ stateLock.lock()
1243
+ defer { stateLock.unlock() }
1244
+ return closed
1245
+ }
1246
+
1247
+ // MARK: - Test support
1248
+
1249
+ /// Closes the descriptor so the next write fails, standing in for a revoked
1250
+ /// file or a disk that stopped answering.
1251
+ func closeHandleForTesting() {
1252
+ queue.sync { closeCurrentHandle() }
1253
+ }
1254
+
1255
+ /// Blocks the write queue until the returned closure is called, so callers
1256
+ /// can observe what `status()` does while the writer is stalled.
1257
+ func stallForTesting() -> () -> Void {
1258
+ let gate = DispatchSemaphore(value: 0)
1259
+ queue.async { gate.wait() }
1260
+ return { gate.signal() }
1261
+ }
1262
+
1263
+ var trackedFileSizeForTesting: UInt64 {
1264
+ queue.sync { currentFileSize }
1265
+ }
1266
+
1267
+ var hasLiveHandleForTesting: Bool {
1268
+ queue.sync { handle != nil }
1269
+ }
1270
+
1271
+ var rotationAttemptsForTesting: Int {
1272
+ queue.sync { rotationAttempts }
1273
+ }
1274
+
1275
+ /// Blocks until everything already enqueued has run, so a test can assert on
1276
+ /// the file without racing the writer.
1277
+ func settleForTesting() {
1278
+ queue.sync {}
1279
+ }
1280
+ }