From 6e679f302fc136c9a3f716ce51ef8e606ddc4e86 Mon Sep 17 00:00:00 2001
From: spoondla <s_poondla@apple.com>
Date: Fri, 23 Jan 2026 17:28:54 -0800
Subject: [PATCH v7] Fix (bug #19382) server crash when ALTER TYPE is used
 mid-transaction in PL/pgSQL

When ALTER TYPE changes a composite type's column types within a
transaction, PL/pgSQL record variables that were populated before
the ALTER still hold data in the old format. Returning such records
causes a crash because the output functions expect data matching the
new type definition, not the old one.

The crash manifested as a segmentation fault in record_out() when it
attempted to interpret integer data as a text pointer, due to the
mismatch between the stored data and the current type definition.

The fix snapshots tupDesc_identifier values for all composite types
reachable from a record variable's type (including nested composite
types) at assignment time. At RETURN/RETURN NEXT time, these
identifiers are compared against current values from the type cache.
If any have changed, an error is raised instead of risking a crash.
---
 .../plpgsql/src/expected/plpgsql_record.out   | 113 +++++++
 src/pl/plpgsql/src/pl_exec.c                  | 286 +++++++++++++++++-
 src/pl/plpgsql/src/plpgsql.h                  |   9 +
 src/pl/plpgsql/src/sql/plpgsql_record.sql     | 104 +++++++
 4 files changed, 511 insertions(+), 1 deletion(-)

diff --git a/src/pl/plpgsql/src/expected/plpgsql_record.out b/src/pl/plpgsql/src/expected/plpgsql_record.out
index 511f9e03c85..1d2bbeae5a3 100644
--- a/src/pl/plpgsql/src/expected/plpgsql_record.out
+++ b/src/pl/plpgsql/src/expected/plpgsql_record.out
@@ -885,3 +885,116 @@ table two_int8s_tab;
  (42,42)
 (1 row)
 
+-- Tests for bug #19382: server crash when ALTER TYPE is used mid-transaction
+-- in PL/pgSQL. Record variables populated before ALTER TYPE must not be
+-- returned, as the stored data no longer matches the current type definition.
+-- Case 1: Direct composite type change (INT -> TEXT)
+create type bug19382_foo as (a int, b int);
+create function bug19382_test_direct() returns record as $$
+declare r bug19382_foo := row(123, power(2, 30));
+begin
+    alter type bug19382_foo alter attribute b type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_direct();
+ERROR:  cannot return record variable "r" after composite type "bug19382_foo" was altered
+CONTEXT:  PL/pgSQL function bug19382_test_direct() line 5 at RETURN
+drop function bug19382_test_direct();
+drop type bug19382_foo cascade;
+-- Case 2: Nested composite type change
+create type bug19382_inner as (x int, y int);
+create type bug19382_outer as (a int, b bug19382_inner);
+create function bug19382_test_nested() returns record as $$
+declare r bug19382_outer;
+begin
+    r := row(1, row(10, power(2, 30)::int4)::bug19382_inner)::bug19382_outer;
+    alter type bug19382_inner alter attribute y type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_nested();
+ERROR:  cannot return record variable "r" after composite type "bug19382_inner" was altered
+CONTEXT:  PL/pgSQL function bug19382_test_nested() line 6 at RETURN
+drop function bug19382_test_nested();
+drop type bug19382_outer cascade;
+drop type bug19382_inner cascade;
+-- Case 3: OUT parameter
+create type bug19382_foo1 as (a int, b int);
+create function bug19382_test_out(out r1 bug19382_foo1) as $$
+begin
+    r1 := row(1, 2);
+    alter type bug19382_foo1 alter attribute b type text;
+    return;
+end;
+$$ language plpgsql;
+select bug19382_test_out();
+ERROR:  cannot return record variable "r1" after composite type "bug19382_foo1" was altered
+CONTEXT:  PL/pgSQL function bug19382_test_out() line 5 at RETURN
+drop function bug19382_test_out();
+drop type bug19382_foo1 cascade;
+-- Case 4: No ALTER TYPE (baseline — must not error)
+create type bug19382_foo2 as (a int, b int);
+create function bug19382_test_baseline() returns bug19382_foo2 as $$
+declare r bug19382_foo2 := row(1, 2);
+begin
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_baseline();
+ bug19382_test_baseline 
+------------------------
+ (1,2)
+(1 row)
+
+drop function bug19382_test_baseline();
+drop type bug19382_foo2;
+-- Case 5: Field-by-field assignment (dot notation)
+create type bug19382_foo3 as (a int, b int);
+create function bug19382_test_field_assign() returns record as $$
+declare r bug19382_foo3;
+begin
+    r.a := 123;
+    r.b := power(2, 30)::int4;
+    alter type bug19382_foo3 alter attribute b type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_field_assign();
+ERROR:  cannot return record variable "r" after composite type "bug19382_foo3" was altered
+CONTEXT:  PL/pgSQL function bug19382_test_field_assign() line 7 at RETURN
+drop function bug19382_test_field_assign();
+drop type bug19382_foo3 cascade;
+-- Case 6: SELECT INTO individual fields
+create type bug19382_foo4 as (a int, b int);
+create table bug19382_tbl (a int, b int);
+insert into bug19382_tbl values (123, power(2, 30)::int4);
+create function bug19382_test_select_into_field() returns record as $$
+declare r bug19382_foo4;
+begin
+    select a, b into r.a, r.b from bug19382_tbl;
+    alter type bug19382_foo4 alter attribute b type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_select_into_field();
+ERROR:  cannot return record variable "r" after composite type "bug19382_foo4" was altered
+CONTEXT:  PL/pgSQL function bug19382_test_select_into_field() line 6 at RETURN
+drop function bug19382_test_select_into_field();
+drop table bug19382_tbl;
+drop type bug19382_foo4 cascade;
+-- Case 7: RAISE NOTICE with record variable (exec_eval_datum path)
+create type bug19382_foo5 as (a int, b int);
+create function bug19382_test_eval_datum() returns void as $$
+declare r bug19382_foo5;
+begin
+    r.b := power(2, 30)::int4;
+    alter type bug19382_foo5 alter attribute b type text;
+    raise notice 'r = %', r;
+end;
+$$ language plpgsql;
+select bug19382_test_eval_datum();
+ERROR:  cannot return record variable "r" after composite type "bug19382_foo5" was altered
+CONTEXT:  PL/pgSQL function bug19382_test_eval_datum() line 6 at RAISE
+drop function bug19382_test_eval_datum();
+drop type bug19382_foo5 cascade;
diff --git a/src/pl/plpgsql/src/pl_exec.c b/src/pl/plpgsql/src/pl_exec.c
index 65b0fd0790f..a7c8f8a8bf9 100644
--- a/src/pl/plpgsql/src/pl_exec.c
+++ b/src/pl/plpgsql/src/pl_exec.c
@@ -470,6 +470,12 @@ static char *format_preparedparamsdata(PLpgSQL_execstate *estate,
 static PLpgSQL_variable *make_callstmt_target(PLpgSQL_execstate *estate,
 											  PLpgSQL_expr *expr);
 
+static void check_record_type_not_altered(PLpgSQL_rec *rec);
+static void collect_composite_type_versions(Oid typid,
+											Oid **oids, uint64 **versions,
+											int *n, int *alloc);
+static void snapshot_record_composite_types(PLpgSQL_execstate *estate,
+											PLpgSQL_rec *rec);
 
 /* ----------
  * plpgsql_exec_function	Called by the call handler for
@@ -3287,8 +3293,30 @@ exec_stmt_return(PLpgSQL_execstate *estate, PLpgSQL_stmt_return *stmt)
 				}
 				break;
 
-			case PLPGSQL_DTYPE_ROW:
 			case PLPGSQL_DTYPE_REC:
+				{
+					PLpgSQL_rec *rec = (PLpgSQL_rec *) retvar;
+					int32		rettypmod;
+
+					/*
+					 * Check if the record's composite type was altered since
+					 * the record was populated. If so, raise an error to
+					 * prevent crashes when outputting the record.
+					 */
+					if (rec->rectypeid != RECORDOID && rec->erh != NULL &&
+						!ExpandedRecordIsEmpty(rec->erh))
+						check_record_type_not_altered(rec);
+
+					exec_eval_datum(estate,
+									retvar,
+									&estate->rettype,
+									&rettypmod,
+									&estate->retval,
+									&estate->retisnull);
+				}
+				break;
+
+			case PLPGSQL_DTYPE_ROW:
 				{
 					/* exec_eval_datum can handle these cases */
 					int32		rettypmod;
@@ -3434,6 +3462,14 @@ exec_stmt_return_next(PLpgSQL_execstate *estate,
 					TupleDesc	rec_tupdesc;
 					TupleConversionMap *tupmap;
 
+					/*
+					 * Check if the record's composite type was altered since
+					 * the record was populated. If so, raise an error to
+					 * prevent crashes when storing to the tuplestore.
+					 */
+					if (rec->rectypeid != RECORDOID && rec->erh != NULL)
+						check_record_type_not_altered(rec);
+
 					/* If rec is null, try to convert it to a row of nulls */
 					if (rec->erh == NULL)
 						instantiate_empty_record_variable(estate, rec);
@@ -5451,6 +5487,15 @@ exec_eval_datum(PLpgSQL_execstate *estate,
 				}
 				else
 				{
+					/*
+					 * Check if the record's composite type was altered
+					 * since the record was populated.  This catches all
+					 * output paths: RETURN, RAISE, EXECUTE USING, etc.
+					 */
+					if (rec->rectypeid != RECORDOID &&
+						!ExpandedRecordIsEmpty(rec->erh))
+						check_record_type_not_altered(rec);
+
 					if (ExpandedRecordIsEmpty(rec->erh))
 					{
 						/* Empty record is also a NULL */
@@ -7042,6 +7087,14 @@ exec_move_row(PLpgSQL_execstate *estate,
 				if (rec->erh)
 					DeleteExpandedObject(ExpandedRecordGetDatum(rec->erh));
 				rec->erh = NULL;
+				/* Clear composite type snapshot */
+				if (rec->compTypeOids)
+					pfree(rec->compTypeOids);
+				if (rec->compTypeVersions)
+					pfree(rec->compTypeVersions);
+				rec->nCompTypes = 0;
+				rec->compTypeOids = NULL;
+				rec->compTypeVersions = NULL;
 			}
 			return;
 		}
@@ -7925,6 +7978,9 @@ instantiate_empty_record_variable(PLpgSQL_execstate *estate, PLpgSQL_rec *rec)
 	/* OK, do it */
 	rec->erh = make_expanded_record_from_typeid(rec->rectypeid, -1,
 												estate->datum_context);
+
+	/* Snapshot composite type versions for ALTER TYPE detection */
+	snapshot_record_composite_types(estate, rec);
 }
 
 /* ----------
@@ -8967,6 +9023,9 @@ assign_record_var(PLpgSQL_execstate *estate, PLpgSQL_rec *rec,
 
 	/* ... and install the new */
 	rec->erh = erh;
+
+	/* Snapshot composite type versions for ALTER TYPE detection */
+	snapshot_record_composite_types(estate, rec);
 }
 
 /*
@@ -9216,3 +9275,228 @@ format_preparedparamsdata(PLpgSQL_execstate *estate,
 
 	return paramstr.data;
 }
+
+/*
+ * check_record_type_not_altered
+ *
+ * Check if any composite type reachable from this record's type has been
+ * altered since the record was populated.  If so, raise an error to prevent
+ * crashes that would occur when outputting data that no longer matches the
+ * current type definition.
+ *
+ * The outermost type is always checked using er_tupdesc_id (which is set
+ * when the ExpandedRecord is created and works regardless of how the record
+ * was populated, whether by whole assignment, field assignment, etc.).
+ *
+ * Nested composite types are checked against the snapshot taken at record
+ * assignment time, if available.
+ */
+static void
+check_record_type_not_altered(PLpgSQL_rec *rec)
+{
+	TypeCacheEntry *typentry;
+	Oid			check_typid;
+	int			i;
+
+	/* Nothing to do for anonymous RECORD type */
+	if (rec->rectypeid == RECORDOID)
+		return;
+
+	/*
+	 * Always check outermost type using er_tupdesc_id.  This works for all
+	 * code paths (whole assignment, field assignment, SELECT INTO, etc.)
+	 * because er_tupdesc_id is set when the ExpandedRecord is created.
+	 * Resolve domain types to their base composite type first.
+	 */
+	check_typid = rec->rectypeid;
+	if (get_typtype(check_typid) == TYPTYPE_DOMAIN)
+		check_typid = getBaseType(check_typid);
+
+	typentry = lookup_type_cache(check_typid, TYPECACHE_TUPDESC);
+
+	if (rec->erh->er_tupdesc_id != typentry->tupDesc_identifier)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATATYPE_MISMATCH),
+				 errmsg("cannot return record variable \"%s\" after composite type \"%s\" was altered",
+						rec->refname,
+						format_type_be(check_typid))));
+
+	/*
+	 * If we have a snapshot of nested composite types (taken at whole-record
+	 * assignment time), check those too.  Skip index 0 since that's the
+	 * outermost type we already checked above.
+	 */
+	for (i = 1; i < rec->nCompTypes; i++)
+	{
+		typentry = lookup_type_cache(rec->compTypeOids[i], TYPECACHE_TUPDESC);
+
+		if (typentry->tupDesc_identifier != rec->compTypeVersions[i])
+			ereport(ERROR,
+					(errcode(ERRCODE_DATATYPE_MISMATCH),
+					 errmsg("cannot return record variable \"%s\" after composite type \"%s\" was altered",
+							rec->refname,
+							format_type_be(rec->compTypeOids[i]))));
+	}
+}
+
+/*
+ * collect_composite_type_versions
+ *
+ * Recursively collect tupDesc_identifier values for a composite type and
+ * all composite types reachable from its attributes.  Skips anonymous
+ * RECORD types and types already recorded (to prevent infinite recursion).
+ *
+ * oids/versions arrays are repalloc'd as needed; n/alloc updated in place.
+ */
+static void
+collect_composite_type_versions(Oid typid,
+								Oid **oids, uint64 **versions,
+								int *n, int *alloc)
+{
+	TypeCacheEntry *typentry;
+	TupleDesc	tupdesc;
+	int			i;
+
+	/* Resolve domain types to their base composite type */
+	if (get_typtype(typid) == TYPTYPE_DOMAIN)
+		typid = getBaseType(typid);
+
+	/* Skip if already recorded */
+	for (i = 0; i < *n; i++)
+	{
+		if ((*oids)[i] == typid)
+			return;
+	}
+
+	typentry = lookup_type_cache(typid, TYPECACHE_TUPDESC);
+
+	/* Grow arrays if needed */
+	if (*n >= *alloc)
+	{
+		*alloc *= 2;
+		*oids = repalloc(*oids, *alloc * sizeof(Oid));
+		*versions = repalloc(*versions, *alloc * sizeof(uint64));
+	}
+
+	(*oids)[*n] = typid;
+	(*versions)[*n] = typentry->tupDesc_identifier;
+	(*n)++;
+
+	tupdesc = typentry->tupDesc;
+	if (tupdesc == NULL)
+		return;
+
+	/* Recurse into composite-type attributes */
+	for (i = 0; i < tupdesc->natts; i++)
+	{
+		Form_pg_attribute attr = TupleDescAttr(tupdesc, i);
+		Oid			attrtypid;
+		char		typtype;
+
+		if (attr->attisdropped)
+			continue;
+
+		attrtypid = attr->atttypid;
+		if (attrtypid == RECORDOID)
+			continue;
+
+		typtype = get_typtype(attrtypid);
+
+		/* Resolve domain types to their base type */
+		if (typtype == TYPTYPE_DOMAIN)
+		{
+			attrtypid = getBaseType(attrtypid);
+			typtype = get_typtype(attrtypid);
+		}
+
+		if (typtype == TYPTYPE_COMPOSITE)
+			collect_composite_type_versions(attrtypid,
+										   oids, versions, n, alloc);
+	}
+}
+
+/*
+ * snapshot_record_composite_types
+ *
+ * Take a snapshot of tupDesc_identifier values for all composite types
+ * reachable from the record's declared type.  Called when a record variable
+ * is assigned a new value, so that check_record_type_not_altered() can
+ * detect mid-transaction ALTER TYPE at RETURN time.
+ */
+static void
+snapshot_record_composite_types(PLpgSQL_execstate *estate,
+								PLpgSQL_rec *rec)
+{
+	MemoryContext oldcxt;
+	int			alloc = 8;
+	int			n = 0;
+	Oid		   *oids;
+	uint64	   *versions;
+
+	/* Nothing to do for anonymous RECORD type */
+	if (rec->rectypeid == RECORDOID)
+	{
+		rec->nCompTypes = 0;
+		return;
+	}
+
+	/*
+	 * Fast path: if we already have a snapshot for this type and the
+	 * outermost type's identifier hasn't changed, the snapshot is still
+	 * valid.  This avoids expensive type tree walks and syscache lookups
+	 * when a record is assigned repeatedly in a loop.
+	 *
+	 * If the identifier HAS changed (ALTER TYPE happened), fall through
+	 * to re-snapshot with the new identifiers.
+	 *
+	 * Note: compTypeOids[0] stores the resolved base composite type OID
+	 * (domains are resolved by collect_composite_type_versions), so we
+	 * must resolve rec->rectypeid before comparing.
+	 */
+	if (rec->nCompTypes > 0)
+	{
+		Oid			root_typid = rec->rectypeid;
+
+		if (get_typtype(root_typid) == TYPTYPE_DOMAIN)
+			root_typid = getBaseType(root_typid);
+
+		if (rec->compTypeOids[0] == root_typid)
+		{
+			TypeCacheEntry *typentry;
+
+			typentry = lookup_type_cache(root_typid, TYPECACHE_TUPDESC);
+			if (typentry->tupDesc_identifier == rec->compTypeVersions[0])
+				return;		/* type unchanged, snapshot still valid */
+		}
+	}
+
+	oldcxt = MemoryContextSwitchTo(estate->datum_context);
+	oids = palloc(alloc * sizeof(Oid));
+	versions = palloc(alloc * sizeof(uint64));
+
+	collect_composite_type_versions(rec->rectypeid,
+									&oids, &versions, &n, &alloc);
+
+	MemoryContextSwitchTo(oldcxt);
+
+	if (n > 0)
+	{
+		/* Free previous snapshot if any */
+		if (rec->compTypeOids)
+			pfree(rec->compTypeOids);
+		if (rec->compTypeVersions)
+			pfree(rec->compTypeVersions);
+
+		rec->nCompTypes = n;
+		rec->compTypeOids = oids;
+		rec->compTypeVersions = versions;
+	}
+	else
+	{
+		pfree(oids);
+		pfree(versions);
+		rec->nCompTypes = 0;
+		rec->compTypeOids = NULL;
+		rec->compTypeVersions = NULL;
+	}
+}
diff --git a/src/pl/plpgsql/src/plpgsql.h b/src/pl/plpgsql/src/plpgsql.h
index addb14a9959..cf9a657613d 100644
--- a/src/pl/plpgsql/src/plpgsql.h
+++ b/src/pl/plpgsql/src/plpgsql.h
@@ -435,6 +435,15 @@ typedef struct PLpgSQL_rec
 
 	/* We always store record variables as "expanded" records */
 	ExpandedRecordHeader *erh;
+
+	/*
+	 * Composite type version snapshot for ALTER TYPE detection.
+	 * Populated when the record is assigned; checked at RETURN time.
+	 * Includes the outermost type and all nested composite types.
+	 */
+	int			nCompTypes;
+	Oid		   *compTypeOids;
+	uint64	   *compTypeVersions;
 } PLpgSQL_rec;
 
 /*
diff --git a/src/pl/plpgsql/src/sql/plpgsql_record.sql b/src/pl/plpgsql/src/sql/plpgsql_record.sql
index 4fbed38b8bb..f4035881c86 100644
--- a/src/pl/plpgsql/src/sql/plpgsql_record.sql
+++ b/src/pl/plpgsql/src/sql/plpgsql_record.sql
@@ -577,3 +577,107 @@ insert into two_int8s_tab values (compresult(42));
 -- reconnect so we lose any local knowledge of anonymous record types
 \c -
 table two_int8s_tab;
+
+-- Tests for bug #19382: server crash when ALTER TYPE is used mid-transaction
+-- in PL/pgSQL. Record variables populated before ALTER TYPE must not be
+-- returned, as the stored data no longer matches the current type definition.
+
+-- Case 1: Direct composite type change (INT -> TEXT)
+create type bug19382_foo as (a int, b int);
+create function bug19382_test_direct() returns record as $$
+declare r bug19382_foo := row(123, power(2, 30));
+begin
+    alter type bug19382_foo alter attribute b type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_direct();
+drop function bug19382_test_direct();
+drop type bug19382_foo cascade;
+
+-- Case 2: Nested composite type change
+create type bug19382_inner as (x int, y int);
+create type bug19382_outer as (a int, b bug19382_inner);
+create function bug19382_test_nested() returns record as $$
+declare r bug19382_outer;
+begin
+    r := row(1, row(10, power(2, 30)::int4)::bug19382_inner)::bug19382_outer;
+    alter type bug19382_inner alter attribute y type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_nested();
+drop function bug19382_test_nested();
+drop type bug19382_outer cascade;
+drop type bug19382_inner cascade;
+
+-- Case 3: OUT parameter
+create type bug19382_foo1 as (a int, b int);
+create function bug19382_test_out(out r1 bug19382_foo1) as $$
+begin
+    r1 := row(1, 2);
+    alter type bug19382_foo1 alter attribute b type text;
+    return;
+end;
+$$ language plpgsql;
+select bug19382_test_out();
+drop function bug19382_test_out();
+drop type bug19382_foo1 cascade;
+
+-- Case 4: No ALTER TYPE (baseline — must not error)
+create type bug19382_foo2 as (a int, b int);
+create function bug19382_test_baseline() returns bug19382_foo2 as $$
+declare r bug19382_foo2 := row(1, 2);
+begin
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_baseline();
+drop function bug19382_test_baseline();
+drop type bug19382_foo2;
+
+-- Case 5: Field-by-field assignment (dot notation)
+create type bug19382_foo3 as (a int, b int);
+create function bug19382_test_field_assign() returns record as $$
+declare r bug19382_foo3;
+begin
+    r.a := 123;
+    r.b := power(2, 30)::int4;
+    alter type bug19382_foo3 alter attribute b type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_field_assign();
+drop function bug19382_test_field_assign();
+drop type bug19382_foo3 cascade;
+
+-- Case 6: SELECT INTO individual fields
+create type bug19382_foo4 as (a int, b int);
+create table bug19382_tbl (a int, b int);
+insert into bug19382_tbl values (123, power(2, 30)::int4);
+create function bug19382_test_select_into_field() returns record as $$
+declare r bug19382_foo4;
+begin
+    select a, b into r.a, r.b from bug19382_tbl;
+    alter type bug19382_foo4 alter attribute b type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_select_into_field();
+drop function bug19382_test_select_into_field();
+drop table bug19382_tbl;
+drop type bug19382_foo4 cascade;
+
+-- Case 7: RAISE NOTICE with record variable (exec_eval_datum path)
+create type bug19382_foo5 as (a int, b int);
+create function bug19382_test_eval_datum() returns void as $$
+declare r bug19382_foo5;
+begin
+    r.b := power(2, 30)::int4;
+    alter type bug19382_foo5 alter attribute b type text;
+    raise notice 'r = %', r;
+end;
+$$ language plpgsql;
+select bug19382_test_eval_datum();
+drop function bug19382_test_eval_datum();
+drop type bug19382_foo5 cascade;
-- 
2.39.5 (Apple Git-154)

