Hang Up, and It Rings Once More

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.

November 15, 2026
Harrison Guo
7 min read
Engineering Debugging

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 appWhat the gateway then pushedWhat 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
What I did in the app Decline
What the gateway then pushed “ended: missed”
What the app had nothing → placeholder ring
What I did in the app Hang up
What the gateway then pushed “ended”
What the app had nothing → placeholder ring
What I did in the app Answer a WeChat call
What the gateway then pushed “handed over”
What the app had 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 asPushes the gateway still sends for that call
answeredanswerednot “answered” (the app knows); still “ended” later, if the app has not handed the call off
declined or hung updonenone
handed the call to the streamdone (an explicit release from the app)none
The app… answered
Recorded as answered
Pushes the gateway still sends for that call not “answered” (the app knows); still “ended” later, if the app has not handed the call off
The app… declined or hung up
Recorded as done
Pushes the gateway still sends for that call none
The app… handed the call to the stream
Recorded as done (an explicit release from the app)
Pushes the gateway still sends for that call none

“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!

Preview of future curated comments

This section will display user comments from various platforms like X, Reddit, YouTube, and more. Comments will be curated for quality and relevance.