Handling IP address change
Tip
PJSUA-LIB readers — symbol equivalents are listed at the bottom of this page.
This article describes how to handle IP-address changes in PJSIP
applications, with a focus on the mobile case (Wi-Fi / cellular
hand-off, AP roaming, VPN connect/disconnect). It targets PJSIP 2.7
and later, where the high-level API
pj::Endpoint::handleIpChange() was introduced (PJSUA-LIB:
pjsua_handle_ip_change()).
PJSIP does not detect IP changes on its own; the application observes the change via platform APIs and then calls into PJSIP to have it clean up. Detection pointers are at the end of this page; the bulk of the article is about the cleanup.
Problem description
IP-address change and access-point reconnection are common on mobile. Typical scenarios:
Wi-Fi disconnect → cellular fallback
Cellular → Wi-Fi when re-entering coverage
Wi-Fi → Wi-Fi between two APs with different subnets
VPN connect / disconnect
In each case the local IP that PJSIP was using disappears or is replaced. Without intervention, this manifests as long send timeouts, stale registrations, and dropped calls — particularly painful when TCP/TLS transports are involved, because lingering connections bound to the dead interface can wedge the SIP path for tens of seconds before the OS gives up.
pj::Endpoint::handleIpChange() (PJSUA-LIB:
pjsua_handle_ip_change()) lets the application announce
“my IP changed; clean up” and have the library do the work
(transport restart, optional shutdown of stale transports, account
re-registration, optional re-INVITE / UPDATE on active calls). The
application is still responsible for detecting the change
(platform-specific, see below).
Important
Call handleIpChange after the new network connection is
established and a usable IP is available, not at the moment the
old interface goes down. Listener restart, transport reopen, and
re-REGISTER all need the new stack to be up — calling too early
means the steps fail and have to be retried by the application.
On a Wi-Fi-off → cellular-on transition, wait for the platform’s
“default network changed / reachable” event before invoking.
High-level flow
When pj::Endpoint::handleIpChange() is invoked, PJSUA-LIB
runs the following in order. Parameters live on
pj::IpChangeParam (global) and
pj::AccountIpChangeConfig (per-account, on
AccountConfig::ipChangeConfig).
Shut down all TCP/TLS transports (if
pj::IpChangeParam::shutdownTransportis set). This is the key step for mobile responsiveness — sockets still alive on the old interface won’t sit waiting for a remote FIN while the OS retransmits over a dead path. Added in #3781, defaulttrue. UDP is not covered here; UDP “transports” are the listener itself, handled by the next step.Restart the SIP transport listener (if
pj::IpChangeParam::restartListeneris set, the default). This rebinds the listening socket so it picks up the new IP. See Listener restart caveats below.Per account: shut down the registration transport (if
pj::AccountIpChangeConfig::shutdownTpis set on the account, defaulttrue). Even if the account shares its transport with another, the transport is force-closed — required on platforms where the kernel doesn’t deliver disconnect notifications promptly (iOS in particular).Re-register: send REGISTER with the new Contact URI, per
pj::AccountNatConfig::contactRewriteUse/pj::AccountNatConfig::contactRewriteMethod.Active calls: either hang up (
pj::AccountIpChangeConfig::hangupCalls) or refresh them with re-INVITE or SIP UPDATE (pj::AccountIpChangeConfig::reinviteFlags,pj::AccountIpChangeConfig::reinvUseUpdate). WhenreinviteFlagsis 0, neither is sent.
Tuning for fast switching (mobile)
On a mobile hand-off, the total time between “old IP gone” and “calls flowing on the new IP” is dominated by three things:
Stale TCP/TLS sockets on the old interface. A REGISTER or in-dialog request that lands on a still-open connection bound to the now-dead interface will queue / retransmit until the OS gives up — typically tens of seconds. Set
pj::IpChangeParam::shutdownTransporttotrue(the default since #3781) to force-close all TCP/TLS transports immediately.Stale UDP listener bound to the old IP. If you created the UDP transport with a specific bound address (a literal IP rather than
0.0.0.0/::), the socket can’t receive on the new IP.pj::IpChangeParam::restartListenerrebinds the listener — but as a side-effect it resets the bound address to wildcard (see Listener restart caveats).REGISTER on a dead transport. If your application calls
pj::Account::modify()during IP change to update account config (IPv6 preference, etc.), the default behaviour is to fire a REGISTER immediately — on the old transport, which is dead. Setpj::AccountRegConfig::disableRegOnModifytotrue(added in #3910) to suppress that, then letpj::Endpoint::handleIpChange()drive the re-REGISTER on the new transport.
For active calls, prefer UPDATE over re-INVITE when the peer
supports it: set
pj::AccountIpChangeConfig::reinvUseUpdate to true
(added in #3146). Both methods carry a new SDP offer / answer
with the post-change addresses; the win of UPDATE (RFC 3311) is
that it’s a generally lighter exchange than re-INVITE. PJSUA-LIB
falls back to re-INVITE if the peer didn’t advertise UPDATE in
Allow.
Note
Do you need a TCP/TLS listener at all? Mobile clients almost
never accept incoming SIP connections — they REGISTER and receive
on the same outbound connection. Two build-time macros in
config_site.h skip listener creation entirely (both default
0):
With listeners disabled, outbound TCP/TLS still works and the
listener-restart step on IP change is a graceful no-op (per
#3873). See the macro doxygen for the additional
pjsua_acc_config::contact_use_src_port /
pj::AccountNatConfig::contactUseSrcPort setting they
require. To remove TLS code entirely for footprint, set
PJSIP_HAS_TLS_TRANSPORT to 0 (TCP cannot be
disabled at build time).
A reasonable mobile-tuned configuration in PJSUA2 (set the
per-account knobs on the pj::AccountConfig you pass to
pj::Account::create() or pj::Account::modify()):
AccountConfig acc_cfg;
// ... existing config (id, registrar, credentials) ...
acc_cfg.regConfig.disableRegOnModify = true; // if you call modify()
acc_cfg.ipChangeConfig.shutdownTp = true; // default
acc_cfg.ipChangeConfig.reinvUseUpdate = true; // prefer UPDATE
account.modify(acc_cfg); // or pass acc_cfg to account.create()
// On the IP-change event, hand off to PJSUA-LIB:
IpChangeParam param; // defaults: restartListener=true,
// shutdownTransport=true
Endpoint::instance().handleIpChange(param);
PJSUA-LIB equivalent:
pjsua_ip_change_param ip_change_param;
pjsua_ip_change_param_default(&ip_change_param);
/* shutdown_transport and restart_listener already default to
* PJ_TRUE; shown here for clarity. */
ip_change_param.shutdown_transport = PJ_TRUE;
ip_change_param.restart_listener = PJ_TRUE;
/* On the account config: */
acc_cfg.ip_change_cfg.shutdown_tp = PJ_TRUE;
acc_cfg.ip_change_cfg.reinv_use_update = PJ_TRUE; /* prefer UPDATE */
acc_cfg.disable_reg_on_modify = PJ_TRUE; /* if you call modify() */
pjsua_handle_ip_change(&ip_change_param);
On bound_addr and OS routing
Binding a transport to a literal local IP via
pj::TransportConfig::boundAddress /
pjsua_transport_config::bound_addr indirectly pins the
outbound interface — the kernel routes by destination but is
constrained by the bound source, and each IP normally belongs to
one interface. Two caveats matter on mobile:
If the bound IP no longer exists on any interface after the IP change (the Wi-Fi case), the socket’s sends fail with
EADDRNOTAVAIL. If you setbound_addrexplicitly, you’re responsible for updating it to the new IP before callinghandleIpChange.On Android, the per-app default network is enforced above
bind()— binding to a non-default-network IP isn’t sufficient on its own. Pair it withConnectivityManager.bindProcessToNetwork(newNetwork)(Java side) so all subsequently-created sockets use that network.
For most apps the simplest pattern is to leave bound_addr empty
(wildcard) and let the kernel pick — that picks up route-table
changes immediately. Set bound_addr only when you have a real
need to pin to a specific interface.
If you do need to pin, the field lives in two places — SIP transport and per-account media (RTP/RTCP):
// PJSUA2 — call before recreating SIP transport / modifying account.
const std::string new_local_ip = "10.0.0.5";
Endpoint &ep = Endpoint::instance();
// SIP transport: pass on the TransportConfig at transportCreate() time.
TransportConfig sip_tp_cfg;
sip_tp_cfg.boundAddress = new_local_ip;
ep.transportClose(old_sip_tp_id);
ep.transportCreate(PJSIP_TRANSPORT_UDP, sip_tp_cfg);
// Media (RTP/RTCP): on the account config, then re-apply.
acc_cfg.mediaConfig.transportConfig.boundAddress = new_local_ip;
account.modify(acc_cfg);
ep.handleIpChange(IpChangeParam());
/* PJSUA-LIB */
pj_str_t new_local_ip = pj_str("10.0.0.5");
/* SIP transport: recreate with the new bound_addr. */
pjsua_transport_config tp_cfg;
pjsua_transport_config_default(&tp_cfg);
tp_cfg.bound_addr = new_local_ip;
pjsua_transport_close(old_sip_tp_id, PJ_TRUE);
pjsua_transport_create(PJSIP_TRANSPORT_UDP, &tp_cfg, NULL);
/* Media (RTP/RTCP) lives on the account's rtp_cfg. */
acc_cfg.rtp_cfg.bound_addr = new_local_ip;
pjsua_acc_modify(acc_id, &acc_cfg);
pjsua_ip_change_param param;
pjsua_ip_change_param_default(¶m);
pjsua_handle_ip_change(¶m);
Note that an existing SIP transport can’t have its bound_addr
changed in place; recreate the transport (close the old, create the
new). The media rtp_cfg.bound_addr updated through
acc_modify / modify is picked up the next time a media
transport is created — for new calls, and for existing calls that
are refreshed with PJSUA_CALL_REINIT_MEDIA (which is part
of the default reinviteFlags, so handleIpChange itself
triggers it).
Listener restart caveats
Two pieces of behaviour to be aware of when
restartListener = true:
Bound address is reset to wildcard. If you originally created the listener with a specific bound IP (via
pj::TransportConfig::boundAddress/pjsua_transport_config::bound_addr), the restart inpjsip_tcp_transport_restart()/pjsip_tls_transport_restart()rebinds to0.0.0.0(IPv4) or::(IPv6). This is documented in #3873. If you require the listener to stay bound to a specific interface, setrestartListener = falseand manage the listener yourself after IP change.Restart can race the underlying socket. Some platforms (notably iOS) need a moment between “old socket released” and “new socket can bind” — see
pj::IpChangeParam::restartLisDelay(defaultPJSUA_TRANSPORT_RESTART_DELAY_TIME). If a restart attempt returns any non-success status andrestartLisDelayis non-zero, PJSUA-LIB schedules one retry after the delay; the per-attempt result is logged at level 3.
Maintaining a call during IP change
By default, pj::Endpoint::handleIpChange() keeps active
calls alive and refreshes them via re-INVITE (or UPDATE, see above)
once re-registration completes. The refresh uses
pj::AccountIpChangeConfig::reinviteFlags, defaulting to
PJSUA_CALL_REINIT_MEDIA |
PJSUA_CALL_UPDATE_CONTACT |
PJSUA_CALL_UPDATE_VIA.
Two situations the automatic flow doesn’t cover and the application must handle:
IP change during ongoing SDP negotiation (offer sent, answer not yet received). A new SDP offer can’t be sent. Do this in two steps:
Update Contact only (no new offer) via UPDATE with
PJSUA_CALL_NO_SDP_OFFERso the peer can route its pending answer to the new address. Not every endpoint supports UPDATE — if a proxy is in the path you can usually skip this step.Update media after the answer arrives, via UPDATE / re-INVITE with
PJSUA_CALL_REINIT_MEDIA.
IP change before a call is confirmed. For outgoing calls the call is disconnected and reported via
pj::Call::onCallState()(PJSUA-LIB:pjsua_callback::on_call_state). For incoming calls the dialog stays active; hang up manually if the new IP makes the call infeasible.
Network change to a different IP version (IPv4 ↔ IPv6)
For same-family IP changes (IPv4 → IPv4 or IPv6 → IPv6) the call to
pj::Endpoint::handleIpChange() is sufficient. For
cross-family changes, the application has to set up a transport for
the new family first and then update account preferences. See
IPv6 modes and defaults in the
IPv6 and NAT64 guide for the mode reference.
PJSUA2 (keep your own pj::AccountConfig around since the
class doesn’t expose a getter):
void ip_change_to_ip6(Account *acc, AccountConfig &acc_cfg)
{
// Create new IPv6 transport if needed; e.g. TLS6
Endpoint &ep = Endpoint::instance();
TransportConfig tp_cfg;
ep.transportCreate(PJSIP_TRANSPORT_TLS6, tp_cfg);
acc_cfg.sipConfig.ipv6Use = PJSUA_IPV6_ENABLED_USE_IPV6_ONLY;
acc_cfg.mediaConfig.ipv6Use = PJSUA_IPV6_ENABLED_USE_IPV6_ONLY;
// Prevent modify() from sending a REGISTER on the old transport.
acc_cfg.regConfig.disableRegOnModify = true;
acc->modify(acc_cfg);
IpChangeParam param;
ep.handleIpChange(param);
}
PJSUA-LIB:
static void ip_change_to_ip6()
{
...
// Create new IPv6 transport, if it's not yet available. e.g: TLS6
status = pjsua_transport_create(PJSIP_TRANSPORT_TLS6,
&tp_cfg, &transport_id);
...
// For PJSIP earlier than 2.14
// Bind account to IPv6 transport
// pjsua_acc_set_transport(acc_id, transport_id);
// Modify account configuration
pjsua_acc_get_config(acc_id, app_config.pool, &acc_cfg);
// ******************************************************
// ** For PJSIP 2.14 and above:
acc_cfg.ipv6_sip_use = PJSUA_IPV6_ENABLED_USE_IPV6_ONLY;
acc_cfg.ipv6_media_use = PJSUA_IPV6_ENABLED_USE_IPV6_ONLY;
// ** For PJSIP earlier than 2.14:
// acc_cfg.ipv6_media_use = PJSUA_IPV6_ENABLED;
// ******************************************************
// acc_cfg.ip_change_cfg.hangup_calls = PJ_TRUE;
// Available since #3910, prevents pjsua_acc_modify() from
// prematurely sending a REGISTER on the old (dead) transport.
acc_cfg.disable_reg_on_modify = PJ_TRUE;
pjsua_acc_modify(acc_id, &acc_cfg);
...
// Handle ip change
pjsua_ip_change_param_default(¶m);
pjsua_handle_ip_change(¶m);
}
Note
The example forces USE_IPV6_ONLY to tear down existing IPv4
state entirely. If you set ipv6_sip_use = PREFER_IPV6 instead,
the account is dual-stack and existing calls that were negotiated
over IPv4 continue to run over IPv4 — the new preference only
affects subsequent outgoing offers/requests. Choose
USE_IPV6_ONLY when the old family is truly gone.
Diagnostics
Progress callback
pj::Endpoint::onIpChangeProgress() (PJSUA2) and the
underlying pjsua_callback::on_ip_change_progress
(PJSUA-LIB) fire for each stage of the operation:
PJSUA_IP_CHANGE_OP_RESTART_LIS— listener restartPJSUA_IP_CHANGE_OP_ACC_SHUTDOWN_TP— per-account transport shutdownPJSUA_IP_CHANGE_OP_ACC_UPDATE_CONTACT— re-REGISTERPJSUA_IP_CHANGE_OP_ACC_HANGUP_CALLS— call hang-upPJSUA_IP_CHANGE_OP_ACC_REINVITE_CALLS— call refresh (re-INVITE / UPDATE)PJSUA_IP_CHANGE_OP_COMPLETED— all stages done
Each invocation carries a status code and per-stage info — use this for UI feedback or to time the overall switch.
Log lines
At log level 3, pjsua_core.c emits "Start handling IP address
change" on entry. At level 4 it logs the staged steps:
IP change shutting down transports..IP change temporarily ignores request timeoutper-listener restart attempts (with the listener’s name)
The TCP/TLS listener restart helpers
(pjsip_tcp_transport_restart() /
pjsip_tls_transport_restart()) also log at level 3 when a
restart is requested but no listener exists — that’s the no-op
“update published address only” path from #3873.
IP address change detection
PJSIP doesn’t poll for IP changes; the application has to detect
and call pj::Endpoint::handleIpChange() (PJSUA-LIB:
pjsua_handle_ip_change()). Platform pointers:
iOS
Use the Reachability API or modern Network framework path monitor. Note that iOS aggressively suspends sockets when the app backgrounds — see also the iOS push notification guide if your app needs to wake on incoming SIP traffic.
Android
Use ConnectivityManager
with a NetworkCallback registered via
registerDefaultNetworkCallback (API 24+). On older API levels,
CONNECTIVITY_ACTION broadcast is the fallback.
PJSUA-LIB equivalents
PJSUA2 |
PJSUA-LIB |
|---|---|
|
|
|
|
|
|
|