NEURLFilterControlProvider: stop(reason:) always receives .none, and is skipped in some teardown paths

Testing NEURLFilterControlProvider on iOS 26.6, I've measured two behaviours around stop(reason:) that I can't reconcile with the documentation, and I'd like to know whether either is intended.

Setup: development-signed build, filter configured and running, instrumented stop(reason:) that logs the reason and then emits elapsed-time markers. All timings below are from device log archives collected with "log collect --device".

  1. The reason parameter is always .none

nesessionmanager records a specific stop reason for itself, then passes .none (0) to the provider. Two different user actions, two different internal reasons, same .none delivered.

Disabling via NEURLFilterManager (isEnabled = false, saveToPreferences):

15:20:12.173 nesessionmanager: Setting last stop reason to 31 15:20:12.175 MyProvider: stop() entered, reason=0 (none) 15:20:14.208 MyProvider: stop() completed after 2032ms 15:20:14.209 nesessionmanager: status changed to disconnected, last stop reason Configuration was disabled

Disabling the filter from iOS Settings:

15:25:04.167 nesessionmanager: Setting last stop reason to 1 15:25:04.169 MyProvider: stop() entered, reason=0 (none) 15:25:06.198 MyProvider: stop() completed after 2028ms 15:25:06.200 nesessionmanager: status changed to disconnected, last stop reason Stop command received

The system clearly knows why the filter stopped, and the values it uses (31, 1) are outside the NEProviderStopReason range, so there appears to be a richer internal enum that isn't mapped through to the provider.

Is .none what a URL filter control provider should expect in all cases? If so, is there any supported way for the provider to distinguish a deliberate user disable from system settings, say, an in-app disable, app update or a configuration reload?

  1. Is stop(reason:) guaranteed to be called?

I do cleanup work in stop(), so I want to know whether it is a reliable lifecycle callback or a best-effort one. I found at least one teardown path where it is skipped entirely.

When the configuration is removed as part of the app being deleted, the session is torn down without the provider ever being invoked. The extension is terminated roughly 355 ms before the configuration removal:

15:33:59.361 launchd: service inactive: MyProvider, leaving extension group 15:33:59.363 runningboardd: Executing termination request 15:33:59.431 launchd: remove all extension instances ... total of 0 extension instances were found to remove 15:33:59.716 nesessionmanager: configuration has been removed, stopping 15:33:59.716 nesessionmanager: Setting last stop reason to 32 15:33:59.717 nesessionmanager: plugin NEURLFilterPlugin(...) disposed 15:33:59.717 nesessionmanager: status changed to disconnected, last stop reason Configuration was removed

No stop() call appears anywhere in the archive, and the plugin is disposed rather than stopped. The whole teardown takes one millisecond.

The ordering looks like the cause: by the time nesessionmanager removes the configuration, the extension process is already gone, so there is nothing left to call stop() on. Thread 713975 describes stopTunnel being called with .providerDisabled in the equivalent situation for a packet tunnel provider, so the behaviour appears to differ by provider type.

So my question is about the contract rather than this specific path: should a URL filter control provider treat stop(reason:) as guaranteed, or as best-effort? If it is best-effort, are there other teardown paths where it is skipped, so I can make sure nothing important depends on it running?

NEURLFilterControlProvider: stop(reason:) always receives .none, and is skipped in some teardown paths
 
 
Q