diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK index 6c6bb902f..0513bf563 160000 --- a/OneSignal-KMP-SDK +++ b/OneSignal-KMP-SDK @@ -1 +1 @@ -Subproject commit 6c6bb902f1011307f56e4a18ea297539de68d88d +Subproject commit 0513bf56367c76439cb33f40a9d28723a2874f0c diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index a76259a58..6077cdd24 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -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 */; }; @@ -1816,6 +1817,7 @@ A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsTests.swift; sourceTree = ""; }; A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsRefreshServiceTests.swift; sourceTree = ""; }; 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandlerTests.swift; sourceTree = ""; }; + 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogStoreRetentionTests.swift; sourceTree = ""; }; 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingController.swift; sourceTree = ""; }; 7C91A2B0D84F1E9A3C5B6D8E /* OSRemoteLoggingConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingConfiguration.swift; sourceTree = ""; }; C0462F96E1AADF655F3B3765 /* OSRemoteLoggingController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSRemoteLoggingController.h; sourceTree = ""; }; @@ -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 */, @@ -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; diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/FileLogStore.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/FileLogStore.swift index e70dbc73c..f4f6b6da9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/FileLogStore.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/FileLogStore.swift @@ -32,35 +32,79 @@ import OneSignalCore /// Persists encoded crash logs so they can be uploaded after the app restarts. /// -/// 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. +/// A bounded cache, not a queue: retention decisions come from `CrashRetention` in the shared +/// module, so iOS and Android reclaim identically and this type contributes only the file I/O. 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" + /// 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" + /// Kotlin default arguments do not cross the Objective-C boundary, so every selector call + /// must pass this explicitly. + private static let retentionPolicy = CrashRetention.shared.defaultPolicy + + /// Reads the attributes a `CrashDirEntry` is built from. Injectable because unreadable + /// attributes cannot be staged on a real filesystem: revoking access fails the listing. + typealias AttributeLookup = (URL) -> URLResourceValues? + + static let defaultAttributeLookup: AttributeLookup = { url in + try? url.resourceValues(forKeys: [ + .contentModificationDateKey, + .fileSizeKey, + .isRegularFileKey + ]) + } + private let rootURL: URL private let fileManager: FileManager + private let attributeLookup: AttributeLookup + /// Crash-path diagnostics; `OneSignalLog` would fan out to app listeners and the remote sink. + private let crashWarn: (String) -> Void private let ioQueue = DispatchQueue(label: queueLabel, qos: .utility) + private let inFlightLock = NSLock() + private var inFlightNames = Set() - init(rootPath: String, fileManager: FileManager = .default) { + init( + rootPath: String, + fileManager: FileManager = .default, + crashWarn: ((String) -> Void)? = nil, + attributeLookup: @escaping AttributeLookup = FileLogStore.defaultAttributeLookup + ) { self.rootURL = URL(fileURLWithPath: rootPath, isDirectory: true) self.fileManager = fileManager + let crashLogger = OSCrashLogger() + self.crashWarn = crashWarn ?? { crashLogger.warn(message: $0) } + self.attributeLookup = attributeLookup try? createRootDirectory() } + /// Runs synchronously on the crashing thread and must not throw: a fatal handler may + /// terminate the process the moment this returns. func save(bytes: KotlinByteArray) -> Bool { guard bytes.size > 0 else { return false } + // Refuse rather than store-then-reclaim: an over-limit record would claim the whole + // shared budget or be evicted before it could upload. + guard Int64(bytes.size) <= Self.retentionPolicy.maxRecordBytes else { + crashWarn( + "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)) + let targetURL = rootURL.appendingPathComponent(id) + let tmpName = targetURL.appendingPathExtension("tmp").lastPathComponent + try withInFlightNames([id, tmpName]) { + try writeDurably(bytes.data, to: targetURL) + enforceAccumulationCaps(keepName: id) + } return true } catch { return false @@ -114,13 +158,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 + // 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`: iOS never ran the + // OpenTelemetry pipeline, so the only reapable foreign files are this store's own + // interrupted writes, and never one it currently has in flight. + let now = Self.nowMillis() + let inFlight = self.snapshotInFlightNames() + for entry in try self.directoryEntries() + where entry.name.hasSuffix(Self.temporaryFileSuffix) + && !inFlight.contains(entry.name) + && Self.hasReachedMinAge(entry, now: now, minAgeMillis: minAgeMillis) { + // Per-entry, so one undeletable leftover cannot strand the rest of the sweep. + if self.remove(name: entry.name) { + deleted += 1 } - try self.fileManager.removeItem(at: url) - deleted += 1 } } catch { OneSignalLog.onesignalLog( @@ -133,36 +187,181 @@ 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 first so payloads are only materialized for records that survive both bounds. + 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 { Self.hasReachedMinAge($0, now: now, minAgeMillis: 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) } } - private func fileURLs() throws -> [URL] { + /// Whether [entry] is known to have existed for at least [minAgeMillis]. + /// + /// An entry nothing can date returns false: an unreadable timestamp is not evidence of age, + /// so the record is withheld rather than uploaded or reaped, and the caps still bound it. + private static func hasReachedMinAge( + _ entry: CrashDirEntry, + now: Int64, + minAgeMillis: Int64 + ) -> Bool { + guard let writtenMs = CrashRetention.shared.effectiveWriteTimeMs(entry: entry)?.int64Value else { + return false + } + return now - writtenMs >= max(0, minAgeMillis) + } + + /// Applies the shared retention policy and deletes what it selects. + /// + /// - Returns: names to withhold 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 { + let now = Self.nowMillis() + var withheld = Set() + + let inFlight = snapshotInFlightNames() + let expired = CrashRetention.shared.selectExpiredOwned( + entries: entries, + nowMs: now, + policy: Self.retentionPolicy + ) + for entry in expired where !inFlight.contains(entry.name) { + withheld.insert(entry.name) + remove(name: entry.name) + } + + // Survivors only, per the ILogFileStore contract: an expired record still in the listing + // would consume a count slot and byte budget it is about to give back. + let survivors = entries.filter { !withheld.contains($0.name) } + let overflow = CrashRetention.shared.selectOverflowOwned( + entries: survivors, + nowMs: now, + // Every in-flight name: evicting one corrupts a crash being captured right now. + keepNames: inFlight, + policy: Self.retentionPolicy + ) + for entry in overflow where !inFlight.contains(entry.name) { + 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 + } + + /// Reserves every in-flight name, not just [keepName]: this path unlinks whatever the selector + /// returns without re-checking, so an unnamed sibling's record really is deleted. Runs inline + /// on the crashing thread, so the `isWithinCaps` guard is what keeps the sort out of the + /// steady state. Overflow only; expiry is left to the uploader passes. + 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(), + keepNames: snapshotInFlightNames().union([keepName]), + policy: Self.retentionPolicy + ) + for entry in overflow { + remove(name: entry.name, crashSafe: true) + } + } + + /// - Returns: whether the file is gone. An already-removed file counts as success, since + /// crash-path eviction and the async reclaim can target the same name. + @discardableResult + private func remove(name: String, crashSafe: Bool = false) -> Bool { + do { + try fileManager.removeItem(at: rootURL.appendingPathComponent(name)) + return true + } catch { + if fileLogStoreIsAlreadyRemoved(error) { + return true + } + let message = "FileLogStore failed to reclaim \(name): \(error.localizedDescription)" + if crashSafe { + crashWarn(message) + } else { + OneSignalLog.onesignalLog(.LL_WARN, message: message) + } + return false + } + } + + /// Attributes are routinely unreadable before first unlock, so the entry is kept: omitting it + /// would put the file outside every bound at once. A missing mtime must stay `nil`, never a + /// stand-in the policy reads back as an age. Entries are dropped only when `isRegularFile` + /// is known false. + private func directoryEntries() throws -> [CrashDirEntry] { 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 + ).compactMap { url in + let values = attributeLookup(url) + if values?.isRegularFile == false { + return nil + } + let name = url.lastPathComponent + return CrashDirEntry( + name: name, + lastModifiedMs: values?.contentModificationDate.map { + KotlinLong(longLong: Int64($0.timeIntervalSince1970 * 1_000)) + }, + lengthBytes: Int64(values?.fileSize ?? 0) + ) } } - private func isOldEnough(_ url: URL, minAgeMillis: Int64) throws -> Bool { - let values = try url.resourceValues(forKeys: [.contentModificationDateKey]) - guard let modifiedAt = values.contentModificationDate else { - return false + private static func nowMillis() -> Int64 { + Int64(Date().timeIntervalSince1970 * 1_000) + } + + private func withInFlightNames(_ names: [String], _ body: () throws -> Void) rethrows { + inFlightLock.lock() + inFlightNames.formUnion(names) + inFlightLock.unlock() + defer { + inFlightLock.lock() + inFlightNames.subtract(names) + inFlightLock.unlock() } - return Date().timeIntervalSince(modifiedAt) * 1_000 >= Double(max(0, minAgeMillis)) + try body() + } + + private func snapshotInFlightNames() -> Set { + inFlightLock.lock() + defer { inFlightLock.unlock() } + return inFlightNames } private func isSafeEntryId(_ id: String) -> Bool { @@ -233,3 +432,12 @@ final class FileLogStore: ILogFileStore { } } } + +private func fileLogStoreIsAlreadyRemoved(_ error: Error) -> Bool { + let nsError = error as NSError + if nsError.domain == NSCocoaErrorDomain { + return nsError.code == CocoaError.fileNoSuchFile.rawValue + || nsError.code == CocoaError.fileReadNoSuchFile.rawValue + } + return nsError.domain == NSPOSIXErrorDomain && nsError.code == Int(ENOENT) +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/FileLogStoreRetentionTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/FileLogStoreRetentionTests.swift new file mode 100644 index 000000000..0bff3f8c3 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/FileLogStoreRetentionTests.swift @@ -0,0 +1,673 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import OneSignalCore +import OneSignalKMP +@testable import OneSignalOSCore +import XCTest + +/// A scratch crash directory and the staging every file-store suite needs. +class FileLogStoreTestCase: XCTestCase { + fileprivate var temporaryDirectory: URL! + + override func setUpWithError() throws { + temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + // A test may have made the directory read-only; restore it or the fixture outlives the run. + try? FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: temporaryDirectory.path + ) + try? FileManager.default.removeItem(at: temporaryDirectory) + } +} + +/// The bounds that keep the crash directory a bounded cache rather than a queue. The policy +/// decisions themselves are unit-tested in the shared module. +final class FileLogStoreRetentionTests: FileLogStoreTestCase { + // MARK: - write-time size limit + + func testRefusesPayloadOverThePerRecordLimit() { + let store = makeStore() + let oversized = Data(count: Int(CrashRetention.shared.defaultPolicy.maxRecordBytes) + 1) + + XCTAssertFalse(store.save(bytes: oversized.kotlinByteArray)) + XCTAssertEqual(ownedFileNames().count, 0) + } + + func testAcceptsPayloadAtTheLimit() { + let store = makeStore() + let atLimit = Data(count: Int(CrashRetention.shared.defaultPolicy.maxRecordBytes)) + + XCTAssertTrue(store.save(bytes: atLimit.kotlinByteArray)) + XCTAssertEqual(ownedFileNames().count, 1) + } + + // MARK: - age ceiling + + func testListReadableDropsAndDeletesRecordsPastTheAgeCeiling() throws { + let ceiling = CrashRetention.shared.defaultPolicy.maxReadAgeMillis + try writeRecord(named: "expired.otlp", ageMillis: ceiling + 60_000) + try writeRecord(named: "fresh.otlp", ageMillis: 60_000) + + let readable = try awaitListReadable(minAgeMillis: 0) + + XCTAssertEqual(readable.map { $0.id }, ["fresh.otlp"]) + XCTAssertFalse(fileExists("expired.otlp")) + XCTAssertTrue(fileExists("fresh.otlp")) + } + + func testRecordInsideTheAgeWindowIsRetained() throws { + let ceiling = CrashRetention.shared.defaultPolicy.maxReadAgeMillis + try writeRecord(named: "edge.otlp", ageMillis: ceiling - 60_000) + + let readable = try awaitListReadable(minAgeMillis: 0) + + XCTAssertEqual(readable.map { $0.id }, ["edge.otlp"]) + XCTAssertTrue(fileExists("edge.otlp")) + } + + func testExpiredRecordThatCannotBeDeletedIsStillWithheldFromReaders() throws { + // Withholding must not be contingent on the delete succeeding. + let ceiling = CrashRetention.shared.defaultPolicy.maxReadAgeMillis + try writeRecord(named: "expired-stuck.otlp", ageMillis: ceiling + 60_000) + try writeRecord(named: "fresh.otlp", ageMillis: 60_000) + // Denying directory writes fails the unlink without making the entries unreadable. + try FileManager.default.setAttributes( + [.posixPermissions: 0o500], + ofItemAtPath: temporaryDirectory.path + ) + + let readable = try awaitListReadable(minAgeMillis: 0) + + // Premise, not behavior: if the removal went through, this re-covers ordinary expiry. + XCTAssertTrue(fileExists("expired-stuck.otlp")) + XCTAssertEqual(readable.map { $0.id }, ["fresh.otlp"]) + } + + // MARK: - accumulation caps + + func testListReadableReclaimsAnInheritedOverCapBacklog() throws { + // The upgrade case: a directory written by a build with no caps, trimmed on the first + // uploader pass rather than waiting for the next crash. + let max = Int(CrashRetention.shared.defaultPolicy.maxRecordCount) + for index in 0..<(max + 10) { + try writeRecord(named: "seed-\(index).otlp", ageMillis: Int64(1_000 * (index + 1))) + } + + let readable = try awaitListReadable(minAgeMillis: 0) + + XCTAssertEqual(readable.count, max) + XCTAssertEqual(ownedFileNames().count, max) + } + + func testSaveEvictsOldestFirstPastTheCountCap() throws { + let max = Int(CrashRetention.shared.defaultPolicy.maxRecordCount) + for index in 0..() + fileManager.onMoved = { url in + parkedLock.lock() + parkedNames.insert(url.lastPathComponent) + parkedLock.unlock() + parked.fulfill() + XCTAssertEqual(release.wait(timeout: .now() + 10), .success) + } + + let saved = expectation(description: "both writes complete") + saved.expectedFulfillmentCount = 2 + for _ in 0..<2 { + DispatchQueue.global().async { + XCTAssertTrue(store.save(bytes: Data("new".utf8).kotlinByteArray)) + saved.fulfill() + } + } + wait(for: [parked], timeout: 10) + + // Same store, so the scan sees the in-flight set the two parked writes are holding. + let listed = expectation(description: "listReadable") + store.listReadable(minAgeMillis: 0) { _, _ in listed.fulfill() } + wait(for: [listed], timeout: 10) + let survivors = Set(ownedFileNames()) + + release.signal() + release.signal() + wait(for: [saved], timeout: 10) + + parkedLock.lock() + let inFlight = parkedNames + parkedLock.unlock() + XCTAssertEqual(inFlight.count, 2) + // Premise, not behavior: the reclaim loop skips in-flight names regardless. + XCTAssertEqual(inFlight.subtracting(survivors), []) + XCTAssertEqual(survivors.count, max) + } + + func testSaveDoesNotEvictAnotherThreadsInFlightRecord() throws { + // The write path unlinks whatever the selector returns with no in-flight check of its + // own, so caps enforcement must not destroy the crash another thread is still capturing. + let max = Int(CrashRetention.shared.defaultPolicy.maxRecordCount) + for index in 0.. = [], + fileManager: FileManager = .default, + crashWarn: ((String) -> Void)? = nil, + attributeLookup: FileLogStore.AttributeLookup? = nil + ) -> FileLogStore { + let lookup = attributeLookup ?? { url in + denied.contains(url.lastPathComponent) + ? nil + : FileLogStore.defaultAttributeLookup(url) + } + return FileLogStore( + rootPath: temporaryDirectory.path, + fileManager: fileManager, + crashWarn: crashWarn, + attributeLookup: lookup + ) + } + + func writeRecord(named name: String, ageMillis: Int64, bytes: Int = 16) throws { + let url = temporaryDirectory.appendingPathComponent(name) + try Data(count: bytes).write(to: url) + let modified = Date(timeIntervalSinceNow: -Double(ageMillis) / 1_000) + try FileManager.default.setAttributes([.modificationDate: modified], ofItemAtPath: url.path) + } + + /// A record named the way the store names its own: `{millis}-{uuid}.otlp`. Anything asserting + /// on dating must use this shape — a name without leading millis cannot reach the name + /// fallback and silently tests the undatable path instead. + /// - Returns: the generated name. + @discardableResult + func writeProductionShapedRecord( + ageMillis: Int64, + bytes: Int = 16, + suffix: String = ".otlp" + ) throws -> String { + let name = "\(Self.nowMillis() - ageMillis)-\(UUID().uuidString)\(suffix)" + try writeRecord(named: name, ageMillis: ageMillis, bytes: bytes) + return name + } + + static func nowMillis() -> Int64 { + Int64(Date().timeIntervalSince1970 * 1_000) + } + + func fileExists(_ name: String) -> Bool { + FileManager.default.fileExists(atPath: temporaryDirectory.appendingPathComponent(name).path) + } + + func ownedFileNames() -> [String] { + let contents = (try? FileManager.default.contentsOfDirectory(atPath: temporaryDirectory.path)) ?? [] + return contents.filter { $0.hasSuffix(CrashRetention.shared.defaultPolicy.ownedSuffix) } + } + + func awaitListReadable( + minAgeMillis: Int64, + attributesUnreadableFor denied: Set = [], + attributeLookup: FileLogStore.AttributeLookup? = nil + ) throws -> [StoredLogFile] { + let expectation = expectation(description: "listReadable") + var result: [StoredLogFile] = [] + makeStore(attributesUnreadableFor: denied, attributeLookup: attributeLookup) + .listReadable(minAgeMillis: minAgeMillis) { entries, _ in + result = entries ?? [] + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + return result + } + + func awaitDeleteUnrecognized( + minAgeMillis: Int64, + attributesUnreadableFor denied: Set = [], + fileManager: FileManager = .default + ) throws -> Int { + let expectation = expectation(description: "deleteUnrecognizedEntries") + var deleted = 0 + makeStore(attributesUnreadableFor: denied, fileManager: fileManager) + .deleteUnrecognizedEntries(minAgeMillis: minAgeMillis) { count, _ in + deleted = Int(truncating: count ?? 0) + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + return deleted + } +} + +private final class FileLogStoreLogListener: NSObject, OSLogListener { + var entries: [String] = [] + + func onLogEvent(_ event: OneSignalLogEvent) { + entries.append(event.entry) + } +} + +/// `removeItem` reports the file already gone, the way a racing crash-path eviction looks. +private final class FileLogStoreMissingItemFileManager: FileManager { + override func removeItem(at url: URL) throws { + throw CocoaError(.fileNoSuchFile) + } +} + +/// Parks the durable write just after its `.tmp` moves into place, so the record is on disk and +/// still in flight while another thread scans the directory. +private final class FileLogStoreMoveBarrierFileManager: FileManager { + var onMoved: ((URL) -> Void)? + + override func moveItem(at srcURL: URL, to dstURL: URL) throws { + try super.moveItem(at: srcURL, to: dstURL) + onMoved?(dstURL) + } +} + +/// Invokes a hook once the durable write has created its `.tmp`, so a concurrent temp sweep can +/// race the in-flight name. +private final class FileLogStoreReentrantCleanupFileManager: FileManager { + var onTemporaryWrite: (() -> Void)? + + override func setAttributes(_ attributes: [FileAttributeKey: Any], ofItemAtPath path: String) throws { + try super.setAttributes(attributes, ofItemAtPath: path) + if path.hasSuffix(".tmp") { + onTemporaryWrite?() + } + } +}