Skip to content

Remove XCTest observer on the main thread during framework unload#291

Open
ypopovych wants to merge 1 commit into
mainfrom
fix/observer-main-thread-teardown
Open

Remove XCTest observer on the main thread during framework unload#291
ypopovych wants to merge 1 commit into
mainfrom
fix/observer-main-thread-teardown

Conversation

@ypopovych

Copy link
Copy Markdown
Contributor

Problem

XCTestObservationCenter.removeTestObserver (like addTestObserver) is main-thread-only. The SDK's observer lifecycle is asymmetric:

  • Added from the load-time C constructor __AutoLoadHooklibraryLoaded()start()addTestObserver. Constructors run at dylib load = main thread → fine.
  • Removed from the unload-time C destructor __AutoUnloadHooklibraryUnloaded()stop()removeObserver()removeTestObserver. Nothing hopped to the main thread.

The destructor's thread depends on who calls exit():

Runner exit() caller Thread Result
Classic xctest (Xcode / iOS-sim) after the run loop main ✅ fine
swift-testing / swift test Runner.main async continuation Swift Concurrency background thread 💥 crash

So under the swift-testing runner, teardown raised:

*** Terminating app due to uncaught exception 'NSInternalInconsistencyException',
    reason: 'Test observers can only be registered and unregistered on the main thread.'
   -[XCTestObservationCenter removeTestObserver:]  ←  __AutoUnloadHook

Pre-existing; independent of any OpenTelemetry work.

Fix

removeObserver() now removes on the main thread — directly when already on it, otherwise via DispatchQueue.main.async. The hop is async on purpose: a synchronous hop can deadlock during process teardown if the main thread is already finalizing, and if the block never runs because the process exits first, that's harmless (the observer only needs detaching while the runner is alive).

🤖 Generated with Claude Code

`XCTestObservationCenter.removeTestObserver` is main-thread-only. The
observer is added from the load-time constructor (`__AutoLoadHook`, always
main) but removed from the unload-time destructor (`__AutoUnloadHook`),
whose thread depends on who calls `exit()`. The classic `xctest` runner
exits on the main thread, but the swift-testing runner exits from a Swift
Concurrency continuation on a background thread — so `removeTestObserver`
ran off the main thread and raised
`NSInternalInconsistencyException: "Test observers can only be registered
and unregistered on the main thread."` at process teardown.

`removeObserver()` now removes on the main thread: directly when already
on it, otherwise via `DispatchQueue.main.async`. The hop is async on
purpose — a synchronous hop can deadlock during teardown if the main
thread is already finalizing, and if the block never runs because the
process exits first that's harmless (the observer only needs detaching
while the runner is alive).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ypopovych ypopovych requested review from a team as code owners July 10, 2026 11:13

@datadog-datadog-prod-us1 datadog-datadog-prod-us1 Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Datadog Autotest: PASS

More details

This PR fixes a critical crash in the Swift Testing runner by ensuring XCTest observer removal happens on the main thread. The SDK's observer lifecycle was asymmetric — added on main thread during dylib load, but removed during unload from a background thread under swift-testing, causing NSInternalInconsistencyException. The fix adds a thread check: direct removal on main thread, async dispatch to main queue from background threads. State transitions synchronously, preventing deadlocks during process teardown. The change is minimal, well-documented, and handles all three execution paths safely (main thread, background thread, process exit before async block runs).

Was this helpful? React 👍 or 👎

📊 Validated against 4 scenarios · Open Bits AI session

🤖 Datadog Autotest · Commit a47125c · What is Autotest? · Any feedback? Reach out in #autotest

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