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