Skip to content

fix: [SDK-5065] bound the crash-record cache on iOS - #1725

Draft
abdulraqeeb33 wants to merge 1 commit into
mainfrom
ar/sdk-5065-ios-crash-retention
Draft

fix: [SDK-5065] bound the crash-record cache on iOS#1725
abdulraqeeb33 wants to merge 1 commit into
mainfrom
ar/sdk-5065-ios-crash-retention

Conversation

@abdulraqeeb33

Copy link
Copy Markdown
Contributor

Description

One Line Summary

Bounds the iOS crash-record cache — age ceiling, count/byte caps, write-size limit — using the retention policy shared from the KMP module.

Relates to SDK-5065.

Important

Draft — depends on OneSignal-KMP-SDK#20. CrashRetention does not exist in the pinned submodule yet. This needs that PR merged and the OneSignal-KMP-SDK pin bumped before it can build in CI.

The bug

FileLogStore has no retention at all:

  • save() enforces no size limit
  • listReadable() has only the lower minAgeMillis gate — no ceiling
  • deleteUnrecognizedEntries() reaps only .otlp.tmp, never owned records at any age
  • no count cap, no byte cap, no eviction anywhere

So a crash record that fails to upload is re-read and re-POSTed on every launch, indefinitely, and the directory grows unbounded. The path is under .cachesDirectory, so iOS reclaims the disk under pressure — but the repeated re-upload is unmitigated, and a stuck record keeps being sent until the OS happens to purge.

This is the same defect Android hit when OpenTelemetry's disk-buffering was removed and its retention went with it. Android rebuilt the policy over several review rounds. Rather than reimplement it here and let the two drift, the policy moved to commonMain and this adopts it.

What changed

Decisions come from CrashRetention; this file keeps only the I/O — snapshot the directory into CrashDirEntrys, apply what the selectors return.

Retention runs on all three paths, which is the part Android got wrong first:

Path Behavior
save() refuses payloads over the per-record limit; trims after a write, always keeping the record just written
listReadable() reclaims before materializing payloads, so an over-cap backlog is never fully loaded into memory
deleteUnrecognizedEntries() also reclaims — it is the only scan that runs when remote logging is disabled, so otherwise a directory nothing reads is never bounded

Deliberately unchanged: foreign-file policy

The shared selectUnrecognized reaps any non-owned file. iOS stays narrower and keeps reaping only its own .otlp.tmp. iOS never ran the OpenTelemetry pipeline, so there is no legacy format sharing this directory, and files we did not write are not ours to assume about. testFileStoreDeletesOnlyInterruptedTemporaryWrites pins that intent and still passes unchanged.

Testing

FileLogStoreRetentionTests — 10 cases covering the write-size limit and its exact boundary, the age ceiling and the record just inside it, oldest-first eviction past the count cap, never evicting the just-written record, inherited over-cap backlogs reclaimed by both the read and cleanup paths, and the preserved foreign-file behavior.

Verified these actually bite: reverted FileLogStore.swift to its pre-retention state and re-ran — 7 of 10 fail, with the 3 passes being the negative and boundary cases that should still hold.

Full OneSignalOSCore suite: 112 tests, 0 failures on iPhone 17 Pro simulator.

The policy decisions themselves are unit-tested in the shared module (20 cases, green on both iosSimulatorArm64 and Android JVM).

FileLogStore had no retention. save() enforced no size limit, listReadable had
only the lower minAgeMillis gate with no ceiling, deleteUnrecognizedEntries
reaped only .otlp.tmp and never touched owned records at any age, and there was
no count or byte cap anywhere. A record that fails to upload was therefore
re-read and re-POSTed on every launch indefinitely, with the directory growing
until the OS reclaimed the cache. Android hit the same defect when
OpenTelemetry's disk-buffering was removed and rebuilt the policy; this adopts
that policy rather than reimplementing it.

The decisions come from CrashRetention in the shared module, so both platforms
reclaim identically and the rules stay unit-tested in one place. This file
keeps only the I/O: snapshot the directory, apply what the selectors return.

Retention now runs on all three paths. save() refuses oversized payloads and
trims after a write, keeping the record it just wrote. listReadable reclaims
before materializing payloads, so an over-cap backlog is never fully loaded.
deleteUnrecognizedEntries reclaims too, since it is the only scan that runs
when remote logging is disabled and otherwise a directory nothing reads would
never be bounded.

Foreign-file handling is deliberately left as it was, narrower than Android's
shared selector: iOS never ran the OpenTelemetry pipeline, so there is no
legacy format sharing this directory, and files we did not write are not ours
to assume about.

Depends on the CrashRetention API landing in the KMP submodule; the pin bump
comes with that merge.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abdulraqeeb33
abdulraqeeb33 requested a review from a team August 25, 2026 20:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant