From 11e58e3b23e91faeac8aa093eb1c2f4504fba07a Mon Sep 17 00:00:00 2001
From: Bohyun Lee <bohyun.lee@databricks.com>
Date: Fri, 31 Jul 2026 14:21:53 +0200
Subject: [PATCH] Add pg_upgrade --wal-upgrade: capture a major-version upgrade
 as WAL

pg_upgrade normally reinitializes the new cluster with a fresh system
identifier and WAL history, which breaks LSN continuity at the upgrade
boundary: a base backup taken before the upgrade cannot be replayed into
the upgraded cluster, and every streaming standby must be rebuilt by
copying the whole data directory.  --wal-upgrade instead records the
upgrade as a replayable WAL "window" so the transformation can be
streamed and replayed like ordinary WAL.

The upgrade runs as usual, then a burst server started in binary-upgrade
mode emits the whole window (CN..COMPLETE) in one gated backend call
using a new RM_PG_UPGRADE_ID resource manager: rewritten catalog/system
files travel as full-page images (RELFILE_DATA), SLRUs as bulk segment
images (SLRU_DATA), non-relation files (pg_filenode.map, PG_VERSION) as
raw images (RAWFILE), and the directory skeleton as DIRTREE.  User
relations, which pg_upgrade transfers verbatim, are not carried as data;
their identities are logged in a RELINK manifest so a streaming standby
relinks them from its own retained old data directory (reproducing the
primary's transfer mode).  START/COMPLETE markers plus durable
upgrade_started / upgrade_finalized flags in pg_control make a crashed
partial upgrade detectable and refused rather than auto-served.

On recovery the new binary derives the end-of-upgrade checkpoint (CN)
in-process and arms pg_control to replay the window, supporting three
paths: local first-start, a fresh streaming-standby skeleton (armed via
the pg_upgrade.signal sentinel plus pg_upgrade_standby_old_datadir /
pg_upgrade_standby_transfer_mode GUCs), and cross-version archive PITR
(pg_control is synthesized so the version gate passes, then recovery
flows organically through CN).  --wal-upgrade-signal-handoff emits a
handoff record into the old primary's WAL so a physical standby pauses
cleanly at the boundary.

Bumps PG_CONTROL_VERSION and CATALOG_VERSION_NO.

Co-authored-by: Isaac
---
 doc/src/sgml/ref/pgupgrade.sgml                 |  133 +++
 src/backend/access/rmgrdesc/Makefile            |    1 +
 src/backend/access/rmgrdesc/meson.build         |    1 +
 src/backend/access/rmgrdesc/pgupgradedesc.c     |  170 ++++
 src/backend/access/rmgrdesc/xlogdesc.c          |   11 +
 src/backend/access/transam/Makefile             |    3 +-
 src/backend/access/transam/clog.c               |   10 +
 src/backend/access/transam/meson.build          |    1 +
 src/backend/access/transam/multixact.c          |   17 +
 src/backend/access/transam/pgupgrade_wal.c      | 1963 +++++++++++++++++++++++++++++++++++++++++++
 src/backend/access/transam/rmgr.c               |    1 +
 src/backend/access/transam/slru.c               |   71 ++
 src/backend/access/transam/xlog.c               | 1289 +++++++++++++++++++++++++++-
 src/backend/access/transam/xlogfuncs.c          |  465 ++++++++++
 src/backend/access/transam/xlogrecovery.c       |   32 +
 src/backend/postmaster/postmaster.c             |    7 +
 src/backend/postmaster/startup.c                |   11 +
 src/backend/replication/walsender.c             |    1 +
 src/backend/storage/buffer/bufmgr.c             |   25 +
 src/backend/utils/adt/pg_upgrade_support.c      |   30 +
 src/backend/utils/init/miscinit.c               |   86 ++
 src/backend/utils/misc/guc_parameters.dat       |   15 +
 src/backend/utils/misc/postgresql.conf.sample   |    8 +
 src/bin/initdb/initdb.c                         |    6 +
 src/bin/pg_controldata/pg_controldata.c         |    4 +
 src/bin/pg_ctl/pg_ctl.c                         |   17 +
 src/bin/pg_resetwal/pg_resetwal.c               |   42 +-
 src/bin/pg_upgrade/Makefile                     |    1 +
 src/bin/pg_upgrade/check.c                      |   47 ++
 src/bin/pg_upgrade/controldata.c                |    9 +
 src/bin/pg_upgrade/file.c                       |   67 +-
 src/bin/pg_upgrade/info.c                       |   87 +-
 src/bin/pg_upgrade/meson.build                  |    4 +
 src/bin/pg_upgrade/option.c                     |   75 +-
 src/bin/pg_upgrade/pg_upgrade.c                 |  790 ++++++++++++++++-
 src/bin/pg_upgrade/pg_upgrade.h                 |   55 ++
 src/bin/pg_upgrade/relfilenumber.c              |    1 +
 src/bin/pg_upgrade/revertable.c                 |  129 +++
 src/bin/pg_upgrade/server.c                     |   54 +-
 src/bin/pg_upgrade/t/009_initdb_option.pl       |  218 +++++
 src/bin/pg_upgrade/t/010_wal_upgrade.pl         |  669 +++++++++++++++
 src/bin/pg_upgrade/t/011_wal_upgrade_standby.pl |  753 +++++++++++++++++
 src/bin/pg_waldump/pgupgradedesc.c              |    1 +
 src/bin/pg_waldump/rmgrdesc.c                   |    1 +
 src/bin/pg_waldump/t/001_basic.pl               |    3 +-
 src/common/file_utils.c                         |  124 +++
 src/include/access/clog.h                       |    3 +
 src/include/access/multixact.h                  |    4 +
 src/include/access/pgupgrade_wal.h              |   98 +++
 src/include/access/rmgrlist.h                   |    2 +
 src/include/access/slru.h                       |    4 +
 src/include/access/xlog.h                       |   74 ++
 src/include/access/xlogrecovery.h               |    7 +
 src/include/catalog/catversion.h                |    2 +-
 src/include/catalog/pg_control.h                |  216 ++++-
 src/include/catalog/pg_proc.dat                 |    8 +
 src/include/common/file_utils.h                 |   17 +
 src/include/utils/guc.h                         |    1 +
 src/tools/pgindent/typedefs.list                |   15 +
 59 files changed, 7871 insertions(+), 88 deletions(-)

diff --git a/doc/src/sgml/ref/pgupgrade.sgml b/doc/src/sgml/ref/pgupgrade.sgml
index e4e8c02e6d..645eceb04b 100644
--- a/doc/src/sgml/ref/pgupgrade.sgml
+++ b/doc/src/sgml/ref/pgupgrade.sgml
@@ -262,6 +262,31 @@ PostgreSQL documentation
       </listitem>
      </varlistentry>
 
+     <varlistentry>
+      <term><option>--initdb</option></term>
+      <listitem>
+       <para>
+        Create the new cluster automatically by running
+        <command>initdb</command> before upgrading, instead of requiring the
+        user to have created it manually.  The WAL segment size, data checksum
+        setting, encoding, and locale are derived from the old cluster so that
+        <application>pg_upgrade</application> can verify compatibility.
+       </para>
+       <para>
+        The new cluster data directory specified with
+        <option>-D</option>/<option>--new-datadir</option> must not already
+        exist when this option is given; if it does,
+        <application>pg_upgrade</application> will exit with an error.
+       </para>
+       <para>
+        This option cannot be combined with
+        <option>-c</option>/<option>--check</option>, because
+        <option>--check</option> is read-only and must not create the new
+        cluster.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry>
       <term><option>--no-statistics</option></term>
       <listitem>
@@ -373,6 +398,108 @@ PostgreSQL documentation
       </listitem>
      </varlistentry>
 
+     <varlistentry>
+      <term><option>--wal-upgrade</option></term>
+      <listitem>
+       <para>
+        Capture the entire upgrade as write-ahead log (WAL) so that it is
+        replayable, and leave the new cluster ready to start read-write on its
+        own.  The new cluster comes up on its first start with no separate
+        commit step, exactly as an ordinary upgraded cluster would.
+       </para>
+       <para>
+        Compared with a plain upgrade, <option>--wal-upgrade</option> keeps the
+        old cluster intact so the upgrade can be reverted (see
+        <option>--wal-upgrade-rollback</option>) until it is explicitly retired
+        (see <option>--wal-upgrade-delete-old</option>), and it makes the
+        upgraded state streamable so a standby can be re-provisioned from the
+        upgraded primary by ordinary replication rather than a fresh base backup.
+       </para>
+       <para>
+        A replacement standby need not even be initialized with
+        <command>initdb</command>: an empty data directory containing only its
+        configuration, a <filename>standby.signal</filename>, a
+        <filename>pg_upgrade.signal</filename> sentinel (whose contents are the
+        path of this standby's retained pre-upgrade data directory, from which
+        user relations are linked in), and
+        <varname>primary_conninfo</varname> pointing at the upgraded primary is
+        sufficient.  On its first start the server synthesizes the control file
+        and <filename>PG_VERSION</filename>, fetches the upgrade anchor from the
+        primary over the replication connection, and streams the upgrade in,
+        coming up as a hot standby of the new-version primary.
+       </para>
+       <para>
+        It may be combined with any transfer mode.  With
+        <option>--copy</option> or <option>--clone</option> the old cluster's
+        files are duplicated, so it stays intact and the upgrade is revertable.
+        <option>--link</option> and <option>--swap</option> also work, but they
+        disable the old cluster (as in an ordinary upgrade), so those upgrades
+        are forward-only: <option>--wal-upgrade-rollback</option> then has
+        nothing to revert to.  (<option>--link</option> shares the old cluster's
+        files, and <option>--swap</option> moves them into the new cluster.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><option>--wal-upgrade-rollback</option></term>
+      <listitem>
+       <para>
+        Discard a cluster produced by <option>--wal-upgrade</option> and return
+        to the old cluster, which was never modified.  The new cluster data
+        directory given with <option>-D</option>/<option>--new-datadir</option>
+        is removed; the operator then simply restarts on the old data directory
+        with the old binaries.
+       </para>
+       <para>
+        Rollback is available whenever the old cluster is intact.  If the old
+        cluster is unusable &mdash; consumed by <option>--swap</option>, damaged,
+        or already started after the upgrade &mdash; there is nothing safe to
+        return to, so the command exits with an error.  If the new cluster has
+        already been started and taken writes, rolling back discards them; the
+        command warns rather than blocks, leaving that judgement to the
+        operator.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><option>--wal-upgrade-delete-old</option></term>
+      <listitem>
+       <para>
+        Retire the old cluster once the operator is confident in the upgrade.
+        The old cluster data directory given with
+        <option>-d</option>/<option>--old-datadir</option> is removed.  This
+        requires a fully upgraded new cluster
+        (<option>-D</option>/<option>--new-datadir</option>), so the old cluster
+        is never removed unless a working replacement exists.
+       </para>
+       <para>
+        <application>pg_upgrade</application> does not distinguish a primary from
+        a standby: to clean up a replica set, run this command on each node
+        whose old cluster should be removed.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><option>--wal-upgrade-signal-handoff</option></term>
+      <listitem>
+       <para>
+        Emit a handoff trigger into the live old cluster's WAL and then shut the
+        old cluster down at that point, so that streaming standbys following it
+        stand down before the upgrade.  Shutting the old cluster down as part of
+        this step ensures no transaction appends WAL after the handoff record,
+        which would otherwise leave standbys stopping short of the primary's
+        final state.  Requires the old cluster's data directory
+        (<option>-d</option>/<option>--old-datadir</option>) and bin directory
+        (<option>-b</option>/<option>--old-bindir</option>).  This is used before
+        a <option>--wal-upgrade</option> run in replicated deployments; it does
+        not itself upgrade anything.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry>
       <term><option>-?</option></term>
       <term><option>--help</option></term>
@@ -462,6 +589,12 @@ make prefix=/usr/local/pgsql.new install
      prebuilt installers do this step automatically. There is no need to
      start the new cluster.
     </para>
+    <para>
+     Alternatively, pass <option>--initdb</option> to
+     <application>pg_upgrade</application> to have it run
+     <command>initdb</command> automatically, deriving the required settings
+     from the old cluster.  In that case this manual step can be skipped.
+    </para>
    </step>
 
    <step>
diff --git a/src/backend/access/rmgrdesc/Makefile b/src/backend/access/rmgrdesc/Makefile
index cd95eec37f..601c30dd5a 100644
--- a/src/backend/access/rmgrdesc/Makefile
+++ b/src/backend/access/rmgrdesc/Makefile
@@ -10,6 +10,7 @@ include $(top_builddir)/src/Makefile.global
 
 OBJS = \
 	brindesc.o \
+	pgupgradedesc.o \
 	clogdesc.o \
 	committsdesc.o \
 	dbasedesc.o \
diff --git a/src/backend/access/rmgrdesc/meson.build b/src/backend/access/rmgrdesc/meson.build
index d9000ccd9f..5070a526d3 100644
--- a/src/backend/access/rmgrdesc/meson.build
+++ b/src/backend/access/rmgrdesc/meson.build
@@ -14,6 +14,7 @@ rmgr_desc_sources = files(
   'logicalmsgdesc.c',
   'mxactdesc.c',
   'nbtdesc.c',
+  'pgupgradedesc.c',
   'relmapdesc.c',
   'replorigindesc.c',
   'rmgrdesc_utils.c',
diff --git a/src/backend/access/rmgrdesc/pgupgradedesc.c b/src/backend/access/rmgrdesc/pgupgradedesc.c
new file mode 100644
index 0000000000..6ffe85848b
--- /dev/null
+++ b/src/backend/access/rmgrdesc/pgupgradedesc.c
@@ -0,0 +1,170 @@
+/*-------------------------------------------------------------------------
+ *
+ * pgupgradedesc.c
+ *	  rmgr descriptor routines for RM_PG_UPGRADE_ID WAL records.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ *
+ * IDENTIFICATION
+ *	  src/backend/access/rmgrdesc/pgupgradedesc.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/pgupgrade_wal.h"
+#include "access/transam.h"		/* FirstNormalObjectId */
+#include "access/xlogreader.h"
+#include "catalog/pg_control.h"
+#include "lib/stringinfo.h"
+
+void
+pg_upgrade_desc(StringInfo buf, XLogReaderState *record)
+{
+	char	   *rec = XLogRecGetData(record);
+	uint8		info = XLogRecGetInfo(record) & ~XLR_INFO_MASK;
+
+	if (info == XLOG_UPGRADE_START || info == XLOG_UPGRADE_COMPLETE)
+	{
+		xl_pg_upgrade xlrec;
+
+		memcpy(&xlrec, rec, SizeOfXLPgUpgrade);
+		appendStringInfo(buf, "old_major_version %u; new_major_version %u; time %lld",
+						 xlrec.old_major_version,
+						 xlrec.new_major_version,
+						 (long long) xlrec.upgrade_time);
+	}
+	else if (info == XLOG_UPGRADE_SLRU_DATA)
+	{
+		xl_upgrade_slru_data xlrec;
+		const char *slru_names[] = UPGRADE_SLRU_DIRS;
+
+		memcpy(&xlrec, rec, SizeOfXLUpgradeSlruData);
+		appendStringInfo(buf, "slru %s; segs %04" PRIX64 "..%04" PRIX64 "; bytes %u",
+						 xlrec.slru_type < 3 ? slru_names[xlrec.slru_type] : "unknown",
+						 xlrec.first_seg,
+						 xlrec.last_seg,
+						 xlrec.total_bytes);
+	}
+	else if (info == XLOG_UPGRADE_RELFILE_DATA)
+	{
+		/* Batched: [entry][data][entry][data]...  Summarize the entries. */
+		char	   *ptr = rec;
+		char	   *end = rec + XLogRecGetDataLen(record);
+		int			nentries = 0;
+		int			nuser = 0;
+		uint64		total = 0;
+		xl_upgrade_relfile_entry first;
+
+		while (ptr + SizeOfXLUpgradeRelfileEntry <= end)
+		{
+			xl_upgrade_relfile_entry ent;
+
+			memcpy(&ent, ptr, SizeOfXLUpgradeRelfileEntry);
+			if (nentries == 0)
+				first = ent;
+			nentries++;
+
+			/*
+			 * User relations are listed in the RELINK manifest, not carried
+			 * as data in a RELFILE record.  Count any user relfilenumbers
+			 * that do appear here, so the "user" total in pg_waldump is 0 for
+			 * a schema-only window.
+			 */
+			if (ent.relfilenumber >= FirstNormalObjectId)
+				nuser++;
+			total += ent.nbytes;
+			ptr += SizeOfXLUpgradeRelfileEntry + ent.nbytes;
+		}
+		appendStringInfo(buf, "%d files (%d user); %llu bytes; first rel %u/%u/%u seg %u blkoff %u",
+						 nentries, nuser, (unsigned long long) total,
+						 nentries ? first.tablespace_oid : 0,
+						 nentries ? first.database_oid : 0,
+						 nentries ? first.relfilenumber : 0,
+						 nentries ? first.segno : 0,
+						 nentries ? first.blockoff : 0);
+	}
+	else if (info == XLOG_UPGRADE_RELINK)
+	{
+		/* Manifest of user relations to link on the standby: no file data. */
+		char	   *ptr = rec;
+		char	   *end = rec + XLogRecGetDataLen(record);
+		int			nentries = 0;
+		xl_upgrade_relink_entry first;
+
+		while (ptr + SizeOfXLUpgradeRelinkEntry <= end)
+		{
+			xl_upgrade_relink_entry ent;
+
+			memcpy(&ent, ptr, SizeOfXLUpgradeRelinkEntry);
+			if (nentries == 0)
+				first = ent;
+			nentries++;
+			ptr += SizeOfXLUpgradeRelinkEntry;
+		}
+		appendStringInfo(buf, "%d files to relink; first rel %u/%u/%u fork %u seg %u",
+						 nentries,
+						 nentries ? first.tablespace_oid : 0,
+						 nentries ? first.database_oid : 0,
+						 nentries ? first.relfilenumber : 0,
+						 nentries ? first.forknum : 0,
+						 nentries ? first.segno : 0);
+	}
+	else if (info == XLOG_UPGRADE_RAWFILE)
+	{
+		xl_upgrade_rawfile xlrec;
+		char	   *path = rec + SizeOfXLUpgradeRawfile;
+
+		memcpy(&xlrec, rec, SizeOfXLUpgradeRawfile);
+		appendStringInfo(buf, "rawfile \"%.*s\"; bytes %u",
+						 (int) xlrec.path_len, path, xlrec.data_len);
+	}
+	else if (info == XLOG_UPGRADE_DIRTREE)
+	{
+		xl_upgrade_dirtree xlrec;
+		char	   *first = rec + SizeOfXLUpgradeDirtree;
+
+		memcpy(&xlrec, rec, SizeOfXLUpgradeDirtree);
+		appendStringInfo(buf, "dirs %u (%u bytes); symlinks %u (%u bytes); first \"%s\"",
+						 xlrec.ndirs, xlrec.dir_bytes,
+						 xlrec.nsymlinks, xlrec.sym_bytes,
+						 xlrec.ndirs > 0 ? first : "");
+	}
+	else if (info == XLOG_UPGRADE_HANDOFF)
+	{
+		xl_pg_upgrade_handoff xlrec;
+
+		memcpy(&xlrec, rec, SizeOfXLPgUpgradeHandoff);
+		appendStringInfo(buf, "old_major_version %u; target_major_version %u; time %lld",
+						 xlrec.old_major_version,
+						 xlrec.target_major_version,
+						 (long long) xlrec.handoff_time);
+	}
+}
+
+const char *
+pg_upgrade_identify(uint8 info)
+{
+	switch (info & ~XLR_INFO_MASK)
+	{
+		case XLOG_UPGRADE_START:
+			return "PG_UPGRADE_START";
+		case XLOG_UPGRADE_COMPLETE:
+			return "PG_UPGRADE_COMPLETE";
+		case XLOG_UPGRADE_SLRU_DATA:
+			return "UPGRADE_SLRU_DATA";
+		case XLOG_UPGRADE_RELFILE_DATA:
+			return "UPGRADE_RELFILE_DATA";
+		case XLOG_UPGRADE_RELINK:
+			return "UPGRADE_RELINK";
+		case XLOG_UPGRADE_RAWFILE:
+			return "UPGRADE_RAWFILE";
+		case XLOG_UPGRADE_DIRTREE:
+			return "UPGRADE_DIRTREE";
+		case XLOG_UPGRADE_HANDOFF:
+			return "PG_UPGRADE_HANDOFF";
+	}
+	return NULL;
+}
diff --git a/src/backend/access/rmgrdesc/xlogdesc.c b/src/backend/access/rmgrdesc/xlogdesc.c
index 2468a7d257..190a22b564 100644
--- a/src/backend/access/rmgrdesc/xlogdesc.c
+++ b/src/backend/access/rmgrdesc/xlogdesc.c
@@ -218,6 +218,7 @@ xlog_desc(StringInfo buf, XLogReaderState *record)
 	{
 		/* no further information to print */
 	}
+	/* Upgrade records are handled by pg_upgrade_desc() via RM_PG_UPGRADE_ID */
 }
 
 const char *
@@ -225,6 +226,16 @@ xlog_identify(uint8 info)
 {
 	const char *id = NULL;
 
+	/*
+	 * Upgrade record types must be checked before masking, since
+	 * 0xC0/0xC1/0xC2 all reduce to 0xC0 after applying ~XLR_INFO_MASK (0xF0).
+	 */
+
+	/*
+	 * Upgrade records are handled by pg_upgrade_identify() via
+	 * RM_PG_UPGRADE_ID
+	 */
+
 	switch (info & ~XLR_INFO_MASK)
 	{
 		case XLOG_CHECKPOINT_SHUTDOWN:
diff --git a/src/backend/access/transam/Makefile b/src/backend/access/transam/Makefile
index a32f473e0a..81294a8f96 100644
--- a/src/backend/access/transam/Makefile
+++ b/src/backend/access/transam/Makefile
@@ -37,7 +37,8 @@ OBJS = \
 	xlogrecovery.o \
 	xlogstats.o \
 	xlogutils.o \
-	xlogwait.o
+	xlogwait.o \
+	pgupgrade_wal.o
 
 include $(top_srcdir)/src/backend/common.mk
 
diff --git a/src/backend/access/transam/clog.c b/src/backend/access/transam/clog.c
index 75012d4b8f..3eb6e4f5f0 100644
--- a/src/backend/access/transam/clog.c
+++ b/src/backend/access/transam/clog.c
@@ -1115,6 +1115,16 @@ clog_redo(XLogReaderState *record)
 		elog(PANIC, "clog_redo: unknown op code %u", info);
 }
 
+/*
+ * Restore a captured pg_xact (CLOG) segment during --wal-upgrade replay.
+ * Thin wrapper exposing XactCtl to the RM_PG_UPGRADE_ID redo handler.
+ */
+void
+CLOGUpgradeRestoreSegment(int64 segno, const char *data, Size datalen)
+{
+	SlruUpgradeRestoreSegment(XactCtl, segno, data, datalen);
+}
+
 /*
  * Entrypoint for sync.c to sync clog files.
  */
diff --git a/src/backend/access/transam/meson.build b/src/backend/access/transam/meson.build
index 06aadc7f31..cabc681622 100644
--- a/src/backend/access/transam/meson.build
+++ b/src/backend/access/transam/meson.build
@@ -6,6 +6,7 @@ backend_sources += files(
   'generic_xlog.c',
   'multixact.c',
   'parallel.c',
+  'pgupgrade_wal.c',
   'rmgr.c',
   'slru.c',
   'subtrans.c',
diff --git a/src/backend/access/transam/multixact.c b/src/backend/access/transam/multixact.c
index 8c69465813..7856ced105 100644
--- a/src/backend/access/transam/multixact.c
+++ b/src/backend/access/transam/multixact.c
@@ -2995,6 +2995,23 @@ multixact_redo(XLogReaderState *record)
 		elog(PANIC, "multixact_redo: unknown op code %u", info);
 }
 
+/*
+ * Restore captured pg_multixact/offsets and pg_multixact/members segments
+ * during --wal-upgrade replay.  Thin wrappers exposing the static SLRU
+ * ctls to the RM_PG_UPGRADE_ID redo handler.
+ */
+void
+MultiXactOffsetUpgradeRestoreSegment(int64 segno, const char *data, Size datalen)
+{
+	SlruUpgradeRestoreSegment(MultiXactOffsetCtl, segno, data, datalen);
+}
+
+void
+MultiXactMemberUpgradeRestoreSegment(int64 segno, const char *data, Size datalen)
+{
+	SlruUpgradeRestoreSegment(MultiXactMemberCtl, segno, data, datalen);
+}
+
 /*
  * Entrypoint for sync.c to sync offsets files.
  */
diff --git a/src/backend/access/transam/pgupgrade_wal.c b/src/backend/access/transam/pgupgrade_wal.c
new file mode 100644
index 0000000000..14c20feed6
--- /dev/null
+++ b/src/backend/access/transam/pgupgrade_wal.c
@@ -0,0 +1,1963 @@
+/*
+ * pgupgrade_wal.c
+ *
+ * WAL redo and startup handling for RM_PG_UPGRADE_ID records written by
+ * pg_upgrade --wal-upgrade:
+ *
+ *   XLOG_UPGRADE_START    (0x00) -- window open, write PG_VERSION
+ *   XLOG_UPGRADE_COMPLETE (0x10) -- window close, informational
+ *   XLOG_UPGRADE_SLRU_DATA   (0x20) -- bulk SLRU segment image
+ *   XLOG_UPGRADE_RELFILE_DATA(0x30) -- bulk relation file segment image (system rels)
+ *   XLOG_UPGRADE_DIRTREE     (0x40) -- initdb directory + symlink skeleton
+ *   XLOG_UPGRADE_RAWFILE     (0x50) -- verbatim non-relation file image
+ *   XLOG_UPGRADE_HANDOFF     (0x60) -- stand-down trigger for an old-format standby
+ *   XLOG_UPGRADE_RELINK      (0x70) -- manifest of USER relation files a streaming
+ *                                     standby links from its retained old datadir
+ *                                     (the window omits their data)
+ *
+ * The XID/OID/multixact counters are not WAL-logged: they are transplanted into
+ * pg_control before the end-of-upgrade checkpoint, which carries them, and
+ * recovery reproduces them from that checkpoint.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/backend/access/transam/pgupgrade_wal.c
+ */
+#include "postgres.h"
+
+#include <fcntl.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+#include "access/clog.h"		/* CLOGUpgradeRestoreSegment */
+#include "access/multixact.h"	/* MultiXact*UpgradeRestoreSegment */
+#include "access/pgupgrade_wal.h"
+#include "access/slru.h"
+#include "access/xlog.h"
+#include "access/xlog_internal.h"
+#include "access/xlogrecovery.h"	/* pgUpgradeReplayInProgress */
+#include "access/xloginsert.h"
+#include "access/xlogreader.h"
+#include "access/xlogutils.h"	/* wal_segment_close */
+#include "catalog/pg_control.h"
+#include "catalog/pg_tablespace_d.h"
+#include "common/controldata_utils.h"	/* get_controlfile for local CN
+										 * derivation */
+#include "common/file_perm.h"	/* pg_dir_create_mode */
+#include "common/file_utils.h"	/* pg_clone_file, pg_copy_file_range_all */
+#include "common/relpath.h"		/* RelFileLocator, ForkNumber */
+#include "miscadmin.h"
+#include "storage/bufmgr.h"		/* buffer-manager RELFILE_DATA redo */
+#include "storage/smgr.h"		/* smgr create for empty relfiles */
+#include "storage/bufpage.h"	/* PageSetLSN */
+#include "storage/fd.h"
+#include "storage/copydir.h"	/* copydir() for WAL segment migration */
+#include "storage/ipc.h"
+#include "storage/lwlock.h"
+#include "replication/walreceiver.h"	/* libpqwalreceiver client for
+										 * auto-anchor */
+#include "utils/elog.h"
+
+/* -------------------------------------------------------------------------
+ * pg_upgrade WAL-replay-based atomicity check
+ * -------------------------------------------------------------------------
+ */
+
+/*
+ * Private state for the XLogReader used by UpgradeWalScanMarkers().
+ */
+typedef struct UpgradeWalReadPrivate
+{
+	char		dir[MAXPGPATH]; /* WAL directory to read segments from */
+	TimeLineID	tli;			/* timeline (always 1 for upgrade WAL) */
+	XLogRecPtr	endptr;			/* one past the last byte of available WAL */
+	int			openerr_elevel; /* ereport level if a segment cannot be opened */
+} UpgradeWalReadPrivate;
+
+static void
+UpgradeWalSegOpen(XLogReaderState *state, XLogSegNo nextSegNo, TimeLineID *tli_p)
+{
+	UpgradeWalReadPrivate *priv = (UpgradeWalReadPrivate *) state->private_data;
+	char		fname[MAXFNAMELEN];
+	char		path[MAXPGPATH];
+
+	XLogFileName(fname, priv->tli, nextSegNo, state->segcxt.ws_segsize);
+	snprintf(path, sizeof(path), "%s/%s", priv->dir, fname);
+	state->seg.ws_file = BasicOpenFile(path, O_RDONLY | PG_BINARY);
+	if (state->seg.ws_file < 0)
+		ereport(priv->openerr_elevel,
+				(errcode_for_file_access(),
+				 errmsg("could not open upgrade WAL segment \"%s\": %m", path)));
+}
+
+static void
+UpgradeWalSegClose(XLogReaderState *state)
+{
+	if (state->seg.ws_file >= 0)
+		close(state->seg.ws_file);
+	state->seg.ws_file = -1;
+}
+
+static int
+UpgradeWalPageRead(XLogReaderState *state, XLogRecPtr targetPagePtr, int reqLen,
+				   XLogRecPtr targetRecPtr, char *readBuf)
+{
+	UpgradeWalReadPrivate *priv = (UpgradeWalReadPrivate *) state->private_data;
+	int			count = XLOG_BLCKSZ;
+	WALReadError errinfo;
+
+	/* Never read past the last available byte of WAL. */
+	if (targetPagePtr + XLOG_BLCKSZ > priv->endptr)
+	{
+		if (targetPagePtr + reqLen > priv->endptr)
+			return -1;
+		count = (int) (priv->endptr - targetPagePtr);
+	}
+
+	if (!WALRead(state, readBuf, targetPagePtr, count, priv->tli, &errinfo))
+		return -1;
+
+	return count;
+}
+
+/*
+ * Parse the WAL in "waldir" and locate the pg_upgrade markers plus the
+ * end-of-upgrade checkpoint (CN) that recovery must anchor at.  A real
+ * XLogReader is used, not a byte-pattern match: the upgrade WAL is full of
+ * arbitrary full-page-image bytes, so any fixed byte pair recurs by chance.
+ *
+ * Out-params:
+ *   found_start / found_complete -- the START / COMPLETE markers were seen.
+ *   cn        -- CheckPoint of the last online checkpoint preceding START.  This
+ *               is CN, the recovery anchor; it carries the transplanted
+ *               XID/OID/multixact counters.
+ *   cn_lsn    -- record LSN of that checkpoint (-> ControlFile.checkPoint).
+ *
+ * Returns false if there is no readable WAL at all.  A present-but-unreadable or
+ * corrupt window (a segment cannot be opened, a checkpoint record is malformed)
+ * stops recovery with FATAL: this runs in the startup process, where a broken
+ * local window must not be silently ignored.
+ */
+bool
+UpgradeWalScanMarkers(const char *waldir, bool *found_start,
+					  bool *found_complete, CheckPoint *cn,
+					  XLogRecPtr *cn_lsn, uint64 *wal_sysid)
+{
+	DIR		   *dir;
+	struct dirent *de;
+	int			segsize = 0;
+	XLogSegNo	lowseg = 0;
+	XLogSegNo	highseg = 0;
+	XLogSegNo	runstart = 0;
+	bool		any = false;
+	char		runstart_path[MAXPGPATH] = {0};
+	UpgradeWalReadPrivate priv;
+	XLogReaderState *reader;
+	XLogRecPtr	startptr;
+	XLogRecPtr	first;
+	CheckPoint	last_ckpt;
+	XLogRecPtr	last_ckpt_lsn = InvalidXLogRecPtr;
+
+	*found_start = false;
+	*found_complete = false;
+	*cn_lsn = InvalidXLogRecPtr;
+	*wal_sysid = 0;
+	MemSet(cn, 0, sizeof(CheckPoint));
+	MemSet(&last_ckpt, 0, sizeof(CheckPoint));
+
+	/*
+	 * First pass over the directory: determine the segment size (all WAL
+	 * segment files are exactly one segment long) and the lowest/highest
+	 * segment numbers present.
+	 */
+	dir = AllocateDir(waldir);
+	if (dir == NULL)
+		return false;
+	while ((de = ReadDir(dir, waldir)) != NULL)
+	{
+		TimeLineID	ftli;
+		XLogSegNo	segno;
+		char		path[MAXPGPATH];
+		struct stat st;
+
+		if (!IsXLogFileName(de->d_name))
+			continue;
+
+		if (segsize == 0)
+		{
+			snprintf(path, sizeof(path), "%s/%s", waldir, de->d_name);
+			if (stat(path, &st) != 0 || st.st_size == 0)
+				continue;
+			segsize = (int) st.st_size;
+		}
+
+		XLogFromFileName(de->d_name, &ftli, &segno, segsize);
+
+		/*
+		 * The upgrade WAL (CN..COMPLETE) is always on timeline 1.  Only bound
+		 * the scan by TLI-1 segments: after a standby's end-of-recovery
+		 * timeline switch, higher-TLI segments also live in pg_wal/, and
+		 * including them would push the scan's end past the last TLI-1
+		 * segment, so the reader would try to open a nonexistent 00000001...
+		 * segment and FATAL.
+		 */
+		if (ftli != 1)
+			continue;
+		if (!any || segno < lowseg)
+			lowseg = segno;
+		if (!any || segno > highseg)
+			highseg = segno;
+		any = true;
+	}
+	FreeDir(dir);
+
+	if (!any || segsize == 0)
+		return false;
+
+	/*
+	 * Bound the scan to the contiguous run of TLI-1 segments ending at
+	 * highseg, not from lowseg.  The upgrade window is always the topmost
+	 * contiguous run; when delivered by archive-PITR staging, pg_wal/ can
+	 * also hold unrelated pre-window segments from the restored base backup,
+	 * with a gap between them. Starting at lowseg would make
+	 * XLogFindNextRecord walk into that hole and FATAL.  Walk down from
+	 * highseg while each preceding segment is present.  (On the primary's own
+	 * first start the window is the only content, so runstart == lowseg.)
+	 */
+	runstart = highseg;
+	{
+		char		segname[MAXFNAMELEN];
+		char		segpath[MAXPGPATH];
+		struct stat st;
+
+		while (runstart > lowseg)
+		{
+			XLogFileName(segname, 1, runstart - 1, segsize);
+			snprintf(segpath, sizeof(segpath), "%s/%s", waldir, segname);
+			if (stat(segpath, &st) != 0)
+				break;			/* gap: runstart-1 is missing */
+			runstart--;
+		}
+		XLogFileName(segname, 1, runstart, segsize);
+		snprintf(runstart_path, sizeof(runstart_path), "%s/%s", waldir, segname);
+	}
+
+	/*
+	 * Capture the system identifier the upgrade WAL was emitted under, from
+	 * xlp_sysid in the run-start segment's long page header.  Recovery
+	 * validates every WAL page's xlp_sysid against
+	 * pg_control->system_identifier, so the arming step stamps pg_control
+	 * with this value -- letting a fresh skeleton adopt the sysid in-process
+	 * from the WAL, exactly as it does CN, with no offline sysid stamping.
+	 */
+	{
+		int			fd = OpenTransientFile(runstart_path, O_RDONLY | PG_BINARY);
+		XLogLongPageHeaderData longhdr;
+
+		if (fd >= 0)
+		{
+			if (pg_pread(fd, &longhdr, sizeof(longhdr), 0) == sizeof(longhdr) &&
+				longhdr.std.xlp_magic == XLOG_PAGE_MAGIC &&
+				(longhdr.std.xlp_info & XLP_LONG_HEADER))
+				*wal_sysid = longhdr.xlp_sysid;
+			CloseTransientFile(fd);
+		}
+	}
+
+	priv.tli = 1;
+	priv.openerr_elevel = FATAL;
+	strlcpy(priv.dir, waldir, sizeof(priv.dir));
+	XLogSegNoOffsetToRecPtr(runstart, 0, segsize, startptr);
+	XLogSegNoOffsetToRecPtr(highseg + 1, 0, segsize, priv.endptr);
+
+	reader = XLogReaderAllocate(segsize, NULL,
+								XL_ROUTINE(.page_read = UpgradeWalPageRead,
+										   .segment_open = UpgradeWalSegOpen,
+										   .segment_close = UpgradeWalSegClose),
+								&priv);
+	if (reader == NULL)
+		return false;
+
+	/*
+	 * Find the first valid record at/after the start of the run-start
+	 * segment.
+	 */
+	{
+		char	   *errormsg = NULL;
+
+		first = XLogFindNextRecord(reader, startptr, &errormsg);
+	}
+	if (XLogRecPtrIsInvalid(first))
+	{
+		XLogReaderFree(reader);
+		return false;
+	}
+
+	XLogBeginRead(reader, first);
+	for (;;)
+	{
+		char	   *errormsg;
+		XLogRecord *record = XLogReadRecord(reader, &errormsg);
+		uint8		rmid;
+		uint8		info;
+
+		if (record == NULL)
+			break;				/* end of WAL or unreadable -- stop */
+
+		rmid = XLogRecGetRmid(reader);
+		info = XLogRecGetInfo(reader) & ~XLR_INFO_MASK;
+
+		if (rmid == RM_XLOG_ID &&
+			(info == XLOG_CHECKPOINT_ONLINE || info == XLOG_CHECKPOINT_SHUTDOWN))
+		{
+			/*
+			 * Track the most recent checkpoint so that, on reaching START, we
+			 * capture CN (the last checkpoint preceding START).  "Already
+			 * applied?" is decided by the caller from the control file, not
+			 * by detecting a post-COMPLETE checkpoint here (it may be on a
+			 * later timeline this TLI-1 scan cannot read).
+			 */
+			if (XLogRecGetDataLen(reader) < sizeof(CheckPoint))
+				ereport(FATAL,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("checkpoint record too short")));
+			memcpy(&last_ckpt, XLogRecGetData(reader), sizeof(CheckPoint));
+			last_ckpt_lsn = reader->ReadRecPtr;
+		}
+		else if (rmid == RM_PG_UPGRADE_ID)
+		{
+			if (info == XLOG_UPGRADE_START)
+			{
+				*found_start = true;
+				/* CN is the checkpoint immediately preceding START */
+				*cn = last_ckpt;
+				*cn_lsn = last_ckpt_lsn;
+			}
+			else if (info == XLOG_UPGRADE_COMPLETE)
+			{
+				*found_complete = true;
+				break;			/* window is closed; nothing after COMPLETE
+								 * matters */
+			}
+		}
+	}
+
+	XLogReaderFree(reader);
+	return true;
+}
+
+
+/*
+ * True once PerformWalUpgradeIfNeeded() has armed the sanctioned upgrade
+ * bootstrap for this startup.  The redo handlers consult it to distinguish the
+ * bootstrap replay (apply the upgrade images) from an ordinary/standby stream
+ * that merely contains these records (stop and require a restart).
+ * Startup-process-local.
+ */
+static bool in_upgrade_bootstrap = false;
+
+/*
+ * Set when first-startup armed the STREAMING-STANDBY path (a fresh skeleton
+ * auto-armed from the primary), as opposed to the primary's own crash recovery
+ * or an archive/PITR restore.  On the streaming path the skeleton is empty of
+ * user data, so an XLOG_UPGRADE_RELINK manifest with no old-datadir path to link
+ * from is a fatal misconfiguration (the user relations would silently be
+ * absent); on the other two paths the files are already on disk, so the same
+ * manifest is a legitimate no-op.  Startup-process-local.
+ */
+static bool armed_streaming_standby = false;
+
+/*
+ * The CN LSN the streaming-standby path armed recovery at (locally derived from
+ * the retained old datadir; see ArmFromLocalDerivationIfConfigured).  Recorded so
+ * the XLOG_UPGRADE_START redo handler can verify the checkpoint recovery actually
+ * began at matches the derivation -- turning a wrong derivation into a clear FATAL
+ * rather than silent mis-recovery.  InvalidXLogRecPtr on every other path.
+ * Startup-process-local.
+ */
+static XLogRecPtr armed_cn_lsn = InvalidXLogRecPtr;
+
+/*
+ * Is the durable "window reached COMPLETE" flag set in pg_control?  It is set
+ * and fsync'd by the XLOG_UPGRADE_COMPLETE redo handler (and by pg_upgrade on
+ * the primary, which does not replay) the instant the window reaches COMPLETE,
+ * so it distinguishes a completed upgrade from a crashed partial one even when
+ * a torn final WAL page hides the COMPLETE record from the scan.  The caller
+ * pairs it with a control checkpoint past CN to decide "finalized" (see
+ * PerformWalUpgradeIfNeeded).  Reads the control file already loaded by
+ * LocalProcessControlFile() before the startup process runs.
+ */
+static bool
+UpgradeWindowFinalized(void)
+{
+	return GetControlFileUpgradeFinalized();
+}
+
+
+/*
+ * Compute the CN segment number by replicating pg_resetwal's FindEndOfXLOG rule
+ * against the retained old datadir, matching the segment the producer's
+ * "pg_resetwal -l" targeted on the primary:
+ *
+ *   CN_seg = max( XLByteToSeg(old_tail, old_seg_size),
+ *                 highest segment in <old_datadir>/pg_wal ) + 1
+ *
+ * The scan takes the max segno over all timelines and over partial (.partial)
+ * segments, without a timeline filter, exactly as pg_resetwal.c FindEndOfXLOG
+ * does.  This must match pg_resetwal because the producer's -l target came from
+ * the old cluster's own "pg_resetwal -n" result: if the old cluster was ever
+ * promoted, a higher-timeline segment could hold the max segno, and a
+ * TLI-1-only scan would compute a smaller CN_seg than the producer targeted.
+ * (The window itself is on timeline 1, but this is a floor computation, not a
+ * window scan.)  The +1 advances into virgin territory, as pg_resetwal does.
+ * Segment math uses the old cluster's segment size.
+ */
+static XLogSegNo
+DeriveUpgradeCnSegment(const char *old_datadir, XLogRecPtr old_tail,
+					   int old_seg_size)
+{
+	char		waldir[MAXPGPATH];
+	DIR		   *dir;
+	struct dirent *de;
+	XLogSegNo	maxseg;
+
+	/* Floor: the segment holding the old cluster's checkpoint redo. */
+	XLByteToSeg(old_tail, maxseg, old_seg_size);
+
+	snprintf(waldir, sizeof(waldir), "%s/%s", old_datadir, XLOGDIR);
+	dir = AllocateDir(waldir);
+	if (dir == NULL)
+		ereport(FATAL,
+				(errcode_for_file_access(),
+				 errmsg("could not open old cluster WAL directory \"%s\": %m",
+						waldir)));
+	while ((de = ReadDir(dir, waldir)) != NULL)
+	{
+		TimeLineID	ftli;
+		XLogSegNo	segno;
+
+		/* complete and partial segments both count (see header) */
+		if (!IsXLogFileName(de->d_name) && !IsPartialXLogFileName(de->d_name))
+			continue;
+
+		XLogFromFileName(de->d_name, &ftli, &segno, old_seg_size);
+		(void) ftli;			/* parsed for the API; not a filter */
+
+		if (segno > maxseg)
+			maxseg = segno;
+	}
+	FreeDir(dir);
+
+	/* Advance by one into virgin territory (matches pg_resetwal). */
+	return maxseg + 1;
+}
+
+/*
+ * Is this data directory staged for --wal-upgrade recovery?  True iff the
+ * pg_upgrade.signal sentinel is present.  The sentinel is the single gate all
+ * three detection sites share (checkDataDir's control-file synthesis, and the
+ * streaming/archive arms in this file), so they classify a directory
+ * identically; the surrounding config (primary_conninfo vs recovery.signal)
+ * then selects streaming vs archive-PITR.
+ */
+bool
+UpgradeSignalStaged(void)
+{
+	char		path[MAXPGPATH];
+	struct stat st;
+
+	snprintf(path, sizeof(path), "%s/%s", DataDir, PG_UPGRADE_SIGNAL_FILE);
+	return stat(path, &st) == 0;
+}
+
+/*
+ * Automatic streaming-standby arming, deriving CN LOCALLY.
+ *
+ * A fresh vN+1 skeleton with primary_conninfo set arms its control file at CN
+ * without asking the primary for the anchor: it derives CN itself from its own
+ * retained old data directory, exactly reproducing where the producer's
+ * pg_resetwal placed CN.  The one thing it still needs from the primary is the
+ * system identifier -- the new cluster's sysid, which the window WAL pages are
+ * stamped with -- and that comes from the standard IDENTIFY_SYSTEM command it
+ * would run anyway.  Runs in the startup process before StartupXLOG, so no SQL
+ * backend is needed.
+ *
+ * Derivation:
+ *   1. sysid from IDENTIFY_SYSTEM on the primary (== the new cluster's sysid).
+ *   2. old_tail = old cluster's checkPointCopy.redo, from its control file.
+ *   3. CN_seg = FindEndOfXLOG rule over the old datadir (DeriveUpgradeCnSegment).
+ *   4. CN is the DB_SHUTDOWNED checkpoint at the start of CN_seg on timeline 1:
+ *      cn_lsn = segment-boundary LSN just past the long page header, and because
+ *      CN is a shutdown checkpoint, redo == cn_lsn.
+ *
+ * Gated on the pg_upgrade.signal sentinel: only a skeleton staged for
+ * --wal-upgrade recovery carries it, so an ordinary streaming standby
+ * (primary_conninfo set, but no sentinel) is left entirely untouched and starts
+ * normally.  The streaming mode is inferred from primary_conninfo being set (vs.
+ * the recovery.signal-driven archive path).  Returns false (caller falls back to
+ * the local-window path) when the sentinel is absent or no primary is configured.
+ * A missing old datadir GUC, a connection failure, or an unreadable old control
+ * file while armed is a hard FATAL.
+ */
+static bool
+ArmFromLocalDerivationIfConfigured(void)
+{
+	WalReceiverConn *conn;
+	char	   *err = NULL;
+	TimeLineID	ignored_tli = 0;
+	char	   *sysid_str;
+	uint64		sysid = 0;
+	const char *old_datadir;
+	ControlFileData *old_control;
+	bool		crc_ok = false;
+	XLogRecPtr	old_tail;
+	int			old_seg_size;
+	XLogSegNo	cn_seg;
+	XLogRecPtr	cn_lsn;
+	CheckPoint	cn;
+	struct stat st;
+
+	/*
+	 * Only act on a skeleton explicitly staged for --wal-upgrade recovery
+	 * (the pg_upgrade.signal sentinel).  Without it an ordinary streaming
+	 * standby would try to derive an upgrade anchor on every start.
+	 */
+	if (!UpgradeSignalStaged())
+		return false;
+
+	/*
+	 * Once the window has been replayed to COMPLETE this node is a finalized
+	 * new-version cluster and the durable pg_control upgrade_finalized flag
+	 * is set (the COMPLETE redo fsync'd it).  Re-deriving CN and re-arming at
+	 * the old CN would rewind recovery behind this standby's actual replay
+	 * position, and fail once the primary drops the retention slot, so a
+	 * restart with the sentinel still staged falls through here and starts as
+	 * an ordinary hot standby.  This mirrors the local-window path's
+	 * UpgradeWindowFinalized() guard.
+	 */
+	if (UpgradeWindowFinalized())
+		return false;
+
+	/*
+	 * primary_conninfo set -> this is the streaming path (as opposed to the
+	 * archive/PITR path, which has recovery.signal and no primary).
+	 */
+	if (PrimaryConnInfo == NULL || PrimaryConnInfo[0] == '\0')
+		return false;
+
+	/*
+	 * A streamed upgrade standby must also be in standby mode: arming the
+	 * control file at CN commits this node to following the primary's forward
+	 * WAL (which exists only on the primary).  Without standby.signal the
+	 * node would leave recovery and come up read-write at CN -- split-brain
+	 * against the real primary.  standby.signal is the operator's
+	 * responsibility (like any standby); if the upgrade sentinel is staged
+	 * with primary_conninfo but without it, refuse to arm rather than risk
+	 * that outcome.
+	 */
+	{
+		char		standbysig[MAXPGPATH];
+
+		snprintf(standbysig, sizeof(standbysig), "%s/standby.signal", DataDir);
+		if (stat(standbysig, &st) != 0)
+			ereport(FATAL,
+					(errcode(ERRCODE_CONFIG_FILE_ERROR),
+					 errmsg("pg_upgrade streaming skeleton has \"%s\" and primary_conninfo but no \"standby.signal\"",
+							PG_UPGRADE_SIGNAL_FILE),
+					 errhint("Create a standby.signal file so this node follows the primary; a streamed upgrade target must run in standby mode.")));
+	}
+
+	/*
+	 * The old datadir path is standby-local (the primary cannot know it); the
+	 * operator supplies it in the skeleton's postgresql.conf.  Without it CN
+	 * cannot be derived, so this is a hard misconfiguration.
+	 */
+	old_datadir = pg_upgrade_standby_old_datadir;
+	if (old_datadir == NULL || old_datadir[0] == '\0')
+		ereport(FATAL,
+				(errcode(ERRCODE_CONFIG_FILE_ERROR),
+				 errmsg("streaming --wal-upgrade skeleton requires \"pg_upgrade_standby_old_datadir\" to derive the upgrade anchor"),
+				 errhint("Set \"pg_upgrade_standby_old_datadir\" in postgresql.conf to this standby's retained pre-upgrade data directory.")));
+
+	/*
+	 * Obtain the sysid from IDENTIFY_SYSTEM on the primary.  This is the
+	 * primary's == the new cluster's sysid == what the window WAL pages are
+	 * stamped with, so recovery's per-page xlp_sysid check passes once the
+	 * control file adopts it.  The returned TLI is ignored: CN lives on
+	 * timeline 1 (hardcoded below).  A connection failure while the sentinel
+	 * is staged is a hard FATAL.
+	 */
+	load_file("libpqwalreceiver", false);
+	if (WalReceiverFunctions == NULL)
+		elog(FATAL, "libpqwalreceiver didn't initialize correctly");
+
+	conn = walrcv_connect(PrimaryConnInfo, true, false, false,
+						  "pg_upgrade_anchor", &err);
+	if (conn == NULL)
+		ereport(FATAL,
+				(errcode(ERRCODE_CONNECTION_FAILURE),
+				 errmsg("could not connect to the primary to identify the pg_upgrade system: %s",
+						err ? err : "unknown error"),
+				 errhint("Set primary_conninfo to a live --wal-upgrade primary.")));
+
+	sysid_str = walrcv_identify_system(conn, &ignored_tli);
+	walrcv_disconnect(conn);
+	if (sysid_str == NULL ||
+		sscanf(sysid_str, "%" SCNu64, &sysid) != 1 ||
+		sysid == 0)
+		ereport(FATAL,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("malformed system identifier from primary: \"%s\"",
+						sysid_str ? sysid_str : "(null)")));
+
+	/*
+	 * Read the retained old cluster's control file to get old_tail (its clean
+	 * shutdown checkpoint redo == checkPoint) and its WAL segment size.
+	 * Using the backend get_controlfile(): it OpenTransientFile()s the file,
+	 * palloc's a copy, and CRC-checks it -- no shared state, safe from the
+	 * startup process before StartupXLOG.
+	 *
+	 * Pre-check that the control file is present and readable:
+	 * get_controlfile() ereport(ERROR)s with a generic "could not open file"
+	 * if it is missing or unreadable, which the startup process turns into a
+	 * bare postmaster restart loop with no hint at the real cause.  Fail with
+	 * a clear, actionable FATAL instead (a misconfigured
+	 * pg_upgrade_standby_old_datadir is the usual cause).
+	 */
+	{
+		char		ctlpath[MAXPGPATH];
+		struct stat st;
+
+		snprintf(ctlpath, sizeof(ctlpath), "%s/global/pg_control", old_datadir);
+		if (stat(ctlpath, &st) != 0)
+			ereport(FATAL,
+					(errcode_for_file_access(),
+					 errmsg("could not access old cluster control file \"%s\": %m",
+							ctlpath),
+					 errhint("\"pg_upgrade_standby_old_datadir\" must point at this standby's retained pre-upgrade data directory.")));
+	}
+
+	old_control = get_controlfile(old_datadir, &crc_ok);
+	if (!crc_ok)
+		ereport(FATAL,
+				(errcode(ERRCODE_DATA_CORRUPTED),
+				 errmsg("pg_control CRC check failed for old data directory \"%s\"",
+						old_datadir),
+				 errhint("The retained pre-upgrade data directory named by \"pg_upgrade_standby_old_datadir\" is unreadable or corrupt.")));
+
+	old_tail = old_control->checkPointCopy.redo;
+	old_seg_size = (int) old_control->xlog_seg_size;
+	pfree(old_control);
+
+	/*
+	 * Byte-contiguous CN derivation assumes the new cluster's WAL segment
+	 * size equals the old cluster's: pg_upgrade --wal-upgrade-exact positions
+	 * the new WAL at the old cluster's next SEGMENT, and we recompute that
+	 * segment here from the old datadir using old_seg_size.  If the two
+	 * differ (the upgrade changed wal_segment_size), the segment-to-LSN
+	 * mapping on each side no longer lines up and the derived CN would be
+	 * wrong.  Recovery's START-time guard would eventually catch the mismatch
+	 * and FATAL, but far downstream and opaquely; reject it here with a clear
+	 * message instead.  (wal_segment_size is this node's, already loaded from
+	 * its control file by LocalProcessControlFile() before StartupXLOG.)
+	 */
+	if (old_seg_size != wal_segment_size)
+		ereport(FATAL,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("pg_upgrade streaming standby requires matching WAL segment sizes"),
+				 errdetail("The retained old data directory uses %d-byte WAL segments but this cluster uses %d-byte segments.",
+						   old_seg_size, wal_segment_size),
+				 errhint("Re-initialize the new cluster with the same --wal-segsize as the old cluster before streaming the upgrade window.")));
+
+	/* Replicate pg_resetwal's FindEndOfXLOG rule over the old datadir. */
+	cn_seg = DeriveUpgradeCnSegment(old_datadir, old_tail, old_seg_size);
+
+	/*
+	 * CN is the DB_SHUTDOWNED checkpoint the producer wrote at the very start
+	 * of CN_seg on timeline 1: the record begins just past the segment's long
+	 * page header.  Because it is a shutdown checkpoint, redo == checkPoint
+	 * == that segment-boundary LSN.
+	 */
+	XLogSegNoOffsetToRecPtr(cn_seg, SizeOfXLogLongPHD, old_seg_size, cn_lsn);
+
+	MemSet(&cn, 0, sizeof(cn));
+	cn.redo = cn_lsn;
+	cn.ThisTimeLineID = 1;
+	cn.PrevTimeLineID = 1;
+
+	/*
+	 * The remaining CheckPoint counters (nextXid/nextOid/... ) are left zero:
+	 * ArmControlFileForUpgradeRecovery() copies *cn into checkPointCopy only
+	 * to seed recovery's start point, and StartupXLOG re-reads them from the
+	 * real CN checkpoint record the moment it is streamed in and replayed.
+	 * So the zero counters here are transient placeholders, never consulted
+	 * for anything durable.
+	 */
+	armed_cn_lsn = cn_lsn;
+
+	ereport(LOG,
+			(errmsg("auto-armed streaming standby from locally derived anchor "
+					"(sysid " UINT64_FORMAT ", CN %X/%08X, redo %X/%08X, TLI 1, CN segment %llu)",
+					sysid, LSN_FORMAT_ARGS(cn_lsn),
+					LSN_FORMAT_ARGS(cn.redo), (unsigned long long) cn_seg)));
+
+	ArmControlFileForUpgradeRecovery(&cn, cn_lsn, sysid, true);
+	return true;
+}
+
+/*
+ * Detect a pending pg_upgrade --wal-upgrade and, if found, arm recovery to
+ * replay it.  Called from the startup process before StartupXLOG().  Dispatches
+ * to one of three paths:
+ *
+ *   - streaming standby: a fresh skeleton with primary_conninfo derives CN
+ *     locally and arms to stream the window from the primary;
+ *   - local window: START/COMPLETE markers already in pg_wal/ are scanned,
+ *     CN derived, and the control file armed to replay them;
+ *   - archive PITR: no local window, but the sentinel is staged, so the
+ *     bootstrap is armed for the window arriving via restore_command.
+ *
+ * Returns true when it armed the bootstrap (caller proceeds into StartupXLOG),
+ * false for an ordinary (non-upgrade or already-finalized) start.
+ */
+bool
+PerformWalUpgradeIfNeeded(void)
+{
+	char		wal_dir[MAXPGPATH];
+	bool		found_start = false;
+	bool		found_complete = false;
+	CheckPoint	cn;
+	XLogRecPtr	cn_lsn = InvalidXLogRecPtr;
+	uint64		wal_sysid = 0;
+
+	/* Skip during pg_upgrade internal server starts (-b binary upgrade mode) */
+	if (IsBinaryUpgrade)
+		return false;
+
+	/*
+	 * STREAMING STANDBY PATH.  A fresh skeleton with primary_conninfo set
+	 * derives CN locally from its retained old data directory and arms the
+	 * control file (sysid + CN + TLI=1), then lets StartupXLOG() enter
+	 * standby mode and stream the window forward.  Returns false when no
+	 * primary is configured (fall through to the local-window path).
+	 */
+	if (ArmFromLocalDerivationIfConfigured())
+	{
+		in_upgrade_bootstrap = true;
+		armed_streaming_standby = true;
+
+		/*
+		 * Suppress hot standby from the very start of recovery, before any
+		 * record replays.  A streaming standby has no shared catalogs (those
+		 * under global/) on disk until the window streams in; without this,
+		 * recovery could reach consistency and admit a read-only connection
+		 * in the gap before XLOG_UPGRADE_START, which would FATAL opening a
+		 * not-yet-materialized catalog.  Held until XLOG_UPGRADE_COMPLETE
+		 * clears it.
+		 */
+		pgUpgradeReplayInProgress = true;
+
+		return true;
+	}
+
+	snprintf(wal_dir, sizeof(wal_dir), XLOGDIR);
+
+	/*
+	 * LOCAL-WINDOW PATH.  Scan pg_wal/ for the START/COMPLETE markers and CN.
+	 * A completed --wal-upgrade run leaves a START..COMPLETE window in
+	 * pg_wal/ (no rename).  Cases:
+	 *
+	 * pending (not finalized) -> derive CN from the WAL, arm pg_control
+	 * in-process, and let StartupXLOG() recover the window. already applied
+	 * (COMPLETE marker present, or control checkpoint > CN) -> normal
+	 * startup; a prior startup finalized the upgrade. START, no COMPLETE and
+	 * not finalized -> crash mid-upgrade; FATAL (see below). no START -> not
+	 * an upgrade; normal startup.
+	 *
+	 * Deriving CN here (rather than a prior offline pg_resetwal stamp) lets
+	 * the same WAL stream drive recovery on the primary and on a physical
+	 * standby.
+	 */
+	if (!UpgradeWalScanMarkers(wal_dir, &found_start, &found_complete,
+							   &cn, &cn_lsn, &wal_sysid))
+	{
+		/*
+		 * ARCHIVE-PITR PATH.  No local window, but a cross-version
+		 * upgrade-PITR restore stages the pg_upgrade.signal sentinel (the
+		 * same marker checkDataDir() keys the control-file synthesis on): the
+		 * window arrives later via restore_command as recovery replays
+		 * forward from a pre-upgrade base backup across the upgrade boundary.
+		 * Recovery starts at the base backup's checkpoint and flows through
+		 * CN organically, so no re-anchoring is needed here; just arm
+		 * in_upgrade_bootstrap so the XLOG_UPGRADE_START redo does not FATAL
+		 * when the window is reached. (This is the archive path because there
+		 * is no local window and, on this branch, recovery.signal drives the
+		 * restore rather than primary_conninfo.)
+		 *
+		 * Gate on the sentinel (not raw recovery.signal/standby.signal): an
+		 * ordinary archive PITR or a plain streaming standby must not arm the
+		 * bootstrap, or the standby-safety FATAL-halt guard is defeated. This
+		 * keeps all three detection sites (here, checkDataDir, and the
+		 * streaming path) on the one pg_upgrade.signal sentinel.
+		 */
+		if (UpgradeSignalStaged())
+		{
+			ereport(LOG,
+					(errmsg("archive recovery active with no local pg_upgrade window; "
+							"arming upgrade replay in case the window arrives from the archive")));
+			in_upgrade_bootstrap = true;
+
+			/*
+			 * Suppress hot standby from the very start of recovery, as the
+			 * local-window and streaming paths do.  On this path recovery
+			 * replays a pre-upgrade base backup forward and can reach
+			 * consistency well before the window's XLOG_UPGRADE_START, so a
+			 * read-only backend could otherwise be admitted against the still
+			 * old-version, half-upgraded catalog.  Hot-standby activation is
+			 * a one-way latch, so the flag must be set here at arm time, not
+			 * later in the START redo handler (by then a backend may already
+			 * be in). Cleared at XLOG_UPGRADE_COMPLETE.
+			 */
+			pgUpgradeReplayInProgress = true;
+		}
+		return false;			/* let StartupXLOG drive recovery from
+								 * backup_label */
+	}
+
+	/*
+	 * Crash-atomicity guard, WAL-scan-independent.  The burst server durably
+	 * set upgrade_started in pg_control just before emitting
+	 * XLOG_UPGRADE_START; the COMPLETE path sets upgrade_finalized.  So
+	 * upgrade_started && !finalized is a partial (crashed) upgrade that must
+	 * never auto-serve its half-built catalog -- even if the START-bearing
+	 * WAL did not survive to first boot (byte- contiguous CN generation can
+	 * recycle those segments, so the shutdown checkpoint then hides START
+	 * from the scan above and found_start is false). Refuse before the
+	 * found_start early-return that would otherwise treat this as an ordinary
+	 * cluster.  The old cluster was never written, so re-running pg_upgrade
+	 * is the safe recovery.
+	 */
+	if (GetControlFileUpgradeStarted() && !UpgradeWindowFinalized())
+		ereport(FATAL,
+				(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				 errmsg("pg_upgrade WAL is incomplete: found START without COMPLETE"),
+				 errhint("The upgrade did not finish; re-run pg_upgrade from the old cluster (which is intact).")));
+
+	if (!found_start)
+		return false;			/* not an upgrade */
+
+	/*
+	 * Already finalized?  Requires both durable signals: the pg_control
+	 * upgrade_finalized flag (COMPLETE was reached) and the control
+	 * checkpoint strictly past CN (a checkpoint was written after the
+	 * window).  The flag alone is insufficient because the COMPLETE handler
+	 * sets it one checkpoint before the control checkpoint advances, so a
+	 * crash in that gap must be re-armed and re-replayed (idempotent), not
+	 * skipped.  A checkpoint past CN alone is insufficient because a
+	 * smart-shut-down partial upgrade also has one but never set the flag,
+	 * and must be refused.  Checking this before the partial-window diagnosis
+	 * also means a finalized cluster whose final WAL page is torn (COMPLETE
+	 * missed by the scan) is still recognized as done.
+	 */
+	if (UpgradeWindowFinalized() &&
+		!XLogRecPtrIsInvalid(cn_lsn) && GetControlFileCheckPointLSN() > cn_lsn)
+		return false;			/* finalized; ordinary startup */
+
+	/*
+	 * A complete window whose checkpoint has not yet advanced past CN is a
+	 * pending or mid-finalization upgrade (first start, or a crash after the
+	 * COMPLETE marker but before the end-of-recovery checkpoint).  Fall
+	 * through to arm and (re-)replay it -- the window images are idempotent.
+	 * Only a window that never reached COMPLETE is a genuine partial upgrade:
+	 * the catalog is half-built and, since the new cluster auto-serves
+	 * read-write at end of recovery, arming it would serve a corrupt catalog.
+	 * Refuse; the old cluster was never written and is intact, so re-running
+	 * pg_upgrade is the safe recovery.
+	 */
+	if (!found_complete)
+		ereport(FATAL,
+				(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				 errmsg("pg_upgrade WAL is incomplete: found START without COMPLETE"),
+				 errhint("The upgrade did not finish; re-run pg_upgrade from the old cluster (which is intact).")));
+
+	/*
+	 * Pending.  CN must have been found; otherwise the WAL is malformed and
+	 * re-arming at an invalid LSN would corrupt recovery.
+	 */
+	if (XLogRecPtrIsInvalid(cn_lsn))
+		ereport(FATAL,
+				(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				 errmsg("pg_upgrade WAL is missing the end-of-upgrade checkpoint"),
+				 errhint("Re-run pg_upgrade from the old cluster to start fresh.")));
+
+	ereport(LOG,
+			(errmsg("pg_upgrade WAL found in pg_wal/; arming recovery from end-of-upgrade checkpoint at %X/%08X",
+					LSN_FORMAT_ARGS(cn_lsn))));
+
+	/*
+	 * Arm the control file in-process: point recovery at CN (state =
+	 * DB_IN_PRODUCTION, wal_level = replica) and adopt the upgrade WAL's
+	 * system identifier so recovery's per-page xlp_sysid check passes.
+	 * StartupXLOG() (called right after) reads ControlFile->checkPointCopy.
+	 * Deriving CN and the sysid from the WAL here lets the same WAL stream
+	 * drive recovery on the primary and on a physical standby, with no
+	 * offline stamping step.
+	 */
+	ArmControlFileForUpgradeRecovery(&cn, cn_lsn, wal_sysid, false);
+
+	/*
+	 * Arm the sanctioned bootstrap so the redo handlers may apply the upgrade
+	 * images.  A pg_upgrade record reached without this flag came in through
+	 * an ordinary/standby stream and must not be applied live (see
+	 * pg_upgrade_redo).
+	 */
+	in_upgrade_bootstrap = true;
+
+	/*
+	 * Suppress hot standby before any record replays, as the streaming path
+	 * does: the window's full-page images rebuild the catalogs as they
+	 * replay, so a read-only connection admitted between consistency and
+	 * XLOG_UPGRADE_START could observe a half-built catalog.  Held until
+	 * XLOG_UPGRADE_COMPLETE clears it.  Matters for archive-PITR (consistency
+	 * can be reached well before CN); harmless for the primary's own restart.
+	 */
+	pgUpgradeReplayInProgress = true;
+
+	return true;
+}
+
+/*
+ * Place one user relation file from the standby's retained old datadir into the
+ * new skeleton during XLOG_UPGRADE_RELINK redo, reproducing the result
+ * pg_upgrade's transfer step produced on the primary for the given mode:
+ *
+ *   COPY            - independent full byte copy (copy_file()).
+ *   CLONE           - reflink/COW clone: copyfile(COPYFILE_CLONE_FORCE) on macOS,
+ *                     ioctl(FICLONE) on Linux (same primitives as cloneFile()).
+ *   COPY_FILE_RANGE - copy_file_range().
+ *   LINK            - per-file hardlink, sharing the old inode.
+ *   SWAP            - rename(), moving the file out of the old datadir.
+ *
+ * Like the reflink modes in pg_upgrade itself, CLONE/COPY_FILE_RANGE FATAL if the
+ * filesystem cannot reflink rather than fall back to a full copy -- so the standby
+ * either reproduces the operator's chosen space profile or refuses, never silently
+ * costs 2x.  The caller has already unlink()ed any pre-existing dst.
+ *
+ * The placed file is fsync'd before returning (the caller fsyncs the parent dir).
+ * These files bypass smgr, so the checkpointer has no sync request for them and the
+ * end-of-recovery checkpoint would otherwise advance pg_control past CN with the
+ * data only in the OS cache -- a crash there would leave a finalized upgrade with
+ * missing user relations, never re-replayed.
+ */
+static void
+RelinkPlaceFile(const char *oldfile, const char *newfile, uint8 mode)
+{
+	switch (mode)
+	{
+		case UPGRADE_RELINK_MODE_LINK:
+			/* mirror --link: a per-file hardlink, sharing the old inode */
+			if (link(oldfile, newfile) != 0)
+				ereport(PANIC,
+						(errcode_for_file_access(),
+						 errmsg("could not link \"%s\" to \"%s\": %m",
+								oldfile, newfile)));
+			break;
+
+		case UPGRADE_RELINK_MODE_SWAP:
+
+			/*
+			 * Mirror --swap: move the file out of the old datadir (upstream
+			 * swap renames whole DB dirs, consuming the old cluster).  Unlike
+			 * --link this leaves no entry behind.  rename() over an existing
+			 * dst is atomic and idempotent on replay (a re-run finds the
+			 * source gone via the caller's stat() and skips).
+			 */
+			if (rename(oldfile, newfile) != 0)
+				ereport(PANIC,
+						(errcode_for_file_access(),
+						 errmsg("could not move \"%s\" to \"%s\": %m",
+								oldfile, newfile)));
+			break;
+
+		case UPGRADE_RELINK_MODE_CLONE:
+			{
+				int			save_errno;
+
+				/*
+				 * Reproduce pg_upgrade's cloneFile() via the shared helper,
+				 * so the primary's transfer and this standby-side placement
+				 * use one implementation (copyfile(COPYFILE_CLONE_FORCE) on
+				 * macOS, else ioctl(FICLONE) on Linux).
+				 */
+				switch (pg_clone_file(oldfile, newfile, &save_errno))
+				{
+					case PG_REFLINK_OK:
+						break;
+					case PG_REFLINK_ERROR:
+						errno = save_errno;
+						ereport(PANIC,
+								(errcode_for_file_access(),
+								 errmsg("could not clone \"%s\" to \"%s\": %m",
+										oldfile, newfile),
+								 errhint("The old data directory and the new skeleton must be on the same reflink-capable filesystem.")));
+						break;
+					case PG_REFLINK_UNSUPPORTED:
+						ereport(PANIC,
+								(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+								 errmsg("file cloning not supported on this platform"),
+								 errhint("The primary used --clone; a standby on this platform cannot reproduce it.")));
+						break;
+				}
+				break;
+			}
+
+		case UPGRADE_RELINK_MODE_COPY_FILE_RANGE:
+			{
+				int			save_errno;
+
+				/*
+				 * reproduce pg_upgrade's copyFileByRange() via the shared
+				 * helper
+				 */
+				switch (pg_copy_file_range_all(oldfile, newfile, &save_errno))
+				{
+					case PG_REFLINK_OK:
+						break;
+					case PG_REFLINK_ERROR:
+						errno = save_errno;
+						ereport(PANIC,
+								(errcode_for_file_access(),
+								 errmsg("could not copy_file_range \"%s\" to \"%s\": %m",
+										oldfile, newfile)));
+						break;
+					case PG_REFLINK_UNSUPPORTED:
+						ereport(PANIC,
+								(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+								 errmsg("copy_file_range not supported on this platform"),
+								 errhint("The primary used --copy-file-range; a standby on this platform cannot reproduce it.")));
+						break;
+				}
+				break;
+			}
+
+		case UPGRADE_RELINK_MODE_COPY:
+		default:
+			/* plain full copy: matches --copy (independent file, 2x space) */
+			copy_file(oldfile, newfile);
+			break;
+	}
+
+	/*
+	 * Make the placed file's contents durable.  copy_file() flushes but does
+	 * not fsync (its bin/ callers fsync separately); the reflink/hardlink
+	 * paths fsync nothing.  Without this the end-of-recovery checkpoint could
+	 * advance the control file past CN with the data still in the OS cache --
+	 * see the function header.  The caller fsyncs the parent directory so the
+	 * new dentry itself is durable.
+	 */
+	fsync_fname(newfile, false);
+}
+
+/*
+ * Build the source path of a user relation in the retained old datadir for
+ * XLOG_UPGRADE_RELINK redo.
+ *
+ * For base/ and global/ relations the relpath is version-independent and we
+ * just prefix the old datadir.  For a relation in a user-created tablespace the
+ * relpath GetRelationPath() produced embeds this (new) binary's
+ * TABLESPACE_VERSION_DIRECTORY -- "PG_<newmajor>_<newcat>" -- but the retained
+ * old datadir's tablespace area only holds the OLD version's directory
+ * ("PG_<oldmajor>_<oldcat>").  Blindly prefixing would name a nonexistent path
+ * and the entry would be silently skipped, losing every tablespace relation on
+ * the standby.  So for the tablespace case we substitute the actual PG_*
+ * directory found under old_datadir/pg_tblspc/<spcoid>/.  Returns false if that
+ * directory cannot be resolved (no PG_* entry), so the caller can error rather
+ * than skip.
+ */
+static bool
+RelinkBuildOldFile(const char *old_datadir, Oid spcOid,
+				   const char *relpath, const char *segsuffix,
+				   char *oldfile, size_t oldfile_size)
+{
+	const char *tblspc_prefix = PG_TBLSPC_DIR_SLASH;
+	size_t		tblspc_prefix_len = strlen(tblspc_prefix);
+	const char *verseg;
+	const char *after_ver;
+	DIR		   *dir;
+	struct dirent *de;
+	char		spcdir[MAXPGPATH];
+	char		oldverdir[MAXFNAMELEN];
+	bool		found = false;
+
+	/* base/ and global/ are unversioned: a plain prefix is correct. */
+	if (strncmp(relpath, tblspc_prefix, tblspc_prefix_len) != 0)
+	{
+		snprintf(oldfile, oldfile_size, "%s/%s%s",
+				 old_datadir, relpath, segsuffix);
+		return true;
+	}
+
+	/*
+	 * Tablespace relpath: "pg_tblspc/<spc>/PG_<ver>/<db>/<rel>".  Locate the
+	 * "PG_<ver>" segment (the component after "pg_tblspc/<spc>/") and the
+	 * tail that follows it ("/<db>/<rel>").
+	 */
+	verseg = strchr(relpath + tblspc_prefix_len, '/');
+	if (verseg == NULL)
+		return false;			/* malformed; no version segment */
+	verseg++;					/* now at "PG_<ver>/..." */
+	after_ver = strchr(verseg, '/');
+	if (after_ver == NULL)
+		return false;			/* malformed; no db/rel tail */
+
+	/* Find the single PG_* version directory under the old tablespace. */
+	snprintf(spcdir, sizeof(spcdir), "%s/%s%u",
+			 old_datadir, tblspc_prefix, spcOid);
+	dir = AllocateDir(spcdir);
+	if (dir == NULL)
+		return false;
+	while ((de = ReadDir(dir, spcdir)) != NULL)
+	{
+		if (strncmp(de->d_name, "PG_", 3) == 0)
+		{
+			strlcpy(oldverdir, de->d_name, sizeof(oldverdir));
+			found = true;
+			break;
+		}
+	}
+	FreeDir(dir);
+	if (!found)
+		return false;
+
+	/* Reassemble with the old version directory in place of the new one. */
+	snprintf(oldfile, oldfile_size, "%s/%s%u/%s%s%s",
+			 old_datadir, tblspc_prefix, spcOid, oldverdir, after_ver,
+			 segsuffix);
+	return true;
+}
+
+
+/* -------------------------------------------------------------------------
+ * Redo
+ * -------------------------------------------------------------------------
+ */
+
+void
+pg_upgrade_redo(XLogReaderState *record)
+{
+	uint8		info = XLogRecGetInfo(record) & ~XLR_INFO_MASK;
+
+	if (info == XLOG_UPGRADE_START)
+	{
+		xl_pg_upgrade *xlrec = (xl_pg_upgrade *) XLogRecGetData(record);
+		int			fd;
+
+		/*
+		 * pg_version is a fixed char[8]; a corrupt record may not
+		 * NUL-terminate
+		 */
+		int			len = strnlen(xlrec->pg_version, sizeof(xlrec->pg_version));
+
+		/*
+		 * Standby / ordinary-stream guard.  The upgrade image records carry
+		 * the old cluster's page LSNs and are only safe to apply from the
+		 * sanctioned bootstrap (anchored at CN into a non-serving data
+		 * directory).  Reaching START without in_upgrade_bootstrap means an
+		 * ordinary/standby stream, so FATAL at the boundary rather than apply
+		 * the window live.
+		 *
+		 * For a physical standby this FATAL is the intentional halt: the
+		 * operator installs the new-version binary and relaunches, and
+		 * startup then anchors at CN and replays the window.  StandbyMode
+		 * selects the message so the operator sees which case fired.
+		 */
+		if (!in_upgrade_bootstrap)
+		{
+			if (StandbyMode)
+				ereport(FATAL,
+						(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+						 errmsg("reached pg_upgrade boundary on standby; halting to apply the upgrade"),
+						 errdetail("A --wal-upgrade was performed on the primary; the standby cannot apply it while streaming."),
+						 errhint("Install the new-version binaries and restart this standby; it will replay the upgrade from the end-of-upgrade checkpoint.")));
+			else
+				ereport(FATAL,
+						(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+						 errmsg("pg_upgrade WAL encountered during replay"),
+						 errhint("Restart this server to apply the pg_upgrade; "
+								 "the upgrade cannot be replayed on a running standby.")));
+		}
+
+		/*
+		 * Streaming-standby derivation guard.  On the streaming path CN is
+		 * derived locally (ArmFromLocalDerivationIfConfigured) and recovery
+		 * armed at armed_cn_lsn.  If that derivation put CN at the wrong
+		 * segment, recovery would anchor at the wrong LSN and silently
+		 * mis-recover.  CN is the shutdown checkpoint immediately preceding
+		 * this START, so recovery's redo pointer here must still equal the
+		 * LSN we armed at.  A mismatch means the derived CN did not match the
+		 * streamed window: FATAL clearly rather than continue.  (On the
+		 * local-window and archive paths armed_cn_lsn is Invalid and this
+		 * check is skipped; there CN comes from the WAL scan itself, not a
+		 * derivation that could disagree.)
+		 */
+		if (!XLogRecPtrIsInvalid(armed_cn_lsn))
+		{
+			XLogRecPtr	redo_now = GetRedoRecPtr();
+
+			if (redo_now != armed_cn_lsn)
+				ereport(FATAL,
+						(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+						 errmsg("derived pg_upgrade CN (%X/%08X) did not match the streamed window (recovery redo at %X/%08X)",
+								LSN_FORMAT_ARGS(armed_cn_lsn),
+								LSN_FORMAT_ARGS(redo_now)),
+						 errhint("The retained old data directory named by \"pg_upgrade_standby_old_datadir\" must be this standby's own pre-upgrade directory, so CN is derived at the segment the primary placed it.")));
+		}
+
+		/* Window open: hold off hot standby until XLOG_UPGRADE_COMPLETE. */
+		pgUpgradeReplayInProgress = true;
+
+		/*
+		 * Informational only: record DB_IN_UPGRADE so a crash mid-window (or
+		 * pg_controldata) shows "in pg_upgrade" rather than the "in
+		 * production" the arm set as its crash-recovery trigger.  Does not
+		 * drive recovery; COMPLETE restores DB_IN_PRODUCTION.
+		 */
+		if (in_upgrade_bootstrap)
+			SetControlFileInUpgrade();
+
+		/*
+		 * Write $PGDATA/PG_VERSION from the embedded string; the top-level
+		 * PG_VERSION is created by initdb and is not otherwise WAL-logged.
+		 * (Per-database PG_VERSION is covered by XLOG_DBASE_CREATE_WAL_LOG.)
+		 */
+
+		fd = OpenTransientFile("PG_VERSION",
+							   O_WRONLY | O_CREAT | O_TRUNC | PG_BINARY);
+		if (fd < 0)
+			ereport(PANIC,
+					(errcode_for_file_access(),
+					 errmsg("could not open PG_VERSION: %m")));
+		if (pg_pwrite(fd, xlrec->pg_version, len, 0) != len)
+			ereport(PANIC,
+					(errcode_for_file_access(),
+					 errmsg("could not write PG_VERSION: %m")));
+		if (pg_fsync(fd) != 0)
+			ereport(PANIC,
+					(errcode_for_file_access(),
+					 errmsg("could not fsync PG_VERSION: %m")));
+		CloseTransientFile(fd);
+	}
+	else if (info == XLOG_UPGRADE_COMPLETE)
+	{
+		/*
+		 * Window closed and the cluster is fully reconstructed on disk. Clear
+		 * the guard so hot standby may activate.  The redo loop finishes and
+		 * StartupXLOG() writes its end-of-recovery checkpoint (advancing the
+		 * control checkpoint past COMPLETE), then the cluster comes up
+		 * read-write; a streaming standby continues as an ordinary hot
+		 * standby.
+		 */
+		pgUpgradeReplayInProgress = false;
+
+		/* Restore the DB_IN_PRODUCTION trigger borrowed at START. */
+		if (in_upgrade_bootstrap)
+			ClearControlFileInUpgrade();
+
+		/*
+		 * Record the durable "window reached COMPLETE" flag in pg_control,
+		 * which (paired with a control checkpoint past CN) lets the next
+		 * startup tell a fully-upgraded cluster from a crashed partial one.
+		 * SetControlFileUpgradeFinalized() fsyncs pg_control, so it survives
+		 * a crash immediately after redo with no clean shutdown in between.
+		 * It is set here, one checkpoint before StartupXLOG's end-of-recovery
+		 * checkpoint advances the control checkpoint past CN, so a crash in
+		 * that gap is correctly re-armed and re-replayed (see
+		 * PerformWalUpgradeIfNeeded's finalized guard).
+		 */
+		SetControlFileUpgradeFinalized();
+	}
+	else if (info == XLOG_UPGRADE_HANDOFF)
+	{
+		/*
+		 * Old-format streaming-handoff trigger (see xl_pg_upgrade_handoff /
+		 * XLogWritePgUpgradeHandoff() for why it exists and when it is
+		 * emitted). A standby cannot follow the upgrade in the old WAL
+		 * format, so rather than shut down it pauses recovery at the boundary
+		 * and keeps serving read-only queries while the operator provisions a
+		 * new-version skeleton to stream the window (this node's data
+		 * directory is what that skeleton links user relations from).  The
+		 * pause is reversible, so an aborted upgrade is recoverable: resume
+		 * to follow the restarted old primary, or drain to the end of the old
+		 * WAL on the commit path.  Outside StandbyMode (the old primary's own
+		 * crash recovery) it is a no-op.
+		 */
+		if (StandbyMode)
+		{
+			xl_pg_upgrade_handoff *xlrec =
+				(xl_pg_upgrade_handoff *) XLogRecGetData(record);
+
+			if (!HotStandbyActive())
+			{
+				/*
+				 * recoveryPausesHere() refuses to pause unless hot standby is
+				 * active (there would be no session able to resume it).  A
+				 * caught-up streaming standby reached consistency long ago,
+				 * so this is the unusual case; rather than silently continue
+				 * past the boundary into WAL this old binary cannot read,
+				 * stop cleanly here.  The operator re-provisions from the
+				 * window.
+				 */
+				ereport(FATAL,
+						(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+						 errmsg("reached pg_upgrade handoff on standby before hot standby was active; shutting down for pg_upgrade"),
+						 errdetail("The primary initiated a --wal-upgrade to major version %u; "
+								   "this standby cannot follow the upgrade in the old WAL format.",
+								   xlrec->target_major_version),
+						 errhint("Install the new-version binaries and start a fresh skeleton "
+								 "standby with a pg_upgrade.signal file naming this data directory; "
+								 "it will stream the upgrade window and link user relations in.")));
+			}
+			else
+			{
+				/*
+				 * Request a recovery pause.  The main redo loop honors it
+				 * before applying the next record (xlogrecovery.c), so the
+				 * startup process blocks at the handoff boundary while the
+				 * postmaster and hot-standby backends keep serving read-only
+				 * queries.  pg_wal_replay_resume() (or promotion) releases
+				 * it.
+				 */
+				ereport(LOG,
+						(errmsg("reached pg_upgrade handoff on standby; pausing recovery at the upgrade boundary"),
+						 errdetail("The primary initiated a --wal-upgrade to major version %u; "
+								   "this standby cannot follow the upgrade in the old WAL format.",
+								   xlrec->target_major_version),
+						 errhint("Provision a fresh new-version skeleton standby from this data directory, "
+								 "then resume with pg_wal_replay_resume(); or, if the upgrade was aborted, "
+								 "restart the old primary and resume to keep following it.")));
+				SetRecoveryPause(true);
+			}
+		}
+	}
+	else if (info == XLOG_UPGRADE_DIRTREE)
+	{
+		/*
+		 * Rebuild the directory skeleton before any file image replays into
+		 * it. Paths are PGDATA-relative and emitted parent-before-child, so
+		 * one mkdir() per path suffices.  Idempotent: EEXIST is expected
+		 * (some directories already exist on disk).
+		 */
+		xl_upgrade_dirtree *xlrec =
+			(xl_upgrade_dirtree *) XLogRecGetData(record);
+		char	   *p = (char *) xlrec + SizeOfXLUpgradeDirtree;
+		char	   *dir_end;
+		char	   *sym_end;
+		uint32		done = 0;
+
+		/*
+		 * dir_bytes/sym_bytes are untrusted; make sure the two regions fit
+		 * within the record before deriving end pointers from them.
+		 */
+		if ((Size) xlrec->dir_bytes + xlrec->sym_bytes >
+			XLogRecGetDataLen(record) - SizeOfXLUpgradeDirtree)
+			ereport(PANIC,
+					(errcode(ERRCODE_DATA_CORRUPTED),
+					 errmsg("dirtree record overruns the record")));
+		dir_end = p + xlrec->dir_bytes;
+		sym_end = dir_end + xlrec->sym_bytes;
+
+		while (p < dir_end && done < xlrec->ndirs)
+		{
+			Size		plen = strnlen(p, dir_end - p);
+
+			if (plen == 0 || p + plen >= dir_end)	/* need the NUL terminator */
+				break;
+
+			if (mkdir(p, pg_dir_create_mode) != 0 && errno != EEXIST)
+				ereport(PANIC,
+						(errcode_for_file_access(),
+						 errmsg("could not create directory \"%s\": %m",
+								p)));
+
+			p += plen + 1;
+			done++;
+		}
+
+		if (done != xlrec->ndirs)
+			ereport(PANIC,
+					(errcode(ERRCODE_DATA_CORRUPTED),
+					 errmsg("dirtree record damaged: created %u of %u directories",
+							done, xlrec->ndirs)));
+
+		/*
+		 * Recreate captured symlinks (pg_tblspc/<spcoid> -> external
+		 * tablespace location).  Each entry is two NUL-terminated strings:
+		 * linkpath, target. Create the target directory then the symlink, so
+		 * the tablespace exists before its RELFILE images replay.  EEXIST
+		 * tolerated.
+		 */
+		p = dir_end;
+		done = 0;
+		while (p < sym_end && done < xlrec->nsymlinks)
+		{
+			char	   *linkpath = p;
+			Size		llen = strnlen(linkpath, sym_end - p);
+			char	   *target;
+			Size		tlen;
+
+			if (llen == 0 || p + llen >= sym_end)
+				break;
+			target = p + llen + 1;
+			if (target >= sym_end)
+				break;
+			tlen = strnlen(target, sym_end - target);
+			if (p + llen + 1 + tlen >= sym_end)
+				break;
+
+			/*
+			 * linkpath is a PGDATA-relative link (pg_tblspc/<oid>); reject an
+			 * absolute or ".." path so a corrupt record cannot plant a
+			 * symlink outside the data directory.  (target is legitimately an
+			 * absolute external location, so it is not constrained here.)
+			 */
+			if (linkpath[0] == '/' || strstr(linkpath, "..") != NULL)
+				ereport(PANIC,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("unsafe symlink path \"%s\"",
+								linkpath)));
+
+			/* ensure the external target directory exists */
+			if (mkdir(target, pg_dir_create_mode) != 0 && errno != EEXIST)
+				ereport(PANIC,
+						(errcode_for_file_access(),
+						 errmsg("could not create tablespace directory \"%s\": %m",
+								target)));
+
+			if (symlink(target, linkpath) != 0 && errno != EEXIST)
+				ereport(PANIC,
+						(errcode_for_file_access(),
+						 errmsg("could not create symlink \"%s\" -> \"%s\": %m",
+								linkpath, target)));
+
+			p = target + tlen + 1;
+			done++;
+		}
+
+		if (done != xlrec->nsymlinks)
+			ereport(PANIC,
+					(errcode(ERRCODE_DATA_CORRUPTED),
+					 errmsg("dirtree record damaged: created %u of %u symlinks",
+							done, xlrec->nsymlinks)));
+	}
+	else if (info == XLOG_UPGRADE_SLRU_DATA)
+	{
+		/*
+		 * Restore the captured SLRU segment image(s).  Emitted last in
+		 * pg_upgrade, after all transactions committed and a CHECKPOINT
+		 * flushed the merged CLOG/multixact state, so the image carries both
+		 * the old cluster's historical commit bits (which live only here,
+		 * never in WAL) and the new cluster's restore statuses, and dominates
+		 * any earlier replayed commit record for the same page.  This redo is
+		 * the sole source reconstructing pg_xact and pg_multixact.  Install
+		 * each page into the SimpleLru buffers and flush it so the
+		 * end-of-recovery checkpoint cannot clobber it.
+		 */
+		xl_upgrade_slru_data *xlrec =
+			(xl_upgrade_slru_data *) XLogRecGetData(record);
+		char	   *data = (char *) xlrec + SizeOfXLUpgradeSlruData;
+		Size		seg_size = SLRU_PAGES_PER_SEGMENT * BLCKSZ;
+		int64		seg;
+		Size		off = 0;
+
+		/*
+		 * total_bytes is an untrusted field; validate it against the bytes
+		 * the record actually carries before using it to bound the segment
+		 * reads below.  Otherwise a record claiming more than it holds would
+		 * over-read the WAL buffer.
+		 */
+		if ((Size) xlrec->total_bytes >
+			XLogRecGetDataLen(record) - SizeOfXLUpgradeSlruData)
+			ereport(PANIC,
+					(errcode(ERRCODE_DATA_CORRUPTED),
+					 errmsg("SLRU record claims %u bytes but the "
+							"record is shorter", xlrec->total_bytes)));
+
+		/*
+		 * first_seg/last_seg are likewise untrusted.  They feed basepage =
+		 * seg * SLRU_PAGES_PER_SEGMENT in the restore helpers, so an
+		 * out-of-range value would signed-overflow that product (undefined
+		 * behavior) or install pages under a nonsensical, mis-named segment
+		 * file.  Bound them to the addressable segment range for the SLRU's
+		 * filename width -- 2^24-1 for short names (pg_xact, pg_multixact/
+		 * offsets), 2^60-1 for long names (pg_multixact/members) -- and
+		 * require a non-negative, ordered range.  A valid emit never violates
+		 * this; a violation means the record is corrupt.
+		 */
+		{
+			int64		max_seg;
+
+			switch (xlrec->slru_type)
+			{
+				case UPGRADE_SLRU_XACT:
+				case UPGRADE_SLRU_MXOFF:
+					max_seg = INT64CONST(0xFFFFFF); /* short names */
+					break;
+				case UPGRADE_SLRU_MXMEM:
+					max_seg = INT64CONST(0xFFFFFFFFFFFFFFF);	/* long names */
+					break;
+				default:
+					elog(PANIC, "invalid slru_type %u",
+						 xlrec->slru_type);
+					max_seg = 0;	/* keep the compiler quiet */
+			}
+
+			if (xlrec->first_seg < 0 ||
+				xlrec->last_seg < xlrec->first_seg ||
+				xlrec->last_seg > max_seg)
+				ereport(PANIC,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("SLRU record segment range %lld..%lld out of bounds (slru_type %u)",
+								(long long) xlrec->first_seg,
+								(long long) xlrec->last_seg,
+								xlrec->slru_type)));
+		}
+
+		for (seg = xlrec->first_seg; seg <= xlrec->last_seg; seg++)
+		{
+			/*
+			 * Every segment in first_seg..last_seg must be fully present.  A
+			 * short record means the WAL is damaged; silently restoring fewer
+			 * segments would leave pg_xact/pg_multixact incomplete, so PANIC
+			 * as the sibling handlers do rather than continue with a partial
+			 * SLRU.
+			 */
+			if (off + seg_size > (Size) xlrec->total_bytes)
+				ereport(PANIC,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("SLRU record truncated: "
+								"restored %d of segments %lld..%lld (slru_type %u)",
+								(int) (seg - xlrec->first_seg),
+								(long long) xlrec->first_seg,
+								(long long) xlrec->last_seg,
+								xlrec->slru_type)));
+
+			switch (xlrec->slru_type)
+			{
+				case UPGRADE_SLRU_XACT:
+					CLOGUpgradeRestoreSegment(seg, data + off, seg_size);
+					break;
+				case UPGRADE_SLRU_MXOFF:
+					MultiXactOffsetUpgradeRestoreSegment(seg, data + off, seg_size);
+					break;
+				case UPGRADE_SLRU_MXMEM:
+					MultiXactMemberUpgradeRestoreSegment(seg, data + off, seg_size);
+					break;
+				default:
+					elog(PANIC, "invalid slru_type %u",
+						 xlrec->slru_type);
+			}
+			off += seg_size;
+		}
+	}
+	else if (info == XLOG_UPGRADE_RELFILE_DATA)
+	{
+		/*
+		 * The record batches many relation-file chunks:
+		 * [entry_0][data_0][entry_1][data_1] ... Restore each chunk
+		 * page-by-page through the buffer manager.  Recovery is anchored at
+		 * CN, so these images are the sole writers of these pages (the
+		 * on-disk file was wiped and pg_restore's WAL is not replayed). Going
+		 * through the buffer manager lets XLogReadBufferExtended create the
+		 * file and its directory on demand and flush the page at the
+		 * end-of-recovery checkpoint; RBM_ZERO_AND_LOCK gives a zero-extended
+		 * buffer we overwrite.
+		 */
+		char	   *ptr = XLogRecGetData(record);
+		char	   *end = ptr + XLogRecGetDataLen(record);
+
+		while (ptr < end)
+		{
+			xl_upgrade_relfile_entry ent;
+			char	   *data;
+			RelFileLocator rlocator;
+			ForkNumber	forknum;
+			uint32		npages;
+			BlockNumber base_block;
+
+			/*
+			 * The record is untrusted (it only had to pass a CRC check).
+			 * Validate every offset against the record end before reading: a
+			 * torn or corrupt record must PANIC, not over-read the WAL
+			 * buffer.
+			 */
+			if (end - ptr < (ptrdiff_t) SizeOfXLUpgradeRelfileEntry)
+				ereport(PANIC,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("truncated relfile entry header")));
+			memcpy(&ent, ptr, SizeOfXLUpgradeRelfileEntry);
+			ptr += SizeOfXLUpgradeRelfileEntry;
+			data = ptr;
+			if (ent.nbytes % BLCKSZ != 0 ||
+				(Size) ent.nbytes > (Size) (end - ptr))
+				ereport(PANIC,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("relfile entry payload (%u bytes) "
+								"is misaligned or overruns the record", ent.nbytes)));
+			ptr += ent.nbytes;
+
+			rlocator.spcOid = ent.tablespace_oid;
+			rlocator.dbOid = ent.database_oid;
+			rlocator.relNumber = ent.relfilenumber;
+			forknum = (ForkNumber) ent.forknum;
+
+			/*
+			 * nbytes==0 means an empty relation file: create it and move on.
+			 * Empty system catalogs (pg_publication, pg_enum, ...) have
+			 * 0-byte relfiles; without recreating them the first write fails
+			 * with "could not open file".
+			 */
+			if (ent.nbytes == 0)
+			{
+				SMgrRelation srel = smgropen(rlocator, INVALID_PROC_NUMBER);
+
+				if (!smgrexists(srel, forknum))
+					smgrcreate(srel, forknum, true);
+				smgrclose(srel);
+				continue;
+			}
+
+			/* segments are RELSEG_SIZE blocks; blockoff is this chunk's start */
+			base_block = (BlockNumber) ent.segno * RELSEG_SIZE + ent.blockoff;
+			npages = ent.nbytes / BLCKSZ;
+
+			for (uint32 i = 0; i < npages; i++)
+			{
+				Buffer		buffer;
+				Page		page;
+
+				buffer = XLogReadBufferExtended(rlocator, forknum,
+												base_block + i,
+												RBM_ZERO_AND_LOCK,
+												InvalidBuffer);
+				if (!BufferIsValid(buffer))
+					ereport(PANIC,
+							(errcode(ERRCODE_DATA_CORRUPTED),
+							 errmsg("could not read block %u of relation %u/%u/%u fork %d",
+									base_block + i,
+									rlocator.spcOid, rlocator.dbOid,
+									rlocator.relNumber, forknum)));
+
+				page = BufferGetPage(buffer);
+				memcpy(page, data + (Size) i * BLCKSZ, BLCKSZ);
+
+				/*
+				 * Restamp the page LSN to this RELFILE_DATA record's own LSN,
+				 * mirroring ordinary full-page-image replay
+				 * (XLogRecGetBlockData / RestoreBlockImage in xlogutils.c).
+				 *
+				 * The captured page carries whatever LSN pg_upgrade's restore
+				 * stamped on it, which can sit above CN.  On the
+				 * streaming-standby path CN is forced down to the old
+				 * cluster's nextxlogfile segment (--wal-upgrade-exact), so a
+				 * verbatim page LSN could land beyond the window and the
+				 * end-of-recovery checkpoint would refuse to flush it ("xlog
+				 * flush request ... not satisfied").  Stamping the record LSN
+				 * keeps the page within the window (>= CN) and thus
+				 * flushable.  The physical-equivalence test masks pd_lsn, so
+				 * this does not regress the byte-match-modulo-LSN guarantee.
+				 *
+				 * An uninitialized (all-zero) page must not be stamped --
+				 * PageSetLSN on a new page corrupts the PageIsNew() invariant
+				 * (see xlogutils.c RestoreBlockImage).
+				 */
+				if (!PageIsNew(page))
+				{
+					PageSetLSN(page, record->EndRecPtr);
+
+					/*
+					 * PageSetLSN mutated pd_lsn, invalidating the pd_checksum
+					 * carried in the captured image.  Recompute it in place
+					 * so the in-memory page is self-consistent before it can
+					 * reach disk.  PageSetChecksum is a no-op when checksums
+					 * are off or the page is new, so this is safe and cheap.
+					 */
+					PageSetChecksum(page, base_block + i);
+				}
+				MarkBufferDirty(buffer);
+				UnlockReleaseBuffer(buffer);
+			}
+		}
+	}
+	else if (info == XLOG_UPGRADE_RELINK)
+	{
+		/*
+		 * Manifest of user relation files the window omits.  On a streaming
+		 * standby, link each from this node's retained old datadir into the
+		 * new skeleton at the identical relative path -- pg_upgrade preserves
+		 * relfilenumbers and db/tablespace OIDs, so old and new paths match.
+		 * This is the standby's equivalent of the primary's
+		 * transfer_relfile() step, driven by replay.
+		 *
+		 * The old datadir path is standby-local, supplied via the
+		 * pg_upgrade_standby_old_datadir GUC (see
+		 * ArmFromLocalDerivationIfConfigured); read it here.  When it is
+		 * unset -- on the primary (which already has the files) and on
+		 * archive/PITR recovery (the old files arrived with the base backup)
+		 * -- there is nothing to do.  A streaming standby that reaches this
+		 * record with no path cannot materialize user data, so it FATALs (see
+		 * below).
+		 */
+		char	   *ptr = XLogRecGetData(record);
+		char	   *end = ptr + XLogRecGetDataLen(record);
+		const char *old_datadir = pg_upgrade_standby_old_datadir;
+		bool		have_old_datadir = (old_datadir != NULL &&
+										old_datadir[0] != '\0');
+
+		if (!have_old_datadir)
+		{
+			/*
+			 * A streaming-standby skeleton (armed_streaming_standby) is empty
+			 * of user data, so reaching the manifest with no old-datadir path
+			 * to link from means every user relation would silently be absent
+			 * -- a misconfiguration, not a no-op.  Refuse to continue rather
+			 * than bring up a hot standby that is missing all its user data.
+			 * (On the primary's own crash recovery and on archive/PITR the
+			 * files are already on disk, so the same manifest is a legitimate
+			 * no-op and we just move on.)
+			 */
+			if (armed_streaming_standby)
+				ereport(FATAL,
+						(errcode(ERRCODE_CONFIG_FILE_ERROR),
+						 errmsg("reached the relink manifest but \"pg_upgrade_standby_old_datadir\" is not set"),
+						 errhint("Set \"pg_upgrade_standby_old_datadir\" in postgresql.conf to this standby's retained pre-upgrade data directory so user relations can be linked in.")));
+		}
+		else
+		{
+			/*
+			 * The standby may place user relations with a different transfer
+			 * mode than the primary used: pg_upgrade_standby_transfer_mode
+			 * overrides the per-file mode recorded in the manifest.
+			 * PG_UPGRADE_XFER_MIRROR (the default) keeps the primary's
+			 * per-file mode; anything else forces that one mode for every
+			 * file (e.g. the primary linked to save space, but this standby
+			 * wants independent copies so its old datadir stays a bootable
+			 * rollback target).  Resolved once per record; whether it is
+			 * applied is decided per entry below.
+			 */
+			bool		override_mode =
+				(pg_upgrade_standby_transfer_mode != PG_UPGRADE_XFER_MIRROR);
+			uint8		forced_mode = (uint8) pg_upgrade_standby_transfer_mode;
+
+			if (override_mode)
+				ereport(DEBUG1,
+						(errmsg("overriding relink transfer mode with pg_upgrade_standby_transfer_mode=%d",
+								pg_upgrade_standby_transfer_mode)));
+
+			while (ptr < end)
+			{
+				xl_upgrade_relink_entry ent;
+				RelFileLocator rlocator;
+				RelPathStr	relpath;
+				char		oldfile[MAXPGPATH];
+				char		newfile[MAXPGPATH];
+				char		segsuffix[32];
+				char	   *slash;
+				struct stat oldstat;
+				uint8		place_mode;
+
+				if (end - ptr < (ptrdiff_t) SizeOfXLUpgradeRelinkEntry)
+					ereport(PANIC,
+							(errcode(ERRCODE_DATA_CORRUPTED),
+							 errmsg("truncated relink entry")));
+				memcpy(&ent, ptr, SizeOfXLUpgradeRelinkEntry);
+				ptr += SizeOfXLUpgradeRelinkEntry;
+
+				/*
+				 * The record is untrusted (a corrupt-but-CRC-valid record
+				 * could carry any byte values).  Bound the fields we index or
+				 * branch on before use: forknum indexes forkNames[]
+				 * (0..MAX_FORKNUM).
+				 */
+				if (ent.forknum > MAX_FORKNUM)
+					ereport(PANIC,
+							(errcode(ERRCODE_DATA_CORRUPTED),
+							 errmsg("relink entry fork number %u out of range",
+									ent.forknum)));
+
+				/*
+				 * Effective placement mode.  Only "mirror" consults the
+				 * manifest tuple's transfer_mode -- so only then do we read
+				 * and bounds-check it (it selects a placement primitive).
+				 * With an operator override the manifest's mode is irrelevant
+				 * and left untouched; forced_mode comes from a validated GUC
+				 * enum, so it is always in range.
+				 */
+				if (override_mode)
+					place_mode = forced_mode;
+				else
+				{
+					if (ent.transfer_mode > UPGRADE_RELINK_MODE_SWAP)
+						ereport(PANIC,
+								(errcode(ERRCODE_DATA_CORRUPTED),
+								 errmsg("relink entry transfer mode %u out of range",
+										ent.transfer_mode)));
+					place_mode = ent.transfer_mode;
+				}
+
+				rlocator.spcOid = ent.tablespace_oid;
+				rlocator.dbOid = ent.database_oid;
+				rlocator.relNumber = ent.relfilenumber;
+				relpath = GetRelationPath(rlocator.dbOid, rlocator.spcOid,
+										  rlocator.relNumber,
+										  INVALID_PROC_NUMBER,
+										  (ForkNumber) ent.forknum);
+
+				/* segment 0 has no suffix; higher segments are ".N" */
+				if (ent.segno == 0)
+					segsuffix[0] = '\0';
+				else
+					snprintf(segsuffix, sizeof(segsuffix), ".%u", ent.segno);
+
+				/*
+				 * The destination path is version-stable (this binary built
+				 * both relpath and DataDir), but the source path in the
+				 * retained old datadir uses the OLD version's tablespace
+				 * directory for a user-tablespace relation -- resolve it (see
+				 * RelinkBuildOldFile).
+				 */
+				if (!RelinkBuildOldFile(old_datadir, rlocator.spcOid,
+										relpath.str, segsuffix,
+										oldfile, sizeof(oldfile)))
+					ereport(PANIC,
+							(errcode_for_file_access(),
+							 errmsg("could not resolve the old-cluster source path for tablespace %u relation \"%s\"",
+									rlocator.spcOid, relpath.str),
+							 errhint("The retained old data directory named by \"pg_upgrade_standby_old_datadir\" must contain this standby's pre-upgrade tablespace directory.")));
+				snprintf(newfile, sizeof(newfile), "%s/%s%s",
+						 DataDir, relpath.str, segsuffix);
+
+				/*
+				 * Create the destination's parent dir (base/<dboid>/ etc.);
+				 * the skeleton's initdb only made the built-in databases, and
+				 * the window's DIRTREE record may not have arrived yet.
+				 */
+				slash = strrchr(newfile, '/');
+				if (slash != NULL)
+				{
+					*slash = '\0';
+					if (MakePGDirectory(newfile) != 0 && errno != EEXIST)
+						ereport(PANIC,
+								(errcode_for_file_access(),
+								 errmsg("could not create directory \"%s\": %m",
+										newfile)));
+					*slash = '/';
+				}
+
+				/*
+				 * Place the file, reproducing pg_upgrade's transfer mode (see
+				 * RelinkPlaceFile for the per-mode taxonomy).
+				 *
+				 * The manifest is authoritative for user data, so a
+				 * destination that already exists -- an empty placeholder a
+				 * RELFILE image created for a same-numbered relation, or a
+				 * prior replay of this record -- is removed and replaced.  A
+				 * source that has since vanished (ENOENT) is skipped.
+				 */
+				if (stat(oldfile, &oldstat) != 0)
+				{
+					if (errno == ENOENT)
+						continue;	/* source gone; nothing to place */
+					ereport(PANIC,
+							(errcode_for_file_access(),
+							 errmsg("could not stat \"%s\": %m",
+									oldfile)));
+				}
+				unlink(newfile);
+				RelinkPlaceFile(oldfile, newfile, place_mode);
+
+				/*
+				 * Make the new directory entry durable.  RelinkPlaceFile()
+				 * fsync'd the file contents; fsyncing the parent directory
+				 * here persists the dentry (the new hardlink for --link, the
+				 * moved entry for --swap) before COMPLETE advances the
+				 * control file past CN.
+				 */
+				slash = strrchr(newfile, '/');
+				if (slash != NULL)
+				{
+					*slash = '\0';
+					fsync_fname(newfile, true);
+					*slash = '/';
+				}
+			}
+		}
+	}
+	else if (info == XLOG_UPGRADE_RAWFILE)
+	{
+		/*
+		 * Write a verbatim non-relation file (pg_filenode.map, PG_VERSION),
+		 * creating any missing parent directory.  These files are not
+		 * reachable through the buffer manager, so this is the only way to
+		 * rebuild the relation map and version stamps from an otherwise-empty
+		 * data directory.
+		 */
+		xl_upgrade_rawfile *xlrec =
+			(xl_upgrade_rawfile *) XLogRecGetData(record);
+		char	   *payload = (char *) xlrec + SizeOfXLUpgradeRawfile;
+		char		path[MAXPGPATH];
+		char	   *data = payload + xlrec->path_len;
+		int			fd;
+		char	   *slash;
+
+		/*
+		 * The record is untrusted.  Validate path_len + data_len against the
+		 * bytes actually present before reading either, and require the path
+		 * to be a safe PGDATA-relative path (no absolute path, no "..") so a
+		 * corrupt or hostile record cannot write outside the data directory.
+		 */
+		if (xlrec->path_len >= MAXPGPATH)
+			elog(PANIC, "rawfile path too long");
+		if ((Size) xlrec->path_len + xlrec->data_len >
+			XLogRecGetDataLen(record) - SizeOfXLUpgradeRawfile)
+			ereport(PANIC,
+					(errcode(ERRCODE_DATA_CORRUPTED),
+					 errmsg("rawfile record overruns the record")));
+		memcpy(path, payload, xlrec->path_len);
+		path[xlrec->path_len] = '\0';
+		if (path[0] == '/' || strstr(path, "..") != NULL)
+			ereport(PANIC,
+					(errcode(ERRCODE_DATA_CORRUPTED),
+					 errmsg("unsafe rawfile path \"%s\"", path)));
+
+		/* create parent directory if it does not exist (e.g. base/<dboid>) */
+		slash = strrchr(path, '/');
+		if (slash != NULL)
+		{
+			*slash = '\0';
+			if (mkdir(path, pg_dir_create_mode) != 0 && errno != EEXIST)
+				ereport(PANIC,
+						(errcode_for_file_access(),
+						 errmsg("could not create directory \"%s\": %m",
+								path)));
+			*slash = '/';
+		}
+
+		fd = OpenTransientFile(path, O_WRONLY | O_CREAT | O_TRUNC | PG_BINARY);
+		if (fd < 0)
+			ereport(PANIC,
+					(errcode_for_file_access(),
+					 errmsg("could not open \"%s\": %m", path)));
+		if (pg_pwrite(fd, data, xlrec->data_len, 0) != (ssize_t) xlrec->data_len)
+			ereport(PANIC,
+					(errcode_for_file_access(),
+					 errmsg("could not write \"%s\": %m", path)));
+		if (pg_fsync(fd) != 0)
+			ereport(PANIC,
+					(errcode_for_file_access(),
+					 errmsg("could not fsync \"%s\": %m", path)));
+		CloseTransientFile(fd);
+	}
+	else
+		elog(PANIC, "unknown op code %u", info);
+}
diff --git a/src/backend/access/transam/rmgr.c b/src/backend/access/transam/rmgr.c
index 4fda03a3cf..305ada93e8 100644
--- a/src/backend/access/transam/rmgr.c
+++ b/src/backend/access/transam/rmgr.c
@@ -39,6 +39,7 @@
 #include "replication/message.h"
 #include "replication/origin.h"
 #include "storage/standby.h"
+#include "access/pgupgrade_wal.h"	/* RM_PG_UPGRADE_ID */
 #include "utils/relmapper.h"
 /* IWYU pragma: end_keep */
 
diff --git a/src/backend/access/transam/slru.c b/src/backend/access/transam/slru.c
index 47dd52d674..b6ca7682c4 100644
--- a/src/backend/access/transam/slru.c
+++ b/src/backend/access/transam/slru.c
@@ -1443,6 +1443,77 @@ SimpleLruWriteAll(SlruDesc *ctl, bool allow_redirtied)
 		fsync_fname(ctl->options.Dir, true);
 }
 
+/*
+ * Restore one SLRU segment's worth of pages from a captured image during
+ * pg_upgrade --wal-upgrade WAL replay (XLOG_UPGRADE_SLRU_DATA redo).
+ *
+ * "segno" is the SLRU segment number; "data" is the raw segment image and
+ * "datalen" its length (a whole number of BLCKSZ pages, padded to a full
+ * segment by the emit side).  For each page the captured image is installed
+ * into a SimpleLru buffer, marked dirty, and flushed to disk immediately.
+ *
+ * The image is the final, merged CLOG/multixact state captured after the whole
+ * upgrade completed, so it is authoritative: it dominates anything an earlier
+ * replayed commit record wrote for the same page (this record is emitted last)
+ * and it does not depend on the on-disk SLRU segment file, which pg_upgrade has
+ * unlinked as part of skipping the disk writes.
+ *
+ * The SLRU directory (ctl->Dir) is recreated earlier in replay by the
+ * XLOG_UPGRADE_DIRTREE record (the logged after-image of the initdb directory
+ * tree), so it is guaranteed to exist by the time we write a segment here.
+ */
+void
+SlruUpgradeRestoreSegment(SlruDesc *ctl, int64 segno,
+						  const char *data, Size datalen)
+{
+	int			npages = (int) (datalen / BLCKSZ);
+	int64		basepage = segno * SLRU_PAGES_PER_SEGMENT;
+	SlruWriteAllData fdata;
+
+	/*
+	 * Write the whole segment through a single SlruWriteAll batch so the
+	 * segment file is opened once, not once per page.  Passing &fdata to
+	 * SlruInternalWritePage makes SlruPhysicalWritePage cache and reuse the
+	 * open fd across all of this segment's pages (exactly as
+	 * SimpleLruWriteAll does at checkpoint); we close it once at the end. All
+	 * pages of one segment share a bank on well-configured SLRUs, but acquire
+	 * the correct bank lock per page to stay correct regardless of bank
+	 * layout.
+	 */
+	fdata.num_files = 0;
+
+	for (int i = 0; i < npages; i++)
+	{
+		int64		pageno = basepage + i;
+		LWLock	   *lock = SimpleLruGetBankLock(ctl, pageno);
+		int			slotno;
+
+		LWLockAcquire(lock, LW_EXCLUSIVE);
+
+		/* allocate/zero a buffer slot for this page, then overwrite it */
+		slotno = SimpleLruZeroPage(ctl, pageno);	/* also marks the slot
+													 * dirty */
+		memcpy(ctl->shared->page_buffer[slotno],
+			   data + (Size) i * BLCKSZ, BLCKSZ);
+
+		/* persist through the batch (reuses the cached fd for this segment) */
+		SlruInternalWritePage(ctl, slotno, &fdata);
+
+		LWLockRelease(lock);
+	}
+
+	/* Close the fd(s) the batch left open. */
+	for (int i = 0; i < fdata.num_files; i++)
+	{
+		if (CloseTransientFile(fdata.fd[i]) != 0)
+		{
+			slru_errcause = SLRU_CLOSE_FAILED;
+			slru_errno = errno;
+			SlruReportIOError(ctl, basepage, NULL);
+		}
+	}
+}
+
 /*
  * Remove all segments before the one holding the passed page number
  *
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index a81912b744..1c364c0f54 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -46,7 +46,10 @@
 #include <sys/time.h>
 #include <unistd.h>
 
-#include "access/clog.h"
+#include "access/clog.h"		/* CheckPointCLOG for XLogFlushUpgradeSLRU */
+#include "access/pgupgrade_wal.h"	/* RM_PG_UPGRADE_ID, pg_upgrade WAL
+									 * functions */
+#include "access/slru.h"		/* SLRU_PAGES_PER_SEGMENT for upgrade WAL */
 #include "access/commit_ts.h"
 #include "access/heaptoast.h"
 #include "access/multixact.h"
@@ -86,6 +89,7 @@
 #include "replication/walreceiver.h"
 #include "replication/walsender.h"
 #include "storage/bufmgr.h"
+#include "storage/copydir.h"
 #include "storage/fd.h"
 #include "storage/ipc.h"
 #include "storage/large_object.h"
@@ -143,6 +147,10 @@ int			max_slot_wal_keep_size_mb = -1;
 int			wal_decode_buffer_size = 512 * 1024;
 bool		track_wal_io_timing = false;
 
+/* pg_upgrade --wal-upgrade streaming-standby options (see xlog.h) */
+int			pg_upgrade_standby_transfer_mode = PG_UPGRADE_XFER_MIRROR;
+char	   *pg_upgrade_standby_old_datadir = NULL;
+
 #ifdef WAL_DEBUG
 bool		XLOG_DEBUG = false;
 #endif
@@ -208,6 +216,21 @@ const struct config_enum_entry archive_mode_options[] = {
 	{NULL, 0, false}
 };
 
+/*
+ * pg_upgrade_standby_transfer_mode options.  "mirror" (the default) means reproduce the
+ * per-file mode the primary recorded in the RELINK manifest; the rest override
+ * it with a specific placement primitive on the standby.
+ */
+const struct config_enum_entry pg_upgrade_standby_transfer_mode_options[] = {
+	{"mirror", PG_UPGRADE_XFER_MIRROR, false},
+	{"clone", PG_UPGRADE_XFER_CLONE, false},
+	{"copy", PG_UPGRADE_XFER_COPY, false},
+	{"copy_file_range", PG_UPGRADE_XFER_COPY_FILE_RANGE, false},
+	{"link", PG_UPGRADE_XFER_LINK, false},
+	{"swap", PG_UPGRADE_XFER_SWAP, false},
+	{NULL, 0, false}
+};
+
 /*
  * Statistics for current checkpoint are collected in this global struct.
  * Because only the checkpointer or a stand-alone backend can perform
@@ -4402,6 +4425,221 @@ WriteControlFile(void)
 						XLOG_CONTROL_FILE)));
 }
 
+/*
+ * Synthesize a minimal, valid global/pg_control (and PG_VERSION) from this
+ * binary's compile-time constants so a --wal-upgrade recovery can start without
+ * initdb.  Only the compatibility-check fields must be right; the run-time
+ * fields are fixed up afterward by ArmControlFileForUpgradeRecovery().
+ *
+ * allow_overwrite=false creates the file O_EXCL (streaming-standby skeleton,
+ * never clobber an existing one); allow_overwrite=true uses O_TRUNC to replace
+ * the old-version pg_control left by archive-PITR recovery.
+ *
+ * Runs in the postmaster before CreateSharedMemoryAndSemaphores(), so there is
+ * no shared ControlFile yet; a local buffer is built and written directly.
+ */
+void
+SynthesizeUpgradeStreamControlFile(bool allow_overwrite)
+{
+	ControlFileData *cf;
+	char		buffer[PG_CONTROL_FILE_SIZE];	/* need not be aligned */
+	char		verpath[MAXPGPATH];
+	char		globaldir[MAXPGPATH];
+	char		ctlpath[MAXPGPATH];
+	int			fd;
+	char		mock_auth_nonce[MOCK_AUTH_NONCE_LEN];
+
+	/*
+	 * The CWD is not yet the data directory at this point, so build absolute
+	 * paths from DataDir rather than relying on relative names.
+	 */
+	snprintf(globaldir, sizeof(globaldir), "%s/global", DataDir);
+	snprintf(ctlpath, sizeof(ctlpath), "%s/%s", DataDir, XLOG_CONTROL_FILE);
+
+	/*
+	 * On the overwrite (archive-PITR) path, keep an existing valid
+	 * current-version control file.  A same-version PITR restore's base
+	 * backup carries a good new-version pg_control, and clobbering it would
+	 * discard its data_checksum_version (synthesis hardcodes 0), so a
+	 * checksummed cluster would then fail page verification during replay.
+	 * Synthesize only when the existing file is absent or a different major's
+	 * (the real cross-version case).
+	 */
+	if (allow_overwrite)
+	{
+		struct stat st;
+
+		if (stat(ctlpath, &st) == 0)
+		{
+			bool		existing_crc_ok = false;
+			ControlFileData *existing = get_controlfile(DataDir, &existing_crc_ok);
+
+			if (existing != NULL)
+			{
+				bool		usable = (existing_crc_ok &&
+									  existing->pg_control_version == PG_CONTROL_VERSION &&
+									  existing->catalog_version_no == CATALOG_VERSION_NO);
+
+				pfree(existing);
+				if (usable)
+				{
+					ereport(LOG,
+							(errmsg("keeping the existing new-version control file for upgrade-stream recovery"),
+							 errdetail("A valid same-version pg_control is present; not synthesizing over it (preserves its data checksum state).")));
+					return;
+				}
+			}
+		}
+	}
+
+	cf = (ControlFileData *) palloc0(sizeof(ControlFileData));
+
+	if (!pg_strong_random(mock_auth_nonce, MOCK_AUTH_NONCE_LEN))
+		ereport(FATAL,
+				(errcode(ERRCODE_INTERNAL_ERROR),
+				 errmsg("could not generate secret authorization token")));
+
+	/*
+	 * Status fields.  system_identifier is a placeholder (0); the arm
+	 * overwrites it with the WAL's sysid later.  DB_SHUTDOWNED is what the
+	 * arm expects.
+	 */
+	cf->system_identifier = 0;
+	memcpy(cf->mock_authentication_nonce, mock_auth_nonce, MOCK_AUTH_NONCE_LEN);
+	cf->state = DB_SHUTDOWNED;
+	cf->unloggedLSN = FirstNormalUnloggedLSN;
+
+	/*
+	 * Parameter values used when replaying WAL, from this node's GUCs (its
+	 * own postgresql.conf or the built-in defaults, not the primary's).  As
+	 * for any standby, these must be at least the primary's values or
+	 * recovery aborts "because of insufficient parameter settings" when the
+	 * window's PARAMETER_CHANGE record replays.
+	 */
+	cf->MaxConnections = MaxConnections;
+	cf->max_worker_processes = max_worker_processes;
+	cf->max_wal_senders = max_wal_senders;
+	cf->max_prepared_xacts = max_prepared_xacts;
+	cf->max_locks_per_xact = max_locks_per_xact;
+	cf->wal_level = wal_level;
+	cf->wal_log_hints = wal_log_hints;
+	cf->track_commit_timestamp = track_commit_timestamp;
+	cf->data_checksum_version = 0;
+
+	/* Version and compatibility-check fields (all compile-time constants). */
+	cf->pg_control_version = PG_CONTROL_VERSION;
+	cf->catalog_version_no = CATALOG_VERSION_NO;
+	cf->maxAlign = MAXIMUM_ALIGNOF;
+	cf->floatFormat = FLOATFORMAT_VALUE;
+	cf->blcksz = BLCKSZ;
+	cf->relseg_size = RELSEG_SIZE;
+	cf->slru_pages_per_segment = SLRU_PAGES_PER_SEGMENT;
+	cf->xlog_blcksz = XLOG_BLCKSZ;
+	cf->xlog_seg_size = wal_segment_size;
+	cf->nameDataLen = NAMEDATALEN;
+	cf->indexMaxKeys = INDEX_MAX_KEYS;
+	cf->toast_max_chunk_size = TOAST_MAX_CHUNK_SIZE;
+	cf->loblksize = LOBLKSIZE;
+	cf->float8ByVal = true;		/* vestigial */
+	cf->default_char_signedness = true;
+
+	/* CRC over the struct, exactly as WriteControlFile() does. */
+	INIT_CRC32C(cf->crc);
+	COMP_CRC32C(cf->crc, cf, offsetof(ControlFileData, crc));
+	FIN_CRC32C(cf->crc);
+
+	memset(buffer, 0, PG_CONTROL_FILE_SIZE);
+	memcpy(buffer, cf, sizeof(ControlFileData));
+
+	/*
+	 * Create the standard cluster subdirectories initdb would make; startup
+	 * opens some before replaying the window's DIRTREE record.  Mirrors
+	 * initdb.c's subdirs[]; EEXIST is tolerated.
+	 */
+	{
+		static const char *const subdirs[] = {
+			"global", "base", "pg_wal", "pg_wal/archive_status", "pg_wal/summaries",
+			"pg_commit_ts", "pg_dynshmem", "pg_notify", "pg_serial",
+			"pg_snapshots", "pg_subtrans", "pg_twophase",
+			"pg_multixact", "pg_multixact/members", "pg_multixact/offsets",
+			"pg_replslot", "pg_tblspc", "pg_stat", "pg_stat_tmp", "pg_xact",
+			"pg_logical", "pg_logical/snapshots", "pg_logical/mappings"
+		};
+
+		for (int s = 0; s < (int) lengthof(subdirs); s++)
+		{
+			char		dpath[MAXPGPATH];
+
+			snprintf(dpath, sizeof(dpath), "%s/%s", DataDir, subdirs[s]);
+			if (MakePGDirectory(dpath) != 0 && errno != EEXIST)
+				ereport(FATAL,
+						(errcode_for_file_access(),
+						 errmsg("could not create directory \"%s\": %m", dpath)));
+		}
+	}
+
+	/*
+	 * Write global/pg_control: O_EXCL for a streaming standby (must not
+	 * clobber), O_TRUNC for archive-PITR recovery (must replace the
+	 * old-version file).
+	 */
+	fd = BasicOpenFile(ctlpath,
+					   O_RDWR | O_CREAT | PG_BINARY |
+					   (allow_overwrite ? O_TRUNC : O_EXCL));
+	if (fd < 0)
+		ereport(FATAL,
+				(errcode_for_file_access(),
+				 errmsg("could not create file \"%s\": %m", ctlpath)));
+	errno = 0;
+	if (write(fd, buffer, PG_CONTROL_FILE_SIZE) != PG_CONTROL_FILE_SIZE)
+	{
+		if (errno == 0)
+			errno = ENOSPC;
+		ereport(FATAL,
+				(errcode_for_file_access(),
+				 errmsg("could not write to file \"%s\": %m", ctlpath)));
+	}
+	if (pg_fsync(fd) != 0)
+		ereport(FATAL,
+				(errcode_for_file_access(),
+				 errmsg("could not fsync file \"%s\": %m", ctlpath)));
+	if (close(fd) != 0)
+		ereport(FATAL,
+				(errcode_for_file_access(),
+				 errmsg("could not close file \"%s\": %m", ctlpath)));
+
+	/*
+	 * Write a matching PG_VERSION (major-version stamp checkDataDir
+	 * requires).
+	 */
+	snprintf(verpath, sizeof(verpath), "%s/PG_VERSION", DataDir);
+	fd = BasicOpenFile(verpath, O_RDWR | O_CREAT | O_TRUNC | PG_BINARY);
+	if (fd < 0)
+		ereport(FATAL,
+				(errcode_for_file_access(),
+				 errmsg("could not create file \"%s\": %m", verpath)));
+	if (write(fd, PG_MAJORVERSION "\n", strlen(PG_MAJORVERSION) + 1) !=
+		(int) (strlen(PG_MAJORVERSION) + 1))
+		ereport(FATAL,
+				(errcode_for_file_access(),
+				 errmsg("could not write to file \"%s\": %m", verpath)));
+	if (pg_fsync(fd) != 0)
+		ereport(FATAL,
+				(errcode_for_file_access(),
+				 errmsg("could not fsync file \"%s\": %m", verpath)));
+	if (close(fd) != 0)
+		ereport(FATAL,
+				(errcode_for_file_access(),
+				 errmsg("could not close file \"%s\": %m", verpath)));
+
+	pfree(cf);
+
+	ereport(LOG,
+			(errmsg("synthesized a fresh pg_control and PG_VERSION for an upgrade-stream standby"),
+			 errdetail("The standby will stream and replay the upgrade window from its primary; no initdb was required.")));
+}
+
+
 static void
 ReadControlFile(void)
 {
@@ -4636,6 +4874,171 @@ UpdateControlFile(void)
 	update_controlfile(DataDir, ControlFile, true);
 }
 
+/*
+ * Arm the control file for pg_upgrade --wal-upgrade recovery.
+ *
+ * Points checkPoint at CN (the end-of-upgrade checkpoint) and forces wal_level
+ * to replica so recovery replays from CN through XLOG_UPGRADE_COMPLETE.  State
+ * and minRecoveryPoint differ by mode (see below).  Called before StartupXLOG()
+ * reads checkPointCopy, so the update takes effect this recovery cycle.
+ */
+void
+ArmControlFileForUpgradeRecovery(const struct CheckPoint *cn, XLogRecPtr cn_lsn,
+								 uint64 wal_sysid, bool for_streaming)
+{
+	Assert(ControlFile != NULL);
+
+	ControlFile->checkPoint = cn_lsn;
+	ControlFile->checkPointCopy = *cn;
+	ControlFile->wal_level = WAL_LEVEL_REPLICA;
+	ControlFile->minRecoveryPointTLI = cn->ThisTimeLineID;
+
+	if (for_streaming)
+	{
+		/*
+		 * Streaming standby: CN is not on local disk yet; it must stream in
+		 * from the primary.  InitWalRecovery() enters standby mode only when
+		 * minRecoveryPoint is valid or state == DB_SHUTDOWNED, so set both.
+		 */
+		ControlFile->state = DB_SHUTDOWNED;
+		ControlFile->minRecoveryPoint = cn_lsn;
+	}
+	else
+	{
+		/*
+		 * Local-window arm: the whole window is already in pg_wal/, so
+		 * recover it as ordinary crash recovery, stopping at the end of
+		 * available WAL.
+		 */
+		ControlFile->state = DB_IN_PRODUCTION;
+		ControlFile->minRecoveryPoint = InvalidXLogRecPtr;
+		ControlFile->minRecoveryPointTLI = 0;
+	}
+
+	/*
+	 * Adopt the sysid the upgrade WAL was emitted under so recovery's
+	 * per-page xlp_sysid checks accept the burst.  wal_sysid==0 means the
+	 * scan could not read it; leave pg_control untouched.
+	 */
+	if (wal_sysid != 0)
+		ControlFile->system_identifier = wal_sysid;
+
+	UpdateControlFile();
+}
+
+/*
+ * Informational state flips bracketing the upgrade-window replay: redo calls
+ * these at XLOG_UPGRADE_START and XLOG_UPGRADE_COMPLETE so a crash mid-window
+ * (or pg_controldata) shows "in pg_upgrade".  They do not affect the
+ * recovery-mode decision; Clear restores DB_IN_PRODUCTION.
+ */
+void
+SetControlFileInUpgrade(void)
+{
+	Assert(ControlFile != NULL);
+
+	ControlFile->state = DB_IN_UPGRADE;
+	ControlFile->time = (pg_time_t) time(NULL);
+	UpdateControlFile();
+}
+
+void
+ClearControlFileInUpgrade(void)
+{
+	Assert(ControlFile != NULL);
+
+	if (ControlFile->state == DB_IN_UPGRADE)
+	{
+		ControlFile->state = DB_IN_PRODUCTION;
+		ControlFile->time = (pg_time_t) time(NULL);
+		UpdateControlFile();
+	}
+}
+
+/*
+ * The checkpoint LSN currently recorded in the control file.  Used by
+ * PerformWalUpgradeIfNeeded() as the timeline-independent "already applied?"
+ * signal: once first startup finalizes, this advances past XLOG_UPGRADE_COMPLETE,
+ * whereas a pending upgrade still points at/before CN (which precedes COMPLETE).
+ */
+XLogRecPtr
+GetControlFileCheckPointLSN(void)
+{
+	Assert(ControlFile != NULL);
+	return ControlFile->checkPoint;
+}
+
+/*
+ * --wal-upgrade: durable "upgrade window replayed to COMPLETE" flag.  Set (and
+ * fsync'd) by the XLOG_UPGRADE_COMPLETE redo handler the instant the window
+ * finishes -- one checkpoint BEFORE the end-of-recovery checkpoint advances the
+ * control checkpoint past CN -- so that a crash in that gap leaves the flag set
+ * with the checkpoint still at CN, which PerformWalUpgradeIfNeeded() treats as
+ * "re-arm and re-replay" (idempotent), not "finalized".  The flag lives in
+ * pg_control so it is updated durably in the same write as the rest of the
+ * control-file state.
+ */
+void
+SetControlFileUpgradeFinalized(void)
+{
+	Assert(ControlFile != NULL);
+
+	/*
+	 * Called from two contexts: the XLOG_UPGRADE_COMPLETE redo handler
+	 * (startup process, single-threaded recovery) and EmitUpgradeWalWindow()
+	 * on the live burst server (an ordinary backend, concurrent with the
+	 * checkpointer). Take ControlFileLock so the latter is safe; the former's
+	 * acquire is uncontended.
+	 */
+	LWLockAcquire(ControlFileLock, LW_EXCLUSIVE);
+	if (!ControlFile->upgrade_finalized)
+	{
+		ControlFile->upgrade_finalized = true;
+		UpdateControlFile();
+	}
+	LWLockRelease(ControlFileLock);
+}
+
+bool
+GetControlFileUpgradeFinalized(void)
+{
+	Assert(ControlFile != NULL);
+	return ControlFile->upgrade_finalized;
+}
+
+/*
+ * --wal-upgrade: durable "an upgrade window has been started here" flag.  Set on
+ * the burst server just before XLOG_UPGRADE_START is emitted, so a crash before
+ * COMPLETE leaves a durable trace (upgrade_started && !upgrade_finalized) that
+ * first-startup can refuse on, independent of whether the START-bearing WAL
+ * survived recycling.  See the field comment in pg_control.h.
+ */
+void
+SetControlFileUpgradeStarted(void)
+{
+	Assert(ControlFile != NULL);
+
+	/*
+	 * Like SetControlFileUpgradeFinalized(): may run on the live burst server
+	 * (an ordinary backend, concurrent with the checkpointer), so take the
+	 * lock.
+	 */
+	LWLockAcquire(ControlFileLock, LW_EXCLUSIVE);
+	if (!ControlFile->upgrade_started)
+	{
+		ControlFile->upgrade_started = true;
+		UpdateControlFile();
+	}
+	LWLockRelease(ControlFileLock);
+}
+
+bool
+GetControlFileUpgradeStarted(void)
+{
+	Assert(ControlFile != NULL);
+	return ControlFile->upgrade_started;
+}
+
 /*
  * Returns the unique system identifier from control file.
  */
@@ -5932,6 +6335,22 @@ StartupXLOG(void)
 									 timebuf, sizeof(timebuf)))));
 			break;
 
+		case DB_IN_UPGRADE:
+
+			/*
+			 * Crashed while replaying the upgrade window (informational
+			 * state).  PerformWalUpgradeIfNeeded() already re-armed at CN
+			 * before this point (resetting the state to DB_IN_PRODUCTION), so
+			 * this case is reached only if that arming was skipped; report it
+			 * honestly and let recovery proceed rather than FATAL as an
+			 * "invalid" state.
+			 */
+			ereport(LOG,
+					(errmsg("database system was interrupted while replaying a pg_upgrade window (last known up at %s)",
+							str_time(ControlFile->time,
+									 timebuf, sizeof(timebuf)))));
+			break;
+
 		default:
 			ereport(FATAL,
 					(errcode(ERRCODE_DATA_CORRUPTED),
@@ -6561,6 +6980,16 @@ StartupXLOG(void)
 	if (performedWalRecovery)
 		promoted = PerformRecoveryXLogAction();
 
+	/*
+	 * The --wal-upgrade new cluster comes up read-write on first start, as
+	 * ordinary pg_upgrade does.  Atomicity is enforced earlier:
+	 * PerformWalUpgradeIfNeeded() FATALs on a partial
+	 * (START-without-COMPLETE) local window before arming, so only a
+	 * fully-replayed window reaches this point and serves.  If the upgrade
+	 * result is unsatisfactory, the operator falls back to the untouched old
+	 * cluster (or a standby).
+	 */
+
 	/*
 	 * If any of the critical GUCs have changed, log them before we allow
 	 * backends to write WAL.
@@ -7116,6 +7545,21 @@ ShutdownXLOG(int code, Datum arg)
 	ereport(IsPostmasterEnvironment ? LOG : NOTICE,
 			(errmsg("shutting down")));
 
+	/*
+	 * pg_upgrade --wal-upgrade-signal-handoff: if it armed a handoff, emit
+	 * the XLOG_UPGRADE_HANDOFF trigger now, while WAL insertion is still
+	 * permitted and WAL senders are still active to stream it.  This runs
+	 * after the postmaster has drained all backends (fast shutdown waits for
+	 * PM_WAIT_BACKENDS before invoking the shutdown checkpoint), so every
+	 * user commit is already flushed at an LSN below the handoff; the
+	 * shutdown checkpoint that follows lands above it.  The handoff is
+	 * therefore the exact end of the old WAL stream a streaming standby stops
+	 * at, with no commit able to slip past it.  Only a live primary emits (a
+	 * standby replaying this record must not re-emit).
+	 */
+	if (!RecoveryInProgress())
+		EmitPgUpgradeHandoffIfArmed();
+
 	/*
 	 * Signal walsenders to move to stopping state.
 	 */
@@ -8667,6 +9111,849 @@ XLogAssignLSN(void)
 	return XLogInsert(RM_XLOG_ID, XLOG_ASSIGN_LSN);
 }
 
+/*
+ * Write a WAL record marking the start (XLOG_UPGRADE_START) or completion
+ * (XLOG_UPGRADE_COMPLETE) of pg_upgrade.  These records bracket the
+ * schema-restore window; both markers present means the upgrade was atomic.
+ */
+XLogRecPtr
+XLogWritePgUpgrade(bool is_start, uint32 old_major_version,
+				   uint32 new_major_version)
+{
+	xl_pg_upgrade xlrec;
+	XLogRecPtr	RecPtr;
+
+	/* zero padding and the pg_version tail so no stack bytes leak into WAL */
+	memset(&xlrec, 0, sizeof(xlrec));
+	xlrec.old_major_version = old_major_version;
+	xlrec.new_major_version = new_major_version;
+	xlrec.upgrade_time = (pg_time_t) time(NULL);
+	/* Embed PG_MAJORVERSION so redo can write $PGDATA/PG_VERSION */
+	snprintf(xlrec.pg_version, sizeof(xlrec.pg_version), "%s\n",
+			 PG_MAJORVERSION);
+
+	XLogBeginInsert();
+	XLogRegisterData(&xlrec, SizeOfXLPgUpgrade);
+	RecPtr = XLogInsert(RM_PG_UPGRADE_ID,
+						is_start ? XLOG_UPGRADE_START
+						: XLOG_UPGRADE_COMPLETE);
+
+	if (is_start)
+		ereport(LOG,
+				(errmsg("recorded pg_upgrade start at %X/%X (old major version %u, new major version %u)",
+						LSN_FORMAT_ARGS(RecPtr),
+						old_major_version, new_major_version)));
+	else
+		ereport(LOG,
+				(errmsg("recorded pg_upgrade completion at %X/%X (old major version %u, new major version %u)",
+						LSN_FORMAT_ARGS(RecPtr),
+						old_major_version, new_major_version)));
+
+	return RecPtr;
+}
+
+/*
+ * XLogWritePgUpgradeHandoff -- emit the OLD-format streaming-handoff trigger.
+ *
+ * Called from ShutdownXLOG() on the OLD primary as it shuts down (see
+ * EmitPgUpgradeHandoffIfArmed), so the record is written in the OLD WAL page
+ * format, streamed to a physical standby still following it, and guaranteed to
+ * sit after all user WAL and before the shutdown checkpoint.  See
+ * xl_pg_upgrade_handoff for why this is a separate record from the new-format
+ * XLOG_UPGRADE_START burst.
+ */
+XLogRecPtr
+XLogWritePgUpgradeHandoff(uint32 old_major_version, uint32 target_major_version)
+{
+	xl_pg_upgrade_handoff xlrec;
+	XLogRecPtr	RecPtr;
+
+	/* zero padding so no uninitialized stack bytes leak into WAL */
+	memset(&xlrec, 0, sizeof(xlrec));
+	xlrec.old_major_version = old_major_version;
+	xlrec.target_major_version = target_major_version;
+	xlrec.handoff_time = (pg_time_t) time(NULL);
+
+	XLogBeginInsert();
+	XLogRegisterData(&xlrec, SizeOfXLPgUpgradeHandoff);
+	RecPtr = XLogInsert(RM_PG_UPGRADE_ID, XLOG_UPGRADE_HANDOFF);
+
+	/*
+	 * Flush it: a streaming standby must receive this record before the
+	 * primary shuts down, so it must be on disk, not buffered.
+	 */
+	XLogFlush(RecPtr);
+
+	ereport(LOG,
+			(errmsg("pg_upgrade handoff trigger recorded at %X/%X "
+					"(old major version %u, target major version %u)",
+					LSN_FORMAT_ARGS(RecPtr),
+					old_major_version, target_major_version)));
+
+	return RecPtr;
+}
+
+/*
+ * EmitPgUpgradeHandoffIfArmed -- emit the streaming-handoff trigger at shutdown
+ * if pg_upgrade --wal-upgrade-signal-handoff armed it.
+ *
+ * Called from ShutdownXLOG() on a live primary, before WAL senders stop and
+ * before the shutdown checkpoint.  The sentinel file (written by the pg_upgrade
+ * lifecycle subcommand) holds the target major version.  Absent sentinel is the
+ * common case and a no-op.  The file is removed once consumed so a later restart
+ * does not re-emit.
+ */
+void
+EmitPgUpgradeHandoffIfArmed(void)
+{
+	const char *path = PG_UPGRADE_HANDOFF_SIGNAL_FILE;
+	FILE	   *f;
+	int			target_major = 0;
+
+	f = AllocateFile(path, "r");
+	if (f == NULL)
+	{
+		if (errno != ENOENT)
+			ereport(FATAL,
+					(errcode_for_file_access(),
+					 errmsg("could not open pg_upgrade handoff signal file \"%s\": %m",
+							path)));
+		return;					/* not armed: normal shutdown */
+	}
+
+	if (fscanf(f, "%d", &target_major) != 1 || target_major <= 0)
+	{
+		FreeFile(f);
+		ereport(FATAL,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("invalid data in pg_upgrade handoff signal file \"%s\"",
+						path)));
+	}
+	FreeFile(f);
+
+	(void) XLogWritePgUpgradeHandoff(PG_VERSION_NUM / 10000,
+									 (uint32) target_major);
+
+	if (unlink(path) != 0)
+		ereport(FATAL,
+				(errcode_for_file_access(),
+				 errmsg("could not remove pg_upgrade handoff signal file \"%s\": %m",
+						path)));
+}
+
+/*
+ * Recursively collect PGDATA-relative subdirectory paths under "abspath"
+ * (relative prefix "relpath"), appending each NUL-terminated to dbuf and
+ * counting them in *ndirs.  Parents are appended before children so a plain
+ * mkdir() per path suffices on replay.
+ *
+ * pg_wal is skipped; the upgrade WAL segments and pg_wal/ are handled by
+ * PerformWalUpgradeIfNeeded(), not by the directory after-image.
+ */
+static void
+CollectUpgradeDirs(const char *abspath, const char *relpath,
+				   StringInfo dbuf, uint32 *ndirs,
+				   StringInfo sbuf, uint32 *nsymlinks)
+{
+	DIR		   *dir;
+	struct dirent *de;
+
+	dir = AllocateDir(abspath);
+	if (dir == NULL)
+		return;
+
+	while ((de = ReadDir(dir, abspath)) != NULL)
+	{
+		char		childabs[MAXPGPATH];
+		char		childrel[MAXPGPATH];
+		struct stat st;
+
+		if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0)
+			continue;
+
+		/* top-level pg_wal is not part of the after-image (see comment) */
+		if (relpath[0] == '\0' && strcmp(de->d_name, "pg_wal") == 0)
+			continue;
+
+		/*
+		 * Top-level pg_replslot is not part of the after-image either.  Its
+		 * upgrade-window retention slot has a per-slot "state" file that is
+		 * not WAL-logged, so capturing the directory without it would leave a
+		 * reconstructed cluster with an empty pg_replslot/<slot>/ that PANICs
+		 * at startup.  The slot is a primary-only artifact; a reconstructed
+		 * cluster starts with an empty pg_replslot/, so skip the whole
+		 * subtree.
+		 */
+		if (relpath[0] == '\0' && strcmp(de->d_name, "pg_replslot") == 0)
+			continue;
+
+		snprintf(childabs, sizeof(childabs), "%s/%s", abspath, de->d_name);
+
+		if (relpath[0] == '\0')
+			snprintf(childrel, sizeof(childrel), "%s", de->d_name);
+		else
+			snprintf(childrel, sizeof(childrel), "%s/%s", relpath, de->d_name);
+
+		/*
+		 * A symlink (e.g. pg_tblspc/<spcoid>) is captured as (linkpath,
+		 * target) so replay can recreate it; we do not recurse through it
+		 * (its contents arrive as RELFILE images via smgr once the symlink
+		 * exists).
+		 */
+		if (lstat(childabs, &st) == 0 && S_ISLNK(st.st_mode))
+		{
+			char		target[MAXPGPATH];
+			ssize_t		tlen;
+
+			tlen = readlink(childabs, target, sizeof(target) - 1);
+			if (tlen < 0 || tlen >= (ssize_t) sizeof(target))
+				continue;		/* unreadable / too long -- skip */
+			target[tlen] = '\0';
+
+			appendBinaryStringInfo(sbuf, childrel, strlen(childrel) + 1);
+			appendBinaryStringInfo(sbuf, target, strlen(target) + 1);
+			(*nsymlinks)++;
+			continue;
+		}
+
+		if (lstat(childabs, &st) != 0 || !S_ISDIR(st.st_mode))
+			continue;			/* only directories and symlinks; skip files */
+
+		/* append this directory, then recurse into it (parent-before-child) */
+		appendBinaryStringInfo(dbuf, childrel, strlen(childrel) + 1);
+		(*ndirs)++;
+
+		CollectUpgradeDirs(childabs, childrel, dbuf, ndirs, sbuf, nsymlinks);
+	}
+	FreeDir(dir);
+}
+
+/*
+ * Emit one XLOG_UPGRADE_DIRTREE record capturing the after-image of the new
+ * cluster's directory tree (every subdirectory under PGDATA except pg_wal).
+ * initdb creates this tree outside the server, so it is not otherwise
+ * WAL-logged; this lets recovery rebuild the skeleton from WAL without initdb.
+ *
+ * Emitted right after XLOG_UPGRADE_START and before the file-image records, so
+ * the directories exist before any relfile/SLRU image is replayed into them.
+ */
+XLogRecPtr
+XLogWriteUpgradeDirSkel(void)
+{
+	xl_upgrade_dirtree xlrec;
+	StringInfoData dbuf;		/* directory paths */
+	StringInfoData sbuf;		/* symlink entries */
+	XLogRecPtr	RecPtr;
+
+	/* zero padding so no uninitialized stack bytes leak into WAL */
+	memset(&xlrec, 0, sizeof(xlrec));
+	initStringInfo(&dbuf);
+	initStringInfo(&sbuf);
+	xlrec.ndirs = 0;
+	xlrec.nsymlinks = 0;
+	CollectUpgradeDirs(DataDir, "", &dbuf, &xlrec.ndirs,
+					   &sbuf, &xlrec.nsymlinks);
+	xlrec.dir_bytes = (uint32) dbuf.len;
+	xlrec.sym_bytes = (uint32) sbuf.len;
+
+	XLogBeginInsert();
+	XLogRegisterData(&xlrec, SizeOfXLUpgradeDirtree);
+	if (dbuf.len > 0)
+		XLogRegisterData(dbuf.data, dbuf.len);
+	if (sbuf.len > 0)
+		XLogRegisterData(sbuf.data, sbuf.len);
+	RecPtr = XLogInsert(RM_PG_UPGRADE_ID, XLOG_UPGRADE_DIRTREE);
+
+	ereport(LOG,
+			(errmsg("pg_upgrade directory after-image recorded at %X/%X (%u directories, %u symlinks)",
+					LSN_FORMAT_ARGS(RecPtr), xlrec.ndirs, xlrec.nsymlinks)));
+
+	pfree(dbuf.data);
+	pfree(sbuf.data);
+	return RecPtr;
+}
+
+/*
+ * Emit XLOG_UPGRADE_SLRU_DATA records for all segment files in one SLRU
+ * directory (pg_xact, pg_multixact/offsets, or pg_multixact/members).  These
+ * FPIs are self-contained: redo is the sole writer of these pages, so it just
+ * writes the raw bytes back per segment file.
+ *
+ * Segments are sorted and packed into records covering contiguous runs
+ * [first_seg, last_seg] to minimise record count.  Returns the LSN of the last
+ * record, or InvalidXLogRecPtr if the directory is empty.
+ */
+XLogRecPtr
+XLogWriteUpgradeSlruData(uint8 slru_type)
+{
+	const char *slru_dirs[] = UPGRADE_SLRU_DIRS;
+	const char *slru_dir;
+	DIR		   *dir;
+	struct dirent *de;
+	XLogRecPtr	lsn = InvalidXLogRecPtr;
+
+	/* sorted list of (seg_number, filepath) pairs */
+	typedef struct
+	{
+		int64		segno;
+		char		path[MAXPGPATH];
+	} SlruSegInfo;
+
+	SlruSegInfo *segs = NULL;
+	int			nsegs = 0;
+	int			segs_alloc = 0;
+	int			i;
+
+	/*
+	 * Max segments per WAL record.  Reserve headroom for the
+	 * xl_upgrade_slru_data header and the XLogRecord header so a full batch's
+	 * data plus those headers still fits under XLogRecordMaxSize; otherwise a
+	 * contiguous run of exactly XLogRecordMaxSize/seg_size segments would
+	 * produce a record that XLogInsert() rejects as oversized.  (Mirrors the
+	 * relfile batch cap's headroom.)
+	 */
+	Size		seg_size = SLRU_PAGES_PER_SEGMENT * BLCKSZ;
+	int			max_segs_per_rec =
+		(int) ((XLogRecordMaxSize - SizeOfXLUpgradeSlruData - SizeOfXLogRecord)
+			   / seg_size);
+
+	if (slru_type >= lengthof(slru_dirs))
+		elog(ERROR, "invalid slru_type %u", slru_type);
+
+	slru_dir = slru_dirs[slru_type];
+
+	/* --- Pass 1: collect and sort segment numbers --- */
+	dir = AllocateDir(slru_dir);
+	if (dir == NULL)
+		return InvalidXLogRecPtr;
+
+	while ((de = ReadDir(dir, slru_dir)) != NULL)
+	{
+		int64		segno;
+		size_t		len = strlen(de->d_name);
+
+		if (de->d_name[0] == '.')
+			continue;
+
+		/*
+		 * Parse the FULL hex segment name, mirroring SlruScanDirectory() in
+		 * slru.c.  A capped read (e.g. "%04") would truncate long (15-digit)
+		 * or high-count (5-6 digit) names to a wrong segment number and
+		 * install the pages at the wrong offset.  Accept only all-hex names.
+		 */
+		if (strspn(de->d_name, "0123456789ABCDEF") != len)
+			continue;
+		segno = strtoi64(de->d_name, NULL, 16);
+
+		if (nsegs >= segs_alloc)
+		{
+			segs_alloc = (segs_alloc == 0) ? 64 : segs_alloc * 2;
+			if (segs == NULL)
+				segs = palloc_array(SlruSegInfo, segs_alloc);
+			else
+				segs = repalloc_array(segs, SlruSegInfo, segs_alloc);
+		}
+		segs[nsegs].segno = segno;
+		snprintf(segs[nsegs].path, MAXPGPATH, "%s/%s", slru_dir, de->d_name);
+		nsegs++;
+	}
+	FreeDir(dir);
+
+	if (nsegs == 0)
+	{
+		if (segs)
+			pfree(segs);
+		return InvalidXLogRecPtr;
+	}
+
+	/* sort ascending by segment number */
+	{
+		int			k,
+					n = nsegs;
+
+		/* simple insertion sort -- segment count is typically small */
+		for (k = 1; k < n; k++)
+		{
+			SlruSegInfo tmp = segs[k];
+			int			m = k;
+
+			while (m > 0 && segs[m - 1].segno > tmp.segno)
+			{
+				segs[m] = segs[m - 1];
+				m--;
+			}
+			segs[m] = tmp;
+		}
+	}
+
+	/* --- Pass 2: pack consecutive segments per record, allocate exact size --- */
+	i = 0;
+	while (i < nsegs)
+	{
+		xl_upgrade_slru_data xlrec;
+		int			j;
+		int			batch_count;
+		Size		batch_bytes;
+		char	   *packbuf;
+
+		/* Determine how many consecutive segments go in this batch */
+		j = i;
+		while (j < nsegs &&
+			   (j - i) < max_segs_per_rec &&
+			   (j == i || segs[j].segno == segs[j - 1].segno + 1))
+			j++;
+
+		batch_count = j - i;
+		batch_bytes = (Size) batch_count * seg_size;
+
+		/* Allocate exactly the batch size, not a worst-case buffer. */
+		packbuf = palloc(batch_bytes);
+
+		/* Read each segment file into the pack buffer */
+		for (int k = 0; k < batch_count; k++)
+		{
+			int			fd;
+			ssize_t		nbytes;
+
+			char	   *segbuf = packbuf + (Size) k * seg_size;
+			Size		total = 0;
+
+			fd = OpenTransientFile(segs[i + k].path, O_RDONLY | PG_BINARY);
+			if (fd < 0)
+				ereport(ERROR,
+						(errcode_for_file_access(),
+						 errmsg("could not open SLRU file \"%s\": %m",
+								segs[i + k].path)));
+
+			/*
+			 * Read the whole segment.  read() may return a short count
+			 * without reaching EOF (e.g. interrupted after a partial
+			 * transfer), so loop: only a return of 0 is genuine EOF.  A
+			 * segment that ends before seg_size is a legitimately-partial
+			 * final segment; zero-pad its remainder so redo can stride by
+			 * seg_size.  Treating any short read as EOF would zero-fill the
+			 * tail of a full interior segment and corrupt it (for pg_xact,
+			 * silently losing commit status).
+			 */
+			for (;;)
+			{
+				nbytes = read(fd, segbuf + total, seg_size - total);
+				if (nbytes < 0)
+				{
+					CloseTransientFile(fd);
+					ereport(ERROR,
+							(errcode_for_file_access(),
+							 errmsg("could not read SLRU file \"%s\": %m",
+									segs[i + k].path)));
+				}
+				if (nbytes == 0)
+					break;		/* EOF */
+				total += (Size) nbytes;
+				if (total >= seg_size)
+					break;
+			}
+			CloseTransientFile(fd);
+
+			/* pad a genuinely-short (final) segment to full size */
+			if (total < seg_size)
+				memset(segbuf + total, 0, seg_size - total);
+		}
+
+		/* zero padding so no uninitialized stack bytes leak into WAL */
+		memset(&xlrec, 0, sizeof(xlrec));
+		xlrec.slru_type = slru_type;
+		xlrec.first_seg = segs[i].segno;
+		xlrec.last_seg = segs[i + batch_count - 1].segno;
+		xlrec.total_bytes = (uint32) batch_bytes;
+
+#ifdef USE_ASSERT_CHECKING
+
+		/*
+		 * Fault-injection hook: inflate total_bytes past the payload actually
+		 * registered below, producing a valid-CRC but internally inconsistent
+		 * record that exercises pg_upgrade_redo's SLRU bounds check.  Gated
+		 * on USE_ASSERT_CHECKING so a production build never emits such a
+		 * record.
+		 */
+		if (getenv("PG_UPGRADE_TEST_CORRUPT_SLRU_LEN") != NULL)
+			xlrec.total_bytes = (uint32) (batch_bytes + seg_size);
+#endif
+
+		XLogBeginInsert();
+		XLogRegisterData(&xlrec, SizeOfXLUpgradeSlruData);
+		XLogRegisterData(packbuf, batch_bytes);
+		lsn = XLogInsert(RM_PG_UPGRADE_ID, XLOG_UPGRADE_SLRU_DATA);
+
+		pfree(packbuf);
+		i = j;
+	}
+
+	pfree(segs);
+	return lsn;
+}
+
+/*
+ * Batched emission of XLOG_UPGRADE_RELFILE_DATA.  These FPIs are
+ * self-contained: redo is the sole writer of these relation-file pages.  Many
+ * chunks are packed into one record to use the coarsest WAL granularity:
+ *
+ *     UpgradeRelfileBatch b;
+ *     XLogUpgradeRelfileBatchBegin(&b);
+ *     for each file: XLogUpgradeRelfileBatchAddFile(&b, path, ...);
+ *     XLogUpgradeRelfileBatchEnd(&b);
+ *
+ * The batch is capped a little below XLogRecordMaxSize to leave room for the
+ * XLogRecord header; a single chunk never exceeds cap minus one entry header,
+ * so after a flush any chunk fits.
+ */
+#define UPGRADE_RELFILE_BATCH_CAP \
+	((Size) ((XLogRecordMaxSize / BLCKSZ - 2) * (Size) BLCKSZ))
+#define UPGRADE_RELFILE_MAX_CHUNK \
+	((Size) (((UPGRADE_RELFILE_BATCH_CAP - SizeOfXLUpgradeRelfileEntry) / BLCKSZ) * (Size) BLCKSZ))
+
+/*
+ * Flush the accumulated batch as one WAL record of type "info"
+ * (XLOG_UPGRADE_RELFILE_DATA for the relfile batch, XLOG_UPGRADE_RELINK for the
+ * relink manifest -- both accumulate into UpgradeRelfileBatch the same way).
+ */
+static void
+XLogUpgradeBatchFlush(UpgradeRelfileBatch *b, uint8 info)
+{
+	if (b->used == 0)
+		return;
+
+	XLogBeginInsert();
+	XLogRegisterData(b->buf, b->used);
+	XLogInsert(RM_PG_UPGRADE_ID, info);
+
+	b->nrecords++;
+	b->used = 0;
+	b->nentries = 0;
+}
+
+void
+XLogUpgradeRelfileBatchBegin(UpgradeRelfileBatch *b)
+{
+	b->buf = palloc(UPGRADE_RELFILE_BATCH_CAP);
+	b->cap = UPGRADE_RELFILE_BATCH_CAP;
+	b->used = 0;
+	b->nentries = 0;
+	b->nrecords = 0;
+	b->nfiles = 0;
+}
+
+/*
+ * Add every chunk of one relation file segment to the batch, flushing full
+ * records as we go.  A missing or empty file is silently skipped.
+ */
+void
+XLogUpgradeRelfileBatchAddFile(UpgradeRelfileBatch *b, const char *path,
+							   Oid tsoid, Oid dboid, RelFileNumber rfnum,
+							   uint8 forknum, uint32 segno)
+{
+	struct stat stbuf;
+	int			fd;
+	Size		filesize;
+	Size		off;
+
+	if (stat(path, &stbuf) != 0)
+	{
+		if (errno == ENOENT)
+			return;
+		ereport(ERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not stat \"%s\": %m", path)));
+	}
+
+	filesize = (Size) stbuf.st_size;
+
+	/*
+	 * An empty (0-byte) relation file still needs a record so replay
+	 * recreates it; otherwise the first write to an empty system catalog
+	 * fails with "could not open file".  Emit a zero-page entry (nbytes=0);
+	 * RELFILE redo creates the file via smgr without writing any page.  Only
+	 * the base segment (segno 0) needs this; higher segments are never empty.
+	 */
+	if (filesize == 0)
+	{
+		xl_upgrade_relfile_entry ent;
+
+		if (segno != 0)
+			return;				/* an empty non-base segment: nothing to
+								 * create */
+
+		if (b->used + SizeOfXLUpgradeRelfileEntry > b->cap)
+			XLogUpgradeBatchFlush(b, XLOG_UPGRADE_RELFILE_DATA);
+
+		memset(&ent, 0, sizeof(ent));
+		ent.tablespace_oid = tsoid;
+		ent.database_oid = dboid;
+		ent.relfilenumber = rfnum;
+		ent.forknum = forknum;
+		ent.segno = segno;
+		ent.blockoff = 0;
+		ent.nbytes = 0;			/* signals "create empty file, no pages" */
+
+		memcpy(b->buf + b->used, &ent, SizeOfXLUpgradeRelfileEntry);
+		b->used += SizeOfXLUpgradeRelfileEntry;
+		b->nentries++;
+		b->nfiles++;
+		return;
+	}
+
+	fd = OpenTransientFile(path, O_RDONLY | PG_BINARY);
+	if (fd < 0)
+		ereport(ERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not open \"%s\": %m", path)));
+
+	for (off = 0; off < filesize; off += UPGRADE_RELFILE_MAX_CHUNK)
+	{
+		Size		chunk = Min(UPGRADE_RELFILE_MAX_CHUNK, filesize - off);
+		xl_upgrade_relfile_entry ent;
+		ssize_t		nread;
+
+		/* Start a fresh record if this entry would overflow the batch. */
+		if (b->used + SizeOfXLUpgradeRelfileEntry + chunk > b->cap)
+			XLogUpgradeBatchFlush(b, XLOG_UPGRADE_RELFILE_DATA);
+
+		/* zero the struct so its alignment padding is never uninitialized WAL */
+		memset(&ent, 0, sizeof(ent));
+		ent.tablespace_oid = tsoid;
+		ent.database_oid = dboid;
+		ent.relfilenumber = rfnum;
+		ent.forknum = forknum;
+		ent.segno = segno;
+		ent.blockoff = (uint32) (off / BLCKSZ);
+		ent.nbytes = (uint32) chunk;
+
+		memcpy(b->buf + b->used, &ent, SizeOfXLUpgradeRelfileEntry);
+		b->used += SizeOfXLUpgradeRelfileEntry;
+
+		nread = pg_pread(fd, b->buf + b->used, chunk, off);
+		if (nread != (ssize_t) chunk)
+		{
+			CloseTransientFile(fd);
+			ereport(ERROR,
+					(errcode_for_file_access(),
+					 errmsg("could not read \"%s\": %m", path)));
+		}
+		b->used += chunk;
+		b->nentries++;
+	}
+
+	CloseTransientFile(fd);
+	b->nfiles++;
+}
+
+void
+XLogUpgradeRelfileBatchEnd(UpgradeRelfileBatch *b)
+{
+	XLogUpgradeBatchFlush(b, XLOG_UPGRADE_RELFILE_DATA);
+	pfree(b->buf);
+	b->buf = NULL;
+
+	ereport(LOG,
+			(errmsg("pg_upgrade captured %d relation files in %d WAL record(s)",
+					b->nfiles, b->nrecords)));
+}
+
+/*
+ * Batched emission of XLOG_UPGRADE_RELINK -- the manifest of user relation
+ * files the window omits.  Each entry is a fixed-size identity; no file data.
+ * Redo links each from the standby's old datadir into the new skeleton.
+ *
+ * Uses the same payload cap as the relfile batch (UPGRADE_RELFILE_BATCH_CAP):
+ * both accumulate into the same WAL-record payload limit.
+ */
+void
+XLogUpgradeRelinkBatchBegin(UpgradeRelfileBatch *b, uint8 xfer_mode)
+{
+	b->buf = palloc(UPGRADE_RELFILE_BATCH_CAP);
+	b->cap = UPGRADE_RELFILE_BATCH_CAP;
+	b->used = 0;
+	b->nentries = 0;
+	b->nrecords = 0;
+	b->nfiles = 0;
+	b->xfer_mode = xfer_mode;
+}
+
+void
+XLogUpgradeRelinkBatchAdd(UpgradeRelfileBatch *b, Oid tsoid, Oid dboid,
+						  RelFileNumber rfnum, uint8 forknum, uint32 segno)
+{
+	xl_upgrade_relink_entry ent;
+
+	if (b->used + SizeOfXLUpgradeRelinkEntry > b->cap)
+		XLogUpgradeBatchFlush(b, XLOG_UPGRADE_RELINK);
+
+	/* zero the struct so alignment padding is never uninitialized WAL */
+	memset(&ent, 0, sizeof(ent));
+	ent.tablespace_oid = tsoid;
+	ent.database_oid = dboid;
+	ent.relfilenumber = rfnum;
+	ent.forknum = forknum;
+	ent.transfer_mode = b->xfer_mode;
+	ent.segno = segno;
+
+	memcpy(b->buf + b->used, &ent, SizeOfXLUpgradeRelinkEntry);
+	b->used += SizeOfXLUpgradeRelinkEntry;
+	b->nentries++;
+	b->nfiles++;
+}
+
+void
+XLogUpgradeRelinkBatchEnd(UpgradeRelfileBatch *b)
+{
+	XLogUpgradeBatchFlush(b, XLOG_UPGRADE_RELINK);
+	pfree(b->buf);
+	b->buf = NULL;
+
+	ereport(LOG,
+			(errmsg("pg_upgrade recorded %d user relation files to relink in %d WAL record(s)",
+					b->nfiles, b->nrecords)));
+}
+
+/*
+ * Emit XLOG_UPGRADE_RAWFILE -- a verbatim image of a non-relation file
+ * (pg_filenode.map, PG_VERSION) so the cluster can be rebuilt from an empty
+ * data directory.  "path" is the PGDATA-relative path.  Returns the record LSN,
+ * or InvalidXLogRecPtr if the file is absent or empty.
+ */
+XLogRecPtr
+XLogWriteUpgradeRawFile(const char *path)
+{
+	xl_upgrade_rawfile xlrec;
+	struct stat stbuf;
+	int			fd;
+	char	   *buf;
+	ssize_t		nbytes;
+	uint32		path_len = (uint32) strlen(path);
+	XLogRecPtr	lsn;
+
+	if (stat(path, &stbuf) != 0)
+	{
+		if (errno == ENOENT)
+			return InvalidXLogRecPtr;
+		ereport(ERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not stat \"%s\": %m", path)));
+	}
+
+	if (stbuf.st_size == 0)
+		return InvalidXLogRecPtr;
+
+	if ((Size) stbuf.st_size + path_len + SizeOfXLUpgradeRawfile > XLogRecordMaxSize)
+		ereport(ERROR,
+				(errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED),
+				 errmsg("file \"%s\" is too large to capture in a pg_upgrade WAL record",
+						path)));
+
+	buf = palloc(stbuf.st_size);
+
+	fd = OpenTransientFile(path, O_RDONLY | PG_BINARY);
+	if (fd < 0)
+		ereport(ERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not open \"%s\": %m", path)));
+	nbytes = read(fd, buf, stbuf.st_size);
+	CloseTransientFile(fd);
+	if (nbytes != stbuf.st_size)
+		ereport(ERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not read \"%s\": %m", path)));
+
+	/* zero padding so no uninitialized stack bytes leak into WAL */
+	memset(&xlrec, 0, sizeof(xlrec));
+	xlrec.path_len = path_len;
+	xlrec.data_len = (uint32) nbytes;
+
+	XLogBeginInsert();
+	XLogRegisterData(&xlrec, SizeOfXLUpgradeRawfile);
+	XLogRegisterData(unconstify(char *, path), path_len);
+	XLogRegisterData(buf, nbytes);
+	lsn = XLogInsert(RM_PG_UPGRADE_ID, XLOG_UPGRADE_RAWFILE);
+
+	pfree(buf);
+	return lsn;
+}
+
+/*
+ * Flush all SLRU (CLOG, commit-ts, multixact) dirty pages to disk and fsync
+ * their segment files, so the SLRU_DATA images captured by the upgrade window
+ * read the final on-disk state.  pg_upgrade runs the burst server with the
+ * SLRU-related pages possibly still dirty in the buffers, and (on some paths)
+ * with dirty OS-cache writes not yet synced, so:
+ *
+ *   - CheckPointCLOG/CheckPointCommitTs/CheckPointMultiXact write out the
+ *     dirty SLRU buffers to the segment files, and
+ *   - the fsync loop below durably syncs those segment files and their parent
+ *     directories.
+ *
+ * This does not request a checkpoint.  CN (the recovery anchor) is the
+ * DB_SHUTDOWNED checkpoint pg_resetwal wrote before the burst server started
+ * and already precedes XLOG_UPGRADE_START; a checkpoint record here would
+ * displace it as the last checkpoint before START.  So the SLRUs are flushed
+ * directly rather than through RequestCheckpoint().
+ */
+void
+XLogFlushUpgradeSLRU(void)
+{
+	/*
+	 * the same SLRU flush steps CheckPointGuts() performs, minus the
+	 * checkpoint
+	 */
+	CheckPointCLOG();
+	CheckPointCommitTs();
+	CheckPointMultiXact();
+
+	/*
+	 * Fsync all SLRU segment files and their parent directories to catch
+	 * evicted pages whose OS-cache writes were not synced under fsync=off.
+	 */
+	{
+		static const char *const slru_dirs[] = UPGRADE_SLRU_DIRS;
+		int			i;
+
+		for (i = 0; i < lengthof(slru_dirs); i++)
+		{
+			DIR		   *dir;
+			struct dirent *de;
+
+			dir = AllocateDir(slru_dirs[i]);
+			if (dir == NULL)
+				continue;
+
+			while ((de = ReadDir(dir, slru_dirs[i])) != NULL)
+			{
+				char		path[MAXPGPATH];
+
+				if (de->d_name[0] == '.')
+					continue;
+
+				/*
+				 * SLRU segment names are short UPPERCASE hex strings (see
+				 * SlruFileName / SlruScanDirectory), not WAL names.
+				 */
+				if (strlen(de->d_name) > 8)
+					continue;
+				if (strspn(de->d_name, "0123456789ABCDEF") != strlen(de->d_name))
+					continue;
+
+				snprintf(path, sizeof(path), "%s/%s",
+						 slru_dirs[i], de->d_name);
+
+				/* fsync_fname ignores enableFsync; forces actual kernel sync */
+				(void) fsync_fname(path, false);
+			}
+			FreeDir(dir);
+
+			/* also fsync the directory itself */
+			(void) fsync_fname(slru_dirs[i], true);
+		}
+	}
+}
+
 /*
  * Check if any of the GUC parameters that are critical for hot standby
  * have changed, and update the value in pg_control file if necessary.
diff --git a/src/backend/access/transam/xlogfuncs.c b/src/backend/access/transam/xlogfuncs.c
index 65bbaeda59..1e43317a3d 100644
--- a/src/backend/access/transam/xlogfuncs.c
+++ b/src/backend/access/transam/xlogfuncs.c
@@ -19,14 +19,21 @@
 #include <unistd.h>
 
 #include "access/htup_details.h"
+#include "access/pgupgrade_wal.h"	/* XLogFlushUpgradeSLRU, emit fns */
+#include "access/transam.h"		/* FirstNormalObjectId */
 #include "access/xlog_internal.h"
 #include "access/xlogbackup.h"
 #include "access/xlogrecovery.h"
 #include "catalog/pg_authid.h"
+#include "catalog/pg_largeobject_d.h"	/* LargeObject*Id (user-data catalogs) */
+#include "catalog/pg_largeobject_metadata_d.h"
+#include "catalog/pg_tablespace_d.h"	/* DEFAULTTABLESPACE_OID */
 #include "catalog/pg_type.h"
+#include "common/relpath.h"		/* PG_TBLSPC_DIR, TABLESPACE_VERSION_DIRECTORY */
 #include "funcapi.h"
 #include "miscadmin.h"
 #include "pgstat.h"
+#include "replication/slot.h"
 #include "utils/acl.h"
 #include "replication/walreceiver.h"
 #include "storage/fd.h"
@@ -300,6 +307,464 @@ pg_create_restore_point(PG_FUNCTION_ARGS)
  * archiving process.  Note that the data before this point is written out
  * to the kernel, but is not necessarily synced to disk.
  */
+
+/*
+ * pg_upgrade --wal-upgrade window emission.
+ *
+ * The individual START/COMPLETE/DIRTREE/SLRU/relfile steps are driven entirely
+ * from C by EmitUpgradeWalWindow() (below), reachable only through the single
+ * binary-upgrade-gated SQL entry point binary_upgrade_emit_wal_window() -- so
+ * these WAL-injection primitives are not exposed as reusable SQL on a normal
+ * cluster.  The static helpers (parse_relfile_name,
+ * is_transferred_user_data_catalog, capture_dir_files, capture_all_relfiles)
+ * and EmitUpgradeWalWindow() follow.
+ */
+
+/*
+ * Parse a relation file name "<rfnum>[_fork][.segno]" into its parts.
+ * Returns true if it is a relation file, filling *rfnum, *forknum (0=main,
+ * 1=fsm, 2=vm, 3=init) and *segno.  Returns false for non-relation files
+ * (PG_VERSION, pg_filenode.map, pg_internal.init, etc.).
+ */
+static bool
+parse_relfile_name(const char *name, RelFileNumber *rfnum,
+				   uint8 *forknum, uint32 *segno)
+{
+	char		base[MAXPGPATH];
+	char	   *p;
+	unsigned long n;
+	char	   *endp;
+
+	if (name[0] < '1' || name[0] > '9')
+		return false;			/* relation files start with a nonzero digit */
+
+	strlcpy(base, name, sizeof(base));
+
+	*segno = 0;
+	p = strrchr(base, '.');
+	if (p != NULL && p[1] >= '0' && p[1] <= '9')
+	{
+		*segno = (uint32) strtoul(p + 1, NULL, 10);
+		*p = '\0';
+	}
+
+	*forknum = 0;
+	if ((p = strstr(base, "_vm")) != NULL && p[3] == '\0')
+	{
+		*forknum = 2;
+		*p = '\0';
+	}
+	else if ((p = strstr(base, "_fsm")) != NULL && p[4] == '\0')
+	{
+		*forknum = 1;
+		*p = '\0';
+	}
+	else if ((p = strstr(base, "_init")) != NULL && p[5] == '\0')
+	{
+		*forknum = 3;
+		*p = '\0';
+	}
+
+	errno = 0;
+	n = strtoul(base, &endp, 10);
+	if (errno != 0 || *endp != '\0' || n == 0 || n > PG_UINT32_MAX)
+		return false;
+	*rfnum = (RelFileNumber) n;
+	return true;
+}
+
+/*
+ * Is this relfilenumber one of the pinned system catalogs that pg_upgrade
+ * nonetheless transfers verbatim from the old cluster (rather than
+ * regenerating), so its file is already on disk in the new cluster and does
+ * not travel through the upgrade window?
+ *
+ * pg_upgrade treats pg_largeobject and pg_largeobject_metadata as "user data":
+ * they hold large-object contents/ownership that pg_dump does not emit, so it
+ * copies/links their files like ordinary user relations (see
+ * get_rel_infos_query() in src/bin/pg_upgrade/info.c).  pg_largeobject is
+ * handled this way for every source version; pg_largeobject_metadata only for
+ * old clusters >= v16 (before v16 the aclitem on-disk format differed, so
+ * pg_upgrade regenerates it via dump/restore -- in which case it IS a fresh
+ * system catalog that the window must carry).
+ *
+ * These have fixed relfilenumbers (== their pinned catalog/index OIDs) unless
+ * pg_upgrade rewrote them, in which case the relfilenumber is >= 16384 and the
+ * ordinary user-relation test already excludes them.  So matching the pinned
+ * numbers here is exact.
+ */
+static bool
+is_transferred_user_data_catalog(RelFileNumber rfnum, uint32 old_major)
+{
+	if (rfnum == LargeObjectRelationId || rfnum == LargeObjectLOidPNIndexId)
+		return true;
+	if (old_major >= 160000 &&
+		(rfnum == LargeObjectMetadataRelationId ||
+		 rfnum == LargeObjectMetadataOidIndexId))
+		return true;
+	return false;
+}
+
+/*
+ * Capture every file in one PGDATA subdirectory (base/<dboid> or global)
+ * as WAL.  Relation files -> XLOG_UPGRADE_RELFILE_DATA (replayed through the
+ * buffer manager); pg_filenode.map and PG_VERSION -> XLOG_UPGRADE_RAWFILE
+ * (written verbatim, since they are not buffered relations).  Everything else
+ * (pg_internal.init and friends) is regenerated by the server and skipped.
+ */
+static void
+capture_dir_files(UpgradeRelfileBatch *batch, UpgradeRelfileBatch *relink,
+				  const char *reldir, Oid tsoid, Oid dboid, uint32 old_major)
+{
+	DIR		   *dir;
+	struct dirent *de;
+	char		path[MAXPGPATH];
+
+	/*
+	 * Unlogged relations (identified by an _init fork) must not have their
+	 * main/fsm/vm forks captured: end-of-recovery ResetUnloggedRelations()
+	 * rebuilds the main fork from _init with O_CREAT|O_EXCL and would FATAL
+	 * if RELFILE redo had already recreated it.  Capture only the _init fork.
+	 *
+	 * Pass 1: collect the set of relfilenumbers that have an _init fork.
+	 */
+	RelFileNumber *unlogged = NULL;
+	int			n_unlogged = 0;
+	int			unlogged_alloc = 0;
+
+	dir = AllocateDir(reldir);
+	if (dir == NULL)
+		return;
+
+	while ((de = ReadDir(dir, reldir)) != NULL)
+	{
+		RelFileNumber rfnum;
+		uint8		forknum;
+		uint32		segno;
+
+		if (parse_relfile_name(de->d_name, &rfnum, &forknum, &segno) &&
+			forknum == INIT_FORKNUM)
+		{
+			if (n_unlogged >= unlogged_alloc)
+			{
+				unlogged_alloc = (unlogged_alloc == 0) ? 16 : unlogged_alloc * 2;
+				if (unlogged == NULL)
+					unlogged = palloc_array(RelFileNumber, unlogged_alloc);
+				else
+					unlogged = repalloc_array(unlogged, RelFileNumber, unlogged_alloc);
+			}
+			unlogged[n_unlogged++] = rfnum;
+		}
+	}
+	FreeDir(dir);
+
+	/* Pass 2: capture files, skipping the main/fsm/vm forks of unlogged rels. */
+	dir = AllocateDir(reldir);
+	if (dir == NULL)
+	{
+		if (unlogged != NULL)
+			pfree(unlogged);
+		return;
+	}
+
+	while ((de = ReadDir(dir, reldir)) != NULL)
+	{
+		RelFileNumber rfnum;
+		uint8		forknum;
+		uint32		segno;
+
+		if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0)
+			continue;
+
+		snprintf(path, sizeof(path), "%s/%s", reldir, de->d_name);
+
+		if (parse_relfile_name(de->d_name, &rfnum, &forknum, &segno))
+		{
+			/*
+			 * Only system relations travel in the window.  User relations
+			 * (relfilenode >= FirstNormalObjectId) are linked/copied verbatim
+			 * from the old datadir into the new one by pg_upgrade's transfer
+			 * step, so their contents are already present on disk and do not
+			 * need to travel through WAL.  Logging them would make the
+			 * upgrade window scale with total user-data size (a full backup
+			 * dumped into WAL); skipping them makes it scale with the schema
+			 * instead.  Only pg_upgrade-rewritten system catalogs, SLRU, and
+			 * the directory skeleton are logged.
+			 *
+			 * A streaming standby has no transfer step, so instead of the
+			 * data the file's identity is recorded in the RELINK manifest: on
+			 * redo the standby links it from its own retained old datadir
+			 * into the new skeleton (the same old->new link the primary just
+			 * did). The manifest entry is emitted from the same branch that
+			 * skips the file, so the manifest lists exactly the files the
+			 * window omits.
+			 */
+			if (rfnum >= FirstNormalObjectId)
+			{
+				XLogUpgradeRelinkBatchAdd(relink, tsoid, dboid, rfnum,
+										  forknum, segno);
+				continue;
+			}
+
+			/*
+			 * A few pinned system catalogs (pg_largeobject and, for old
+			 * clusters >= v16, pg_largeobject_metadata) are likewise
+			 * transferred verbatim by pg_upgrade rather than regenerated --
+			 * they carry user large-object data.  They are omitted from the
+			 * window and relinked exactly like ordinary user relations.
+			 */
+			if (is_transferred_user_data_catalog(rfnum, old_major))
+			{
+				XLogUpgradeRelinkBatchAdd(relink, tsoid, dboid, rfnum,
+										  forknum, segno);
+				continue;
+			}
+
+			/* Skip main/fsm/vm forks of unlogged relations (see comment). */
+			if (forknum != INIT_FORKNUM)
+			{
+				bool		is_unlogged = false;
+
+				for (int i = 0; i < n_unlogged; i++)
+				{
+					if (unlogged[i] == rfnum)
+					{
+						is_unlogged = true;
+						break;
+					}
+				}
+				if (is_unlogged)
+					continue;
+			}
+
+			XLogUpgradeRelfileBatchAddFile(batch, path, tsoid, dboid, rfnum,
+										   forknum, segno);
+		}
+		else if (strcmp(de->d_name, "pg_filenode.map") == 0 ||
+				 strcmp(de->d_name, "PG_VERSION") == 0)
+			(void) XLogWriteUpgradeRawFile(path);
+		/* else: pg_internal.init, etc. -- regenerated, skip */
+	}
+	FreeDir(dir);
+
+	if (unlogged != NULL)
+		pfree(unlogged);
+}
+
+/*
+ * capture_all_relfiles()
+ *
+ * Capture the full physical image of the cluster as WAL: every relation file
+ * under base/<dboid>/ and global/, plus raw pg_filenode.map / PG_VERSION, so
+ * first-startup replay can reconstruct the cluster from an empty data
+ * directory.  Recovery is anchored at CN, so these images are the sole source
+ * of the catalogs; a preceding CHECKPOINT makes the files we read final.
+ *
+ * Internal C helper driven by EmitUpgradeWalWindow(); not SQL-callable.
+ */
+static void
+capture_all_relfiles(uint32 old_major, uint8 transfer_mode)
+{
+	UpgradeRelfileBatch batch;
+	UpgradeRelfileBatch relink;
+	DIR		   *basedir;
+	struct dirent *de;
+
+	XLogUpgradeRelfileBatchBegin(&batch);
+	XLogUpgradeRelinkBatchBegin(&relink, transfer_mode);
+
+	/*
+	 * Shared catalogs live in global/ (tablespace GLOBALTABLESPACE_OID, db
+	 * 0).
+	 */
+	capture_dir_files(&batch, &relink, "global", GLOBALTABLESPACE_OID,
+					  InvalidOid, old_major);
+
+	/* Every database directory under base/. */
+	basedir = AllocateDir("base");
+	if (basedir != NULL)
+	{
+		while ((de = ReadDir(basedir, "base")) != NULL)
+		{
+			char		dbpath[MAXPGPATH];
+			char	   *endp;
+			unsigned long dboid;
+
+			if (de->d_name[0] < '1' || de->d_name[0] > '9')
+				continue;
+			errno = 0;
+			dboid = strtoul(de->d_name, &endp, 10);
+			if (errno != 0 || *endp != '\0')
+				continue;
+
+			snprintf(dbpath, sizeof(dbpath), "base/%s", de->d_name);
+			capture_dir_files(&batch, &relink, dbpath, DEFAULTTABLESPACE_OID,
+							  (Oid) dboid, old_major);
+		}
+		FreeDir(basedir);
+	}
+
+	/*
+	 * Relations in user-defined tablespaces.  Each pg_tblspc/<spcoid>
+	 * symlinks to TABLESPACE_VERSION_DIRECTORY/<dboid>/<files>; walk each and
+	 * capture the relfiles with tsoid=<spcoid> so RELFILE redo (resolving
+	 * spcOid via the symlink) places them correctly.
+	 */
+	{
+		DIR		   *tsdir = AllocateDir(PG_TBLSPC_DIR);
+
+		if (tsdir != NULL)
+		{
+			struct dirent *tde;
+
+			while ((tde = ReadDir(tsdir, PG_TBLSPC_DIR)) != NULL)
+			{
+				char		verpath[MAXPGPATH];
+				char	   *endp;
+				unsigned long spcoid;
+				DIR		   *verdir;
+				struct dirent *vde;
+
+				if (tde->d_name[0] < '1' || tde->d_name[0] > '9')
+					continue;
+				errno = 0;
+				spcoid = strtoul(tde->d_name, &endp, 10);
+				if (errno != 0 || *endp != '\0')
+					continue;
+
+				/* pg_tblspc/<spcoid>/<TABLESPACE_VERSION_DIRECTORY>/ */
+				snprintf(verpath, sizeof(verpath), "%s/%s/%s",
+						 PG_TBLSPC_DIR, tde->d_name,
+						 TABLESPACE_VERSION_DIRECTORY);
+
+				verdir = AllocateDir(verpath);
+				if (verdir == NULL)
+					continue;
+
+				while ((vde = ReadDir(verdir, verpath)) != NULL)
+				{
+					char		dbpath[MAXPGPATH];
+					char	   *dendp;
+					unsigned long dboid;
+
+					if (vde->d_name[0] < '1' || vde->d_name[0] > '9')
+						continue;
+					errno = 0;
+					dboid = strtoul(vde->d_name, &dendp, 10);
+					if (errno != 0 || *dendp != '\0')
+						continue;
+
+					snprintf(dbpath, sizeof(dbpath), "%s/%s",
+							 verpath, vde->d_name);
+					capture_dir_files(&batch, &relink, dbpath, (Oid) spcoid,
+									  (Oid) dboid, old_major);
+				}
+				FreeDir(verdir);
+			}
+			FreeDir(tsdir);
+		}
+	}
+
+	/* Flush the last partial record of relation-file images. */
+	XLogUpgradeRelfileBatchEnd(&batch);
+
+	/* Flush the manifest of user relations for the standby to relink. */
+	XLogUpgradeRelinkBatchEnd(&relink);
+
+	/*
+	 * Top-level PG_VERSION (needed pre-recovery, but capture for
+	 * completeness).
+	 */
+	(void) XLogWriteUpgradeRawFile("PG_VERSION");
+}
+
+/*
+ * EmitUpgradeWalWindow()
+ *
+ * Emit the entire pg_upgrade --wal-upgrade window as WAL, in one C routine, so
+ * the frontend drives the whole capture with a single gated backend call rather
+ * than a sequence of individually SQL-exposed WAL-injection primitives.  The
+ * emission order matters and is:
+ *
+ *   0. create the retention slot pinned at CN (restart_lsn = CN's LSN) so the
+ *      window CN..COMPLETE cannot be recycled before a standby streams it.
+ *   1. flush SLRU dirty pages to disk (no checkpoint).  CN -- the recovery
+ *      anchor, the last checkpoint preceding XLOG_UPGRADE_START -- is not taken
+ *      here: it is the DB_SHUTDOWNED checkpoint pg_resetwal wrote at the start
+ *      of the nextxlogfile segment before the burst server started, and it
+ *      already precedes START.  A checkpoint here would displace it.
+ *   2. XLOG_UPGRADE_START (carries the new PG_VERSION string).
+ *   3. XLOG_UPGRADE_DIRTREE (directory skeleton, before any file image).
+ *   4. relation-file images (system rels) + the RELINK manifest (user rels).
+ *   5. XLOG_UPGRADE_SLRU_DATA for pg_xact, multixact offsets, multixact members.
+ *   6. XLOG_UPGRADE_COMPLETE (terminal marker) + set the durable pg_control
+ *      upgrade_finalized flag -- unless skip_complete is set (assert-build test
+ *      hook simulating a crash mid-window).
+ *
+ * Runs only in binary-upgrade mode (the caller enforces IsBinaryUpgrade); the
+ * burst server is started with -b, so all of pg_upgrade's WAL generation stays
+ * behind that gate and is never exposed as reusable SQL on a normal cluster.
+ */
+void
+EmitUpgradeWalWindow(uint32 old_major, uint32 new_major, uint8 transfer_mode,
+					 bool skip_complete)
+{
+	if (RecoveryInProgress())
+		ereport(ERROR,
+				(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				 errmsg("recovery is in progress")));
+
+	/*
+	 * The upgrade window (CN..COMPLETE) is pinned in pg_wal/ against
+	 * recycling by a migrated physical replication slot, created by
+	 * pg_upgrade on this same burst server just before this call (see
+	 * pg_upgrade.c).  That slot's restart_lsn is GetRedoRecPtr() at creation
+	 * time, which -- because the burst server started from the reset-written
+	 * CN shutdown checkpoint and has written no checkpoint since -- is
+	 * exactly CN, so it pins the whole window size-independently (server.c
+	 * sets max_slot_wal_keep_size=-1).  A standby later connects with
+	 * primary_slot_name = that slot to pull the window. With no migrated slot
+	 * there is no streaming consumer and the window need not be pinned
+	 * (recycled as ordinary WAL, or delivered via the archive).
+	 *
+	 * Step 1: flush SLRU dirty pages durably (see header for why no
+	 * checkpoint).
+	 */
+	XLogFlushUpgradeSLRU();
+
+	/*
+	 * Mark the control file "upgrade started" durably before emitting START.
+	 * This is the crash-atomicity anchor: if the burst server dies (or, in
+	 * the test hook, suppresses COMPLETE) after this point but before
+	 * finalize, the new cluster's control file carries upgrade_started &&
+	 * !upgrade_finalized, which PerformWalUpgradeIfNeeded() refuses to
+	 * auto-serve -- independent of whether the START-bearing WAL survived to
+	 * first boot (see the crash-atomicity guard there).
+	 */
+	SetControlFileUpgradeStarted();
+
+	/* 2. START */
+	(void) XLogWritePgUpgrade(true, old_major, new_major);
+
+	/* 3. directory skeleton */
+	(void) XLogWriteUpgradeDirSkel();
+
+	/* 4. relation-file images + RELINK manifest */
+	capture_all_relfiles(old_major, transfer_mode);
+
+	/* 5. SLRU bulk images: pg_xact, multixact offsets, multixact members */
+	(void) XLogWriteUpgradeSlruData(0);
+	(void) XLogWriteUpgradeSlruData(1);
+	(void) XLogWriteUpgradeSlruData(2);
+
+	/* 6. COMPLETE + durable finalized flag (unless suppressed for testing) */
+	if (!skip_complete)
+	{
+		(void) XLogWritePgUpgrade(false, old_major, new_major);
+		SetControlFileUpgradeFinalized();
+	}
+}
+
 Datum
 pg_current_wal_lsn(PG_FUNCTION_ARGS)
 {
diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c
index c0ae4d3f63..eae43c3b80 100644
--- a/src/backend/access/transam/xlogrecovery.c
+++ b/src/backend/access/transam/xlogrecovery.c
@@ -101,6 +101,7 @@ char	   *PrimaryConnInfo = NULL;
 char	   *PrimarySlotName = NULL;
 bool		wal_receiver_create_temp_slot = false;
 
+
 /*
  * recoveryTargetTimeLineGoal: what the user requested, if any
  *
@@ -302,6 +303,19 @@ static bool backupEndRequired = false;
  */
 bool		reachedConsistency = false;
 
+/*
+ * True while replaying a pg_upgrade --wal-upgrade window.  While set, hot
+ * standby must NOT go active: the cluster is only half-upgraded (new catalogs
+ * partially applied), so no read-only connection may observe it.  Normally set
+ * by the XLOG_UPGRADE_START redo and cleared by XLOG_UPGRADE_COMPLETE
+ * (pg_upgrade_redo()).  For a streaming standby staged without initdb it is set
+ * even earlier -- at arm time in PerformWalUpgradeIfNeeded(), before any record
+ * replays -- because such a skeleton has no shared catalogs on disk until the
+ * window streams in, so it must not admit connections in the gap before
+ * XLOG_UPGRADE_START.
+ */
+bool		pgUpgradeReplayInProgress = false;
+
 /* Buffers dedicated to consistency checks of size BLCKSZ */
 static char *replay_image_masked = NULL;
 static char *primary_image_masked = NULL;
@@ -1815,6 +1829,23 @@ PerformWalRecovery(void)
 				ereport(FATAL,
 						(errmsg("requested recovery stop point is before consistent recovery point")));
 
+			/*
+			 * Refuse to stop inside a --wal-upgrade window.
+			 * pgUpgradeReplayInProgress is set at the window's
+			 * XLOG_UPGRADE_START and cleared only at XLOG_UPGRADE_COMPLETE,
+			 * so if it is still set here the recovery target landed between
+			 * them: the catalog is only half-upgraded. Promoting (or pausing
+			 * then promoting) would open that half-applied cluster
+			 * read-write.  There is no consistent cluster to expose at an
+			 * intra-window point, so fail rather than come up corrupt; the
+			 * operator must choose a target at or after the upgrade.
+			 */
+			if (pgUpgradeReplayInProgress)
+				ereport(FATAL,
+						(errmsg("requested recovery stop point is inside a pg_upgrade window"),
+						 errdetail("The upgrade has begun but not completed at this point, so the cluster would be only partially upgraded."),
+						 errhint("Choose a recovery target at or after the end of the pg_upgrade window, or remove the recovery target to replay the whole upgrade.")));
+
 			/*
 			 * This is the last point where we can restart recovery with a new
 			 * recovery target, if we shutdown and begin again. After this,
@@ -2233,6 +2264,7 @@ CheckRecoveryConsistency(void)
 	if (standbyState == STANDBY_SNAPSHOT_READY &&
 		!LocalHotStandbyActive &&
 		reachedConsistency &&
+		!pgUpgradeReplayInProgress &&
 		IsUnderPostmaster)
 	{
 		SpinLockAcquire(&XLogRecoveryCtl->info_lck);
diff --git a/src/backend/postmaster/postmaster.c b/src/backend/postmaster/postmaster.c
index 90c7c4528e..fb5bcdeb1b 100644
--- a/src/backend/postmaster/postmaster.c
+++ b/src/backend/postmaster/postmaster.c
@@ -1537,6 +1537,13 @@ checkControlFile(void)
 	fp = AllocateFile(path, PG_BINARY_R);
 	if (fp == NULL)
 	{
+		/*
+		 * For a --wal-upgrade streaming standby staged without initdb, the
+		 * pg_control was already synthesized earlier by checkDataDir()
+		 * (before its PG_VERSION gate), so it exists by now.  If it is still
+		 * missing here this is a genuinely broken data directory -- fail
+		 * loudly as usual.
+		 */
 		write_stderr("%s: could not find the database system\n"
 					 "Expected to find it in the directory \"%s\",\n"
 					 "but could not open file \"%s\": %m\n",
diff --git a/src/backend/postmaster/startup.c b/src/backend/postmaster/startup.c
index d91b7caac0..70a598b4a0 100644
--- a/src/backend/postmaster/startup.c
+++ b/src/backend/postmaster/startup.c
@@ -19,6 +19,7 @@
  */
 #include "postgres.h"
 
+#include "access/pgupgrade_wal.h"	/* PerformWalUpgradeIfNeeded */
 #include "access/xlog.h"
 #include "access/xlogrecovery.h"
 #include "access/xlogutils.h"
@@ -251,6 +252,16 @@ StartupProcessMain(const void *startup_data, size_t startup_data_len)
 	 */
 	sigprocmask(SIG_SETMASK, &UnBlockSig, NULL);
 
+	/*
+	 * Detect a pending pg_upgrade --wal-upgrade and, if found, arm the
+	 * control file so the StartupXLOG() below replays the upgrade window (see
+	 * the function for the streaming-standby / local-window / archive-PITR
+	 * paths). A crashed partial upgrade (window began but never reached
+	 * COMPLETE) FATALs here with a re-run instruction rather than serving a
+	 * half-upgraded cluster.
+	 */
+	PerformWalUpgradeIfNeeded();
+
 	/*
 	 * Do what we came for.
 	 */
diff --git a/src/backend/replication/walsender.c b/src/backend/replication/walsender.c
index c931d9b4fa..3af34be400 100644
--- a/src/backend/replication/walsender.c
+++ b/src/backend/replication/walsender.c
@@ -53,6 +53,7 @@
 
 #include "access/timeline.h"
 #include "access/transam.h"
+#include "access/pgupgrade_wal.h"
 #include "access/twophase.h"
 #include "access/xact.h"
 #include "access/xlog_internal.h"
diff --git a/src/backend/storage/buffer/bufmgr.c b/src/backend/storage/buffer/bufmgr.c
index 9ab282a76d..f6a0ae039e 100644
--- a/src/backend/storage/buffer/bufmgr.c
+++ b/src/backend/storage/buffer/bufmgr.c
@@ -5848,6 +5848,31 @@ MarkBufferDirtyHint(Buffer buffer, bool buffer_std)
 	if (!BufferIsValid(buffer))
 		elog(ERROR, "bad buffer ID: %d", buffer);
 
+	/*
+	 * pg_upgrade --wal-upgrade: suppress hint-bit dirtying on the
+	 * binary-upgrade burst server.
+	 *
+	 * A hint-bit change does not advance the page's pd_lsn, but it DOES mark
+	 * the (permanent) page dirty, so the next checkpoint flushes it and --
+	 * per the WAL-before-data rule in FlushBuffer() -- forces XLogFlush up to
+	 * the page's existing pd_lsn.  Under --wal-upgrade the burst server that
+	 * captures the window runs after pg_resetwal has rewound WAL to CN (the
+	 * old cluster's nextxlogfile), while the restore stamped catalog pages
+	 * with LSNs from BEFORE the rewind (past the current end of WAL).  If a
+	 * plain catalog read on that server set a hint bit and dirtied such a
+	 * page, its shutdown checkpoint would try to flush WAL that no longer
+	 * exists and FATAL ("xlog flush request ... is not satisfied").
+	 *
+	 * Hint bits are a non-critical optimization -- this function already does
+	 * not guarantee the buffer is marked dirty (e.g. on a hot standby), and
+	 * callers tolerate that -- so skipping them in binary-upgrade mode is
+	 * safe: the new cluster simply re-derives them on first access.  This
+	 * keeps the captured pages clean so the burst server shuts down cleanly
+	 * with WAL rewound to CN.
+	 */
+	if (IsBinaryUpgrade)
+		return;
+
 	if (BufferIsLocal(buffer))
 	{
 		MarkLocalBufferDirty(buffer);
diff --git a/src/backend/utils/adt/pg_upgrade_support.c b/src/backend/utils/adt/pg_upgrade_support.c
index b505a6b4fe..accb565c42 100644
--- a/src/backend/utils/adt/pg_upgrade_support.c
+++ b/src/backend/utils/adt/pg_upgrade_support.c
@@ -11,6 +11,7 @@
 
 #include "postgres.h"
 
+#include "access/pgupgrade_wal.h"
 #include "access/relation.h"
 #include "access/table.h"
 #include "catalog/binary_upgrade.h"
@@ -435,3 +436,32 @@ binary_upgrade_create_conflict_detection_slot(PG_FUNCTION_ARGS)
 
 	PG_RETURN_VOID();
 }
+
+/*
+ * binary_upgrade_emit_wal_window
+ *
+ * pg_upgrade --wal-upgrade: emit the whole upgrade window (CN..COMPLETE) as WAL
+ * in a single call.  The frontend invokes this once, on the burst server it
+ * started in binary-upgrade mode; the whole capture runs inside this one call
+ * rather than through separate SQL-exposed WAL-injection primitives.  Gated on
+ * IsBinaryUpgrade so the WAL-generation machinery is never reachable as SQL on
+ * an ordinary cluster.
+ *
+ * Args: old_major_version int4, new_major_version int4, transfer_mode int4,
+ *       skip_complete bool (test-only: omit the COMPLETE marker to simulate a
+ *       crash mid-window).
+ */
+Datum
+binary_upgrade_emit_wal_window(PG_FUNCTION_ARGS)
+{
+	uint32		old_major = PG_GETARG_UINT32(0);
+	uint32		new_major = PG_GETARG_UINT32(1);
+	uint8		transfer_mode = (uint8) PG_GETARG_INT32(2);
+	bool		skip_complete = PG_GETARG_BOOL(3);
+
+	CHECK_IS_BINARY_UPGRADE;
+
+	EmitUpgradeWalWindow(old_major, new_major, transfer_mode, skip_complete);
+
+	PG_RETURN_VOID();
+}
diff --git a/src/backend/utils/init/miscinit.c b/src/backend/utils/init/miscinit.c
index 7ffc808073..61e55a98c6 100644
--- a/src/backend/utils/init/miscinit.c
+++ b/src/backend/utils/init/miscinit.c
@@ -30,6 +30,7 @@
 
 #include "access/htup_details.h"
 #include "access/parallel.h"
+#include "access/pgupgrade_wal.h"	/* UpgradeSignalStaged */
 #include "catalog/pg_authid.h"
 #include "common/file_perm.h"
 #include "libpq/libpq.h"
@@ -378,6 +379,91 @@ checkDataDir(void)
 	data_directory_mode = pg_dir_create_mode;
 #endif
 
+	/*
+	 * --wal-upgrade recovery target without initdb.  Before the PG_VERSION /
+	 * pg_control gates, if this data directory is staged for wal-upgrade
+	 * recovery (the pg_upgrade.signal sentinel is present) and lacks a valid
+	 * new-version PG_VERSION, synthesize PG_VERSION + global/pg_control from
+	 * this binary's constants so the pre-start gates pass.  The recovery mode
+	 * is inferred from stock config: primary_conninfo set -> stream from the
+	 * live primary; recovery.signal present -> the window arrives via
+	 * restore_command.  Gated on the sentinel so an ordinary un-initdb'd
+	 * directory still fails at ValidatePgVersion() below.
+	 */
+	{
+		char		recsig[MAXPGPATH];
+		char		verpath[MAXPGPATH];
+		struct stat st;
+		bool		have_upgradesig;
+
+		snprintf(recsig, sizeof(recsig), "%s/%s", DataDir, RECOVERY_SIGNAL_FILE);
+		snprintf(verpath, sizeof(verpath), "%s/PG_VERSION", DataDir);
+		have_upgradesig = UpgradeSignalStaged();
+
+		/*
+		 * Mode inference (keep in lockstep with PerformWalUpgradeIfNeeded()):
+		 * sentinel + primary_conninfo => streaming; sentinel +
+		 * recovery.signal (and no primary) => archive/PITR.  The arm in
+		 * PerformWalUpgradeIfNeeded makes the SAME split --
+		 * ArmFromLocalDerivationIfConfigured() runs the streaming path
+		 * exactly when the sentinel + primary_conninfo are set, and its
+		 * archive arm is reached only when that returned false -- so a dir
+		 * cannot be classified one way here and the other way there.
+		 */
+		if (have_upgradesig &&
+			PrimaryConnInfo != NULL && PrimaryConnInfo[0] != '\0')
+		{
+			/*
+			 * Streaming standby: a fresh new-version skeleton staged with the
+			 * pg_upgrade.signal sentinel and a primary to stream from.  User
+			 * relations are not in the window; they are linked in from the
+			 * retained old datadir (named in pg_upgrade.signal's contents) by
+			 * the RELINK-manifest redo.  Two skeleton shapes are accepted:
+			 *
+			 * - a real initdb skeleton (has a valid new-version pg_control /
+			 * PG_VERSION already): it passes the version gate on its own, so
+			 * nothing to synthesize -- the anchor arm just re-stamps its
+			 * control file at CN. - a bare skeleton (no PG_VERSION yet):
+			 * synthesize a minimal new-version pg_control + PG_VERSION so the
+			 * gate passes.
+			 *
+			 * Either way the skeleton must NOT be a populated old datadir;
+			 * that is the relink source named in the sentinel, never the
+			 * streaming target.  We cannot verify "new-version skeleton" here
+			 * (ReadControlFile below is what version-checks), but we never
+			 * synthesize OVER an existing control file, so an old datadir
+			 * mistakenly used here fails cleanly at the version gate.
+			 */
+			if (stat(verpath, &st) != 0)
+				SynthesizeUpgradeStreamControlFile(false);
+		}
+		else if (have_upgradesig && stat(recsig, &st) == 0)
+		{
+			/*
+			 * ARCHIVE-PITR cross-version recovery (Phase 2).  The data
+			 * directory here is a pre-upgrade base backup that Phase 1 (the
+			 * OLD binary) replayed up to the upgrade boundary and shut down,
+			 * so it still carries the OLD major's pg_control/PG_VERSION --
+			 * which this NEW binary would reject at the version gate below.
+			 * This keys off the ordinary recovery.signal (this IS an archive
+			 * restore, and no primary_conninfo, so not the streaming branch
+			 * above) plus the pg_upgrade.signal sentinel dropped alongside
+			 * the staged upgrade window: only a genuine cross-version
+			 * upgrade-PITR has both, so a normal same-version PITR
+			 * (recovery.signal but no sentinel) is never affected.  A plain
+			 * stat() matches how the backend already selects recovery mode
+			 * (see readRecoverySignalFile()); the authoritative check that a
+			 * complete window is really present is left to
+			 * PerformWalUpgradeIfNeeded(), which FATALs on a partial window.
+			 * Synthesize a NEW-version pg_control from this binary's
+			 * constants so the gate passes; recovery then re-anchors at CN,
+			 * adopts the window's sysid, and replays the window + archived
+			 * tail.
+			 */
+			SynthesizeUpgradeStreamControlFile(true);
+		}
+	}
+
 	/* Check for PG_VERSION */
 	ValidatePgVersion(DataDir);
 }
diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index c9118e7198..7ad4e2a3ce 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -2361,6 +2361,21 @@
   max => 'INT_MAX',
 },
 
+{ name => 'pg_upgrade_standby_old_datadir', type => 'string', context => 'PGC_POSTMASTER', group => 'REPLICATION_STANDBY',
+  short_desc => 'Sets the path of this standby\'s retained pre-upgrade data directory for pg_upgrade --wal-upgrade.',
+  flags => 'GUC_SUPERUSER_ONLY',
+  variable => 'pg_upgrade_standby_old_datadir',
+  boot_val => '""',
+},
+
+{ name => 'pg_upgrade_standby_transfer_mode', type => 'enum', context => 'PGC_POSTMASTER', group => 'REPLICATION_STANDBY',
+  short_desc => 'Sets how a pg_upgrade --wal-upgrade streaming standby places user relation files.',
+  long_desc => 'The default, mirror, reproduces the transfer mode the upgraded primary used; the others override it on this standby.',
+  variable => 'pg_upgrade_standby_transfer_mode',
+  boot_val => 'PG_UPGRADE_XFER_MIRROR',
+  options => 'pg_upgrade_standby_transfer_mode_options',
+},
+
 { name => 'plan_cache_mode', type => 'enum', context => 'PGC_USERSET', group => 'QUERY_TUNING_OTHER',
   short_desc => 'Controls the planner\'s selection of custom or generic plan.',
   long_desc => 'Prepared statements can have custom and generic plans, and the planner will attempt to choose which is better.  This can be set to override the default behavior.',
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index d7942f50a7..f95517a472 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -383,6 +383,14 @@
 
 #primary_conninfo = ''                  # connection string to sending server
 #primary_slot_name = ''                 # replication slot on sending server
+#pg_upgrade_standby_transfer_mode = 'mirror'    # how a --wal-upgrade streaming standby
+                                        # places user files: 'mirror' (match the
+                                        # primary), 'copy', 'clone',
+                                        # 'copy_file_range', 'link', 'swap'
+                                        # (change requires restart)
+#pg_upgrade_standby_old_datadir = ''            # retained pre-upgrade data directory to
+                                        # link user relations from
+                                        # (change requires restart)
 #hot_standby = on                       # "off" disallows queries during recovery
                                         # (change requires restart)
 #max_standby_archive_delay = 30s        # max delay before canceling queries
diff --git a/src/bin/initdb/initdb.c b/src/bin/initdb/initdb.c
index 9d95c5301d..3d8c087b81 100644
--- a/src/bin/initdb/initdb.c
+++ b/src/bin/initdb/initdb.c
@@ -229,6 +229,12 @@ static const char *const backend_options = "--single -F -O -j -c search_path=pg_
 /* Additional switches to pass to backend (either boot or standalone) */
 static char *extra_options = "";
 
+/*
+ * The cluster subdirectories created here are mirrored by
+ * SynthesizeUpgradeStreamControlFile() in xlog.c, which builds a bare skeleton
+ * for a --wal-upgrade streaming standby without running initdb.  Keep the two
+ * lists in sync: a new required subdir added here must be added there too.
+ */
 static const char *const subdirs[] = {
 	"global",
 	"pg_wal/archive_status",
diff --git a/src/bin/pg_controldata/pg_controldata.c b/src/bin/pg_controldata/pg_controldata.c
index fe5fc5ec13..0484582e75 100644
--- a/src/bin/pg_controldata/pg_controldata.c
+++ b/src/bin/pg_controldata/pg_controldata.c
@@ -65,6 +65,8 @@ dbState(DBState state)
 			return _("in archive recovery");
 		case DB_IN_PRODUCTION:
 			return _("in production");
+		case DB_IN_UPGRADE:
+			return _("in pg_upgrade");
 	}
 	return _("unrecognized status code");
 }
@@ -349,6 +351,8 @@ main(int argc, char *argv[])
 		   ControlFile->data_checksum_version);
 	printf(_("Default char data signedness:         %s\n"),
 		   (ControlFile->default_char_signedness ? _("signed") : _("unsigned")));
+	printf(_("wal-upgrade window finalized:         %s\n"),
+		   (ControlFile->upgrade_finalized ? _("yes") : _("no")));
 	printf(_("Mock authentication nonce:            %s\n"),
 		   mock_auth_nonce_str);
 	return 0;
diff --git a/src/bin/pg_ctl/pg_ctl.c b/src/bin/pg_ctl/pg_ctl.c
index 7dc6e1ad8f..1f3daf14f0 100644
--- a/src/bin/pg_ctl/pg_ctl.c
+++ b/src/bin/pg_ctl/pg_ctl.c
@@ -269,6 +269,23 @@ get_pgpid(bool is_status_request)
 
 	if (stat(version_file, &statbuf) != 0 && errno == ENOENT)
 	{
+		char		sigpath[MAXPGPATH];
+		struct stat sigbuf;
+
+		/*
+		 * A --wal-upgrade streaming standby may be staged without initdb -- a
+		 * bare directory carrying only config plus the pg_upgrade.signal
+		 * sentinel.  PG_VERSION does not exist yet; the postmaster
+		 * synthesizes it (and pg_control) on start.  So if the sentinel is
+		 * present, do NOT reject the directory here -- there is simply no
+		 * server running yet, so report "no pid" (0) exactly as for a normal
+		 * not-yet-started cluster and let do_start() launch the postmaster,
+		 * which does the synthesis.
+		 */
+		snprintf(sigpath, sizeof(sigpath), "%s/pg_upgrade.signal", pg_data);
+		if (stat(sigpath, &sigbuf) == 0)
+			return 0;
+
 		write_stderr(_("%s: directory \"%s\" is not a database cluster directory\n"),
 					 progname, pg_data);
 		exit(is_status_request ? 4 : 1);
diff --git a/src/bin/pg_resetwal/pg_resetwal.c b/src/bin/pg_resetwal/pg_resetwal.c
index f8d25afed9..5d6e41662e 100644
--- a/src/bin/pg_resetwal/pg_resetwal.c
+++ b/src/bin/pg_resetwal/pg_resetwal.c
@@ -97,6 +97,15 @@ static int	wal_segsize_val;
 static bool char_signedness_given = false;
 static bool char_signedness_val;
 
+/*
+ * --wal-upgrade-exact: make the -l/--next-wal-file target authoritative so the
+ * new WAL start position can be forced DOWN (not just floored upward over the
+ * existing pg_wal/ segments and the current checkpoint).  Intended for internal
+ * use by "pg_upgrade --wal-upgrade", which must land the end-of-upgrade
+ * checkpoint (CN) at a byte-deterministic segment boundary.
+ */
+static bool wal_upgrade_exact = false;
+
 
 static TimeLineID minXlogTli = 0;
 static XLogSegNo minXlogSegNo = 0;
@@ -135,6 +144,7 @@ main(int argc, char *argv[])
 		{"next-transaction-id", required_argument, NULL, 'x'},
 		{"wal-segsize", required_argument, NULL, 1},
 		{"char-signedness", required_argument, NULL, 2},
+		{"wal-upgrade-exact", no_argument, NULL, 3},
 		{NULL, 0, NULL, 0}
 	};
 
@@ -352,6 +362,10 @@ main(int argc, char *argv[])
 					break;
 				}
 
+			case 3:
+				wal_upgrade_exact = true;
+				break;
+
 			default:
 				/* getopt_long already emitted a complaint */
 				pg_log_error_hint("Try \"%s --help\" for more information.", progname);
@@ -378,6 +392,13 @@ main(int argc, char *argv[])
 		exit(1);
 	}
 
+	/*
+	 * --wal-upgrade-exact makes the -l target authoritative, so it is
+	 * meaningless (and almost certainly a mistake) without -l.
+	 */
+	if (wal_upgrade_exact && log_fname == NULL)
+		pg_fatal("option --wal-upgrade-exact requires -l/--next-wal-file");
+
 	/*
 	 * Don't allow pg_resetwal to be run as root, to avoid overwriting the
 	 * ownership of files in the data directory. We need only check for root
@@ -510,7 +531,18 @@ main(int argc, char *argv[])
 	if (char_signedness_given)
 		ControlFile.default_char_signedness = char_signedness_val;
 
-	if (minXlogSegNo > newXlogSegNo)
+	if (wal_upgrade_exact)
+	{
+		/*
+		 * The -l target is authoritative: force the new WAL start to exactly
+		 * that segment, overriding the FindEndOfXLOG floor.  This is the only
+		 * way to move the position DOWN (below the current checkpoint / the
+		 * highest existing pg_wal/ segment), which pg_upgrade --wal-upgrade
+		 * needs to land CN at a byte-deterministic boundary.
+		 */
+		newXlogSegNo = minXlogSegNo;
+	}
+	else if (minXlogSegNo > newXlogSegNo)
 		newXlogSegNo = minXlogSegNo;
 
 	if (noupdate)
@@ -1239,6 +1271,14 @@ usage(void)
 	printf(_("      --char-signedness=OPTION     set char signedness to \"signed\" or \"unsigned\"\n"));
 	printf(_("      --wal-segsize=SIZE           size of WAL segments, in megabytes\n"));
 
+	/*
+	 * --wal-upgrade-exact is intentionally NOT listed here: it is an internal
+	 * implementation detail of pg_upgrade --wal-upgrade (it forces the -l
+	 * target downward so the end-of-upgrade checkpoint lands at a
+	 * byte-deterministic boundary), not a user-facing option.  Kept
+	 * undocumented like other binary-upgrade-only machinery.
+	 */
+
 	printf(_("\nReport bugs to <%s>.\n"), PACKAGE_BUGREPORT);
 	printf(_("%s home page: <%s>\n"), PACKAGE_NAME, PACKAGE_URL);
 }
diff --git a/src/bin/pg_upgrade/Makefile b/src/bin/pg_upgrade/Makefile
index 771addb675..e37097bcc3 100644
--- a/src/bin/pg_upgrade/Makefile
+++ b/src/bin/pg_upgrade/Makefile
@@ -24,6 +24,7 @@ OBJS = \
 	parallel.o \
 	pg_upgrade.o \
 	relfilenumber.o \
+	revertable.o \
 	server.o \
 	slru_io.o \
 	tablespace.o \
diff --git a/src/bin/pg_upgrade/check.c b/src/bin/pg_upgrade/check.c
index 2155b01b11..5f08315e90 100644
--- a/src/bin/pg_upgrade/check.c
+++ b/src/bin/pg_upgrade/check.c
@@ -560,6 +560,14 @@ check_and_dump_old_cluster(void)
 	 */
 	get_db_rel_and_slot_infos(&old_cluster);
 
+	/*
+	 * On the --wal-upgrade path, also gather the old cluster's physical
+	 * replication slots so they can be recreated on the new cluster (see
+	 * get_old_cluster_physical_slot_infos).  Must run while the old server is
+	 * up.
+	 */
+	get_old_cluster_physical_slot_infos();
+
 	init_tablespaces();
 
 	get_loadable_libraries();
@@ -760,6 +768,12 @@ output_completion_banner(char *deletion_script_file_name)
 		appendPQExpBufferChar(&user_specification, ' ');
 	}
 
+	/* report WAL generated during pg_restore (only for --wal-upgrade) */
+	if (user_opts.wal_upgrade)
+		pg_log(PG_REPORT,
+			   "WAL generated during schema restore: " UINT64_FORMAT " bytes",
+			   log_opts.pg_upgrade_wal_bytes);
+
 	pg_log(PG_REPORT,
 		   "Some statistics are not transferred by pg_upgrade.\n"
 		   "Once you start the new server, consider running these two commands:\n"
@@ -2061,6 +2075,39 @@ check_new_cluster_replication_slots(void)
 	int			i_nslots_on_new;
 	int			i_rdt_slot_on_new;
 
+	/*
+	 * --wal-upgrade migrates the old cluster's physical replication slots and
+	 * recreates them on the new cluster (see
+	 * get_old_cluster_physical_slot_infos / pg_upgrade.c).  Each recreation
+	 * needs a free slot, so the new cluster's max_replication_slots must
+	 * accommodate them.  This is independent of the logical-slot /
+	 * retain_dead_tuples machinery below (which early-returns when there are
+	 * no logical slots and is gated on PG17+), so check it up front and for
+	 * any old major.  Recreation is otherwise best-effort (warn-not-fail), so
+	 * without this a too-small max_replication_slots would silently migrate
+	 * fewer physical slots than expected and quietly break those standbys'
+	 * streaming.
+	 */
+	if (user_opts.wal_upgrade && old_cluster.phys_slot_arr.nslots > 0)
+	{
+		PGconn	   *pconn = connectToServer(&new_cluster, "template1");
+		PGresult   *pres = executeQueryOrDie(pconn,
+											 "SELECT setting FROM pg_settings "
+											 "WHERE name = 'max_replication_slots'");
+		int			max_slots;
+
+		if (PQntuples(pres) != 1)
+			pg_fatal("could not determine \"max_replication_slots\" on the new cluster");
+		max_slots = atoi(PQgetvalue(pres, 0, 0));
+		PQclear(pres);
+		PQfinish(pconn);
+
+		if (old_cluster.phys_slot_arr.nslots > max_slots)
+			pg_fatal("\"max_replication_slots\" (%d) must be greater than or equal to the number of "
+					 "physical replication slots (%d) in the old cluster migrated by --wal-upgrade",
+					 max_slots, old_cluster.phys_slot_arr.nslots);
+	}
+
 	/*
 	 * Logical slots can be migrated since PG17 and a physical slot
 	 * CONFLICT_DETECTION_SLOT can be migrated since PG19.
diff --git a/src/bin/pg_upgrade/controldata.c b/src/bin/pg_upgrade/controldata.c
index 02ea02df60..4b111d7ee6 100644
--- a/src/bin/pg_upgrade/controldata.c
+++ b/src/bin/pg_upgrade/controldata.c
@@ -75,6 +75,15 @@ get_control_data(ClusterInfo *cluster)
 	int			rc;
 	bool		live_check = (cluster == &old_cluster && user_opts.live_check);
 
+	/*
+	 * With --initdb the old cluster's control data is read early, to derive
+	 * initdb options, before the normal call in
+	 * check_cluster_compatibility(). A non-zero ctrl_ver means it has already
+	 * been read; don't repeat it.
+	 */
+	if (cluster->controldata.ctrl_ver != 0)
+		return;
+
 	/*
 	 * Because we test the pg_resetwal output as strings, it has to be in
 	 * English.  Copied from pg_regress.c.
diff --git a/src/bin/pg_upgrade/file.c b/src/bin/pg_upgrade/file.c
index af82c0de49..9c93b51e3f 100644
--- a/src/bin/pg_upgrade/file.c
+++ b/src/bin/pg_upgrade/file.c
@@ -21,6 +21,7 @@
 #endif
 
 #include "common/file_perm.h"
+#include "common/file_utils.h"
 #include "pg_upgrade.h"
 
 
@@ -35,36 +36,17 @@ void
 cloneFile(const char *src, const char *dst,
 		  const char *schemaName, const char *relName)
 {
-#if defined(HAVE_COPYFILE) && defined(COPYFILE_CLONE_FORCE)
-	if (copyfile(src, dst, NULL, COPYFILE_CLONE_FORCE) < 0)
-		pg_fatal("error while cloning relation \"%s.%s\" (\"%s\" to \"%s\"): %m",
-				 schemaName, relName, src, dst);
-#elif defined(__linux__) && defined(FICLONE)
-	int			src_fd;
-	int			dest_fd;
-
-	if ((src_fd = open(src, O_RDONLY | PG_BINARY, 0)) < 0)
-		pg_fatal("error while cloning relation \"%s.%s\": could not open file \"%s\": %m",
-				 schemaName, relName, src);
-
-	if ((dest_fd = open(dst, O_RDWR | O_CREAT | O_EXCL | PG_BINARY,
-						pg_file_create_mode)) < 0)
-		pg_fatal("error while cloning relation \"%s.%s\": could not create file \"%s\": %m",
-				 schemaName, relName, dst);
+	int			save_errno;
 
-	if (ioctl(dest_fd, FICLONE, src_fd) < 0)
+	switch (pg_clone_file(src, dst, &save_errno))
 	{
-		int			save_errno = errno;
-
-		unlink(dst);
-
-		pg_fatal("error while cloning relation \"%s.%s\" (\"%s\" to \"%s\"): %s",
-				 schemaName, relName, src, dst, strerror(save_errno));
+		case PG_REFLINK_OK:
+		case PG_REFLINK_UNSUPPORTED:
+			break;
+		case PG_REFLINK_ERROR:
+			pg_fatal("error while cloning relation \"%s.%s\" (\"%s\" to \"%s\"): %s",
+					 schemaName, relName, src, dst, strerror(save_errno));
 	}
-
-	close(src_fd);
-	close(dest_fd);
-#endif
 }
 
 
@@ -147,32 +129,17 @@ void
 copyFileByRange(const char *src, const char *dst,
 				const char *schemaName, const char *relName)
 {
-#ifdef HAVE_COPY_FILE_RANGE
-	int			src_fd;
-	int			dest_fd;
-	ssize_t		nbytes;
-
-	if ((src_fd = open(src, O_RDONLY | PG_BINARY, 0)) < 0)
-		pg_fatal("error while copying relation \"%s.%s\": could not open file \"%s\": %m",
-				 schemaName, relName, src);
-
-	if ((dest_fd = open(dst, O_RDWR | O_CREAT | O_EXCL | PG_BINARY,
-						pg_file_create_mode)) < 0)
-		pg_fatal("error while copying relation \"%s.%s\": could not create file \"%s\": %m",
-				 schemaName, relName, dst);
+	int			save_errno;
 
-	do
+	switch (pg_copy_file_range_all(src, dst, &save_errno))
 	{
-		nbytes = copy_file_range(src_fd, NULL, dest_fd, NULL, SSIZE_MAX, 0);
-		if (nbytes < 0)
-			pg_fatal("error while copying relation \"%s.%s\": could not copy file range from \"%s\" to \"%s\": %m",
-					 schemaName, relName, src, dst);
+		case PG_REFLINK_OK:
+		case PG_REFLINK_UNSUPPORTED:
+			break;
+		case PG_REFLINK_ERROR:
+			pg_fatal("error while copying relation \"%s.%s\": could not copy file range from \"%s\" to \"%s\": %s",
+					 schemaName, relName, src, dst, strerror(save_errno));
 	}
-	while (nbytes > 0);
-
-	close(src_fd);
-	close(dest_fd);
-#endif
 }
 
 
diff --git a/src/bin/pg_upgrade/info.c b/src/bin/pg_upgrade/info.c
index 37fff93892..8a69148dd5 100644
--- a/src/bin/pg_upgrade/info.c
+++ b/src/bin/pg_upgrade/info.c
@@ -21,7 +21,6 @@ static void create_rel_filename_map(const char *old_data, const char *new_data,
 static void report_unmatched_relation(const RelInfo *rel, const DbInfo *db,
 									  bool is_new_db);
 static void free_db_and_rel_infos(DbInfoArr *db_arr);
-static void get_template0_info(ClusterInfo *cluster);
 static void get_db_infos(ClusterInfo *cluster);
 static char *get_rel_infos_query(void);
 static void process_rel_infos(DbInfo *dbinfo, PGresult *res, void *arg);
@@ -328,7 +327,7 @@ get_db_rel_and_slot_infos(ClusterInfo *cluster)
  * Get information about template0, which will be copied from the old cluster
  * to the new cluster.
  */
-static void
+void
 get_template0_info(ClusterInfo *cluster)
 {
 	PGconn	   *conn = connectToServer(cluster, "template1");
@@ -832,6 +831,90 @@ count_old_cluster_logical_slots(void)
 	return slot_count;
 }
 
+/*
+ * get_old_cluster_physical_slot_infos()
+ *
+ * Gather the old cluster's physical replication slots so --wal-upgrade can
+ * recreate them on the new cluster.  Stock pg_upgrade does NOT migrate physical
+ * slots: before --wal-upgrade a standby could not follow the upgrade at all
+ * (it was rebuilt from scratch), so its slot was pointless to carry.  With
+ * --wal-upgrade the standby follows by streaming the upgrade window, so its
+ * slot becomes meaningful across the boundary -- migrating it preserves both
+ * the standby's slot identity (HA tooling / primary_slot_name keep working)
+ * and, because the recreated slot is reserved before CN, the window retention
+ * itself.  It is the only slot that pins the window; with no physical slot,
+ * --wal-upgrade has no streaming consumer and the window is not pinned.
+ *
+ * Physical slots are cluster-wide (not database-scoped) and carry no decoding
+ * state, so a single query over pg_replication_slots suffices.  Temporary and
+ * invalidated ("lost") slots are skipped: temporary slots cannot survive the
+ * start/stop cycles of the upgrade, and an invalidated slot has no usable
+ * restart_lsn to recreate.  Assumes the old server is running.
+ */
+void
+get_old_cluster_physical_slot_infos(void)
+{
+	PGconn	   *conn;
+	PGresult   *res;
+	PhysicalSlotInfo *slotinfos = NULL;
+	int			num_slots;
+
+	old_cluster.phys_slot_arr.slots = NULL;
+	old_cluster.phys_slot_arr.nslots = 0;
+
+	/* Only relevant to --wal-upgrade. */
+	if (!user_opts.wal_upgrade)
+		return;
+
+	conn = connectToServer(&old_cluster, "template1");
+
+	/*
+	 * Skip invalidated slots so we do not try to recreate a dead one.  The
+	 * pg_replication_slots.invalidation_reason column only exists in PG17+,
+	 * so gate the predicate on the old cluster's version -- otherwise the
+	 * query would error on an older major and abort the whole upgrade.  On
+	 * older majors we simply enumerate all physical slots; recreation below
+	 * is best-effort and warns rather than fails, so a slot that turns out to
+	 * be unusable does not stop the upgrade.
+	 *
+	 * Exclude the internal "pg_conflict_detection" slot: since PG19 the
+	 * conflict-detection slot for retain_dead_tuples subscriptions is a
+	 * cluster-wide (physical) slot, so it shows up here, but its name is
+	 * reserved -- pg_create_physical_replication_slot() rejects it -- and
+	 * pg_upgrade recreates it separately via its own path.  Migrating it here
+	 * would only emit a spurious warning and, if it were the sole physical
+	 * slot, make us believe a standby consumer exists (and try to pin the
+	 * window) when none does.
+	 */
+	res = executeQueryOrDie(conn,
+							"SELECT slot_name "
+							"FROM pg_catalog.pg_replication_slots "
+							"WHERE slot_type = 'physical' AND "
+							"temporary IS FALSE AND "
+							"slot_name <> 'pg_conflict_detection'%s",
+							GET_MAJOR_VERSION(old_cluster.major_version) >= 1700 ?
+							" AND invalidation_reason IS NULL" : "");
+
+	num_slots = PQntuples(res);
+
+	if (num_slots)
+	{
+		int			i_slotname = PQfnumber(res, "slot_name");
+
+		slotinfos = pg_malloc_array(PhysicalSlotInfo, num_slots);
+
+		for (int slotnum = 0; slotnum < num_slots; slotnum++)
+			slotinfos[slotnum].slotname =
+				pg_strdup(PQgetvalue(res, slotnum, i_slotname));
+	}
+
+	PQclear(res);
+	PQfinish(conn);
+
+	old_cluster.phys_slot_arr.slots = slotinfos;
+	old_cluster.phys_slot_arr.nslots = num_slots;
+}
+
 /*
  * get_subscription_info()
  *
diff --git a/src/bin/pg_upgrade/meson.build b/src/bin/pg_upgrade/meson.build
index ffbf6ae8d7..774bf46c78 100644
--- a/src/bin/pg_upgrade/meson.build
+++ b/src/bin/pg_upgrade/meson.build
@@ -14,6 +14,7 @@ pg_upgrade_sources = files(
   'parallel.c',
   'pg_upgrade.c',
   'relfilenumber.c',
+  'revertable.c',
   'server.c',
   'slru_io.c',
   'tablespace.c',
@@ -69,6 +70,9 @@ tests += {
       't/006_transfer_modes.pl',
       't/007_multixact_conversion.pl',
       't/008_extension_control_path.pl',
+      't/009_initdb_option.pl',
+      't/010_wal_upgrade.pl',
+      't/011_wal_upgrade_standby.pl',
     ],
     'deps': [test_ext],
     'test_kwargs': {'priority': 40}, # pg_upgrade tests are slow
diff --git a/src/bin/pg_upgrade/option.c b/src/bin/pg_upgrade/option.c
index f01d2f92d9..dd097f2be0 100644
--- a/src/bin/pg_upgrade/option.c
+++ b/src/bin/pg_upgrade/option.c
@@ -63,6 +63,12 @@ parseCommandLine(int argc, char *argv[])
 		{"no-statistics", no_argument, NULL, 5},
 		{"set-char-signedness", required_argument, NULL, 6},
 		{"swap", no_argument, NULL, 7},
+		{"initdb", no_argument, NULL, 8},
+		/* capture the upgrade as WAL and reconstruct it on first startup */
+		{"wal-upgrade", no_argument, NULL, 9},
+
+		/* --wal-upgrade lifecycle subcommand (acts on -d old) */
+		{"wal-upgrade-signal-handoff", no_argument, NULL, 14},
 
 		{NULL, 0, NULL, 0}
 	};
@@ -234,6 +240,20 @@ parseCommandLine(int argc, char *argv[])
 				user_opts.transfer_mode = TRANSFER_MODE_SWAP;
 				break;
 
+			case 8:
+				user_opts.initdb_new_cluster = true;
+				break;
+
+				/* --wal-upgrade */
+			case 9:
+				user_opts.wal_upgrade = true;
+				break;
+
+				/* --wal-upgrade lifecycle subcommand */
+			case 14:
+				user_opts.revertable_op = REVERTABLE_OP_SIGNAL_HANDOFF;
+				break;
+
 			default:
 				fprintf(stderr, _("Try \"%s --help\" for more information.\n"),
 						os_info.progname);
@@ -244,6 +264,31 @@ parseCommandLine(int argc, char *argv[])
 	if (optind < argc)
 		pg_fatal("too many command-line arguments (first is \"%s\")", argv[optind]);
 
+	/*
+	 * --check is read-only and may run against a live old cluster, while
+	 * --initdb creates the new cluster on disk.  Reject the combination.
+	 */
+	if (user_opts.check && user_opts.initdb_new_cluster)
+		pg_fatal("options %s and %s cannot be used together",
+				 "-c/--check", "--initdb");
+
+	/*
+	 * -O is accepted together with --initdb.  create_new_cluster_via_initdb()
+	 * derives initdb's core options from the old cluster's control data, then
+	 * appends -O (new_cluster.pgopts) to the initdb command line; the same -O
+	 * options are also passed to the new cluster's postmaster during the
+	 * server phases.  This is needed for --wal-upgrade -- e.g. "-c
+	 * allow_in_place_tablespaces=on" -- so it is not rejected.
+	 */
+
+	/*
+	 * --swap is compatible with --wal-upgrade.  The WAL window is generated
+	 * regardless of transfer mode, so standbys can still be reconstructed
+	 * from it.  --swap moves the old cluster's data directories into the new
+	 * cluster (do_swap); this is acceptable because --wal-upgrade offers no
+	 * revert-to-old interface that would depend on the old cluster surviving.
+	 */
+
 	if (!user_opts.sync_method)
 		user_opts.sync_method = pg_strdup("fsync");
 
@@ -264,6 +309,24 @@ parseCommandLine(int argc, char *argv[])
 	else
 		setenv("PGOPTIONS", FIX_DEFAULT_READ_ONLY, 1);
 
+	/*
+	 * --wal-upgrade-signal-handoff acts on a single existing (running) old
+	 * cluster and does not run an upgrade, so it needs only the old cluster's
+	 * data + bin directories, not the full old/new set the normal flow
+	 * requires. It connects to the LIVE old primary, writes the handoff
+	 * trigger into its WAL, and shuts the primary down at that point so no
+	 * transaction can append WAL after the handoff.  The old bin dir is
+	 * needed to run that cluster's pg_ctl for the shutdown.
+	 */
+	if (user_opts.revertable_op == REVERTABLE_OP_SIGNAL_HANDOFF)
+	{
+		if (old_cluster.pgdata == NULL)
+			pg_fatal("--wal-upgrade-signal-handoff requires the old cluster data directory (-d/--old-datadir)");
+		if (old_cluster.bindir == NULL)
+			pg_fatal("--wal-upgrade-signal-handoff requires the old cluster bin directory (-b/--old-bindir)");
+		return;
+	}
+
 	/* Get values from env if not already set */
 	check_required_directory(&old_cluster.bindir, "PGBINOLD", false,
 							 "-b", _("old cluster binaries reside"), false);
@@ -328,6 +391,14 @@ usage(void)
 	printf(_("  --clone                       clone instead of copying files to new cluster\n"));
 	printf(_("  --copy                        copy files to new cluster (default)\n"));
 	printf(_("  --copy-file-range             copy files to new cluster with copy_file_range\n"));
+	/* --wal-upgrade usage */
+	printf(_("  --wal-upgrade                 capture the whole upgrade as WAL and reconstruct\n"
+			 "                                the cluster from it on first startup (atomic,\n"
+			 "                                crash-safe, WAL-replayable)\n"));
+	printf(_("  --wal-upgrade-signal-handoff  signal streaming standbys to stand down for a\n"
+			 "                                --wal-upgrade, then shut the old primary down\n"));
+	printf(_("  --initdb                      create the new cluster with initdb before\n"
+			 "                                upgrading (settings derived from old cluster)\n"));
 	printf(_("  --no-statistics               do not import statistics from old cluster\n"));
 	printf(_("  --set-char-signedness=OPTION  set new cluster char signedness to \"signed\" or\n"
 			 "                                \"unsigned\"\n"));
@@ -336,7 +407,9 @@ usage(void)
 	printf(_("  -?, --help                    show this help, then exit\n"));
 	printf(_("\n"
 			 "Before running pg_upgrade you must:\n"
-			 "  create a new database cluster (using the new version of initdb)\n"
+			 "  create a new database cluster (using the new version of initdb),\n"
+			 "    unless the --initdb option is given, in which case pg_upgrade\n"
+			 "    creates the new cluster for you\n"
 			 "  shutdown the postmaster servicing the old cluster\n"
 			 "  shutdown the postmaster servicing the new cluster\n"));
 	printf(_("\n"
diff --git a/src/bin/pg_upgrade/pg_upgrade.c b/src/bin/pg_upgrade/pg_upgrade.c
index 7366fd4627..98611335a3 100644
--- a/src/bin/pg_upgrade/pg_upgrade.c
+++ b/src/bin/pg_upgrade/pg_upgrade.c
@@ -41,14 +41,18 @@
 
 #include "postgres_fe.h"
 
+#include <dirent.h>
 #include <time.h>
 
 #include "access/multixact.h"
 #include "catalog/pg_class_d.h"
+#include "catalog/pg_collation_d.h"
 #include "common/file_perm.h"
 #include "common/logging.h"
 #include "common/restricted_token.h"
 #include "fe_utils/string_utils.h"
+#include "fe_utils/version.h"
+#include "mb/pg_wchar.h"
 #include "pg_upgrade.h"
 
 /*
@@ -67,6 +71,10 @@ static void copy_xact_xlog_xid(void);
 static void set_frozenxids(void);
 static void make_outputdirs(char *pgdata);
 static void setup(char *argv0);
+static void resolve_new_bindir(const char *argv0);
+static void create_new_cluster_via_initdb(const char *argv0);
+static char *detect_old_cluster_archive_command(void);
+static void write_wal_upgrade_archive_conf(const char *archive_command);
 static void create_logical_replication_slots(void);
 static void create_conflict_detection_slot(void);
 
@@ -74,6 +82,30 @@ ClusterInfo old_cluster,
 			new_cluster;
 OSInfo		os_info;
 
+/*
+ * For --wal-upgrade, the old cluster's archive_command carried forward to the
+ * new cluster (NULL if it was not archiving).  Detected while the old server is
+ * up; used to archive the upgrade window so PITR spans the upgrade.
+ */
+static char *old_cluster_archive_command = NULL;
+
+/*
+ * Fault-injection hook: when set, suppress the XLOG_UPGRADE_COMPLETE record
+ * (and its durable marker), leaving an incomplete window as a crash mid-window
+ * would.  Gated on USE_ASSERT_CHECKING, so a production (non-assert) build
+ * ignores the environment variable entirely and can never emit an incomplete
+ * window.
+ */
+static inline bool
+wal_upgrade_skip_complete(void)
+{
+#ifdef USE_ASSERT_CHECKING
+	return getenv("PG_UPGRADE_TEST_SKIP_COMPLETE") != NULL;
+#else
+	return false;
+#endif
+}
+
 char	   *output_files[] = {
 	SERVER_LOG_FILE,
 #ifdef WIN32
@@ -102,11 +134,29 @@ main(int argc, char **argv)
 	/* Set default restrictive mask until new cluster permissions are read */
 	umask(PG_MODE_MASK_OWNER);
 
+
 	parseCommandLine(argc, argv);
 
+	/*
+	 * A --wal-upgrade lifecycle subcommand acts on the running old cluster
+	 * and exits without running an upgrade.  new_cluster.bindir locates
+	 * pg_ctl.
+	 */
+	if (user_opts.revertable_op != REVERTABLE_OP_NONE)
+	{
+		resolve_new_bindir(argv[0]);
+		perform_revertable_op();
+		return 0;
+	}
+
+
 	get_restricted_token();
 
 	adjust_data_dir(&old_cluster);
+
+	if (user_opts.initdb_new_cluster)
+		create_new_cluster_via_initdb(argv[0]);
+
 	adjust_data_dir(&new_cluster);
 
 	/*
@@ -136,6 +186,12 @@ main(int argc, char **argv)
 
 	check_cluster_compatibility();
 
+	/*
+	 * The --wal-upgrade recovery anchor (CN) is the checkpoint taken just
+	 * before the end-of-upgrade full-page image burst, not the initdb
+	 * checkpoint.  See the end-of-upgrade block below.
+	 */
+
 	check_and_dump_old_cluster();
 
 
@@ -165,21 +221,53 @@ main(int argc, char **argv)
 
 	/* New now using xids of the old system */
 
-	/* -- NEW -- */
+	/*
+	 * For --wal-upgrade the new cluster runs at wal_level=replica (initdb's
+	 * default), so the end-of-upgrade full-page images -- and the persisted
+	 * pg_control -- are at a level a standby can recover from.  Recovery
+	 * anchors at CN and never replays pg_restore's WAL, so restore-phase WAL
+	 * is throwaway.
+	 */
 	start_postmaster(&new_cluster, true);
 
 	prepare_new_globals();
 
+	/*
+	 * For --wal-upgrade no WAL markers are emitted here; the entire upgrade
+	 * image is captured as full-page images at the very end, once everything
+	 * is on disk and a CHECKPOINT has flushed all buffers.  See below.
+	 */
+
 	create_new_objects();
 
+	/*
+	 * Stop the server before transferring relation files, as stock pg_upgrade
+	 * does.  The transfer overwrites the pg_restore-built files by a raw
+	 * copy/clone/link that bypasses the buffer manager; if the server were
+	 * up, the capture-time CHECKPOINT could flush stale dirty pages back over
+	 * the transferred files.  For --wal-upgrade the server restarts with a
+	 * clean buffer pool, so the capture reads exactly the transferred files.
+	 */
 	stop_postmaster(false);
 
+	/*
+	 * The new cluster keeps a fresh system identifier, as stock pg_upgrade
+	 * does.  Recovery of the upgrade window only requires pg_control and the
+	 * window's WAL to agree on the sysid, not that it match the old cluster
+	 * -- see the "Resetting WAL archives" step in copy_xact_xlog_xid().
+	 */
+
 	/*
 	 * Most failures happen in create_new_objects(), which has completed at
 	 * this point.  We do this here because it is just before file transfer,
 	 * which for --link will make it unsafe to start the old cluster once the
 	 * new cluster is started, and for --swap will make it unsafe to start the
 	 * old cluster at all.
+	 *
+	 * As in upstream, --link and --swap disable the old cluster while --copy
+	 * and --clone leave it intact.  --wal-upgrade does not change this: the
+	 * upgrade still generates the WAL window regardless, so standbys are
+	 * re-provisioned by streaming it from the upgraded primary.
 	 */
 	if (user_opts.transfer_mode == TRANSFER_MODE_LINK ||
 		user_opts.transfer_mode == TRANSFER_MODE_SWAP)
@@ -189,20 +277,354 @@ main(int argc, char **argv)
 								 old_cluster.pgdata, new_cluster.pgdata);
 
 	/*
-	 * Assuming OIDs are only used in system tables, there is no need to
-	 * restore the OID counter because we have not transferred any OIDs from
-	 * the old system, but we do it anyway just in case.  We do it late here
-	 * because there is no need to have the schema load use new oids.
+	 * Set the new cluster's next OID (stock upstream step, run with the
+	 * server down and after the restore, which consumes OIDs).  For
+	 * --wal-upgrade it must happen before the end-of-upgrade checkpoint below
+	 * so that checkpoint (CN) records the transplanted OID counter; recovery
+	 * from CN then reproduces the counters.
+	 *
+	 * On the --wal-upgrade STREAMING path (no archive_command carried
+	 * forward) this reset does double duty: it also positions the new
+	 * cluster's WAL at the old cluster's next segment (nextxlogfile, timeline
+	 * 1) so the reset-written DB_SHUTDOWNED checkpoint -- redo == checkPoint
+	 * == the segment boundary -- becomes a byte-deterministic CN.  A fresh
+	 * standby skeleton can then derive CN LOCALLY from its retained old
+	 * datadir (nextxlogfile) with no anchor round-trip.  A plain pg_resetwal
+	 * -l only floors the start upward (it maxes -l over the current
+	 * checkpoint redo and existing pg_wal/ segments), and after the restore
+	 * the checkpoint is at a high segment, so -l alone would be ignored;
+	 * --wal-upgrade-exact makes the -l target authoritative and forces the
+	 * position DOWN to nextxlogfile (pg_resetwal's own KillExistingXLOG then
+	 * deletes the restore's leftover segments, so no separate pg_wal/
+	 * emptying is needed).  On the archive path CN need not be
+	 * byte-deterministic (PITR locates it by scanning the WAL), so the plain
+	 * reset is used.
 	 */
-	prep_status("Setting next OID for new cluster");
-	exec_prog(UTILITY_LOG_FILE, NULL, true, true,
-			  "\"%s/pg_resetwal\" -o %u \"%s\"",
-			  new_cluster.bindir, old_cluster.controldata.chkpnt_nxtoid,
-			  new_cluster.pgdata);
-	check_ok();
+	if (user_opts.wal_upgrade && old_cluster_archive_command == NULL)
+	{
+		prep_status("Setting next OID and CN log position for new cluster");
+		exec_prog(UTILITY_LOG_FILE, NULL, true, true,
+		/* timeline 1 to match controldata and emit no WAL history file */
+				  "\"%s/pg_resetwal\" --wal-upgrade-exact -o %u -l 00000001%s \"%s\"",
+				  new_cluster.bindir, old_cluster.controldata.chkpnt_nxtoid,
+				  old_cluster.controldata.nextxlogfile + 8,
+				  new_cluster.pgdata);
+		check_ok();
+	}
+	else
+	{
+		prep_status("Setting next OID for new cluster");
+		exec_prog(UTILITY_LOG_FILE, NULL, true, true,
+				  "\"%s/pg_resetwal\" -o %u \"%s\"",
+				  new_cluster.bindir, old_cluster.controldata.chkpnt_nxtoid,
+				  new_cluster.pgdata);
+		check_ok();
+	}
 
 	migrate_logical_slots = count_old_cluster_logical_slots();
 
+	if (user_opts.wal_upgrade)
+	{
+		PGconn	   *conn;
+
+		/* Window's last segment; the archive barrier waits for it to drain. */
+		char		upgrade_window_last_seg[MAXPGPATH] = {0};
+
+		/*
+		 * The old cluster's archive_command is detected and carried forward
+		 * only on the --initdb path (create_new_cluster_via_initdb).  Without
+		 * it, the upgrade window is generated but never archived, so a PITR
+		 * base backup could not roll across the upgrade boundary, which is
+		 * the purpose of --wal-upgrade, while the run still reported success.
+		 * Emit a warning rather than produce an upgrade that cannot be
+		 * recovered by PITR.
+		 */
+		if (old_cluster_archive_command == NULL)
+			pg_log(PG_WARNING,
+				   "--wal-upgrade did not configure WAL archiving for the new cluster; "
+				   "the upgrade window will not be archived and cannot be recovered by PITR. "
+				   "Use --initdb so the old cluster's archive_command is carried forward, "
+				   "or configure archiving on the new cluster before it is needed.");
+
+		/*
+		 * Restart with a fresh buffer pool for the WAL capture phase.  The
+		 * server now reads the just-transplanted counters from pg_control, so
+		 * the checkpoint we take below captures them.
+		 */
+		start_postmaster(&new_cluster, true);
+		conn = connectToServer(&new_cluster, "template1");
+
+		/*
+		 * Everything below is emitted after all pg_upgrade work is complete,
+		 * so the upgrade's own WAL is generated as one atomic burst at the
+		 * end rather than interleaved with the restore.
+		 *
+		 * When the window has a consumer, it must be pinned in pg_wal/ BEFORE
+		 * the CN checkpoint by a slot whose restart_lsn sits at or before CN:
+		 * a streaming standby anchors recovery at CN, so CN and the whole
+		 * window must survive; a restart_lsn past CN could let CN be
+		 * recycled.  The pin is size-independent (see server.c's
+		 * max_slot_wal_keep_size=-1).
+		 *
+		 * The consumer, if any, determines whether a slot is needed:
+		 *
+		 * - The old cluster HAD physical slots (a standby was connected).  We
+		 * migrate them here with immediately_reserve = true, which reserves
+		 * each slot's restart_lsn at the burst server's CURRENT insert
+		 * position.  Correctness of the pin (restart_lsn <= CN) rests
+		 * entirely on this loop running BEFORE the CN checkpoint is emitted
+		 * just below (binary_upgrade_emit_wal_window); the migrated slots
+		 * carry only the slot NAME across, not the old restart_lsn, so the
+		 * reserve happens here and now.  Every migrated slot reserves
+		 * at/before CN, so the window is pinned at the minimum of their
+		 * restart_lsns (all <= CN) -- it does not matter which one; any one
+		 * suffices.  Keep the create loop ahead of the emit call: moving it
+		 * after CN would silently break retention.  The standby reconnects
+		 * with its own real primary_slot_name, and the standby-provisioning
+		 * lifecycle drops the slot once caught up.
+		 *
+		 * - No physical slot, but archiving is configured (archive-PITR).  No
+		 * slot is needed: the window's durable home is the archive, not
+		 * pg_wal/.  Generation cannot recycle it (the burst server uses
+		 * max_wal_size=1TB / checkpoint_timeout=1h so no checkpoint fires
+		 * between CN and shutdown), and the wait-for-archive barrier below
+		 * guarantees CN..COMPLETE reaches the archive before shutdown; after
+		 * that pg_wal/ recycling is harmless because PITR restores from the
+		 * archive.
+		 *
+		 * - No physical slot AND no archiving.  Then --wal-upgrade has no
+		 * consumer for the window at all: no standby will stream it (a slot
+		 * is the operator's declaration that one is expected) and no PITR
+		 * will read it.  The primary keeps its transferred files and
+		 * auto-serves exactly as a plain pg_upgrade would, so the window is
+		 * simply unused; we do NOT pin it, and it is recycled as ordinary
+		 * WAL.  A standby wanted later is built conventionally (fresh base
+		 * backup), which does not need the window.
+		 *
+		 * So the dedicated UPGRADE_WINDOW_SLOT is never created; retention
+		 * comes solely from a migrated physical slot, when one exists.
+		 */
+		for (int slotnum = 0; slotnum < old_cluster.phys_slot_arr.nslots; slotnum++)
+		{
+			PhysicalSlotInfo *slot = &old_cluster.phys_slot_arr.slots[slotnum];
+			PGresult   *slotres;
+
+			pg_log(PG_VERBOSE, "migrating physical replication slot \"%s\"",
+				   slot->slotname);
+
+			/*
+			 * Best-effort: a slot that cannot be recreated (e.g. a name
+			 * collision, or a slot that was invalid on an old major where we
+			 * could not filter it out) must not abort the whole upgrade, so
+			 * warn and continue rather than using executeQueryOrDie().
+			 * Losing a migrated slot only means that standby must be
+			 * re-provisioned conventionally; it does not affect the primary's
+			 * upgrade.
+			 */
+			slotres = PQexec(conn,
+							 psprintf("SELECT pg_create_physical_replication_slot('%s', true, false)",
+									  slot->slotname));
+			if (PQresultStatus(slotres) != PGRES_TUPLES_OK)
+			{
+				pg_log(PG_WARNING,
+					   "could not migrate physical replication slot \"%s\": %s",
+					   slot->slotname, PQerrorMessage(conn));
+				pg_log(PG_WARNING,
+					   "The standby that used it must be re-provisioned after the upgrade.");
+			}
+			PQclear(slotres);
+		}
+
+		/*
+		 * Emit the entire upgrade window with a single binary-upgrade-gated
+		 * backend call.  binary_upgrade_emit_wal_window() does, in C:
+		 *
+		 * 1. flush SLRU + checkpoint -- that checkpoint IS CN, the recovery
+		 * anchor (the last checkpoint preceding XLOG_UPGRADE_START); no
+		 * separate CHECKPOINT is issued.  Replay starts at CN, not the initdb
+		 * checkpoint, applying only the end-of-upgrade full-page images that
+		 * follow -- never pg_restore's CREATE DATABASE FILE_COPY records --
+		 * so the cluster reconstructs from an empty data directory. 2.
+		 * PG_UPGRADE_START (with the PG_VERSION string). 3. the
+		 * directory-tree after-image (before any file image, so replay
+		 * recreates the skeleton first). 4. RELFILE full-page images + the
+		 * RELINK manifest. 5. SLRU images for pg_xact / pg_multixact. 6.
+		 * PG_UPGRADE_COMPLETE + the durable pg_control finalized flag.
+		 *
+		 * The XID/OID/multixact counters are not emitted separately: they
+		 * were transplanted into pg_control before CN, so the CN checkpoint
+		 * record already carries them.  CN's LSN is not captured here; first
+		 * startup (PerformWalUpgradeIfNeeded) derives it from the WAL and
+		 * arms pg_control at CN, which is what lets a physical standby
+		 * recover the same anchor from the streamed WAL.
+		 *
+		 * WAL generation happens entirely in C behind the IsBinaryUpgrade
+		 * gate, so the window-emitting steps are not exposed as reusable SQL
+		 * on a normal cluster.  wal_upgrade_skip_complete() (assert builds
+		 * only) suppresses the COMPLETE marker to simulate a crash
+		 * mid-upgrade so first startup FATALs and leaves the old cluster
+		 * intact.
+		 */
+		PQclear(executeQueryOrDie(conn,
+								  "SELECT binary_upgrade_emit_wal_window(%u, %u, %d, %s)",
+								  old_cluster.major_version,
+								  new_cluster.major_version,
+								  (int) user_opts.transfer_mode,
+								  wal_upgrade_skip_complete() ? "true" : "false"));
+
+		/*
+		 * Capture the segment holding PG_UPGRADE_COMPLETE before the switch,
+		 * so the wait-for-archive barrier below targets the window's last
+		 * segment (CN..COMPLETE) rather than a later one.  pg_switch_wal()
+		 * then seals that segment so it is archivable.
+		 */
+		if (old_cluster_archive_command != NULL)
+		{
+			PGresult   *res;
+
+			res = executeQueryOrDie(conn,
+									"SELECT pg_walfile_name(pg_current_wal_lsn())");
+			strlcpy(upgrade_window_last_seg, PQgetvalue(res, 0, 0),
+					sizeof(upgrade_window_last_seg));
+			PQclear(res);
+		}
+
+		PQclear(executeQueryOrDie(conn, "SELECT pg_switch_wal()"));
+
+		/*
+		 * When the window is being archived, wait until the archiver has
+		 * drained the window's last segment (holding PG_UPGRADE_COMPLETE,
+		 * captured above) before shutting the burst server down.  Only
+		 * CN..COMPLETE must reach the archive; pre-CN pg_restore WAL is
+		 * irrelevant and legitimately recycled.  The window is usually
+		 * already archived by now (smart shutdown drains the archiver); this
+		 * barrier makes that explicit.  Mirrors do_pg_backup_stop()'s
+		 * waitforarchive spin on pg_stat_archiver.last_archived_wal.
+		 */
+		if (old_cluster_archive_command != NULL)
+		{
+			PGresult   *res;
+
+			/*
+			 * Distinguish a persistent archiving failure from a transient one
+			 * the archiver retries and recovers from.  pg_stat_archiver's
+			 * last_failed_wal is a sticky high-water mark: it is set on any
+			 * failure and never cleared on a later success, so it cannot tell
+			 * us whether archiving is *currently* failing.  Instead give up
+			 * only when at least one failure has been recorded AND
+			 * last_archived_wal makes no forward progress for a bounded
+			 * number of consecutive polls.  A transient failure (retried and
+			 * drained) advances last_archived and resets the stall counter.
+			 */
+			char		prev_archived[MAXPGPATH] = {0};
+			int64		prev_failed = -1;
+			int			stalled_polls = 0;
+
+			/*
+			 * ~30s of no progress while failures mount -> give up (100ms
+			 * poll)
+			 */
+#define UPGRADE_ARCHIVE_STALL_LIMIT 300
+
+			prep_status("Waiting for the upgrade window to be archived");
+			for (;;)
+			{
+				char		last_archived[MAXPGPATH];
+				int64		failed_count;
+
+				res = executeQueryOrDie(conn,
+										"SELECT coalesce(last_archived_wal, ''), "
+										"failed_count "
+										"FROM pg_stat_archiver");
+				strlcpy(last_archived, PQgetvalue(res, 0, 0), sizeof(last_archived));
+				failed_count = strtoi64(PQgetvalue(res, 0, 1), NULL, 10);
+				PQclear(res);
+
+				/*
+				 * last_archived_wal advances in segment-name order, so once
+				 * it has reached (>=) the window's last segment, CN..COMPLETE
+				 * is archived.
+				 */
+				if (last_archived[0] != '\0' &&
+					strcmp(last_archived, upgrade_window_last_seg) >= 0)
+					break;
+
+				/*
+				 * Persistent-failure detector.  The stall counter is driven
+				 * by ABSENCE OF PROGRESS, not by catching the exact poll on
+				 * which the archiver bumps its failure count: the archiver
+				 * fails far less often than the 100ms poll rate, so keying on
+				 * failed_count > prev_failed would reset the counter on
+				 * almost every poll and never trip.  Instead: increment on
+				 * every poll where last_archived_wal did not advance AND at
+				 * least one archive failure has been recorded (failed_count >
+				 * 0); reset the moment last_archived_wal advances.  A
+				 * transient failure the archiver recovers from advances
+				 * last_archived and clears the counter; a truly stuck
+				 * archive_command makes no progress and trips the fatal after
+				 * UPGRADE_ARCHIVE_STALL_LIMIT polls.  This also covers an
+				 * archive that never produces any segment (fails from the
+				 * first): last_archived stays "" across polls, which is "no
+				 * advance", so the counter still climbs.  prev_failed >= 0
+				 * means we have a prior poll to compare against (skip the
+				 * first).
+				 */
+				if (failed_count > 0 &&
+					prev_failed >= 0 &&
+					strcmp(last_archived, prev_archived) == 0)
+				{
+					if (++stalled_polls >= UPGRADE_ARCHIVE_STALL_LIMIT)
+						pg_fatal("archive_command is persistently failing while "
+								 "archiving the upgrade window (no progress past "
+								 "WAL file %s); the upgrade cannot be made "
+								 "recoverable by PITR",
+								 last_archived[0] != '\0' ? last_archived : "(none)");
+				}
+				else
+					stalled_polls = 0;
+
+				strlcpy(prev_archived, last_archived, sizeof(prev_archived));
+				prev_failed = failed_count;
+
+				pg_usleep(100000);	/* 100ms */
+			}
+			check_ok();
+
+			/*
+			 * No retention slot to drop: pg_upgrade never creates a dedicated
+			 * one.  A migrated physical slot, if any, belongs to the old
+			 * cluster's standby and is preserved so that standby can
+			 * reconnect after the upgrade.  The window (now archived) is free
+			 * to be recycled from pg_wal/ by the auto-served cluster's normal
+			 * checkpointing.
+			 */
+		}
+
+		PQfinish(conn);
+
+		/*
+		 * Clean shutdown (-m smart) so the shutdown checkpoint lands past
+		 * XLOG_UPGRADE_COMPLETE.  The primary is now a normal, fully-upgraded
+		 * cluster keeping its transferred files on disk (not reconstructed
+		 * from WAL): with the control checkpoint past COMPLETE, first
+		 * startup's PerformWalUpgradeIfNeeded() "already applied" guard
+		 * (checkpoint > CN) skips the window replay.
+		 *
+		 * The window (CN..COMPLETE) stays intact in pg_wal/, pinned by the
+		 * retention slot, so a fresh standby skeleton can stream and replay
+		 * it. Revert-and-replay is thus a standby-only mechanism.
+		 */
+		stop_postmaster(false);
+
+		/*
+		 * The durable "window reached COMPLETE" flag in pg_control was
+		 * already set inside binary_upgrade_emit_wal_window() above (on the
+		 * running burst server), which is suppressed by the same
+		 * skip_complete argument -- so a crash-mid-window primary is treated
+		 * as a partial upgrade. Nothing to write here.
+		 */
+	}
+
 	/*
 	 * Migrate replication slots to the new cluster.
 	 *
@@ -252,7 +674,20 @@ main(int argc, char **argv)
 
 	create_script_for_old_cluster_deletion(&deletion_script_file_name);
 
-	issue_warnings_and_set_wal_level();
+	/*
+	 * For --wal-upgrade, skip issue_warnings_and_set_wal_level(): it
+	 * unconditionally starts/stops the server, which would checkpoint and
+	 * recycle the upgrade WAL still needed in pg_wal/.
+	 *
+	 * pg_control is not stamped here.  First startup runs
+	 * PerformWalUpgradeIfNeeded(), which derives CN from the WAL, arms
+	 * pg_control at CN in-process, and crash-recovers from CN through
+	 * PG_UPGRADE_COMPLETE.  Deriving the anchor from the WAL (rather than
+	 * pre-stamping) lets the same WAL stream drive recovery on a physical
+	 * standby.  The whole upgrade is applied in one pass on next startup.
+	 */
+	if (!user_opts.wal_upgrade)
+		issue_warnings_and_set_wal_level();
 
 	pg_log(PG_REPORT,
 		   "\n"
@@ -358,6 +793,263 @@ make_outputdirs(char *pgdata)
 }
 
 
+/*
+ * resolve_new_bindir()
+ *
+ * Idempotent helper: if new_cluster.bindir has not been set by the user via
+ * -B, derive it from the path of the currently executing pg_upgrade binary.
+ * Called early by create_new_cluster_via_initdb() so that the initdb path
+ * is available before verify_directories() runs.
+ */
+static void
+resolve_new_bindir(const char *argv0)
+{
+	if (!new_cluster.bindir)
+	{
+		char		exec_path[MAXPGPATH];
+
+		if (find_my_exec(argv0, exec_path) < 0)
+			pg_fatal("%s: could not find own program executable", argv0);
+		/* Trim off program name and keep just the directory */
+		*last_dir_separator(exec_path) = '\0';
+		canonicalize_path(exec_path);
+		new_cluster.bindir = pg_strdup(exec_path);
+	}
+}
+
+
+/*
+ * create_new_cluster_via_initdb()
+ *
+ * Implements --initdb: run initdb to create the new cluster before upgrading,
+ * deriving WAL segment size, data checksums, encoding, and locale settings
+ * from the old cluster so that check_control_data() passes.
+ *
+ * This runs before the normal verify_directories() / setup() path, so we
+ * use a temporary log directory under the new bindir for the early server
+ * start; make_outputdirs() will replace log_opts.logdir later.
+ */
+static void
+create_new_cluster_via_initdb(const char *argv0)
+{
+	DbLocaleInfo *locale;
+	PQExpBufferData cmd;
+	char		tmp_logdir[MAXPGPATH];
+	char	   *saved_logdir = log_opts.logdir;
+	const char *encoding_name;
+
+	/* Pass argv0 (full path) so find_my_exec can locate the binary */
+	resolve_new_bindir(argv0);
+
+	/*
+	 * Verify that initdb is present and executable before doing any work. The
+	 * normal path checks this later inside verify_directories(), but we run
+	 * before that, so fail early with a useful message.
+	 */
+	{
+		char		initdb_path[MAXPGPATH];
+
+		snprintf(initdb_path, sizeof(initdb_path), "%s/initdb",
+				 new_cluster.bindir);
+		if (validate_exec(initdb_path) != 0)
+			pg_fatal("could not find \"initdb\" in \"%s\": %m\n"
+					 "The --initdb option requires initdb to be present in the new cluster's bin directory.",
+					 new_cluster.bindir);
+	}
+
+	old_cluster.major_version = get_pg_version(old_cluster.pgdata,
+											   &old_cluster.major_version_str);
+
+	/*
+	 * get_control_data() selects pg_resetwal vs. pg_resetxlog via
+	 * bin_version, which check_bindir() normally fills in later.  Seed it now
+	 * so the right binary name is used in this early call.
+	 */
+	if (old_cluster.bin_version == 0)
+		old_cluster.bin_version = old_cluster.major_version;
+
+	/*
+	 * Refuse to clobber an already-populated new data directory.  --initdb is
+	 * meant to create the new cluster from scratch, so an existing PG_VERSION
+	 * there means the operator pointed at the wrong directory or a leftover
+	 * from a previous attempt; either way, silently removing a database
+	 * system is too dangerous.  Fail with pg_upgrade's own clear message (not
+	 * initdb's "directory not empty") so the operator can remove it
+	 * deliberately.
+	 */
+	{
+		char		verfile[MAXPGPATH];
+		struct stat st;
+
+		snprintf(verfile, sizeof(verfile), "%s/PG_VERSION",
+				 new_cluster.pgdata);
+		if (stat(verfile, &st) == 0)
+			pg_fatal("new cluster data directory \"%s\" already contains a database system; "
+					 "--initdb requires an empty or nonexistent directory",
+					 new_cluster.pgdata);
+	}
+
+	get_control_data(&old_cluster);
+
+	/* Set up a temporary log directory for the early server start. */
+	snprintf(tmp_logdir, sizeof(tmp_logdir), "%s/pg_upgrade_initdb.log.d",
+			 new_cluster.bindir);
+	if (mkdir(tmp_logdir, pg_dir_create_mode) < 0 && errno != EEXIST)
+		pg_fatal("could not create temporary log directory \"%s\": %m",
+				 tmp_logdir);
+	log_opts.logdir = tmp_logdir;
+
+	if (!old_cluster.sockdir)
+		old_cluster.sockdir = user_opts.socketdir ? user_opts.socketdir : ".";
+
+	prep_status("Inspecting old cluster locale for new cluster creation");
+	start_postmaster(&old_cluster, true);
+	get_template0_info(&old_cluster);
+
+	/*
+	 * While the old server is up, capture its archive_command so it can be
+	 * carried forward to the new cluster: the upgrade window and post-upgrade
+	 * WAL then flow to the same archive, making the upgrade recoverable by
+	 * ordinary archive-based PITR with no extra operator action.
+	 */
+	if (user_opts.wal_upgrade)
+		old_cluster_archive_command = detect_old_cluster_archive_command();
+	stop_postmaster(false);
+	check_ok();
+
+	locale = old_cluster.template0;
+	encoding_name = pg_encoding_to_char(locale->db_encoding);
+
+	prep_status("Creating new cluster with initdb");
+
+
+	initPQExpBuffer(&cmd);
+	appendPQExpBuffer(&cmd, "\"%s/initdb\" -D \"%s\" -N",
+					  new_cluster.bindir, new_cluster.pgdata);
+	appendPQExpBuffer(&cmd, " -U \"%s\"", os_info.user);
+	appendPQExpBuffer(&cmd, " --wal-segsize=%u",
+					  old_cluster.controldata.walseg / (1024 * 1024));
+
+	/*
+	 * Pass --data-checksums or --no-data-checksums explicitly.  Starting from
+	 * PG18, initdb enables checksums by default, so we must mirror the old
+	 * cluster's setting to avoid a mismatch that check_control_data() would
+	 * reject.
+	 */
+	if (old_cluster.controldata.data_checksum_version != 0)
+		appendPQExpBufferStr(&cmd, " --data-checksums");
+	else
+		appendPQExpBufferStr(&cmd, " --no-data-checksums");
+
+	appendPQExpBuffer(&cmd, " --encoding=%s", encoding_name);
+	appendPQExpBuffer(&cmd, " --locale-provider=%s",
+					  collprovider_name(locale->db_collprovider));
+	appendPQExpBuffer(&cmd, " --lc-collate=\"%s\" --lc-ctype=\"%s\"",
+					  locale->db_collate, locale->db_ctype);
+
+	if (locale->db_locale)
+	{
+		if (locale->db_collprovider == COLLPROVIDER_ICU)
+			appendPQExpBuffer(&cmd, " --icu-locale=\"%s\"",
+							  locale->db_locale);
+		else if (locale->db_collprovider == COLLPROVIDER_BUILTIN)
+			appendPQExpBuffer(&cmd, " --builtin-locale=\"%s\"",
+							  locale->db_locale);
+	}
+
+	if (new_cluster.pgopts)
+		appendPQExpBuffer(&cmd, " %s", new_cluster.pgopts);
+
+	exec_prog(UTILITY_LOG_FILE, NULL, true, true, "%s", cmd.data);
+
+	termPQExpBuffer(&cmd);
+	log_opts.logdir = saved_logdir;
+
+	check_ok();
+
+	/*
+	 * If the old cluster was archiving, enable the same archiving in the new
+	 * cluster's postgresql.conf, so both the burst server and the auto-served
+	 * upgraded cluster archive to the same place.  postgresql.conf (rather
+	 * than -o flags) lets an archive_command with spaces and shell
+	 * metacharacters be quoted correctly.
+	 */
+	if (old_cluster_archive_command != NULL)
+		write_wal_upgrade_archive_conf(old_cluster_archive_command);
+}
+
+/*
+ * Read the old cluster's archive_command over a connection to the running
+ * old server.  Returns a pg_strdup'd copy if archiving was configured
+ * (archive_mode <> off AND archive_command non-empty), else NULL.  Used to
+ * carry the old cluster's archiving forward to the new cluster.
+ */
+static char *
+detect_old_cluster_archive_command(void)
+{
+	PGconn	   *conn = connectToServer(&old_cluster, "template1");
+	PGresult   *res;
+	char	   *mode;
+	char	   *cmd;
+	char	   *result = NULL;
+
+	res = executeQueryOrDie(conn,
+							"SELECT current_setting('archive_mode'), "
+							"current_setting('archive_command')");
+	mode = PQgetvalue(res, 0, 0);
+	cmd = PQgetvalue(res, 0, 1);
+
+	/*
+	 * archive_mode is off/on/always; archive_command defaults to '' (or the
+	 * placeholder "(disabled)" on some builds).  Only carry a real command.
+	 */
+	if (strcmp(mode, "off") != 0 &&
+		cmd[0] != '\0' &&
+		strcmp(cmd, "(disabled)") != 0)
+		result = pg_strdup(cmd);
+
+	PQclear(res);
+	PQfinish(conn);
+	return result;
+}
+
+/*
+ * Append archive settings to the new cluster's postgresql.conf so the
+ * upgrade window and the post-upgrade WAL reach the archive.  archive_command
+ * is the old cluster's command, carried forward.  Only called when the old
+ * cluster was archiving.
+ */
+static void
+write_wal_upgrade_archive_conf(const char *archive_command)
+{
+	char		conf_path[MAXPGPATH];
+	FILE	   *fp;
+	const char *p;
+
+	snprintf(conf_path, sizeof(conf_path), "%s/postgresql.conf",
+			 new_cluster.pgdata);
+
+	fp = fopen(conf_path, "a");
+	if (fp == NULL)
+		pg_fatal("could not open \"%s\" to enable WAL archiving: %m", conf_path);
+
+	/* archive_command is emitted as a single-quoted GUC value; double any '. */
+	fputs("\n# added by pg_upgrade --wal-upgrade (carried from the old cluster)\n"
+		  "archive_mode = on\n"
+		  "archive_command = '", fp);
+	for (p = archive_command; *p; p++)
+	{
+		if (*p == '\'')
+			fputc('\'', fp);
+		fputc(*p, fp);
+	}
+	fputs("'\n", fp);
+
+	if (fclose(fp) != 0)
+		pg_fatal("could not write \"%s\": %m", conf_path);
+}
+
+
 static void
 setup(char *argv0)
 {
@@ -372,17 +1064,7 @@ setup(char *argv0)
 	 * with -B, default to using the path of the currently executed pg_upgrade
 	 * binary.
 	 */
-	if (!new_cluster.bindir)
-	{
-		char		exec_path[MAXPGPATH];
-
-		if (find_my_exec(argv0, exec_path) < 0)
-			pg_fatal("%s: could not find own program executable", argv0);
-		/* Trim off program name and keep just path */
-		*last_dir_separator(exec_path) = '\0';
-		canonicalize_path(exec_path);
-		new_cluster.bindir = pg_strdup(exec_path);
-	}
+	resolve_new_bindir(argv0);
 
 	verify_directories();
 
@@ -598,6 +1280,11 @@ create_new_objects(void)
 	int			dbnum;
 	PGconn	   *conn_new_template1;
 
+	/* LSN snapshot before and after pg_restore to measure WAL generated */
+	PGresult   *lsn_res;
+	uint64		lsn_before = 0,
+				lsn_after = 0;
+
 	prep_status_progress("Restoring database schemas in the new cluster");
 
 	/*
@@ -609,6 +1296,20 @@ create_new_objects(void)
 	 */
 	conn_new_template1 = connectToServer(&new_cluster, "template1");
 	PQclear(executeQueryOrDie(conn_new_template1, "CHECKPOINT"));
+
+	/*
+	 * Snapshot the WAL position before pg_restore to measure the bytes the
+	 * schema restore generates.  Only for --wal-upgrade; otherwise skipped so
+	 * the flow matches stock pg_upgrade.
+	 */
+	if (user_opts.wal_upgrade)
+	{
+		lsn_res = executeQueryOrDie(conn_new_template1,
+									"SELECT pg_current_wal_lsn() - '0/0'");
+		lsn_before = strtoull(PQgetvalue(lsn_res, 0, 0), NULL, 10);
+		PQclear(lsn_res);
+	}
+
 	PQfinish(conn_new_template1);
 
 	/*
@@ -714,6 +1415,25 @@ create_new_objects(void)
 	end_progress_output();
 	check_ok();
 
+	/*
+	 * Measure WAL bytes generated by pg_restore: read the current WAL
+	 * position and compute the delta since lsn_before.  Only for
+	 * --wal-upgrade; skipped otherwise so the flow matches stock pg_upgrade.
+	 */
+	if (user_opts.wal_upgrade)
+	{
+		conn_new_template1 = connectToServer(&new_cluster, "template1");
+		lsn_res = executeQueryOrDie(conn_new_template1,
+									"SELECT pg_current_wal_lsn() - '0/0'");
+		lsn_after = strtoull(PQgetvalue(lsn_res, 0, 0), NULL, 10);
+		PQclear(lsn_res);
+		PQfinish(conn_new_template1);
+
+		log_opts.pg_upgrade_wal_bytes = lsn_after - lsn_before;
+		pg_log(PG_VERBOSE, "pg_upgrade_wal_bytes: " UINT64_FORMAT,
+			   log_opts.pg_upgrade_wal_bytes);
+	}
+
 	/* update new_cluster info now that we have objects in the databases */
 	get_db_rel_and_slot_infos(&new_cluster);
 }
@@ -775,7 +1495,8 @@ copy_xact_xlog_xid(void)
 	prep_status("Setting oldest XID for new cluster");
 	exec_prog(UTILITY_LOG_FILE, NULL, true, true,
 			  "\"%s/pg_resetwal\" -f -u %u \"%s\"",
-			  new_cluster.bindir, old_cluster.controldata.chkpnt_oldstxid,
+			  new_cluster.bindir,
+			  old_cluster.controldata.chkpnt_oldstxid,
 			  new_cluster.pgdata);
 	check_ok();
 
@@ -783,11 +1504,13 @@ copy_xact_xlog_xid(void)
 	prep_status("Setting next transaction ID and epoch for new cluster");
 	exec_prog(UTILITY_LOG_FILE, NULL, true, true,
 			  "\"%s/pg_resetwal\" -f -x %u \"%s\"",
-			  new_cluster.bindir, old_cluster.controldata.chkpnt_nxtxid,
+			  new_cluster.bindir,
+			  old_cluster.controldata.chkpnt_nxtxid,
 			  new_cluster.pgdata);
 	exec_prog(UTILITY_LOG_FILE, NULL, true, true,
 			  "\"%s/pg_resetwal\" -f -e %u \"%s\"",
-			  new_cluster.bindir, old_cluster.controldata.chkpnt_nxtepoch,
+			  new_cluster.bindir,
+			  old_cluster.controldata.chkpnt_nxtepoch,
 			  new_cluster.pgdata);
 	/* must reset commit timestamp limits also */
 	exec_prog(UTILITY_LOG_FILE, NULL, true, true,
@@ -863,7 +1586,20 @@ copy_xact_xlog_xid(void)
 		check_ok();
 	}
 
-	/* now reset the wal archives in the new cluster */
+	/*
+	 * Now reset the WAL archives in the new cluster.  This positions the new
+	 * cluster's WAL at the old cluster's next segment.
+	 *
+	 * For --wal-upgrade this reset also (re)assigns the new cluster's system
+	 * identifier, not forced to the old cluster's value.  It rewrites both
+	 * the control file and the fresh WAL segment header from the same
+	 * ControlFile.system_identifier, so pg_control and the burst WAL stay
+	 * consistent -- all that replay requires (recovery validates the WAL's
+	 * xlp_sysid against pg_control, not its numeric value).  A standby is
+	 * re-provisioned from a fresh skeleton stamped with this sysid, so it
+	 * need not match the pre-upgrade cluster.  Same "new cluster gets a new
+	 * sysid" behavior as stock pg_upgrade.
+	 */
 	prep_status("Resetting WAL archives");
 	exec_prog(UTILITY_LOG_FILE, NULL, true, true,
 	/* use timeline 1 to match controldata and no WAL history file */
diff --git a/src/bin/pg_upgrade/pg_upgrade.h b/src/bin/pg_upgrade/pg_upgrade.h
index d6e5bca579..16e66ec39d 100644
--- a/src/bin/pg_upgrade/pg_upgrade.h
+++ b/src/bin/pg_upgrade/pg_upgrade.h
@@ -157,6 +157,23 @@ typedef struct
 	LogicalSlotInfo *slots;		/* array of logical slot infos */
 } LogicalSlotInfoArr;
 
+/*
+ * Physical replication slot info.  Unlike logical slots these are
+ * cluster-wide (not database-scoped) and carry no decoding state, so the
+ * name is all we need to recreate the slot on the new cluster.  Only
+ * migrated on the --wal-upgrade path (see get_old_cluster_physical_slot_infos).
+ */
+typedef struct
+{
+	char	   *slotname;		/* slot name */
+} PhysicalSlotInfo;
+
+typedef struct
+{
+	int			nslots;			/* number of physical slot infos */
+	PhysicalSlotInfo *slots;	/* array of physical slot infos */
+} PhysicalSlotInfoArr;
+
 /*
  * The following structure represents a relation mapping.
  */
@@ -248,6 +265,20 @@ typedef enum
 	TRANSFER_MODE_SWAP,
 } transferMode;
 
+/*
+ * Revertable-upgrade lifecycle subcommands.  When set (not _NONE),
+ * pg_upgrade does NOT run an upgrade; it acts on an existing cluster and exits.
+ */
+typedef enum
+{
+	REVERTABLE_OP_NONE = 0,		/* normal pg_upgrade run */
+	REVERTABLE_OP_SIGNAL_HANDOFF,	/* emit the handoff trigger into the LIVE
+									 * old primary's WAL; it propagates to
+									 * streaming standbys through the normal
+									 * WAL/replication path, which replay it
+									 * and then stand down before the upgrade */
+} RevertableOp;
+
 /*
  * Enumeration to denote pg_log modes
  */
@@ -289,6 +320,8 @@ typedef struct
 	int			nsubs;			/* number of subscriptions */
 	bool		sub_retain_dead_tuples; /* whether a subscription enables
 										 * retain_dead_tuples. */
+	PhysicalSlotInfoArr phys_slot_arr;	/* physical slots to migrate
+										 * (--wal-upgrade only) */
 } ClusterInfo;
 
 
@@ -306,6 +339,8 @@ typedef struct
 	char	   *dumpdir;		/* Dumps */
 	char	   *logdir;			/* Log files */
 	bool		isatty;			/* is stdout a tty */
+	/* WAL bytes generated during pg_restore (schema restore phase) */
+	uint64		pg_upgrade_wal_bytes;
 } LogOpts;
 
 
@@ -325,6 +360,19 @@ typedef struct
 	int			char_signedness;	/* default char signedness: -1 for initial
 									 * value, 1 for "signed" and 0 for
 									 * "unsigned" */
+	bool		initdb_new_cluster; /* run initdb to create the new cluster
+									 * before upgrading, instead of requiring
+									 * the user to have created it manually */
+
+	/*
+	 * capture the whole upgrade as a WAL-replayable full-page image at the
+	 * end and skip the on-disk data writes, so first startup reconstructs the
+	 * cluster purely from WAL (atomic, crash-safe, recoverable from an empty
+	 * data directory)
+	 */
+	bool		wal_upgrade;
+	/* revertable-upgrade lifecycle subcommand, if any (see enum above) */
+	RevertableOp revertable_op;
 } UserOpts;
 
 typedef struct
@@ -380,6 +428,11 @@ void		check_control_data(ControlData *oldctrl, ControlData *newctrl);
 void		disable_old_cluster(transferMode transfer_mode);
 
 
+/* revertable.c */
+
+void		perform_revertable_op(void);
+
+
 /* dump.c */
 
 void		generate_old_dump(void);
@@ -423,7 +476,9 @@ FileNameMap *gen_db_file_maps(DbInfo *old_db,
 							  DbInfo *new_db, int *nmaps, const char *old_pgdata,
 							  const char *new_pgdata);
 void		get_db_rel_and_slot_infos(ClusterInfo *cluster);
+void		get_template0_info(ClusterInfo *cluster);
 int			count_old_cluster_logical_slots(void);
+void		get_old_cluster_physical_slot_infos(void);
 void		get_subscription_info(ClusterInfo *cluster);
 
 /* option.c */
diff --git a/src/bin/pg_upgrade/relfilenumber.c b/src/bin/pg_upgrade/relfilenumber.c
index 6c467bdc8a..382e03ed65 100644
--- a/src/bin/pg_upgrade/relfilenumber.c
+++ b/src/bin/pg_upgrade/relfilenumber.c
@@ -614,5 +614,6 @@ transfer_relfile(FileNameMap *map, const char *type_suffix)
 				pg_fatal("should never happen");
 				break;
 		}
+
 	}
 }
diff --git a/src/bin/pg_upgrade/revertable.c b/src/bin/pg_upgrade/revertable.c
new file mode 100644
index 0000000000..9c6d0b115c
--- /dev/null
+++ b/src/bin/pg_upgrade/revertable.c
@@ -0,0 +1,129 @@
+/*
+ *	revertable.c
+ *
+ *	--wal-upgrade lifecycle subcommand (signal-handoff).
+ *
+ *	A --wal-upgrade new cluster comes up read-write on its first start, as an
+ *	ordinary pg_upgrade does.  This file implements the signal-handoff subcommand:
+ *
+ *	  --wal-upgrade-signal-handoff  -d old   trigger streaming standbys to stand down
+ *
+ *	(A fresh standby needs no prepare step: with primary_conninfo set it
+ *	derives the upgrade window anchor (CN) locally from its retained old datadir
+ *	and streams the window -- see ArmFromLocalDerivationIfConfigured in
+ *	pgupgrade_wal.c.)
+ *
+ *	Copyright (c) 2010-2026, PostgreSQL Global Development Group
+ *	src/bin/pg_upgrade/revertable.c
+ */
+
+#include "postgres_fe.h"
+
+#include <stdlib.h>				/* system() */
+#include <unistd.h>				/* unlink() */
+
+#include "pg_upgrade.h"
+
+/*
+ * --wal-upgrade-signal-handoff: connect to the live old primary and write the
+ * streaming-handoff trigger into its (old-format) WAL.  It emits a WAL record
+ * that propagates to streaming standbys through the normal replication path,
+ * rather than contacting each standby.  On replaying it, a standby shuts down
+ * cleanly, ready for the new-version binary swap / re-provision.  Run this
+ * before stopping the old
+ * primary and running pg_upgrade.
+ *
+ * Unlike --wal-upgrade itself (which acts on stopped clusters), this one
+ * requires the old primary to be RUNNING.  The target major version passed to
+ * the trigger is this pg_upgrade binary's own major (the new version the
+ * standby will converge to).
+ */
+static void
+do_signal_handoff(void)
+{
+	char		sigpath[MAXPGPATH];
+	FILE	   *f;
+	char		cmd[MAXPGPATH * 2];
+	int			rc;
+
+	if (old_cluster.pgdata == NULL || old_cluster.pgdata[0] == '\0')
+		pg_fatal("--wal-upgrade-signal-handoff requires the old cluster data directory (-d)");
+	if (old_cluster.bindir == NULL || old_cluster.bindir[0] == '\0')
+		pg_fatal("--wal-upgrade-signal-handoff requires the old cluster bin directory (-b)\n"
+				 "(needed to shut the old primary down at the handoff point)");
+
+	/*
+	 * Emit the handoff during the primary's own shutdown, not from a client
+	 * session.  A client-issued handoff cannot be the guaranteed end of the
+	 * old WAL stream: a backend past its commit point, or a fresh connection
+	 * racing the terminate/shutdown, can flush a commit record after the
+	 * marker, so a standby that stopped at the handoff would be missing an
+	 * acknowledged commit.
+	 *
+	 * Instead drop a sentinel and let the server emit the record itself.  On
+	 * a fast shutdown the postmaster drains every backend (flushing their
+	 * commit WAL) before the checkpointer runs ShutdownXLOG(); ShutdownXLOG()
+	 * then emits XLOG_UPGRADE_HANDOFF just before the shutdown checkpoint,
+	 * while WAL senders are still streaming.  By construction all user WAL
+	 * precedes the handoff, which precedes the shutdown checkpoint -- so the
+	 * handoff is the exact, race-free end of the old stream that streaming
+	 * standbys stop at. The sentinel records the target major version (this
+	 * pg_upgrade binary's own major, the version the standby converges to).
+	 */
+	snprintf(sigpath, sizeof(sigpath), "%s/pg_upgrade_handoff.pending",
+			 old_cluster.pgdata);
+
+	prep_status("Arming pg_upgrade handoff on the old primary");
+	f = fopen(sigpath, "w");
+	if (f == NULL)
+		pg_fatal("could not create handoff signal file \"%s\": %m", sigpath);
+	fprintf(f, "%d\n", PG_MAJORVERSION_NUM);
+	if (fclose(f) != 0)
+		pg_fatal("could not write handoff signal file \"%s\": %m", sigpath);
+	check_ok();
+
+	/*
+	 * Fast-stop the old primary; ShutdownXLOG() consumes the sentinel and
+	 * emits the handoff.  system() rather than exec_prog(): the lifecycle
+	 * subcommand runs before make_outputdirs() has set log_opts.logdir, so
+	 * exec_prog() would fail on a "(null)/..." log path.
+	 */
+	prep_status("Shutting down the old primary at the handoff point");
+	snprintf(cmd, sizeof(cmd), "\"%s/pg_ctl\" -w -D \"%s\" -m fast stop",
+			 old_cluster.bindir, old_cluster.pgdata);
+	rc = system(cmd);
+	if (rc != 0)
+	{
+		unlink(sigpath);
+		pg_fatal("could not shut down the old primary at the handoff point "
+				 "(command \"%s\" returned %d)\n"
+				 "Stop the old primary manually before running "
+				 "\"pg_upgrade --wal-upgrade\".",
+				 cmd, rc);
+	}
+	check_ok();
+
+	pg_log(PG_REPORT,
+		   "\nHandoff trigger written to the old primary's WAL as it shut down.\n"
+		   "The trigger propagates to streaming standbys through the normal\n"
+		   "WAL/replication path, which replay it and shut down.  Now run\n"
+		   "\"pg_upgrade --wal-upgrade ...\"; then re-provision each standby\n"
+		   "from the delivered upgrade window.");
+}
+
+/*
+ * Dispatch a --wal-upgrade lifecycle subcommand and exit.  Called from main()
+ * before the normal upgrade flow when user_opts.revertable_op is set.
+ */
+void
+perform_revertable_op(void)
+{
+	switch (user_opts.revertable_op)
+	{
+		case REVERTABLE_OP_SIGNAL_HANDOFF:
+			do_signal_handoff();
+			break;
+		case REVERTABLE_OP_NONE:
+			break;				/* not reached */
+	}
+}
diff --git a/src/bin/pg_upgrade/server.c b/src/bin/pg_upgrade/server.c
index 7da9dffe58..84f0928b7b 100644
--- a/src/bin/pg_upgrade/server.c
+++ b/src/bin/pg_upgrade/server.c
@@ -201,7 +201,58 @@ start_postmaster(ClusterInfo *cluster, bool report_and_exit_on_error)
 	 * win on ext4.
 	 */
 	if (cluster == &new_cluster)
-		appendPQExpBufferStr(&pgoptions, " -c synchronous_commit=off -c fsync=off -c full_page_writes=off");
+	{
+		/*
+		 * Turn off durability requirements to improve object creation speed.
+		 * For --wal-upgrade keep fsync and full_page_writes on: the smart
+		 * shutdown checkpoint then flushes complete page images to disk, so
+		 * all catalog pages are correct even with synchronous_commit off.
+		 */
+		if (user_opts.wal_upgrade)
+		{
+			/* keep fsync=on and full_page_writes=on for durability */
+			appendPQExpBufferStr(&pgoptions, " -c synchronous_commit=off");
+
+			/*
+			 * The end-of-upgrade window (CN checkpoint through
+			 * PG_UPGRADE_COMPLETE) must survive intact in pg_wal/ for first
+			 * startup and standby/PITR replay.  Its full-page images are as
+			 * large as the data (many GB, possibly TB), so checkpoints during
+			 * the burst must not recycle any of it.
+			 *
+			 * The large max_wal_size / checkpoint_timeout below ensure no
+			 * checkpoint fires between CN and shutdown, so nothing recycles
+			 * the window during generation on any path.
+			 *
+			 * For the window to survive AFTER the burst server shuts down, a
+			 * migrated physical slot pins it (see pg_upgrade.c), created
+			 * before CN with immediately_reserve when the old cluster had a
+			 * physical slot -- i.e. a standby was connected.  KeepLogSeg()
+			 * pins every segment at/after its restart_lsn, uncapped while
+			 * max_slot_wal_keep_size is -1; set that here so the pin is
+			 * size-independent.  With no such slot the window is not pinned:
+			 * on the archive-PITR path its durable home is the archive (post-
+			 * shutdown recycling is harmless), and with neither a slot nor
+			 * archiving --wal-upgrade has no window consumer at all, so it is
+			 * recycled as ordinary WAL.
+			 */
+			appendPQExpBufferStr(&pgoptions,
+								 " -c max_slot_wal_keep_size=-1"
+								 " -c max_wal_size=1TB"
+								 " -c checkpoint_timeout=1h");
+
+			/*
+			 * Window archiving (--archive-command) is configured in the new
+			 * cluster's postgresql.conf, not here: an archive_command's
+			 * spaces and shell metacharacters do not survive pg_ctl's "-o"
+			 * string, and postgresql.conf also makes the auto-served cluster
+			 * archive its post-upgrade tail.  See
+			 * write_wal_upgrade_archive_conf().
+			 */
+		}
+		else
+			appendPQExpBufferStr(&pgoptions, " -c synchronous_commit=off -c fsync=off -c full_page_writes=off");
+	}
 
 	/*
 	 * Use -b to disable autovacuum and logical replication launcher
@@ -310,7 +361,6 @@ stop_postmaster(bool in_atexit)
 	os_info.running_cluster = NULL;
 }
 
-
 /*
  * check_pghost_envvar()
  *
diff --git a/src/bin/pg_upgrade/t/009_initdb_option.pl b/src/bin/pg_upgrade/t/009_initdb_option.pl
new file mode 100644
index 0000000000..772b78befc
--- /dev/null
+++ b/src/bin/pg_upgrade/t/009_initdb_option.pl
@@ -0,0 +1,218 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test the --initdb option of pg_upgrade: pg_upgrade creates the new cluster
+# itself via initdb, instead of requiring the user to have run initdb first.
+
+use strict;
+use warnings FATAL => 'all';
+
+use File::Path qw(rmtree);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Initialize and populate the old cluster.
+#
+# Use non-default settings that --initdb must carry over to the new cluster
+# (derived from the old cluster's pg_control): disabled data checksums (initdb
+# enables them by default since PG18), a non-default WAL segment size, and the
+# C locale.  We check below that the new cluster inherits them.
+my $oldnode = PostgreSQL::Test::Cluster->new('old_node');
+$oldnode->init(
+	extra => [
+		'--no-data-checksums',
+		'--wal-segsize' => '2',
+		'--locale' => 'C',
+	]);
+$oldnode->start;
+$oldnode->safe_psql('postgres',
+	    "CREATE TABLE t (id int primary key, note text); "
+	  . "INSERT INTO t SELECT g, 'row ' || g FROM generate_series(1, 100) g; "
+	  . "CREATE DATABASE extra_db;");
+my $rows_before =
+  $oldnode->safe_psql('postgres', 'SELECT count(*) FROM t');
+is($rows_before, '100', 'old cluster has expected rows before upgrade');
+
+# Record the old cluster's settings so we can compare them after the upgrade.
+my $old_checksums = $oldnode->safe_psql('postgres', 'SHOW data_checksums');
+my $old_wal_segsize = $oldnode->safe_psql('postgres', 'SHOW wal_segment_size');
+my $old_encoding = $oldnode->safe_psql('postgres',
+	"SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = 'template0'");
+my $old_collate = $oldnode->safe_psql('postgres',
+	"SELECT datcollate FROM pg_database WHERE datname = 'template0'");
+my $old_ctype = $oldnode->safe_psql('postgres',
+	"SELECT datctype FROM pg_database WHERE datname = 'template0'");
+my $old_provider = $oldnode->safe_psql('postgres',
+	"SELECT datlocprovider FROM pg_database WHERE datname = 'template0'");
+$oldnode->stop;
+
+# Create the new node object but do NOT init() it: pg_upgrade --initdb is
+# responsible for creating the data directory.  Only new() runs, which
+# allocates the port/host/basedir the framework needs.
+my $newnode = PostgreSQL::Test::Cluster->new('new_node');
+
+my $oldbindir = $oldnode->config_data('--bindir');
+my $newbindir = $newnode->config_data('--bindir');
+
+# Sanity: the new data directory must not exist yet.
+ok(!-d $newnode->data_dir,
+	'new cluster data directory does not exist before --initdb');
+
+# Run pg_upgrade with --initdb.  We must run in a writable directory because
+# pg_upgrade writes output files relative to the current directory.
+chdir ${PostgreSQL::Test::Utils::tmp_check};
+
+command_ok(
+	[
+		'pg_upgrade', '--no-sync',
+		'--old-datadir' => $oldnode->data_dir,
+		'--new-datadir' => $newnode->data_dir,
+		'--old-bindir' => $oldbindir,
+		'--new-bindir' => $newbindir,
+		'--socketdir' => $newnode->host,
+		'--old-port' => $oldnode->port,
+		'--new-port' => $newnode->port,
+		'--initdb',
+	],
+	'run of pg_upgrade --initdb creates and upgrades the new cluster');
+
+# The new data directory should now exist and be a v18+ cluster.
+ok(-f $newnode->data_dir . '/PG_VERSION',
+	'new cluster data directory created by --initdb');
+
+# The framework's init() would normally write port/socket settings into
+# postgresql.conf; since we skipped it, append them now so we can start the
+# upgraded cluster through the test harness.  Mirror init()'s own TCP vs Unix
+# socket handling so this works on Windows (where TCP is used) as well.
+my $host = $newnode->host;
+$newnode->append_conf('postgresql.conf', "port = " . $newnode->port);
+if ($PostgreSQL::Test::Cluster::use_tcp)
+{
+	$newnode->append_conf('postgresql.conf', "unix_socket_directories = ''");
+	$newnode->append_conf('postgresql.conf', "listen_addresses = '$host'");
+}
+else
+{
+	$newnode->append_conf('postgresql.conf',
+		"unix_socket_directories = '$host'");
+	$newnode->append_conf('postgresql.conf', "listen_addresses = ''");
+}
+
+$newnode->start;
+
+# Verify the user data survived the upgrade.
+my $rows_after = $newnode->safe_psql('postgres', 'SELECT count(*) FROM t');
+is($rows_after, '100', 'user data survived --initdb upgrade');
+
+# Verify the extra database carried over too.
+my $has_extra = $newnode->safe_psql('postgres',
+	"SELECT count(*) FROM pg_database WHERE datname = 'extra_db'");
+is($has_extra, '1', 'user database carried over by --initdb upgrade');
+
+# Verify the new cluster is a newer major version than the old one.
+my $newver = $newnode->safe_psql('postgres',
+	"SELECT current_setting('server_version_num')::int / 10000");
+ok($newver >= 18, "new cluster reports target major version ($newver)");
+
+# --initdb must reproduce these settings from the old cluster; otherwise
+# check_control_data() would reject the new cluster.  Verify each carried over.
+my $new_checksums = $newnode->safe_psql('postgres', 'SHOW data_checksums');
+is($new_checksums, $old_checksums,
+	"data_checksums propagated by --initdb ($new_checksums)");
+
+my $new_wal_segsize = $newnode->safe_psql('postgres', 'SHOW wal_segment_size');
+is($new_wal_segsize, $old_wal_segsize,
+	"wal_segment_size propagated by --initdb ($new_wal_segsize)");
+
+my $new_encoding = $newnode->safe_psql('postgres',
+	"SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = 'template0'");
+is($new_encoding, $old_encoding,
+	"template0 encoding propagated by --initdb ($new_encoding)");
+
+my $new_collate = $newnode->safe_psql('postgres',
+	"SELECT datcollate FROM pg_database WHERE datname = 'template0'");
+is($new_collate, $old_collate,
+	"template0 collation propagated by --initdb ($new_collate)");
+
+my $new_ctype = $newnode->safe_psql('postgres',
+	"SELECT datctype FROM pg_database WHERE datname = 'template0'");
+is($new_ctype, $old_ctype,
+	"template0 ctype propagated by --initdb ($new_ctype)");
+
+my $new_provider = $newnode->safe_psql('postgres',
+	"SELECT datlocprovider FROM pg_database WHERE datname = 'template0'");
+is($new_provider, $old_provider,
+	"template0 locale provider propagated by --initdb ($new_provider)");
+
+$newnode->stop;
+
+# --initdb must refuse to clobber an already-populated data directory, and the
+# failure must come from pg_upgrade's own PG_VERSION check (not initdb's
+# "directory not empty" error), so confirm the specific message.  pg_upgrade
+# prints its fatal message to stdout, so match there.
+command_checks_all(
+	[
+		'pg_upgrade', '--no-sync',
+		'--old-datadir' => $oldnode->data_dir,
+		'--new-datadir' => $newnode->data_dir,
+		'--old-bindir' => $oldbindir,
+		'--new-bindir' => $newbindir,
+		'--socketdir' => $newnode->host,
+		'--old-port' => $oldnode->port,
+		'--new-port' => $newnode->port,
+		'--initdb',
+	],
+	1,
+	[qr/already contains a database system/],
+	[qr/^$/],
+	'--initdb refuses to overwrite an existing cluster (PG_VERSION check)');
+
+# --initdb must fail early with a clear message if initdb is not present in the
+# new cluster's bin directory.  Point --new-bindir at an empty directory and use
+# a fresh (nonexistent) new data directory so we reach the initdb-present check.
+my $empty_bindir = PostgreSQL::Test::Utils::tempdir;
+command_checks_all(
+	[
+		'pg_upgrade', '--no-sync',
+		'--old-datadir' => $oldnode->data_dir,
+		'--new-datadir' => $newnode->data_dir . '_nonexistent',
+		'--old-bindir' => $oldbindir,
+		'--new-bindir' => $empty_bindir,
+		'--socketdir' => $newnode->host,
+		'--old-port' => $oldnode->port,
+		'--new-port' => $newnode->port,
+		'--initdb',
+	],
+	1,
+	[qr/could not find "initdb"/],
+	[qr/^$/],
+	'--initdb fails early when initdb is missing from the new bindir');
+
+# --initdb creates the new cluster, which --check (read-only) must not do, so
+# the combination is rejected during option parsing.
+command_checks_all(
+	[
+		'pg_upgrade', '--no-sync',
+		'--old-datadir' => $oldnode->data_dir,
+		'--new-datadir' => $newnode->data_dir . '_nonexistent',
+		'--old-bindir' => $oldbindir,
+		'--new-bindir' => $newbindir,
+		'--socketdir' => $newnode->host,
+		'--old-port' => $oldnode->port,
+		'--new-port' => $newnode->port,
+		'--initdb',
+		'--check',
+	],
+	1,
+	[qr/options -c\/--check and --initdb cannot be used together/],
+	[qr/^$/],
+	'--initdb and --check cannot be used together');
+
+# NOTE: unlike upstream's standalone --initdb, this (--wal-upgrade) tree does
+# NOT reject -O/--new-options with --initdb.  create_new_cluster_via_initdb()
+# derives initdb's options from the old cluster's control data and never
+# forwards -O to initdb; -O reaches only the new cluster's postmaster, which the
+# --wal-upgrade flow legitimately needs (e.g. -c allow_in_place_tablespaces=on).
+# So there is no -O/--initdb rejection to test here.
+
+done_testing();
diff --git a/src/bin/pg_upgrade/t/010_wal_upgrade.pl b/src/bin/pg_upgrade/t/010_wal_upgrade.pl
new file mode 100644
index 0000000000..cbd4c0f5e4
--- /dev/null
+++ b/src/bin/pg_upgrade/t/010_wal_upgrade.pl
@@ -0,0 +1,669 @@
+# Copyright (c) 2025-2026, PostgreSQL Global Development Group
+
+# Tests for "pg_upgrade --wal-upgrade" on a single (primary-only) cluster.
+#
+# --wal-upgrade captures the whole upgrade as WAL so the change is
+# revertable, then leaves the new cluster as a normal, fully-upgraded cluster
+# that comes up read-write on its first start like any upgraded cluster.  This
+# test drives the two primary-only paths and, at
+# each step, asserts the cluster's control-file STATE transitions the way the
+# state machine says it should and ends in the expected state:
+#
+#   happy path:  old (vN)  --pg_upgrade-->  new shut down cleanly, "in
+#                production" state, control checkpoint past the upgrade window
+#                --first start-->  auto-serves, not in recovery, data preserved,
+#                target major version.
+#
+#   crash path:  old (vN)  --pg_upgrade with COMPLETE suppressed-->  a window
+#                with START but no COMPLETE on disk  --first start-->  FATAL
+#                (never serves a half-built catalog); the old cluster stays
+#                intact and startable, and the dead-end new cluster is discarded
+#                with rm -rf (there is no revert interface).
+#
+# Cross-version: when $ENV{oldinstall} is set the old cluster is built with an
+# older major's binaries (checksums are pre-18 so pass -k there); otherwise the
+# test runs same-version, exercising the state machine without a real gap.
+#
+# ---------------------------------------------------------------------------
+# ASSUMPTIONS AND INVARIANTS --wal-upgrade relies on (why this works):
+#
+#  1. The window is self-contained and atomic.  pg_upgrade writes the whole
+#     upgrade as a WAL "window": an end-of-upgrade checkpoint (CN), then
+#     XLOG_UPGRADE_START, the system-relation/SLRU/rawfile/dirtree images, and
+#     XLOG_UPGRADE_COMPLETE.  Recovery anchors at CN and either reaches COMPLETE
+#     (fully upgraded) or it did not -- there is no half-upgraded steady state.
+#
+#  2. COMPLETE is the atomicity boundary.  A window with START but no COMPLETE
+#     (crash mid-upgrade) must NEVER be served: the catalog is half-built and
+#     the cluster auto-serves read-write at end of recovery, so serving it would
+#     expose a corrupt catalog.  First startup FATALs in that case.  The durable
+#     pg_control upgrade_finalized flag (paired with a control checkpoint past CN)
+#     distinguishes a finished upgrade from a crashed partial one even if a torn
+#     final WAL page hides the COMPLETE record.
+#
+#  3. The old cluster stays intact on the copy-family transfer modes.  With
+#     copy/clone/copy_file_range the old datadir's files are only read, so a
+#     failed or crashed upgrade can always fall back to starting the old cluster
+#     (the crash path below asserts this).  (link/swap deliberately give that up,
+#     as in stock pg_upgrade.)
+#
+#  4. Counters ride in the CN checkpoint.  The XID/OID/multixact counters are
+#     transplanted into pg_control before CN rather than WAL-logged separately,
+#     so the CN checkpoint record reproduces them on replay.
+
+use strict;
+use warnings FATAL => 'all';
+
+use File::Path qw(rmtree);
+use File::Copy qw(copy);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# The upgrade output files (delete_old_cluster.sh etc.) are written relative to
+# the current directory, so run pg_upgrade from a writable scratch dir.
+chdir ${PostgreSQL::Test::Utils::tmp_check};
+
+# Read the "Database cluster state" line out of pg_controldata for $datadir,
+# using $node's bindir so we read it with the matching-version tool.
+sub cluster_state
+{
+	my ($node, $datadir) = @_;
+	my $bindir = $node->config_data('--bindir');
+	my ($stdout, $stderr) =
+	  run_command([ "$bindir/pg_controldata", '-D', $datadir ]);
+	return $1 if $stdout =~ /Database cluster state:\s+(.*)/;
+	return '';
+}
+
+# True if pg_control records the --wal-upgrade window as finalized (the durable
+# flag that replaced the old pg_upgrade_complete.done marker file).
+sub upgrade_finalized
+{
+	my ($node, $datadir) = @_;
+	my $bindir = $node->config_data('--bindir');
+	my ($stdout, $stderr) =
+	  run_command([ "$bindir/pg_controldata", '-D', $datadir ]);
+	return ($stdout =~ /wal-upgrade window finalized:\s+yes/) ? 1 : 0;
+}
+
+# Build an old cluster and populate it with a little data whose survival we can
+# check after the upgrade.  Returns the $old node (stopped).
+sub setup_old
+{
+	my ($name) = @_;
+	my $old =
+	  PostgreSQL::Test::Cluster->new($name, install_path => $ENV{oldinstall});
+
+	# Checksums are enabled by default from v18 on, but not before, so pass
+	# '-k' on older installs so a checksum-on new cluster can be upgraded.
+	if (defined($ENV{oldinstall}))
+	{
+		$old->init(extra => ['-k']);
+	}
+	else
+	{
+		$old->init;
+	}
+
+	$old->start;
+	$old->safe_psql(
+		'postgres', qq{
+		CREATE TABLE t (id int primary key, note text);
+		INSERT INTO t SELECT g, 'row ' || g FROM generate_series(1, 500) g;
+		CREATE TABLE toasted (id int, big text);
+		INSERT INTO toasted
+		  SELECT g, repeat('abcdef0123456789', 2000) FROM generate_series(1, 200) g;
+		CREATE DATABASE extra_db;
+	});
+	$old->stop;
+	return $old;
+}
+
+# The arguments common to every pg_upgrade --wal-upgrade invocation here.
+sub upgrade_cmd
+{
+	my ($old, $new, @extra) = @_;
+	return [
+		'pg_upgrade', '--no-sync',
+		'--old-datadir' => $old->data_dir,
+		'--new-datadir' => $new->data_dir,
+		'--old-bindir' => $old->config_data('--bindir'),
+		'--new-bindir' => $new->config_data('--bindir'),
+		'--socketdir' => $new->host,
+		'--old-port' => $old->port,
+		'--new-port' => $new->port,
+		'--initdb',
+		'--wal-upgrade',
+		@extra,
+	];
+}
+
+# The framework's init() writes port/socket settings into postgresql.conf.  A
+# --initdb-created new cluster skipped init(), so append them before starting.
+sub add_conn_conf
+{
+	my ($new) = @_;
+	my $conf = $new->data_dir . '/postgresql.conf';
+	open(my $fh, '>>', $conf) or die "could not open $conf: $!";
+	print $fh "\n# added by test to start the --initdb-created cluster\n";
+	print $fh "port = " . $new->port . "\n";
+	print $fh "listen_addresses = ''\n";
+	print $fh "unix_socket_directories = '" . $new->host . "'\n";
+	close($fh);
+}
+
+#
+# 1. HAPPY PATH: upgrade auto-serves and preserves data.
+#
+{
+	my $old = setup_old('old_happy');
+	# Do NOT init() the new node: --wal-upgrade --initdb creates it.
+	my $new = PostgreSQL::Test::Cluster->new('new_happy');
+
+	ok(!-d $new->data_dir,
+		'happy: new data directory does not exist before --initdb');
+
+	command_ok(upgrade_cmd($old, $new),
+		'happy: pg_upgrade --wal-upgrade --initdb succeeds');
+
+	# pg_upgrade shut the new cluster down cleanly, so its control file records
+	# a shut-down / in-production state, not a recovery/upgrade state.
+	my $state = cluster_state($new, $new->data_dir);
+	like($state, qr/^(shut down|in production)$/,
+		"happy: new cluster control state is shut-down/in-production ($state)");
+
+	# pg_control records the window as finalized once it reached COMPLETE.
+	ok(upgrade_finalized($new, $new->data_dir),
+		'happy: pg_control upgrade_finalized set after a full upgrade');
+
+	# The new cluster comes up read-write on its first start, without re-replaying
+	# the window.
+	add_conn_conf($new);
+	$new->start;
+
+	is($new->safe_psql('postgres', 'SELECT pg_is_in_recovery()'),
+		'f', 'happy: upgraded cluster is not in recovery');
+
+	# Data survived and the extra database carried over.
+	is($new->safe_psql('postgres', 'SELECT count(*) FROM t'),
+		'500', 'happy: user table data preserved');
+	is($new->safe_psql('postgres', 'SELECT count(*) FROM toasted'),
+		'200', 'happy: toasted table data preserved');
+	is( $new->safe_psql(
+			'postgres',
+			"SELECT count(*) FROM pg_database WHERE datname = 'extra_db'"),
+		'1',
+		'happy: user database carried over');
+
+	# It reports the new major version.
+	my $newver = $new->safe_psql('postgres',
+		"SELECT current_setting('server_version_num')::int / 10000");
+	ok($newver >= 18, "happy: new cluster reports target major ($newver)");
+
+	# A restart comes up the same way (starting an upgraded cluster is idempotent).
+	$new->restart;
+	is($new->safe_psql('postgres', 'SELECT pg_is_in_recovery()'),
+		'f', 'happy: still serving after restart');
+	is($new->safe_psql('postgres', 'SELECT count(*) FROM t'),
+		'500', 'happy: data stable across restart');
+
+	# The durable pg_control upgrade_finalized flag persists across the restart.
+	# It lets a finalized cluster whose window tail is later torn or trimmed still
+	# be recognized as finalized rather than as a crashed mid-window upgrade.
+	ok(upgrade_finalized($new, $new->data_dir),
+		'happy: durable upgrade_finalized flag set after restart');
+
+	$new->stop;
+	$old->clean_node;
+	$new->clean_node;
+}
+
+#
+# 1b. LINK MODE: window is still schema-only, and rollback is impossible.
+#
+# --link hardlinks user relations into the new cluster instead of copying them,
+# and (as in stock pg_upgrade) disables the old cluster: global/pg_control is
+# renamed to pg_control.old before the transfer, and once the new cluster starts
+# the shared inodes make the old cluster unsafe to run.  Two things must hold:
+#
+#   (a) No user relation PAGES are WAL-logged.  Regardless of transfer mode the
+#       window carries only pg_upgrade-rewritten SYSTEM files (as RELFILE_DATA
+#       full-page images) plus the RELINK manifest for user relations (identities
+#       only, no data); user data never rides the WAL.  We scan the whole window
+#       with pg_waldump and assert the RELINK manifest is present and every
+#       UPGRADE_RELFILE_DATA record reports "0 user" entries.
+#
+#   (b) Rollback to the old cluster is impossible: pg_control is gone (renamed to
+#       .old), so the old datadir will not start.
+#
+{
+	my $old = setup_old('old_link');
+	my $new = PostgreSQL::Test::Cluster->new('new_link');
+
+	# This subtest inspects the WINDOW's contents in the new cluster's pg_wal,
+	# so the window must be retained there.  pg_upgrade retains it only when a
+	# physical slot is migrated (a standby is expected); with neither a slot nor
+	# archiving the window is unused and recycled.  So give the old cluster a
+	# physical slot, which pg_upgrade migrates and uses to pin the window.
+	# (setup_old inits with wal_level=minimal; a slot needs >= replica.)
+	$old->append_conf('postgresql.conf', "wal_level = replica\n");
+	$old->start;
+	$old->safe_psql('postgres',
+		"SELECT pg_create_physical_replication_slot('link_stby_slot', true)");
+	$old->stop;
+
+	# Fingerprint + control state of the intact old cluster, for contrast.
+	ok(-f $old->data_dir . '/global/pg_control',
+		'link: old cluster has pg_control before the upgrade');
+
+	command_ok(upgrade_cmd($old, $new, '--link'),
+		'link: pg_upgrade --wal-upgrade --initdb --link succeeds');
+
+	# (a) Scan the whole window in the new cluster's pg_wal.  User relations must
+	# be represented ONLY by the RELINK manifest (identities, no data); their
+	# pages must never be WAL-logged.  System relations DO travel, as one batched
+	# UPGRADE_RELFILE_DATA record of full-page images (the desc reports how many
+	# of its entries are user relations -- which must be 0).
+	#
+	# That RELFILE_DATA record is large (the whole system catalog) and can span a
+	# segment boundary, so we must dump the WHOLE pg_wal directory in one
+	# invocation (pg_waldump -p follows records across segments); a per-segment
+	# scan would miss a record that starts in one segment and ends in the next.
+	# pg_waldump errors at the end of available WAL -- that is expected, so read
+	# both stdout and stderr and parse whatever records it emitted.
+	my $bindir = $new->config_data('--bindir');
+	my $waldir = $new->data_dir . '/pg_wal';
+	# pg_waldump -p needs an explicit start LSN.  Use the start of the lowest
+	# segment present so it dumps everything (following records across segment
+	# boundaries) to the end of available WAL.
+	opendir(my $wd, $waldir) or die "opendir $waldir: $!";
+	my @segs = sort grep { /^[0-9A-F]{24}$/ } readdir $wd;
+	closedir $wd;
+	# Segment file is TTTTTTTT LLLLLLLL SSSSSSSS (timeline, log, seg); the byte
+	# offset of its first record is (log, seg*WalSegSz) i.e. LSN "log/seg000000".
+	my ($loghex, $seghex) = $segs[0] =~ /^[0-9A-F]{8}([0-9A-F]{8})([0-9A-F]{8})$/;
+	my $start = sprintf('%X/%s000028', hex($loghex), substr($seghex, 6, 2));
+	my ($stdout, $stderr);
+	IPC::Run::run [ "$bindir/pg_waldump", '-p', $waldir, '-s', $start ], '>',
+	  \$stdout, '2>', \$stderr;
+	my $dump = $stdout . $stderr;
+	my $saw_relink = ($dump =~ /UPGRADE_RELINK/);
+	my $saw_relfile = ($dump =~ /UPGRADE_RELFILE_DATA/);
+	my $saw_user_relfile = 0;
+	while ($dump =~ /UPGRADE_RELFILE_DATA\s+\d+ files \((\d+) user\)/g)
+	{
+		$saw_user_relfile = 1 if $1 > 0;
+	}
+	ok($saw_relink, 'link: window carries the RELINK manifest for user relations');
+	ok($saw_relfile,
+		'link: window carries RELFILE_DATA (system relation full-page images)');
+	is($saw_user_relfile, 0,
+		'link: no user relation pages are WAL-logged (window is schema-only)');
+
+	# (b) The old cluster is disabled: pg_control renamed to .old.
+	ok(!-f $old->data_dir . '/global/pg_control',
+		'link: old cluster pg_control removed (renamed to .old)');
+	ok(-f $old->data_dir . '/global/pg_control.old',
+		'link: old cluster pg_control saved as .old');
+
+	# Bring the new cluster up (this is what makes the old cluster unsafe); the
+	# old datadir then can no longer be started.
+	add_conn_conf($new);
+	$new->start;
+	is($new->safe_psql('postgres', 'SELECT count(*) FROM t'),
+		'500', 'link: user data present on the new cluster (hardlinked in)');
+
+	my $oldbin = $old->config_data('--bindir');
+	my $ret = run_log(
+		[ "$oldbin/pg_ctl", '-D', $old->data_dir, '-w', '-t', '10', 'start' ]);
+	ok(!$ret, 'link: old cluster refuses to start (rollback impossible)');
+	# If it somehow started, stop it so it does not leak into later tests.
+	run_log([ "$oldbin/pg_ctl", '-D', $old->data_dir, 'stop' ]) if $ret;
+
+	$new->stop;
+	$old->clean_node;
+	$new->clean_node;
+}
+
+#
+# 2. CRASH PATH: a window with START but no COMPLETE must be refused.
+#
+# PG_UPGRADE_TEST_SKIP_COMPLETE (a test-only fault-injection hook) makes
+# pg_upgrade emit the whole upgrade window but omit the COMPLETE marker,
+# simulating a crash after START.  The new cluster must FATAL on first start
+# (never serve a half-built catalog) and the old cluster must stay intact.  The
+# dead-end new cluster carries no state worth keeping (there is no revert
+# interface), so it is simply discarded with rm -rf.
+#
+# The hook is gated on USE_ASSERT_CHECKING (a production build ignores the env
+# var and can never emit an incomplete window), so this section only runs on a
+# cassert build.
+#
+SKIP:
+{
+	skip 'crash-path fault injection requires a cassert build', 6
+	  unless check_pg_config(qr/^#define USE_ASSERT_CHECKING 1/);
+
+	my $old = setup_old('old_crash');
+
+	# Fingerprint the old cluster so the post-crash check can confirm it is
+	# undamaged.
+	$old->start;
+	my $old_fp =
+	  $old->safe_psql('postgres', 'SELECT count(*), sum(id) FROM t');
+	$old->stop;
+
+	my $new = PostgreSQL::Test::Cluster->new('new_crash');
+
+	# Upgrade with COMPLETE suppressed.  pg_upgrade itself still succeeds; the
+	# missing COMPLETE only bites at the new cluster's first start.
+	{
+		local $ENV{PG_UPGRADE_TEST_SKIP_COMPLETE} = '1';
+		command_ok(upgrade_cmd($old, $new),
+			'crash: pg_upgrade succeeds even with COMPLETE suppressed');
+	}
+
+	# A partial window never sets the finalized flag; that absence is what marks
+	# the cluster as not finalized.
+	ok(!upgrade_finalized($new, $new->data_dir),
+		'crash: upgrade_finalized flag unset on a partial window');
+
+	# First start must FAIL (fail_ok so we can assert on it instead of bailing).
+	add_conn_conf($new);
+	my $started = $new->start(fail_ok => 1);
+	is($started, 0, 'crash: new cluster refuses to start on a partial window');
+
+	# And it must FATAL specifically because the window is incomplete -- not
+	# for some unrelated reason.
+	my $log = slurp_file($new->logfile);
+	like(
+		$log,
+		qr/pg_upgrade WAL is incomplete|found START without COMPLETE/,
+		'crash: startup FATALed with the "incomplete window" message');
+
+	# The old cluster is untouched: it starts and its data is identical.
+	$old->start;
+	is($old->safe_psql('postgres', 'SELECT count(*), sum(id) FROM t'),
+		$old_fp, 'crash: old cluster intact and startable after the failed upgrade');
+	$old->stop;
+
+	# The dead-end new cluster is simply discarded (rm -rf); there is no revert
+	# interface, and the old cluster is the source of truth.
+	rmtree($new->data_dir);
+	ok(!-d $new->data_dir,
+		'crash: half-upgraded new data directory discarded');
+
+	$old->clean_node;
+	$new->clean_node;
+}
+
+#
+# 3. BACKUP-GAP / PITR PATH: recover the transactions executed after the
+#    upgrade went live from a pre-upgrade base backup + archived WAL, ACROSS
+#    the upgrade boundary, WITHOUT a new base backup and WITHOUT a standby.
+#
+# The operator runs a single primary with a periodic base backup and continuous
+# WAL archiving.  The upgrade procedure is:
+#
+#   1. stop the old server
+#   2. pg_upgrade --wal-upgrade
+#   3. start the new server
+#   4. the application connects and executes transactions   <-- must survive
+#   5. take a new base backup
+#
+# If the storage is lost after step 4 but before step 5, upstream pg_upgrade
+# loses the step-4 transactions: the pre-upgrade base backup cannot be rolled
+# forward across the version boundary, and no post-upgrade base backup exists
+# yet.  On a large database step 5 can take a long time, so that gap is wide.
+#
+# With --wal-upgrade the upgrade itself is WAL, so the claim is: restore the
+# last pre-upgrade base backup, replay archived WAL up to the upgrade, replay
+# the upgrade window, then keep replaying the WAL generated on the new version
+# -- recovering the step-4 transactions with no new base backup.  This test
+# encodes that workflow end to end.
+{
+	# A single archive both the old and the new cluster feed, and that the
+	# PITR restore reads back -- one continuous WAL history on disk.
+	my $archive = "${PostgreSQL::Test::Utils::tmp_check}/pitr_archive";
+	rmtree($archive);
+	mkdir($archive) or die "could not create $archive: $!";
+	# Archive atomically (copy to a temp name, then rename) so an immediate
+	# stop that kills the archiver mid-copy can never leave a truncated segment
+	# in the archive for the PITR restore to trip over.
+	my $arch_cmd =
+	  "cp \"%p\" \"$archive/%f.tmp\" && mv \"$archive/%f.tmp\" \"$archive/%f\"";
+	my $restore_cmd = "cp \"$archive/%f\" \"%p\"";
+
+	# Old cluster with WAL archiving on.
+	my $old =
+	  PostgreSQL::Test::Cluster->new('old_pitr', install_path => $ENV{oldinstall});
+	if (defined($ENV{oldinstall}))
+	{
+		$old->init(extra => ['-k'], allows_streaming => 1);
+	}
+	else
+	{
+		$old->init(allows_streaming => 1);
+	}
+	$old->append_conf('postgresql.conf',
+		"archive_mode = on\narchive_command = '$arch_cmd'\n");
+	$old->start;
+
+	# Pre-upgrade data.
+	$old->safe_psql(
+		'postgres', qq{
+		CREATE TABLE t (id int primary key, note text);
+		INSERT INTO t SELECT g, 'pre ' || g FROM generate_series(1, 500) g;
+	});
+
+	# Step 0: the periodic pre-upgrade base backup the operator already has.
+	$old->backup('pitr_base');
+
+	# Capture the old cluster's final WAL position.  Phase 1 of recovery stops
+	# here: at the boundary, past all pre-upgrade transactions, before the
+	# upgrade window (which lives at a later, non-contiguous segment).
+	my $old_end_lsn =
+	  $old->safe_psql('postgres', 'SELECT pg_current_wal_lsn()');
+
+	$old->stop;    # step 1
+
+	# Step 2: pg_upgrade --wal-upgrade --initdb creates the new cluster.
+	my $new = PostgreSQL::Test::Cluster->new('new_pitr');
+	command_ok(upgrade_cmd($old, $new),
+		'pitr: pg_upgrade --wal-upgrade --initdb succeeds');
+
+	# The upgraded cluster inherits the old cluster's archive_command
+	# automatically (pg_upgrade --wal-upgrade carries it forward), so the upgrade
+	# window and the post-upgrade WAL flow to the SAME archive with no extra
+	# option.  Verify that landed in the new cluster's config.
+	add_conn_conf($new);
+	my $newconf = slurp_file($new->data_dir . '/postgresql.conf');
+	like($newconf, qr/archive_mode\s*=\s*on/,
+		'pitr: upgraded cluster inherited archive_mode');
+
+	# The upgrade window (CN..COMPLETE) must have reached the archive; the burst
+	# server's wait-for-archive barrier guarantees this.  Confirm the window
+	# segments are present by scanning the archive for the START/COMPLETE records.
+	my $bindir = $new->config_data('--bindir');
+	my ($start_seg, $complete_seg) = ('', '');
+	opendir(my $ad, $archive) or die "opendir $archive: $!";
+	for my $f (sort grep { /^[0-9A-F]{24}$/ } readdir $ad)
+	{
+		my ($out) = run_command([ "$bindir/pg_waldump", "$archive/$f" ]);
+		$start_seg = $f if $out =~ /PG_UPGRADE_START/;
+		$complete_seg = $f if $out =~ /PG_UPGRADE_COMPLETE/;
+	}
+	closedir $ad;
+	ok($start_seg ne '' && $complete_seg ne '',
+		"pitr: upgrade window archived (START in $start_seg, COMPLETE in $complete_seg)");
+
+	# Step 3: start the upgraded cluster (auto-serves), archiving its tail.
+	$new->start;
+
+	# On the backup/PITR path the window's durable home is the archive, so no
+	# retention slot is created at all (the wait-for-archive barrier guarantees
+	# the window is archived before shutdown; a slot would only pin pg_wal/
+	# forever with no standby to consume it).  Confirm no physical slot exists.
+	is($new->safe_psql('postgres',
+			"SELECT count(*) FROM pg_replication_slots WHERE slot_type = 'physical'"),
+		'0', 'pitr: no retention slot on the archive path');
+
+	# Step 4: the application executes transactions on the new version.  These
+	# are the ones the backup gap loses upstream.
+	$new->safe_psql('postgres',
+		"INSERT INTO t SELECT g, 'post ' || g FROM generate_series(501, 800) g;"
+	);
+	$new->safe_psql('postgres',
+		'CREATE TABLE only_on_new (x int); INSERT INTO only_on_new VALUES (42);');
+
+	# Push the step-4 WAL out to the archive (switch + checkpoint + switch), the
+	# way continuous archiving would before the operator got to step 5.
+	$new->safe_psql('postgres',
+		'SELECT pg_switch_wal(); CHECKPOINT; SELECT pg_switch_wal();');
+	# Give the archiver a moment to drain the tail.
+	$new->poll_query_until('postgres',
+		"SELECT last_archived_wal IS NOT NULL FROM pg_stat_archiver");
+
+	# DISASTER: storage lost after step 4, before the step-5 base backup.  The
+	# new cluster's data dir (and its local pg_wal upgrade window) are gone; only
+	# the pre-upgrade base backup and the archive survive off host.
+	$new->stop('immediate');
+	my $new_datadir = $new->data_dir;
+	rmtree($new_datadir);
+	ok(!-d $new_datadir, 'pitr: upgraded cluster storage lost before new backup');
+
+	# ---- RECOVERY: two-phase PITR across the upgrade boundary ----
+	#
+	# Phase 1 (OLD binary): restore the pre-upgrade base backup and replay
+	# archived OLD WAL up to the upgrade boundary, stopping there WITHOUT
+	# promoting (a promotion would fork a new timeline and clobber the state
+	# Phase 2 needs).  This recovers every pre-upgrade transaction, including
+	# those written after the base backup.
+	# Same-version test: old and new binaries are identical, so one node drives
+	# both phases.  For a real cross-version run ($ENV{oldinstall} set), Phase 1
+	# must use the OLD binary (to read the old-version restored backup) and Phase
+	# 2 the NEW binary (to replay the window).  The restore node is a NEW-version
+	# node so its start()/psql use the new binaries; Phase 1 is driven with the
+	# OLD bindir explicitly via raw pg_ctl.
+	my $old_bindir = $old->config_data('--bindir');
+	my $restore = PostgreSQL::Test::Cluster->new('restore_pitr');
+	$restore->init_from_backup($old, 'pitr_base');
+
+	# The boundary is the old cluster's final WAL position, captured above.
+	# Stopping there keeps Phase 1 on timeline 1, past every pre-upgrade
+	# transaction, without entering the (non-contiguous) upgrade window.
+	$restore->append_conf('postgresql.conf',
+		"restore_command = '$restore_cmd'\n"
+		  . "recovery_target_lsn = '$old_end_lsn'\n"
+		  . "recovery_target_inclusive = on\n"
+		  . "recovery_target_action = 'shutdown'\n");
+	open(my $s1, '>', $restore->data_dir . '/recovery.signal') or die $!;
+	close($s1);
+
+	# Phase 1 runs the OLD binary and stops itself at the recovery target
+	# (recovery_target_action=shutdown).  Drive it with a raw pg_ctl rather than
+	# $node->start, because the node framework expects start() to leave a running
+	# server with a live postmaster.pid; a self-terminating recovery would leave
+	# its pid bookkeeping inconsistent and bail the whole test.
+	# pg_ctl --wait returns non-zero when the server exits instead of staying up
+	# (which is exactly what recovery_target_action=shutdown does), so ignore the
+	# exit code and assert on the log instead.
+	my $p1log = "${PostgreSQL::Test::Utils::tmp_check}/pitr_phase1.log";
+	PostgreSQL::Test::Utils::system_log(
+		"$old_bindir/pg_ctl", '--wait',
+		'--pgdata' => $restore->data_dir,
+		'--log' => $p1log,
+		'--options' => "--cluster-name=restore_pitr_p1",
+		'start');
+	like(slurp_file($p1log), qr/shutdown at recovery target/,
+		'pitr phase 1: old binary stopped at the upgrade boundary (no promote)');
+
+	# Phase 2 (NEW binary): stage the window segments (CN..COMPLETE) into pg_wal/
+	# so the new binary's local scan re-anchors recovery at CN, then replay the
+	# window + the archived post-upgrade tail.  No recovery_target this time.
+	#
+	# This copy mirrors a real operator's recovery step, not a test shortcut: on
+	# the archive-PITR path the window-detection scan (PerformWalUpgradeIfNeeded)
+	# reads the local pg_wal/ before archive recovery is wired up, so the window
+	# segments must physically be present there when the new binary starts -- they
+	# cannot yet be pulled from the archive via restore_command at that point (the
+	# post-upgrade tail, which IS fetched by restore_command, still is).  Fetching
+	# the window from the archive too is future work (move upgrade detection after
+	# archive-recovery init).  The streaming-standby path (011) needs no such
+	# staging: it receives the window over replication.
+	my $rwal = $restore->data_dir . '/pg_wal';
+	opendir(my $ad2, $archive) or die $!;
+	for my $f (sort grep { /^[0-9A-F]{24}$/ } readdir $ad2)
+	{
+		next if $f lt $start_seg or $f gt $complete_seg;
+		copy("$archive/$f", "$rwal/$f") or die "stage $f: $!";
+	}
+	closedir $ad2;
+
+	# Rewrite recovery config: new binary, restore_command for the tail, follow
+	# to the latest timeline, and crucially NO recovery_target (a stale target
+	# before CN would abort as "stop point before consistent recovery point").
+	my $conf = $restore->data_dir . '/postgresql.conf';
+	# Strip the phase-1 recovery_target_* lines, keep base settings.
+	my $txt = slurp_file($conf);
+	$txt =~ s/^recovery_target.*\n//mg;
+	open(my $cw, '>', $conf) or die $!;
+	print $cw $txt;
+	print $cw "recovery_target_timeline = 'latest'\n";
+	# A PITR/standby replay requires the recovery node's resource GUCs to be at
+	# least the primary's (a PARAMETER_CHANGE record in the window carries the
+	# primary's values, e.g. max_connections=100).  The no-initdb synthesized
+	# pg_control comes up with this binary's defaults (max_connections=10), which
+	# are lower, so recovery would abort ("insufficient parameter settings").
+	# Match the primary's values, exactly as an operator provisioning a restore
+	# node must.
+	print $cw "max_connections = 100\n";
+	print $cw "max_worker_processes = 8\n";
+	print $cw "max_wal_senders = 10\n";
+	print $cw "max_prepared_transactions = 0\n";
+	print $cw "max_locks_per_transaction = 128\n";
+	close($cw);
+	open(my $s2, '>', $restore->data_dir . '/recovery.signal') or die $!;
+	close($s2);
+
+	# Cross-version: Phase 1 left an OLD-version pg_control that this NEW binary
+	# would reject at the version gate.  Drop the pg_upgrade.signal sentinel next
+	# to the staged window so the new binary, seeing recovery.signal + this
+	# sentinel in checkDataDir(), synthesizes a new-version pg_control before the
+	# gate.  (recovery.signal, not primary_conninfo, so this is the archive path.)
+	# Same-version needs no synthesis and is unaffected (control file already
+	# matches), but the sentinel is harmless there.
+	open(my $u2, '>', $restore->data_dir . '/pg_upgrade.signal') or die $!;
+	close($u2);
+
+	my $p2 = $restore->start(fail_ok => 1);
+	is($p2, 1, 'pitr phase 2: new binary replays the window + tail and comes up');
+
+  SKIP:
+	{
+		skip 'phase 2 did not start', 3 if $p2 != 1;
+
+		# The server accepts read-only connections at consistency, but recovery
+		# is still replaying the window + tail.  Wait for recovery to finish
+		# (promotion) before checking data, else we observe a mid-replay state.
+		$restore->poll_query_until('postgres', 'SELECT NOT pg_is_in_recovery()')
+		  or die 'phase 2 recovery did not finish';
+
+		# The step-4 transactions survived without a new base backup.
+		is($restore->safe_psql('postgres', 'SELECT count(*) FROM t'),
+			'800', 'pitr: pre- and post-upgrade rows both recovered');
+		is( $restore->safe_psql(
+				'postgres', "SELECT to_regclass('only_on_new') IS NOT NULL"),
+			't', 'pitr: post-upgrade DDL recovered');
+		is($restore->safe_psql('postgres', 'SELECT x FROM only_on_new'),
+			'42', 'pitr: post-upgrade row recovered');
+
+		$restore->stop;
+	}
+
+	$old->clean_node;
+	$restore->clean_node;
+}
+
+done_testing();
diff --git a/src/bin/pg_upgrade/t/011_wal_upgrade_standby.pl b/src/bin/pg_upgrade/t/011_wal_upgrade_standby.pl
new file mode 100644
index 0000000000..99d82add43
--- /dev/null
+++ b/src/bin/pg_upgrade/t/011_wal_upgrade_standby.pl
@@ -0,0 +1,753 @@
+# Copyright (c) 2025-2026, PostgreSQL Global Development Group
+
+# Tests for "pg_upgrade --wal-upgrade" delivering the upgrade to a physical
+# standby by STREAMING the upgrade window from the live, upgraded primary.
+#
+# Under --wal-upgrade the whole upgrade is captured as WAL (the CN..COMPLETE
+# "window"), and a migrated physical replication slot pins that window in the
+# primary's pg_wal/ so it survives the upgrade and stays streamable.  A new-version
+# skeleton that points primary_conninfo at the upgraded primary DERIVES the window
+# anchor (CN) LOCALLY from its retained old data directory (reproducing
+# pg_resetwal's byte-contiguous placement), taking only the system identifier from
+# the primary's standard IDENTIFY_SYSTEM, arms its control file at CN, and streams
+# the window forward -- becoming a hot standby that serves the upgraded data.  No
+# operator "prepare" step, no hand-copied WAL, and no bespoke replication command
+# are required.
+#
+# ---------------------------------------------------------------------------
+# ASSUMPTIONS AND INVARIANTS the standby path relies on (why this works):
+#
+#  1. Relfilenode/OID identity (holds for ALL transfer modes).  pg_upgrade
+#     preserves every user relation's relfilenumber and its database/tablespace
+#     OIDs; the transfer step uses that same number for the file on both sides
+#     and the mode (copy/clone/copy_file_range/link/swap) changes only HOW the
+#     file is moved, never its name.  So a user relation has the SAME on-disk
+#     path in the old cluster and the upgraded new cluster.  This identity is
+#     what lets the standby place a user file at the identical relative path
+#     from the old datadir -- no catalog lookup -- however the mode places it
+#     (copy/clone/copy_file_range/link/swap; see invariant 3).
+#
+#  2. Schema-only window.  The streamed window carries only what pg_upgrade
+#     rewrote: system catalogs, SLRU, the directory skeleton, pg_filenode.map /
+#     PG_VERSION -- NOT user relations (which pg_upgrade transfers unchanged).
+#     The window is therefore schema-sized, not data-sized.  Instead the window
+#     includes an XLOG_UPGRADE_RELINK manifest naming exactly the user files it
+#     omitted (emitted from the same capture walk, so the two cannot drift).
+#
+#  3. User relations come from the standby's OWN retained old datadir.  A
+#     streaming standby is a new-version skeleton -- either a fresh initdb or a
+#     bare data dir whose new-version pg_control/PG_VERSION are synthesized at
+#     startup (SynthesizeUpgradeStreamControlFile; initdb is optional) -- plus an
+#     empty "pg_upgrade.signal" sentinel; its retained pre-upgrade data directory
+#     is named by the pg_upgrade_standby_old_datadir GUC in postgresql.conf.  Redo of the
+#     RELINK manifest processes it one relation-file segment at a time, placing
+#     each from the old datadir at the identical relative path in the skeleton.
+#     By default (pg_upgrade_standby_transfer_mode = mirror) it reproduces the primary's
+#     transfer mode, but the standby MAY choose a different mode via that GUC:
+#       - copy: an independent whole-file copy (copy_file()); 2x space.
+#       - clone: a reflink / copy-on-write clone (copyfile(COPYFILE_CLONE_FORCE)
+#         on macOS, ioctl(FICLONE) on Linux) -- near-zero extra space, exactly as
+#         pg_upgrade --clone does on the primary.
+#       - copy_file_range: copy_file_range(), a server-side/reflink copy.
+#         The redo reproduces each mode's RESULT with the same primitive
+#         pg_upgrade uses; if a reflink mode's filesystem can't reflink, redo
+#         FATALs rather than silently fall back to a 2x copy.  In all three the
+#         old file is only read, so the old datadir stays a bootable rollback
+#         target.
+#       - link: a per-file hardlink (link()), sharing the old inode.
+#       - swap: a rename(), MOVING the file out of the old datadir into the
+#         skeleton -- upstream --swap renames whole database directories, and a
+#         per-file rename here reproduces that (no entry left in the old datadir,
+#         unlike --link).
+#         Both link and swap are fast and space-free and intentionally sacrifice
+#         the old datadir -- the tradeoff the operator accepted by choosing
+#         --link/--swap on the primary (which disables its own old cluster).
+#     The primary can't know the standby's old-datadir path, so it is supplied
+#     locally via pg_upgrade_standby_old_datadir; nothing new is streamed for it.
+#
+#  4. Recovery anchors at CN.  Recovery starts at the end-of-upgrade checkpoint
+#     (CN); the XID/OID/multixact counters were transplanted into pg_control
+#     before CN, so the CN checkpoint record carries them.  The skeleton fetches
+#     CN (sysid + LSN + redo + TLI) from the primary over the replication
+#     connection and arms its control file at CN before replay.
+#
+#  5. WAL page formats differ across majors.  The new-format window is
+#     unreadable by an old-version standby; conversely a fresh skeleton must be
+#     the NEW (in-tree) version.  This is why an existing old standby cannot just
+#     follow the upgrade: it pauses at the handoff (below) -- staying up to serve
+#     reads -- and is replaced by a new-version skeleton that streams the window.
+#
+# This test drives that path and, at each step, asserts the state transitions
+# land where the state machine says they should:
+#
+#   handoff:   an EXISTING old-version standby streaming from the old primary,
+#              on --wal-upgrade-signal-handoff, replays XLOG_UPGRADE_HANDOFF and
+#              PAUSES recovery at the boundary (did not promote or shut down),
+#              staying up to serve read-only queries while a new-version skeleton
+#              is provisioned.  It is then stopped and its retained datadir
+#              becomes the relink source for the skeleton below.  Same-version
+#              only: the handoff record exists solely in the patched binary.
+#   abort:     the handoff pause is reversible -- if the upgrade is abandoned, the
+#              old primary is restarted read-write and the standby resumes replay
+#              past the (no-op) handoff record and keeps following it.
+#   primary:   old (vN)  --pg_upgrade--> auto-served new primary (not in
+#              recovery), retention slot present, window pinned in pg_wal/.
+#   standby:   a fresh new-version skeleton + primary_conninfo +
+#              pg_upgrade_standby_old_datadir + an empty pg_upgrade.signal sentinel
+#              --start--> auto-armed from the primary, STREAMED the window, and
+#              the RELINK redo copied the user relations in; came up in recovery
+#              (pg_is_in_recovery = t), converged to the primary's data, and the
+#              copies are inode-independent (old datadir left intact).
+#   no-initdb: the same, but from a BARE data dir (config + sentinel only, never
+#              initdb'd) -- checkDataDir() synthesizes pg_control + PG_VERSION from
+#              this binary's constants, so no initdb is required to stream.
+#   negative:  streaming the window into a skeleton with pg_upgrade_standby_old_datadir
+#              unset leaves the user relations ABSENT -- the window alone does not
+#              reconstruct user data, proving the relink step is load-bearing.
+#              And a fresh cluster with no primary and no window is just an
+#              ordinary empty cluster (no silent alternative delivery path).
+#
+# Cross-version: when $ENV{oldinstall} is set the old primary is built with an
+# older major's binaries; otherwise the test runs same-version.  Either way the
+# STANDBY skeleton is always the new (in-tree) version, since streaming a window
+# into an older-version standby is not the supported direction.
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# pg_upgrade writes output files relative to the current directory.
+chdir ${PostgreSQL::Test::Utils::tmp_check};
+
+# Config that lets a node act as a streaming-replication primary.
+# Use values >= what PostgreSQL::Test::Cluster's allows_streaming default sets
+# (max_wal_senders = 10), so a hot standby replaying this primary's WAL does not
+# abort with "insufficient parameter settings".
+my $primary_conf = q{
+wal_level = replica
+max_wal_senders = 10
+max_replication_slots = 10
+hot_standby = on
+};
+
+#
+# 1. Old primary with data -> upgrade -> auto-served new primary.
+#
+my $old =
+  PostgreSQL::Test::Cluster->new('old', install_path => $ENV{oldinstall});
+if (defined($ENV{oldinstall}))
+{
+	# Checksums default on from v18; pass -k on older installs so a checksum-on
+	# new cluster can be upgraded.
+	$old->init(allows_streaming => 1, extra => ['-k']);
+}
+else
+{
+	$old->init(allows_streaming => 1);
+}
+$old->append_conf('postgresql.conf', $primary_conf);
+$old->start;
+$old->safe_psql(
+	'postgres', qq{
+	CREATE TABLE t (id int primary key, v text);
+	INSERT INTO t SELECT g, 'v' || g FROM generate_series(1, 2000) g;
+	CREATE INDEX ON t (v);
+	CREATE TABLE toasted (id int, big text);
+	INSERT INTO toasted
+	  SELECT g, repeat('abcdef0123456789', 3000) FROM generate_series(1, 300) g;
+	-- Large objects live in pg_largeobject[_metadata], which pg_upgrade
+	-- transfers verbatim as user data.  Under --wal-upgrade those catalogs are
+	-- excluded from the window and named in the RELINK manifest instead, so the
+	-- standby links them from its own retained old datadir like any user
+	-- relation; seed some to exercise that path.
+	SELECT lo_from_bytea(0, decode(repeat(md5(g::text), 50), 'hex'))
+	  FROM generate_series(1, 40) g;
+});
+# Fold the large-object content into the fingerprint so a mis-delivered
+# pg_largeobject on the standby (from WAL vs. relink) is caught by convergence.
+my $fp_query = q{SELECT count(*), sum(hashtext(v)::bigint),
+	(SELECT count(*) || ':' || coalesce(sum(length(data))::text, '0')
+	   FROM pg_largeobject) FROM t};
+my $want = $old->safe_psql('postgres', $fp_query);
+
+# Create a NAMED physical replication slot on the old primary.  Under
+# --wal-upgrade pg_upgrade migrates physical slots (stock pg_upgrade migrates
+# only logical ones), so this slot must reappear on the upgraded primary under
+# the SAME name -- preserving the standby's slot identity across the upgrade.
+# (Only exercised same-version; a stock old major has no --wal-upgrade
+# machinery, but the slot itself is ordinary and would migrate the same way.)
+my $migrated_slot = 'my_standby_slot';
+if (!defined($ENV{oldinstall}))
+{
+	$old->safe_psql('postgres',
+		"SELECT pg_create_physical_replication_slot('$migrated_slot', true)");
+}
+
+#
+# 1a. Handoff: an EXISTING old-version standby pauses at the upgrade point.
+#
+# --wal-upgrade-signal-handoff writes an XLOG_UPGRADE_HANDOFF record into the
+# LIVE old primary's WAL and fast-stops the primary at that point.  A streaming
+# old standby replays the record and PAUSES recovery at the boundary (it does not
+# shut down or promote): it cannot follow the upgrade in the old WAL format, so it
+# stays up serving read-only queries while a fresh new-version standby (section 2)
+# is provisioned to take over by streaming the window.  The pause is reversible
+# (see section 2b for the aborted-upgrade case).
+#
+# The handoff record (emitted by the old primary's own ShutdownXLOG) exists
+# only in this patched binary, so this can only be exercised same-version; a
+# stock old major cannot emit it.  Skip it (but still stop the primary) when
+# running cross-version.
+# The paused old standby is then stopped, and its retained datadir becomes the
+# RELINK SOURCE for a fresh new-version skeleton in section 2 (it is never
+# adopted/started in place -- the skeleton is a separate initdb'd cluster that
+# links user relations from it), so declare it at outer scope (only populated on
+# the same-version path, where the handoff can run).
+my $oldsby;
+
+if (!defined($ENV{oldinstall}))
+{
+	# A real old-version standby streaming from the live old primary.
+	$old->backup('handoff_base');
+	$oldsby = PostgreSQL::Test::Cluster->new('old_standby');
+	$oldsby->init_from_backup($old, 'handoff_base', has_streaming => 1);
+	$oldsby->append_conf('postgresql.conf', $primary_conf);
+	$oldsby->start;
+
+	# It is a hot standby that has converged to the primary's data.
+	$old->wait_for_catchup($oldsby, 'replay', $old->lsn('insert'));
+	is($oldsby->safe_psql('postgres', 'SELECT pg_is_in_recovery()'),
+		't', 'handoff: old standby is a hot standby before the handoff');
+	is($oldsby->safe_psql('postgres', 'SELECT count(*) FROM t'),
+		'2000', 'handoff: old standby has the pre-upgrade data');
+
+	# Record the current end of the standby's log so wait_for_log() below only
+	# matches the handoff message emitted from this point on.
+	my $logstart = -s $oldsby->logfile;
+
+	# Signal the handoff: writes the trigger into the running old primary's WAL
+	# and fast-stops the primary at that point.
+	command_ok(
+		[
+			'pg_upgrade', '--no-sync',
+			'--wal-upgrade-signal-handoff',
+			'--old-datadir' => $old->data_dir,
+			'--old-bindir' => $old->config_data('--bindir'),
+			'--socketdir' => $old->host,
+			'--old-port' => $old->port,
+		],
+		'handoff: signal-handoff writes the trigger and stops the old primary');
+
+	# pg_upgrade shut the old primary down itself; sync the framework's state.
+	$old->_update_pid(0);
+
+	# The old standby replays the handoff and PAUSES recovery at the boundary
+	# (rather than shutting down): it cannot follow the upgrade in the old WAL
+	# format, but it stays up serving read-only queries while a fresh
+	# new-version skeleton is provisioned.  Wait for the pause message.
+	$oldsby->wait_for_log(
+		qr/reached pg_upgrade handoff on standby; pausing recovery at the upgrade boundary/,
+		$logstart);
+	ok(1, 'handoff: old standby replayed the handoff and paused at the boundary');
+
+	# It paused, it did not shut down or promote: the postmaster is still up and
+	# still in recovery, and it still answers read-only queries at the
+	# pre-upgrade snapshot.  This is the read-only-availability guarantee: the
+	# standby keeps serving through the upgrade instead of going dark.
+	ok(-f $oldsby->data_dir . '/postmaster.pid',
+		'handoff: old standby is still running after the handoff (paused, not stopped)');
+	is($oldsby->safe_psql('postgres', 'SELECT pg_is_in_recovery()'),
+		't', 'handoff: paused standby is still in recovery (did not promote)');
+	is($oldsby->safe_psql('postgres', 'SELECT count(*) FROM t'),
+		'2000', 'handoff: paused standby still serves the pre-upgrade data read-only');
+
+	# Retire the paused standby in favor of the new skeleton built in section 2.
+	# A clean stop leaves its data directory a valid, bootable vN cluster; the
+	# skeleton's RELINK redo (default copy mode) only READS these files, so the
+	# old datadir stays intact both as the relink source and as a rollback
+	# target.
+	$oldsby->stop;
+}
+else
+{
+	$old->stop;
+}
+
+# The new primary is created by --initdb; do NOT init() it here.
+my $new = PostgreSQL::Test::Cluster->new('new');
+
+command_ok(
+	[
+		'pg_upgrade', '--no-sync',
+		'--old-datadir' => $old->data_dir,
+		'--new-datadir' => $new->data_dir,
+		'--old-bindir' => $old->config_data('--bindir'),
+		'--new-bindir' => $new->config_data('--bindir'),
+		'--socketdir' => $new->host,
+		'--old-port' => $old->port,
+		'--new-port' => $new->port,
+		'--initdb',
+		'--wal-upgrade',
+	],
+	'primary: pg_upgrade --wal-upgrade --initdb succeeds');
+
+# The --initdb-created new cluster skipped init(); append the settings the test
+# harness needs to start and stream from it.
+my $conf = $new->data_dir . '/postgresql.conf';
+open(my $fh, '>>', $conf) or die "could not open $conf: $!";
+print $fh "\n# added by test\n";
+print $fh "port = " . $new->port . "\n";
+print $fh "listen_addresses = '"
+  . ($PostgreSQL::Test::Utils::windows_os ? '127.0.0.1' : '') . "'\n";
+print $fh "unix_socket_directories = '" . $new->host . "'\n";
+print $fh $primary_conf;
+close($fh);
+
+# Trust replication + normal connections locally so the standby can connect.
+$new->append_conf('pg_hba.conf',
+	"local replication all trust\nhost replication all 127.0.0.1/32 trust\nhost replication all ::1/128 trust\n"
+);
+
+$new->start;
+
+# The upgraded primary comes up read-write (not in recovery) with its data.
+is($new->safe_psql('postgres', 'SELECT pg_is_in_recovery()'),
+	'f', 'primary: serving read-write, not in recovery');
+is($new->safe_psql('postgres', $fp_query),
+	$want, 'primary: data preserved after upgrade');
+
+# The retention slot that pins the upgrade window is present on the live
+# primary, so the window is streamable to a standby.
+my $nslots = $new->safe_psql('postgres',
+	"SELECT count(*) FROM pg_replication_slots WHERE slot_type = 'physical'");
+ok($nslots >= 1, "primary: retention slot present ($nslots physical slot(s))");
+
+# Physical-slot migration and reuse: the named slot created on the old primary
+# must have been recreated on the upgraded primary under the same name, and --
+# because a migrated slot already pins the window -- the dedicated
+# UPGRADE_WINDOW_SLOT ("pg_upgrade_window") must NOT have been created.  So the
+# migrated slot is reused as the window pin (only checked same-version, where
+# the old cluster actually had a physical slot).
+if (!defined($ENV{oldinstall}))
+{
+	is( $new->safe_psql(
+			'postgres',
+			"SELECT count(*) FROM pg_replication_slots "
+			  . "WHERE slot_type = 'physical' AND slot_name = '$migrated_slot'"),
+		'1',
+		"primary: physical slot \"$migrated_slot\" migrated across the upgrade");
+
+	is( $new->safe_psql(
+			'postgres',
+			"SELECT count(*) FROM pg_replication_slots "
+			  . "WHERE slot_name = 'pg_upgrade_window'"),
+		'0',
+		"primary: dedicated retention slot not created when a physical slot is reused");
+}
+
+#
+# 2. Fresh skeleton streams the window; the RELINK manifest links user data.
+#
+# The upgrade window carries only the files pg_upgrade touched (system catalogs,
+# SLRU, skeleton) -- NOT user relations, so it is schema-sized, not data-sized.
+# Instead the window includes an XLOG_UPGRADE_RELINK manifest naming the user
+# relation files pg_upgrade transferred; on redo the standby places each from its
+# own retained old datadir into the fresh skeleton.  With pg_upgrade_standby_transfer_mode
+# left at the default (mirror) it reproduces the primary's mode; the primary here
+# used --copy, so each is an independent copy and the old datadir stays an intact
+# bootable cluster -- the inode check below asserts exactly that.  The old
+# datadir's path is given by the pg_upgrade_standby_old_datadir GUC.  So a standby is a
+# fresh new-version initdb skeleton + a streaming connection + those config
+# settings + an empty pg_upgrade.signal sentinel; it never runs pg_upgrade.
+#
+# The retained old datadir here is the stood-down old standby from section 1a,
+# so this only runs same-version, where that standby exists.  (The mechanism is
+# version-independent; only the same-version test rig for the old datadir is.)
+SKIP:
+{
+	skip 'relink-manifest test reuses the same-version stood-down old standby', 10
+	  if defined($ENV{oldinstall}) || !defined($oldsby);
+
+	my $olddir = $oldsby->data_dir;
+
+	# The old datadir still has the user relation files from before the upgrade;
+	# they are the relink source and never travel through the window.
+	my @userrels = glob("$olddir/base/*/[0-9]*");
+	ok(scalar(@userrels) > 0,
+		'standby: retained old datadir has the user relation files (relink source)');
+
+	# A FRESH new-version skeleton -- initdb'd, empty of user data -- is the
+	# standby target.  It points primary_conninfo at the upgraded primary,
+	# names the retained old datadir in the pg_upgrade_standby_old_datadir GUC, and
+	# stages an EMPTY pg_upgrade.signal sentinel; startup infers the streaming
+	# mode from primary_conninfo, streams the window, and its RELINK redo copies
+	# the user relations in from that old datadir.  pg_upgrade_standby_transfer_mode is
+	# left unset ("mirror"), so the standby reproduces the primary's --copy.
+	my $standby = PostgreSQL::Test::Cluster->new('standby');
+	$standby->init;
+	$standby->append_conf('postgresql.conf', $primary_conf);
+	my $sdir = $standby->data_dir;
+	$standby->append_conf('postgresql.conf',
+		"primary_conninfo = '" . $new->connstr . "'\n");
+	$standby->append_conf('postgresql.conf',
+		"pg_upgrade_standby_old_datadir = '$olddir'\n");
+	open(my $ss, '>', "$sdir/pg_upgrade.signal") or die $!;    # empty sentinel
+	close($ss);
+	$standby->set_standby_mode;    # standby.signal
+
+	# Before streaming, the skeleton has no user table 't' data of its own.
+	$standby->start;
+
+	# It auto-armed and streamed the window.
+	like(slurp_file($standby->logfile),
+		qr/auto-armed streaming standby from locally derived anchor/,
+		'standby: auto-armed from the primary over replication');
+
+	# It converges: the window supplied the system catalogs and the RELINK
+	# manifest linked the user relations from the old datadir, so the user table
+	# (and its large objects) match the primary.
+	$standby->poll_query_until('postgres', 'SELECT count(*) = 2000 FROM t')
+	  or die "standby did not converge to the upgraded data in time";
+	$new->wait_for_catchup($standby, 'replay', $new->lsn('insert'));
+
+	is( $standby->safe_psql('postgres', $fp_query),
+		$want,
+		'standby: converged to the upgraded primary data (window + relink manifest)');
+
+	# The user relation files are now present in the skeleton.
+	my @linked = glob("$sdir/base/*/[0-9]*");
+	ok(scalar(@linked) > 0,
+		'standby: user relation files copied into the skeleton by RELINK redo');
+
+	# They are COPIES, not hardlinks: a skeleton user file and its old-datadir
+	# source have different inodes, so the old datadir is untouched and stays a
+	# bootable cluster to roll back to (upstream's copy-transfer guarantee).
+	{
+		my $rel = $linked[0];
+		$rel =~ s{^\Q$sdir\E/}{};    # e.g. base/5/16384
+		my $ino_new = (stat("$sdir/$rel"))[1];
+		my $ino_old = (stat("$olddir/$rel"))[1];
+		isnt($ino_new, $ino_old,
+			'standby: user file is an independent copy, not a hardlink (old datadir intact)');
+	}
+
+	# Restart with the pg_upgrade.signal sentinel STILL staged (the operator need
+	# not remove it): a converged standby must come up as an ordinary hot standby
+	# and must NOT re-fetch the anchor / re-arm at the old CN.  The durable
+	# pg_control upgrade_finalized flag makes first-startup arming no-op.
+	ok(-f "$sdir/pg_upgrade.signal",
+		'standby: sentinel still present after convergence (not auto-removed)');
+	my $sby_bindir = $standby->config_data('--bindir');
+	my ($cd_out) = run_command([ "$sby_bindir/pg_controldata", '-D', $sdir ]);
+	like($cd_out, qr/wal-upgrade window finalized:\s+yes/,
+		'standby: durable upgrade_finalized flag set after convergence');
+	my $log_before_restart = -s $standby->logfile;
+	$standby->restart;
+	is($standby->safe_psql('postgres', 'SELECT pg_is_in_recovery()'),
+		't', 'standby: restarts as an ordinary hot standby');
+	is($standby->safe_psql('postgres', $fp_query),
+		$want, 'standby: data still matches the primary after restart');
+	# The finalized guard must have short-circuited arming: no new "auto-armed"
+	# line since the restart (re-arming would rewind recovery behind the standby's
+	# replay position, and fail once the primary drops the retention slot).
+	my $log_after = slurp_file($standby->logfile, $log_before_restart);
+	unlike($log_after, qr/auto-armed streaming standby from locally derived anchor/,
+		'standby: converged restart does NOT re-arm at the old CN (finalized guard)');
+
+	$standby->stop;
+}
+
+#
+# 2b. Transfer-mode override: the standby need not mirror the primary's mode.
+#
+# The primary upgraded with --copy, but this standby sets
+# pg_upgrade_standby_transfer_mode = link.  The RELINK redo must then HARDLINK each user
+# relation from the old datadir instead of copying it, so a skeleton user file
+# and its old-datadir source share one inode -- the exact inverse of section 2's
+# copy assertion.  This proves the mode comes from the standby's own config, not
+# the manifest.  Reuses the stood-down old standby datadir, so same-version only.
+#
+SKIP:
+{
+	skip 'transfer-mode override test reuses the same-version stood-down old standby', 3
+	  if defined($ENV{oldinstall}) || !defined($oldsby);
+
+	my $olddir = $oldsby->data_dir;
+
+	my $linksby = PostgreSQL::Test::Cluster->new('linksby');
+	$linksby->init;
+	$linksby->append_conf('postgresql.conf', $primary_conf);
+	my $ldir = $linksby->data_dir;
+	$linksby->append_conf('postgresql.conf',
+		"primary_conninfo = '" . $new->connstr . "'\n");
+	$linksby->append_conf('postgresql.conf',
+		"pg_upgrade_standby_old_datadir = '$olddir'\n");
+	# Override: place user relations by hardlink even though the primary copied.
+	$linksby->append_conf('postgresql.conf',
+		"pg_upgrade_standby_transfer_mode = 'link'\n");
+	open(my $ls, '>', "$ldir/pg_upgrade.signal") or die $!;    # empty sentinel
+	close($ls);
+	$linksby->set_standby_mode;
+	$linksby->start;
+
+	$linksby->poll_query_until('postgres', 'SELECT count(*) = 2000 FROM t')
+	  or die "link-mode standby did not converge to the upgraded data in time";
+	$new->wait_for_catchup($linksby, 'replay', $new->lsn('insert'));
+	is($linksby->safe_psql('postgres', $fp_query),
+		$want, 'override: link-mode standby converged to the upgraded data');
+
+	# At least one relation file in the skeleton must share an inode with its
+	# counterpart in the old datadir -- i.e. be a hardlink.  User relations (the
+	# ones in the RELINK manifest) are hardlinked under the link override;
+	# system catalogs, which arrive as fresh RELFILE images in the window, never
+	# share an inode.  Section 2 (copy) has ZERO shared inodes, so a nonzero
+	# count here proves pg_upgrade_standby_transfer_mode overrode the primary's --copy.
+	my @relfiles = glob("$ldir/base/*/[0-9]*");
+	ok(scalar(@relfiles) > 0,
+		'override: relation files present in the link-mode skeleton');
+	my $shared = 0;
+	for my $f (@relfiles)
+	{
+		(my $rel = $f) =~ s{^\Q$ldir\E/}{};
+		my $ino_new = (stat("$ldir/$rel"))[1];
+		my $ino_old = (stat("$olddir/$rel"))[1];
+		$shared++ if defined $ino_old && $ino_new == $ino_old;
+	}
+	ok($shared > 0,
+		"override: $shared user relation file(s) hardlinked to the old datadir (mode overridden to link)");
+
+	$linksby->stop;
+}
+
+#
+# 2c. No initdb: a BARE skeleton (empty data dir, never initdb'd) streams the
+#     window just like an initdb'd one.
+#
+# The postmaster needs a valid new-version pg_control before it can load it and
+# start recovery -- but that file is synthesizable from this binary's own
+# constants, so a real initdb is not required.  Stage a data directory with only
+# the standby-local config (postgresql.conf, pg_hba.conf), an empty
+# pg_upgrade.signal sentinel, and standby.signal -- NO PG_VERSION, NO pg_control,
+# NO base/.  At first startup checkDataDir() synthesizes pg_control + PG_VERSION +
+# the subdir skeleton (logs "no initdb was required"), the anchor is derived
+# locally, the window streams, and the RELINK redo links user relations from the
+# retained old datadir.  Same convergence as section 2, without initdb.
+#
+# Driven with pg_ctl/psql directly (not $node->init, which runs initdb): a bare
+# skeleton is exactly what has no initdb.
+#
+SKIP:
+{
+	skip 'no-initdb bare-skeleton test reuses the same-version stood-down old standby', 4
+	  if defined($ENV{oldinstall}) || !defined($oldsby);
+
+	my $olddir = $oldsby->data_dir;
+
+	# A separate PostgreSQL::Test::Cluster gives us its allocated port, host
+	# (socket dir), data_dir path, and logfile -- but we do NOT call ->init, so
+	# no initdb runs and the data directory starts out empty.
+	my $bare = PostgreSQL::Test::Cluster->new('bareskel');
+	my $bdir = $bare->data_dir;
+	mkdir $bdir or die "could not create $bdir: $!";
+	chmod 0700, $bdir;
+	# ->init normally makes these; ->start/->backup teardown expect them to exist.
+	mkdir $bare->backup_dir;
+	mkdir $bare->archive_dir;
+
+	# Stage only the standby-local config initdb would otherwise write, plus the
+	# streaming settings and the empty sentinel.  Everything else (pg_control,
+	# PG_VERSION, the directory skeleton) is synthesized at first startup.
+	my $sockdir = $bare->host;
+	open(my $bc, '>', "$bdir/postgresql.conf") or die $!;
+	print $bc "port = " . $bare->port . "\n";
+	print $bc "listen_addresses = '"
+	  . ($PostgreSQL::Test::Utils::windows_os ? '127.0.0.1' : '') . "'\n";
+	print $bc "unix_socket_directories = '$sockdir'\n";
+	print $bc "hot_standby = on\n";
+	print $bc "primary_conninfo = '" . $new->connstr . "'\n";
+	print $bc "pg_upgrade_standby_old_datadir = '$olddir'\n";
+	close($bc);
+	open(my $bh, '>', "$bdir/pg_hba.conf") or die $!;
+	print $bh "local all all trust\n";
+	print $bh "host all all 127.0.0.1/32 trust\n";
+	print $bh "host all all ::1/128 trust\n";
+	close($bh);
+	open(my $bs, '>', "$bdir/pg_upgrade.signal") or die $!;    # empty sentinel
+	close($bs);
+	open(my $bst, '>', "$bdir/standby.signal") or die $!;
+	close($bst);
+
+	# It really is bare -- initdb's outputs are absent before startup.
+	ok(!-f "$bdir/PG_VERSION" && !-f "$bdir/global/pg_control",
+		'no-initdb: skeleton has no PG_VERSION / pg_control before startup (never initdb\'d)');
+
+	$bare->start;
+
+	# checkDataDir() synthesized the control file rather than finding one.
+	like(slurp_file($bare->logfile),
+		qr/synthesized a fresh pg_control and PG_VERSION/,
+		'no-initdb: pg_control + PG_VERSION synthesized at startup (no initdb was required)');
+
+	# Same convergence as the initdb'd standby: window streamed, user relations
+	# relinked, data matches the primary.
+	$bare->poll_query_until('postgres', 'SELECT count(*) = 2000 FROM t')
+	  or die "no-initdb standby did not converge to the upgraded data in time";
+	$new->wait_for_catchup($bare, 'replay', $new->lsn('insert'));
+	is($bare->safe_psql('postgres', $fp_query),
+		$want, 'no-initdb: bare skeleton converged to the upgraded primary data');
+	is($bare->safe_psql('postgres', 'SELECT pg_is_in_recovery()'),
+		't', 'no-initdb: bare skeleton is a hot standby (in recovery)');
+
+	$bare->stop;
+}
+
+#
+# 2a. Negative control: the RELINK manifest is load-bearing, and a missing
+#     old-datadir path is a hard error, not a silent partial recovery.
+#
+# Stream the SAME schema-only window into a fresh skeleton with the
+# pg_upgrade.signal sentinel present (so it arms and streams) but
+# pg_upgrade_standby_old_datadir UNSET.  The RELINK redo would have no source, so the
+# user relations -- which were never in the window -- could only come up ABSENT.
+# Rather than silently bring up a hot standby missing all its user data, the redo
+# must FATAL at the manifest.  This proves both that the relink step is
+# load-bearing AND that its misconfiguration fails loudly.  Runs only
+# same-version, where $new streams.
+#
+SKIP:
+{
+	skip 'negative relink test needs the live streaming primary', 2
+	  if defined($ENV{oldinstall});
+
+	my $norelink = PostgreSQL::Test::Cluster->new('norelink');
+	$norelink->init;
+	$norelink->append_conf('postgresql.conf', $primary_conf);
+	my $ndir = $norelink->data_dir;
+	# primary_conninfo + an EMPTY pg_upgrade.signal, but NO pg_upgrade_standby_old_datadir.
+	# With LOCAL CN derivation the retained old datadir is needed UP FRONT (to
+	# derive CN from its control file before streaming), so the skeleton FATALs at
+	# startup rather than later at relink redo.
+	$norelink->append_conf('postgresql.conf',
+		"primary_conninfo = '" . $new->connstr . "'\n");
+	$norelink->set_standby_mode;
+	open(my $ns, '>', "$ndir/pg_upgrade.signal") or die $!;
+	close($ns);
+
+	# Start fails: the startup process FATALs immediately, so do not use ->start,
+	# which die()s on a non-running server; launch via pg_ctl and check the
+	# outcome ourselves.
+	my $logstart = -s $norelink->logfile;
+	$norelink->start(fail_ok => 1);
+
+	# Startup FATALs because CN cannot be derived without the retained old
+	# datadir, refusing to bring the standby up with no anchor / user data.
+	$norelink->wait_for_log(
+		qr/streaming --wal-upgrade skeleton requires "pg_upgrade_standby_old_datadir" to derive the upgrade anchor/,
+		$logstart);
+	ok(1,
+		'negative: startup FATALs when the streaming skeleton has no old datadir to derive CN (no silent partial recovery)');
+
+	# The server must NOT be serving: a standby missing all user data never opens.
+	my ($rc, $stdout, $stderr) =
+	  $norelink->psql('postgres', 'SELECT 1', timeout => 10);
+	isnt($rc, 0,
+		'negative: standby does not come up when the relink source is missing');
+
+	# The startup process FATALed, taking the postmaster down after pg_ctl had
+	# already reported "started", so the framework still thinks this node is
+	# running.  Sync its state to stopped so END-time teardown does not try (and
+	# fail) to stop an already-dead server.
+	$norelink->_update_pid(0);
+}
+
+#
+# 2b. Aborted upgrade: the paused standby resumes and keeps following the old
+#     primary.  The handoff pause is reversible, so a --wal-upgrade that is
+#     signaled but then abandoned does not strand the standby -- the operator
+#     restarts the old primary read-write and resumes replay, and the standby
+#     streams the primary's new old-format WAL as before.
+#
+# This uses its own same-version primary/standby pair: the main $old cluster was
+# consumed by the real upgrade above, and the handoff record only exists in the
+# patched (same-version) binary.
+#
+SKIP:
+{
+	skip 'abort/resume test needs the same-version handoff record', 4
+	  if defined($ENV{oldinstall});
+
+	my $apri = PostgreSQL::Test::Cluster->new('abort_primary');
+	$apri->init(allows_streaming => 1);
+	$apri->append_conf('postgresql.conf', $primary_conf);
+	$apri->start;
+	$apri->safe_psql('postgres',
+		'CREATE TABLE t (id int primary key); INSERT INTO t SELECT generate_series(1, 100)');
+
+	$apri->backup('abort_base');
+	my $asby = PostgreSQL::Test::Cluster->new('abort_standby');
+	$asby->init_from_backup($apri, 'abort_base', has_streaming => 1);
+	$asby->append_conf('postgresql.conf', $primary_conf);
+	$asby->start;
+	$apri->wait_for_catchup($asby, 'replay', $apri->lsn('insert'));
+
+	my $logstart = -s $asby->logfile;
+
+	# Signal the handoff (writes the trigger and stops the primary) but do NOT
+	# run pg_upgrade -- this models an upgrade that was started then abandoned.
+	command_ok(
+		[
+			'pg_upgrade', '--no-sync',
+			'--wal-upgrade-signal-handoff',
+			'--old-datadir' => $apri->data_dir,
+			'--old-bindir' => $apri->config_data('--bindir'),
+			'--socketdir' => $apri->host,
+			'--old-port' => $apri->port,
+		],
+		'abort: signal-handoff writes the trigger and stops the primary');
+	$apri->_update_pid(0);
+
+	# The standby pauses at the boundary (it does not shut down).
+	$asby->wait_for_log(
+		qr/reached pg_upgrade handoff on standby; pausing recovery at the upgrade boundary/,
+		$logstart);
+	is($asby->safe_psql('postgres', 'SELECT pg_is_in_recovery()'),
+		't', 'abort: standby paused and still in recovery');
+
+	# Abort: restart the old primary read-write (crash recovery treats the
+	# handoff as a no-op) and generate more old-format WAL.
+	$apri->start;
+	is($apri->safe_psql('postgres', 'SELECT pg_is_in_recovery()'),
+		'f', 'abort: old primary restarted read-write');
+	$apri->safe_psql('postgres', 'INSERT INTO t SELECT generate_series(101, 200)');
+
+	# Resume the standby: replay continues past the (no-op) handoff record and
+	# the standby follows the old primary's new WAL.
+	$asby->safe_psql('postgres', 'SELECT pg_wal_replay_resume()');
+	$apri->wait_for_catchup($asby, 'replay', $apri->lsn('insert'));
+	is($asby->safe_psql('postgres', 'SELECT count(*) FROM t'),
+		'200', 'abort: resumed standby caught up to the old primary past the handoff');
+
+	$asby->stop;
+	$apri->stop;
+}
+
+#
+# 3. Negative: a fresh new-version skeleton with NO primary and NO local window
+#    starts as an ordinary cluster -- no silent alternative delivery path.
+#
+my $lonely = PostgreSQL::Test::Cluster->new('lonely');
+$lonely->init(allows_streaming => 1);
+$lonely->append_conf('postgresql.conf', $primary_conf);
+$lonely->start;
+is($lonely->safe_psql('postgres', 'SELECT pg_is_in_recovery()'),
+	'f', 'negative: plain new cluster with no primary is a normal live cluster');
+is($lonely->safe_psql('postgres', "SELECT count(*) FROM pg_tables WHERE tablename = 't'"),
+	'0', 'negative: no upgrade window was silently applied');
+$lonely->stop;
+
+$new->stop;
+
+done_testing();
diff --git a/src/bin/pg_waldump/pgupgradedesc.c b/src/bin/pg_waldump/pgupgradedesc.c
new file mode 120000
index 0000000000..c729dc19b0
--- /dev/null
+++ b/src/bin/pg_waldump/pgupgradedesc.c
@@ -0,0 +1 @@
+../../../src/backend/access/rmgrdesc/pgupgradedesc.c
\ No newline at end of file
diff --git a/src/bin/pg_waldump/rmgrdesc.c b/src/bin/pg_waldump/rmgrdesc.c
index 931ab8b979..326334e136 100644
--- a/src/bin/pg_waldump/rmgrdesc.c
+++ b/src/bin/pg_waldump/rmgrdesc.c
@@ -28,6 +28,7 @@
 #include "commands/tablespace.h"
 #include "replication/message.h"
 #include "replication/origin.h"
+#include "access/pgupgrade_wal.h"	/* pg_upgrade_desc, pg_upgrade_identify */
 #include "rmgrdesc.h"
 #include "storage/standbydefs.h"
 #include "utils/relmapper.h"
diff --git a/src/bin/pg_waldump/t/001_basic.pl b/src/bin/pg_waldump/t/001_basic.pl
index 53b2f016b8..837bdf3d14 100644
--- a/src/bin/pg_waldump/t/001_basic.pl
+++ b/src/bin/pg_waldump/t/001_basic.pl
@@ -80,7 +80,8 @@ CommitTs
 ReplicationOrigin
 Generic
 LogicalMessage
-XLOG2$/,
+XLOG2
+PgUpgrade$/,
 	'rmgr list');
 
 
diff --git a/src/common/file_utils.c b/src/common/file_utils.c
index 390e60bd01..6c897266fe 100644
--- a/src/common/file_utils.c
+++ b/src/common/file_utils.c
@@ -23,7 +23,15 @@
 #include <fcntl.h>
 #include <sys/stat.h>
 #include <unistd.h>
+#ifdef HAVE_COPYFILE_H
+#include <copyfile.h>
+#endif
+#ifdef __linux__
+#include <sys/ioctl.h>
+#include <linux/fs.h>
+#endif
 
+#include "common/file_perm.h"
 #include "common/file_utils.h"
 #ifdef FRONTEND
 #include "common/logging.h"
@@ -748,3 +756,119 @@ pg_pwrite_zeros(int fd, size_t size, pgoff_t offset)
 
 	return total_written;
 }
+
+/*
+ * pg_clone_file -- create "dst" as a reflink (copy-on-write) clone of "src".
+ *
+ * Uses copyfile(COPYFILE_CLONE_FORCE) where available (macOS), else
+ * ioctl(FICLONE) (Linux).  "dst" must not already exist.  Returns
+ * PG_REFLINK_UNSUPPORTED when the build has neither primitive,
+ * PG_REFLINK_ERROR (with *save_errno set) on a syscall failure -- in which case
+ * a partially created "dst" has been unlinked -- and PG_REFLINK_OK on success.
+ *
+ * Shared by pg_upgrade's file transfer and the backend --wal-upgrade relink
+ * replay so the two reproduce identical on-disk results.
+ */
+PGReflinkResult
+pg_clone_file(const char *src, const char *dst, int *save_errno)
+{
+	*save_errno = 0;
+
+#if defined(HAVE_COPYFILE) && defined(COPYFILE_CLONE_FORCE)
+	if (copyfile(src, dst, NULL, COPYFILE_CLONE_FORCE) < 0)
+	{
+		*save_errno = errno;
+		return PG_REFLINK_ERROR;
+	}
+	return PG_REFLINK_OK;
+#elif defined(__linux__) && defined(FICLONE)
+	{
+		int			src_fd;
+		int			dst_fd;
+
+		if ((src_fd = open(src, O_RDONLY | PG_BINARY, 0)) < 0)
+		{
+			*save_errno = errno;
+			return PG_REFLINK_ERROR;
+		}
+		if ((dst_fd = open(dst, O_RDWR | O_CREAT | O_EXCL | PG_BINARY,
+						   pg_file_create_mode)) < 0)
+		{
+			*save_errno = errno;
+			close(src_fd);
+			return PG_REFLINK_ERROR;
+		}
+		if (ioctl(dst_fd, FICLONE, src_fd) < 0)
+		{
+			*save_errno = errno;
+			close(dst_fd);
+			close(src_fd);
+			unlink(dst);
+			return PG_REFLINK_ERROR;
+		}
+		close(dst_fd);
+		close(src_fd);
+		return PG_REFLINK_OK;
+	}
+#else
+	return PG_REFLINK_UNSUPPORTED;
+#endif
+}
+
+/*
+ * pg_copy_file_range_all -- copy the whole of "src" to a new file "dst" using
+ * copy_file_range(2) (a server-side / reflink-capable copy on supporting
+ * filesystems).  "dst" must not already exist.  Return codes match
+ * pg_clone_file().
+ *
+ * Shared by pg_upgrade's file transfer and the backend --wal-upgrade relink
+ * replay.
+ */
+PGReflinkResult
+pg_copy_file_range_all(const char *src, const char *dst, int *save_errno)
+{
+	*save_errno = 0;
+
+#if defined(HAVE_COPY_FILE_RANGE)
+	{
+		int			src_fd;
+		int			dst_fd;
+
+		if ((src_fd = open(src, O_RDONLY | PG_BINARY, 0)) < 0)
+		{
+			*save_errno = errno;
+			return PG_REFLINK_ERROR;
+		}
+		if ((dst_fd = open(dst, O_RDWR | O_CREAT | O_EXCL | PG_BINARY,
+						   pg_file_create_mode)) < 0)
+		{
+			*save_errno = errno;
+			close(src_fd);
+			return PG_REFLINK_ERROR;
+		}
+
+		/* Loop until copy_file_range() reports end-of-file (a 0 return). */
+		for (;;)
+		{
+			ssize_t		nbytes = copy_file_range(src_fd, NULL, dst_fd, NULL,
+												 SSIZE_MAX, 0);
+
+			if (nbytes < 0)
+			{
+				*save_errno = errno;
+				close(dst_fd);
+				close(src_fd);
+				unlink(dst);
+				return PG_REFLINK_ERROR;
+			}
+			if (nbytes == 0)
+				break;
+		}
+		close(dst_fd);
+		close(src_fd);
+		return PG_REFLINK_OK;
+	}
+#else
+	return PG_REFLINK_UNSUPPORTED;
+#endif
+}
diff --git a/src/include/access/clog.h b/src/include/access/clog.h
index 7894998c76..2d06436ea5 100644
--- a/src/include/access/clog.h
+++ b/src/include/access/clog.h
@@ -57,4 +57,7 @@ extern void clog_redo(XLogReaderState *record);
 extern void clog_desc(StringInfo buf, XLogReaderState *record);
 extern const char *clog_identify(uint8 info);
 
+/* restore captured CLOG segment during --wal-upgrade replay */
+extern void CLOGUpgradeRestoreSegment(int64 segno, const char *data, Size datalen);
+
 #endif							/* CLOG_H */
diff --git a/src/include/access/multixact.h b/src/include/access/multixact.h
index 6be5299ab6..53f4dfd535 100644
--- a/src/include/access/multixact.h
+++ b/src/include/access/multixact.h
@@ -152,6 +152,10 @@ extern void multixact_twophase_postabort(FullTransactionId fxid, uint16 info,
 extern void multixact_redo(XLogReaderState *record);
 extern void multixact_desc(StringInfo buf, XLogReaderState *record);
 extern const char *multixact_identify(uint8 info);
+
+/* restore captured multixact SLRU segments during --wal-upgrade replay */
+extern void MultiXactOffsetUpgradeRestoreSegment(int64 segno, const char *data, Size datalen);
+extern void MultiXactMemberUpgradeRestoreSegment(int64 segno, const char *data, Size datalen);
 extern char *mxid_to_string(MultiXactId multi, int nmembers,
 							MultiXactMember *members);
 extern char *mxstatus_to_string(MultiXactStatus status);
diff --git a/src/include/access/pgupgrade_wal.h b/src/include/access/pgupgrade_wal.h
new file mode 100644
index 0000000000..03e3c61634
--- /dev/null
+++ b/src/include/access/pgupgrade_wal.h
@@ -0,0 +1,98 @@
+/*
+ * pgupgrade_wal.h
+ *
+ * declarations for RM_PG_UPGRADE_ID WAL redo and emit functions.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/access/pgupgrade_wal.h
+ */
+#ifndef PGUPGRADE_WAL_H
+#define PGUPGRADE_WAL_H
+
+#include "access/xlogreader.h"
+#include "catalog/pg_control.h"
+#include "lib/stringinfo.h"
+
+/* WAL upgrade check -- called from StartupProcessMain() before StartupXLOG() */
+extern bool PerformWalUpgradeIfNeeded(void);
+
+/*
+ * True iff the pg_upgrade.signal sentinel is present, i.e. this data directory
+ * is staged for --wal-upgrade recovery.  Shared gate for the detection sites in
+ * PerformWalUpgradeIfNeeded() and checkDataDir().
+ */
+extern bool UpgradeSignalStaged(void);
+
+/*
+ * Scan a pg_wal directory for the --wal-upgrade markers (START/COMPLETE)
+ * and the end-of-upgrade checkpoint (CN) that precedes START.  Used by the
+ * local/primary and archive-PITR startup paths (PerformWalUpgradeIfNeeded).
+ * Returns false if there is no readable WAL at all.
+ */
+extern bool UpgradeWalScanMarkers(const char *waldir, bool *found_start,
+								  bool *found_complete, CheckPoint *cn,
+								  XLogRecPtr *cn_lsn, uint64 *wal_sysid);
+
+/* RM_PG_UPGRADE_ID rmgr callbacks (registered in rmgrlist.h) */
+extern void pg_upgrade_redo(XLogReaderState *record);
+extern void pg_upgrade_desc(StringInfo buf, XLogReaderState *record);
+extern const char *pg_upgrade_identify(uint8 info);
+
+/* Emit functions (internal to the WAL-window capture, defined in xlog.c) */
+extern XLogRecPtr XLogWritePgUpgrade(bool is_start, uint32 old_major_version,
+									 uint32 new_major_version);
+extern XLogRecPtr XLogWritePgUpgradeHandoff(uint32 old_major_version,
+											uint32 target_major_version);
+extern XLogRecPtr XLogWriteUpgradeSlruData(uint8 slru_type);
+extern XLogRecPtr XLogWriteUpgradeRawFile(const char *path);
+extern XLogRecPtr XLogWriteUpgradeDirSkel(void);
+
+/*
+ * Emit the entire --wal-upgrade window in one C routine.  Reachable only via
+ * the binary-upgrade-gated SQL function binary_upgrade_emit_wal_window(); the
+ * individual steps are not exposed as SQL.
+ */
+extern void EmitUpgradeWalWindow(uint32 old_major_version,
+								 uint32 new_major_version,
+								 uint8 transfer_mode, bool skip_complete);
+
+/*
+ * Batched emission of relation-file images.  Many file chunks are packed into
+ * each XLOG_UPGRADE_RELFILE_DATA record, up to the max WAL payload.
+ */
+typedef struct UpgradeRelfileBatch
+{
+	char	   *buf;			/* accumulation buffer (freed by BatchEnd) */
+	Size		cap;			/* capacity == payload cap */
+	Size		used;			/* bytes accumulated for the current record */
+	int			nentries;		/* entries in the current record */
+	int			nrecords;		/* records flushed so far */
+	int			nfiles;			/* files added so far */
+	uint8		xfer_mode;		/* relink batch only: pg_upgrade transfer mode
+								 * stamped into each entry */
+} UpgradeRelfileBatch;
+
+extern void XLogUpgradeRelfileBatchBegin(UpgradeRelfileBatch *b);
+extern void XLogUpgradeRelfileBatchAddFile(UpgradeRelfileBatch *b, const char *path,
+										   Oid tsoid, Oid dboid, RelFileNumber rfnum,
+										   uint8 forknum, uint32 segno);
+extern void XLogUpgradeRelfileBatchEnd(UpgradeRelfileBatch *b);
+
+/*
+ * Batched emission of the XLOG_UPGRADE_RELINK manifest: one fixed-size entry
+ * per USER relation file that pg_upgrade transferred verbatim (and the window
+ * therefore omits).  No file data, just identities; redo links them from the
+ * standby's old datadir.  Reuses UpgradeRelfileBatch for the accumulation state.
+ */
+extern void XLogUpgradeRelinkBatchBegin(UpgradeRelfileBatch *b, uint8 xfer_mode);
+extern void XLogUpgradeRelinkBatchAdd(UpgradeRelfileBatch *b,
+									  Oid tsoid, Oid dboid, RelFileNumber rfnum,
+									  uint8 forknum, uint32 segno);
+extern void XLogUpgradeRelinkBatchEnd(UpgradeRelfileBatch *b);
+
+/* force-flush SLRU dirty pages bypassing enableFsync -- for pg_upgrade with fsync=off */
+extern void XLogFlushUpgradeSLRU(void);
+
+#endif							/* PGUPGRADE_WAL_H */
diff --git a/src/include/access/rmgrlist.h b/src/include/access/rmgrlist.h
index ae32ef16d6..b3ec186a88 100644
--- a/src/include/access/rmgrlist.h
+++ b/src/include/access/rmgrlist.h
@@ -48,3 +48,5 @@ PG_RMGR(RM_REPLORIGIN_ID, "ReplicationOrigin", replorigin_redo, replorigin_desc,
 PG_RMGR(RM_GENERIC_ID, "Generic", generic_redo, generic_desc, generic_identify, NULL, NULL, generic_mask, NULL)
 PG_RMGR(RM_LOGICALMSG_ID, "LogicalMessage", logicalmsg_redo, logicalmsg_desc, logicalmsg_identify, NULL, NULL, NULL, logicalmsg_decode)
 PG_RMGR(RM_XLOG2_ID, "XLOG2", xlog2_redo, xlog2_desc, xlog2_identify, NULL, NULL, NULL, xlog2_decode)
+/* dedicated resource manager for all pg_upgrade WAL records */
+PG_RMGR(RM_PG_UPGRADE_ID, "PgUpgrade", pg_upgrade_redo, pg_upgrade_desc, pg_upgrade_identify, NULL, NULL, NULL, NULL)
diff --git a/src/include/access/slru.h b/src/include/access/slru.h
index b4adb1789c..4d4af5e8d1 100644
--- a/src/include/access/slru.h
+++ b/src/include/access/slru.h
@@ -227,6 +227,10 @@ extern int	SimpleLruReadPage_ReadOnly(SlruDesc *ctl, int64 pageno,
 									   const void *opaque_data);
 extern void SimpleLruWritePage(SlruDesc *ctl, int slotno);
 extern void SimpleLruWriteAll(SlruDesc *ctl, bool allow_redirtied);
+
+/* restore a captured SLRU segment image during --wal-upgrade replay */
+extern void SlruUpgradeRestoreSegment(SlruDesc *ctl, int64 segno,
+									  const char *data, Size datalen);
 #ifdef USE_ASSERT_CHECKING
 extern void SlruPagePrecedesUnitTests(SlruDesc *ctl, int per_page);
 #else
diff --git a/src/include/access/xlog.h b/src/include/access/xlog.h
index 4dd9862420..d2a1bc1b8e 100644
--- a/src/include/access/xlog.h
+++ b/src/include/access/xlog.h
@@ -98,6 +98,26 @@ typedef enum RecoveryState
 extern PGDLLIMPORT int wal_level;
 extern PGDLLIMPORT bool XLogLogicalInfo;
 
+/*
+ * pg_upgrade --wal-upgrade streaming-standby transfer mode (postgresql.conf
+ * GUC pg_upgrade_standby_transfer_mode).  PG_UPGRADE_XFER_MIRROR reproduces the
+ * per-file mode the primary recorded in the manifest; the others override it.
+ * Concrete modes map to UPGRADE_RELINK_MODE_* in pg_control.h.  See
+ * ArmFromLocalDerivationIfConfigured().
+ */
+typedef enum
+{
+	PG_UPGRADE_XFER_MIRROR = -1,	/* unset: use the manifest's per-file mode */
+	PG_UPGRADE_XFER_CLONE = 0,
+	PG_UPGRADE_XFER_COPY = 1,
+	PG_UPGRADE_XFER_COPY_FILE_RANGE = 2,
+	PG_UPGRADE_XFER_LINK = 3,
+	PG_UPGRADE_XFER_SWAP = 4,
+} PgUpgradeTransferMode;
+
+extern PGDLLIMPORT int pg_upgrade_standby_transfer_mode;
+extern PGDLLIMPORT char *pg_upgrade_standby_old_datadir;
+
 /* Is WAL archiving enabled (always or only while server is running normally)? */
 #define XLogArchivingActive() \
 	(AssertMacro(XLogArchiveMode == ARCHIVE_MODE_OFF || wal_level >= WAL_LEVEL_REPLICA), XLogArchiveMode > ARCHIVE_MODE_OFF)
@@ -273,6 +293,47 @@ extern WALAvailability GetWALAvailability(XLogRecPtr targetLSN);
 extern void XLogPutNextOid(Oid nextOid);
 extern XLogRecPtr XLogRestorePoint(const char *rpName);
 extern XLogRecPtr XLogAssignLSN(void);
+
+/* pg_upgrade WAL markers */
+extern XLogRecPtr XLogWritePgUpgrade(bool is_start,
+									 uint32 old_major_version,
+									 uint32 new_major_version);
+
+/* SLRU bulk image for pg_upgrade WAL replay */
+extern XLogRecPtr XLogWriteUpgradeSlruData(uint8 slru_type);
+
+/* arm pg_control at CN for in-process --wal-upgrade recovery */
+struct CheckPoint;
+extern void ArmControlFileForUpgradeRecovery(const struct CheckPoint *cn,
+											 XLogRecPtr cn_lsn,
+											 uint64 wal_sysid,
+											 bool for_streaming);
+
+/* control-file checkpoint LSN, for the "upgrade already applied?" test */
+extern XLogRecPtr GetControlFileCheckPointLSN(void);
+
+/*
+ * informational DB_IN_UPGRADE flips during window replay (set at START,
+ * cleared back to DB_IN_PRODUCTION at COMPLETE) -- diagnostics only
+ */
+extern void SetControlFileInUpgrade(void);
+extern void ClearControlFileInUpgrade(void);
+extern void SynthesizeUpgradeStreamControlFile(bool allow_overwrite);
+
+/*
+ * --wal-upgrade: durable "window replayed to COMPLETE" flag in pg_control
+ * (replaces the former pg_upgrade_complete.done marker file).  Set at COMPLETE
+ * redo; paired with a control checkpoint past CN to decide "finalized".
+ */
+extern void SetControlFileUpgradeFinalized(void);
+extern bool GetControlFileUpgradeFinalized(void);
+extern void SetControlFileUpgradeStarted(void);
+extern bool GetControlFileUpgradeStarted(void);
+
+/* emit the streaming-handoff trigger at shutdown if pg_upgrade armed it */
+extern void EmitPgUpgradeHandoffIfArmed(void);
+
+/* batched relation-file images live in access/pgupgrade_wal.h */
 extern void UpdateFullPageWrites(void);
 extern void GetFullPageWriteInfo(XLogRecPtr *RedoRecPtr_p, bool *doPageWrites_p);
 extern XLogRecPtr GetRedoRecPtr(void);
@@ -333,6 +394,19 @@ extern SessionBackupState get_backup_status(void);
 #define RECOVERY_SIGNAL_FILE	"recovery.signal"
 #define STANDBY_SIGNAL_FILE		"standby.signal"
 #define BACKUP_LABEL_FILE		"backup_label"
+/* armed by pg_upgrade --wal-upgrade-signal-handoff; consumed by ShutdownXLOG */
+#define PG_UPGRADE_HANDOFF_SIGNAL_FILE	"pg_upgrade_handoff.pending"
+/*
+ * Empty, presence-only sentinel (like standby.signal) the operator stages into
+ * a --wal-upgrade recovery target; its presence arms upgrade replay at
+ * first-startup.  Recovery mode comes from stock config (primary_conninfo =>
+ * stream from the live primary; recovery.signal => restore_command).  The
+ * standby-local options (old-datadir path, transfer mode) live in
+ * postgresql.conf GUCs, not in this file.  See
+ * ArmFromLocalDerivationIfConfigured().
+ */
+#define PG_UPGRADE_SIGNAL_FILE			"pg_upgrade.signal"
+
 #define BACKUP_LABEL_OLD		"backup_label.old"
 
 #define TABLESPACE_MAP			"tablespace_map"
diff --git a/src/include/access/xlogrecovery.h b/src/include/access/xlogrecovery.h
index ba7750dca0..961350e156 100644
--- a/src/include/access/xlogrecovery.h
+++ b/src/include/access/xlogrecovery.h
@@ -150,6 +150,13 @@ extern PGDLLIMPORT TimeLineID recoveryTargetTLI;
 /* Have we already reached a consistent database state? */
 extern PGDLLIMPORT bool reachedConsistency;
 
+/*
+ * Set while replaying a pg_upgrade --wal-upgrade window (between
+ * XLOG_UPGRADE_START and XLOG_UPGRADE_COMPLETE).  Suppresses hot standby
+ * activation so no read-only connection sees a half-upgraded cluster.
+ */
+extern PGDLLIMPORT bool pgUpgradeReplayInProgress;
+
 /* Are we currently in standby mode? */
 extern PGDLLIMPORT bool StandbyMode;
 
diff --git a/src/include/catalog/catversion.h b/src/include/catalog/catversion.h
index 73ed6a0fa4..3abfa1ef36 100644
--- a/src/include/catalog/catversion.h
+++ b/src/include/catalog/catversion.h
@@ -57,6 +57,6 @@
  */
 
 /*							yyyymmddN */
-#define CATALOG_VERSION_NO	202607022
+#define CATALOG_VERSION_NO	202607023
 
 #endif
diff --git a/src/include/catalog/pg_control.h b/src/include/catalog/pg_control.h
index 80b3a730e0..e52ecf7359 100644
--- a/src/include/catalog/pg_control.h
+++ b/src/include/catalog/pg_control.h
@@ -17,12 +17,13 @@
 
 #include "access/transam.h"
 #include "access/xlogdefs.h"
+#include "common/relpath.h"		/* for RelFileNumber */
 #include "pgtime.h"				/* for pg_time_t */
 #include "port/pg_crc32c.h"
 
 
 /* Version identifier for this pg_control format */
-#define PG_CONTROL_VERSION	1902
+#define PG_CONTROL_VERSION	1905
 
 /* Nonce key length, see below */
 #define MOCK_AUTH_NONCE_LEN		32
@@ -83,12 +84,198 @@ typedef struct CheckPoint
 #define XLOG_FPI						0xB0
 #define XLOG_ASSIGN_LSN					0xC0
 #define XLOG_OVERWRITE_CONTRECORD		0xD0
+
+/*
+ * pg_upgrade WAL record types in RM_PG_UPGRADE_ID.
+ * Using a dedicated rmgr gives us a clean 0x10-aligned namespace free from
+ * the XLR_RMGR_INFO_MASK = 0xF0 constraint that limits RM_XLOG_ID to one
+ * record type per 0x10 bucket.
+ */
+#define XLOG_UPGRADE_START			0x00	/* upgrade window start marker */
+#define XLOG_UPGRADE_COMPLETE		0x10	/* upgrade window complete marker */
+#define XLOG_UPGRADE_SLRU_DATA			0x20	/* bulk SLRU segment image */
+#define XLOG_UPGRADE_RELFILE_DATA		0x30	/* bulk relation file segment
+												 * image */
+#define XLOG_UPGRADE_RAWFILE			0x50	/* verbatim non-relation file
+												 * image (pg_filenode.map,
+												 * PG_VERSION) */
+#define XLOG_UPGRADE_DIRTREE			0x40	/* logged after-image of the
+												 * initdb directory tree */
+#define XLOG_UPGRADE_HANDOFF			0x60	/* OLD-format
+												 * streaming-handoff trigger,
+												 * emitted in the OLD
+												 * cluster's own WAL just
+												 * before pg_upgrade shuts it
+												 * down */
+#define XLOG_UPGRADE_RELINK				0x70	/* manifest of user relations
+												 * to link from the standby's
+												 * old datadir (not carried in
+												 * the window) */
 #define XLOG_CHECKPOINT_REDO			0xE0
 #define XLOG_LOGICAL_DECODING_STATUS_CHANGE	0xF0
 
 /* XLOG info values for XLOG2 rmgr */
 #define XLOG2_CHECKSUMS					0x00
 
+/*
+ * XLOG_UPGRADE_START / XLOG_UPGRADE_COMPLETE -- mark the upgrade window so
+ * crash recovery and tooling (pg_waldump) can identify it and verify
+ * atomicity.  pg_version[] carries $PGDATA/PG_VERSION, which initdb writes
+ * outside the server and is not otherwise WAL-logged.  See pg_upgrade_redo().
+ */
+typedef struct xl_pg_upgrade
+{
+	uint32		old_major_version;	/* old cluster PG_VERSION_NUM major */
+	uint32		new_major_version;	/* new cluster PG_VERSION_NUM major */
+	pg_time_t	upgrade_time;	/* wall-clock time of this record */
+	char		pg_version[8];	/* new cluster PG_MAJORVERSION, e.g. "18\n" */
+} xl_pg_upgrade;
+
+#define SizeOfXLPgUpgrade	sizeof(xl_pg_upgrade)
+
+/*
+ * XLOG_UPGRADE_HANDOFF -- streaming-standby handoff trigger.  Carries no data;
+ * it is a control signal.  Unlike the other pg_upgrade records (new-format WAL
+ * readable only by the new binary), this is emitted into the old cluster's own
+ * WAL in the old format just before shutdown, so a physical standby still
+ * streaming the old primary can read it.  On replay a StandbyMode server stops
+ * cleanly at this LSN and reports that a handoff is beginning.
+ * target_major_version is informational (log message only).
+ * See pg_upgrade_redo().
+ */
+typedef struct xl_pg_upgrade_handoff
+{
+	uint32		old_major_version;	/* this (old) cluster's major version */
+	uint32		target_major_version;	/* major version being upgraded to */
+	pg_time_t	handoff_time;	/* wall-clock time of this record */
+} xl_pg_upgrade_handoff;
+
+#define SizeOfXLPgUpgradeHandoff	sizeof(xl_pg_upgrade_handoff)
+
+/*
+ * XLOG_UPGRADE_SLRU_DATA -- raw images of consecutive SLRU segment files.
+ * Each record batches segments first_seg..last_seg of one SLRU directory
+ * (slru_type; see UPGRADE_SLRU_* below); payload is this header followed by
+ * total_bytes of raw file data.  A directory's segments are split across as
+ * many records as needed to stay under the WAL record size limit.
+ * See pg_upgrade_redo().
+ */
+typedef struct xl_upgrade_slru_data
+{
+	uint8		slru_type;		/* which SLRU: 0=pg_xact, 1=mxoff, 2=mxmem */
+	int64		first_seg;		/* first segment file number in this record */
+	int64		last_seg;		/* last segment file number in this record */
+	uint32		total_bytes;	/* total bytes of raw SLRU data that follow */
+	/* followed by total_bytes of raw segment file data (consecutive segments) */
+} xl_upgrade_slru_data;
+
+#define SizeOfXLUpgradeSlruData		(offsetof(xl_upgrade_slru_data, total_bytes) + sizeof(uint32))
+
+/* slru_type values for xl_upgrade_slru_data */
+#define UPGRADE_SLRU_XACT		0
+#define UPGRADE_SLRU_MXOFF		1
+#define UPGRADE_SLRU_MXMEM		2
+
+/* directory paths corresponding to the slru_type values above */
+#define UPGRADE_SLRU_DIRS		{ "pg_xact", "pg_multixact/offsets", "pg_multixact/members" }
+
+/*
+ * XLOG_UPGRADE_RELFILE_DATA -- raw images of relation files.  One record
+ * batches many chunks up to the max WAL payload, as a sequence of entries each
+ * being an xl_upgrade_relfile_entry header followed by its nbytes of data:
+ *
+ *     [entry_0][data_0][entry_1][data_1] ... [entry_k][data_k]
+ *
+ * A segment larger than the payload cap is split into page-aligned chunks (see
+ * blockoff), possibly across several records.  See pg_upgrade_redo().
+ */
+typedef struct xl_upgrade_relfile_entry
+{
+	Oid			tablespace_oid; /* tablespace containing the file */
+	Oid			database_oid;	/* database OID (0 for shared relations) */
+	RelFileNumber relfilenumber;	/* relation file number */
+	uint8		forknum;		/* fork: 0=main, 1=FSM, 2=VM, 3=init */
+	uint32		segno;			/* 1GB segment number (0 = base segment) */
+	uint32		blockoff;		/* first block within the segment for this
+								 * chunk */
+	uint32		nbytes;			/* bytes of raw page data that follow */
+	/* followed by nbytes bytes of raw file data for this chunk */
+} xl_upgrade_relfile_entry;
+
+#define SizeOfXLUpgradeRelfileEntry		sizeof(xl_upgrade_relfile_entry)
+
+/*
+ * XLOG_UPGRADE_RELINK -- manifest of user relation files that pg_upgrade
+ * transferred verbatim (and the window therefore omits, keeping it
+ * schema-sized).  Each entry names one file by identity; no file data follows.
+ * One record batches many entries: [entry_0][entry_1] ... [entry_k].  Redo is a
+ * no-op on the primary and places each file from the standby's retained old
+ * datadir on a streaming standby.  See pg_upgrade_redo().
+ */
+typedef struct xl_upgrade_relink_entry
+{
+	Oid			tablespace_oid; /* tablespace containing the file */
+	Oid			database_oid;	/* database OID (0 for shared relations) */
+	RelFileNumber relfilenumber;	/* relation file number */
+	uint8		forknum;		/* fork: 0=main, 1=FSM, 2=VM, 3=init */
+	uint8		transfer_mode;	/* selects the placement primitive; see
+								 * UPGRADE_RELINK_MODE_* below and
+								 * relink_place_file() */
+	uint32		segno;			/* 1GB segment number (0 = base segment) */
+} xl_upgrade_relink_entry;
+
+/*
+ * transfer_mode values, mirroring pg_upgrade's transferMode (see
+ * src/bin/pg_upgrade/pg_upgrade.h); duplicated here because the record is read
+ * by the backend, which does not include pg_upgrade headers.
+ */
+#define UPGRADE_RELINK_MODE_CLONE			0
+#define UPGRADE_RELINK_MODE_COPY			1
+#define UPGRADE_RELINK_MODE_COPY_FILE_RANGE	2
+#define UPGRADE_RELINK_MODE_LINK			3
+#define UPGRADE_RELINK_MODE_SWAP			4
+
+#define SizeOfXLUpgradeRelinkEntry		sizeof(xl_upgrade_relink_entry)
+
+/*
+ * XLOG_UPGRADE_RAWFILE -- verbatim image of a non-relation file not reachable
+ * through the buffer manager (currently pg_filenode.map and PG_VERSION), so the
+ * cluster can be rebuilt from an empty data directory.  Payload is this header,
+ * then path_len bytes of PGDATA-relative path (no trailing NUL), then data_len
+ * bytes of contents.  See pg_upgrade_redo().
+ */
+typedef struct xl_upgrade_rawfile
+{
+	uint32		path_len;		/* length of the PGDATA-relative path */
+	uint32		data_len;		/* length of the file contents that follow */
+	/* followed by path_len bytes of path, then data_len bytes of contents */
+} xl_upgrade_rawfile;
+
+#define SizeOfXLUpgradeRawfile	(offsetof(xl_upgrade_rawfile, data_len) + sizeof(uint32))
+
+/*
+ * XLOG_UPGRADE_DIRTREE -- logged after-image of the new cluster's directory
+ * tree once pg_upgrade has finished, so recovery can rebuild the skeleton from
+ * WAL without a surviving on-disk skeleton and without running initdb.  Payload
+ * is this header followed by:
+ *   1. ndirs NUL-terminated PGDATA-relative directory paths (dir_bytes long),
+ *      emitted parent-before-child; then
+ *   2. nsymlinks symlink entries (sym_bytes long), each two NUL-terminated
+ *      strings: PGDATA-relative link path, then target.  These capture
+ *      user-tablespace symlinks (pg_tblspc/<spcoid> -> external location).
+ * See pg_upgrade_redo().
+ */
+typedef struct xl_upgrade_dirtree
+{
+	uint32		ndirs;			/* number of directory paths */
+	uint32		dir_bytes;		/* total bytes of directory-path data */
+	uint32		nsymlinks;		/* number of symlink entries */
+	uint32		sym_bytes;		/* total bytes of symlink-entry data */
+	/* followed by dir_bytes of dir paths, then sym_bytes of symlink entries */
+} xl_upgrade_dirtree;
+
+#define SizeOfXLUpgradeDirtree	(offsetof(xl_upgrade_dirtree, sym_bytes) + sizeof(uint32))
+
 
 /*
  * System status indicator.  Note this is stored in pg_control; if you change
@@ -103,6 +290,15 @@ typedef enum DBState
 	DB_IN_CRASH_RECOVERY,
 	DB_IN_ARCHIVE_RECOVERY,
 	DB_IN_PRODUCTION,
+
+	/*
+	 * A --wal-upgrade cluster replaying its upgrade window (between
+	 * XLOG_UPGRADE_START and _COMPLETE).  Informational only, so
+	 * pg_controldata shows "in pg_upgrade" for a half-reconstructed cluster;
+	 * it does not drive the recovery-mode decision.  Appended last to keep
+	 * on-disk values stable.
+	 */
+	DB_IN_UPGRADE,
 } DBState;
 
 /*
@@ -237,6 +433,24 @@ typedef struct ControlFileData
 	 */
 	bool		default_char_signedness;
 
+	/*
+	 * --wal-upgrade: true once the upgrade window has been replayed to
+	 * XLOG_UPGRADE_COMPLETE.  Durable "upgrade finalized" signal that lets
+	 * first-startup tell a fully-upgraded cluster from a crashed partial one.
+	 * See PerformWalUpgradeIfNeeded().
+	 */
+	bool		upgrade_finalized;
+
+	/*
+	 * --wal-upgrade: true once the burst server has begun emitting the window
+	 * (set just before XLOG_UPGRADE_START, cleared only at finalize).
+	 * Durable evidence that an upgrade started here, so a crashed partial
+	 * upgrade is detectable even when the START-bearing WAL did not survive.
+	 * started && !finalized => refuse to auto-serve.  See
+	 * PerformWalUpgradeIfNeeded().
+	 */
+	bool		upgrade_started;
+
 	/*
 	 * Random nonce, used in authentication requests that need to proceed
 	 * based on values that are cluster-unique, like a SASL exchange that
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 3cb84359ad..1001d81107 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -6823,6 +6823,9 @@
 { oid => '2849', descr => 'current wal write location',
   proname => 'pg_current_wal_lsn', provolatile => 'v', prorettype => 'pg_lsn',
   proargtypes => '', prosrc => 'pg_current_wal_lsn' },
+# The pg_upgrade --wal-upgrade window is emitted by the single
+# binary-upgrade-gated function binary_upgrade_emit_wal_window() (see the
+# binary_upgrade_* block), not by per-step SQL functions.
 { oid => '2852', descr => 'current wal insert location',
   proname => 'pg_current_wal_insert_lsn', provolatile => 'v',
   prorettype => 'pg_lsn', proargtypes => '',
@@ -11994,6 +11997,11 @@
   proisstrict => 'f', provolatile => 'v', proparallel => 'u',
   prorettype => 'void', proargtypes => '',
   prosrc => 'binary_upgrade_create_conflict_detection_slot' },
+{ oid => '9700', descr => 'for use by pg_upgrade (--wal-upgrade window)',
+  proname => 'binary_upgrade_emit_wal_window', proisstrict => 'f',
+  provolatile => 'v', proparallel => 'u', prorettype => 'void',
+  proargtypes => 'int4 int4 int4 bool',
+  prosrc => 'binary_upgrade_emit_wal_window' },
 
 # conversion functions
 { oid => '4310', descr => 'internal conversion function for KOI8R to WIN1251',
diff --git a/src/include/common/file_utils.h b/src/include/common/file_utils.h
index d6415424b6..81f89aa9c1 100644
--- a/src/include/common/file_utils.h
+++ b/src/include/common/file_utils.h
@@ -59,6 +59,23 @@ extern ssize_t pg_pwritev_with_retry(int fd,
 
 extern ssize_t pg_pwrite_zeros(int fd, size_t size, pgoff_t offset);
 
+/*
+ * Result of the reflink helpers below.  The caller decides how to report a
+ * failure (pg_fatal in the frontend, ereport in the backend), so these return a
+ * code rather than raising an error themselves.
+ */
+typedef enum PGReflinkResult
+{
+	PG_REFLINK_OK,				/* the clone/copy succeeded */
+	PG_REFLINK_UNSUPPORTED,		/* not compiled with support on this platform */
+	PG_REFLINK_ERROR,			/* a syscall failed; see *save_errno */
+}			PGReflinkResult;
+
+extern PGReflinkResult pg_clone_file(const char *src, const char *dst,
+									 int *save_errno);
+extern PGReflinkResult pg_copy_file_range_all(const char *src, const char *dst,
+											  int *save_errno);
+
 /* Filename components */
 #define PG_TEMP_FILES_DIR "pgsql_tmp"
 #define PG_TEMP_FILE_PREFIX "pgsql_tmp"
diff --git a/src/include/utils/guc.h b/src/include/utils/guc.h
index 8057d7870a..5a6de7c61f 100644
--- a/src/include/utils/guc.h
+++ b/src/include/utils/guc.h
@@ -345,6 +345,7 @@ extern PGDLLIMPORT bool optimize_bounded_sort;
 extern PGDLLIMPORT const struct config_enum_entry archive_mode_options[];
 extern PGDLLIMPORT const struct config_enum_entry dynamic_shared_memory_options[];
 extern PGDLLIMPORT const struct config_enum_entry io_method_options[];
+extern PGDLLIMPORT const struct config_enum_entry pg_upgrade_standby_transfer_mode_options[];
 extern PGDLLIMPORT const struct config_enum_entry recovery_target_action_options[];
 extern PGDLLIMPORT const struct config_enum_entry server_message_level_options[];
 extern PGDLLIMPORT const struct config_enum_entry wal_level_options[];
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index ffb413ab61..b554f6c798 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1970,6 +1970,7 @@ PGP_S2K
 PGPing
 PGQueryClass
 PGRUsage
+PGReflinkResult
 PGSemaphore
 PGSemaphoreData
 PGShmemHeader
@@ -2369,11 +2370,14 @@ PgStat_TableStatus
 PgStat_TableXactStatus
 PgStat_WalCounters
 PgStat_WalStats
+PgUpgradeTransferMode
 PgXmlErrorContext
 PgXmlStrictness
 Pg_abi_values
 Pg_finfo_record
 Pg_magic_struct
+PhysicalSlotInfo
+PhysicalSlotInfoArr
 PipeProtoChunk
 PipeProtoHeader
 PlaceHolderInfo
@@ -2705,6 +2709,7 @@ ReturningClause
 ReturningExpr
 ReturningOption
 ReturningOptionKind
+RevertableOp
 RevmapContents
 RevokeRoleGrantAction
 RewriteMappingDataEntry
@@ -2941,6 +2946,7 @@ SlruErrorCause
 SlruOpts
 SlruPageStatus
 SlruScanCallback
+SlruSegInfo
 SlruSegState
 SlruShared
 SlruSharedData
@@ -3336,12 +3342,14 @@ UnresolvedTup
 UnresolvedTupData
 UpdateContext
 UpdateStmt
+UpgradeRelfileBatch
 UpgradeTask
 UpgradeTaskProcessCB
 UpgradeTaskReport
 UpgradeTaskSlot
 UpgradeTaskSlotState
 UpgradeTaskStep
+UpgradeWalReadPrivate
 UploadManifestCmd
 UpperRelationKind
 UserAuth
@@ -4521,6 +4529,8 @@ xl_multixact_create
 xl_multixact_truncate
 xl_overwrite_contrecord
 xl_parameter_change
+xl_pg_upgrade
+xl_pg_upgrade_handoff
 xl_relmap_update
 xl_replorigin_drop
 xl_replorigin_set
@@ -4534,6 +4544,11 @@ xl_standby_locks
 xl_tblspc_create_rec
 xl_tblspc_drop_rec
 xl_testcustomrmgrs_message
+xl_upgrade_dirtree
+xl_upgrade_rawfile
+xl_upgrade_relfile_entry
+xl_upgrade_relink_entry
+xl_upgrade_slru_data
 xl_xact_abort
 xl_xact_assignment
 xl_xact_commit
-- 
2.50.1 (Apple Git-155)

