Shipping & Infra5 min read

Starting the Call Closed the Stream That Call Needed

Rejected twice in review for the same reason. The attached screenshot showed a healthy call screen — timer running, end button live — and zero events arriving from the server. There were no errors at all.

#swift#realtime#testing#gotchas#reality-check
Two-panel diagram. Left shows a cleanup call at the top of the call entry point ending the event stream the call needs, with the timer still running on screen, zero events, and no errors. Right shows a per-call session factory plus a guard that flips a call to failed when the stream ends without an error while the state still says in-call.
The start button was the cut. And there were no errors.

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 call

A 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

  1. Do your test doubles share production's object lifetimes? A lazy double passes because it "hasn't been built yet." Every lifetime defect escapes through that gap.
  2. Beyond a "connected" flag, do you count how much flowed? Bytes or events — you need to be able to see a zero.
  3. 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.

Related