A realtime voice app was rejected in review for the second time on the same grounds. The note, in substance: the app did not respond when a call was attempted.
I opened the attached screenshot. The call screen was up and healthy. End button, elapsed timer, both alive. Only the transcript area was empty, with a "take your time" hint showing.
The connection had succeeded. And zero events were arriving from the server.
In your app, is "connected" evidence that data is actually flowing?
A finished stream never comes back
The cause was stream lifetime.
The session object builds its stream once, in init. And stop() calls finish() on that continuation. A finished AsyncStream never yields again, no matter how many times you reconnect. Later yields are silently dropped and for await returns immediately.
In the commit just before, I had fixed a "cleanup is missing on the failure path" problem. The way I fixed it: put the cleanup at the top of the begin() entry point.
func begin() async {
session.stop() // ← added to fix missing cleanup on failure paths
audio.stop()
...
}But the model held one session instance. So:
The moment the first call starts, the stream that call needs is closed.
The symptom is bad in a specific way
Audio, transcript, and speech detection all vanish — and there is no error.
The state still reads "in call." Heartbeats keep going out. So usage time just burns. It reproduces 100% of the time, on any device.
And that is exactly the screen the reviewer saw. An empty call with a running timer.
The parent app didn't have this bug
This app is a fork. I checked the parent. (A different realtime defect in the same app is in the time the app interrupted itself.)
The parent builds sessions through a factory from the start:
let makeVoice: () -> any VoiceSession // a new session per callA new session per call means a new stream per call. The same cleanup call causes no harm there.
That shape never flowed into the two forks. Which gives a rule: fixes flow from parent to fork, but a fork inheriting the parent's good shape is not automatic. Bug fixes get propagated deliberately. Design shapes get propagated by nobody.
Why the tests were green is the worse part
This defect should have been caught by tests. It wasn't, and the reason matters.
The test double built its stream as a lazy var / computed property. So at the moment begin() called stop() at the top, the double hadn't even created the stream yet. Nothing happened, and the test went green.
Production, at that same moment, ended its stream permanently.
If the double's lifetime differs from production's, lifetime defects cannot be caught in principle. I moved five doubles across three apps to init-time creation.
Empty doubles are the same trap. A double whose events is an already-finished stream represents "the socket is dead." That is not a valid premise for a healthy-call test.
What would you do?
An app reports connected but no data arrives. Where do you put the guard?
- Call cleanup at the entry point — what I did. With any reused object, the start becomes the cut.
- Watch the state value more closely — the state said "in call." State is not evidence of life.
- Count the bytes that flowed — count audio bytes per call and a zero-byte call surfaces instantly.
Three layers of defense
- A new session per call (factory). And pass "whose stream is this" into the pump as an argument. Re-reading the model field lets an old pump grab the new stream.
- Make dead calls visible. If the stream ends without an error while the state still says "in call," flip it to failed and settle it closed. With this in place, review would have seen an error screen, not a rejection.
- Throw on reuse. A second
connect()raises instead of dying quietly.
Verification wasn't the state value — it was audio bytes per call. On the same device family as review: 19,200 bytes on call one, 19,200 on call two. Before the fix both were zero.
Three checks
- Do your test doubles share production's object lifetimes? A
lazydouble passes because it "hasn't been built yet." Every lifetime defect escapes through that gap. - Beyond a "connected" flag, do you count how much flowed? Bytes or events — you need to be able to see a zero.
- Do you promote an error-free stream ending to a failure? A quiet end looks exactly like a clean end. Only crossing it with the state separates them.
The honest part
I found this after burning two review cycles. The first rejection was a different defect in the same function. So I edited that function twice and missed this both times.
And the second smoke test only checked state values, which is why it passed the defect through. My verification shared the same illusion as my bug — that a state of "in call" means a call is happening.