diff --git a/gdb/testsuite/gdb.base/detach-while-running.c b/gdb/testsuite/gdb.base/detach-while-running.c
new file mode 100644
index 00000000000..9c038f4af18
--- /dev/null
+++ b/gdb/testsuite/gdb.base/detach-while-running.c
@@ -0,0 +1,27 @@
+/* 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 <unistd.h>
+
+int
+main (int argc, char **argv)
+{
+  for (int i = 0; i < 30; i++)
+    sleep (1);
+
+  return 0;
+}
diff --git a/gdb/testsuite/gdb.base/detach-while-running.exp b/gdb/testsuite/gdb.base/detach-while-running.exp
new file mode 100644
index 00000000000..4ce6c7045ea
--- /dev/null
+++ b/gdb/testsuite/gdb.base/detach-while-running.exp
@@ -0,0 +1,95 @@
+# 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/>.
+
+# Test detaching while the inferior is running.  Basically:
+#
+#  (gdb) attach PID
+#  (gdb) c&
+#  (gdb) detach
+
+require can_spawn_for_attach
+
+standard_testfile
+
+if {[build_executable "failed to prepare" $testfile $srcfile {debug}] == -1} {
+    return
+}
+
+# The test proper.  See description above.
+
+proc test {} {
+    global binfile gdb_prompt
+
+    # This test requires executing commands while the target is
+    # running, which, when testing with the remote target, requires
+    # non-stop remote protocol.  Until that variant of the RSP is the
+    # default, force target non-stop mode on.
+    set is_remote \
+	[expr {[target_info exists gdb_protocol] \
+		   && ([target_info gdb_protocol] == "remote" \
+			   || [target_info gdb_protocol] == "extended-remote")}]
+
+    save_vars { ::GDBFLAGS } {
+	if {$is_remote} {
+	    append ::GDBFLAGS " -ex \"maint set target-non-stop on\""
+	}
+	clean_restart ${binfile}
+    }
+
+    set test_spawn_id [spawn_wait_for_attach $binfile]
+    set testpid [spawn_id_get_pid $test_spawn_id]
+
+    set any "\[^\r\n\]*"
+
+    # Iterate more than once so that we test re-attaching after
+    # detaching, in case GDB incorrectly detaches and the process
+    # crashes after the detach.
+    set n_iters 2
+    for {set iter 1} {$iter <= $n_iters} {incr iter} {
+	with_test_prefix "iter=$iter" {
+	    set attached 0
+
+	    gdb_test_multiple "attach $testpid" "attach" {
+		-re "Attaching to program:${any}process $testpid\r\n.*$gdb_prompt " {
+		    pass $gdb_test_name
+		    set attached 1
+		}
+	    }
+
+	    if {!$attached} {
+		break
+	    }
+
+	    gdb_test_multiple "continue &" "" {
+		-re "Continuing\.\r\n$::gdb_prompt " {
+		    pass $gdb_test_name
+		}
+	    }
+
+	    gdb_test "detach" "Detaching from.*"
+
+	    # Sleep a bit before reattaching to let the detached
+	    # process crash and exit if e.g., GDB managed to leave
+	    # breakpoint traps behind.
+	    if {$iter != $n_iters} {
+		sleep 1
+	    }
+	}
+    }
+
+    kill_wait_spawned_process $test_spawn_id
+}
+
+test
diff --git a/gdb/windows-nat.c b/gdb/windows-nat.c
index b123a66ef0f..0d8484c832a 100644
--- a/gdb/windows-nat.c
+++ b/gdb/windows-nat.c
@@ -357,6 +357,13 @@ struct windows_nat_target final : public x86_nat_target<inf_child_target>
      needed.  */
   void wait_for_debug_event_main_thread (DEBUG_EVENT *event);
 
+  /* Force the process_thread thread to return from WaitForDebugEvent.
+     PROCESS_ALIVE is set to false if the inferior process exits while
+     we're trying to break out the process_thread thread.  This can
+     happen because this is called while all threads are running free,
+     while we're trying to detach.  */
+  void break_out_process_thread (bool &process_alive);
+
   /* Queue used to send requests to process_thread.  This is
      implicitly locked.  */
   std::queue<gdb::function_view<bool ()>> m_queue;
@@ -379,6 +386,12 @@ struct windows_nat_target final : public x86_nat_target<inf_child_target>
 
   /* True if currently in async mode.  */
   bool m_is_async = false;
+
+  /* True if we last called ContinueDebugEvent and the process_thread
+     thread is now waiting for events.  False if WaitForDebugEvent
+     already returned an event, and we need to ContinueDebugEvent
+     again to restart the inferior.  */
+  bool m_continued = false;
 };
 
 static void
@@ -498,6 +511,8 @@ windows_nat_target::wait_for_debug_event_main_thread (DEBUG_EVENT *event)
 	wait_for_debug_event (event, INFINITE);
       return false;
     });
+
+  m_continued = false;
 }
 
 /* See nat/windows-nat.h.  */
@@ -1352,6 +1367,8 @@ windows_nat_target::windows_continue (DWORD continue_status, int id,
 				" - ContinueDebugEvent failed"),
 			      *err);
 
+  m_continued = !last_call;
+
   return TRUE;
 }
 
@@ -2072,20 +2089,140 @@ windows_nat_target::attach (const char *args, int from_tty)
   target_terminal::ours ();
 }
 
+void
+windows_nat_target::break_out_process_thread (bool &process_alive)
+{
+  /* This is called when the process_thread thread is blocked in
+     WaitForDebugEvent (unless it already returned some event we
+     haven't consumed yet), and we need to unblock it so that we can
+     have it call DebugActiveProcessStop.
+
+     To make WaitForDebugEvent return, we need to force some event in
+     the inferior.  Any method that lets us do that (without
+     disturbing the other threads), injects a new thread in the
+     inferior.
+
+     We don't use DebugBreakProcess for this, because that injects a
+     thread that ends up executing a breakpoint instruction.  We can't
+     let the injected thread hit that breakpoint _after_ we've
+     detached.  Consuming events until we see a breakpoint trap isn't
+     100% reliable, because we can't distinguish it from some other
+     thread itself deciding to call int3 while we're detaching, unless
+     we temporarily suspend all threads.  It's just a lot of
+     complication, and there's an easier way.
+
+     Important observation: the thread creation event for the newly
+     injected thread is sufficient to unblock WaitForDebugEvent.
+
+     Instead of DebugBreakProcess, we can instead use
+     CreateRemoteThread to control the code that the injected thread
+     runs ourselves.  We could consider pointing the injected thread
+     at some side-effect-free Win32 function as entry point.  However,
+     finding the address of such a function requires having at least
+     minimal symbols loaded for ntdll.dll.  Having a way that avoids
+     that is better, so that detach always works correctly even when
+     we don't have any symbols loaded.
+
+     So what we do is inject a thread that doesn't actually run ANY
+     userspace code, because we force-terminate it as soon as we see
+     its corresponding thread creation event.  CreateRemoteThread
+     gives us the new thread's ID, which we can match with the thread
+     associated with the CREATE_THREAD_DEBUG_EVENT event.  */
+
+  DWORD injected_thread_id = 0;
+  HANDLE injected_thread_handle
+    = CreateRemoteThread (windows_process.handle, NULL,
+			  0, (LPTHREAD_START_ROUTINE) 0,
+			  NULL, 0, &injected_thread_id);
+
+  if (injected_thread_handle == NULL)
+    {
+      DWORD err = GetLastError ();
+
+      DEBUG_EVENTS ("CreateRemoteThread failed with %u", err);
+
+      if (err == ERROR_ACCESS_DENIED)
+	{
+	  /* Creating the remote thread fails with ERROR_ACCESS_DENIED
+	     if the process exited before we had a chance to inject
+	     the thread.  Continue with the loop below and consume the
+	     process exit event anyhow, so that our caller can always
+	     call windows_continue.  */
+	}
+      else
+	throw_winerror_with_name (_("Can't detach from running process.  "
+				    "Interrupt it first."),
+				  err);
+    }
+
+  process_alive = true;
+
+  /* At this point, the user has declared that they want to detach, so
+     any event that happens from this point on should be forwarded to
+     the inferior.  */
+
+  for (;;)
+    {
+      DEBUG_EVENT current_event;
+      wait_for_debug_event_main_thread (&current_event);
+
+      if (current_event.dwDebugEventCode == EXIT_PROCESS_DEBUG_EVENT)
+	{
+	  DEBUG_EVENTS ("got EXIT_PROCESS_DEBUG_EVENT");
+	  process_alive = false;
+	  break;
+	}
+
+      if (current_event.dwDebugEventCode == CREATE_THREAD_DEBUG_EVENT
+	  && current_event.dwThreadId == injected_thread_id)
+	{
+	  DEBUG_EVENTS ("got CREATE_THREAD_DEBUG_EVENT for injected thread");
+
+	  /* Terminate the injected thread, so it doesn't run any code
+	     at all.  All we wanted was some event, and
+	     CREATE_THREAD_DEBUG_EVENT is sufficient.  */
+	  CHECK (TerminateThread (injected_thread_handle, 0));
+	  break;
+	}
+
+      DEBUG_EVENTS ("got unrelated event, code %u",
+		    current_event.dwDebugEventCode);
+      windows_continue (DBG_CONTINUE, -1, 0);
+    }
+
+  if (injected_thread_handle != NULL)
+    CHECK (CloseHandle (injected_thread_handle));
+}
+
 void
 windows_nat_target::detach (inferior *inf, int from_tty)
 {
+  /* If we see the process exit while unblocking the process_thread
+     helper thread, then we should skip the actual
+     DebugActiveProcessStop call.  But don't report an error.  Just
+     pretend the process exited shortly after the detach.  */
+  bool process_alive = true;
+
+  /* The process_thread helper thread will be blocked in
+     WaitForDebugEvent waiting for events if we've resumed the target
+     before we get here, e.g., with "attach&" or "c&".  We need to
+     unblock it so that we can have it call DebugActiveProcessStop
+     below, in the do_synchronously block.  */
+  if (m_continued)
+    break_out_process_thread (process_alive);
+
   windows_continue (DBG_CONTINUE, -1, 0, true);
 
   std::optional<unsigned> err;
-  do_synchronously ([&] ()
-    {
-      if (!DebugActiveProcessStop (windows_process.current_event.dwProcessId))
-	err = (unsigned) GetLastError ();
-      else
-	DebugSetProcessKillOnExit (FALSE);
-      return false;
-    });
+  if (process_alive)
+    do_synchronously ([&] ()
+      {
+	if (!DebugActiveProcessStop (windows_process.current_event.dwProcessId))
+	  err = (unsigned) GetLastError ();
+	else
+	  DebugSetProcessKillOnExit (FALSE);
+	return false;
+      });
 
   if (err.has_value ())
     {
