-
Notifications
You must be signed in to change notification settings - Fork 265
fix: [SDK-5065] bound the crash-record cache on iOS #1725
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d51447b
4184f1b
f6cbab2
abaf2d1
a714474
cf50a0a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,32 +35,73 @@ import OneSignalCore | |
| /// Writes are synchronous and durable because fatal handlers may terminate the | ||
| /// process immediately after `save` returns. Directory scans and cleanup run on | ||
| /// a utility queue to keep disk I/O off the caller. | ||
| /// | ||
| /// This is a bounded cache, not a queue. Retention decisions — which records have aged out, | ||
| /// which exceed the accumulation caps — come from `CrashRetention` in the shared module, so | ||
| /// iOS and Android reclaim identically; this type contributes only the file I/O. Without | ||
| /// those bounds a record that never uploads successfully is re-read and re-sent on every | ||
| /// launch for the life of the install. | ||
| final class FileLogStore: ILogFileStore { | ||
| /// Complete records use `.otlp`; interrupted durable writes leave | ||
| /// `.otlp.tmp` files that are safe to reap after the minimum-age gate. | ||
| private static let ownedFileSuffix = ".otlp" | ||
| private static let temporaryFileSuffix = ".otlp.tmp" | ||
| /// Complete records use the shared policy's suffix; interrupted durable writes leave | ||
| /// `.tmp` files alongside them that are safe to reap after the minimum-age gate. | ||
| /// | ||
| /// Taken from the policy rather than restated, so what this store writes cannot drift out | ||
| /// of what `isOwned` accepts — a mismatch would hide brand-new records from every reader. | ||
| private static let ownedFileSuffix = CrashRetention.shared.defaultPolicy.ownedSuffix | ||
| private static let temporaryFileSuffix = ownedFileSuffix + ".tmp" | ||
| private static let queueLabel = "com.onesignal.logger.file-store" | ||
|
|
||
| /// The shared bounds, named once. Kotlin default arguments do not cross the Objective-C | ||
| /// boundary, so every selector call must pass this explicitly — binding it here keeps the | ||
| /// four numbers from being restated, and possibly transposed, at each call site. | ||
| private static let retentionPolicy = CrashRetention.shared.defaultPolicy | ||
|
|
||
| /// Reads the attributes a `CrashDirEntry` is built from. Injectable because the failure that | ||
| /// matters here — attributes unreadable while the directory still lists — cannot be staged on | ||
| /// a real filesystem: revoking directory access fails the listing itself instead. | ||
| typealias AttributeLookup = (URL) -> URLResourceValues? | ||
|
|
||
| static let defaultAttributeLookup: AttributeLookup = { url in | ||
| try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey]) | ||
| } | ||
|
|
||
| private let rootURL: URL | ||
| private let fileManager: FileManager | ||
| private let attributeLookup: AttributeLookup | ||
| private let ioQueue = DispatchQueue(label: queueLabel, qos: .utility) | ||
|
|
||
| init(rootPath: String, fileManager: FileManager = .default) { | ||
| init( | ||
| rootPath: String, | ||
| fileManager: FileManager = .default, | ||
| attributeLookup: @escaping AttributeLookup = FileLogStore.defaultAttributeLookup | ||
| ) { | ||
| self.rootURL = URL(fileURLWithPath: rootPath, isDirectory: true) | ||
| self.fileManager = fileManager | ||
| self.attributeLookup = attributeLookup | ||
| try? createRootDirectory() | ||
| } | ||
|
|
||
| func save(bytes: KotlinByteArray) -> Bool { | ||
| guard bytes.size > 0 else { | ||
| return false | ||
| } | ||
| // Refuse rather than store-then-reclaim. A record this large would either claim the | ||
| // whole shared budget or be deleted before it could be uploaded, so losing it loudly | ||
| // here beats losing it silently on a later launch. | ||
| guard Int64(bytes.size) <= Self.retentionPolicy.maxRecordBytes else { | ||
| OneSignalLog.onesignalLog( | ||
| .LL_WARN, | ||
| message: "FileLogStore refusing record of \(bytes.size) bytes, over the " | ||
| + "\(Self.retentionPolicy.maxRecordBytes)-byte limit" | ||
| ) | ||
| return false | ||
| } | ||
| do { | ||
| try createRootDirectory() | ||
| let timestamp = Int64(Date().timeIntervalSince1970 * 1_000) | ||
| let id = "\(timestamp)-\(UUID().uuidString)\(Self.ownedFileSuffix)" | ||
| try writeDurably(bytes.data, to: rootURL.appendingPathComponent(id)) | ||
| enforceAccumulationCaps(keepName: id) | ||
| return true | ||
| } catch { | ||
| return false | ||
|
|
@@ -114,13 +155,23 @@ final class FileLogStore: ILogFileStore { | |
| ioQueue.async { | ||
| var deleted = 0 | ||
| do { | ||
| for url in try self.fileURLs() | ||
| where url.lastPathComponent.hasSuffix(Self.temporaryFileSuffix) { | ||
| guard try self.isOldEnough(url, minAgeMillis: minAgeMillis) else { | ||
| continue | ||
| // Also the only scan that runs when remote logging is disabled, so it is the | ||
| // sole chance to bound a directory `listReadable` never touches. | ||
| _ = self.reclaim(entries: try self.directoryEntries()) | ||
|
|
||
| // Deliberately narrower than the shared `selectUnrecognized`, which reaps any | ||
| // non-owned file. iOS never ran the OpenTelemetry pipeline, so there is no | ||
| // legacy format to clean up here — only this store's own interrupted writes. | ||
| // Anything else in the directory belongs to someone we should not assume about. | ||
| let now = Self.nowMillis() | ||
| for entry in try self.directoryEntries() | ||
| where entry.name.hasSuffix(Self.temporaryFileSuffix) | ||
| && now - entry.lastModifiedMs >= max(0, minAgeMillis) { | ||
| // Per-entry, so one undeletable leftover cannot strand the rest of the | ||
| // sweep — a file locked before first unlock would otherwise wedge it. | ||
| if self.remove(name: entry.name) { | ||
| deleted += 1 | ||
| } | ||
| try self.fileManager.removeItem(at: url) | ||
| deleted += 1 | ||
| } | ||
| } catch { | ||
| OneSignalLog.onesignalLog( | ||
|
|
@@ -133,38 +184,150 @@ final class FileLogStore: ILogFileStore { | |
| } | ||
|
|
||
| private func readableEntries(minAgeMillis: Int64) throws -> [StoredLogFile] { | ||
| try fileURLs() | ||
| .filter { $0.lastPathComponent.hasSuffix(Self.ownedFileSuffix) } | ||
| .filter { try isOldEnough($0, minAgeMillis: minAgeMillis) } | ||
| .compactMap { url in | ||
| let entries = try directoryEntries() | ||
| // Reclaim before reading so payloads are only materialized for records that survive | ||
| // both bounds — an over-cap backlog is never fully loaded into memory. | ||
| let reclaimed = reclaim(entries: entries) | ||
| let now = Self.nowMillis() | ||
|
|
||
| return entries | ||
| .filter { CrashRetention.shared.isOwned(name: $0.name, policy: Self.retentionPolicy) } | ||
| .filter { !reclaimed.contains($0.name) } | ||
| .filter { now - $0.lastModifiedMs >= max(0, minAgeMillis) } | ||
| .compactMap { entry in | ||
| let url = rootURL.appendingPathComponent(entry.name) | ||
| guard let data = try? Data(contentsOf: url) else { | ||
| return nil | ||
| } | ||
| return StoredLogFile(id: url.lastPathComponent, bytes: data.kotlinByteArray) | ||
| return StoredLogFile(id: entry.name, bytes: data.kotlinByteArray) | ||
| } | ||
| } | ||
|
|
||
| /// Applies the shared retention policy and deletes what it selects. | ||
| /// | ||
| /// - Returns: names that must be withheld from readers, including any whose unlink failed — | ||
| /// a record past the ceiling must not be uploaded even if it could not be removed. | ||
| private func reclaim(entries: [CrashDirEntry]) -> Set<String> { | ||
| let now = Self.nowMillis() | ||
| var withheld = Set<String>() | ||
|
|
||
| let expired = CrashRetention.shared.selectExpiredOwned( | ||
| entries: entries, | ||
| nowMs: now, | ||
| policy: Self.retentionPolicy | ||
| ) | ||
| for entry in expired { | ||
| withheld.insert(entry.name) | ||
| remove(name: entry.name) | ||
| } | ||
|
|
||
| // Only the survivors of the expiry pass, per the ILogFileStore contract: an expired | ||
| // record still in the listing consumes a count slot and budget, so the overflow pass | ||
| // would evict live records to make room for ones already being deleted. | ||
| let survivors = entries.filter { !withheld.contains($0.name) } | ||
| let overflow = CrashRetention.shared.selectOverflowOwned( | ||
| entries: survivors, | ||
| nowMs: now, | ||
| keepName: nil, | ||
| policy: Self.retentionPolicy | ||
| ) | ||
| for entry in overflow { | ||
| withheld.insert(entry.name) | ||
| remove(name: entry.name) | ||
| } | ||
|
|
||
| if !withheld.isEmpty { | ||
| OneSignalLog.onesignalLog( | ||
| .LL_DEBUG, | ||
| message: "FileLogStore reclaimed \(expired.count) expired and " | ||
| + "\(overflow.count) over-cap record(s)" | ||
| ) | ||
| } | ||
| return withheld | ||
| } | ||
|
|
||
| /// Trims the directory back inside the accumulation caps after a write, always keeping | ||
| /// [keepName]. Runs synchronously on the crashing thread, so it exits on one directory | ||
| /// listing in the steady state and only sorts when the caps are actually breached. | ||
| /// | ||
| /// Overflow only, deliberately: expiry is a scan the crashing thread should not pay for when | ||
| /// nothing is over cap, and an under-cap expired record is reclaimed on the next uploader | ||
| /// pass by `listReadable` / `deleteUnrecognizedEntries`, both of which run expiry. This is | ||
| /// the same split Android's write path makes. | ||
| private func enforceAccumulationCaps(keepName: String) { | ||
| guard let entries = try? directoryEntries() else { | ||
| return | ||
| } | ||
| guard !CrashRetention.shared.isWithinCaps( | ||
| entries: entries, | ||
| policy: Self.retentionPolicy | ||
| ) else { | ||
| return | ||
| } | ||
| let overflow = CrashRetention.shared.selectOverflowOwned( | ||
| entries: entries, | ||
| nowMs: Self.nowMillis(), | ||
| keepName: keepName, | ||
| policy: Self.retentionPolicy | ||
| ) | ||
| for entry in overflow { | ||
| remove(name: entry.name) | ||
| } | ||
|
Comment on lines
+257
to
+275
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. warning (2/3) — This scan path runs overflow only, not expiry.
One reviewer treated the write-path
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deliberate, and now commented at Behavior unchanged, but this is the second independent review to question it against the |
||
| } | ||
|
|
||
| /// - Returns: whether the file is gone. Callers reclaiming records ignore this — a failed | ||
| /// unlink is still withheld from readers — but the temp sweep counts only real deletions. | ||
| @discardableResult | ||
| private func remove(name: String) -> Bool { | ||
| do { | ||
| try fileManager.removeItem(at: rootURL.appendingPathComponent(name)) | ||
| return true | ||
| } catch { | ||
| OneSignalLog.onesignalLog( | ||
| .LL_WARN, | ||
| message: "FileLogStore failed to reclaim \(name): \(error.localizedDescription)" | ||
| ) | ||
| return false | ||
| } | ||
| } | ||
|
|
||
| /// Snapshots the directory as the platform-neutral entries the shared policy consumes. | ||
| /// | ||
| /// An unreadable attribute — data protection before first unlock, a transient I/O error — | ||
| /// dates the entry to the epoch instead of omitting it. Omission would put the file outside | ||
| /// every bound at once: uncounted by the caps, unselectable by either reclaim pass, and so | ||
| /// leaked for the life of the install. Epoch keeps it in the snapshot and reads as | ||
| /// unrecoverably stale, which is what Android's `File.lastModified()` already yields on the | ||
| /// same failure, so both platforms reclaim it. | ||
| private func directoryEntries() throws -> [CrashDirEntry] { | ||
| try fileURLs().map { url in | ||
| let values = attributeLookup(url) | ||
| let modifiedAt = values?.contentModificationDate | ||
| return CrashDirEntry( | ||
| name: url.lastPathComponent, | ||
| lastModifiedMs: modifiedAt.map { Int64($0.timeIntervalSince1970 * 1_000) } ?? 0, | ||
| lengthBytes: Int64(values?.fileSize ?? 0) | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| private static func nowMillis() -> Int64 { | ||
| Int64(Date().timeIntervalSince1970 * 1_000) | ||
| } | ||
|
|
||
| private func fileURLs() throws -> [URL] { | ||
| guard fileManager.fileExists(atPath: rootURL.path) else { | ||
| return [] | ||
| } | ||
| return try fileManager.contentsOfDirectory( | ||
| at: rootURL, | ||
| includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], | ||
| includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey, .isRegularFileKey], | ||
| options: [.skipsHiddenFiles] | ||
| ).filter { | ||
| (try? $0.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true | ||
| } | ||
| } | ||
|
|
||
| private func isOldEnough(_ url: URL, minAgeMillis: Int64) throws -> Bool { | ||
| let values = try url.resourceValues(forKeys: [.contentModificationDateKey]) | ||
| guard let modifiedAt = values.contentModificationDate else { | ||
| return false | ||
| } | ||
| return Date().timeIntervalSince(modifiedAt) * 1_000 >= Double(max(0, minAgeMillis)) | ||
| } | ||
|
|
||
| private func isSafeEntryId(_ id: String) -> Bool { | ||
| !id.isEmpty && URL(fileURLWithPath: id).lastPathComponent == id | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
warning (2/3) — Crash-path
save()now does a full directory listing (and possibly sort/unlinks) after the durable write.savemust finish synchronously on the crashing thread.enforceAccumulationCapsalwayscontentsOfDirectorys, buildsCrashDirEntrys, and crosses into KotlinisWithinCaps. An inherited over-cap backlog — or a directory padded with foreign files this store deliberately will not reap — is not bounded by the new caps, so this can stall chaining to the previous exception handler.Consider enqueueing trim on
ioQueueafter fsync+rename, or skipping the listing unless a cheap local counter says the directory is over cap.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Accepted trade-off, consistent on both platforms.
save()must finish synchronously on the crashing thread, and deferring the trim toioQueuewould mean a crash that kills the process before the queue drains leaves the directory over cap — the failure mode the caps exist to prevent.The cheap-exit you suggest is already there:
isWithinCapsshort-circuits before any sort or unlink, so the steady-state cost is one listing rather than a listing plus sort. The unbounded case is an inherited pre-retention backlog, which is bounded once by the first post-upgrade crash and cheap from then on. Android made the same call, and keeping the two in step matters more here than shaving the first-crash cost.