From b48a2713483045d88a21e6ea0efd99e4a9cf6343 Mon Sep 17 00:00:00 2001
From: Andrey Borodin <amborodin@acm.org>
Date: Mon, 24 Aug 2026 13:52:58 +0300
Subject: [PATCH] Demonstrate WAL recycling race in pg_basebackup --create-slot

pg_basebackup creates a requested replication slot only after the
server has selected and sent the backup startpoint. Nothing retains WAL
in that window, so a checkpoint can recycle the startpoint segment
before the WAL streamer starts.

Add an injection point before the server sends the selected startpoint
and a TAP test that recycles the segment while the backup waits there,
then observes the resulting missing-WAL failure.
---
 src/backend/backup/basebackup.c               |   6 +
 src/test/recovery/meson.build                 |   1 +
 .../recovery/t/056_basebackup_slot_race.pl    | 121 ++++++++++++++++++
 3 files changed, 128 insertions(+)
 create mode 100644 src/test/recovery/t/056_basebackup_slot_race.pl

diff --git a/src/backend/backup/basebackup.c b/src/backend/backup/basebackup.c
index e3c04ecd810..656d4f3aeab 100644
--- a/src/backend/backup/basebackup.c
+++ b/src/backend/backup/basebackup.c
@@ -324,6 +324,12 @@ perform_base_backup(basebackup_options *opt, bbsink *sink,
 			state.bytes_total_is_valid = true;
 		}
 
+		/*
+		 * The startpoint has been selected, but the client does not know it
+		 * yet and therefore cannot have created the requested slot.
+		 */
+		INJECTION_POINT("basebackup-before-send-startpoint", NULL);
+
 		/* notify basebackup sink about start of backup */
 		bbsink_begin_backup(sink, &state, SINK_BUFFER_LENGTH);
 
diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build
index 39ec8c4946d..b61bc60c000 100644
--- a/src/test/recovery/meson.build
+++ b/src/test/recovery/meson.build
@@ -64,6 +64,7 @@ tests += {
       't/053_standby_login_event_trigger.pl',
       't/054_unlogged_sequence_promotion.pl',
       't/055_cascade_reconnect.pl',
+      't/056_basebackup_slot_race.pl',
     ],
   },
 }
diff --git a/src/test/recovery/t/056_basebackup_slot_race.pl b/src/test/recovery/t/056_basebackup_slot_race.pl
new file mode 100644
index 00000000000..29da020d762
--- /dev/null
+++ b/src/test/recovery/t/056_basebackup_slot_race.pl
@@ -0,0 +1,121 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Demonstrate WAL recycling before pg_basebackup creates its requested slot.
+#
+# The injection point stops BASE_BACKUP after choosing its startpoint but
+# before sending it to the client.  The test recycles that WAL and then lets
+# pg_basebackup create the slot and start its WAL streamer.  The test passes
+# when pg_basebackup fails with the expected missing-WAL error.
+
+use strict;
+use warnings FATAL => 'all';
+use File::Path qw(rmtree);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if ($ENV{enable_injection_points} ne 'yes')
+{
+	plan skip_all => 'Injection points not supported by this build';
+}
+
+# Small WAL segments make recycling cheap.
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init(allows_streaming => 1, extra => [ '--wal-segsize', '1' ]);
+$node->append_conf(
+	'postgresql.conf', q[
+wal_keep_size = 0
+min_wal_size = 2MB
+max_wal_size = 4MB
+checkpoint_timeout = 1h
+]);
+$node->start;
+
+# injection_points may not be installed under installcheck.
+if (!$node->check_extension('injection_points'))
+{
+	plan skip_all => 'Extension injection_points not installed';
+}
+$node->safe_psql('postgres', 'CREATE EXTENSION injection_points;');
+
+# Stop BASE_BACKUP before it sends the selected startpoint to the client.
+$node->safe_psql('postgres',
+	"SELECT injection_points_attach('basebackup-before-send-startpoint', 'wait');"
+);
+
+my $backupdir = $node->backup_dir . '/basebackup_race';
+my ($bb_stdout, $bb_stderr) = ('', '');
+my $bb_timeout =
+  IPC::Run::timeout(3 * $PostgreSQL::Test::Utils::timeout_default);
+my $bb = IPC::Run::start(
+	[
+		'pg_basebackup',
+		'--pgdata' => $backupdir,
+		'--wal-method' => 'stream',
+		'--slot' => 'basebackup_race',
+		'--create-slot',
+		'--checkpoint' => 'fast',
+		'--no-sync',
+		'-d' => $node->connstr('postgres')
+	],
+	'>' => \$bb_stdout,
+	'2>' => \$bb_stderr,
+	$bb_timeout);
+
+$node->wait_for_event('walsender', 'basebackup-before-send-startpoint');
+
+# The client cannot create the slot before it receives the startpoint.
+is( $node->safe_psql(
+		'postgres', 'SELECT count(*) FROM pg_replication_slots;'),
+	'0',
+	'no replication slot exists while startpoint is unprotected');
+
+# do_pg_backup_start() used the current checkpoint's REDO pointer.
+my $startpoint_wal = $node->safe_psql('postgres',
+	'SELECT pg_walfile_name(redo_lsn) FROM pg_control_checkpoint();');
+note "backup startpoint is in WAL segment $startpoint_wal";
+
+is( $node->safe_psql(
+		'postgres',
+		"SELECT count(*) FROM pg_ls_waldir() WHERE name = '$startpoint_wal';"
+	),
+	'1',
+	'WAL segment containing the backup startpoint exists while waiting');
+
+# Nothing retains the startpoint while BASE_BACKUP waits.
+$node->advance_wal(10);
+$node->safe_psql('postgres', 'CHECKPOINT;');
+is( $node->safe_psql(
+		'postgres',
+		"SELECT count(*) FROM pg_ls_waldir() WHERE name = '$startpoint_wal';"
+	),
+	'0',
+	'WAL segment containing the backup startpoint was recycled before slot '
+	  . 'creation'
+);
+
+# Let pg_basebackup create the slot and request the recycled WAL.
+$node->safe_psql('postgres',
+	"SELECT injection_points_wakeup('basebackup-before-send-startpoint');");
+$node->safe_psql('postgres',
+	"SELECT injection_points_detach('basebackup-before-send-startpoint');");
+
+$bb->finish;
+note "pg_basebackup stderr:\n$bb_stderr";
+
+isnt($bb->result(0), 0, 'pg_basebackup failed as expected (bug reproduced)')
+  or diag "pg_basebackup stdout: $bb_stdout\npg_basebackup stderr: $bb_stderr";
+like(
+	$bb_stderr,
+	qr/requested WAL segment [0-9A-F]+ has already been removed/,
+	'WAL streamer failed because the startpoint segment was removed')
+  or diag "pg_basebackup stderr: $bb_stderr";
+
+# The WAL streamer created the slot before noticing the missing segment.
+rmtree($backupdir);
+$node->safe_psql('postgres',
+	"SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots "
+	  . "WHERE slot_name = 'basebackup_race';"
+);
+
+done_testing();
-- 
That's all, folks. May the source be with you.


