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