From bca427fbf799a7ca2f348f82198ad706ab44c957 Mon Sep 17 00:00:00 2001
From: Andrey Borodin <amborodin@acm.org>
Date: Sat, 13 Jun 2026 13:05:29 +0500
Subject: [PATCH v2026-06-14 3/3] injection_points: attach and coordinate wait
 points without SQL

Move this module's wait/wakeup state out of a DSM into its own file
(injection_points_wait.shm) and add injection_points_state, a small client
that maps both files the way the backend does to attach/detach a wait point
and to detect and release a waiter, all without SQL.  A TAP test exercises
the no-SQL flow.
---
 src/test/modules/injection_points/.gitignore  |   3 +
 src/test/modules/injection_points/Makefile    |  21 +
 .../injection_points/injection_points.c       | 321 ++++++++++--
 .../injection_points/injection_points.h       |  39 ++
 .../injection_points/injection_points_state.c | 460 ++++++++++++++++++
 src/test/modules/injection_points/meson.build |  21 +
 .../t/001_wait_without_sql.pl                 | 128 +++++
 7 files changed, 957 insertions(+), 36 deletions(-)
 create mode 100644 src/test/modules/injection_points/injection_points_state.c
 create mode 100644 src/test/modules/injection_points/t/001_wait_without_sql.pl

diff --git a/src/test/modules/injection_points/.gitignore b/src/test/modules/injection_points/.gitignore
index 0de307e70a6..88fccf29987 100644
--- a/src/test/modules/injection_points/.gitignore
+++ b/src/test/modules/injection_points/.gitignore
@@ -4,3 +4,6 @@
 /results/
 /tmp_check/
 /tmp_check_iso/
+
+# Standalone state client
+/injection_points_state
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index c01d2fb095c..b7387abe84c 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -24,10 +24,19 @@ ISOLATION = basic \
 # some isolation tests require wal_level=replica
 ISOLATION_OPTS = --temp-config $(top_srcdir)/src/test/modules/injection_points/extra.conf
 
+TAP_TESTS = 1
+
 # The injection points are cluster-wide, so disable installcheck
 NO_INSTALLCHECK = 1
 
+# Standalone client used by the TAP tests to wait on and wake up injection
+# points without a backend connection.  It is built alongside the module
+# (MODULE_big already owns OBJS, so it cannot go through PGXS PROGRAM) and its
+# absolute path is exported for the TAP tests.
+INJ_STATE_CLIENT = injection_points_state$(X)
+
 export enable_injection_points
+export INJECTION_POINTS_STATE := $(abspath $(INJ_STATE_CLIENT))
 
 ifdef USE_PGXS
 PG_CONFIG = pg_config
@@ -51,3 +60,15 @@ check:
 endif
 
 endif
+
+# Build the standalone state client.  Its object is compiled by the implicit
+# rule (which adds -I. so injection_points.h is found) and it links against no
+# backend libraries.
+all: $(INJ_STATE_CLIENT)
+
+$(INJ_STATE_CLIENT): injection_points_state.o
+	$(CC) $(CFLAGS) $< $(LDFLAGS) $(LDFLAGS_EX) $(LIBS) -o $@
+
+clean: clean-injection-points-state
+clean-injection-points-state:
+	rm -f $(INJ_STATE_CLIENT) injection_points_state.o
diff --git a/src/test/modules/injection_points/injection_points.c b/src/test/modules/injection_points/injection_points.c
index 9b8e1aaad0b..b24a3035e2a 100644
--- a/src/test/modules/injection_points/injection_points.c
+++ b/src/test/modules/injection_points/injection_points.c
@@ -17,13 +17,19 @@
 
 #include "postgres.h"
 
+#include <fcntl.h>
+#include <sys/stat.h>
+#ifndef WIN32
+#include <sys/mman.h>
+#endif
+
 #include "fmgr.h"
 #include "funcapi.h"
 #include "injection_points.h"
 #include "miscadmin.h"
 #include "nodes/pg_list.h"
 #include "nodes/value.h"
-#include "storage/dsm_registry.h"
+#include "storage/fd.h"
 #include "storage/ipc.h"
 #include "storage/lwlock.h"
 #include "storage/shmem.h"
@@ -37,10 +43,6 @@
 
 PG_MODULE_MAGIC;
 
-/* Maximum number of waits usable in injection points at once */
-#define INJ_MAX_WAIT	8
-#define INJ_NAME_MAXLEN	64
-
 /*
  * List of injection points stored in TopMemoryContext attached
  * locally to this process.
@@ -50,22 +52,40 @@ static List *inj_list_local = NIL;
 /*
  * Shared state information for injection points.
  *
- * This state data can be initialized in two ways: dynamically with a DSM
- * or when loading the module.
+ * This is mapped from a fixed file in the data directory (INJ_STATE_FILE)
+ * rather than being allocated in the main shared memory segment or a DSM.
+ * Backing it with a file lets external programs without a backend connection
+ * map the same state, observe which injection points are being waited on and
+ * release them (see injection_points_state.c).  This works in contexts where
+ * condition variables and latches are unavailable, e.g. the postmaster or a
+ * process that has not set up its PGPROC yet.
+ *
+ * The leading InjectionPointPublicState portion is the contract shared with
+ * those external tools, so it must stay first and keep a frontend-compatible
+ * layout (see injection_points.h).  The fields below it are backend-only.
  */
 typedef struct InjectionPointSharedState
 {
-	/* Protects access to other fields */
-	slock_t		lock;
+	/* Names of injection points attached to wait counters (slot in use). */
+	char		name[INJ_MAX_WAIT][INJ_NAME_MAXLEN];
 
 	/* Counters advancing when injection_points_wakeup() is called */
 	pg_atomic_uint32 wait_counts[INJ_MAX_WAIT];
 
-	/* Names of injection points attached to wait counters */
-	char		name[INJ_MAX_WAIT][INJ_NAME_MAXLEN];
+	/* Protects access to the name array (backend-only) */
+	slock_t		lock;
 } InjectionPointSharedState;
 
-/* Pointer to shared-memory state. */
+/* The public prefix must match InjectionPointPublicState bit for bit. */
+StaticAssertDecl(offsetof(InjectionPointSharedState, name) == 0,
+				 "name must be the first field of InjectionPointSharedState");
+StaticAssertDecl(offsetof(InjectionPointSharedState, wait_counts) ==
+				 offsetof(InjectionPointPublicState, wait_counts),
+				 "wait_counts offset must match InjectionPointPublicState");
+StaticAssertDecl(sizeof(pg_atomic_uint32) == sizeof(uint32),
+				 "pg_atomic_uint32 must be layout-compatible with uint32");
+
+/* Pointer to the mapped shared state. */
 static InjectionPointSharedState *inj_state = NULL;
 
 extern PGDLLEXPORT void injection_error(const char *name,
@@ -81,63 +101,292 @@ extern PGDLLEXPORT void injection_wait(const char *name,
 /* track if injection points attached in this process are linked to it */
 static bool injection_point_local = false;
 
-static void injection_shmem_request(void *arg);
+/* How injection_map_state() should open the backing file. */
+typedef enum InjectionMapMode
+{
+	INJ_MAP_CREATE,				/* discard any stale file, create fresh */
+	INJ_MAP_ATTACH,				/* map an already-existing file */
+	INJ_MAP_ATTACH_OR_CREATE,	/* attach if present, else create */
+} InjectionMapMode;
+
 static void injection_shmem_init(void *arg);
+static void injection_shmem_attach(void *arg);
 
 static const ShmemCallbacks injection_shmem_callbacks = {
-	.request_fn = injection_shmem_request,
+	/* Create and initialize the backing file once at postmaster startup. */
 	.init_fn = injection_shmem_init,
+	/* Re-map it in each child that does not inherit the mapping (Windows). */
+	.attach_fn = injection_shmem_attach,
 };
 
 /*
- * Routine for shared memory area initialization, used as a callback
- * when initializing dynamically with a DSM or when loading the module.
+ * Initialize a freshly-created shared state.
  */
 static void
-injection_point_init_state(void *ptr, void *arg)
+injection_point_init_state(InjectionPointSharedState *state)
 {
-	InjectionPointSharedState *state = (InjectionPointSharedState *) ptr;
-
 	SpinLockInit(&state->lock);
 	memset(state->name, 0, sizeof(state->name));
 	for (int i = 0; i < INJ_MAX_WAIT; i++)
 		pg_atomic_init_u32(&state->wait_counts[i], 0);
 }
 
-static void
-injection_shmem_request(void *arg)
+/*
+ * Resolve the absolute path of the backing file.  Anchored at the data
+ * directory rather than the current working directory: EXEC_BACKEND children
+ * re-map the file from attach_fn before they chdir into the data directory, so
+ * a relative path would resolve against the wrong directory there.  This also
+ * matches the absolute path that out-of-process tools build from the data
+ * directory they are given.
+ */
+static const char *
+injection_state_file_path(void)
 {
-	ShmemRequestStruct(.name = "injection_points",
-					   .size = sizeof(InjectionPointSharedState),
-					   .ptr = (void **) &inj_state,
-		);
+	static char path[MAXPGPATH];
+
+	if (path[0] != '\0')
+		return path;
+
+	if (DataDir != NULL && DataDir[0] != '\0')
+		snprintf(path, sizeof(path), "%s/%s", DataDir, INJ_STATE_FILE);
+	else
+		strlcpy(path, INJ_STATE_FILE, sizeof(path));
+
+	return path;
 }
 
+/*
+ * proc_exit callback removing the backing file.  Registered only by the
+ * process that created it (the postmaster, or a lone backend when the module
+ * is not preloaded), so that the file disappears together with the cluster.
+ */
 static void
-injection_shmem_init(void *arg)
+injection_state_file_cleanup(int code, Datum arg)
 {
+	if (inj_state != NULL)
+	{
+#ifndef WIN32
+		munmap(inj_state, sizeof(InjectionPointSharedState));
+#else
+		UnmapViewOfFile(inj_state);
+#endif
+		inj_state = NULL;
+	}
+
 	/*
-	 * First time through, so initialize.  This is shared with the dynamic
-	 * initialization using a DSM.
+	 * Only the postmaster (or a standalone backend) should unlink the file;
+	 * forked children inherit this callback but must not remove it.
 	 */
-	injection_point_init_state(inj_state, NULL);
+	if (!IsUnderPostmaster)
+		(void) unlink(injection_state_file_path());
 }
 
+#ifndef WIN32
 /*
- * Initialize shared memory area for this module through DSM.
+ * Wait for a file created by a concurrent process to reach its final size.
+ *
+ * The winner of the O_EXCL create race makes the file at length zero and only
+ * then ftruncate()s it to "size".  A process that lost the race and is
+ * attaching could otherwise mmap() past end-of-file and take a SIGBUS on first
+ * access.  The gap is just the few instructions between create and ftruncate,
+ * so in practice this returns on the first check.
  */
 static void
-injection_init_shmem(void)
+injection_wait_for_size(int fd, Size size, const char *path, int elevel)
+{
+	/* Generous bound; the writer needs only microseconds. */
+	for (int i = 0; i < 10000; i++)
+	{
+		struct stat st;
+
+		if (fstat(fd, &st) != 0)
+		{
+			ereport(elevel,
+					(errcode_for_file_access(),
+					 errmsg("could not stat injection point state file \"%s\": %m",
+							path)));
+			return;
+		}
+		if (st.st_size >= (off_t) size)
+			return;
+		pg_usleep(1000L);		/* 1ms */
+	}
+
+	ereport(elevel,
+			(errmsg("injection point state file \"%s\" was never sized by its creator",
+					path)));
+}
+#endif
+
+/*
+ * Map INJ_STATE_FILE into this process, creating and/or initializing it as
+ * dictated by "mode", and set inj_state.
+ *
+ * The state is backed by an ordinary file so that external programs can map
+ * the same bytes (POSIX mmap or, on Windows, a file-backed CreateFileMapping).
+ * All accessors must use the mapping, never plain file reads, because file
+ * I/O is not guaranteed to be coherent with a mapped view on Windows.
+ */
+static void
+injection_map_state(InjectionMapMode mode, int elevel)
+{
+	Size		size = sizeof(InjectionPointSharedState);
+	const char *path = injection_state_file_path();
+	bool		created = false;
+
+	if (inj_state != NULL)
+		return;
+
+	if (mode == INJ_MAP_CREATE)
+		(void) unlink(path);	/* drop any stale file from a crash */
+
+#ifndef WIN32
+	{
+		int			fd;
+		int			oflags = O_RDWR;
+
+		if (mode != INJ_MAP_ATTACH)
+			oflags |= O_CREAT | O_EXCL;
+
+		fd = OpenTransientFile(path, oflags);
+		if (fd < 0 && mode == INJ_MAP_ATTACH_OR_CREATE && errno == EEXIST)
+		{
+			/* Lost the race to create it; just attach. */
+			oflags = O_RDWR;
+			fd = OpenTransientFile(path, oflags);
+
+			/*
+			 * The winner may not have ftruncate()d the file to full size yet;
+			 * wait for it so the mmap() below cannot fault past end-of-file.
+			 */
+			if (fd >= 0)
+				injection_wait_for_size(fd, size, path, elevel);
+		}
+		else if (fd >= 0 && (oflags & O_CREAT))
+			created = true;
+
+		if (fd < 0)
+			ereport(elevel,
+					(errcode_for_file_access(),
+					 errmsg("could not open injection point state file \"%s\": %m",
+							path)));
+
+		if (created && ftruncate(fd, size) != 0)
+		{
+			CloseTransientFile(fd);
+			(void) unlink(path);
+			ereport(elevel,
+					(errcode_for_file_access(),
+					 errmsg("could not size injection point state file \"%s\": %m",
+							path)));
+		}
+
+		inj_state = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
+		CloseTransientFile(fd);
+
+		if (inj_state == MAP_FAILED)
+		{
+			inj_state = NULL;
+			ereport(elevel,
+					(errcode_for_file_access(),
+					 errmsg("could not map injection point state file \"%s\": %m",
+							path)));
+		}
+	}
+#else
+	{
+		HANDLE		hfile;
+		HANDLE		hmap;
+		DWORD		share = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE;
+		DWORD		disp = (mode == INJ_MAP_ATTACH) ? OPEN_EXISTING : CREATE_NEW;
+
+		hfile = CreateFile(path,
+						   GENERIC_READ | GENERIC_WRITE,
+						   share, NULL, disp, FILE_ATTRIBUTE_NORMAL, NULL);
+
+		/*
+		 * If creation failed because a file from an earlier cluster lifetime
+		 * is in the way, attach to it instead of failing.  Don't insist on a
+		 * specific error code: besides ERROR_FILE_EXISTS, a file whose
+		 * deletion is still pending reports ERROR_ACCESS_DENIED.
+		 * FILE_SHARE_DELETE (above) lets such a file be reopened and unlinked
+		 * while still mapped.
+		 */
+		if (hfile == INVALID_HANDLE_VALUE && mode == INJ_MAP_ATTACH_OR_CREATE)
+		{
+			disp = OPEN_EXISTING;
+			hfile = CreateFile(path,
+							   GENERIC_READ | GENERIC_WRITE,
+							   share, NULL, disp, FILE_ATTRIBUTE_NORMAL, NULL);
+		}
+		else if (hfile != INVALID_HANDLE_VALUE && disp == CREATE_NEW)
+			created = true;
+
+		if (hfile == INVALID_HANDLE_VALUE)
+			ereport(elevel,
+					(errmsg("could not open injection point state file \"%s\": error code %lu",
+							path, GetLastError())));
+
+		/* CreateFileMapping extends the backing file to the mapping size. */
+		hmap = CreateFileMapping(hfile, NULL, PAGE_READWRITE, 0, (DWORD) size, NULL);
+		if (hmap == NULL)
+		{
+			CloseHandle(hfile);
+			ereport(elevel,
+					(errmsg("could not create mapping for injection point state file \"%s\": error code %lu",
+							path, GetLastError())));
+		}
+
+		inj_state = MapViewOfFile(hmap, FILE_MAP_ALL_ACCESS, 0, 0, size);
+		CloseHandle(hmap);
+		CloseHandle(hfile);
+
+		if (inj_state == NULL)
+			ereport(elevel,
+					(errmsg("could not map injection point state file \"%s\": error code %lu",
+							path, GetLastError())));
+	}
+#endif
+
+	if (created)
+	{
+		injection_point_init_state(inj_state);
+		on_proc_exit(injection_state_file_cleanup, 0);
+	}
+}
+
+/*
+ * Shared memory callbacks.  We do not request any space in the main segment;
+ * the file mapping is the source of truth.  init_fn runs once in the
+ * postmaster (children inherit the mapping through fork), while attach_fn
+ * re-maps the file in children that do not inherit it (EXEC_BACKEND/Windows).
+ */
+static void
+injection_shmem_init(void *arg)
 {
-	bool		found;
+	injection_map_state(INJ_MAP_CREATE, FATAL);
+}
+
+static void
+injection_shmem_attach(void *arg)
+{
+	injection_map_state(INJ_MAP_ATTACH, FATAL);
+}
 
+/*
+ * Ensure inj_state is available in the current process.
+ *
+ * Backends preloading the module inherit (fork) or re-map (EXEC_BACKEND) the
+ * state set up at postmaster startup.  When the module is not preloaded, the
+ * first process to reach here creates the file and the rest attach to it.
+ */
+static void
+injection_init_shmem(void)
+{
 	if (inj_state != NULL)
 		return;
 
-	inj_state = GetNamedDSMSegment("injection_points",
-								   sizeof(InjectionPointSharedState),
-								   injection_point_init_state,
-								   &found, NULL);
+	injection_map_state(INJ_MAP_ATTACH_OR_CREATE, ERROR);
 }
 
 /*
diff --git a/src/test/modules/injection_points/injection_points.h b/src/test/modules/injection_points/injection_points.h
index caabc4ffb32..560bede9d45 100644
--- a/src/test/modules/injection_points/injection_points.h
+++ b/src/test/modules/injection_points/injection_points.h
@@ -15,6 +15,45 @@
 #ifndef INJECTION_POINTS_H
 #define INJECTION_POINTS_H
 
+#include <stdint.h>
+
+/* Maximum number of waits usable in injection points at once */
+#define INJ_MAX_WAIT	8
+#define INJ_NAME_MAXLEN 64
+
+/*
+ * Name of the file under the data directory holding this module's wait/wakeup
+ * state.  The state is mapped from this file (see injection_points.c) so that
+ * external programs without a backend connection can observe and release
+ * waiting processes (see injection_points_state.c).
+ *
+ * This is distinct from the core registry file (injection_points.shm, see
+ * src/backend/utils/misc/injection_point.c) that records which points are
+ * attached: the registry says *which* points exist, this file coordinates the
+ * wait/wakeup of points whose action is "wait".
+ */
+#define INJ_STATE_FILE	"injection_points_wait.shm"
+
+/*
+ * Publicly-mappable portion of the injection point shared state.
+ *
+ * This describes the layout that external tools rely on when mapping
+ * INJ_STATE_FILE.  It must stay at the very front of the backend-only
+ * InjectionPointSharedState (see injection_points.c, which static-asserts
+ * the layout), and must only use types that are also available to frontend
+ * code: no slock_t, no pg_atomic_uint32.
+ *
+ * "name" holds the injection point name registered by a waiting process in
+ * its slot, or an empty string if the slot is free.  "wait_counts" is bumped
+ * to release the waiter occupying the matching slot; a 32-bit aligned counter
+ * is binary-compatible with the backend's pg_atomic_uint32.
+ */
+typedef struct InjectionPointPublicState
+{
+	char		name[INJ_MAX_WAIT][INJ_NAME_MAXLEN];
+	uint32_t	wait_counts[INJ_MAX_WAIT];
+} InjectionPointPublicState;
+
 typedef enum InjectionPointConditionType
 {
 	INJ_CONDITION_ALWAYS = 0,	/* always run */
diff --git a/src/test/modules/injection_points/injection_points_state.c b/src/test/modules/injection_points/injection_points_state.c
new file mode 100644
index 00000000000..9c1434936bf
--- /dev/null
+++ b/src/test/modules/injection_points/injection_points_state.c
@@ -0,0 +1,460 @@
+/*--------------------------------------------------------------------------
+ *
+ * injection_points_state.c
+ *		Standalone client for the injection point shared state.
+ *
+ * This small program maps the injection point state files from a data
+ * directory and lets a test harness drive injection points without going
+ * through a backend connection or any SQL.  It can:
+ *
+ *	  - attach/detach a "wait" injection point by writing the core registry file
+ *		(injection_points.shm, see src/backend/utils/misc/injection_point.c),
+ *	  - detect that a process has reached a wait point and release it by
+ *		writing this module's wait file (injection_points_wait.shm, see
+ *		injection_points.c).
+ *
+ * That is useful when the cooperating process has no PGPROC or no wait-event
+ * visibility (for example the postmaster, or early startup before the SQL
+ * machinery is up), where SQL-driven attach/wakeup is not an option and a
+ * fixed sleep would be unreliable.
+ *
+ * Both files are mapped exactly like the backend does -- POSIX mmap() or, on
+ * Windows, a file-backed CreateFileMapping() -- because plain file reads are
+ * not guaranteed to be coherent with a mapped view on Windows.
+ *
+ * NB: this is prototype/test tooling.  attach/detach update the registry
+ * without holding the backend's InjectionPointLock, so they assume no
+ * concurrent SQL attach/detach of the same array.  That holds for the
+ * intended use (attaching points out of band, before or alongside controlled
+ * test sessions).
+ *
+ * TODO: if this outgrows test tooling, the registry should get a real
+ * publication protocol that is safe against concurrent writers - an
+ * out-of-process equivalent of InjectionPointLock, or a CAS-based claim on
+ * the generation counter - instead of this single-writer assumption.
+ *
+ * Usage:
+ *	  injection_points_state DATADIR attach NAME
+ *	  injection_points_state DATADIR detach NAME
+ *	  injection_points_state DATADIR wait   NAME [TIMEOUT_SEC]
+ *	  injection_points_state DATADIR wakeup NAME [TIMEOUT_SEC]
+ *
+ * Exit status: 0 success, 1 usage/IO error, 2 timeout.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *		src/test/modules/injection_points/injection_points_state.c
+ *
+ * -------------------------------------------------------------------------
+ */
+
+#include <stdalign.h>
+#include <stddef.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#ifndef WIN32
+#include <errno.h>
+#include <fcntl.h>
+#include <sys/mman.h>
+#include <sys/stat.h>
+#include <time.h>
+#include <unistd.h>
+#else
+#include <windows.h>
+#endif
+
+#include "injection_points.h"
+
+#define INJ_DEFAULT_TIMEOUT_SEC		180
+#define INJ_POLL_INTERVAL_MS		10
+
+/*
+ * Mirror of the core active injection points array (InjectionPointsCtl /
+ * InjectionPointEntry in src/backend/utils/misc/injection_point.c).
+ *
+ * The backend stores that array in INJ_POINTS_FILE using pg_atomic_uint{32,64}
+ * for "max_inuse" and "generation".  Those atomic types are layout-compatible
+ * with the plain integers used here (the backend static-asserts the widths),
+ * so we can read and write the same bytes to attach and detach points.  The
+ * field sizes below must match the backend's INJ_*_MAXLEN /
+ * MAX_INJECTION_POINTS.
+ *
+ * "generation" must be alignas(8): the backend's pg_atomic_uint64 forces
+ * 8-byte alignment, so the entries array starts 8 bytes into the control
+ * struct.  On an ILP32 platform a plain uint64_t is only 4-byte aligned, which
+ * would push entries to offset 4 and make us read and write the wrong slots.
+ * The _Static_assert below pins the offset so any future drift fails the build
+ * instead of hanging a test.
+ */
+#define INJ_POINTS_FILE			"injection_points.shm"
+#define INJ_REG_MAXPOINTS		128
+#define INJ_LIB_MAXLEN			128
+#define INJ_FUNC_MAXLEN			128
+#define INJ_PRIVATE_MAXLEN		1024
+
+typedef struct InjectionRegEntry
+{
+	alignas(8) uint64_t generation; /* even: free, odd: in use */
+	char		name[INJ_NAME_MAXLEN];
+	char		library[INJ_LIB_MAXLEN];
+	char		function[INJ_FUNC_MAXLEN];
+	char		private_data[INJ_PRIVATE_MAXLEN];
+} InjectionRegEntry;
+
+typedef struct InjectionRegCtl
+{
+	uint32_t	max_inuse;
+	InjectionRegEntry entries[INJ_REG_MAXPOINTS];
+} InjectionRegCtl;
+
+_Static_assert(offsetof(InjectionRegCtl, entries) == 8,
+			   "registry entries must start at offset 8 to match the backend");
+
+static const char *progname = "injection_points_state";
+
+static void
+usage(void)
+{
+	fprintf(stderr,
+			"usage: %s DATADIR {attach|detach|wait|wakeup} NAME [TIMEOUT_SEC]\n",
+			progname);
+}
+
+/* Sleep for the given number of milliseconds. */
+static void
+sleep_ms(int ms)
+{
+#ifndef WIN32
+	struct timespec ts;
+
+	ts.tv_sec = ms / 1000;
+	ts.tv_nsec = (long) (ms % 1000) * 1000000L;
+	nanosleep(&ts, NULL);
+#else
+	Sleep(ms);
+#endif
+}
+
+/* Atomically bump a 32-bit counter shared with the backend. */
+static void
+atomic_inc_u32(volatile uint32_t *counter)
+{
+#if defined(_MSC_VER)
+	_InterlockedIncrement((volatile long *) counter);
+#else
+	__atomic_add_fetch(counter, 1, __ATOMIC_SEQ_CST);
+#endif
+}
+
+/* Loads/stores matching the backend's generation protocol. */
+static uint32_t
+load_u32(volatile uint32_t *p)
+{
+#if defined(_MSC_VER)
+	return (uint32_t) _InterlockedOr((volatile long *) p, 0);
+#else
+	return __atomic_load_n(p, __ATOMIC_ACQUIRE);
+#endif
+}
+
+static void
+store_u32(volatile uint32_t *p, uint32_t v)
+{
+#if defined(_MSC_VER)
+	_InterlockedExchange((volatile long *) p, (long) v);
+#else
+	__atomic_store_n(p, v, __ATOMIC_RELEASE);
+#endif
+}
+
+static uint64_t
+load_u64(volatile uint64_t *p)
+{
+#if defined(_MSC_VER)
+	return (uint64_t) _InterlockedOr64((volatile __int64 *) p, 0);
+#else
+	return __atomic_load_n(p, __ATOMIC_ACQUIRE);
+#endif
+}
+
+/* Publish "generation" after the other fields are in place (release store). */
+static void
+store_u64_release(volatile uint64_t *p, uint64_t v)
+{
+#if defined(_MSC_VER)
+	_InterlockedExchange64((volatile __int64 *) p, (__int64) v);
+#else
+	__atomic_store_n(p, v, __ATOMIC_RELEASE);
+#endif
+}
+
+/*
+ * Map a file of the given size read-write, the same way the backend does.
+ * Returns the mapped base, or NULL on failure (with a message on stderr).
+ */
+static void *
+map_file(const char *path, size_t size)
+{
+#ifndef WIN32
+	int			fd;
+	void	   *base;
+
+	fd = open(path, O_RDWR, 0);
+	if (fd < 0)
+	{
+		fprintf(stderr, "%s: could not open \"%s\": %s\n",
+				progname, path, strerror(errno));
+		return NULL;
+	}
+
+	base = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
+	close(fd);
+
+	if (base == MAP_FAILED)
+	{
+		fprintf(stderr, "%s: could not map \"%s\": %s\n",
+				progname, path, strerror(errno));
+		return NULL;
+	}
+	return base;
+#else
+	HANDLE		hfile;
+	HANDLE		hmap;
+	void	   *base;
+
+	hfile = CreateFile(path, GENERIC_READ | GENERIC_WRITE,
+					   FILE_SHARE_READ | FILE_SHARE_WRITE,
+					   NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
+	if (hfile == INVALID_HANDLE_VALUE)
+	{
+		fprintf(stderr, "%s: could not open \"%s\": error code %lu\n",
+				progname, path, GetLastError());
+		return NULL;
+	}
+
+	hmap = CreateFileMapping(hfile, NULL, PAGE_READWRITE, 0,
+							 (DWORD) size, NULL);
+	if (hmap == NULL)
+	{
+		fprintf(stderr, "%s: could not create mapping for \"%s\": error code %lu\n",
+				progname, path, GetLastError());
+		CloseHandle(hfile);
+		return NULL;
+	}
+
+	base = MapViewOfFile(hmap, FILE_MAP_ALL_ACCESS, 0, 0, size);
+	CloseHandle(hmap);
+	CloseHandle(hfile);
+
+	if (base == NULL)
+	{
+		fprintf(stderr, "%s: could not map \"%s\": error code %lu\n",
+				progname, path, GetLastError());
+		return NULL;
+	}
+	return base;
+#endif
+}
+
+/*
+ * Attach a "wait" injection point by adding it to the registry, mimicking
+ * InjectionPointAttach(): find a free slot, fill the entry, then publish it by
+ * flipping the generation to odd with a release store.  The point fires the
+ * module's injection_wait() callback (private_data left zeroed, i.e. an
+ * unconditional INJ_CONDITION_ALWAYS condition).
+ */
+static int
+do_attach(InjectionRegCtl *ctl, const char *name)
+{
+	uint32_t	max_inuse = load_u32(&ctl->max_inuse);
+	int			free_idx = -1;
+	InjectionRegEntry *e;
+	uint64_t	generation;
+
+	for (uint32_t i = 0; i < max_inuse; i++)
+	{
+		uint64_t	g = load_u64(&ctl->entries[i].generation);
+
+		if (g % 2 == 0)
+		{
+			if (free_idx < 0)
+				free_idx = (int) i;
+		}
+		else if (strncmp(ctl->entries[i].name, name, INJ_NAME_MAXLEN) == 0)
+		{
+			fprintf(stderr, "%s: injection point \"%s\" already attached\n",
+					progname, name);
+			return 1;
+		}
+	}
+
+	if (free_idx < 0)
+	{
+		if (max_inuse >= INJ_REG_MAXPOINTS)
+		{
+			fprintf(stderr, "%s: too many injection points\n", progname);
+			return 1;
+		}
+		free_idx = (int) max_inuse;
+	}
+
+	e = &ctl->entries[free_idx];
+	generation = load_u64(&e->generation);	/* even (free) */
+
+	memset(e->name, 0, INJ_NAME_MAXLEN);
+	strncpy(e->name, name, INJ_NAME_MAXLEN - 1);
+	memset(e->library, 0, INJ_LIB_MAXLEN);
+	strncpy(e->library, "injection_points", INJ_LIB_MAXLEN - 1);
+	memset(e->function, 0, INJ_FUNC_MAXLEN);
+	strncpy(e->function, "injection_wait", INJ_FUNC_MAXLEN - 1);
+	memset(e->private_data, 0, INJ_PRIVATE_MAXLEN);
+
+	store_u64_release(&e->generation, generation + 1);	/* publish (odd) */
+
+	if ((uint32_t) (free_idx + 1) > max_inuse)
+		store_u32(&ctl->max_inuse, (uint32_t) (free_idx + 1));
+
+	return 0;
+}
+
+/* Detach an injection point by flipping its generation back to even. */
+static int
+do_detach(InjectionRegCtl *ctl, const char *name)
+{
+	uint32_t	max_inuse = load_u32(&ctl->max_inuse);
+
+	for (uint32_t i = 0; i < max_inuse; i++)
+	{
+		uint64_t	g = load_u64(&ctl->entries[i].generation);
+
+		if (g % 2 == 0)
+			continue;
+		if (strncmp(ctl->entries[i].name, name, INJ_NAME_MAXLEN) == 0)
+		{
+			store_u64_release(&ctl->entries[i].generation, g + 1);
+			return 0;
+		}
+	}
+
+	fprintf(stderr, "%s: injection point \"%s\" not attached\n",
+			progname, name);
+	return 1;
+}
+
+/*
+ * Return the wait slot currently registered for "name", or -1 if none.
+ *
+ * The backend writes the name under a spinlock before it starts waiting, so a
+ * stable match means the wait point has been reached.  A torn read simply
+ * fails to match and the caller retries.
+ */
+static int
+find_wait_slot(InjectionPointPublicState *state, const char *name)
+{
+	for (int i = 0; i < INJ_MAX_WAIT; i++)
+	{
+		if (strncmp(state->name[i], name, INJ_NAME_MAXLEN) == 0)
+			return i;
+	}
+	return -1;
+}
+
+/*
+ * Poll the wait file until "name" appears (a process has reached the point),
+ * up to timeout_sec.  Returns the slot, or -1 on timeout.
+ */
+static int
+wait_for_slot(InjectionPointPublicState *state, const char *name,
+			  int timeout_sec)
+{
+	int			max_polls = (timeout_sec * 1000) / INJ_POLL_INTERVAL_MS;
+
+	for (int polls = 0;; polls++)
+	{
+		int			slot = find_wait_slot(state, name);
+
+		if (slot >= 0)
+			return slot;
+		if (polls >= max_polls)
+			return -1;
+		sleep_ms(INJ_POLL_INTERVAL_MS);
+	}
+}
+
+int
+main(int argc, char **argv)
+{
+	const char *datadir;
+	const char *mode;
+	const char *name;
+	int			timeout_sec = INJ_DEFAULT_TIMEOUT_SEC;
+	char		path[1024];
+
+	if (argc < 4 || argc > 5)
+	{
+		usage();
+		return 1;
+	}
+
+	datadir = argv[1];
+	mode = argv[2];
+	name = argv[3];
+	if (argc == 5)
+		timeout_sec = atoi(argv[4]);
+
+	if (strlen(name) >= INJ_NAME_MAXLEN)
+	{
+		fprintf(stderr, "%s: injection point name too long\n", progname);
+		return 1;
+	}
+
+	/* attach/detach operate on the core registry file. */
+	if (strcmp(mode, "attach") == 0 || strcmp(mode, "detach") == 0)
+	{
+		InjectionRegCtl *ctl;
+
+		snprintf(path, sizeof(path), "%s/%s", datadir, INJ_POINTS_FILE);
+		ctl = (InjectionRegCtl *) map_file(path, sizeof(InjectionRegCtl));
+		if (ctl == NULL)
+			return 1;
+
+		if (strcmp(mode, "attach") == 0)
+			return do_attach(ctl, name);
+		else
+			return do_detach(ctl, name);
+	}
+
+	/* wait/wakeup operate on this module's wait file. */
+	if (strcmp(mode, "wait") == 0 || strcmp(mode, "wakeup") == 0)
+	{
+		InjectionPointPublicState *state;
+		int			slot;
+
+		snprintf(path, sizeof(path), "%s/%s", datadir, INJ_STATE_FILE);
+		state = (InjectionPointPublicState *) map_file(path,
+													   sizeof(InjectionPointPublicState));
+		if (state == NULL)
+			return 1;
+
+		slot = wait_for_slot(state, name, timeout_sec);
+		if (slot < 0)
+		{
+			fprintf(stderr, "%s: timed out waiting for injection point \"%s\"\n",
+					progname, name);
+			return 2;
+		}
+
+		if (strcmp(mode, "wakeup") == 0)
+			atomic_inc_u32(&state->wait_counts[slot]);
+
+		return 0;
+	}
+
+	usage();
+	return 1;
+}
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index 59dba1cb023..605dabb3ee8 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -21,6 +21,17 @@ injection_points = shared_module('injection_points',
 )
 test_install_libs += injection_points
 
+# Standalone client used by the TAP tests to wait on and wake up injection
+# points without a backend connection.  It only needs the module's own header.
+injection_points_state = executable('injection_points_state',
+  files('injection_points_state.c'),
+  include_directories: include_directories('.'),
+  kwargs: default_bin_args + {
+    'install': false,
+  },
+)
+testprep_targets += injection_points_state
+
 test_install_data += files(
   'injection_points.control',
   'injection_points--1.0.sql',
@@ -30,6 +41,16 @@ tests += {
   'name': 'injection_points',
   'sd': meson.current_source_dir(),
   'bd': meson.current_build_dir(),
+  'tap': {
+    'env': {
+      'enable_injection_points': get_option('injection_points') ? 'yes' : 'no',
+      'INJECTION_POINTS_STATE': injection_points_state.full_path(),
+    },
+    'tests': [
+      't/001_wait_without_sql.pl',
+    ],
+    'deps': [injection_points_state],
+  },
   'regress': {
     'sql': [
       'injection_points',
diff --git a/src/test/modules/injection_points/t/001_wait_without_sql.pl b/src/test/modules/injection_points/t/001_wait_without_sql.pl
new file mode 100644
index 00000000000..9a1cce1c1cf
--- /dev/null
+++ b/src/test/modules/injection_points/t/001_wait_without_sql.pl
@@ -0,0 +1,128 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Drive an injection point entirely from outside the server, without issuing
+# any SQL to attach or coordinate it.  Two files in the data directory back the
+# shared state:
+#
+#   - injection_points.shm: the core registry of attached points (see
+#     src/backend/utils/misc/injection_point.c).  Writing it attaches a point.
+#   - injection_points_wait.shm: this module's wait/wakeup coordination (see
+#     injection_points.c).
+#
+# The standalone injection_points_state client maps these files the same way
+# the backend does and is able to attach a "wait" point, detect that a process
+# reached it, release it, and detach it -- all without a backend connection.
+# This is the synchronization primitive needed when the cooperating process
+# has no PGPROC or no wait-event visibility (postmaster, early startup, ...),
+# where SQL-driven attach/wakeup is not available and a fixed sleep would be
+# unreliable.  SQL is used here only to *trigger* the point (the code path that
+# would normally contain the INJECTION_POINT() macro) and to observe state.
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if ($ENV{enable_injection_points} ne 'yes')
+{
+	plan skip_all => 'Injection points not supported by this build';
+}
+
+my $client = $ENV{INJECTION_POINTS_STATE};
+if (!defined $client || $client eq '')
+{
+	plan skip_all => 'injection_points_state client not available';
+}
+
+# Preload the module so the wait file is created at startup and the library is
+# available in every backend.
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init;
+$node->append_conf('postgresql.conf',
+	"shared_preload_libraries = 'injection_points'");
+$node->start;
+$node->safe_psql('postgres', 'CREATE EXTENSION injection_points;');
+
+my $datadir = $node->data_dir;
+
+# Both backing files must exist as soon as the server is up.
+ok(-f "$datadir/injection_points.shm",
+	'core registry file created at startup');
+ok(-f "$datadir/injection_points_wait.shm",
+	'wait state file created at startup');
+
+# Attach a "wait" injection point by writing the registry file directly, with
+# no SQL involved.
+$node->command_ok(
+	[ $client, $datadir, 'attach', 'external-wait' ],
+	'external client attached a wait point without SQL');
+
+# The backend must see the externally-attached point in its registry.
+my $listed = $node->safe_psql('postgres',
+	"SELECT point_name || ',' || library || ',' || function"
+	  . " FROM injection_points_list() WHERE point_name = 'external-wait';");
+is( $listed,
+	'external-wait,injection_points,injection_wait',
+	'backend sees the externally-attached injection point');
+
+# Trigger the point from a background session, which blocks in injection_wait().
+my $session = $node->background_psql('postgres', on_error_stop => 0);
+$session->query_until(
+	qr/start/, qq[
+	\\echo start
+	SELECT injection_points_run('external-wait');
+]);
+
+# Detect that the wait point was reached.  The client polls the mapped wait
+# file instead of guessing with a sleep, so it behaves the same on fast and
+# slow machines.
+$node->command_ok(
+	[ $client, $datadir, 'wait', 'external-wait' ],
+	'external client detected the wait point without SQL');
+
+# Detach the point *before* waking the waiter.  In a code path that runs
+# INJECTION_POINT() in a loop, waking first would let the woken process loop
+# back and immediately re-enter the wait at the same still-attached point, so
+# the robust order is detach-then-wake.  Here the point is run once so the
+# order is not strictly required, but the test models the correct pattern.
+# Detach only flips the registry generation; the waiter stays blocked on the
+# separate wait file until we bump its counter below.
+$node->command_ok(
+	[ $client, $datadir, 'detach', 'external-wait' ],
+	'external client detached the point without SQL');
+
+# The backend must no longer see it in the registry, even though a process is
+# still blocked at the point.
+my $still = $node->safe_psql('postgres',
+	"SELECT count(*) FROM injection_points_list()"
+	  . " WHERE point_name = 'external-wait';");
+is($still, '0', 'backend no longer sees the detached injection point');
+
+# Release the waiter by bumping its counter through the mapped wait file, again
+# without any SQL or backend connection.
+$node->command_ok(
+	[ $client, $datadir, 'wakeup', 'external-wait' ],
+	'external client woke the waiter without SQL');
+
+# The blocked SELECT must now finish.
+$session->query_safe('SELECT 1;');
+$session->quit;
+
+# A wait for the now-cleared point must time out rather than block forever,
+# proving the tool reflects live state.
+$node->command_fails_like(
+	[ $client, $datadir, 'wait', 'external-wait', '1' ],
+	qr/timed out/,
+	'external client times out when no process waits');
+
+$node->stop;
+
+# Both backing files are removed together with the cluster.
+ok(!-f "$datadir/injection_points.shm",
+	'core registry file removed at shutdown');
+ok(!-f "$datadir/injection_points_wait.shm",
+	'wait state file removed at shutdown');
+
+done_testing();
-- 
2.50.1 (Apple Git-155)

