From 8fcba03aabc2defbad75628b337fa0c651039a50 Mon Sep 17 00:00:00 2001
From: Joel Jacobson <joel@compiler.org>
Date: Tue, 19 May 2026 10:55:50 -0700
Subject: [PATCH 2/3] Test LISTEN startup notification for already-seen work

Add an injection-point isolation test that pauses a first LISTEN after
the pending listen action has been applied, but before the command
returns to the client.  A concurrent transaction inserts a row and
sends a NOTIFY while the listener is paused.

When LISTEN returns, the listener receives one notification and its
initial table scan sees the same row.  This demonstrates the harmless
false positive described by the LISTEN documentation: applications may
observe the same work through both the startup scan and the
notification stream, and should tolerate that.
---
 src/backend/commands/async.c                  |  3 ++
 src/test/modules/injection_points/Makefile    |  1 +
 .../async-notify-listen-false-positive.out    | 21 +++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../async-notify-listen-false-positive.spec   | 43 +++++++++++++++++++
 5 files changed, 69 insertions(+)
 create mode 100644 src/test/modules/injection_points/expected/async-notify-listen-false-positive.out
 create mode 100644 src/test/modules/injection_points/specs/async-notify-listen-false-positive.spec

diff --git a/src/backend/commands/async.c b/src/backend/commands/async.c
index cefd5297a73..bd7eff7f305 100644
--- a/src/backend/commands/async.c
+++ b/src/backend/commands/async.c
@@ -1394,6 +1394,9 @@ AtCommit_Notify(void)
 	/* Apply staged listen/unlisten changes */
 	ApplyPendingListenActions(true);
 
+	if (pendingActions != NULL)
+		INJECTION_POINT("async-notify-after-listen-commit", NULL);
+
 	/* If no longer listening to anything, get out of listener array */
 	if (amRegisteredListener && LocalChannelTableIsEmpty())
 		asyncQueueUnregister();
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 37c1b1cffb6..dcf442df9ba 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-false-positive \
 	    async-notify-listen-startup \
 	    inplace \
 	    repack \
diff --git a/src/test/modules/injection_points/expected/async-notify-listen-false-positive.out b/src/test/modules/injection_points/expected/async-notify-listen-false-positive.out
new file mode 100644
index 00000000000..4cab951ca55
--- /dev/null
+++ b/src/test/modules/injection_points/expected/async-notify-listen-false-positive.out
@@ -0,0 +1,21 @@
+Parsed test spec with 3 sessions
+
+starting permutation: listen notify wake inspect
+step listen: LISTEN race; <waiting ...>
+step notify: 
+	INSERT INTO notify_queue VALUES (1);
+	NOTIFY race, '1';
+
+step wake: 
+	SELECT FROM injection_points_detach('async-notify-after-listen-commit');
+	SELECT FROM injection_points_wakeup('async-notify-after-listen-commit');
+ <waiting ...>
+step listen: <... completed>
+listener: NOTIFY "race" with payload "1" from notifier
+step inspect: SELECT id FROM notify_queue ORDER BY id;
+id
+--
+ 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 61a68bcfe15..8a91b0a41aa 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-false-positive',
       'async-notify-listen-startup',
       'inplace',
       'repack',
diff --git a/src/test/modules/injection_points/specs/async-notify-listen-false-positive.spec b/src/test/modules/injection_points/specs/async-notify-listen-false-positive.spec
new file mode 100644
index 00000000000..ad31c37acb6
--- /dev/null
+++ b/src/test/modules/injection_points/specs/async-notify-listen-false-positive.spec
@@ -0,0 +1,43 @@
+# Test harmless duplicate notification during LISTEN startup.
+#
+# A notification can arrive for work that the first database inspection after
+# LISTEN already observes.  Applications should inspect database state after
+# LISTEN returns and tolerate a few notifications for rows already seen.
+
+setup
+{
+	CREATE EXTENSION injection_points;
+	CREATE TABLE notify_queue(id int primary key);
+}
+
+teardown
+{
+	DROP TABLE notify_queue;
+	DROP EXTENSION injection_points;
+}
+
+session listener
+setup
+{
+	SELECT FROM injection_points_set_local();
+	SELECT FROM injection_points_attach('async-notify-after-listen-commit', 'wait');
+}
+step listen		{ LISTEN race; }
+step inspect	{ SELECT id FROM notify_queue ORDER BY id; }
+teardown		{ UNLISTEN *; }
+
+session notifier
+step notify
+{
+	INSERT INTO notify_queue VALUES (1);
+	NOTIFY race, '1';
+}
+
+session controller
+step wake
+{
+	SELECT FROM injection_points_detach('async-notify-after-listen-commit');
+	SELECT FROM injection_points_wakeup('async-notify-after-listen-commit');
+}
+
+permutation listen notify wake(listen) inspect
-- 
2.52.0

