EASession intermittently opens with an input stream that never delivers data, iOS 27

We use ExternalAccessory to talk to a smart card, i.e. a YubiKey 5Ci, over Lightning. On iOS 27.0, most EASession objects we open are unable to receive. NSStreamEventHasBytesAvailable never fires on the input stream and not one byte arrives, yet the session reports no problem anywhere.

Environment

iPhone 11, iOS 27.0 build 24A437 (public release), YubiKey 5Ci on Lightning, protocol com.yubico.ylp.

What we see on an affected session

both streams streamStatus        2 (NSStreamStatusOpen)
streamError on both              nil
NSStreamEventOpenCompleted       fires on both
hasSpaceAvailable before write   YES
write:maxLength:                 returns 18 of 18
NSStreamEventHasSpaceAvailable   fires
NSStreamEventErrorOccurred       never
NSStreamEventEndEncountered      never
NSStreamEventHasBytesAvailable   never fires
inputStream.hasBytesAvailable    false on every poll over 10 s

Everything reports success. Only the reply is missing.

Behaviour

A healthy session replies in single digit milliseconds. An affected one returns nothing at all, ever. The condition is set when the session is created and never changes. Opening another session on the same accessory is an independent roll of the dice. Quitting and relaunching the app does not help. Only physically detaching and reattaching the key does, after which connectionID increments and the next session works.

15 of 25 sessions affected on 24A437. Also 9 of 20 on the 24A5430a beta, so it predates the public release.

Already ruled out

Not a late reply, we waited 10 s. Not a missed event, we polled hasBytesAvailable directly about 200 times per failure. Not a leaked session, teardown is instrumented and every session deallocates exactly once. Not the hardware, another app on the same phone and key reads its serial and firmware every time, and that app opens one session and never closes it. The USB C interface of the same key, via CryptoTokenKit, never fails.

Questions

  1. Is there any supported way to tell at creation time that an EASession input stream will not deliver data? Nothing we can read distinguishes the two cases.

  2. Is repeatedly creating and releasing EASession objects for the same accessory and protocol supported, and is any teardown step needed beyond clearing the delegates, closing the streams, removing them from the run loop and releasing the session?

  3. Is there a supported way to recover without asking the user to unplug the accessory?

On iOS 27.0, most EASession objects we open are unable to receive. NSStreamEventHasBytesAvailable never fires on the input stream, and not one byte arrives, yet the session reports no problem anywhere.

Have you filed a bug on this and, if so, what is the bug number?

  1. Is there any supported way to tell at creation time that an EASession input stream will not deliver data? Nothing we can read distinguishes the two cases.

No. I'm not sure what actually went wrong, but I suspect it happened in the lower-level system, outside your app’s visibility or control.

  1. Is repeatedly creating and releasing EASession objects for the same accessory and protocol supported, and is any teardown step needed beyond clearing the delegates, closing the streams, removing them from the run loop, and releasing the session?

Yes, this should "work“; however, I wouldn't expect it to get your accessory working. If the problem happened in the lower-level system, then you'll generally end up just creating and destroying connections to the same object/infrastructure without anything actually changing.

  1. Is there a supported way to recover without asking the user to unplug the accessory?

This is the only option I'd consider reliable.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Thanks Kevin, that's helpful, and it matches what we're seeing.

Filed as FB24887479.

On your three points:

Detecting it at creation. Understood, and that matches our result. We tried every property on both streams and found nothing that separates a session that will receive from one that won't, so we're not holding out hope there.

The fault being in the lower level system. Agreed, and one thing supports it that we hadn't mentioned. When we release an affected session and open a new one on the same accessory, EAAccessory.connectionID does not change, yet the new session receives normally. Nothing about the physical connection or the accessory changed, so whatever is wrong is state that belongs to the session, below where we can see it.

Recreating sessions. You said you wouldn't expect it to help, so this may be worth knowing: in our testing it does, consistently. The replacement session answers the same command in about 2 ms right after the previous one couldn't receive it at all, and in one batch it fired 9 times and recovered 9 times. That's what makes us doubt the recovery is coincidental.

On unplugging being the only reliable option, that's the part we can't take. This is smart card middleware used for authentication, so asking the user to pull and reinsert the key mid operation isn't something we can ship.

So the question that decides it for us: does opening a new EASession actually rebuild the per session state on the system side, or are we winning a race that could stop working in a future release?

Recreating sessions. You said you wouldn't expect it to help, so this may be worth knowing: in our testing it does, consistently. The replacement session answers the same command in about 2 ms right after the previous one couldn't receive it at all, and in one batch it fired 9 times and recovered 9 times. That's what makes us doubt the recovery is coincidental.

Interesting. Throwing out a guess, when are you creating your sessions/manager? And, in particular, is it "early" in your app launch cycle? For example, is it initiated by static variable initialization?

The issue here is the difference between:

  1. "a process"

  2. "an app"

When your app initially starts executing, it starts as an isolated process that's independently executing code with minimal "connection" to the broader system. It transitions to "an app" by calling into our APIs, which is what then allows it access to the screen as well as the broader range of system services. This transition dynamic is less obvious in our more modern APIs, but triggering this transition is what functions like NSApplicationMain and UIApplicationMain actually "do".

In any case, the problem with very early initialization is that many of our system components assume they're talking to "an app" and may not work properly if your app isn't "really" an app. Adding to the fun, the line I'm drawing isn't actually as clear-cut as it sounds. Our APIs tend to work by directly communicating with technology-specific daemon. So instead of the "base" API failing, you end up getting other bugs/failures caused by the daemon being unable to correctly reference your app, since it’s not TECHNICALLY an app "yet".

In any case, the solution here is to avoid interacting with any of our APIs until after app initialization has started.

Does opening a new EASession actually rebuild the per-session state on the system side?

Both? At the lowest levels of the system, the actual hardware objects are basically "static" and will only go away when the hardware goes away. However, higher-level objects are tied to the connection with your app, which means they'd be destroyed and recreated when your app breaks those connections by destroying the session/manager.

or are we winning a race that could stop working in a future release?

I can't really promise whether or not something will work in the future; however, to the extent that any kind of "race" is involved, being "slower" by moving initialization later is nearly always the right answer.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

EASession intermittently opens with an input stream that never delivers data, iOS 27
 
 
Q