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,1328 @@
1
+ package com.margelo.nitro.nitrologger
2
+
3
+ import java.io.File
4
+ import java.io.FileOutputStream
5
+ import java.io.RandomAccessFile
6
+ import java.util.Locale
7
+ import java.util.concurrent.CountDownLatch
8
+ import java.util.concurrent.Executors
9
+ import java.util.concurrent.RejectedExecutionException
10
+ import java.util.concurrent.ThreadFactory
11
+ import java.util.concurrent.TimeUnit
12
+ import java.util.concurrent.locks.ReentrantLock
13
+ import java.util.zip.GZIPOutputStream
14
+ import kotlin.concurrent.withLock
15
+
16
+ /**
17
+ * The thing that actually writes, one per canonical path.
18
+ *
19
+ * Handles for the same file share it — see [LogWriterRegistry]. Batching, drop
20
+ * policy and loss notices all live in JavaScript; this side appends pre-batched
21
+ * text, rotates, compresses, prunes and keeps counters. It is deliberately the
22
+ * dumb end.
23
+ *
24
+ * **Two synchronisation domains, and the split is the whole design.**
25
+ * [stateLock] guards counters and the stream swap and is never held across I/O;
26
+ * the single-threaded [executor] owns every byte that touches the disk. That is
27
+ * what makes [status] answerable while the writer thread is stalled on storage
28
+ * that has stopped responding — which the JavaScript side depends on, because
29
+ * its backpressure loop polls exactly then.
30
+ *
31
+ * **Accepted bytes stay reserved until the write terminally completes.** The cap
32
+ * is on bytes in flight, not bytes enqueued, so a burst cannot queue a gigabyte
33
+ * behind a slow disk by virtue of each individual batch fitting.
34
+ *
35
+ * This is a port of the iOS `LogFileWriter`, and the parity is the point: a
36
+ * rotation or purge that behaves differently on the two platforms is a bug
37
+ * report nobody can reproduce. Where the platforms genuinely differ — no
38
+ * `O_NOFOLLOW`, no protection class, `Os.fstat` behind [PlatformIo] — the
39
+ * comment says so rather than papering over it.
40
+ */
41
+ class LogFileWriter internal constructor(
42
+ val file: File,
43
+ val canonicalPath: String,
44
+ val policy: LogRotationPolicy,
45
+ val lineFramed: Boolean,
46
+ private val platform: PlatformIo,
47
+ private val rawWrite: RawWrite,
48
+ private val compressor: Compressor,
49
+ /**
50
+ * Epoch millis. Answers "how old is this file" and stamps archive names, both
51
+ * of which have to mean the same thing across a restart — so this one is
52
+ * allowed to jump when the device clock is corrected.
53
+ */
54
+ private val clock: () -> Long,
55
+ /**
56
+ * Monotonic millis. Answers "how much of the budget is left" and "has the
57
+ * backoff elapsed", neither of which may ever be affected by an NTP
58
+ * correction or a user changing the date.
59
+ *
60
+ * The two are separate because a wall clock that jumps forward mid-wait
61
+ * shortens a deadline harmlessly, but one that jumps *backward* extends it —
62
+ * and a close that was promised 200 ms and takes an hour is an ANR. iOS keeps
63
+ * the same split, using `DispatchTime` for deadlines and `Date` for ages.
64
+ */
65
+ private val monotonic: () -> Long
66
+ ) {
67
+ /**
68
+ * The raw write, injectable so short writes and hard failures can be tested
69
+ * without storage that misbehaves on demand.
70
+ *
71
+ * Returns the number of bytes written, which is allowed to be fewer than
72
+ * asked for. `FileOutputStream.write` loops internally, but it can still
73
+ * throw after having written some of the buffer — so the caller has to treat
74
+ * a failure as "an unknown prefix landed" either way.
75
+ */
76
+ fun interface RawWrite {
77
+ fun write(stream: FileOutputStream, data: ByteArray, offset: Int, length: Int): Int
78
+ }
79
+
80
+ /** Archive compression, injectable because the interesting case is failure. */
81
+ fun interface Compressor {
82
+ fun compress(source: File, destination: File): Boolean
83
+ }
84
+
85
+ companion object {
86
+ /**
87
+ * Hard cap on payload bytes in flight. Documented as a payload bound: the
88
+ * process still holds the `ByteArray` copies and queue entries on top.
89
+ */
90
+ const val HARD_CAP_BYTES = 1_048_576L
91
+
92
+ /** Reads used to find the last record boundary during a startup trim. */
93
+ private const val TAIL_SCAN_WINDOW = 1 shl 20
94
+
95
+ /** Successful writes between descriptor-liveness checks. */
96
+ private const val HEALTH_CHECK_STRIDE = 8
97
+
98
+ /** How long a failed rotation waits before being attempted again. */
99
+ private const val ROTATION_BACKOFF_MS = 5_000L
100
+
101
+ /** How long a failed reopen waits. */
102
+ private const val REOPEN_BACKOFF_MS = 1_000L
103
+
104
+ /**
105
+ * Longest any deadline-bounded call will wait. Well short of the ANR
106
+ * window a synchronous crash-path flush has to live inside.
107
+ */
108
+ const val MAX_DEADLINE_MS = 30_000L
109
+
110
+ /**
111
+ * How long [logFilePaths] will wait for the executor before answering with
112
+ * the active path alone. Short: this call takes no deadline of its own, and
113
+ * a support-log collection is not worth blocking the JS thread over.
114
+ */
115
+ const val PATHS_DEADLINE_MS = 2_000L
116
+
117
+ private const val NEWLINE = '\n'.code.toByte()
118
+
119
+ /**
120
+ * `Infinity` means "wait as long as you are allowed to", which is the
121
+ * ceiling — not zero. `NaN` and non-positive values mean no waiting, which
122
+ * is also what a caller passing 0 deliberately asks for.
123
+ */
124
+ fun clampDeadline(value: Double): Long = when {
125
+ value.isNaN() || value <= 0 -> 0L
126
+ value.isInfinite() -> MAX_DEADLINE_MS
127
+ else -> minOf(value, MAX_DEADLINE_MS.toDouble()).toLong()
128
+ }
129
+
130
+ private val defaultRawWrite = RawWrite { stream, data, offset, length ->
131
+ stream.write(data, offset, length)
132
+ length
133
+ }
134
+
135
+ private val defaultCompressor = Compressor { source, destination ->
136
+ try {
137
+ source.inputStream().use { input ->
138
+ GZIPOutputStream(destination.outputStream().buffered()).use { output ->
139
+ input.copyTo(output, 64 * 1024)
140
+ }
141
+ }
142
+ true
143
+ } catch (_: Exception) {
144
+ false
145
+ }
146
+ }
147
+
148
+ /**
149
+ * `<base>.YYYYMMDDTHHMMSSZ_<8 hex>`.
150
+ *
151
+ * Spelled out rather than left to a locale-sensitive formatter so the
152
+ * produced name and [isArchiveName] cannot drift apart — a purge that stops
153
+ * recognising its own archives is a silent compliance failure. The random
154
+ * suffix disambiguates rotations inside the same second, which the
155
+ * one-second stamp cannot.
156
+ */
157
+ private val ARCHIVE_SUFFIX = Regex("""^\d{8}T\d{6}Z_[a-f0-9]{8}(\.gz)?$""")
158
+ private val STAGING_SUFFIX = Regex("""^\d{8}T\d{6}Z_[a-f0-9]{8}\.gz\.part$""")
159
+
160
+ /**
161
+ * A finished archive: `<base>.<stamp>` or `<base>.<stamp>.gz`.
162
+ *
163
+ * Deliberately excludes `.part`. A staging file is a compression that was
164
+ * interrupted — counting it as an archive would let it occupy a retention
165
+ * slot a real archive should have, and would hand a truncated gzip to
166
+ * anyone calling `getLogFilePaths()` to collect logs for support.
167
+ */
168
+ fun isArchiveName(name: String, baseName: String): Boolean =
169
+ matches(name, baseName, ARCHIVE_SUFFIX)
170
+
171
+ /** A gzip staging file, `<base>.<stamp>.gz.part`. */
172
+ fun isStagingName(name: String, baseName: String): Boolean =
173
+ matches(name, baseName, STAGING_SUFFIX)
174
+
175
+ /**
176
+ * Everything this writer can ever put on disk under its directory: the
177
+ * active file, the sidecar, every archive, every gzip staging file.
178
+ *
179
+ * This predicate IS the naming scheme, and `clearLogs` is the reason it is
180
+ * written down in one place. A purge that recognises fewer names than
181
+ * rotation can produce leaves survivors — and an interrupted compression
182
+ * leaving a plaintext orphan that outlives a compliance purge is exactly
183
+ * the failure this is here to prevent.
184
+ */
185
+ fun isArtifactName(name: String, baseName: String): Boolean {
186
+ if (name == baseName) return true
187
+ if (name == "$baseName.meta") return true
188
+ return isArchiveName(name, baseName) || isStagingName(name, baseName)
189
+ }
190
+
191
+ private fun matches(name: String, baseName: String, pattern: Regex): Boolean {
192
+ val prefix = "$baseName."
193
+ if (!name.startsWith(prefix)) return false
194
+ return pattern.matches(name.substring(prefix.length))
195
+ }
196
+
197
+ @Throws(LogWriterException::class)
198
+ fun open(
199
+ file: File,
200
+ canonicalPath: String,
201
+ policy: LogRotationPolicy,
202
+ lineFramed: Boolean,
203
+ /**
204
+ * Required, with no default on purpose. [PlatformIo.Jvm] is the only
205
+ * thing in this package that touches `java.nio.file`, so a caller who
206
+ * silently fell back to it on Android would reintroduce the API-26
207
+ * problem the seam exists to keep out. Tests pass it explicitly too.
208
+ */
209
+ platform: PlatformIo,
210
+ rawWrite: RawWrite? = null,
211
+ compressor: Compressor? = null,
212
+ clock: (() -> Long)? = null,
213
+ monotonic: (() -> Long)? = null
214
+ ): LogFileWriter = LogFileWriter(
215
+ file = file,
216
+ canonicalPath = canonicalPath,
217
+ policy = policy,
218
+ lineFramed = lineFramed,
219
+ platform = platform,
220
+ rawWrite = rawWrite ?: defaultRawWrite,
221
+ compressor = compressor ?: defaultCompressor,
222
+ clock = clock ?: System::currentTimeMillis,
223
+ monotonic = monotonic ?: { System.nanoTime() / 1_000_000L }
224
+ ).also { it.start() }
225
+ }
226
+
227
+ /** Set by the thread factory so the inline-flush guard can recognise itself. */
228
+ @Volatile
229
+ private var writerThread: Thread? = null
230
+
231
+ private val executor = Executors.newSingleThreadExecutor(ThreadFactory { runnable ->
232
+ Thread(runnable, "com.nitrologger.filewriter").apply {
233
+ isDaemon = true
234
+ writerThread = this
235
+ }
236
+ })
237
+
238
+ // State behind stateLock — cheap, never held across I/O.
239
+ private val stateLock = ReentrantLock()
240
+ /** Held for the whole of [clearLogs], so purges cannot interleave. */
241
+ private val purgeLock = ReentrantLock()
242
+ private var reservedBytes = 0L
243
+ private var generation = 1L
244
+ private var closed = false
245
+ private var degraded = LogDegradation.NONE
246
+ private val loss = HashMap<Long, LongArray>()
247
+ private var lastSyncSucceeded = true
248
+ /** Handles currently holding this writer. The registry evicts at zero. */
249
+ var refCount = 0
250
+ private set
251
+
252
+ // State confined to the executor thread.
253
+ private var stream: FileOutputStream? = null
254
+ private var currentFileSize = 0L
255
+ private var currentFileStart = 0L
256
+ private var lastReopenAttempt = Long.MIN_VALUE / 4
257
+ private var rotationBlockedUntil = Long.MIN_VALUE / 4
258
+ /**
259
+ * A rotation's fresh start time that is not safely on disk yet.
260
+ *
261
+ * Set when a rotation archives the file, cleared only once the sidecar has
262
+ * actually been written. While it is set it outranks the sidecar entirely,
263
+ * because the sidecar still holds the *archived* file's age — and every
264
+ * reopen has to survive that, not just the one rotation performs. A failed
265
+ * sidecar write followed by a failed reopen would otherwise send the next
266
+ * backoff retry back to the stale value and restart the rotate-on-every-write
267
+ * runaway.
268
+ */
269
+ private var pendingFileStart: Long? = null
270
+ private var writesSinceHealthCheck = 0
271
+ /**
272
+ * Set by the close barrier, on the executor. Everything enqueued before the
273
+ * barrier still writes; everything after it is refused.
274
+ */
275
+ private var terminated = false
276
+ /** Rotations that got past the backoff guard. Test support. */
277
+ private var rotationAttempts = 0
278
+
279
+ private val directory: File get() = file.parentFile ?: File(".")
280
+ private val baseName: String get() = file.name
281
+
282
+ private fun start() {
283
+ val shortfall = !LogSecureFile.createDirectory(directory, platform)
284
+ if (shortfall) note(LogDegradation.PROTECTION)
285
+
286
+ // The startup trim happens before the append stream exists, through its own
287
+ // read-write descriptor. iOS reads and truncates through the very
288
+ // descriptor it will write with; here the JDK gives no single handle that
289
+ // is both appending and readable, so this is a separate open. The window
290
+ // that opens is the construction of one writer over app-private storage
291
+ // with no other descriptor on the file yet, which is the narrowest form of
292
+ // the problem — but it is a real difference and not worth hiding.
293
+ trimTornTailIfFramed()
294
+
295
+ if (!reopen(initial = true)) {
296
+ throw LogWriterException(
297
+ LogWriterException.Kind.OPEN_FAILED,
298
+ "could not open the log file"
299
+ )
300
+ }
301
+ onExecutor { sweepRetention() }
302
+ }
303
+
304
+ // MARK: - Reference counting (registry-owned, called under the registry lock)
305
+
306
+ fun retain() {
307
+ refCount += 1
308
+ }
309
+
310
+ fun releaseOne(): Int {
311
+ refCount -= 1
312
+ return refCount
313
+ }
314
+
315
+ // MARK: - Opening
316
+
317
+ /**
318
+ * Opens for appending, creating if needed. Every open funnels through here.
319
+ *
320
+ * Append mode is `O_APPEND` underneath, which makes each write land at the
321
+ * current end of file whatever else wrote in between. Without it a second
322
+ * descriptor on the same file — a replaced destination draining late, a stale
323
+ * handle after a rotation — writes from its own stale offset and silently
324
+ * overwrites.
325
+ *
326
+ * There is no `O_NOFOLLOW` equivalent here, so the symlink check is done by
327
+ * the registry before the path is ever handed over, and re-checked here.
328
+ * That is a check-then-open rather than one atomic syscall; on Android the
329
+ * directory is app-private storage that no other app can write to, which is
330
+ * what makes the remaining window acceptable rather than merely unavoidable.
331
+ */
332
+ private fun reopen(initial: Boolean = false): Boolean {
333
+ if (!initial) lastReopenAttempt = monotonic()
334
+ closeCurrentStream()
335
+
336
+ if (!LogSecureFile.createDirectory(directory, platform)) note(LogDegradation.PROTECTION)
337
+
338
+ val opened = try {
339
+ if (LogSecureFile.isSymbolicLink(file, platform)) null else FileOutputStream(file, true)
340
+ } catch (_: Exception) {
341
+ null
342
+ }
343
+
344
+ if (opened == null) {
345
+ // Reset the rotation triggers even though nothing opened. Leaving the
346
+ // size above the threshold makes every later write re-enter rotation,
347
+ // which archives the file again and again until pruning has eaten every
348
+ // real archive.
349
+ currentFileSize = 0
350
+ // `pendingFileStart` deliberately survives a failed open — it is the only
351
+ // record that this file was rotated, and the retry needs it.
352
+ currentFileStart = pendingFileStart ?: clock()
353
+ writesSinceHealthCheck = 0
354
+ return false
355
+ }
356
+
357
+ if (!LogSecureFile.secure(file, platform)) note(LogDegradation.PROTECTION)
358
+
359
+ stream = opened
360
+ currentFileSize = try {
361
+ opened.channel.size()
362
+ } catch (_: Exception) {
363
+ file.length()
364
+ }
365
+ // A pending rotation time outranks the sidecar, which until it is rewritten
366
+ // still describes the file that was just archived. The write is retried on
367
+ // every reopen and the value is only dropped once it lands, so no number of
368
+ // failed opens in between can send this process back to the stale age.
369
+ val pending = pendingFileStart
370
+ if (pending != null) {
371
+ currentFileStart = pending
372
+ if (writeSidecar(pending)) pendingFileStart = null else note(LogDegradation.SIDECAR)
373
+ } else {
374
+ currentFileStart = creationTimeOf(file)
375
+ }
376
+ writesSinceHealthCheck = 0
377
+ return true
378
+ }
379
+
380
+ private fun closeCurrentStream() {
381
+ val live = stream ?: return
382
+ stream = null
383
+ try {
384
+ live.close()
385
+ } catch (_: Exception) {
386
+ // The descriptor is going away regardless.
387
+ }
388
+ }
389
+
390
+ /**
391
+ * When the current log file was created, for age-based rotation.
392
+ *
393
+ * **The sidecar is authoritative once it exists.** Age rotation has to survive
394
+ * a restart, so it cannot be measured from when this process happened to open
395
+ * the file — but the filesystem's own answer cannot carry it either.
396
+ * `creationTime()` is API 26, and where it exists it is not reliably
397
+ * populated: several Android filesystems have no birth time and hand back the
398
+ * mtime instead. An mtime advances on *every write*, so a writer that trusted
399
+ * it would see a freshly-created file at every restart and postpone age
400
+ * rotation forever — the exact failure the sidecar was introduced to prevent,
401
+ * which reading the filesystem first quietly reintroduced.
402
+ *
403
+ * So the filesystem is consulted only to *seed* a sidecar that does not exist
404
+ * yet, and only when its answer is plausible (positive, not in the future).
405
+ * After that the recorded value is the only one that counts, and rotation
406
+ * deletes it along with the file it describes.
407
+ */
408
+ private fun creationTimeOf(target: File): Long {
409
+ val now = clock()
410
+ val sidecar = File(directory, "$baseName.meta")
411
+ val recorded = try {
412
+ if (sidecar.isFile) sidecar.readText().trim().toLongOrNull() else null
413
+ } catch (_: Exception) {
414
+ null
415
+ }
416
+
417
+ // Positive AND not in the future. A recorded time ahead of the clock is
418
+ // either a corrupt sidecar or one written before the device's time was
419
+ // corrected backwards; either way `now - it` is negative, which reads as
420
+ // "this file is not old yet" and postpones age rotation until wall time
421
+ // catches up — potentially days. Falling through rewrites it.
422
+ if (recorded != null && recorded > 0 && recorded <= now) return recorded
423
+
424
+ // `Throwable`, not `Exception`. This is a best-effort enrichment on a path
425
+ // that must never fail to open a log file, and the specific hazard is
426
+ // `NoClassDefFoundError` from an implementation that reached for a class its
427
+ // API level does not have — an `Error`, which `catch (Exception)` would let
428
+ // straight through.
429
+ val reported = try {
430
+ platform.creationTimeMillis(target)
431
+ } catch (_: Throwable) {
432
+ null
433
+ }
434
+ val seed = if (reported != null && reported > 0 && reported <= now) reported else now
435
+
436
+ // Without a sidecar, age-based rotation restarts its clock on every open.
437
+ // That degrades the policy rather than breaking the writer, so it is
438
+ // recorded and logging continues.
439
+ if (!writeSidecar(seed)) note(LogDegradation.SIDECAR)
440
+ return seed
441
+ }
442
+
443
+ /** Records [value] as the current file's start time. */
444
+ private fun writeSidecar(value: Long): Boolean {
445
+ val sidecar = File(directory, "$baseName.meta")
446
+ return try {
447
+ sidecar.writeText(value.toString())
448
+ if (!LogSecureFile.secure(sidecar, platform)) note(LogDegradation.PROTECTION)
449
+ true
450
+ } catch (_: Exception) {
451
+ false
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Cuts a torn trailing record left by a crash — but only when the producer
457
+ * has declared that records are newline-framed.
458
+ *
459
+ * Without that declaration the trailing bytes are indistinguishable from a
460
+ * record that simply contains newlines, and trimming would eat good data to
461
+ * tidy up after a crash that may not have happened.
462
+ */
463
+ private fun trimTornTailIfFramed() {
464
+ if (!lineFramed || !file.isFile || file.length() == 0L) return
465
+
466
+ try {
467
+ RandomAccessFile(file, "rw").use { raf ->
468
+ val length = raf.length()
469
+ if (length == 0L) return
470
+
471
+ raf.seek(length - 1)
472
+ if (raf.read() == NEWLINE.toInt()) return // already on a record boundary
473
+
474
+ // Scan backwards a window at a time rather than giving up after one.
475
+ // A single fixed window is wrong in both directions: a record larger
476
+ // than the window hides its own preceding boundary, and a file whose
477
+ // only content is one incomplete record has no boundary at all.
478
+ val buffer = ByteArray(TAIL_SCAN_WINDOW)
479
+ var end = length
480
+ var keep: Long? = null
481
+
482
+ while (end > 0) {
483
+ val start = if (end > TAIL_SCAN_WINDOW) end - TAIL_SCAN_WINDOW else 0L
484
+ val wanted = (end - start).toInt()
485
+ raf.seek(start)
486
+ raf.readFully(buffer, 0, wanted)
487
+ val index = lastIndexOfNewline(buffer, wanted)
488
+ if (index >= 0) {
489
+ keep = start + index + 1
490
+ break
491
+ }
492
+ end = start
493
+ }
494
+
495
+ // No newline in the whole file. Under a *declared* framing contract
496
+ // that is not ambiguous — every record ends in one, so a file without
497
+ // any contains no complete record and all of it is torn.
498
+ val target = keep ?: 0L
499
+ if (target < length) raf.setLength(target)
500
+ }
501
+ } catch (_: Exception) {
502
+ // A file that cannot be read is left exactly as it was found.
503
+ }
504
+ }
505
+
506
+ private fun lastIndexOfNewline(buffer: ByteArray, length: Int): Int {
507
+ for (i in length - 1 downTo 0) {
508
+ if (buffer[i] == NEWLINE) return i
509
+ }
510
+ return -1
511
+ }
512
+
513
+ // MARK: - Appending
514
+
515
+ /**
516
+ * Accept or refuse a batch, then write it on the executor.
517
+ *
518
+ * The decision is made entirely under [stateLock] and before anything is
519
+ * enqueued, so the cap holds no matter how many threads are calling: two
520
+ * batches that each fit but do not fit together cannot both be accepted.
521
+ */
522
+ fun append(
523
+ handleId: Long,
524
+ handleGeneration: Long,
525
+ batch: String,
526
+ entryCount: Long
527
+ ): LogAppendResult {
528
+ val data = batch.toByteArray(Charsets.UTF_8)
529
+ val bytes = data.size.toLong()
530
+
531
+ stateLock.lock()
532
+ if (closed) return rejectUnlocking(LogRejectReason.CLOSED, handleId)
533
+ if (handleGeneration != generation) {
534
+ return rejectUnlocking(LogRejectReason.STALE_GENERATION, handleId)
535
+ }
536
+ // Validated on both sides of the bridge. An entry count that disagrees with
537
+ // the payload makes every loss number downstream a guess.
538
+ if (entryCount < 0 || entryCount > 1_000_000) {
539
+ return rejectUnlocking(LogRejectReason.FAILED, handleId)
540
+ }
541
+ if ((bytes == 0L) != (entryCount == 0L)) {
542
+ return rejectUnlocking(LogRejectReason.FAILED, handleId)
543
+ }
544
+ if (bytes == 0L) {
545
+ // Nothing to do, and nothing to complain about.
546
+ val status = statusLocked(handleId)
547
+ stateLock.unlock()
548
+ return LogAppendResult(accepted = true, rejectReason = null, status = status)
549
+ }
550
+ if (reservedBytes + bytes > HARD_CAP_BYTES) {
551
+ return rejectUnlocking(LogRejectReason.FULL, handleId)
552
+ }
553
+ reservedBytes += bytes
554
+ val status = statusLocked(handleId)
555
+ stateLock.unlock()
556
+
557
+ try {
558
+ executor.execute { performWrite(data, handleId, entryCount, handleGeneration) }
559
+ } catch (_: RejectedExecutionException) {
560
+ // A close shut the executor down between this batch being accepted and
561
+ // being submitted. Two things have to happen and neither is optional:
562
+ // give back the reservation, because nothing will ever run to release it
563
+ // and a leak here permanently inflates `queuedBytes` until the 1 MB cap
564
+ // refuses everything; and refuse the batch rather than letting the
565
+ // exception cross the bridge, since the caller must be told these records
566
+ // did not make it.
567
+ release(bytes)
568
+ return LogAppendResult(
569
+ accepted = false,
570
+ rejectReason = LogRejectReason.CLOSED,
571
+ status = status(handleId)
572
+ )
573
+ }
574
+ return LogAppendResult(accepted = true, rejectReason = null, status = status)
575
+ }
576
+
577
+ private fun rejectUnlocking(reason: LogRejectReason, handleId: Long): LogAppendResult {
578
+ val status = statusLocked(handleId)
579
+ stateLock.unlock()
580
+ return LogAppendResult(accepted = false, rejectReason = reason, status = status)
581
+ }
582
+
583
+ private fun performWrite(data: ByteArray, handleId: Long, entryCount: Long, writeGeneration: Long) {
584
+ try {
585
+ if (terminated) {
586
+ // Past the close barrier. Anything reaching here was accepted, missed
587
+ // the flush, and has no stream left to go to — reopening one would
588
+ // resurrect a writer the caller has finished with. It is counted lost
589
+ // rather than dropped silently, which matches the `durable = false`
590
+ // and non-zero `pendingBytes` that close already reported.
591
+ record(entryCount, data.size.toLong(), handleId)
592
+ return
593
+ }
594
+
595
+ val stale = stateLock.withLock { writeGeneration != generation }
596
+ if (stale) {
597
+ // A purge landed between acceptance and here. These bytes belong to a
598
+ // file that was deliberately deleted, so they are dropped WITHOUT being
599
+ // counted as loss: writing them into the fresh file would resurrect
600
+ // pre-purge data, and reporting them would describe a gap the user
601
+ // asked for.
602
+ return
603
+ }
604
+
605
+ val live = writableStream()
606
+ if (live == null) {
607
+ record(entryCount, data.size.toLong(), handleId)
608
+ return
609
+ }
610
+
611
+ // The true end of file, not a tracked counter: it is what a partial write
612
+ // has to be rolled back to, and being wrong about it means truncating
613
+ // somebody else's bytes.
614
+ val offsetBefore = try {
615
+ live.channel.size()
616
+ } catch (_: Exception) {
617
+ currentFileSize
618
+ }
619
+
620
+ try {
621
+ writeAll(live, data)
622
+ currentFileSize = offsetBefore + data.size
623
+ healthCheckPeriodically()
624
+ rotateIfNeeded()
625
+ } catch (_: Exception) {
626
+ // Roll back to the record boundary the batch started at. A half-written
627
+ // batch is a half-written record, and a half-written record makes the
628
+ // rest of the file unparseable from that point on — the loss would
629
+ // spread from one batch to everything after it.
630
+ try {
631
+ live.channel.truncate(offsetBefore)
632
+ currentFileSize = offsetBefore
633
+ } catch (_: Exception) {
634
+ // Nothing better to try; the batch is counted lost either way.
635
+ }
636
+ record(entryCount, data.size.toLong(), handleId)
637
+ invalidateStreamIfUnlinked()
638
+ }
639
+ } finally {
640
+ release(data.size.toLong())
641
+ }
642
+ }
643
+
644
+ /**
645
+ * Writes every byte or throws.
646
+ *
647
+ * `FileOutputStream.write` loops internally, but it is still allowed to throw
648
+ * having written part of the buffer, and an injected [RawWrite] may report a
649
+ * short write directly. Treating either as success is how a log file ends up
650
+ * with a record missing its second half.
651
+ */
652
+ private fun writeAll(target: FileOutputStream, data: ByteArray) {
653
+ var written = 0
654
+ while (written < data.size) {
655
+ val n = rawWrite.write(target, data, written, data.size - written)
656
+ if (n <= 0) throw LogWriterException(LogWriterException.Kind.OPEN_FAILED, "write failed")
657
+ written += n
658
+ }
659
+ }
660
+
661
+ // MARK: - Stream liveness (executor only)
662
+
663
+ /**
664
+ * The live stream, reopening if the backoff allows.
665
+ *
666
+ * [ignoringBackoff] is for the explicit-durability paths — [flush] and
667
+ * [close]. A caller there is asking for what is buffered to be on storage
668
+ * NOW, and a degraded writer sitting inside its reopen backoff would
669
+ * otherwise report failure and hand back nothing, with no second chance
670
+ * coming. That is exactly the process-death case, where the records being
671
+ * given up on are the ones explaining the shutdown.
672
+ *
673
+ * The parameter comes from SwiftLogger's `FileDestination`, by way of the
674
+ * iOS twin's `writableHandle(ignoringBackoff:)`; both ports had dropped it.
675
+ */
676
+ private fun writableStream(ignoringBackoff: Boolean = false): FileOutputStream? {
677
+ stream?.let { return it }
678
+ if (!ignoringBackoff && monotonic() - lastReopenAttempt < REOPEN_BACKOFF_MS) return null
679
+ reopen()
680
+ return stream
681
+ }
682
+
683
+ /**
684
+ * Confirms the stream still points at a file anyone can read.
685
+ *
686
+ * Writes to an unlinked inode succeed forever and land nowhere. No error is
687
+ * raised, so without this an externally deleted log file means silent loss
688
+ * for the rest of the process's life. A link count of zero catches the plain
689
+ * delete and the delete-then-recreate that a path-existence check misses.
690
+ */
691
+ private fun healthCheckPeriodically() {
692
+ writesSinceHealthCheck += 1
693
+ if (writesSinceHealthCheck < HEALTH_CHECK_STRIDE) return
694
+ writesSinceHealthCheck = 0
695
+ invalidateStreamIfUnlinked()
696
+ }
697
+
698
+ private fun invalidateStreamIfUnlinked() {
699
+ val live = stream ?: return
700
+ val links = try {
701
+ platform.linkCount(live.fd)
702
+ } catch (_: Exception) {
703
+ -1
704
+ }
705
+ // -1 means the platform cannot say, which is not evidence of deletion.
706
+ if (links != 0) return
707
+ reopen()
708
+ }
709
+
710
+ // MARK: - Status, flush, close
711
+
712
+ fun status(handleId: Long): LogSinkStatus = stateLock.withLock { statusLocked(handleId) }
713
+
714
+ private fun statusLocked(handleId: Long): LogSinkStatus {
715
+ val totals = loss[handleId]
716
+ return LogSinkStatus(
717
+ queuedBytes = reservedBytes,
718
+ lostBytes = totals?.get(1) ?: 0L,
719
+ lostEntries = totals?.get(0) ?: 0L,
720
+ degraded = degraded
721
+ )
722
+ }
723
+
724
+ /**
725
+ * Drains and syncs, bounded by a monotonic deadline.
726
+ *
727
+ * The barrier is enqueued behind whatever the executor is already doing, so a
728
+ * writer wedged mid-write times out here rather than blocking the caller
729
+ * forever — which on the crash path is the difference between a partial log
730
+ * and an ANR.
731
+ */
732
+ fun flush(handleId: Long, deadlineMs: Double): LogFlushOutcome =
733
+ flushUntil(handleId, monotonic() + clampDeadline(deadlineMs))
734
+
735
+ private fun flushUntil(handleId: Long, expiry: Long): LogFlushOutcome {
736
+ var timedOut = false
737
+
738
+ if (Thread.currentThread() === writerThread) {
739
+ // Already on the write thread. Enqueueing and waiting here would be a
740
+ // deadlock against ourselves — the barrier could never run until we
741
+ // returned. Run it inline instead; ordering is preserved either way,
742
+ // because nothing enqueued after us has started.
743
+ syncNow()
744
+ } else {
745
+ val done = CountDownLatch(1)
746
+ try {
747
+ executor.execute {
748
+ try {
749
+ syncNow()
750
+ } finally {
751
+ done.countDown()
752
+ }
753
+ }
754
+ timedOut = !done.await(remaining(expiry), TimeUnit.MILLISECONDS)
755
+ } catch (_: InterruptedException) {
756
+ // Nothing was established about durability, so this reports the same as
757
+ // a timeout — with the interrupt put back, because a caller that is
758
+ // being cancelled has to stay cancellable.
759
+ Thread.currentThread().interrupt()
760
+ timedOut = true
761
+ } catch (_: Exception) {
762
+ // A rejected execution means the executor is gone; nothing is pending
763
+ // that could still become durable.
764
+ timedOut = true
765
+ }
766
+ }
767
+
768
+ stateLock.lock()
769
+ val status = statusLocked(handleId)
770
+ val pending = reservedBytes
771
+ val synced = lastSyncSucceeded
772
+ stateLock.unlock()
773
+
774
+ return LogFlushOutcome(
775
+ durable = !timedOut && pending == 0L && synced,
776
+ timedOut = timedOut,
777
+ pendingBytes = pending,
778
+ status = status
779
+ )
780
+ }
781
+
782
+ /**
783
+ * Executor-confined. Records whether the data actually reached storage, which
784
+ * is the difference between `durable` and "we asked".
785
+ */
786
+ private fun syncNow() {
787
+ // Ignoring the backoff, which is the whole point: this runs only for a
788
+ // caller that asked for durability now. Without it a writer that lost its
789
+ // stream inside the backoff window reports a failed flush and reopens
790
+ // nothing, so the retry the caller is told to make fails the same way.
791
+ val live = writableStream(ignoringBackoff = true)
792
+ if (live == null) {
793
+ stateLock.withLock { lastSyncSucceeded = false }
794
+ return
795
+ }
796
+ val ok = try {
797
+ live.fd.sync()
798
+ true
799
+ } catch (_: Exception) {
800
+ false
801
+ }
802
+ stateLock.withLock { lastSyncSucceeded = ok }
803
+ }
804
+
805
+ /**
806
+ * Flushes, then closes the stream — both inside ONE budget.
807
+ *
808
+ * The deadline is computed once, at entry. Giving the flush the full budget
809
+ * and then the close barrier the full budget again means a stalled writer
810
+ * blocks for twice what the caller asked for.
811
+ *
812
+ * [onTerminated] runs on the executor once the stream is really shut,
813
+ * **regardless of whether this call waited that long**. The two are
814
+ * deliberately decoupled: the caller's deadline bounds how long it blocks;
815
+ * the callback reports when the writer actually stopped. The registry needs
816
+ * the second, because releasing a path because a close *gave up waiting*
817
+ * would let a replacement writer open the same file while this one still has
818
+ * a write executing.
819
+ */
820
+ fun close(handleId: Long, deadlineMs: Double, onTerminated: (() -> Unit)? = null): LogFlushOutcome {
821
+ val expiry = monotonic() + clampDeadline(deadlineMs)
822
+
823
+ stateLock.withLock { closed = true }
824
+
825
+ val outcome = flushUntil(handleId, expiry)
826
+
827
+ val done = CountDownLatch(1)
828
+ var submitted = false
829
+ try {
830
+ executor.execute {
831
+ try {
832
+ terminated = true
833
+ closeCurrentStream()
834
+ onTerminated?.invoke()
835
+ } finally {
836
+ done.countDown()
837
+ }
838
+ }
839
+ submitted = true
840
+ // Whatever the flush left of the budget, and nothing more.
841
+ done.await(remaining(expiry), TimeUnit.MILLISECONDS)
842
+ } catch (_: InterruptedException) {
843
+ Thread.currentThread().interrupt()
844
+ } catch (_: RejectedExecutionException) {
845
+ // Never enqueued, so nothing else will ever run it.
846
+ }
847
+ // Only when the barrier never got submitted. Invoking it because the *wait*
848
+ // ended early would run it twice — once here and once on the executor — and
849
+ // the registry's close claim is counted, so a second call releases a path
850
+ // another close still holds.
851
+ if (!submitted) onTerminated?.invoke()
852
+ executor.shutdown()
853
+ return outcome
854
+ }
855
+
856
+ /** Milliseconds left on a [monotonic]-based expiry, never negative. */
857
+ private fun remaining(expiry: Long): Long = maxOf(0L, expiry - monotonic())
858
+
859
+ private fun release(bytes: Long) {
860
+ stateLock.withLock { reservedBytes = maxOf(0L, reservedBytes - bytes) }
861
+ }
862
+
863
+ private fun record(entries: Long, bytes: Long, handleId: Long) {
864
+ if (entries <= 0 && bytes <= 0) return
865
+ stateLock.withLock {
866
+ val totals = loss.getOrPut(handleId) { longArrayOf(0, 0) }
867
+ totals[0] += entries
868
+ totals[1] += bytes
869
+ }
870
+ }
871
+
872
+ private fun note(flag: Int) {
873
+ stateLock.withLock { degraded = degraded or flag }
874
+ }
875
+
876
+ // MARK: - Rotation (executor only)
877
+
878
+ private fun rotateIfNeeded() {
879
+ val live = stream ?: return
880
+
881
+ // Two clocks, deliberately. The backoff asks "has enough time passed since
882
+ // the last failure", which must not be re-answered by an NTP correction;
883
+ // the age test asks "how old is this file", which is measured against a
884
+ // creation time recorded on a previous run and so has to be epoch.
885
+ val steady = monotonic()
886
+ if (steady < rotationBlockedUntil) return
887
+
888
+ val now = clock()
889
+ val tooBig = currentFileSize >= policy.maxFileSizeBytes
890
+ val tooOld = policy.maxFileAgeSeconds?.let {
891
+ now - currentFileStart >= (it * 1000).toLong()
892
+ } ?: false
893
+ if (!tooBig && !tooOld) return
894
+ rotationAttempts += 1
895
+
896
+ try {
897
+ live.fd.sync()
898
+ } catch (_: Exception) {
899
+ // The rename below still moves whatever did land.
900
+ }
901
+ closeCurrentStream()
902
+
903
+ val archive = File(directory, "$baseName.${rotationStamp()}")
904
+ if (!file.renameTo(archive)) {
905
+ // Back off. A rotation that fails on every write — a read-only volume, a
906
+ // directory someone removed — would otherwise retry on every single
907
+ // batch, turning a degraded log into a busy one.
908
+ note(LogDegradation.ROTATION)
909
+ rotationBlockedUntil = steady + ROTATION_BACKOFF_MS
910
+ reopen()
911
+ return
912
+ }
913
+ if (!LogSecureFile.secure(archive, platform)) note(LogDegradation.PROTECTION)
914
+
915
+ // The sidecar describes the file that was just archived, so it must not
916
+ // survive to date the fresh one: leaving it makes a brand new file look old
917
+ // enough to rotate immediately, on every write.
918
+ //
919
+ // Overwritten rather than deleted, and held in memory until it lands.
920
+ // Deleting was the obvious move and the wrong one — now that the sidecar is
921
+ // authoritative, a deletion that *fails* leaves the archived file's age in
922
+ // charge of the fresh file and causes exactly the runaway this prevents.
923
+ // Recording the intent here and letting `reopen` perform and retry the
924
+ // write is what makes a failed write survivable across as many failed
925
+ // reopens as it takes.
926
+ pendingFileStart = clock()
927
+
928
+ if (policy.compressArchives) compress(archive)
929
+ sweepRetention()
930
+ reopen()
931
+ }
932
+
933
+ /**
934
+ * Replaces [source] with a gzipped copy, keeping the original if anything
935
+ * goes wrong: a bigger archive beats a lost one.
936
+ *
937
+ * Compression writes to a `.part` staging name and renames on success, so an
938
+ * interruption leaves something the purge recognises rather than a
939
+ * plausible-looking `.gz` that no tool can open.
940
+ */
941
+ private fun compress(source: File) {
942
+ val finalFile = File(source.absolutePath + ".gz")
943
+ val staging = File(finalFile.absolutePath + ".part")
944
+ staging.delete()
945
+
946
+ if (!compressor.compress(source, staging)) {
947
+ staging.delete()
948
+ note(LogDegradation.GZIP)
949
+ return
950
+ }
951
+ if (!LogSecureFile.secure(staging, platform)) note(LogDegradation.PROTECTION)
952
+
953
+ if (!staging.renameTo(finalFile)) {
954
+ staging.delete()
955
+ note(LogDegradation.GZIP)
956
+ return
957
+ }
958
+ if (!LogSecureFile.secure(finalFile, platform)) note(LogDegradation.PROTECTION)
959
+
960
+ if (!source.delete()) {
961
+ // The plaintext original survived alongside the compressed copy. Remove
962
+ // the compressed one instead of leaving two: a plaintext log the caller
963
+ // believes was compressed away is a file nobody remembers to delete.
964
+ finalFile.delete()
965
+ note(LogDegradation.GZIP)
966
+ }
967
+ }
968
+
969
+ private fun rotationStamp(): String {
970
+ val stamp = java.text.SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US).apply {
971
+ timeZone = java.util.TimeZone.getTimeZone("UTC")
972
+ }.format(java.util.Date(clock()))
973
+ val suffix = java.util.UUID.randomUUID().toString().replace("-", "").take(8).lowercase(Locale.US)
974
+ return "${stamp}_$suffix"
975
+ }
976
+
977
+ // MARK: - Retention (executor only)
978
+
979
+ data class Artifact(val file: File, val modified: Long, val size: Long)
980
+
981
+ /**
982
+ * Applies all three retention limits. Runs at open and after each rotation.
983
+ *
984
+ * No background timer: an idle process writes nothing, so there is nothing to
985
+ * sweep, and a timer that fires in the background is a wakeup the app pays
986
+ * for. An active process rotates, and rotation sweeps.
987
+ */
988
+ private fun sweepRetention() {
989
+ var failed = false
990
+ fun remove(target: File) {
991
+ if (target.delete()) return
992
+ // Not `exists()`: it returns false both for "already gone" and for "could
993
+ // not tell", and only the first of those is a successful removal.
994
+ if (platform.lookup(target) != PlatformIo.Presence.ABSENT) failed = true
995
+ }
996
+
997
+ val names = directory.list()
998
+ if (names == null) {
999
+ // An unreadable directory is not an empty one, and a sweep that silently
1000
+ // did nothing would let retention drift without a word.
1001
+ note(LogDegradation.PRUNE)
1002
+ return
1003
+ }
1004
+
1005
+ // Orphaned compressions first. A `.part` is a gzip that was interrupted —
1006
+ // by a crash, or by a process that died mid-rotation — and nothing will
1007
+ // ever finish it. Compression runs on this same executor, so a staging file
1008
+ // seen from here is never one being written.
1009
+ names.filter { isStagingName(it, baseName) }.forEach { remove(File(directory, it)) }
1010
+
1011
+ var archives = archives(directory, baseName)
1012
+
1013
+ // Oldest first for age, then count, then total size — each pass works on
1014
+ // what the previous one left.
1015
+ policy.maxArchiveAgeSeconds?.let { maxAge ->
1016
+ val cutoff = clock() - (maxAge * 1000).toLong()
1017
+ val expired = archives.filter { it.modified < cutoff }
1018
+ expired.forEach { remove(it.file) }
1019
+ archives = archives - expired.toSet()
1020
+ }
1021
+
1022
+ if (archives.size > policy.maxArchivedFilesCount) {
1023
+ val excess = archives.drop(policy.maxArchivedFilesCount)
1024
+ excess.forEach { remove(it.file) }
1025
+ archives = archives.take(policy.maxArchivedFilesCount)
1026
+ }
1027
+
1028
+ policy.maxTotalLogBytes?.let { cap ->
1029
+ var total = currentFileSize + archives.sumOf { it.size }
1030
+ val remaining = archives.toMutableList()
1031
+ // Newest-first order, so dropping from the end sheds the oldest.
1032
+ while (total > cap && remaining.isNotEmpty()) {
1033
+ val oldest = remaining.removeAt(remaining.size - 1)
1034
+ remove(oldest.file)
1035
+ total = if (total > oldest.size) total - oldest.size else 0
1036
+ }
1037
+ }
1038
+
1039
+ if (failed) note(LogDegradation.PRUNE)
1040
+ }
1041
+
1042
+ /**
1043
+ * Archives for [baseName], newest first.
1044
+ *
1045
+ * Ordered by modification time rather than by name. The name's timestamp has
1046
+ * one-second resolution, so a burst of rotations inside the same second all
1047
+ * share it and only the random suffix differs — sorting by name would keep an
1048
+ * arbitrary subset and delete newer archives than it kept. Names break exact
1049
+ * ties so the order is still deterministic.
1050
+ */
1051
+ private fun archives(directory: File, baseName: String): List<Artifact> {
1052
+ val names = directory.list() ?: return emptyList()
1053
+ return names
1054
+ .filter { isArchiveName(it, baseName) }
1055
+ .map { File(directory, it) }
1056
+ .map { Artifact(it, it.lastModified(), it.length()) }
1057
+ .sortedWith(compareByDescending<Artifact> { it.modified }.thenByDescending { it.file.name })
1058
+ }
1059
+
1060
+ /**
1061
+ * The active file and every archive, newest first — read **on the executor**.
1062
+ *
1063
+ * Rotation, compression, retention and purge all mutate these names, and all
1064
+ * of them run on the executor. Enumerating from the caller's thread would
1065
+ * race every one of them: the honest failure is handing back a `.gz` that is
1066
+ * mid-rename, or an archive that pruning deleted a microsecond later, to a
1067
+ * caller whose whole purpose is to open those files.
1068
+ *
1069
+ * Bounded, because this is reachable from the JS thread and the executor may
1070
+ * be wedged on storage that has stopped answering. On timeout the active
1071
+ * path is returned alone: it is the one name this writer owns unconditionally
1072
+ * and can state without reading the directory, whereas a partial archive list
1073
+ * would be indistinguishable from a complete one.
1074
+ */
1075
+ fun logFilePaths(): List<String> {
1076
+ var snapshot: List<String>? = null
1077
+ val captured = onExecutorBounded(PATHS_DEADLINE_MS) {
1078
+ val paths = mutableListOf(file.absolutePath)
1079
+ paths.addAll(archives(directory, baseName).map { it.file.absolutePath })
1080
+ snapshot = paths
1081
+ }
1082
+ return if (captured) snapshot ?: listOf(file.absolutePath) else listOf(file.absolutePath)
1083
+ }
1084
+
1085
+ // MARK: - Purge
1086
+
1087
+ /**
1088
+ * Deletes every artifact and fences every handle.
1089
+ *
1090
+ * The generation bumps FIRST and unconditionally. Anything still in flight is
1091
+ * dropped when it reaches the executor, and any handle that has not rebound
1092
+ * is refused — so a deletion still running cannot race a fresh write, and a
1093
+ * partial deletion leaves everyone fenced rather than half of them writing
1094
+ * into files that are about to disappear.
1095
+ *
1096
+ * Only a complete deletion reopens the file. The caller rebinds on
1097
+ * `durable && rebound`, and on anything else stays fenced until it retries.
1098
+ */
1099
+ fun clearLogs(deadlineMs: Double): Pair<LogClearOutcome, Long> {
1100
+ // The budget starts HERE, before waiting for anything. Computing it after
1101
+ // acquiring the lock would let a caller asking for 100 ms sit behind
1102
+ // another purge's full 30 seconds and still believe it was given 100.
1103
+ val budget = clampDeadline(deadlineMs)
1104
+ val expiry = monotonic() + budget
1105
+
1106
+ // One purge at a time per writer. Two overlapping purges each bump the
1107
+ // generation, and the first to finish would otherwise report success for a
1108
+ // fence the second has already moved.
1109
+ val acquired = try {
1110
+ purgeLock.tryLock(budget, TimeUnit.MILLISECONDS)
1111
+ } catch (_: InterruptedException) {
1112
+ Thread.currentThread().interrupt()
1113
+ false
1114
+ }
1115
+ if (!acquired) {
1116
+ val current = stateLock.withLock { generation }
1117
+ return LogClearOutcome(0, listOf(file.absolutePath), durable = false) to current
1118
+ }
1119
+
1120
+ try {
1121
+ stateLock.lock()
1122
+ generation += 1
1123
+ val fenced = generation
1124
+ loss.clear()
1125
+ degraded = LogDegradation.NONE
1126
+ stateLock.unlock()
1127
+
1128
+ var outcome = LogClearOutcome(0, emptyList(), durable = false)
1129
+ val done = CountDownLatch(1)
1130
+
1131
+ try {
1132
+ executor.execute {
1133
+ try {
1134
+ closeCurrentStream()
1135
+
1136
+ // An unreadable directory is NOT an empty one. Sweeping an empty
1137
+ // list would report a durable purge while every artifact sat
1138
+ // untouched behind a permissions or I/O failure — the worst
1139
+ // possible lie for this particular call to tell. `list()` returns
1140
+ // null for both "not a directory" and "could not read it", so
1141
+ // absence has to be established separately.
1142
+ val names = directory.list()
1143
+ val directoryAbsent =
1144
+ names == null && platform.lookup(directory) == PlatformIo.Presence.ABSENT
1145
+ if (names == null && !directoryAbsent) {
1146
+ outcome = LogClearOutcome(0, listOf(directory.absolutePath), durable = false)
1147
+ return@execute
1148
+ }
1149
+
1150
+ var deleted = 0
1151
+ val failures = mutableListOf<String>()
1152
+ for (name in (names ?: emptyArray())) {
1153
+ if (!isArtifactName(name, baseName)) continue
1154
+ val target = File(directory, name)
1155
+ if (target.delete()) {
1156
+ deleted += 1
1157
+ continue
1158
+ }
1159
+ // `delete()` said no. Only a platform that positively reports the
1160
+ // path as gone lets this count as deleted — something else removed
1161
+ // it between the listing and here. `File.exists()` cannot make
1162
+ // that distinction: it returns false for an absent file and for
1163
+ // one behind a permissions or I/O failure alike, and treating the
1164
+ // second as the first is how a purge reports `durable = true` over
1165
+ // artifacts still sitting on disk. For this call, of every lie
1166
+ // available, that is the worst one.
1167
+ if (platform.lookup(target) == PlatformIo.Presence.ABSENT) {
1168
+ deleted += 1
1169
+ } else {
1170
+ // The path is this package's own artifact name, not user
1171
+ // content.
1172
+ failures.add(target.absolutePath)
1173
+ }
1174
+ }
1175
+
1176
+ if (failures.isNotEmpty()) {
1177
+ outcome = LogClearOutcome(deleted, failures, durable = false)
1178
+ return@execute
1179
+ }
1180
+
1181
+ // `delete()` returning true only means the change is in the
1182
+ // directory's in-memory state. Until the directory itself is
1183
+ // synced, a crash or a power loss can bring every one of those
1184
+ // names back — and this call exists precisely to promise they are
1185
+ // gone.
1186
+ if (!directoryAbsent && !platform.syncDirectory(directory)) {
1187
+ outcome = LogClearOutcome(deleted, listOf(directory.absolutePath), durable = false)
1188
+ return@execute
1189
+ }
1190
+
1191
+ val current = stateLock.withLock { generation }
1192
+ if (current != fenced) {
1193
+ outcome = LogClearOutcome(deleted, emptyList(), durable = false)
1194
+ return@execute
1195
+ }
1196
+
1197
+ currentFileSize = 0
1198
+ currentFileStart = clock()
1199
+ // The purge took the sidecar with everything else, so a rotation
1200
+ // that never managed to record its time has nothing left to
1201
+ // reconcile: the file that follows is new, not rotated.
1202
+ pendingFileStart = null
1203
+ // Deletion succeeded whether or not a fresh file could be opened,
1204
+ // and `durable` describes the deletion — that is what a compliance
1205
+ // caller asked about. Whether the writer is usable again is a
1206
+ // separate fact, reported separately, because a handle that rebinds
1207
+ // onto a writer with no stream would accept records and lose them.
1208
+ //
1209
+ // A purge that lands after the close barrier still deletes — that
1210
+ // is the whole point of the call — but it must not reopen.
1211
+ outcome = LogClearOutcome(
1212
+ deletedCount = deleted,
1213
+ failedPaths = emptyList(),
1214
+ durable = true,
1215
+ rebound = if (terminated) false else reopen()
1216
+ )
1217
+ } finally {
1218
+ done.countDown()
1219
+ }
1220
+ }
1221
+ } catch (_: Exception) {
1222
+ return LogClearOutcome(0, listOf(file.absolutePath), durable = false) to fenced
1223
+ }
1224
+
1225
+ val completed = try {
1226
+ done.await(remaining(expiry), TimeUnit.MILLISECONDS)
1227
+ } catch (_: InterruptedException) {
1228
+ // An interrupted wait establishes nothing about the deletion, and this
1229
+ // call must never claim durability it did not observe.
1230
+ Thread.currentThread().interrupt()
1231
+ false
1232
+ }
1233
+ if (!completed) {
1234
+ return LogClearOutcome(0, listOf(file.absolutePath), durable = false) to fenced
1235
+ }
1236
+ return outcome to fenced
1237
+ } finally {
1238
+ purgeLock.unlock()
1239
+ }
1240
+ }
1241
+
1242
+ /** The generation a handle must rebind to after a durable purge. */
1243
+ val currentGeneration: Long get() = stateLock.withLock { generation }
1244
+
1245
+ val isClosed: Boolean get() = stateLock.withLock { closed }
1246
+
1247
+ // MARK: - Test support
1248
+
1249
+ private fun onExecutor(block: () -> Unit) {
1250
+ if (Thread.currentThread() === writerThread) {
1251
+ block()
1252
+ return
1253
+ }
1254
+ val done = CountDownLatch(1)
1255
+ executor.execute {
1256
+ try {
1257
+ block()
1258
+ } finally {
1259
+ done.countDown()
1260
+ }
1261
+ }
1262
+ try {
1263
+ done.await()
1264
+ } catch (_: InterruptedException) {
1265
+ Thread.currentThread().interrupt()
1266
+ }
1267
+ }
1268
+
1269
+ /**
1270
+ * Runs [block] on the executor and waits at most [budgetMs] for it.
1271
+ *
1272
+ * Returns whether it actually completed — false for a timeout and false for a
1273
+ * shut-down executor, both of which mean the caller must not read whatever
1274
+ * [block] was going to produce. A successful `await` is also the happens-before
1275
+ * edge that makes what [block] wrote visible here.
1276
+ */
1277
+ private fun onExecutorBounded(budgetMs: Long, block: () -> Unit): Boolean {
1278
+ if (Thread.currentThread() === writerThread) {
1279
+ block()
1280
+ return true
1281
+ }
1282
+ val done = CountDownLatch(1)
1283
+ return try {
1284
+ executor.execute {
1285
+ try {
1286
+ block()
1287
+ } finally {
1288
+ done.countDown()
1289
+ }
1290
+ }
1291
+ done.await(budgetMs, TimeUnit.MILLISECONDS)
1292
+ } catch (_: RejectedExecutionException) {
1293
+ false
1294
+ } catch (_: InterruptedException) {
1295
+ // "Did it complete" is the question, and an interrupted wait never found
1296
+ // out — so the caller takes its fallback. The flag goes back on: swallowing
1297
+ // an interrupt is how a thread stops being cancellable.
1298
+ Thread.currentThread().interrupt()
1299
+ false
1300
+ }
1301
+ }
1302
+
1303
+ /** Blocks until everything already enqueued has run. */
1304
+ fun settleForTesting() = onExecutor {}
1305
+
1306
+ /** Closes the stream so the next write fails, standing in for revoked storage. */
1307
+ fun closeStreamForTesting() = onExecutor { closeCurrentStream() }
1308
+
1309
+ /**
1310
+ * Shuts the executor down without going through [close], standing in for the
1311
+ * window where a concurrent close has torn it down but `closed` is not yet
1312
+ * visible to a batch already past the acceptance check.
1313
+ */
1314
+ fun shutdownExecutorForTesting() {
1315
+ executor.shutdownNow()
1316
+ }
1317
+
1318
+ /** Blocks the executor until the returned lambda is called. */
1319
+ fun stallForTesting(): () -> Unit {
1320
+ val gate = CountDownLatch(1)
1321
+ executor.execute { gate.await() }
1322
+ return { gate.countDown() }
1323
+ }
1324
+
1325
+ val trackedFileSizeForTesting: Long get() { settleForTesting(); return currentFileSize }
1326
+ val hasLiveStreamForTesting: Boolean get() { settleForTesting(); return stream != null }
1327
+ val rotationAttemptsForTesting: Int get() { settleForTesting(); return rotationAttempts }
1328
+ }