Skip to content

fix(sync): bound a CloudKit record name so a long database path cannot crash the app - #2585

Merged
datlechin merged 2 commits into
mainfrom
fix/cloudkit-record-name-length
Aug 31, 2026
Merged

fix(sync): bound a CloudKit record name so a long database path cannot crash the app#2585
datlechin merged 2 commits into
mainfrom
fix/cloudkit-record-name-length

Conversation

@datlechin

Copy link
Copy Markdown
Member

Fixes #2575.

Root cause

A column layout's sync category embeds the connection UUID, the database name and the table name, each percent-encoded with .alphanumerics as the only allowed set (TableScope.storageComponent). On SQLite the database name is a file path, so every /, -, . and _ becomes three characters and the category has no bound at all. SyncRecordType.recordName(for:) then concatenated it onto Settings_ and handed the result to CKRecord.ID(recordName:), which raises CKException past CloudKit's limit.

That exception unwinds a Swift async frame, which leaves the concurrency runtime inconsistent, so the app segfaults seconds to a minute later in swift_task_isCurrentExecutorWithFlagsImpl from a call site that varies per crash. The dirty flag is on disk, so the next launch retries and crashes again.

Measured, not assumed

scripts/check-cloudkit-record-name-limit.sh binary-searches the real CloudKit framework for the limit and fails when it disagrees with SyncRecordName.maximumLength, so a future SDK change re-measures rather than trusting a transcribed number. What it and the exploratory probe found:

input result
255 ASCII accepted
256 ASCII CKException: recordName (…) is too long
250 é (250 UTF-16 units, 500 UTF-8 bytes) accepted
128 emoji (256 UTF-16 units, 128 characters) raises
héllo, テーブル accepted
"" raises (recordName can not be empty)

So the limit counts UTF-16 code units, not characters and not bytes, and the documented "ASCII only" is not enforced. Non-ASCII table names were never a second crash path.

The fix

SyncRecordType.recordName(for:) is the one function both mappers call, so the bound goes there. A name that already fits is returned unchanged, byte for byte, so every record that reached iCloud keeps its identity and nothing migrates. A name that would exceed the limit carries sha256-<digest of the identifier> instead, which is deterministic, so two Macs land on the same record. Names that would have exceeded the limit cannot exist in iCloud today, because creating one crashed.

Pull is unaffected: SyncRecordMapper.settingsCategory(from:) already reads the category out of the record's category field rather than out of its name.

A stuck user self-heals on update. The dirty entry is still on disk and now pushes under a bounded name.

The trap that came with it

performPush used to recover a local identifier by parsing it back out of the returned record name. With a digest that returns the hash, so the dirty flag would never clear and the same record would push on every sync forever. The record name is an identity, not an encoding of the identifier, so the push now resolves a saved record through SyncRecordMapper.identities(for:in:), built from the dirty sets and tombstones it already read, before the push goes out. That also drops the push path's reliance on Favorite_ / FavoriteFolder_ / FavoriteTable_ longest-prefix disambiguation.

Scope of the class

AppSettings is the only record type with an unbounded identifier. FavoriteTablesStorage.syncId and FavoriteDatabasesStorage.syncId already SHA-256 their composite keys for exactly this reason; column layout is the one that did not. Everything else is a UUID. The iOS target only syncs connections, groups and tags, all UUID-keyed, and picks up the same bound through the shared transport.

Built and tested

  • verify.sh generate, verify.sh build, verify.sh lint: PASS
  • swift test --package-path Packages/TableProCore: full package suite green
  • verify.sh test SyncRecordIdentityTests ColumnLayoutSyncTests SyncChangeTrackerTests SyncScopeTests SyncCoordinatorTokenExpiryTests FavoriteDatabaseSyncTests: PASS
  • scripts/check-cloudkit-record-name-limit.sh: PASS (declared 255, measured 255)

New coverage:

  • SyncRecordTypeTests: a name at exactly 255 is unchanged, one unit past it is shortened, the count is UTF-16 units, shortening is deterministic, two long identifiers stay distinct, and a wrangler-length SQLite category fits.
  • ColumnLayoutSyncTests: the real ColumnLayoutTableKey for a long SQLite path produces a record name CloudKit accepts. Without the fix the same key measures over 255.
  • SyncRecordIdentityTests: a shortened name no longer carries its category, and the identity map still resolves it.
  • SyncRecordNameConstructionTests: a source scan proving nothing outside the two mappers constructs a CKRecord.ID, the same shape as the existing SyncMapperFieldAccessTests gate.

No UI change, so no screenshots and no TableProUITests automation: the whole fix is in the sync layer and is covered by unit tests.

Review

Codex read the diff cold. Its review pass returned no findings. Its adversarial-review pass returned two, both verified as pre-existing and left out of this PR:

  • AppSettings tombstones are never pushed as deletions. Real, and it predates this change: every other record type appends its tombstones to the delete list, AppSettings does not, so a cleared column layout is never removed from iCloud and a full fetch restores it. Codex's added point is fair, that an over-long record could not reach iCloud before this fix, so the gap now reaches one more group of users. It is still not a six-line addition: ColumnLayoutPersister.clear followed by a later save on the same table leaves one identifier both dirty and tombstoned, so enqueueing settings tombstones today would delete a layout the user had just saved, in the same push. Closing it means making markDirty retire a tombstone, which changes behaviour for every record type, plus inbound deletion handling that a digest name cannot resolve on its own. That is its own change with its own two-device test.
  • A late save acknowledgement can clear a newer local edit. Also pre-existing, and Codex says so: the parse-based cleanup this replaces had the same race. The snapshot is weakly safer, because an identifier first marked dirty while the push is in flight is not in it and so is not cleared. The real fix is generation-versioned dirty state across every record type.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
@datlechin
datlechin merged commit c64cf8c into main Aug 31, 2026
5 checks passed
@datlechin
datlechin deleted the fix/cloudkit-record-name-length branch August 31, 2026 11:12
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.

Crash loop after resizing a column on a database with a long file path

1 participant