From 174f0e288dd1e11c31550ff06bac8c6ddb541803 Mon Sep 17 00:00:00 2001 From: Andrey Borodin Date: Fri, 12 Jun 2026 10:38:21 +0500 Subject: [PATCH v2026-06-12 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 | 245 ++++++++-- .../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, 881 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..a868e3d2ac5 100644 --- a/src/test/modules/injection_points/injection_points.c +++ b/src/test/modules/injection_points/injection_points.c @@ -17,13 +17,18 @@ #include "postgres.h" +#include +#ifndef WIN32 +#include +#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 +42,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 +51,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 +100,217 @@ 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); } +/* + * 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_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; + } + + /* + * Only the postmaster (or a standalone backend) should unlink the file; + * forked children inherit this callback but must not remove it. + */ + if (!IsUnderPostmaster) + (void) unlink(INJ_STATE_FILE); +} + +/* + * 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_shmem_request(void *arg) +injection_map_state(InjectionMapMode mode, int elevel) { - ShmemRequestStruct(.name = "injection_points", - .size = sizeof(InjectionPointSharedState), - .ptr = (void **) &inj_state, - ); + Size size = sizeof(InjectionPointSharedState); + bool created = false; + + if (inj_state != NULL) + return; + + if (mode == INJ_MAP_CREATE) + (void) unlink(INJ_STATE_FILE); /* 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(INJ_STATE_FILE, 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(INJ_STATE_FILE, oflags); + } + 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", + INJ_STATE_FILE))); + + if (created && ftruncate(fd, size) != 0) + { + CloseTransientFile(fd); + (void) unlink(INJ_STATE_FILE); + ereport(elevel, + (errcode_for_file_access(), + errmsg("could not size injection point state file \"%s\": %m", + INJ_STATE_FILE))); + } + + 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", + INJ_STATE_FILE))); + } + } +#else + { + HANDLE hfile; + HANDLE hmap; + DWORD disp = (mode == INJ_MAP_ATTACH) ? OPEN_EXISTING : CREATE_NEW; + + hfile = CreateFile(INJ_STATE_FILE, + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, disp, FILE_ATTRIBUTE_NORMAL, NULL); + if (hfile == INVALID_HANDLE_VALUE && + mode == INJ_MAP_ATTACH_OR_CREATE && + GetLastError() == ERROR_FILE_EXISTS) + { + disp = OPEN_EXISTING; + hfile = CreateFile(INJ_STATE_FILE, + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + 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", + INJ_STATE_FILE, 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", + INJ_STATE_FILE, 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", + INJ_STATE_FILE, 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) { - /* - * First time through, so initialize. This is shared with the dynamic - * initialization using a DSM. - */ - injection_point_init_state(inj_state, NULL); + injection_map_state(INJ_MAP_CREATE, FATAL); +} + +static void +injection_shmem_attach(void *arg) +{ + injection_map_state(INJ_MAP_ATTACH, FATAL); } /* - * Initialize shared memory area for this module through DSM. + * 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) { - bool found; - 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 + +/* 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 +#include +#include +#include +#include +#include + +#ifndef WIN32 +#include +#include +#include +#include +#include +#include +#else +#include +#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)