TelephonyMessagingKit drops first SMS at cold launch — race between client XPC handler registration and server pending flush

Hi all,

I'm the developer of OV Message, an end-to-end encrypted SMS messaging app already shipped on Google Play (Android, where it natively encrypts SMS content). The iOS port aims to be the default carrier-messaging app, handling SMS, MMS, and RCS through TelephonyMessagingKit with the com.apple.developer.carrier-messaging-app entitlement under the EU programme. While testing the cold-launch flow on iOS 26.x, I've hit a reproducible bug that silently drops the first SMS/MMS/RCS that wakes the app, and I'd like to confirm whether other devs working with this API see the same.

The bug

When a default carrier-messaging app is force-killed and a message arrives, iOS correctly:

  1. Routes the message via CommCenter (IMS in my case — SFR France)
  2. Wakes the app in background (state = .background at didFinishLaunchingWithOptions)
  3. Acquires a TelephonyMessaging runningboard assertion on the app

But CommCenter then pushes the pending message via XPC before the client TMK library has finished registering its messageHandlersByID dictionary. Result: client responds Received unhandled request, server logs TMKXPCError Code=2, message is dropped, never delivered to for await in incomingMessageNotifications. Subsequent messages (with the app warm) work fine.

Native log sequence (from idevicesyslog with the Telephony logging profile)

T+0.000  CommCenter: SMS arrives via IMS (k3GPP)
T+0.003  CommCenter: Default app is set to com.example.app
T+0.004  CommCenter: Attempting to launch and acquire process assertion
T+0.083  CommCenter: Notifying SMS message received, target: bundleID=...
T+0.085  CommCenter(TMK): There are no client connections matching, pending message
[~125 ms — app boots]
T+0.128  App(TMK): Configuring connection
T+0.128  App(TMK): Pinging remote end
T+0.130  CommCenter(TMK): Received new connection from PID
T+0.130  CommCenter(TMK): New incoming connection, flushing pending messages (1)   ← server flushes
T+0.130  App(TMK): Received unhandled request                                       ← client not ready
T+0.131  CommCenter(TMK): Failed to send pending message: TMKXPCError Code=2
T+0.132  App(TMK): Registered for IncomingMessageNotification (smsReceived)         ← ~2 ms too late

The race window between Pinging remote end (client) and Registered for IncomingMessageNotification (client) is 2–7 ms across my measurements. CommCenter considers the connection ready as soon as the ping completes, but the client library populates messageHandlersByID slightly after, so the dispatch fails.

Minimal reproduction

I built a ~50-line Swift app to confirm this isn't specific to OV Message. UIKit AppDelegate, single for await in TelephonyMessagingSession.shared.smsService.incomingMessageNotifications started in didFinishLaunchingWithOptions. No SwiftUI, no other modules, no Darwin notifications. Just TMK.

Steps:

  1. Build & install on iPhone iOS 26.x with carrier-messaging-app entitlement (auto-provisioned in iOS 26)
  2. Settings → Apps → Default Messaging → select the test app
  3. Force-kill, then send 2 SMS in rapid succession from another phone
  4. Wait 30 s, open the app — log shows only the 2nd SMS

Same result: the 1st SMS is gone. I've reproduced this consistently dozens of times.

Source code (Swift + xcodegen project.yml): https://gist.github.com/ovmessage/fbc529292a65222191bec6ce5e5a4275

What I've tried

  • Task.detached(priority: .userInitiated) to decouple the for await from main thread scheduling — no effect (race is internal to TMK lib, before our scheduling)
  • Pre-fetching cellularServices synchronously — no effect
  • Subscribing MMS + RCS in parallel — no effect
  • Direct XPCSession/xpc_connection_create_mach_service to com.apple.commcenter.tmk.xpc — Apple has marked these unavailable on iOS for 3rd-party apps (no public way to bypass the lib)

I've also done runtime introspection of the TMK framework via Mirror, which confirms the architecture: a single XPCConnection.messageHandlersByID dict shared by smsReceived, mmsReceived, rcsReceivedNotification — all four entries (incl. serviceStatusNotification) are populated after the XPC ping. So the same race affects SMS, MMS, and RCS equally.

Suggested fixes (Apple-side)

Either:

  1. Server (CommCenter): defer flushing pending messages until the client confirms its handlers are registered (extra XPC handshake message)
  2. Client (TelephonyMessagingKit): register messageHandlersByID entries before sending Pinging remote end, so they exist when the server starts flushing
  3. Buffer client-side: cache messages received before handler registration completes, dispatch on attach

Filed in Feedback Assistant

FB[YOUR_FB_NUMBER_HERE]

Question for fellow devs

If you're also building with carrier-messaging-app entitlement (Beeper, Google Messages on iOS, anyone in the EU programme), can you confirm whether you see the same race? Especially interested in whether:

  • It happens with non-IMS carriers (mine is SFR France, IMS-routed via SIP)
  • iOS 26.1 / 26.2 changed the timing
  • Anyone has found a workaround I haven't tried

Thanks.

The bug When a default carrier-messaging app is force-killed and a message arrives, iOS correctly:

If you haven't already, please file a bug on this and then post the bug number back here.

Anyone has found a workaround I haven't tried?

Maybe. Try calling "isConfiguredForCarrierMessaging" before you "touch" any of the service objects. If that still fails, then I'd use a task to briefly delay session creation a while after you called isConfiguredForCarrierMessaging.

There's actually infrastructure in place that's supposed to handle this issue. I'm not sure why it's failing, but my best guess is that this particular sequence means that the pending queue isn't set up properly. Calling isConfiguredForCarrierMessaging might give that infrastructure the head start it needs to avoid this issue.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Hi Kevin,

Thanks a lot for jumping on this — really appreciate it.

I tested the pattern you suggested in a fresh, isolated repro: read isConfiguredForCarrierMessaging first, then delay session creation via a Task.sleep before touching smsService.incomingMessageNotifications. I varied the delay across four values to be thorough.

Code shape (full source available, Swift, ~50 lines):

let session = TelephonyMessagingSession.shared
let configured = session.isConfiguredForCarrierMessaging          // PHASE 1
TMKLogger.shared.log("isConfiguredForCarrierMessaging=\(configured)")

Task.detached(priority: .userInitiated) {
    try? await Task.sleep(nanoseconds: kDelayMs * 1_000_000)      // PHASE 2
    let notifications = try session.smsService.incomingMessageNotifications  // PHASE 3
    for await notification in notifications {
        TMKLogger.shared.log("✅ SMS received: \(notification.message.content.body)")
    }
}

Test protocol on each delay:

  1. Settings → Apps → Default Messaging → TMKTest
  2. Force-kill the app
  3. Send two SMS in rapid succession from another phone
  4. Wait 30 s without touching the phone
  5. Open TMKTest, read the persistent log

iPhone 14 Plus, iOS 26.4.2, SFR France (IMS-routed SIP).

Empirical results

Delay1st SMS2nd SMSPhase 3 ready at
200 ms❌ lost✅ receivedT+0.2 s
500 ms❌ lost✅ receivedT+0.5 s
1000 ms❌ lost✅ receivedT+1.0 s
2000 ms❌ lost✅ receivedT+2.0 s

The 1st SMS is dropped identically across all four runs. The 2nd SMS arrives normally. Pattern is rigorously identical regardless of delay length.

A relevant detail

In every test, isConfiguredForCarrierMessaging returns true synchronously before the delay starts (1–19 ms after didFinishLaunchingWithOptions, never failing). If reading that flag is what arms the pending-queue infrastructure you mentioned, that infrastructure should already be in place by the time the delay runs and Phase 3 fires. Yet the message is still flushed to a connection that doesn't have its handlers registered yet — the native CommCenter logs continue to show TMKXPCError Code=2 and Received unhandled request regardless of how long we wait before touching the service.

This suggests the race isn't between "infrastructure armed" and "service touched", but rather inside the XPC handshake itself: messageHandlersByID on the client XPCConnection is populated after Pinging remote end returns, and CommCenter starts flushing pending messages on Received new connection from PID — that ~2–7 ms gap is what we keep losing the 1st message in. Delaying anything before smsService access doesn't move the XPC handshake — it just delays it later.

Question

In the runtime introspection I did earlier on the framework binary, I found a TelephonyMessagingKit.Messaging.Server type (mangled _$s21TelephonyMessagingKit0B0O6ServerC) with what appears to be a synchronous callback method setIncomingMessageHandler<A: Message>(@Sendable (XPCPeerMessage<A>) throws -> ()). A synchronous handler installed at session creation would sidestep the for await race entirely.

Is that an internal-only API for now, or is there any chance it's being considered for @_spi exposure to carrier-messaging-entitled apps in a future iOS 26.x point release? If not, would you have any other client-side angle to try, or is the right path here to wait for a server-side fix in CommCenter (option 1 in my original post: defer flushing until the client confirms handler registration)?

Thanks again for taking the time on this — happy to share the full TMKTest_Workaround repro project (xcodegen + 4 Swift files) if useful.

Best,

SO, before I talk about any details, the most critical thing here is that you must get a bug filed on this. See the end of this post for the details of exactly what I need in that bug report.

I'm happy to try and look for some kind of workaround, but the ultimate solution is a fix on our side. One thing to understand here is that the reason I'm asking you to file a bug isn't simply to note the problem but is actually to document the developer impact, which is one of the main factors we use to prioritize our work.

Yet the message is still flushed to a connection that doesn't have its handlers registered yet — the native CommCenter logs continue to show TMKXPCError Code=2 and Received unhandled request regardless of how long we wait before touching the service.

SO, one thing to clarify here is that CommCenter isn't acting "spontaneously". What's actually happening in the sequence above is that CommCenter was told to "flush its messages", which is what it then "did". This message here:

T+0.130  App(TMK): Received unhandled request                                       ← client not ready

...happens because the message router inside TMK didn't have a message handler in place for SMS messages. This message here:

T+0.132  App(TMK): Registered for IncomingMessageNotification (smsReceived)         ← ~2 ms too late

...is actually happening when THE handler is registered. In other words, the problem here is that TMK told CommCenter to flush before it was fully configured, NOT that CommCenter flushed too soon.

Similarly, this error:

T+0.131  CommCenter(TMK): Failed to send pending message: TMKXPCError Code=2

...is actually an error that TMK returned to CommCenter, NOT an error CommCenter itself generated.

That then leads to here:

In every test, isConfiguredForCarrierMessaging returns true synchronously before the delay starts (1–19 ms after didFinishLaunchingWithOptions, never failing).

The object that actually manages the XPC connection is a private object which is lazily initialized on access, so my hope was that kicking that initialization off earlier might mean that the pending message queue was in place before SMS delivery started.

I'm not sure exactly why it didn't work, but it's probably tied to the exact ordering of the initialization process across multiple threads. That leads me to my next step, which is to:

  • Set up your test project so that you're logging between every single call into TMK and make sure the logging is going to the system console.

  • Install the "Phone (General)" profile on your test device.

  • Reproduce the issue a few times on your test device. For each test, note the time you started and finished the test and then wait a few minutes between each test. (The goal here is to make sure there is a significant time gap between each test, so that the activity of one test doesn't overlap with the next test.)

  • Collect the sysdiagnose using the instructions found in the profile instructions.

  • Upload the sysdiagnose, your test code (so I can see exactly what and where you're logging), and the time log to a bug report, then post the bug number back here.

The sysdiagnose will let me see exactly how the event ordering and threading is happening, which is the best shot at a workaround.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

TelephonyMessagingKit drops first SMS at cold launch — race between client XPC handler registration and server pending flush
 
 
Q