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

@DTS Engineer @ov_secure Hi guys, any changes here? Faced the same issue...

@DTS Engineer @ov_secure Hi guys, any changes here? Faced the same issue...

Bug number?

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

@DTS Engineer

It is a funny situation now... I started preparing a test project and wanted to catch sysdiagnose, to create a bug report, but the test project is working fine :) Even when the test app is killed - when I send SMS from another phone, it is received and saved.

My app still does not work, and I am trying to find the reason. Of course, my app is bigger and have additional launch time pressure, like initialising log file and configuring analytics, but it happens after started message monitoring... So I am cutting launch functionality now, but so far no success...

(Sorry for the late reply, I just got back from vacation.)

It is a funny situation now... I started preparing a test project and wanted to catch sysdiagnose, to create a bug report, but the test project is working fine :) Even when the test app is killed - when I send SMS from another phone, it is received and saved.

My app still does not work, and I am trying to find the reason. Of course, my app is bigger and has additional launch time pressure, like initialising log file and configuring analytics, but it happens after started message monitoring... So I am cutting launch functionality now, but so far no success...

Interesting. A few suggestions:

First off, I suspect that this is actually tied to either the order you’re creating TMK objects or some issue with how your threading is interacting with TMK, NOT timing. However, since you've got a working test app you can easily confirm that by simply delaying when your test app initializes with TMK as long as you want. There will be a point where that delay will matter, but I suspect you'll find that it's long enough (probably between 5-> ~30s?) that it can't be a factor in your main app.

Switching to the main app side, my favorite "trick" here is to focus ENTIRELY on the specific problem you’re trying to understand and ignore EVERYTHING else, particularly whether or not your app actually still "works". It doesn't matter if your app can't make calls or crashes 2s after you initialize TMK. All you care about is whether you dropped messages or not. I generally start by duplicating the entire project and then quickly start disabling/modifying large chunks of my app (in this case, the initialization process), testing frequently to see if whether or not the problem is still happening. The key here is to work fast and take lots of shortcuts.

For example, if your UI can't initialize without data from a subcomponent you want to disable, then you don't bother trying to make it "right" but instead just disable the UI component as well or cripple the subcomponent so that it returns fixed data without actually doing any of what it was "supposed" to do. The key here is to work quickly and test frequently, without thinking too much about "why" any given change would/would not matter. What I've found from experience is that you'll be doing through this process and things will suddenly work, often when a completely unexpected component was disabled. You can then "backup" more slowly, first undoing your most recent changes (so the problem reappears) and then working through the code you reenabled until you find what actually matters. Once you've figured that out, then you go back to your "real" project and actually implement a real fix.

What I've found from experience is that the normal inclination to not break your app can really get in the way of the key goal of simply finding out what the problem is. The focus tends to shift from "what is the problem?" to "will this tweak fix the problem?", which often ends up wasting lots of time fairly looking for small tweaks and/or speculating about what the problem might be.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

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