From 16c94f3d118a4da02b37b9a45d2abafda6b984f4 Mon Sep 17 00:00:00 2001
From: Joel Jacobson <joel@compiler.org>
Date: Tue, 19 May 2026 10:44:46 -0700
Subject: [PATCH 1/3] Test missed LISTEN startup notification

Add an injection-point isolation test that pauses a first LISTEN before
AtCommit_Notify() applies the pending listen action.  A concurrent
NOTIFY in that window should be delivered when LISTEN returns.

This is distinct from the setup race documented in listen.sgml.  That
race can produce false positives: a new listener can receive
notifications for changes it already saw during its initial database
inspection.  Such extra notifications are harmless.

The race tested here is a false negative.  The listener has already
registered its queue position, and its channel table entry is visible
to SignalBackends().  However, AtCommit_Notify() has not yet marked it
listening=true, so a notification committed after that point can be
skipped and lost entirely.

Before the fix, this test fails by missing that notification.  The test
would have passed before the LISTEN/NOTIFY rework in 282b1cd, which
introduced the shared channel map and direct advancement.
---
 src/backend/commands/async.c                  |  4 ++
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/async-notify-listen-startup.out  | 18 +++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/async-notify-listen-startup.spec    | 38 +++++++++++++++++++
 5 files changed, 62 insertions(+)
 create mode 100644 src/test/modules/injection_points/expected/async-notify-listen-startup.out
 create mode 100644 src/test/modules/injection_points/specs/async-notify-listen-startup.spec

diff --git a/src/backend/commands/async.c b/src/backend/commands/async.c
index db6a9a6561b..cefd5297a73 100644
--- a/src/backend/commands/async.c
+++ b/src/backend/commands/async.c
@@ -184,6 +184,7 @@
 #include "utils/builtins.h"
 #include "utils/dsa.h"
 #include "utils/guc_hooks.h"
+#include "utils/injection_point.h"
 #include "utils/memutils.h"
 #include "utils/ps_status.h"
 #include "utils/snapmgr.h"
@@ -1387,6 +1388,9 @@ AtCommit_Notify(void)
 	if (Trace_notify)
 		elog(DEBUG1, "AtCommit_Notify");
 
+	if (pendingActions != NULL)
+		INJECTION_POINT("async-notify-before-listen-commit", NULL);
+
 	/* Apply staged listen/unlisten changes */
 	ApplyPendingListenActions(true);
 
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index c01d2fb095c..37c1b1cffb6 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -13,6 +13,7 @@ REGRESS = injection_points hashagg reindex_conc vacuum
 REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 
 ISOLATION = basic \
+	    async-notify-listen-startup \
 	    inplace \
 	    repack \
 	    repack_temporal \
diff --git a/src/test/modules/injection_points/expected/async-notify-listen-startup.out b/src/test/modules/injection_points/expected/async-notify-listen-startup.out
new file mode 100644
index 00000000000..d65e5015cff
--- /dev/null
+++ b/src/test/modules/injection_points/expected/async-notify-listen-startup.out
@@ -0,0 +1,18 @@
+Parsed test spec with 3 sessions
+
+starting permutation: listen notify wake check
+step listen: LISTEN race; <waiting ...>
+step notify: NOTIFY race, 'payload';
+step wake: 
+	SELECT FROM injection_points_detach('async-notify-before-listen-commit');
+	SELECT FROM injection_points_wakeup('async-notify-before-listen-commit');
+ <waiting ...>
+step listen: <... completed>
+listener: NOTIFY "race" with payload "payload" from notifier
+step check: SELECT 1 AS x;
+x
+-
+1
+(1 row)
+
+step wake: <... completed>
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index 59dba1cb023..61a68bcfe15 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -44,6 +44,7 @@ tests += {
   'isolation': {
     'specs': [
       'basic',
+      'async-notify-listen-startup',
       'inplace',
       'repack',
       'repack_temporal',
diff --git a/src/test/modules/injection_points/specs/async-notify-listen-startup.spec b/src/test/modules/injection_points/specs/async-notify-listen-startup.spec
new file mode 100644
index 00000000000..29832a514d0
--- /dev/null
+++ b/src/test/modules/injection_points/specs/async-notify-listen-startup.spec
@@ -0,0 +1,38 @@
+# Test LISTEN/NOTIFY startup behavior while committing the first LISTEN.
+#
+# A first LISTEN registers the backend's queue position before the transaction
+# becomes visible, then commits the local listen state later in AtCommit_Notify.
+# A concurrent NOTIFY in that window must still wake the listener, so the
+# notification is delivered after LISTEN returns.
+
+setup
+{
+	CREATE EXTENSION injection_points;
+}
+
+teardown
+{
+	DROP EXTENSION injection_points;
+}
+
+session listener
+setup
+{
+	SELECT FROM injection_points_set_local();
+	SELECT FROM injection_points_attach('async-notify-before-listen-commit', 'wait');
+}
+step listen	{ LISTEN race; }
+step check	{ SELECT 1 AS x; }
+teardown	{ UNLISTEN *; }
+
+session notifier
+step notify	{ NOTIFY race, 'payload'; }
+
+session controller
+step wake
+{
+	SELECT FROM injection_points_detach('async-notify-before-listen-commit');
+	SELECT FROM injection_points_wakeup('async-notify-before-listen-commit');
+}
+
+permutation listen notify wake(listen) check
-- 
2.52.0

