Commit 9623a69d49 for asterisk.org

commit 9623a69d499425c1c7752731b0247db10c1118ac
Author: rikrobson <richard@rikrobson.co.uk>
Date:   Mon Sep 28 16:55:52 2026 +0000

    res_pjsip_pubsub: Expire subscriptions recreated from persistence.

    A subscription recreated from persistence after a restart is never
    accepted by pjproject, so its server timeout never fires. The expiration
    task scheduled in its place called pubsub_on_refresh_timeout(), which in
    the normal state only sends another NOTIFY. If the subscriber had stopped
    refreshing, the subscription therefore never ended: at expiry Asterisk
    sent "Subscription-State: active;expires=0", then kept notifying with a
    wrapped expires value on every state change until the next restart.

    The expiration task now moves the subscription to the terminate pending
    state first, as pubsub_on_server_timeout() does, so it ends with a final
    "terminated" NOTIFY. A refresh still cancels the task, so subscriptions
    that are refreshed are unaffected.

    Assisted-by: Claude Opus 5.5

    Resolves: #2187

diff --git a/res/res_pjsip_pubsub.c b/res/res_pjsip_pubsub.c
index 1852a5f877..9dd999284d 100644
--- a/res/res_pjsip_pubsub.c
+++ b/res/res_pjsip_pubsub.c
@@ -3204,7 +3204,7 @@ static int generate_initial_notify(struct ast_sip_subscription *sub)
 	return res;
 }

-static int pubsub_on_refresh_timeout(void *userdata);
+static int pubsub_on_expiration_timeout(void *userdata);

 static int initial_notify_task(void * obj)
 {
@@ -3232,7 +3232,7 @@ static int initial_notify_task(void * obj)

 		ast_debug(3, "Scheduling timer: %s\n", name);
 		ind->sub_tree->expiration_task = ast_sip_schedule_task(ind->sub_tree->serializer,
-			ind->expires * 1000, pubsub_on_refresh_timeout, name,
+			ind->expires * 1000, pubsub_on_expiration_timeout, name,
 			ind->sub_tree, AST_SIP_SCHED_TASK_FIXED | AST_SIP_SCHED_TASK_DATA_AO2);
 		if (!ind->sub_tree->expiration_task) {
 			ast_log(LOG_ERROR, "Unable to create expiration timer of %d seconds for %s\n",
@@ -4219,6 +4219,32 @@ static int pubsub_on_refresh_timeout(void *userdata)
 	return 0;
 }

+/*!
+ * \brief Expire a subscription recreated from persistence
+ *
+ * A recreated subscription is never accepted by pjproject, so its server
+ * timeout never fires.  Any refresh cancels this task, so if it runs the
+ * subscription has expired: terminate it as pubsub_on_server_timeout would.
+ */
+static int pubsub_on_expiration_timeout(void *userdata)
+{
+	struct sip_subscription_tree *sub_tree = userdata;
+	pjsip_dialog *dlg = sub_tree->dlg;
+
+	/* The dialog lock is held across the state change and the NOTIFY so they
+	 * happen together. pubsub_on_refresh_timeout() takes the same lock again;
+	 * that recursive lock is safe, as the dialog lock is a recursive mutex.
+	 */
+	pjsip_dlg_inc_lock(dlg);
+	if (sub_tree->state == SIP_SUB_TREE_NORMAL) {
+		sub_tree->state = SIP_SUB_TREE_TERMINATE_PENDING;
+	}
+	pubsub_on_refresh_timeout(sub_tree);
+	pjsip_dlg_dec_lock(dlg);
+
+	return 0;
+}
+
 static int serialized_pubsub_on_refresh_timeout(void *userdata)
 {
 	struct sip_subscription_tree *sub_tree = userdata;