Skip to content

Commit d7b42d6

Browse files
AR Abdul Azeezcursoragent
andcommitted
fix: [SDK-5065] retry observability components that failed to start
applyAction committed currentConfig even when a component never came up. Since a stable remote payload produces an identical config on the next refresh, the evaluator returned NoChange and the dead crash handler, ANR detector or sink stayed down for the rest of the process. enableFeatures now reports whether everything started, the config is only committed once it did, and startLogging is null-guarded like its siblings so a retry cannot tear down a healthy sink. startLogging also only had half the teardown invariant: it cleared its own field but left Logging's global pointing at the old sink while shutting it down. Every log emitted between shutdown and the replacement being installed -- including the warn in that window -- went to a telemetry whose consumer was already cancelled, where it queued and was never drained. On a throwing factory the global stayed on the dead instance for the session. Reverts the ExpiryOutcome split from the previous commit. It was added on the theory that an expired record whose delete failed could hold the directory over cap while invisible to the byte accounting. Writing the test disproved it: expired records are by definition the oldest, so the selector always picks them for eviction rather than retention, and only retained records claim budget. Including them in the candidate set changes no outcome, so the two-set bookkeeping was inert complexity. Kept a test that the record stays unreadable when its delete fails, which is the part that does matter. Also drops a tautological assertion that passed regardless of keepName now that size is not grounds for eviction, replaces a counter mutated from six concurrent coroutines with an AtomicInteger, stops building a throwaway platform provider just to read a path the pure helper computes, and corrects two KDocs that still claimed the byte cap bounds disk rather than claim. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 9524cfb commit d7b42d6

8 files changed

Lines changed: 165 additions & 51 deletions

File tree

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import com.onesignal.debug.internal.logging.logger.android.FileLogStore
1111
import com.onesignal.debug.internal.logging.logger.android.OneSignalLogHttpSender
1212
import com.onesignal.debug.internal.logging.logger.android.createAndroidLoggerPlatformProvider
1313
import com.onesignal.debug.internal.logging.logger.android.formatCrashDirInventory
14+
import com.onesignal.debug.internal.logging.logger.android.getCrashStoragePath
1415
import com.onesignal.logger.LoggerFactory
1516
import java.io.File
1617
import kotlin.coroutines.cancellation.CancellationException
@@ -63,10 +64,13 @@ internal class OneSignalCrashUploaderWrapper(
6364
}
6465
}
6566

66-
/** Resolves the crash directory the logger module reads and writes. */
67-
private fun crashStoragePath(): String =
68-
createAndroidLoggerPlatformProvider(applicationService.appContext) { featureManager }
69-
.crashStoragePath
67+
/**
68+
* Resolves the crash directory the logger module reads and writes. Uses the pure path
69+
* helper rather than a provider: building one costs a `PackageManager` round-trip and an
70+
* ID resolver, and re-emits the provider's "Crash logs stored at" line, all to read a
71+
* value derived from the context alone.
72+
*/
73+
private fun crashStoragePath(): String = getCrashStoragePath(applicationService.appContext)
7074

7175
/**
7276
* Logs a snapshot of the crash dir (counts of owned `.otlp` vs foreign/legacy

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/CrashDirCleanup.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ internal const val CRASH_MAX_READ_AGE_MILLIS = 72L * 60 * 60 * 1000
2424
* than a healthy install will ever hold. The byte bound is the backstop for pathological
2525
* payloads (deep stacktraces, huge exception messages) where count alone would not keep the
2626
* directory small.
27+
*
28+
* [CRASH_MAX_TOTAL_BYTES] bounds *claim*, not bytes on disk. Since writes are size-limited,
29+
* the two coincide for anything this build wrote. They diverge only for records inherited
30+
* from a build without that limit: each claims at most [CRASH_MAX_RECORD_BYTES], so a handful
31+
* of oversized leftovers can occupy more than this while still counting as within cap. That
32+
* is deliberate — they are real crashes and deserve an upload attempt — and it is bounded by
33+
* the count cap and by [CRASH_MAX_READ_AGE_MILLIS] aging them out.
2734
*/
2835
internal const val CRASH_MAX_RECORD_COUNT = 50
2936

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/FileLogStore.kt

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ import kotlin.coroutines.cancellation.CancellationException
2626
*
2727
* Owned records are bounded on both axes, replacing the caps disk-buffering used to apply:
2828
* [CRASH_MAX_READ_AGE_MILLIS] ages records out, and [CRASH_MAX_RECORD_COUNT] /
29-
* [CRASH_MAX_TOTAL_BYTES] cap accumulation. Both bounds are enforced on every path that
29+
* [CRASH_MAX_TOTAL_BYTES] cap accumulation — the latter by budget claim rather than raw disk
30+
* bytes, which differ only for oversized records inherited from a build that predates the
31+
* write-time limit in [save]. Both bounds are enforced on every path that
3032
* touches the directory — [save], [listReadable] and [deleteUnrecognizedEntries] — so a
3133
* backlog inherited from a build without caps is reclaimed on the next uploader pass rather
3234
* than waiting for a crash. Over-limit records are deleted, not merely hidden from
@@ -154,38 +156,32 @@ internal class FileLogStore(
154156
)
155157
}.orEmpty()
156158

157-
/**
158-
* Outcome of an expiry pass. The two sets differ when a delete fails: the record is still
159-
* unreadable, but it also still occupies the directory, so it must stay visible to the
160-
* accumulation caps instead of being quietly exempted from them.
161-
*/
162-
private data class ExpiryOutcome(val expired: Set<String>, val removed: Set<String>) {
163-
companion object {
164-
val NONE = ExpiryOutcome(emptySet(), emptySet())
165-
}
166-
}
167-
168159
/**
169160
* Deletes owned records past [CRASH_MAX_READ_AGE_MILLIS]. Called from both read paths so
170161
* over-age records are reclaimed even when remote logging is off and the uploader never
171162
* gets as far as [listReadable].
163+
*
164+
* @return every expired name, whether or not its delete succeeded. One that could not be
165+
* removed must still not be read, and it cannot distort the accumulation caps either:
166+
* expired records are by definition the oldest, so the selector always picks them for
167+
* eviction rather than retention, and only retained records claim budget.
172168
*/
173-
private fun reclaimExpiredOwnedRecords(entries: List<CrashDirEntry>, nowMs: Long): ExpiryOutcome {
169+
private fun reclaimExpiredOwnedRecords(entries: List<CrashDirEntry>, nowMs: Long): Set<String> {
174170
val expired = selectExpiredOwnedEntries(entries, nowMs)
175-
if (expired.isEmpty()) return ExpiryOutcome.NONE
176-
val removed = HashSet<String>()
171+
if (expired.isEmpty()) return emptySet()
172+
var deleted = 0
177173
for (entry in expired) {
178174
if (File(rootDir, entry.name).delete()) {
179-
removed.add(entry.name)
175+
deleted++
180176
} else {
181177
Logging.warn("FileLogStore: failed to reclaim expired record ${entry.name}")
182178
}
183179
}
184180
Logging.info(
185-
"FileLogStore: reclaimed ${removed.size}/${expired.size} expired record(s) in ${rootDir.path}: " +
181+
"FileLogStore: reclaimed $deleted/${expired.size} expired record(s) in ${rootDir.path}: " +
186182
expired.take(MAX_NAMES_LOGGED).joinToString(", ") { it.name },
187183
)
188-
return ExpiryOutcome(expired = expired.mapTo(HashSet()) { it.name }, removed = removed)
184+
return expired.mapTo(HashSet()) { it.name }
189185
}
190186

191187
@Suppress("TooGenericExceptionCaught", "SwallowedException")
@@ -196,9 +192,9 @@ internal class FileLogStore(
196192
val entries = listEntries(rootDir)
197193
// Reclaim before reading: payloads are only materialized for records that
198194
// survive both bounds, so an over-cap backlog is never fully loaded.
199-
val expiry = reclaimExpiredOwnedRecords(entries, now)
200-
val evicted = reclaimOverLimitRecords(entries.filterNot { expiry.removed.contains(it.name) })
201-
val dropped = expiry.expired + evicted
195+
val expired = reclaimExpiredOwnedRecords(entries, now)
196+
val evicted = reclaimOverLimitRecords(entries.filterNot { expired.contains(it.name) })
197+
val dropped = expired + evicted
202198
val suffixMatches =
203199
entries.filter { isOwnedCrashFile(it.name) && !dropped.contains(it.name) }
204200
val readable =
@@ -208,7 +204,7 @@ internal class FileLogStore(
208204
Logging.debug(
209205
"FileLogStore: listReadable minAgeMs=$minAgeMillis total=${entries.size} " +
210206
"suffix=${suffixMatches.size} readable=${readable.size} " +
211-
"expired=${expiry.expired.size} overCap=${evicted.size} " +
207+
"expired=${expired.size} overCap=${evicted.size} " +
212208
"legacy=${entries.count { !isOwnedCrashFile(it.name) }}",
213209
)
214210
readable
@@ -264,8 +260,8 @@ internal class FileLogStore(
264260
try {
265261
val now = System.currentTimeMillis()
266262
val listed = listEntries(rootDir)
267-
val expiry = reclaimExpiredOwnedRecords(listed, now)
268-
reclaimOverLimitRecords(listed.filterNot { expiry.removed.contains(it.name) })
263+
val expired = reclaimExpiredOwnedRecords(listed, now)
264+
reclaimOverLimitRecords(listed.filterNot { expired.contains(it.name) })
269265
val foreign = selectUnrecognizedEntries(listed, now, minAgeMillis)
270266
if (foreign.isEmpty()) {
271267
Logging.debug("FileLogStore: no unrecognized files to purge in ${rootDir.path}")

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/LoggerLifecycleManager.kt

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -138,35 +138,64 @@ internal class LoggerLifecycleManager(
138138
return ObservabilityConfig(isEnabled = enabled, logLevel = level)
139139
}
140140

141-
/** Must be called while holding [lock]. */
141+
/**
142+
* Must be called while holding [lock].
143+
*
144+
* [currentConfig] is only advanced once the requested state is actually in place. If a
145+
* component failed to start, the config is left behind so the next HYDRATE — which for a
146+
* stable remote payload is an identical one — still evaluates to `Enable` and retries the
147+
* missing piece, instead of collapsing to `NoChange` and leaving it dead for the session.
148+
*/
142149
private fun applyAction(action: ObservabilityConfigAction, newConfig: ObservabilityConfig) {
143-
when (action) {
144-
is ObservabilityConfigAction.Enable -> enableFeatures(newConfig.logLevel ?: LogLevel.ERROR)
145-
is ObservabilityConfigAction.Disable -> disableFeatures()
146-
is ObservabilityConfigAction.UpdateLogLevel -> updateLogLevel(action.newLevel)
147-
is ObservabilityConfigAction.NoChange -> Logging.debug("OneSignal: logger config unchanged")
148-
}
149-
currentConfig = newConfig
150+
val applied =
151+
when (action) {
152+
is ObservabilityConfigAction.Enable -> enableFeatures(newConfig.logLevel ?: LogLevel.ERROR)
153+
is ObservabilityConfigAction.UpdateLogLevel -> updateLogLevel(action.newLevel)
154+
is ObservabilityConfigAction.Disable -> {
155+
disableFeatures()
156+
true
157+
}
158+
is ObservabilityConfigAction.NoChange -> {
159+
Logging.debug("OneSignal: logger config unchanged")
160+
true
161+
}
162+
}
163+
if (applied) currentConfig = newConfig
150164
}
151165

166+
/**
167+
* Starts whatever is not already running. Each component is independent: one failing must
168+
* not stop the others.
169+
*
170+
* @return true when every feature is up, so the caller knows whether to commit the config
171+
*/
152172
@Suppress("TooGenericExceptionCaught")
153-
private fun enableFeatures(logLevel: LogLevel) {
173+
private fun enableFeatures(logLevel: LogLevel): Boolean {
154174
Logging.info("OneSignal: Enabling logger module features at level $logLevel")
175+
var allStarted = true
155176
try {
156177
startCrashHandler()
157178
} catch (t: Throwable) {
179+
allStarted = false
158180
Logging.warn("OneSignal: Failed to start logger crash handler: ${t.message}", t)
159181
}
160182
try {
161183
startAnrDetector()
162184
} catch (t: Throwable) {
185+
allStarted = false
163186
Logging.warn("OneSignal: Failed to start logger ANR detector: ${t.message}", t)
164187
}
165188
try {
166-
startLogging(logLevel)
189+
// Guarded like the other two so a retry does not tear down a healthy sink.
190+
if (remoteTelemetry == null) startLogging(logLevel)
167191
} catch (t: Throwable) {
192+
allStarted = false
168193
Logging.warn("OneSignal: Failed to start logger logging: ${t.message}", t)
169194
}
195+
if (!allStarted) {
196+
Logging.warn("OneSignal: Some logger features did not start; will retry on the next config refresh")
197+
}
198+
return allStarted
170199
}
171200

172201
@Suppress("TooGenericExceptionCaught")
@@ -200,13 +229,16 @@ internal class LoggerLifecycleManager(
200229
}
201230
}
202231

232+
/** @return true when the new level is live, so the caller knows whether to commit the config */
203233
@Suppress("TooGenericExceptionCaught")
204-
private fun updateLogLevel(newLevel: LogLevel) {
234+
private fun updateLogLevel(newLevel: LogLevel): Boolean {
205235
Logging.info("OneSignal: Updating logger module log level to $newLevel")
206-
try {
236+
return try {
207237
startLogging(newLevel)
238+
true
208239
} catch (t: Throwable) {
209240
Logging.warn("OneSignal: Failed to update logger log level: ${t.message}", t)
241+
false
210242
}
211243
}
212244

@@ -228,11 +260,14 @@ internal class LoggerLifecycleManager(
228260

229261
@Suppress("TooGenericExceptionCaught")
230262
private fun startLogging(logLevel: LogLevel) {
231-
// Same invariant as disableFeatures: drop the reference before tearing the old sink
232-
// down. A throwing shutdown() must not leave the field pointing at a dead instance,
233-
// because an identical later config evaluates to NoChange and would never replace it.
263+
// Same invariant as disableFeatures: detach both the field and Logging's global before
264+
// tearing the old sink down. Shutting down first would leave every log emitted until
265+
// the replacement is installed — including the warn below — going to a telemetry whose
266+
// consumer is already cancelled, where it is queued and never drained. If the factory
267+
// then throws, the global would keep pointing at that dead instance for the session.
234268
val previous = remoteTelemetry
235269
remoteTelemetry = null
270+
Logging.setLoggerTelemetry(null) { false }
236271
try {
237272
previous?.shutdown()
238273
} catch (t: Throwable) {

OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingRemoteTest.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import kotlinx.coroutines.delay
1616
import kotlinx.coroutines.runBlocking
1717
import kotlinx.coroutines.withTimeout
1818
import org.robolectric.annotation.Config
19+
import java.util.concurrent.atomic.AtomicInteger
1920

2021
/**
2122
* Covers the single remaining remote-logging sink. Emission is asynchronous, so each
@@ -110,8 +111,9 @@ class LoggingRemoteTest : FunSpec({
110111
test("every severity is forwarded") {
111112
val telemetry = mockk<ILogTelemetryRemote>(relaxed = true)
112113
val sixth = CompletableDeferred<Unit>()
113-
var seen = 0
114-
coEvery { telemetry.emit(any()) } answers { if (++seen == 6) sixth.complete(Unit); Unit }
114+
// Emission fans out across Dispatchers.Default, so the counter is touched concurrently.
115+
val seen = AtomicInteger(0)
116+
coEvery { telemetry.emit(any()) } answers { if (seen.incrementAndGet() == 6) sixth.complete(Unit); Unit }
115117
Logging.setLoggerTelemetry(telemetry) { true }
116118

117119
Logging.verbose("v")

OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/android/CrashDirCleanupTest.kt

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,12 +147,6 @@ class CrashDirCleanupTest : FunSpec({
147147
evicted.map { it.name } shouldBe listOf("${CRASH_MAX_RECORD_COUNT}-a.otlp")
148148
}
149149

150-
test("keepName retains an oversized just-written record") {
151-
val entries = listOf(owned("fresh-a.otlp", ageMs = 1_000, bytes = CRASH_MAX_RECORD_BYTES + 1))
152-
153-
selectOverflowOwnedEntries(entries, keepName = "fresh-a.otlp") shouldBe emptyList()
154-
}
155-
156150
test("an oversized keepName does not evict the pending backlog") {
157151
// The regression this guards: charging keepName its full length started the budget
158152
// over cap, so every sibling failed the remaining-budget check and the entire backlog

OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/android/FileLogStoreTest.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,21 @@ class FileLogStoreTest : FunSpec({
189189
dir.listFiles()!!.count { it.name.endsWith(CRASH_OWNED_SUFFIX) } shouldBe CRASH_MAX_RECORD_COUNT
190190
}
191191

192+
// A delete can fail (read-only dir, filesystem error). The record must stay unreadable
193+
// regardless, and must not resurface on a later pass just because it survived.
194+
test("an expired record that cannot be deleted is still withheld from readers") {
195+
write("expired-stuck.otlp", ageMsAgo = CRASH_MAX_READ_AGE_MILLIS + 60_000)
196+
write("fresh.otlp", ageMsAgo = 60_000)
197+
// Read-only dir makes unlink fail on POSIX without making the entries unreadable.
198+
dir.setWritable(false)
199+
200+
val readable = runBlocking { FileLogStore(dir.path).listReadable(minAgeMillis = 0) }
201+
202+
dir.setWritable(true)
203+
readable.map { it.id } shouldBe listOf("fresh.otlp")
204+
File(dir, "expired-stuck.otlp").exists() shouldBe true
205+
}
206+
192207
test("deleteUnrecognizedEntries evicts an inherited over-cap backlog") {
193208
repeat(CRASH_MAX_RECORD_COUNT + 10) { i -> write("seed-$i.otlp", ageMsAgo = 1_000L * (i + 1)) }
194209
write("1784621689841")

OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerFaultTest.kt

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,67 @@ class LoggerLifecycleManagerFaultTest : FunSpec({
261261
detectorCount shouldBe 1
262262
}
263263

264+
// A component that failed to start must be retried on the next config refresh. Committing
265+
// currentConfig after a partial failure collapsed the next identical HYDRATE to NoChange,
266+
// which left the dead component down for the rest of the process.
267+
268+
test("a crash handler that failed to start is retried on the next identical config") {
269+
val failing = mockk<ILogCrashHandler>(relaxed = true)
270+
every { failing.initialize() } throws RuntimeException("initialize boom")
271+
val replacement = mockk<ILogCrashHandler>(relaxed = true)
272+
var calls = 0
273+
val manager = managerWith(crashHandler = { if (calls++ == 0) failing else replacement })
274+
275+
manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
276+
manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
277+
278+
verify { replacement.initialize() }
279+
}
280+
281+
test("an ANR detector that failed to start is retried on the next identical config") {
282+
val failing = mockk<ILogAnrDetector>(relaxed = true)
283+
every { failing.start() } throws RuntimeException("start boom")
284+
val replacement = mockk<ILogAnrDetector>(relaxed = true)
285+
var calls = 0
286+
val manager = managerWith(anrDetector = { if (calls++ == 0) failing else replacement })
287+
288+
manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
289+
manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
290+
291+
verify { replacement.start() }
292+
}
293+
294+
test("a retry does not tear down the components that did start") {
295+
val handler = mockk<ILogCrashHandler>(relaxed = true)
296+
val failingDetector = mockk<ILogAnrDetector>(relaxed = true)
297+
every { failingDetector.start() } throws RuntimeException("start boom")
298+
var detectorCalls = 0
299+
var handlerCalls = 0
300+
val manager =
301+
managerWith(
302+
crashHandler = { handlerCalls++; handler },
303+
anrDetector = { detectorCalls++; if (detectorCalls == 1) failingDetector else mockk(relaxed = true) },
304+
)
305+
306+
manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
307+
manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
308+
309+
// Only the ANR detector is rebuilt; the healthy crash handler is left alone.
310+
handlerCalls shouldBe 1
311+
detectorCalls shouldBe 2
312+
}
313+
314+
test("once every component is up an identical config stops retrying") {
315+
var handlerCalls = 0
316+
val manager = managerWith(crashHandler = { handlerCalls++; mockk(relaxed = true) })
317+
318+
manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
319+
manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
320+
manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
321+
322+
handlerCalls shouldBe 1
323+
}
324+
264325
test("disable then re-enable builds fresh collaborators") {
265326
var handlerCount = 0
266327
var detectorCount = 0

0 commit comments

Comments
 (0)