diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c
index 6a09a1a1517..b0bdb789523 100644
--- a/src/backend/access/heap/heapam.c
+++ b/src/backend/access/heap/heapam.c
@@ -3965,6 +3965,8 @@ l2:
 
 		LockBuffer(buffer, BUFFER_LOCK_UNLOCK);
 
+		INJECTION_POINT("heap_update-toast-unlocked", NULL);
+
 		/*
 		 * Let the toaster do its thing, if needed.
 		 *
@@ -4221,6 +4223,12 @@ l2:
 	oldtup.t_data->t_infomask &= ~(HEAP_XMAX_BITS | HEAP_MOVED);
 	oldtup.t_data->t_infomask2 &= ~HEAP_KEYS_UPDATED;
 	/* ... and store info about transaction updating this tuple */
+	/*
+	 * FIXME: These values were computed before temporarily released the
+	 * lock. If the tuple was locked in FOR KEY SHARE mode while we were not
+	 * holding the lock, these do not contain the locker's XID, and the lock
+	 * is lost.
+	 */
 	Assert(TransactionIdIsValid(xmax_old_tuple));
 	HeapTupleHeaderSetXmax(oldtup.t_data, xmax_old_tuple);
 	oldtup.t_data->t_infomask |= infomask_old_tuple;
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 8e2dfaa2384..b06a149b15c 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -37,7 +37,8 @@ ISOLATION = basic \
 	    $(WAIT_CLEANUP_TST) \
 	    heap_lock_update \
 	    on_conflict_probe_window \
-	    multixact-aborted-updater
+	    multixact-aborted-updater \
+	    heap_update_toast_lock
 
 # some isolation tests require wal_level=replica
 ISOLATION_OPTS = --temp-config $(top_srcdir)/src/test/modules/injection_points/extra.conf
diff --git a/src/test/modules/injection_points/expected/heap_update_toast_lock.out b/src/test/modules/injection_points/expected/heap_update_toast_lock.out
new file mode 100644
index 00000000000..57f0bfab2fc
--- /dev/null
+++ b/src/test/modules/injection_points/expected/heap_update_toast_lock.out
@@ -0,0 +1,26 @@
+Parsed test spec with 3 sessions
+
+starting permutation: s1update s2begin s2keyshare wake s1keyupdate s2commit s1check
+step s1update: UPDATE t SET val = bigstring() WHERE id = 1; <waiting ...>
+step s2begin: BEGIN;
+step s2keyshare: SELECT id FROM t WHERE id = 1 FOR KEY SHARE;
+id
+--
+ 1
+(1 row)
+
+step wake: 
+        SELECT FROM injection_points_detach('heap_update-toast-unlocked');
+        SELECT FROM injection_points_wakeup('heap_update-toast-unlocked');
+ <waiting ...>
+step s1update: <... completed>
+step wake: <... completed>
+step s1keyupdate: UPDATE t SET id = id + 1; <waiting ...>
+step s2commit: COMMIT;
+step s1keyupdate: <... completed>
+step s1check: SELECT id, length(val), other FROM t;
+id|length|other
+--+------+-----
+ 2|  6400|    0
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index 97e7a736282..db455f1d9b5 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -45,6 +45,7 @@ injection_points_isolation = [
   'heap_lock_update',
   'on_conflict_probe_window',
   'multixact-aborted-updater',
+  'heap_update_toast_lock',
 ]
 
 # wait_cleanup terminates another backend, whose FATAL message can be lost
diff --git a/src/test/modules/injection_points/specs/heap_update_toast_lock.spec b/src/test/modules/injection_points/specs/heap_update_toast_lock.spec
new file mode 100644
index 00000000000..32088a534d4
--- /dev/null
+++ b/src/test/modules/injection_points/specs/heap_update_toast_lock.spec
@@ -0,0 +1,73 @@
+# Test that heap_update() protects the old tuple from concurrent updates
+# while it has released the buffer lock to do TOASTing.
+#
+# When the new tuple needs toasting (or doesn't fit on the page), heap_update()
+# temporarily marks the old tuple as locked by its own transaction, using the
+# tuple lock mode computed from the modified columns (LockTupleNoKeyExclusive
+# if no key columns were changed), and releases the buffer lock.  While it's
+# not holding the buffer lock, another transaction can acquire a KEY SHARE lock
+# on the same tuple.
+#
+# The bug: the FOR SHARE lock acquired in that window will be lost when the
+# update finishes, because the update overwrites the tuple's xmax with a new
+# xmax and infomasks that were computed before releasing the lock.
+setup
+{
+        CREATE EXTENSION injection_points;
+
+        CREATE TABLE t (id int PRIMARY KEY, val text, other int);
+        INSERT INTO t VALUES (1, 'initial', 0);
+
+	-- random, incompressible text well over TOAST_TUPLE_THRESHOLD
+	CREATE FUNCTION bigstring() RETURNS text AS $$
+	  SELECT string_agg(md5(i::text), '') FROM generate_series(1, 200) i
+	$$ LANGUAGE SQL
+}
+teardown
+{
+        DROP TABLE t;
+        DROP EXTENSION injection_points;
+}
+
+session s1
+setup   {
+        SELECT FROM injection_points_set_local();
+        SELECT FROM injection_points_attach('heap_update-toast-unlocked', 'wait');
+}
+
+step s1update     { UPDATE t SET val = bigstring() WHERE id = 1; }
+step s1keyupdate  { UPDATE t SET id = id + 1;}
+
+step s1check      { SELECT id, length(val), other FROM t; }
+
+session s2
+step s2begin      { BEGIN; }
+step s2keyshare   { SELECT id FROM t WHERE id = 1 FOR KEY SHARE; }
+step s2commit     { COMMIT; }
+
+session wake
+step wake       {
+        SELECT FROM injection_points_detach('heap_update-toast-unlocked');
+        SELECT FROM injection_points_wakeup('heap_update-toast-unlocked');
+}
+
+# Concurrent non-key update must wait for s1 to commit.
+permutation
+    # Start UPDATE on the tuple. This pauses on the injection point, after
+    # locking the tuple but before completing the UPDATE.
+    s1update
+
+    # Lock the tuple from another session
+    s2begin s2keyshare
+
+    # Finish the update and the s2 locker. Session s2 continues to
+    # hold its KEY SHARE lock.
+    wake(s1update)
+
+    # Try to update the key column. This should block on s2's FOR KEY SHARE lock
+    #
+    # FIXME: this does not block as it should
+    s1keyupdate
+
+    s2commit
+    s1check
