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,444 @@
1
+ import Foundation
2
+
3
+ /// One writer per file, no matter how many destinations point at it.
4
+ ///
5
+ /// Two `FileHandle`s appending to the same path from different queues interleave
6
+ /// mid-record, and two rotation schedules racing over the same file archive each
7
+ /// other's fresh output. The registry makes that impossible by construction:
8
+ /// everything that resolves to the same file gets the same `LogWriter`.
9
+ ///
10
+ /// **The key is the realpath-resolved path.** `logs/app.log`,
11
+ /// `logs/../logs/app.log`, and a `logs` symlink into the real directory are the
12
+ /// same file and must land on the same entry — comparing the strings the caller
13
+ /// passed in would hand out two writers for one file, which is exactly the
14
+ /// collision the registry exists to stop.
15
+ public final class LogWriterRegistry {
16
+ public static let shared = LogWriterRegistry()
17
+
18
+ /// A condition rather than a plain lock, because acquisition sometimes has to
19
+ /// *wait* — see `closing` below.
20
+ private let condition = NSCondition()
21
+ private var writers: [String: LogWriter] = [:]
22
+ /// Paths whose writer has been evicted but is still draining and closing.
23
+ ///
24
+ /// Eviction and close cannot be one atomic step: closing waits on the write
25
+ /// queue, and holding the registry lock across that would stall every other
26
+ /// file. But between the two, the map has no entry for the path — so an
27
+ /// acquire arriving in that window would build a *second* writer while the
28
+ /// first still has accepted batches queued, giving one file two queues and
29
+ /// two rotation schedules. That is the exact collision this registry exists
30
+ /// to prevent, so acquisition waits out the close instead.
31
+ ///
32
+ /// Counted rather than a set: it costs nothing and stops a stray double
33
+ /// release from clearing a marker another close still needs.
34
+ private var closing: [String: Int] = [:]
35
+ private var nextHandleID: UInt64 = 1
36
+
37
+ /// How long an acquire will wait for a previous writer on the same path to
38
+ /// finish shutting down before giving up.
39
+ static let closeWaitSeconds: TimeInterval = 5
40
+
41
+ init() {}
42
+
43
+ /// Acquires a handle on the writer for `path`, creating it if needed.
44
+ ///
45
+ /// Acquisition happens entirely under the lock, so two runtimes opening the
46
+ /// same file concurrently cannot both construct a writer and have one silently
47
+ /// replace the other. Construction does touch the filesystem while the lock is
48
+ /// held; it happens once per file and only on the open path, which is the
49
+ /// cheaper trade than a second writer existing for even an instant.
50
+ public func acquire(
51
+ path: String,
52
+ policy: LogRotationPolicy,
53
+ lineFramed: Bool,
54
+ rawWrite: LogWriter.RawWrite? = nil,
55
+ compressor: LogWriter.Compressor? = nil
56
+ ) throws -> LogFileHandle {
57
+ let resolved = try LogWriterRegistry.resolve(path: path)
58
+
59
+ condition.lock()
60
+ defer { condition.unlock() }
61
+
62
+ // Wait out a close still in progress on this path — but not forever. The
63
+ // claim is now cleared by the writer's own queue rather than by whoever
64
+ // called close, so a wedged disk means it may never clear at all. Failing
65
+ // the open is the fail-closed answer: one writer per file is the invariant
66
+ // worth keeping, and a caller that cannot have it should be told so rather
67
+ // than handed a second one.
68
+ let waitUntil = Date().addingTimeInterval(Self.closeWaitSeconds)
69
+ while closing[resolved.canonicalPath] != nil {
70
+ if !condition.wait(until: waitUntil) {
71
+ throw LogWriterError.stillClosing
72
+ }
73
+ }
74
+
75
+ let writer: LogWriter
76
+ if let existing = writers[resolved.canonicalPath], !existing.isClosed {
77
+ // A second destination on the same file must agree about how that file is
78
+ // written. Silently honouring the first caller's rotation policy would
79
+ // give the second one a file that behaves nothing like what it asked for,
80
+ // and silently honouring the last would change it under the first.
81
+ guard existing.policy == policy, existing.lineFramed == lineFramed else {
82
+ throw LogWriterError.configConflict
83
+ }
84
+ writer = existing
85
+ } else {
86
+ writer = try LogWriter(
87
+ fileURL: resolved.url,
88
+ canonicalPath: resolved.canonicalPath,
89
+ policy: policy,
90
+ lineFramed: lineFramed,
91
+ rawWrite: rawWrite,
92
+ compressor: compressor
93
+ )
94
+ writers[resolved.canonicalPath] = writer
95
+ }
96
+
97
+ writer.retain()
98
+ let id = nextHandleID
99
+ nextHandleID &+= 1
100
+ return LogFileHandle(id: id, writer: writer, registry: self)
101
+ }
102
+
103
+ /// Drops one handle's claim, closing and evicting the writer at zero.
104
+ ///
105
+ /// Called from `LogFileHandle.deinit` as well as from an explicit dispose, so
106
+ /// a runtime torn down without running JavaScript finalizers still gives the
107
+ /// file descriptor back.
108
+ func release(_ writer: LogWriter, handleID: UInt64, deadlineMs: Double) {
109
+ let path = writer.canonicalPath
110
+
111
+ condition.lock()
112
+ let remaining = writer.releaseOne()
113
+ let shouldClose = remaining <= 0
114
+ if shouldClose {
115
+ if writers[path] === writer { writers.removeValue(forKey: path) }
116
+ // Claim the path for the duration of the close. An acquire arriving now
117
+ // waits rather than building a rival writer over a file this one is
118
+ // still draining.
119
+ closing[path, default: 0] += 1
120
+ }
121
+ condition.unlock()
122
+
123
+ guard shouldClose else { return }
124
+
125
+ // The claim is dropped by the writer's own queue, in `onTerminated`, not
126
+ // when this call stops waiting. A close that hits its deadline leaves work
127
+ // still executing on that queue; releasing the path then would let a
128
+ // replacement writer open the same file underneath it.
129
+ _ = writer.close(handleID: handleID, deadlineMs: deadlineMs) { [weak self] in
130
+ self?.finishClosing(path)
131
+ }
132
+ }
133
+
134
+ private func finishClosing(_ path: String) {
135
+ condition.lock()
136
+ if let outstanding = closing[path], outstanding > 1 {
137
+ closing[path] = outstanding - 1
138
+ } else {
139
+ closing.removeValue(forKey: path)
140
+ }
141
+ condition.broadcast()
142
+ condition.unlock()
143
+ }
144
+
145
+ struct Resolved {
146
+ let url: URL
147
+ let canonicalPath: String
148
+ }
149
+
150
+ /// Turns a caller-supplied path into the canonical file it names.
151
+ ///
152
+ /// The parent directory is created before resolution, because `realpath`
153
+ /// answers only for things that exist — resolving first would fall back to
154
+ /// the literal string on a fresh install and hand out a key that stops
155
+ /// matching the moment the directory appears.
156
+ static func resolve(path: String) throws -> Resolved {
157
+ guard !path.isEmpty else { throw LogWriterError.openFailed("empty path") }
158
+ let url = URL(fileURLWithPath: path).standardizedFileURL
159
+ let directory = url.deletingLastPathComponent()
160
+ let name = url.lastPathComponent
161
+ guard !name.isEmpty, name != "." , name != ".." else {
162
+ throw LogWriterError.openFailed("path does not name a file")
163
+ }
164
+
165
+ try LogSecureFile.createDirectory(at: directory)
166
+
167
+ guard let canonicalDirectory = realpath(directory.path, nil) else {
168
+ throw LogWriterError.openFailed("could not resolve the log directory")
169
+ }
170
+ defer { free(canonicalDirectory) }
171
+ let directoryPath = String(cString: canonicalDirectory)
172
+
173
+ // The log file itself must not be a symlink. Following one would write the
174
+ // app's log wherever the link points — a path the caller never named and
175
+ // the purge would never clean — and would let two different registry keys
176
+ // resolve to the same inode.
177
+ let candidate = URL(fileURLWithPath: directoryPath).appendingPathComponent(name)
178
+ var info = stat()
179
+ if lstat(candidate.path, &info) == 0 && (info.st_mode & S_IFMT) == S_IFLNK {
180
+ throw LogWriterError.symlinkEscape
181
+ }
182
+
183
+ return Resolved(url: candidate, canonicalPath: candidate.path)
184
+ }
185
+
186
+ // MARK: - Test support
187
+
188
+ /// A registry with no shared state, so tests do not leak writers into each
189
+ /// other through the singleton.
190
+ static func isolated() -> LogWriterRegistry { LogWriterRegistry() }
191
+
192
+ var liveWriterCountForTesting: Int {
193
+ condition.lock()
194
+ defer { condition.unlock() }
195
+ return writers.count
196
+ }
197
+
198
+ var closingCountForTesting: Int {
199
+ condition.lock()
200
+ defer { condition.unlock() }
201
+ return closing.count
202
+ }
203
+ }
204
+
205
+ /// One JavaScript destination's claim on a writer.
206
+ ///
207
+ /// Owns the identity that loss is attributed to and the generation that fences
208
+ /// it after a purge. The Nitro hybrid object holds exactly one of these, so the
209
+ /// native finalizer running is enough to release the claim — no JavaScript has
210
+ /// to execute for the descriptor to come back.
211
+ public final class LogFileHandle {
212
+ let id: UInt64
213
+ private let writer: LogWriter
214
+ private weak var registry: LogWriterRegistry?
215
+ /// A three-state lifecycle rather than a `released` flag.
216
+ ///
217
+ /// The middle state is the one that matters: `close` flushes before it lets
218
+ /// go, and with only a boolean the handle stayed fully usable throughout that
219
+ /// flush — new appends and even a purge could start while shutdown was
220
+ /// already under way. Entering `closing` first shuts the door before any of
221
+ /// the waiting begins.
222
+ private enum State { case active, closing, released }
223
+
224
+ /// A condition rather than a plain lock, because a purge must be waited *out*
225
+ /// rather than waited *behind*.
226
+ ///
227
+ /// A purge can legitimately run for its full deadline — tens of seconds on a
228
+ /// slow volume. Holding this lock for that whole span would make every other
229
+ /// entry point inherit that wait, and `close`, which has a deadline of its
230
+ /// own to keep, would blow straight through it. So the purge marks itself
231
+ /// with `purging` and drops the lock; anyone who genuinely cannot proceed
232
+ /// alongside it waits on the condition under their own bound.
233
+ private let condition = NSCondition()
234
+ private var generation: UInt64
235
+ private var state: State = .active
236
+ /// Set for the duration of `clearLogs`, so `close` can tell "a deletion is in
237
+ /// flight" from "the handle is idle" without holding the lock across it.
238
+ private var purging = false
239
+
240
+ init(id: UInt64, writer: LogWriter, registry: LogWriterRegistry) {
241
+ self.id = id
242
+ self.writer = writer
243
+ self.registry = registry
244
+ self.generation = writer.currentGeneration
245
+ }
246
+
247
+ deinit {
248
+ // Zero deadline: a deinit may be running during runtime teardown, where
249
+ // blocking on a wedged disk is a hang the user sees as a frozen app. The
250
+ // descriptor is closed either way.
251
+ releaseNow(deadlineMs: 0)
252
+ }
253
+
254
+ public var filePath: String { writer.fileURL.path }
255
+
256
+ /// The generation to write under, or `nil` once this handle is released.
257
+ ///
258
+ /// Releasing drops this handle's claim, but the writer itself lives on while
259
+ /// any other handle holds it — so nothing about the writer stops a released
260
+ /// handle from still working. Every operation checks here instead. The one
261
+ /// that matters most is `clearLogs`: without this, a destination that was
262
+ /// disposed minutes ago could delete the files that live destinations are
263
+ /// writing to.
264
+ private func liveGeneration() -> UInt64? {
265
+ condition.lock()
266
+ defer { condition.unlock() }
267
+ return state == .active ? generation : nil
268
+ }
269
+
270
+ /// Reserves and enqueues a batch, **with the handle lock held across the
271
+ /// call**.
272
+ ///
273
+ /// Reading the generation and then releasing the lock before appending leaves
274
+ /// a window where `close` shuts the door, flushes, and returns `durable` — and
275
+ /// only then does the append it never saw reach the writer and get accepted.
276
+ /// Those records are outside the barrier that just promised everything was on
277
+ /// disk. Holding the lock closes the window at no cost: `writer.append` only
278
+ /// reserves bytes and enqueues, never touches the disk, and never reaches back
279
+ /// for this lock.
280
+ public func appendBatch(_ batch: String, entryCount: Int) -> LogAppendResult {
281
+ condition.lock()
282
+ defer { condition.unlock() }
283
+
284
+ guard state == .active else {
285
+ return LogAppendResult(
286
+ accepted: false,
287
+ rejectReason: .closed,
288
+ status: LogSinkStatus(queuedBytes: 0, lostBytes: 0, lostEntries: 0, degraded: 0)
289
+ )
290
+ }
291
+ return writer.append(
292
+ handleID: id, handleGeneration: generation, batch: batch, entryCount: entryCount
293
+ )
294
+ }
295
+
296
+ public func status() -> LogSinkStatus {
297
+ guard liveGeneration() != nil else {
298
+ return LogSinkStatus(queuedBytes: 0, lostBytes: 0, lostEntries: 0, degraded: 0)
299
+ }
300
+ return writer.status(handleID: id)
301
+ }
302
+
303
+ public func flush(deadlineMs: Double) -> LogFlushOutcome {
304
+ guard liveGeneration() != nil else {
305
+ // Not `durable: true`. A released handle did not flush anything, and
306
+ // saying otherwise invites a caller to treat its records as safe.
307
+ return LogFlushOutcome(
308
+ durable: false, timedOut: false, pendingBytes: 0,
309
+ status: LogSinkStatus(queuedBytes: 0, lostBytes: 0, lostEntries: 0, degraded: 0)
310
+ )
311
+ }
312
+ return writer.flush(handleID: id, deadlineMs: deadlineMs)
313
+ }
314
+
315
+ public func logFilePaths() -> [String] {
316
+ guard liveGeneration() != nil else { return [] }
317
+ return writer.logFilePaths()
318
+ }
319
+
320
+ /// Deletes every artifact, then rebinds this handle **only if the purge was
321
+ /// complete**.
322
+ ///
323
+ /// Rebinding on a partial deletion is the failure the ordering exists to
324
+ /// prevent: writes would resume into a directory where deletion is still
325
+ /// pending or has failed, and the records that landed there would be the ones
326
+ /// the purge was supposed to guarantee were gone. Staying fenced costs new
327
+ /// logs until an explicit retry; rebinding early costs the guarantee.
328
+ ///
329
+ /// The purge marks itself and drops the lock rather than holding it for the
330
+ /// whole deletion. It is bounded by its own deadline, but that bound can be
331
+ /// tens of seconds, and every other entry point — `close` above all, which
332
+ /// has a deadline it has promised to keep — would otherwise inherit it. The
333
+ /// generation fence, not this lock, is what keeps concurrent appends off a
334
+ /// directory being deleted: the writer bumps it before the first `unlink`, so
335
+ /// anything still holding the old one is refused as stale.
336
+ public func clearLogs(deadlineMs: Double) -> LogClearOutcome {
337
+ condition.lock()
338
+ // A handle on its way out must not be able to delete files that live
339
+ // destinations are writing to; and a second concurrent purge on this handle
340
+ // would race the rebind below with nothing to arbitrate the two.
341
+ guard state == .active, !purging else {
342
+ condition.unlock()
343
+ return LogClearOutcome(deletedCount: 0, failedPaths: [], durable: false)
344
+ }
345
+ purging = true
346
+ condition.unlock()
347
+
348
+ let result = writer.clearLogs(deadlineMs: deadlineMs)
349
+
350
+ condition.lock()
351
+ defer { condition.unlock() }
352
+ purging = false
353
+ // Broadcast unconditionally: a `close` may be waiting this purge out under
354
+ // its own deadline, and it has to learn the deletion finished whether or
355
+ // not the deletion succeeded.
356
+ condition.broadcast()
357
+
358
+ // Rebind only onto a writer that came back with a usable descriptor, and
359
+ // only if this handle is still open. Deletion succeeding and the writer
360
+ // being usable again are separate facts; adopting the new generation
361
+ // without the second means accepting records into a writer that has nowhere
362
+ // to put them, and adopting it after a `close` slipped in reopens a door
363
+ // that was deliberately shut.
364
+ //
365
+ // Bind to the generation THIS purge established — never to whatever is
366
+ // current by now. Another purge may have moved the fence on since, and
367
+ // adopting its generation would authorise writes against a deletion still
368
+ // in flight. Binding to a superseded generation is safe: the next append is
369
+ // simply refused as stale, which is what a fenced handle should get.
370
+ var outcome = result.outcome
371
+ if state == .active, outcome.durable, outcome.rebound {
372
+ generation = result.generation
373
+ } else {
374
+ // `rebound` is a fact about THIS handle, not about the writer, and the
375
+ // two come apart exactly when a `close` gives up waiting for this purge:
376
+ // the writer really did get a fresh descriptor, and this handle really is
377
+ // never going to adopt it. Passing the writer's answer up would tell the
378
+ // caller to resume a destination that has already been shut.
379
+ outcome.rebound = false
380
+ }
381
+ return outcome
382
+ }
383
+
384
+ /// Shuts the handle and gives the writer back, inside **one** budget.
385
+ ///
386
+ /// Closing is three waits — out an in-flight purge, through the flush, then
387
+ /// through the writer's own teardown — and a caller that asked for 200 ms
388
+ /// meant 200 ms for the lot. Handing each step the full figure turns a
389
+ /// deadline into a multiple of itself, which on the JS side is a dispose that
390
+ /// blocks the runtime for three times as long as it promised.
391
+ public func close(deadlineMs: Double) -> LogFlushOutcome {
392
+ let expiry = Date().addingTimeInterval(Double(LogWriter.clampDeadline(deadlineMs)) / 1000)
393
+
394
+ // Shut the door before doing any waiting: everything below can take the
395
+ // whole budget, and nothing new should be able to start during it.
396
+ condition.lock()
397
+ guard state == .active else {
398
+ condition.unlock()
399
+ return LogFlushOutcome(
400
+ durable: false, timedOut: false, pendingBytes: 0,
401
+ status: LogSinkStatus(queuedBytes: 0, lostBytes: 0, lostEntries: 0, degraded: 0)
402
+ )
403
+ }
404
+ state = .closing
405
+
406
+ // Let an in-flight deletion finish before tearing the writer down. Waiting
407
+ // is worth something: a purge interrupted by the close barrier still
408
+ // deletes, it just cannot reopen, so the fresh file is missing until the
409
+ // next acquire. Waiting past the budget is worth nothing.
410
+ while purging {
411
+ if !condition.wait(until: expiry) { break }
412
+ }
413
+ condition.unlock()
414
+
415
+ let outcome = writer.flush(handleID: id, deadlineMs: Self.remaining(until: expiry))
416
+ releaseNow(deadlineMs: Self.remaining(until: expiry))
417
+ return outcome
418
+ }
419
+
420
+ /// Milliseconds left, floored at zero — which the writer reads as "do not
421
+ /// wait", not as "wait forever".
422
+ private static func remaining(until expiry: Date) -> Double {
423
+ max(0, expiry.timeIntervalSinceNow * 1000)
424
+ }
425
+
426
+ private func releaseNow(deadlineMs: Double) {
427
+ condition.lock()
428
+ if state == .released {
429
+ condition.unlock()
430
+ return
431
+ }
432
+ state = .released
433
+ condition.broadcast()
434
+ condition.unlock()
435
+ registry?.release(writer, handleID: id, deadlineMs: deadlineMs)
436
+ }
437
+
438
+ var writerForTesting: LogWriter { writer }
439
+ var generationForTesting: UInt64 {
440
+ condition.lock()
441
+ defer { condition.unlock() }
442
+ return generation
443
+ }
444
+ }
@@ -0,0 +1,180 @@
1
+ import Foundation
2
+ import os
3
+
4
+ /// The os_log bridge, minus Nitro.
5
+ ///
6
+ /// Everything worth getting wrong here — the level map, and what happens to a
7
+ /// line longer than the unified log will store — is a pure function of its
8
+ /// input, so it lives where XCTest can reach it. `HybridNativeConsoleSink` is
9
+ /// the adapter around this and holds no logic of its own.
10
+ public final class NativeConsoleWriter {
11
+
12
+ /// Bytes of message text per `os_log` call.
13
+ ///
14
+ /// The unified log stores a bounded amount per entry and silently drops the
15
+ /// rest — no ellipsis, no diagnostic, the tail simply is not there. For a
16
+ /// logger that is the worst kind of failure: the console shows a complete-
17
+ /// looking line that is missing the half you needed. 900 leaves room under
18
+ /// the practical 1 KB limit for the chunk marker and os_log's own framing.
19
+ static let chunkBytes = 900
20
+
21
+ /// At most this many chunks per line. A stack trace should arrive whole; a
22
+ /// runaway 4 MB string should not become five thousand console entries.
23
+ static let maxChunks = 8
24
+
25
+ private let lock = NSLock()
26
+ private var logger: os.Logger?
27
+
28
+ public init() {}
29
+
30
+ /// Binds the subsystem and category. Calling it again rebinds.
31
+ public func install(subsystem: String, category: String) {
32
+ // Empty strings produce a logger that is legal but unfindable in Console,
33
+ // so fall back to something a developer can actually search for.
34
+ let subsystem = subsystem.isEmpty ? Self.fallbackSubsystem : subsystem
35
+ let category = category.isEmpty ? "log" : category
36
+ let bound = os.Logger(subsystem: subsystem, category: category)
37
+ lock.lock()
38
+ logger = bound
39
+ lock.unlock()
40
+ }
41
+
42
+ /// Writes one drained batch.
43
+ ///
44
+ /// `levels` and `messages` are parallel arrays rather than a struct array,
45
+ /// which keeps a batch to two bridge crossings instead of one per entry.
46
+ /// `messages` is authoritative on count: a short `levels` means the caller
47
+ /// has a bug, and dropping the messages it does have would turn that bug
48
+ /// into missing logs.
49
+ public func logBatch(levels: [Double], messages: [String]) {
50
+ guard !messages.isEmpty else { return }
51
+ let logger = self.resolvedLogger()
52
+
53
+ for (index, message) in messages.enumerated() {
54
+ let type = Self.osLogType(forCode: index < levels.count ? levels[index] : 2)
55
+ for chunk in Self.chunks(of: message) {
56
+ // `.public` because redaction already happened in the JavaScript layer
57
+ // — what arrives here is the final rendered line, and marking it
58
+ // private would hide it from the developer without protecting anything
59
+ // that was not already decided upstream.
60
+ logger.log(level: type, "\(chunk, privacy: .public)")
61
+ }
62
+ }
63
+ }
64
+
65
+ /// A logger even if `install` never ran.
66
+ ///
67
+ /// Losing every line because a caller forgot a setup call is a worse failure
68
+ /// than logging under a guessed category, and this is the diagnostic channel
69
+ /// — the one you reach for when something else has already gone wrong.
70
+ private func resolvedLogger() -> os.Logger {
71
+ lock.lock()
72
+ if let logger {
73
+ lock.unlock()
74
+ return logger
75
+ }
76
+ let fallback = os.Logger(subsystem: Self.fallbackSubsystem, category: "log")
77
+ logger = fallback
78
+ lock.unlock()
79
+ return fallback
80
+ }
81
+
82
+ private static let fallbackSubsystem =
83
+ Bundle.main.bundleIdentifier ?? "com.nitrologger"
84
+
85
+ // MARK: - Level mapping
86
+
87
+ /// Codes 0–5 (verbose…todo), matching `LEVEL_ORDER` in TypeScript.
88
+ ///
89
+ /// There is no `Int(_:)` here, deliberately. Levels arrive as `Double`
90
+ /// because that is what JavaScript has, and that conversion **traps** on
91
+ /// `NaN` and on anything outside `Int`'s range — and `1e30` is perfectly
92
+ /// finite, so an `isFinite` guard does not save it. A trap here is a crash in
93
+ /// the logging path, reachable from every log call in the app. Matching on
94
+ /// the `Double` cannot trap at all, and is exact for values this small.
95
+ ///
96
+ /// One rule for everything else: a code this does not recognise — negative,
97
+ /// enormous, `NaN` — is `.default`. It carries no trustworthy severity, and
98
+ /// `.default` shows it without hiding it as `.debug` or crying `.fault`. The
99
+ /// rendered line still carries its own level tag either way.
100
+ static func osLogType(forCode code: Double) -> OSLogType {
101
+ switch code.rounded() {
102
+ case 0, 1: return .debug // verbose, debug
103
+ case 2: return .info
104
+ case 3: return .default // warning
105
+ case 4: return .error
106
+ case 5: return .fault // todo
107
+ default: return .default
108
+ }
109
+ }
110
+
111
+ // MARK: - Chunking
112
+
113
+ /// Splits a line into pieces the unified log will store whole.
114
+ ///
115
+ /// A line that already fits comes back untouched and unmarked — the common
116
+ /// case must look exactly as it was rendered. Only when splitting is
117
+ /// unavoidable does each piece get an `(i/n)` prefix, so a reader can tell a
118
+ /// continuation from a fresh entry and can see when one is missing.
119
+ ///
120
+ /// Splits fall on Character boundaries, never inside a grapheme: cutting a
121
+ /// flag emoji or a combining sequence in half produces replacement
122
+ /// characters in Console and, worse, suggests corruption that is not there.
123
+ /// A single grapheme wider than a whole chunk — which no real log line
124
+ /// contains — is emitted intact and left for the unified log to trim, since
125
+ /// the alternative is deliberately producing mojibake.
126
+ static func chunks(of message: String) -> [String] {
127
+ guard message.utf8.count > chunkBytes else { return [message] }
128
+
129
+ // Reserve room for the widest marker any of these pieces can carry, so
130
+ // adding the prefix cannot push a piece back over the limit.
131
+ let budget = chunkBytes - markerWidth
132
+ var pieces: [String] = []
133
+ var current = ""
134
+ var currentBytes = 0
135
+
136
+ for character in message {
137
+ let width = String(character).utf8.count
138
+ if currentBytes + width > budget, !current.isEmpty {
139
+ pieces.append(current)
140
+ current = ""
141
+ currentBytes = 0
142
+ if pieces.count == maxChunks { break }
143
+ }
144
+ current.append(character)
145
+ currentBytes += width
146
+ }
147
+
148
+ if pieces.count < maxChunks, !current.isEmpty {
149
+ pieces.append(current)
150
+ }
151
+
152
+ // Whatever is left over after the chunk ceiling is announced by size
153
+ // rather than dropped in silence. A byte count is a length, not content,
154
+ // so it says nothing about what was in the line.
155
+ let consumed = pieces.reduce(0) { $0 + $1.utf8.count }
156
+ var remaining = message.utf8.count - consumed
157
+ if remaining > 0, var last = pieces.popLast() {
158
+ // Make room for the notice inside the entry rather than appending past
159
+ // the limit — otherwise the unified log truncates the very sentence that
160
+ // exists to report truncation, which is the one line here that must
161
+ // survive. Each character given back grows the count it reports.
162
+ while last.utf8.count + noticeWidth + markerWidth > chunkBytes,
163
+ let dropped = last.popLast() {
164
+ remaining += String(dropped).utf8.count
165
+ }
166
+ last += " …+\(remaining) bytes truncated"
167
+ pieces.append(last)
168
+ }
169
+
170
+ let total = pieces.count
171
+ return pieces.enumerated().map { "(\($0.offset + 1)/\(total)) \($0.element)" }
172
+ }
173
+
174
+ /// `"(8/8) "` — the widest prefix `maxChunks` can produce.
175
+ private static let markerWidth = "(\(maxChunks)/\(maxChunks)) ".utf8.count
176
+
177
+ /// The truncation notice at its widest, so reserving this much is always
178
+ /// enough however large the count turns out to be.
179
+ private static let noticeWidth = " …+\(Int.max) bytes truncated".utf8.count
180
+ }