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).

  1. Shut down all TCP/TLS transports (if pj::IpChangeParam::shutdownTransport is 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, default true. UDP is not covered here; UDP “transports” are the listener itself, handled by the next step.

  2. Restart the SIP transport listener (if pj::IpChangeParam::restartListener is set, the default). This rebinds the listening socket so it picks up the new IP. See Listener restart caveats below.

  3. Per account: shut down the registration transport (if pj::AccountIpChangeConfig::shutdownTp is set on the account, default true). 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).

  4. Re-register: send REGISTER with the new Contact URI, per pj::AccountNatConfig::contactRewriteUse / pj::AccountNatConfig::contactRewriteMethod.

  5. Active calls: either hang up (pj::AccountIpChangeConfig::hangupCalls) or refresh them with re-INVITE or SIP UPDATE (pj::AccountIpChangeConfig::reinviteFlags, pj::AccountIpChangeConfig::reinvUseUpdate). When reinviteFlags is 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::shutdownTransport to true (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::restartListener rebinds 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. Set pj::AccountRegConfig::disableRegOnModify to true (added in #3910) to suppress that, then let pj::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 set bound_addr explicitly, you’re responsible for updating it to the new IP before calling handleIpChange.

  • 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 with ConnectivityManager.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(&param);
pjsua_handle_ip_change(&param);

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 in pjsip_tcp_transport_restart() / pjsip_tls_transport_restart() rebinds to 0.0.0.0 (IPv4) or :: (IPv6). This is documented in #3873. If you require the listener to stay bound to a specific interface, set restartListener = false and 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 (default PJSUA_TRANSPORT_RESTART_DELAY_TIME). If a restart attempt returns any non-success status and restartLisDelay is 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:

    1. Update Contact only (no new offer) via UPDATE with PJSUA_CALL_NO_SDP_OFFER so 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.

    2. 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(&param);
    pjsua_handle_ip_change(&param);
}

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 restart

  • PJSUA_IP_CHANGE_OP_ACC_SHUTDOWN_TP — per-account transport shutdown

  • PJSUA_IP_CHANGE_OP_ACC_UPDATE_CONTACT — re-REGISTER

  • PJSUA_IP_CHANGE_OP_ACC_HANGUP_CALLS — call hang-up

  • PJSUA_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 timeout

  • per-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

pj::IpChangeParam::restartListener

pjsua_ip_change_param::restart_listener

pj::IpChangeParam::restartLisDelay

pjsua_ip_change_param::restart_lis_delay

pj::IpChangeParam::shutdownTransport

pjsua_ip_change_param::shutdown_transport

pj::Endpoint::handleIpChange()

pjsua_handle_ip_change()

pj::Endpoint::onIpChangeProgress()

pjsua_callback::on_ip_change_progress

pj::AccountRegConfig::disableRegOnModify

pjsua_acc_config::disable_reg_on_modify

pj::AccountIpChangeConfig::shutdownTp

pjsua_ip_change_acc_cfg::shutdown_tp

pj::AccountIpChangeConfig::hangupCalls

pjsua_ip_change_acc_cfg::hangup_calls

pj::AccountIpChangeConfig::reinviteFlags

pjsua_ip_change_acc_cfg::reinvite_flags

pj::AccountIpChangeConfig::reinvUseUpdate

pjsua_ip_change_acc_cfg::reinv_use_update

References

  • Shutdown all TCP/TLS transports on IP change: #3781

  • SIP UPDATE for refreshing calls: #3146

  • Listener restart checks: #3872

  • Listener restart: skip if no listener, doc bound-addr reset: #3873

  • disable_reg_on_modify: #3910