I think unlocking the mutex in this function is error prone. First, it does not lock the mutex itself, but the mutex is locked when we enter the function - but then the function is responsible for unlocking it. This asymmetry makes it hard to follow.

The reason why this function needs to do the unlocking is because further down it does the upnp_device_notify(), that should not be within the mutex lock.

To have better symmetry, we should split this into collecting the variables and the call to upnp_device_notify(). Currently, these functions do both.

So, have one function allocating and filling the varnames/varvalues (which it fully does under the mutex). Once it returns, we unlock them mutex and call upnp_device_notify() if the changed-count was > 0.

This is true for this function UPnPLastChangeCollector_notify() and UPnPVarChangeCollector_notify_lastchange(). To indicate that they are called within a mutex, I like to add a '_locked' suffix.

// Maybe have a convenient 'object' containing the fields.
struct EventVariables {
    char **varnames;
    char **varvalues;
    int var_count;
};
void EventVariables_init(struct EventVariables *ev) { /* sets fields to null / 0 */ }
void EventVariables_clear(struct EventVariables *ev) { /* free() varnames and varvalues, calls _init() */ }

// Then rename the _notify methods to this, and just allocate and fill the variables.
UPnPLastChangeCollector_fill_eventvars_locked(upnp_var_change_collector_t *obj, struct EventVariables *variables);
UPnPLastChangeCollector_fill_LastChangevar_locked(upnp_var_change_collector_t *obj, struct EventVariables *variables);