Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */; };
A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */; };
C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */; };
32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */; };
B96A3B6BA8CC49EE4796D9BF /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; };
25898119922BDCDA7AF0B9CC /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; };
9EAF92032D0429FA35E04417 /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; };
Expand Down Expand Up @@ -1816,6 +1817,7 @@
A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsTests.swift; sourceTree = "<group>"; };
A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsRefreshServiceTests.swift; sourceTree = "<group>"; };
3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandlerTests.swift; sourceTree = "<group>"; };
9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogStoreRetentionTests.swift; sourceTree = "<group>"; };
8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingController.swift; sourceTree = "<group>"; };
7C91A2B0D84F1E9A3C5B6D8E /* OSRemoteLoggingConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingConfiguration.swift; sourceTree = "<group>"; };
C0462F96E1AADF655F3B3765 /* OSRemoteLoggingController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSRemoteLoggingController.h; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2608,6 +2610,7 @@
A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */,
A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */,
3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */,
9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */,
3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */,
3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */,
3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */,
Expand Down Expand Up @@ -4655,6 +4658,7 @@
A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */,
A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */,
C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */,
32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */,
3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
213 changes: 188 additions & 25 deletions iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/FileLogStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

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.

save must finish synchronously on the crashing thread. enforceAccumulationCaps always contentsOfDirectorys, builds CrashDirEntrys, and crosses into Kotlin isWithinCaps. 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 ioQueue after fsync+rename, or skipping the listing unless a cheap local counter says the directory is over cap.

Copy link
Copy Markdown
Contributor Author

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 to ioQueue would 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: isWithinCaps short-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.

return true
} catch {
return false
Expand Down Expand Up @@ -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(
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning (2/3) — This scan path runs overflow only, not expiry.

ILogFileStore asks implementations to run both selectExpiredOwned and selectOverflowOwned on every directory scan. Here isWithinCaps can early-return a directory of in-window-but-expired records, so save() never clears them; when caps are already full, expired entries still consume slots and each crash pays a listing+sort to evict one.

One reviewer treated the write-path keepName / no-expiry split as intentional. If it is, a short comment here would close the gap with the contract quoted above reclaim. Otherwise the entries are already in hand — one selectExpiredOwned (excluding keepName) before overflow would match the read/cleanup paths.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deliberate, and now commented at enforceAccumulationCaps — thanks for pushing on it. The split matches pre-deletion Android: the write path runs overflow only so the crashing thread does not pay for an expiry scan when nothing is over cap, and under-cap expired records are reclaimed on the next uploader pass by listReadable / deleteUnrecognizedEntries.

Behavior unchanged, but this is the second independent review to question it against the ILogFileStore wording, which is fair evidence the intent was not legible in the code. The comment now says why and where expiry actually happens.

}

/// - 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
}
Expand Down
Loading