Windows IP Interface Notifications: Snapshot Refresh and Safe Cancellation
Register IP interface notifications, coalesce callbacks into fresh snapshots, and cancel safely without freeing callback state too early.
NotifyIpInterfaceChange registers a process callback for IPv4, IPv6, or both families. It is useful when a network-aware app needs to react to interface configuration changes, but the callback is not a complete interface snapshot and its registration has a lifetime of its own. Treat the callback as an invalidation signal: wake a worker, query current state through IP Helper, and keep the callback context alive until deregistration and callback quiescence are both handled by the application’s lifetime strategy.
Registration is not a Win32 event handle
Use AF_UNSPEC when the app needs both IPv4 and IPv6 updates. With InitialNotification set to TRUE, the callback receives an initial MibInitialNotification; that is registration confirmation, not evidence of a network change, and its Row argument is NULL. On success, NotifyIpInterfaceChange returns NO_ERROR and writes a notification handle. On failure, it returns an error code and the handle is NULL.
Keep the returned notification handle separate from any event or thread handle used by your application. Microsoft documents CancelMibChangeNotify2 as the way to deregister this notification; do not confuse that operation with closing the application’s event handle shown below.
Keep the callback short and avoid retaining its row pointer
Microsoft serializes invocations of this callback. The row pointer for a real change contains only enough information to identify the interface for a follow-up query, is owned by the operating system, and must not be freed by the application. If the callback queues work, copy the identity fields before returning; never enqueue the raw pointer for a later worker to inspect.
For many applications, the simplest design is to signal a worker and let it re-read the complete interface table. An auto-reset event also coalesces repeated signals while it is already signaled, so the worker should reconcile current state rather than assume one wake equals one change:
#include <winsock2.h>
#include <windows.h>
#include <iphlpapi.h>
#pragma comment(lib, "iphlpapi.lib")
struct WatchState {
HANDLE wakeEvent;
volatile LONG callbackEventError;
};
VOID CALLBACK OnIpInterfaceChange(
PVOID context,
PMIB_IPINTERFACE_ROW row,
MIB_NOTIFICATION_TYPE notificationType)
{
UNREFERENCED_PARAMETER(row); // do not retain the OS-owned pointer
UNREFERENCED_PARAMETER(notificationType); // initial notice also wakes the worker
auto* state = static_cast<WatchState*>(context);
if (!SetEvent(state->wakeEvent)) {
const DWORD error = GetLastError();
InterlockedExchange(&state->callbackEventError, static_cast<LONG>(error));
}
}
DWORD RegisterInterfaceWatch(WatchState* state, HANDLE* registration)
{
*registration = nullptr;
return NotifyIpInterfaceChange(
AF_UNSPEC, OnIpInterfaceChange, state, TRUE, registration);
}
The worker can wait on wakeEvent, call GetIpInterfaceTable(AF_UNSPEC, &table), process the returned rows, and release the table with FreeMibTable. If only one row needs refreshing, copy its Family, InterfaceLuid, and InterfaceIndex while the callback’s row is valid, then build a separate query row:
MIB_IPINTERFACE_ROW query{};
InitializeIpInterfaceEntry(&query); // sets default fields, including Family = AF_UNSPEC
query.Family = copiedFamily; // GetIpInterfaceEntry requires AF_INET or AF_INET6
query.InterfaceLuid = copiedLuid;
query.InterfaceIndex = copiedIndex;
const DWORD queryStatus = GetIpInterfaceEntry(&query);
The initializer sets defaults; it does not preserve fields assigned before the call. GetIpInterfaceEntry requires Family to be AF_INET or AF_INET6, plus at least one interface identifier, so assign the copied fields after initialization. Never read the incomplete callback row later.
Handle creation and registration failures independently
Create the event before registering the callback so the callback never observes an uninitialized handle. CreateEventW returns NULL on failure; capture GetLastError() immediately, before another Win32 call overwrites the thread’s last-error value. NotifyIpInterfaceChange, by contrast, returns its error code directly, so inspect that return value rather than treating GetLastError() as the registration result.
WatchState state{CreateEventW(nullptr, FALSE, FALSE, nullptr), 0};
if (state.wakeEvent == nullptr) {
const DWORD error = GetLastError();
return error;
}
HANDLE registration = nullptr;
const DWORD status = RegisterInterfaceWatch(&state, ®istration);
if (status != NO_ERROR) {
CloseHandle(state.wakeEvent); // no callback registration was created
return status;
}
After successful registration, keep state and its event alive while callbacks may use them. The registration continues until it is explicitly cancelled or the process exits; the system cleans it up at process termination, but Microsoft recommends explicit deregistration. On an orderly stop, signal the worker’s independent shutdown path and let it finish its snapshot work. Then call CancelMibChangeNotify2 from a control thread that is not executing the callback and does not hold a resource on which the callback waits. A NO_ERROR result documents successful deregistration, but Microsoft Learn does not specify that the call waits for a callback already in progress. Do not infer callback quiescence or free the context/close the event solely from that return; keep both valid until an application-owned lifetime protocol proves the callback can no longer access them. If the target platform contract does not establish that safely, retain the small callback state and event until process shutdown.
const DWORD cancelStatus = CancelMibChangeNotify2(registration);
if (cancelStatus != NO_ERROR) {
// Do not assume deregistration or callback quiescence; retain state and report the error.
ReportCancellationFailure(cancelStatus); // application-defined
}
// Even on NO_ERROR, release state.wakeEvent/state only after the application's
// separately verified callback-lifetime protocol permits it.
Do not call CancelMibChangeNotify2 from the callback for that registration. Microsoft documents that this can deadlock; a cancellation thread must also avoid owning resources on which the callback can wait. A failed call does not establish safe teardown, so preserve the callback context and event, surface the failure, and follow a documented recovery path instead of pretending cleanup completed. ReportCancellationFailure above represents application logging/telemetry, not an IP Helper API.
Refresh state, don’t infer reachability
An interface-change notification tells the app that IP interface state changed; it does not prove that DNS, a remote host, a VPN tunnel, or an application endpoint is reachable. After a wake, refresh only the state the application needs, then run a separate connection or health check if the product depends on end-to-end reachability. Keep API return codes in logs so a missing interface, invalid family, failed table query, and failed cancellation remain distinguishable.
Test the initial notification, IPv4-only and IPv6-only changes, rapid repeated changes, interface removal while a worker is refreshing, and shutdown while callbacks are arriving. Verify that every table returned by GetIpInterfaceTable is freed, no callback row pointer escapes, and the context/event remain valid until deregistration plus any in-flight callback use is safely resolved. The API is available on Windows Vista and later and is linked through Iphlpapi.lib.
Related:
- Windows NCSI Diagnostics: Separate the Connectivity Icon from Real Reachability
- Windows Built-In VPN Client Operations: Profiles, Routes, and Diagnostics
Sources:
- NotifyIpInterfaceChange function - Microsoft Learn
- CancelMibChangeNotify2 function - Microsoft Learn
- GetIpInterfaceEntry function - Microsoft Learn
- InitializeIpInterfaceEntry function - Microsoft Learn
- GetIpInterfaceTable function - Microsoft Learn
- CreateEventW function - Microsoft Learn
- SetEvent function - Microsoft Learn