public inbox for [email protected]  
help / color / mirror / Atom feed
From: Andrey Borodin <[email protected]>
To: Michael Paquier <[email protected]>
Cc: Robert Haas <[email protected]>
Cc: Postgres hackers <[email protected]>
Cc: Heikki Linnakangas <[email protected]>
Subject: Re: injection_points: Switch wait/wakeup to use atomics rather than latches
Date: Sun, 14 Jun 2026 14:23:54 +0500
Message-ID: <[email protected]> (raw)
In-Reply-To: <[email protected]>
References: <CA+TgmoamSXmT7uMXBw6c3YJgSSz6dYdpMsJf47jwaPH9kVbmyA@mail.gmail.com>
	<[email protected]>
	<CA+TgmoYE-9WhM-owWtX3grbSeHtOJCF__wEEdoD-jj-oJ+=TSg@mail.gmail.com>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>



> On 12 Jun 2026, at 12:02, Andrey Borodin <[email protected]> wrote:
> 
> I hacked up a prototype (on top of
> your atomics commit) that maps the state portably on both sides


v2026-06-12 passed locally but tripped on the Windows CI (EXEC_BACKEND),
v2026-06-14 fixes that.

The mistake was handing ActiveInjectionPoints to children as the
postmaster's pointer through BackendParameters.  That only works for the
main shared memory segment, which is re-attached at a fixed address; the
file-backed array is mapped wherever each process lands, so the inherited
pointer was garbage and the first point hit in a child crashed.

v2026-06-14 drops that and lets each process map the file itself. Rather
than racing to map it at the right moment in child startup, the registry
is mapped lazily on first use: the first time a process checks a point it
attaches the file if it exists, and treats "no file" as "nothing armed".
So a point is reached even when it fires before the child has attached
shared memory - e.g. "backend-initialize", which 005_negotiate_encryption
exercises - and a point armed out of band, even before the server is up,
is not silently missed.  Forked children still just inherit the
postmaster's mapping.

Personally I do not like lazy initialization, it's race-prone. But it
guarantees the file is mapped before the first point is checked, wherever
that point sits.

The same revision also closes an unrelated init race: when two processes
create the backing file at once, the loser could map it before the winner
sized it (SIG-something on first touch). The attach path now waits for the
file to reach full size before mapping.

CI is green. Same two patches on top of your atomics commit. Let me know
if this prototype goes radically wrong way.

Thanks!


Best regards, Andrey Borodin.



Attachments:

  [application/octet-stream] v2026-06-14-0001-injection_points-Switch-wait-wakeup-to-r.patch (4.9K, ../[email protected]/2-v2026-06-14-0001-injection_points-Switch-wait-wakeup-to-r.patch)
  download | inline diff:
From 80cb6d9463194d887fc88a3567a82e8322b5f6c1 Mon Sep 17 00:00:00 2001
From: Michael Paquier <[email protected]>
Date: Thu, 28 May 2026 11:15:33 +0900
Subject: [PATCH v2026-06-14 1/3] injection_points: Switch wait/wakeup to rely
 on atomics

This removes the dependency based on counters and environment variables,
replacing the waiting loop by a wait on an atomic counter, whose check
increases over time in an exponential manner (starts at 10us, up to
100ms).
---
 .../injection_points/injection_points.c       | 53 ++++++++++---------
 1 file changed, 27 insertions(+), 26 deletions(-)

diff --git a/src/test/modules/injection_points/injection_points.c b/src/test/modules/injection_points/injection_points.c
index ba282e3dcab..9b8e1aaad0b 100644
--- a/src/test/modules/injection_points/injection_points.c
+++ b/src/test/modules/injection_points/injection_points.c
@@ -23,11 +23,11 @@
 #include "miscadmin.h"
 #include "nodes/pg_list.h"
 #include "nodes/value.h"
-#include "storage/condition_variable.h"
 #include "storage/dsm_registry.h"
 #include "storage/ipc.h"
 #include "storage/lwlock.h"
 #include "storage/shmem.h"
+#include "storage/spin.h"
 #include "utils/builtins.h"
 #include "utils/guc.h"
 #include "utils/injection_point.h"
@@ -59,13 +59,10 @@ typedef struct InjectionPointSharedState
 	slock_t		lock;
 
 	/* Counters advancing when injection_points_wakeup() is called */
-	uint32		wait_counts[INJ_MAX_WAIT];
+	pg_atomic_uint32 wait_counts[INJ_MAX_WAIT];
 
 	/* Names of injection points attached to wait counters */
 	char		name[INJ_MAX_WAIT][INJ_NAME_MAXLEN];
-
-	/* Condition variable used for waits and wakeups */
-	ConditionVariable wait_point;
 } InjectionPointSharedState;
 
 /* Pointer to shared-memory state. */
@@ -102,9 +99,9 @@ injection_point_init_state(void *ptr, void *arg)
 	InjectionPointSharedState *state = (InjectionPointSharedState *) ptr;
 
 	SpinLockInit(&state->lock);
-	memset(state->wait_counts, 0, sizeof(state->wait_counts));
 	memset(state->name, 0, sizeof(state->name));
-	ConditionVariableInit(&state->wait_point);
+	for (int i = 0; i < INJ_MAX_WAIT; i++)
+		pg_atomic_init_u32(&state->wait_counts[i], 0);
 }
 
 static void
@@ -222,7 +219,7 @@ injection_notice(const char *name, const void *private_data, void *arg)
 		elog(NOTICE, "notice triggered for injection point %s", name);
 }
 
-/* Wait on a condition variable, awaken by injection_points_wakeup() */
+/* Wait until injection_points_wakeup() is called */
 void
 injection_wait(const char *name, const void *private_data, void *arg)
 {
@@ -254,31 +251,37 @@ injection_wait(const char *name, const void *private_data, void *arg)
 		{
 			index = i;
 			strlcpy(inj_state->name[i], name, INJ_NAME_MAXLEN);
-			old_wait_counts = inj_state->wait_counts[i];
+			old_wait_counts = pg_atomic_read_u32(&inj_state->wait_counts[i]);
 			break;
 		}
 	}
 	SpinLockRelease(&inj_state->lock);
 
 	if (index < 0)
-		elog(ERROR, "could not find free slot for wait of injection point %s ",
+		elog(ERROR, "could not find free slot for wait of injection point %s",
 			 name);
 
-	/* And sleep.. */
-	ConditionVariablePrepareToSleep(&inj_state->wait_point);
-	for (;;)
+	/*
+	 * Wait until the counter is bumped by injection_points_wakeup().
+	 *
+	 * This loop starts with a short delay for responsiveness, enlarged to
+	 * ease the CPU workload in slower environments.
+	 */
+#define INJ_WAIT_INITIAL_US		10	/* 10us */
+#define INJ_WAIT_MAX_US			100000	/* 100ms */
+	pgstat_report_wait_start(injection_wait_event);
 	{
-		uint32		new_wait_counts;
+		int			delay_us = INJ_WAIT_INITIAL_US;
 
-		SpinLockAcquire(&inj_state->lock);
-		new_wait_counts = inj_state->wait_counts[index];
-		SpinLockRelease(&inj_state->lock);
-
-		if (old_wait_counts != new_wait_counts)
-			break;
-		ConditionVariableSleep(&inj_state->wait_point, injection_wait_event);
+		while (pg_atomic_read_u32(&inj_state->wait_counts[index]) == old_wait_counts)
+		{
+			CHECK_FOR_INTERRUPTS();
+			pg_usleep(delay_us);
+			if (delay_us < INJ_WAIT_MAX_US)
+				delay_us *= 2;
+		}
 	}
-	ConditionVariableCancelSleep();
+	pgstat_report_wait_end();
 
 	/* Remove this injection point from the waiters. */
 	SpinLockAcquire(&inj_state->lock);
@@ -443,7 +446,7 @@ injection_points_wakeup(PG_FUNCTION_ARGS)
 	if (inj_state == NULL)
 		injection_init_shmem();
 
-	/* First bump the wait counter for the injection point to wake up */
+	/* Find the injection point then bump its wait counter */
 	SpinLockAcquire(&inj_state->lock);
 	for (int i = 0; i < INJ_MAX_WAIT; i++)
 	{
@@ -458,11 +461,9 @@ injection_points_wakeup(PG_FUNCTION_ARGS)
 		SpinLockRelease(&inj_state->lock);
 		elog(ERROR, "could not find injection point %s to wake up", name);
 	}
-	inj_state->wait_counts[index]++;
 	SpinLockRelease(&inj_state->lock);
 
-	/* And broadcast the change to the waiters */
-	ConditionVariableBroadcast(&inj_state->wait_point);
+	pg_atomic_fetch_add_u32(&inj_state->wait_counts[index], 1);
 	PG_RETURN_VOID();
 }
 
-- 
2.50.1 (Apple Git-155)



  [application/octet-stream] v2026-06-14-0003-injection_points-attach-and-coordinate-w.patch (37.3K, ../[email protected]/3-v2026-06-14-0003-injection_points-attach-and-coordinate-w.patch)
  download | inline diff:
From bca427fbf799a7ca2f348f82198ad706ab44c957 Mon Sep 17 00:00:00 2001
From: Andrey Borodin <[email protected]>
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)



  [application/octet-stream] v2026-06-14-0002-injection_points-back-the-active-points-.patch (15.5K, ../[email protected]/4-v2026-06-14-0002-injection_points-back-the-active-points-.patch)
  download | inline diff:
From 51b971709c3027388a9ccd17129f31be468dade8 Mon Sep 17 00:00:00 2001
From: Andrey Borodin <[email protected]>
Date: Sat, 13 Jun 2026 13:05:22 +0500
Subject: [PATCH v2026-06-14 2/3] injection_points: back the active points
 array with a file

Map ActiveInjectionPoints from a file in the data directory
(injection_points.shm, overridable with PG_INJECTION_POINTS_FILE) instead of
the main shared memory segment, so out-of-process tools can read and attach
injection points with no backend connection.  The lock-free generation
protocol used to read the array is unchanged.

EXEC_BACKEND children map the file themselves instead of inheriting the
postmaster's pointer, which would address an arbitrary mapping the re-exec'd
child does not reproduce.  The backing file is anchored at the data directory
(children attach before chdir'ing into it).  The registry is mapped lazily on
the first lookup, so a point that fires before this process attached -- an
early child point such as "backend-initialize", or one armed out of band --
is still seen; if no file exists, nothing has been armed.
---
 src/backend/postmaster/launch_backend.c  |  11 -
 src/backend/utils/misc/injection_point.c | 349 ++++++++++++++++++++++-
 2 files changed, 335 insertions(+), 25 deletions(-)

diff --git a/src/backend/postmaster/launch_backend.c b/src/backend/postmaster/launch_backend.c
index 8f3cfea880c..14cceff54ef 100644
--- a/src/backend/postmaster/launch_backend.c
+++ b/src/backend/postmaster/launch_backend.c
@@ -99,9 +99,6 @@ typedef struct
 	HANDLE		UsedShmemSegID;
 #endif
 	void	   *UsedShmemSegAddr;
-#ifdef USE_INJECTION_POINTS
-	struct InjectionPointsCtl *ActiveInjectionPoints;
-#endif
 	PROC_HDR   *ProcGlobal;
 	PGPROC	   *AuxiliaryProcs;
 	PGPROC	   *PreparedXactProcs;
@@ -730,10 +727,6 @@ save_backend_variables(BackendParameters *param,
 	param->UsedShmemSegID = UsedShmemSegID;
 	param->UsedShmemSegAddr = UsedShmemSegAddr;
 
-#ifdef USE_INJECTION_POINTS
-	param->ActiveInjectionPoints = ActiveInjectionPoints;
-#endif
-
 	param->ProcGlobal = ProcGlobal;
 	param->AuxiliaryProcs = AuxiliaryProcs;
 	param->PreparedXactProcs = PreparedXactProcs;
@@ -986,10 +979,6 @@ restore_backend_variables(BackendParameters *param)
 	UsedShmemSegID = param->UsedShmemSegID;
 	UsedShmemSegAddr = param->UsedShmemSegAddr;
 
-#ifdef USE_INJECTION_POINTS
-	ActiveInjectionPoints = param->ActiveInjectionPoints;
-#endif
-
 	ProcGlobal = param->ProcGlobal;
 	AuxiliaryProcs = param->AuxiliaryProcs;
 	PreparedXactProcs = param->PreparedXactProcs;
diff --git a/src/backend/utils/misc/injection_point.c b/src/backend/utils/misc/injection_point.c
index 272ef5e578a..2bb4579e0de 100644
--- a/src/backend/utils/misc/injection_point.c
+++ b/src/backend/utils/misc/injection_point.c
@@ -21,11 +21,17 @@
 
 #ifdef USE_INJECTION_POINTS
 
+#include <fcntl.h>
 #include <sys/stat.h>
+#include <unistd.h>
+#ifndef WIN32
+#include <sys/mman.h>
+#endif
 
 #include "fmgr.h"
 #include "miscadmin.h"
 #include "storage/fd.h"
+#include "storage/ipc.h"
 #include "storage/lwlock.h"
 #include "storage/shmem.h"
 #include "storage/subsystems.h"
@@ -72,6 +78,17 @@ typedef struct InjectionPointEntry
 	char		private_data[INJ_PRIVATE_MAXLEN];
 } InjectionPointEntry;
 
+/*
+ * The active points array is mapped from a file (see below) that out-of-process
+ * tools can read and write.  Those tools cannot use the pg_atomic_* API, so
+ * they mirror this layout with plain integers; make sure the atomic types stay
+ * layout-compatible with their underlying width.
+ */
+StaticAssertDecl(sizeof(pg_atomic_uint32) == sizeof(uint32),
+				 "pg_atomic_uint32 must be layout-compatible with uint32");
+StaticAssertDecl(sizeof(pg_atomic_uint64) == sizeof(uint64),
+				 "pg_atomic_uint64 must be layout-compatible with uint64");
+
 #define MAX_INJECTION_POINTS	128
 
 /*
@@ -87,8 +104,39 @@ typedef struct InjectionPointsCtl
 	InjectionPointEntry entries[MAX_INJECTION_POINTS];
 } InjectionPointsCtl;
 
+/*
+ * The 8-byte-aligned generation counter pushes the entries array to offset 8,
+ * past max_inuse and its padding.  Out-of-process tools mirror this, so pin it
+ * here too: this offset is part of the on-file contract.
+ */
+StaticAssertDecl(offsetof(InjectionPointsCtl, entries) == 8,
+				 "InjectionPointsCtl.entries must start at offset 8");
+
 NON_EXEC_STATIC InjectionPointsCtl *ActiveInjectionPoints;
 
+/*
+ * Name of the file backing the active injection points array.
+ *
+ * Unlike the rest of shared memory, this array lives in an ordinary file so
+ * that out-of-process tools (with no backend connection, no SQL, and possibly
+ * running before or instead of the postmaster) can map the same bytes, attach
+ * injection points and coordinate with the processes that hit them.  The path
+ * defaults to this name relative to the data directory, but can be overridden
+ * with the PG_INJECTION_POINTS_FILE environment variable so that points can be
+ * attached even before initdb has created a data directory (e.g. for
+ * single-user mode bootstrap).
+ */
+#define INJ_POINTS_FILE			"injection_points.shm"
+#define INJ_POINTS_FILE_ENV		"PG_INJECTION_POINTS_FILE"
+
+/* How injection_map_points() should open the backing file. */
+typedef enum InjectionMapMode
+{
+	INJ_MAP_ATTACH,				/* map an already-existing file */
+	INJ_MAP_ATTACH_OR_CREATE,	/* attach if present, else create */
+	INJ_MAP_ATTACH_IF_EXISTS,	/* attach if present, else leave unmapped */
+} InjectionMapMode;
+
 /*
  * Backend local cache of injection callbacks already loaded, stored in
  * TopMemoryContext.
@@ -110,8 +158,9 @@ typedef struct InjectionPointCacheEntry
 
 static HTAB *InjectionPointCache = NULL;
 
-static void InjectionPointShmemRequest(void *arg);
-static void InjectionPointShmemInit(void *arg);
+static void injection_shmem_init(void *arg);
+static void injection_shmem_attach(void *arg);
+static void injection_map_points(InjectionMapMode mode, int elevel);
 
 /*
  * injection_point_cache_add
@@ -229,29 +278,281 @@ injection_point_cache_get(const char *name)
 	return NULL;
 }
 
+/*
+ * The active injection points array is backed by a file rather than the main
+ * shared memory segment, so we do not reserve any space there.  init_fn maps
+ * (and, if needed, creates) the file once in the postmaster; children inherit
+ * the mapping through fork(), while attach_fn re-maps it in children that do
+ * not (EXEC_BACKEND/Windows).
+ */
 const ShmemCallbacks InjectionPointShmemCallbacks = {
-	.request_fn = InjectionPointShmemRequest,
-	.init_fn = InjectionPointShmemInit,
+	.init_fn = injection_shmem_init,
+	.attach_fn = injection_shmem_attach,
 };
 
 /*
- * Reserve space for the dynamic shared hash table
+ * Resolve the path of the backing file.  The result is cached in a static
+ * buffer so that the cleanup callback can unlink the same path that was
+ * mapped, regardless of later CWD or environment changes.
  */
-static void
-InjectionPointShmemRequest(void *arg)
+static const char *
+injection_points_file_path(void)
 {
-	ShmemRequestStruct(.name = "InjectionPoint hash",
-					   .size = sizeof(InjectionPointsCtl),
-					   .ptr = (void **) &ActiveInjectionPoints,
-		);
+	static char path[MAXPGPATH];
+	const char *env;
+
+	if (path[0] != '\0')
+		return path;
+
+	env = getenv(INJ_POINTS_FILE_ENV);
+	if (env != NULL && env[0] != '\0')
+		strlcpy(path, env, sizeof(path));
+	else if (DataDir != NULL && DataDir[0] != '\0')
+	{
+		/*
+		 * Anchor the file at the data directory rather than relying on 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 and
+		 * the attach would fail.  This also matches the absolute path that
+		 * out-of-process tools build from the data directory they are given.
+		 */
+		snprintf(path, sizeof(path), "%s/%s", DataDir, INJ_POINTS_FILE);
+	}
+	else
+		strlcpy(path, INJ_POINTS_FILE, sizeof(path));
+
+	return path;
 }
 
+/*
+ * Initialize a freshly-created array.
+ */
 static void
-InjectionPointShmemInit(void *arg)
+injection_points_init_ctl(InjectionPointsCtl *ctl)
 {
-	pg_atomic_init_u32(&ActiveInjectionPoints->max_inuse, 0);
+	pg_atomic_init_u32(&ctl->max_inuse, 0);
 	for (int i = 0; i < MAX_INJECTION_POINTS; i++)
-		pg_atomic_init_u64(&ActiveInjectionPoints->entries[i].generation, 0);
+		pg_atomic_init_u64(&ctl->entries[i].generation, 0);
+}
+
+/*
+ * proc_exit callback that drops the mapping and, in the process that owns the
+ * cluster lifecycle (the postmaster, or a standalone backend), unlinks the
+ * backing file so it does not survive the cluster.  Forked children inherit
+ * this callback but must not remove the file.
+ */
+static void
+injection_points_file_cleanup(int code, Datum arg)
+{
+	if (ActiveInjectionPoints != NULL)
+	{
+#ifndef WIN32
+		munmap(ActiveInjectionPoints, sizeof(InjectionPointsCtl));
+#else
+		UnmapViewOfFile(ActiveInjectionPoints);
+#endif
+		ActiveInjectionPoints = NULL;
+	}
+
+	if (!IsUnderPostmaster)
+		(void) unlink(injection_points_file_path());
+}
+
+#ifndef WIN32
+/*
+ * 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_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 file \"%s\": %m",
+							path)));
+			return;
+		}
+		if (st.st_size >= (off_t) size)
+			return;
+		pg_usleep(1000L);		/* 1ms */
+	}
+
+	ereport(elevel,
+			(errmsg("injection point file \"%s\" was never sized by its creator",
+					path)));
+}
+#endif
+
+/*
+ * Map the backing file into this process, creating and/or initializing it as
+ * dictated by "mode", and set ActiveInjectionPoints.
+ *
+ * Accessors must always 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_points(InjectionMapMode mode, int elevel)
+{
+	const char *path = injection_points_file_path();
+	Size		size = sizeof(InjectionPointsCtl);
+	bool		created = false;
+
+	if (ActiveInjectionPoints != NULL)
+		return;
+
+#ifndef WIN32
+	{
+		int			fd;
+		int			oflags = O_RDWR;
+
+		if (mode == INJ_MAP_ATTACH_OR_CREATE)
+			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)
+		{
+			/* A missing file simply means nothing has been armed yet. */
+			if (mode == INJ_MAP_ATTACH_IF_EXISTS && errno == ENOENT)
+				return;
+			ereport(elevel,
+					(errcode_for_file_access(),
+					 errmsg("could not open injection point 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 file \"%s\": %m",
+							path)));
+		}
+
+		ActiveInjectionPoints = mmap(NULL, size, PROT_READ | PROT_WRITE,
+									 MAP_SHARED, fd, 0);
+		CloseTransientFile(fd);
+
+		if (ActiveInjectionPoints == MAP_FAILED)
+		{
+			ActiveInjectionPoints = NULL;
+			ereport(elevel,
+					(errcode_for_file_access(),
+					 errmsg("could not map injection point 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_OR_CREATE) ? CREATE_NEW : OPEN_EXISTING;
+
+		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 to start.  Do not
+		 * insist on a specific error code: besides ERROR_FILE_EXISTS, a file
+		 * whose deletion is still pending reports ERROR_ACCESS_DENIED, and we
+		 * want to recover from that too.  FILE_SHARE_DELETE (above) lets such a
+		 * file be reopened and lets the cleanup unlink it 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)
+		{
+			DWORD		err = GetLastError();
+
+			/* A missing file simply means nothing has been armed yet. */
+			if (mode == INJ_MAP_ATTACH_IF_EXISTS &&
+				(err == ERROR_FILE_NOT_FOUND || err == ERROR_PATH_NOT_FOUND))
+				return;
+			ereport(elevel,
+					(errmsg("could not open injection point file \"%s\": error code %lu",
+							path, err)));
+		}
+
+		/* 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 file \"%s\": error code %lu",
+							path, GetLastError())));
+		}
+
+		ActiveInjectionPoints = MapViewOfFile(hmap, FILE_MAP_ALL_ACCESS, 0, 0, size);
+		CloseHandle(hmap);
+		CloseHandle(hfile);
+
+		if (ActiveInjectionPoints == NULL)
+			ereport(elevel,
+					(errmsg("could not map injection point file \"%s\": error code %lu",
+							path, GetLastError())));
+	}
+#endif
+
+	if (created)
+	{
+		injection_points_init_ctl(ActiveInjectionPoints);
+		on_proc_exit(injection_points_file_cleanup, 0);
+	}
+}
+
+static void
+injection_shmem_init(void *arg)
+{
+	injection_map_points(INJ_MAP_ATTACH_OR_CREATE, FATAL);
+}
+
+static void
+injection_shmem_attach(void *arg)
+{
+	injection_map_points(INJ_MAP_ATTACH, FATAL);
 }
 #endif							/* USE_INJECTION_POINTS */
 
@@ -412,6 +713,26 @@ InjectionPointCacheRefresh(const char *name)
 	InjectionPointEntry local_copy;
 	InjectionPointCacheEntry *cached;
 
+	/*
+	 * The registry may not be mapped yet in this process: an EXEC_BACKEND
+	 * child reaches early points (e.g. "backend-initialize") before its shmem
+	 * attach callback runs, and the postmaster itself runs points before it
+	 * creates the file.  Map it lazily so a point armed out of band, or before
+	 * this process attached, is never missed.  If no file exists nothing has
+	 * been armed; a genuine mapping failure is reported rather than hidden.
+	 */
+	if (ActiveInjectionPoints == NULL)
+		injection_map_points(INJ_MAP_ATTACH_IF_EXISTS, ERROR);
+	if (ActiveInjectionPoints == NULL)
+	{
+		if (InjectionPointCache)
+		{
+			hash_destroy(InjectionPointCache);
+			InjectionPointCache = NULL;
+		}
+		return NULL;
+	}
+
 	/*
 	 * First read the number of in-use slots.  More entries can be added or
 	 * existing ones can be removed while we're reading them.  If the entry
-- 
2.50.1 (Apple Git-155)



view thread (21+ messages)  latest in thread

reply

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Reply to all the recipients using the --to and --cc options:
  reply via email

  To: [email protected]
  Cc: [email protected], [email protected], [email protected], [email protected], [email protected]
  Subject: Re: injection_points: Switch wait/wakeup to use atomics rather than latches
  In-Reply-To: <[email protected]>

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox