diff --git a/gdb/breakpoint.c b/gdb/breakpoint.c
index 7b84126c241..bca322172e0 100644
--- a/gdb/breakpoint.c
+++ b/gdb/breakpoint.c
@@ -8078,29 +8078,37 @@ disable_breakpoints_in_unloaded_shlib (program_space *pspace, const solib &solib
 {
   bool disabled_shlib_breaks = false;
 
-  for (bp_location *loc : all_bp_locations ())
+  for (breakpoint &b : all_breakpoints ())
     {
-      /* ALL_BP_LOCATIONS bp_location has LOC->OWNER always non-NULL.  */
-      struct breakpoint *b = loc->owner;
+      bool bp_modified = false;
 
-      if (pspace == loc->pspace
-	  && !loc->shlib_disabled
-	  && (((b->type == bp_breakpoint
-		|| b->type == bp_jit_event
-		|| b->type == bp_hardware_breakpoint)
-	       && (loc->loc_type == bp_loc_hardware_breakpoint
-		   || loc->loc_type == bp_loc_software_breakpoint))
-	      || is_tracepoint (b))
-	  && solib_contains_address_p (solib, loc->address))
+      if (b.type != bp_breakpoint
+	  && b.type != bp_jit_event
+	  && b.type != bp_hardware_breakpoint
+	  && !is_tracepoint (&b))
+	continue;
+
+      for (bp_location &loc : b.locations ())
 	{
-	  loc->shlib_disabled = 1;
+	  if (pspace != loc.pspace || loc.shlib_disabled)
+	    continue;
+
+	  if (loc.loc_type != bp_loc_hardware_breakpoint
+	      && loc.loc_type != bp_loc_software_breakpoint
+	      && !is_tracepoint (&b))
+	    continue;
+
+	  if (!solib_contains_address_p (solib, loc.address))
+	    continue;
+
+	  loc.shlib_disabled = 1;
+
 	  /* At this point, we cannot rely on remove_breakpoint
 	     succeeding so we must mark the breakpoint as not inserted
 	     to prevent future errors occurring in remove_breakpoints.  */
-	  loc->inserted = 0;
+	  loc.inserted = 0;
 
-	  /* This may cause duplicate notifications for the same breakpoint.  */
-	  notify_breakpoint_modified (b);
+	  bp_modified = true;
 
 	  if (!disabled_shlib_breaks)
 	    {
@@ -8108,9 +8116,12 @@ disable_breakpoints_in_unloaded_shlib (program_space *pspace, const solib &solib
 	      warning (_("Temporarily disabling breakpoints "
 			 "for unloaded shared library \"%s\""),
 		       solib.so_name.c_str ());
+	      disabled_shlib_breaks = true;
 	    }
-	  disabled_shlib_breaks = true;
 	}
+
+      if (bp_modified)
+	notify_breakpoint_modified (&b);
     }
 }
 
diff --git a/gdb/testsuite/gdb.base/shlib-unload-lib.c b/gdb/testsuite/gdb.base/shlib-unload-lib.c
new file mode 100644
index 00000000000..c38715f7ac9
--- /dev/null
+++ b/gdb/testsuite/gdb.base/shlib-unload-lib.c
@@ -0,0 +1,30 @@
+/* This testcase is part of GDB, the GNU debugger.
+
+   Copyright 2024 Free Software Foundation, Inc.
+
+   This program is free software; you can redistribute it and/or modify
+   it under the terms of the GNU General Public License as published by
+   the Free Software Foundation; either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU General Public License for more details.
+
+   You should have received a copy of the GNU General Public License
+   along with this program.  If not, see <http://www.gnu.org/licenses/>.  */
+
+#include "shlib-unload.h"
+
+int
+foo (void)
+{
+  return inline_func ();
+}
+
+int
+bar (void)
+{
+  return inline_func ();
+}
diff --git a/gdb/testsuite/gdb.base/shlib-unload.c b/gdb/testsuite/gdb.base/shlib-unload.c
new file mode 100644
index 00000000000..4a3cb5d8b5d
--- /dev/null
+++ b/gdb/testsuite/gdb.base/shlib-unload.c
@@ -0,0 +1,63 @@
+/* This testcase is part of GDB, the GNU debugger.
+
+   Copyright 2024 Free Software Foundation, Inc.
+
+   This program is free software; you can redistribute it and/or modify
+   it under the terms of the GNU General Public License as published by
+   the Free Software Foundation; either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU General Public License for more details.
+
+   You should have received a copy of the GNU General Public License
+   along with this program.  If not, see <http://www.gnu.org/licenses/>.  */
+
+#include <stdlib.h>
+
+#ifdef __WIN32__
+#include <windows.h>
+#define dlopen(name, mode) LoadLibrary (TEXT (name))
+#ifdef _WIN32_WCE
+# define dlsym(handle, func) GetProcAddress (handle, TEXT (func))
+#else
+# define dlsym(handle, func) GetProcAddress (handle, func)
+#endif
+#define dlclose(handle) FreeLibrary (handle)
+#else
+#include <dlfcn.h>
+#endif
+
+#include <assert.h>
+
+#include "shlib-unload.h"
+
+int
+main (void)
+{
+  int res;
+  void *handle;
+  int (*func) (void);
+
+  int val = inline_func ();
+
+  handle = dlopen (SHLIB_NAME, RTLD_LAZY);
+  assert (handle != NULL);
+
+  func = (int (*)(void)) dlsym (handle, "foo");
+  assert (func != NULL);
+
+  val += func ();
+
+  func = (int (*)(void)) dlsym (handle, "bar");
+  assert (func != NULL);
+
+  val += func ();
+
+  res = dlclose (handle);	/* Break here.  */
+  assert (res == 0);
+
+  return val;
+}
diff --git a/gdb/testsuite/gdb.base/shlib-unload.exp b/gdb/testsuite/gdb.base/shlib-unload.exp
new file mode 100644
index 00000000000..791f9f518e8
--- /dev/null
+++ b/gdb/testsuite/gdb.base/shlib-unload.exp
@@ -0,0 +1,114 @@
+# Copyright 2024 Free Software Foundation, Inc.
+
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+# Tests for GDB's handling of a shared library being unloaded via a
+# call to dlclose.  See the individual test_* procs for a description
+# of each test.
+
+standard_testfile .c -lib.c
+
+# One of the tests uses this Python file.  The test_* proc checks that
+# GDB supports Python tests.  Some of the other procs don't use this
+# Python file.
+set pyfile [gdb_remote_download host ${srcdir}/${subdir}/${testfile}.py]
+
+# Build the library and copy it to the target.
+set libname ${testfile}-lib
+set libfile [standard_output_file $libname]
+if { [build_executable "build shlib" $libfile $srcfile2 {debug shlib}] == -1} {
+    return
+}
+set libfile_on_target [gdb_download_shlib $libfile]
+
+# Build the executable.
+set opts [list debug shlib_load additional_flags=-DSHLIB_NAME=\"${libname}\"]
+if { [build_executable "build exec" $binfile $srcfile $opts] == -1} {
+    return
+}
+
+# The line number of the dlclose call.
+set bp_line [gdb_get_line_number "Break here" $srcfile]
+
+# If the target is remote, then the library name in the bp_disabled_re
+# below will have a 'target:' prefix.
+if {[is_remote target]} {
+    set target_prefix_re "target:"
+} else {
+    set target_prefix_re ""
+}
+
+# The line emitted when GDB disables breakpoints after unloading a
+# shared library.
+set bp_disabled_re "warning: Temporarily disabling breakpoints for unloaded shared library \"$target_prefix_re[string_to_regexp $::libfile_on_target]\""
+
+# The complete regexp for when GDB stops on the line after BP_LINE,
+# assuming that GDB has disabled some breakpoints.
+set stop_after_bp_re [multi_line \
+			  "^$::bp_disabled_re" \
+			  "[expr $::bp_line + 1]\\s+assert \\(res == 0\\);"]
+
+# Checking that a breakpoint with multiple locations in a shared
+# library only triggers a single breakpoint modified event from
+# disable_breakpoints_in_unloaded_shlib when the shared library is
+# unloaded.
+proc_with_prefix test_bp_modified_events {} {
+    if { ![allow_python_tests] } {
+	unsupported "python support needed"
+	return
+    }
+
+    clean_restart $::binfile
+
+    if {![runto_main]} {
+	return
+    }
+
+    # If the debug information doesn't allow GDB to identify inline
+    # functions then this test isn't going to work.
+    get_debug_format
+    if { [skip_inline_frame_tests] } {
+	unsupported "skipping inline frame tests"
+	return
+    }
+
+    gdb_breakpoint $::srcfile:$::bp_line
+    gdb_continue_to_breakpoint "stop before dlclose"
+
+    gdb_breakpoint inline_func
+    set bp_num [get_integer_valueof "\$bpnum" "*UNKNOWN*" \
+		    "get b/p number"]
+
+    gdb_test_no_output "source $::pyfile" "import python scripts"
+
+    gdb_test "next" $::stop_after_bp_re
+
+    # The breakpoint should have been modified once when some of its
+    # locations are made pending after the shared library is unloaded.
+    gdb_test_multiple "python print(bp_modified_counts\[$bp_num\])" "" {
+	-re -wrap "^1" {
+	    pass $gdb_test_name
+	}
+	-re -wrap "^2" {
+	    # A second event occurs when the pending breakpoint is
+	    # incorrectly deleted.
+	    kfail gdb/32404 $gdb_test_name
+	}
+	-re -wrap "^$::decimal" {
+	    fail $gdb_test_name
+	}
+    }
+}
+
+test_bp_modified_events
diff --git a/gdb/testsuite/gdb.base/shlib-unload.h b/gdb/testsuite/gdb.base/shlib-unload.h
new file mode 100644
index 00000000000..3448eed5cb9
--- /dev/null
+++ b/gdb/testsuite/gdb.base/shlib-unload.h
@@ -0,0 +1,26 @@
+/* This testcase is part of GDB, the GNU debugger.
+
+   Copyright 2024 Free Software Foundation, Inc.
+
+   This program is free software; you can redistribute it and/or modify
+   it under the terms of the GNU General Public License as published by
+   the Free Software Foundation; either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU General Public License for more details.
+
+   You should have received a copy of the GNU General Public License
+   along with this program.  If not, see <http://www.gnu.org/licenses/>.  */
+
+static inline int __attribute__((__always_inline__))
+inline_func ()
+{
+  return 0;
+}
+
+/* Two library functions.  */
+extern int foo (void);
+extern int bar (void);
diff --git a/gdb/testsuite/gdb.base/shlib-unload.py b/gdb/testsuite/gdb.base/shlib-unload.py
new file mode 100644
index 00000000000..eb541fd53fd
--- /dev/null
+++ b/gdb/testsuite/gdb.base/shlib-unload.py
@@ -0,0 +1,31 @@
+# Copyright (C) 2024 Free Software Foundation, Inc.
+
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+# Breakpoint modification events will be recorded in this dictionary.
+# The keys are the b/p numbers, and the values are the number of
+# modification events seen.
+bp_modified_counts = {}
+
+# Record breakpoint modification events into the global
+# bp_modified_counts dictionary.
+def bp_modified(bp):
+    global bp_modified_counts
+    if bp.number not in bp_modified_counts:
+        bp_modified_counts[bp.number] = 1
+    else:
+        bp_modified_counts[bp.number] += 1
+
+# Register the event handler.
+gdb.events.breakpoint_modified.connect(bp_modified)
