Skip to content

fix: make layout observation lifecycle thread-safe - #814

Open
marandaneto wants to merge 4 commits into
mainfrom
investigate/issue-806-layout-lifecycle
Open

fix: make layout observation lifecycle thread-safe#814
marandaneto wants to merge 4 commits into
mainfrom
investigate/issue-806-layout-lifecycle

Conversation

@marandaneto

@marandaneto marandaneto commented Sep 10, 2026

Copy link
Copy Markdown
Member

💡 Motivation and Context

Converts this reproduction-only PR into a production lifecycle fix. The reproductions are now passing regression tests; no intentionally failing tests remain.

Related to #806, but these confirmed lifecycle defects do not establish the cause of the customer's production NSISEngine crash. The diagnostic warning in #815 and run-loop proposal in #813 remain separate work. This preserves synchronous UIKit forwarding on its original calling thread; it does not suppress background layout.

Changes

  • Eagerly initialize the callback publisher instead of racing Swift's lazy initialization.
  • Serialize and coalesce subscriber-count observer delivery, reconciling the latest count without holding callback-state locks during observer execution. Concurrent and reentrant observers cannot overlap or apply an older count after the final current count.
  • Bound subscriber-count delivery to 32 observer calls per batch. Queue pending reconciliation on a private serial queue while retaining notification ownership, so sustained churn cannot keep the initiating thread in an unbounded drain. This bounds call count, not the duration of arbitrary observer code.
  • Replace exchange-based forwarding with hooks that capture immutable original IMPs. A layout dispatch that retained a hook before unsubscribe can still forward safely after uninstall, without recursive alias lookup.
  • Cache hooks per original IMP and retain their block implementations for process lifetime, since in-flight dispatches and other swizzlers may retain those function pointers. Ordinary restarts reuse the same hook.
  • Preserve an observed newer swizzler during teardown and avoid wrapping its retained chain again. An active hook token prevents retired hooks inside a different current chain from duplicating notifications.
  • Use dedicated test view classes so runtime regression fixtures do not interfere with the shared publisher or other suites.

Third-party swizzlers still need to honor their forwarding/restoration contracts. This cannot coordinate arbitrary other SDKs concurrently overwriting the same selector. The warning in #815 will need adapting to the new hook implementation before merging on top of this change.

💚 How did you test it?

Reproduction-first validation confirmed stale count delivery, a hasSwizzled TSan race, multiple callback objects from concurrent first access, stack-overflowing forwarding after unsubscribe, and clobbering a newer swizzler before the fixes. A retained-old-hook regression also caught duplicate notifications during implementation.

Validation Result
Focused lifecycle suite with Thread Sanitizer Pass, three repetitions/relaunches; includes 5,000 rounds of eight concurrent retained subscriptions
Reentrant and bounded-delivery regressions Pass: 32 synchronous calls, deferred reconciliation to zero/nonzero counts, 257 calls across multiple batches, no overlapping observers
make test Pass: 793 Swift Testing tests, plus XCTest
make testOniOSSimulator Pass: 190 XCTest tests and 919 Swift Testing tests
SDK targets via make build Pass: iOS, macOS, Mac Catalyst, tvOS, watchOS, visionOS
make format, make lint, git diff --check Pass
make build SDK and platform examples passed; external-client example blocked by checkout/package identity mismatch described below

Full build stops at ExternalSDK-iOS: the example expects package identity posthog-ios, while the isolated checkout is named posthog-ios-issue-806-lifecycle. Subsequent CocoaPods examples were not reached. No unrelated build configuration was changed.

Autoreview command: $HOME/.pi/agent/skills/autoreview/scripts/autoreview --mode branch --base origin/main --prompt-file autoreview-scope.md. One test-ordering finding was rejected after checking the actual code: this test uses only zero-interval trailing subscriptions, whose invokeTrailing directly enqueues on main before the continuation barrier. It does not traverse the background leading-throttle queue. The bundle-only reviewer could not see that unchanged implementation. No accepted review blockers remain.

The bounded-delivery follow-up (5826de1b0) passed isolated autoreview with no findings: $HOME/.pi/agent/skills/autoreview/scripts/autoreview --mode commit --commit HEAD --prompt-file autoreview-bounded-scope.md. Its tests first failed against the unbounded loop, then passed along with the existing lifecycle tests under Thread Sanitizer across three repetitions/relaunches.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed. Internal lifecycle behavior is documented above and in code.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Added a patch changeset: .changeset/stable-layout-lifecycle.md.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Manoel requested implementing the lifecycle fixes in this existing PR worktree and pushing to this PR. Pi used reproduction-first tests, file edits, make-based validation, Thread Sanitizer, GitHub CLI, and isolated Pi autoreview. Changes remain within layout-hook lifecycle and throttled subscriber-count delivery; no run-loop architecture or public API changes are included. Human review is required.

Keep this as a reproduction-only draft. The stale-count test intentionally fails, and Thread Sanitizer detects the existing hasSwizzled race. No production fix is included.
@marandaneto marandaneto changed the title test: reproduce layout publisher lifecycle races fix: make layout observation lifecycle thread-safe Sep 10, 2026
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-09-10 09:31:32 UTC
Duration: 223413ms

✅ All Tests Passed!

45/45 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 2816ms
Format Validation.Event Has Uuid 388ms
Format Validation.Event Has Lib Properties 2756ms
Format Validation.Distinct Id Is String 2753ms
Format Validation.Token Is Present 2717ms
Format Validation.Custom Properties Preserved 2760ms
Format Validation.Event Has Timestamp 242ms
Retry Behavior.Retries On 503 11734ms
Retry Behavior.Does Not Retry On 400 2232ms
Retry Behavior.Does Not Retry On 401 4717ms
Retry Behavior.Respects Retry After Header 7706ms
Retry Behavior.Implements Backoff 21288ms
Retry Behavior.Retries On 500 8857ms
Retry Behavior.Retries On 502 8733ms
Retry Behavior.Retries On 504 8837ms
Retry Behavior.Max Retries Respected 21656ms
Deduplication.Generates Unique Uuids 2874ms
Deduplication.Preserves Uuid On Retry 8793ms
Deduplication.Preserves Uuid And Timestamp On Retry 16217ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7678ms
Deduplication.No Duplicate Events In Batch 2846ms
Deduplication.Different Events Have Different Uuids 2910ms
Compression.Sends Gzip When Enabled 2729ms
Batch Format.Uses Proper Batch Structure 2754ms
Batch Format.Flush With No Events Sends Nothing 203ms
Batch Format.Multiple Events Batched Together 2763ms
Error Handling.Does Not Retry On 403 4763ms
Error Handling.Does Not Retry On 413 4674ms
Error Handling.Retries On 408 9216ms

Feature_Flags Tests

16/16 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 2769ms
Request Payload.Flags Request Uses V2 Query Param 2719ms
Request Payload.Flags Request Hits Flags Path Not Decide 2701ms
Request Payload.Flags Request Omits Authorization Header 2704ms
Request Payload.Token In Flags Body Matches Init 2772ms
Request Payload.Groups Round Trip 2785ms
Request Payload.Groups Default To Empty Object 2764ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It 2733ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 2766ms
Request Payload.Disable Geoip Omitted Defaults To False 2635ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 2697ms
Request Lifecycle.No Flags Request On Init Alone 54ms
Request Lifecycle.No Flags Request On Normal Capture 2756ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 5559ms
Request Lifecycle.Mock Response Value Is Returned To Caller 2786ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 2959ms

@marandaneto
marandaneto marked this pull request as ready for review September 10, 2026 09:16
@marandaneto
marandaneto requested a review from a team as a code owner September 10, 2026 09:16
@marandaneto

Copy link
Copy Markdown
Member Author

@ioannisj or @turnipdabeets can you help me testing this out? its very specific and we dont want to break anything, burnt quite some time figuring this out already

@greptile-apps

greptile-apps Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "fix: bound subscriber count notification..." | Re-trigger Greptile

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