
Hang Up, and It Rings Once More
My app showed incoming calls for a phone at home through Apple VoIP pushes and CallKit. Decline one, and a split second later it rang again and vanished. The cause is a PushKit rule: every VoIP push must become a reported call. The gateway's 'call ended' push arrived after the app had already ended the call, so the app had to report a call it no longer had. The fix was to stop sending pushes the app did not need.
Table of Contents
My second iPhone lives at home, and my main phone rings when it rings (the setup). The gateway at home sees the incoming call and sends my main phone an Apple VoIP push; the TracePort app turns that into a normal incoming-call screen with CallKit. When the call stops ringing at home, the gateway sends a second push, “ended”, and the app takes the call screen down.
It worked. Then I noticed something on almost every call I handled myself: decline, and a moment later the phone rang once more, for a fraction of a second, and the call screen vanished. The same after hanging up. The same after answering a WeChat call.
The rule that makes it happen
Since iOS 13, a VoIP push is not just a message. Apple’s PushKit documentation is explicit: when the app receives one, it must report an incoming call to CallKit before it finishes handling the push. If it does not, iOS terminates the app, and if it keeps not doing so, iOS may stop delivering VoIP pushes to it at all.
That rule is there so apps cannot use VoIP pushes as a free background wake-up. It has an awkward consequence for the “call ended” message. If the app still has that call on screen, fine: report the end. If the app has already ended it, the app holds a push that says “end call X”, has no call X, and is still obliged to report a call. The only compliant move is to report a placeholder call and end it immediately. iOS shows it. That was the extra ring.
My handler did exactly that, on purpose, to stay within the rule:
// an "end" push for a call we no longer have: PushKit still wants a reported call
NSUUID *placeholder = uuid ?: [NSUUID UUID];
[_provider reportNewIncomingCallWithUUID:placeholder update:update completion:^(NSError *error) {
if (error == nil) {
[self->_provider reportCallWithUUID:placeholder endedAtDate:nil reason:CXCallEndedReasonRemoteEnded];
}
completion();
}];
The timeline in the logs
The app keeps its own log file, so I could line the two sides up. Declining a call, from the app’s log (numbers redacted):
14:51:30.444 Calls: hangup → Hangup +86… ← I tap Decline; the gateway hangs up at home
14:51:30.874 Calls: push { type = end; why = missed; } ← 430 ms later
The gateway did what it was built to do: the call at home went from ringing to gone, so it pushed “ended”. By then the app had finished the call itself, 430 ms earlier. Every path where the app acts first had the same shape:
| What I did in the app | What the gateway then pushed | What the app had |
|---|---|---|
| Decline | “ended: missed” | nothing → placeholder ring |
| Hang up | “ended” | nothing → placeholder ring |
| Answer a WeChat call | “handed over” | the call already passed to the stream → placeholder ring |
There was also a second, smaller version of the same thing. The cellular side of the gateway sent “ended” twice for one call: once when the call’s state became disconnected, and again when it was removed from the list a moment later. The second one was always for a call the app no longer had.
Why I didn’t just ignore the push
The tempting fix on the app side is: if an “end” push names a call I have already ended, do nothing. That breaks the PushKit rule, and the punishment is silent: the app gets terminated, and after a while VoIP pushes stop arriving, which means the phone stops ringing for real calls. Trading a cosmetic flash for that is a bad deal.
So the fix had to be on the sending side: do not send a VoIP push the app does not need.
Who knows what
The gateway can know, because every one of those app actions goes through it. Decline, hang up and answer are requests to the gateway’s API before anything happens at home. So the gateway records the app’s action on that call first, then acts:
| The app… | Recorded as | Pushes the gateway still sends for that call |
|---|---|---|
| answered | answered | not “answered” (the app knows); still “ended” later, if the app has not handed the call off |
| declined or hung up | done | none |
| handed the call to the stream | done (an explicit release from the app) | none |
answereddonedone (an explicit release from the app)“Record first, then act” matters because of timing. The cellular call is answered or hung up by one gateway process (the bridge, through oFono), and the state change that triggers a push is seen by another (the hands-free service). If the mark were written after acting, the state change could win the race and the push would go out anyway. So the mark goes into a small file under /run before the bridge touches the call, and the hands-free service checks it before every end push:
/// Should this end go to the app? Not when the app already knows.
pub fn app_needs_end(stage: Option<&str>, why: &str) -> bool {
match stage {
Some("done") => false,
Some("answered") => why != "answered",
_ => true,
}
}
The one case that still needs an end push is deliberate: I answer on the lock screen, the system call stays up, and the person at the other end hangs up before I open the app. Then the app still shows a call, and the “ended” push takes it down properly.
The rest of the fix:
- One end push per call. The cellular watcher remembers that it already sent the end when the call went to disconnected, and stays quiet when the call is removed.
- No “handed over” push for WeChat. When the app answers, it already knows the call is answered; the gateway no longer tells it.
- No ringing a call I just ended. While I am watching the phone’s screen, the app also polls the gateway once a second, in case a push is slow. Right after I decline, the gateway can still list the call as ringing for a few hundred milliseconds. The app now remembers calls it ended in the last minute and does not ring them again.
After the change, the gateway log for a declined call reads:
call 97F07EB5…: Hangup from the app
call 97F07EB5…: missed, not pushed (TracePort already knows)
and the phone stays quiet.
While I was in those logs
The same app log showed something else. That decline had first been sent to the gateway’s Tailscale address. The gateway received it and hung up, but the reply never came back, and the app waited the full 15-second timeout before trying the gateway’s LAN address, which answered “no such call”. For a decline nobody notices, because the call screen closes immediately. For an answer, the stream would have waited 15 seconds to open.
The app remembered the same gateway under two addresses and tried them one after another. It now sends to all of them at once and takes the first success. At home, the LAN answers almost instantly; away, the LAN does not answer and Tailscale does, without a timeout first.
What I took from it
- Platform rules turn messages into obligations. A VoIP push is not information the app may ignore; it is a call the app must report. Design the server’s messages with that cost in mind.
- Don’t tell a client what it told you. The app’s own actions came back to it as pushes. Whoever initiates a state change should not be notified of it.
- Record intent before acting across processes. A mark written after the action loses races to the side effects it is meant to suppress.
- Keep logs on both ends. The fix was obvious only once the app’s log and the gateway’s log were next to each other with a 430 ms gap between them.
🎧 More Ways to Consume This Content
I occasionally advise small teams on backend reliability, Go performance, and production AI systems. Learn more: /services
Comments
This space is waiting for your voice.
Comments will be supported shortly. Stay connected for updates!
This section will display user comments from various platforms like X, Reddit, YouTube, and more. Comments will be curated for quality and relevance.
Have questions? Reach out through:
Want to see your comment featured? Mention us on X or tag us on Reddit.