Skip to content
Merged
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
8 changes: 8 additions & 0 deletions iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@
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 */; };
A91B2C3D4E5F60718293A4B5 /* OSRemoteLoggingConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C91A2B0D84F1E9A3C5B6D8E /* OSRemoteLoggingConfiguration.swift */; };
B82C3D4E5F60718293A4B5C6 /* OSRemoteLoggingConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C91A2B0D84F1E9A3C5B6D8E /* OSRemoteLoggingConfiguration.swift */; };
C73D4E5F60718293A4B5C6D7 /* OSRemoteLoggingConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C91A2B0D84F1E9A3C5B6D8E /* OSRemoteLoggingConfiguration.swift */; };
ACE2175908241BB46C9F1829 /* OSRemoteLoggingControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658E6E9E6BC6BBF702BCBD33 /* OSRemoteLoggingControllerTests.swift */; };
7732574D325D34CC7C498199 /* OSRemoteLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6972EE491A57C79EFE56D4C8 /* OSRemoteLogger.swift */; };
698F58A488FCE503DFD5247F /* OSLogCrashHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD3284210A7DF2597594778 /* OSLogCrashHandler.swift */; };
Expand Down Expand Up @@ -1814,6 +1817,7 @@
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>"; };
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>"; };
658E6E9E6BC6BBF702BCBD33 /* OSRemoteLoggingControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingControllerTests.swift; sourceTree = "<group>"; };
6972EE491A57C79EFE56D4C8 /* OSRemoteLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLogger.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2747,6 +2751,7 @@
DEC08AFF2947D4E900C81DA3 /* OneSignalSwiftInterface.swift */,
C0462F96E1AADF655F3B3765 /* OSRemoteLoggingController.h */,
8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */,
7C91A2B0D84F1E9A3C5B6D8E /* OSRemoteLoggingConfiguration.swift */,
3CC9A6352AFA26E7008F68FD /* PrivacyInfo.xcprivacy */,
);
path = Source;
Expand Down Expand Up @@ -4432,6 +4437,7 @@
9124121E1E73342200E41FD7 /* OneSignalJailbreakDetection.m in Sources */,
3C14E3A42AFAE54C006ED053 /* OneSignalSwiftInterface.swift in Sources */,
B96A3B6BA8CC49EE4796D9BF /* OSRemoteLoggingController.swift in Sources */,
A91B2C3D4E5F60718293A4B5 /* OSRemoteLoggingConfiguration.swift in Sources */,
912412471E73369600E41FD7 /* OneSignalHelper.m in Sources */,
CA8E19062193C76D009DA223 /* OSInAppMessagingHelpers.m in Sources */,
7AAA60682485D0420004FADE /* OSMigrationController.m in Sources */,
Expand Down Expand Up @@ -4591,6 +4597,7 @@
7AECE59F23675F6300537907 /* OSFocusTimeProcessorFactory.m in Sources */,
DEC08B012947D4E900C81DA3 /* OneSignalSwiftInterface.swift in Sources */,
25898119922BDCDA7AF0B9CC /* OSRemoteLoggingController.swift in Sources */,
B82C3D4E5F60718293A4B5C6 /* OSRemoteLoggingConfiguration.swift in Sources */,
CA1A6E7120DC2E73001C41B9 /* OneSignalDialogRequest.m in Sources */,
912412331E73342200E41FD7 /* OneSignalTracker.m in Sources */,
7A674F1C2360D82E001F9ACD /* OSBaseFocusTimeProcessor.m in Sources */,
Expand Down Expand Up @@ -4720,6 +4727,7 @@
4529DEEA1FA8360C00CEAB1D /* UIApplicationOverrider.m in Sources */,
DEC08B022947D4E900C81DA3 /* OneSignalSwiftInterface.swift in Sources */,
9EAF92032D0429FA35E04417 /* OSRemoteLoggingController.swift in Sources */,
C73D4E5F60718293A4B5C6D7 /* OSRemoteLoggingConfiguration.swift in Sources */,
7A93269E25AF4F0300BBEC27 /* OSPendingCallbacks.m in Sources */,
7AECE59823674AB700537907 /* OSUnattributedFocusTimeProcessor.m in Sources */,
7A5A818224897693002E07C8 /* MigrationTests.m in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,19 @@ public extension OSRemoteLoggerProtocol {

@_implementationOnly import OneSignalKMP

private final class OSRemoteLoggerLifecycle {
private let lock = NSLock()
final class OSRemoteLoggerLifecycle {
/// A condition rather than a plain lock so teardown can wait on in-flight flushes.
private let lock = NSCondition()
private var isStarted = false
private var isShuttingDown = false
private var isShutdown = false
private var activeFlushes = 0

var canStartUploader: Bool {
/// True while the transport is usable and teardown has not begun. Gates record
/// emission, uploader start, and explicit flushes alike, so nothing new is accepted
/// once the SDK has been told to stop. Keyed on shutdown *beginning* rather than
/// finishing, because the final drain is asynchronous.
var isActive: Bool {
lock.lock()
defer { lock.unlock() }
return isStarted && !isShuttingDown && !isShutdown
Expand All @@ -76,16 +82,51 @@ private final class OSRemoteLoggerLifecycle {
return true
}

/// Rejects once shutdown has *begun*, not just once it has finished. The final
/// drain is asynchronous, so a logger told to shut down can otherwise still be
/// started afterwards and install a crash handler nothing will ever unregister.
func start() -> Bool {
lock.lock()
defer { lock.unlock() }
guard !isStarted, !isShutdown else {
guard !isStarted, !isShuttingDown, !isShutdown else {
return false
}
isStarted = true
return true
}

/// Claims a flush slot, so teardown can tell a flush is still crossing into KMP.
/// Returns false once shutdown has begun, meaning the caller must not cross.
func beginFlush() -> Bool {
lock.lock()
defer { lock.unlock() }
guard isStarted, !isShuttingDown, !isShutdown else {
return false
}
activeFlushes += 1
return true
}

func endFlush() {
lock.lock()
activeFlushes -= 1
if activeFlushes == 0 {
lock.broadcast()
}
lock.unlock()
}

/// Blocks until flushes admitted before shutdown began have finished, so the
/// teardown drain never overlaps one. `beginFlush` already refuses new flushes by
/// this point, so the set can only shrink. Bounded, because a wedged flush must
/// not stop teardown from completing.
func waitForFlushesToDrain(timeout: TimeInterval) {
let deadline = Date().addingTimeInterval(timeout)
lock.lock()
defer { lock.unlock() }
while activeFlushes > 0, lock.wait(until: deadline) {}
}

func beginShutdown() -> Bool {
lock.lock()
defer { lock.unlock() }
Expand Down Expand Up @@ -170,6 +211,13 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol {
private let lifecycleOperationLock = NSLock()
private let uploaderOwner = UUID()

/// Serial so overlapping teardowns cannot stack several bounded drains at once.
private static let teardownQueue = DispatchQueue(label: "com.onesignal.logger.remote-teardown")

/// Matches the bound KMP puts on its own shutdown drain, so a wedged flush delays
/// teardown by no more than the drain itself already can.
private static let flushDrainTimeout: TimeInterval = 5

public convenience init(
installIdProvider: @escaping () -> String,
onesignalIdProvider: @escaping () -> String?,
Expand Down Expand Up @@ -237,9 +285,12 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol {
let crashLogger = OSCrashLogger()
let lifecycle = OSRemoteLoggerLifecycle()
let fileStore = FileLogStore(rootPath: provider.crashStoragePath)
// Console-only logger on purpose. Exporter diagnostics describe the POST that
// ships log records, so routing them through OneSignalLog would feed each POST
// back into the export queue as a new record and never settle.
let httpSender = Self.makeHttpSender(
requestSender: requestSenderOverride,
logger: logger,
logger: crashLogger,
isDiagnosticsEnabled: exporterLoggingEnabledProvider,
lifecycle: lifecycle
)
Expand Down Expand Up @@ -309,7 +360,7 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol {
let logger = self.logger
let lifecycle = self.lifecycle
OSCrashUploaderCoordinator.shared.enqueue(owner: owner) {
guard lifecycle.canStartUploader else {
guard lifecycle.isActive else {
OSCrashUploaderCoordinator.shared.finish(owner: owner)
return
}
Expand Down Expand Up @@ -347,6 +398,9 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol {
exceptionMessage: String?,
exceptionStacktrace: String?
) {
guard lifecycle.isActive else {
return
}
LogLoggingHelper.shared.log(
telemetry: telemetry,
level: level,
Expand All @@ -359,20 +413,49 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol {
}

public func forceFlush(completion: @escaping () -> Void) {
telemetry.forceFlush(completionHandler: { _ in completion() })
// Claiming a slot rather than just testing a flag: the flush completes
// asynchronously even when started inline, so shutdown could otherwise begin
// after the check passed and drain the same telemetry concurrently.
// `shutdown()` waits for the slot to be released. The completion still has to
// run on every path — callers end a background task in it, and swallowing it
// would leak that task.
guard lifecycle.beginFlush() else {
completion()
return
}
let lifecycle = self.lifecycle
telemetry.forceFlush(completionHandler: { _ in
lifecycle.endFlush()
completion()
})
}

public func shutdown() {
lifecycleOperationLock.lock()
defer { lifecycleOperationLock.unlock() }
guard lifecycle.beginShutdown() else {
lifecycleOperationLock.unlock()
return
}

OSCrashUploaderCoordinator.shared.cancel(owner: uploaderOwner)
crashHandler.unregister()
telemetry.shutdown()
lifecycle.finishShutdown()
lifecycleOperationLock.unlock()

// `telemetry.shutdown()` blocks for up to five seconds draining buffered
// records, and callers reach here from app launch and app-id changes, where
// that would stall the UI. `beginShutdown()` has already closed the door on
// new records, so the drain can finish on its own thread. Unregistering the
// crash handler stays synchronous above: a later logger cannot install its
// handler while this one is still registered.
Self.teardownQueue.async { [self] in
// A flush admitted just before `beginShutdown()` may still be crossing
// into KMP; the drain below would otherwise run alongside it. Safe to
// block here: this is a background queue, and the KMP completion that
// releases the slot resumes on main.
lifecycle.waitForFlushesToDrain(timeout: Self.flushDrainTimeout)
telemetry.shutdown()
lifecycle.finishShutdown()
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,65 @@ final class OSLoggerAdaptersTests: XCTestCase {
try? FileManager.default.removeItem(at: temporaryDirectory)
}

func testLifecycleRejectsStartOnceShutdownHasBegun() {
let lifecycle = OSRemoteLoggerLifecycle()

XCTAssertTrue(lifecycle.beginShutdown())
// The final drain is deferred, so `isShutdown` is still false at this point.
// Starting anyway would register a crash handler that nothing unregisters,
// permanently blocking every later logger from installing its own.
XCTAssertFalse(lifecycle.start())
XCTAssertFalse(lifecycle.isActive)
}

func testLifecycleShutdownWaitsForInFlightFlush() {
let lifecycle = OSRemoteLoggerLifecycle()
XCTAssertTrue(lifecycle.start())
XCTAssertTrue(lifecycle.beginFlush())
XCTAssertTrue(lifecycle.beginShutdown())

// A flush admitted before shutdown began still holds its slot, so teardown
// must not proceed to the drain while it is outstanding.
let drained = DispatchSemaphore(value: 0)
DispatchQueue.global().async {
lifecycle.waitForFlushesToDrain(timeout: 5)
drained.signal()
}
XCTAssertEqual(drained.wait(timeout: .now() + 0.3), .timedOut)

lifecycle.endFlush()
XCTAssertEqual(drained.wait(timeout: .now() + 2), .success)
}

func testLifecycleRefusesNewFlushOnceShutdownBegins() {
let lifecycle = OSRemoteLoggerLifecycle()
XCTAssertTrue(lifecycle.start())
XCTAssertTrue(lifecycle.beginShutdown())

// Nothing new may cross into KMP, so the drain has a shrinking set to wait on.
XCTAssertFalse(lifecycle.beginFlush())

let drained = DispatchSemaphore(value: 0)
DispatchQueue.global().async {
lifecycle.waitForFlushesToDrain(timeout: 5)
drained.signal()
}
XCTAssertEqual(drained.wait(timeout: .now() + 2), .success)
}

func testLifecycleStopsAcceptingRecordsWhenShutdownBegins() {
let lifecycle = OSRemoteLoggerLifecycle()

XCTAssertTrue(lifecycle.start())
XCTAssertTrue(lifecycle.isActive)

// Gates emission, uploader start, and explicit flushes together, so nothing
// new is accepted once the SDK has been told to stop.
XCTAssertTrue(lifecycle.beginShutdown())
XCTAssertFalse(lifecycle.isActive)
XCTAssertFalse(lifecycle.start())
}

func testFileStoreSynchronouslySavesAndListsPayload() throws {
let store = FileLogStore(rootPath: temporaryDirectory.path)
let payload = makeKotlinBytes([1, 2, 3, 255])
Expand Down Expand Up @@ -246,6 +305,29 @@ final class OSLoggerAdaptersTests: XCTestCase {
XCTAssertTrue(lines.isEmpty)
}

/// KMP #15 set `objcExportSuspendFunctionLaunchThreadRestriction=none`, lifting
/// Kotlin/Native's rule that exported `suspend` functions may only be called from
/// the main thread. Asserts that actually holds for the framework we link, so the
/// Swift side does not have to marshal every crossing onto main — and fails loudly
/// if that flag is ever dropped.
func testKmpSuspendCallSucceedsOffMainThread() {
let store = FileLogStore(rootPath: temporaryDirectory.path)
let telemetry = LoggerFactory.shared.createCrashLocalTelemetry(
platformProvider: makePlatformProvider(),
fileStore: store
)
let completed = expectation(description: "KMP suspend call completes off main")

DispatchQueue.global().async {
XCTAssertFalse(Thread.isMainThread)
telemetry.forceFlush { _ in
completed.fulfill()
}
}

wait(for: [completed], timeout: 5)
}

func testKmpPipelineInvokesSwiftAdapters() throws {
let listener = LoggerAdapterListener()
OneSignalLog.debug().__add(listener)
Expand Down
Loading
Loading