]> git.ipfire.org Git - thirdparty/binutils-gdb.git/commitdiff
gdb: crash if thread unexpectedly disappears from thread list
authorAndrew Burgess <aburgess@redhat.com>
Fri, 16 May 2025 16:56:58 +0000 (17:56 +0100)
committerAndrew Burgess <aburgess@redhat.com>
Tue, 23 Sep 2025 17:43:47 +0000 (18:43 +0100)
A bug was reported to Red Hat where GDB was crashing with an assertion
failure, the assertion message is:

  ../../gdb/regcache.c:432: internal-error: get_thread_regcache: Assertion `thread->state != THREAD_EXITED' failed.

The backtrace for the crash is:

  #5  0x000055a21da8a880 in internal_vproblem(internal_problem *, const char *, int, const char *, typedef __va_list_tag __va_list_tag *) (problem=problem@entry=0x55a21e289060 <internal_error_problem>, file=<optimized out>, line=<optimized out>, fmt=<optimized out>, ap=ap@entry=0x7ffec7576be0) at ../../gdb/utils.c:477
  #6  0x000055a21da8aadf in internal_verror (file=<optimized out>, line=<optimized out>, fmt=<optimized out>, ap=ap@entry=0x7ffec7576be0) at ../../gdb/utils.c:503
  #7  0x000055a21dcbd055 in internal_error_loc (file=file@entry=0x55a21dd33b71 "../../gdb/regcache.c", line=line@entry=432, fmt=<optimized out>) at ../../gdbsupport/errors.cc:57
  #8  0x000055a21d8baaa9 in get_thread_regcache (thread=thread@entry=0x55a258de3a50) at ../../gdb/regcache.c:432
  #9  0x000055a21d74fa18 in print_signal_received_reason (uiout=0x55a258b649b0, siggnal=GDB_SIGNAL_TRAP) at ../../gdb/infrun.c:9287
  #10 0x000055a21d7daad9 in mi_interp::on_signal_received (this=0x55a258af5f60, siggnal=GDB_SIGNAL_TRAP) at ../../gdb/mi/mi-interp.c:372
  #11 0x000055a21d76ef99 in interps_notify<void (interp::*)(gdb_signal), gdb_signal&> (method=&virtual table offset 88, this adjustment 974682) at ../../gdb/interps.c:369
  #12 0x000055a21d76e58f in interps_notify_signal_received (sig=<optimized out>, sig@entry=GDB_SIGNAL_TRAP) at ../../gdb/interps.c:378
  #13 0x000055a21d75074d in notify_signal_received (sig=GDB_SIGNAL_TRAP) at ../../gdb/infrun.c:6818
  #14 0x000055a21d755af0 in normal_stop () at ../../gdb/gdbthread.h:432
  #15 0x000055a21d768331 in fetch_inferior_event () at ../../gdb/infrun.c:4753

The user is using a build of GDB with 32-bit ARM support included, and
they gave the following description for what they were doing at the
time of the crash:

  Suspended the execution of the firmware in Eclipse.  The gdb was
  connected to JLinkGDBServer with activated FreeRTOS awareness JLink
  plugin.

So they are remote debugging with a non-gdbserver target.

Looking in normal_stop() we see this code:

  /* As we're presenting a stop, and potentially removing breakpoints,
     update the thread list so we can tell whether there are threads
     running on the target.  With target remote, for example, we can
     only learn about new threads when we explicitly update the thread
     list.  Do this before notifying the interpreters about signal
     stops, end of stepping ranges, etc., so that the "new thread"
     output is emitted before e.g., "Program received signal FOO",
     instead of after.  */
  update_thread_list ();

  if (last.kind () == TARGET_WAITKIND_STOPPED && stopped_by_random_signal)
    notify_signal_received (inferior_thread ()->stop_signal ());

Which accounts for the transition from frame #14 to frame #13.  But it
is the update_thread_list() call which interests me.  This call asks
the target (remote target in this case) for the current thread list,
and then marks threads exited based on the answer.

And so, if a (badly behaved) target (incorrectly) removes a thread
from the thread list, then the update_thread_list() call will mark the
impacted thread as exited, even if GDB is currently handling a signal
stop event for that target.

My guess for what's going on here then is this:

  1. Thread receives a signal.
  2. Remote target sends GDB a stop with signal packet.
  3. Remote decides that the thread is going away soon, and marks the
     thread as exited.
  4. GDB asks for the thread list.
  5. Remote sends back the thread list, which doesn't include the
     event thread, as the remote things this thread has exited.
  6. GDB marks the thread as exited, and then proceeds to try and
     print the signal stop event for the event thread.
  7. Printing the signal stop requires reading registers, which
     requires a regache.  We can only get a regcache for a non-exited
     thread, and so GDB raises an assertion.

Using the gdbreplay test frame work I was able to reproduce this
failure using gdbserver.  I create an inferior with two threads, the
main thread sends a signal to the second thread, GDB sees the signal
arrive and prints this information for the user.

Having captured the trace of this activity, I then find the thread
list reply in the log file, and modify it to remove the second thread.

Now, when I replay the modified log file I see the same assertion
complaining about an attempt to get a regcache for an exited thread.

I'm not entirely sure the best way to fix this.  Clearly the problem
here is a bad remote target.  But, replies from a remote target
should (in my opinion) not be considered trusted, as a consequence, we
should not be asserting based on data coming from a remote.  Instead,
we should be giving warnings or errors and have GDB handle the bad
data as best it can.

This is the second attempt to fix this issue, my first patch can be
seen here:

  https://inbox.sourceware.org/gdb-patches/062e438c8677e2ab28fac6183d2ea6d444cb9121.1747567717.git.aburgess@redhat.com

In the first patch I was to checking in normal_stop, immediately after
the call to update_thread_list, to see if the current thread was now
marked as exited.  However CI testing showed an issue with this
approach; I was already checking for many different TARGET_WAITKIND_*
kinds where the "is the current thread exited" question didn't make
sense, and it turns out that the list of kinds in my first attempt was
already insufficient.

Rather than trying to just adding to the list, in this revised patch
I'm proposing to move the "is this thread exited" check inside the
block which handles signal stop events.

Right now, the only part of normal_stop which I know relies on the
current thread not being exited is the call to notify_signal_received,
so before calling notify_signal_received I check to see if the current
thread is now exited.  If it is then I print a warning to indicate
that the thread has unexpectedly exited and that the current
command (continue/step/etc) has been cancelled, I then change the
current event type to TARGET_WAITKIND_SPURIOUS.

GDB's output now looks like this in all-stop mode:

  (gdb) continue
  Continuing.
  [New Thread 3483690.3483693]
  [Thread 3483690.3483693 exited]
  warning: Thread 3483690.3483693 unexpectedly exited after non-exit event
  [Switching to Thread 3483690.3483693]
  (gdb)

The non-stop output is identical, except we don't switch thread (stop
events never trigger a thread switch in non-stop mode).

The include test makes use of the gdbreplay framework, and tests in
all-stop and non-stop modes.  I would like to do more extensive
testing of GDB's state after the receiving the unexpected thread list,
but due to using gdbreplay for testing, this is quite hard.  Many
commands, especially those looking at thread state, are likely to
trigger additional packets being sent to the remote, which causes
gdbreplay to bail out as the new packet doesn't match the original
recorded state.  However, I really don't think it is a good idea to
change gdbserver in order to "fake" this error case, so for now, using
gdbreplay is the best idea I have.

Bug: https://bugzilla.redhat.com/show_bug.cgi?id=2366461

gdb/infrun.c
gdb/testsuite/gdb.replay/missing-thread.c [new file with mode: 0644]
gdb/testsuite/gdb.replay/missing-thread.exp [new file with mode: 0644]

index 82e7284065b61da903ab15953478fa62b1eb464c..2e59af2a7355cd98429b5a726dca446d92fa643c 100644 (file)
@@ -9582,7 +9582,38 @@ normal_stop ()
   update_thread_list ();
 
   if (last.kind () == TARGET_WAITKIND_STOPPED && stopped_by_random_signal)
-    notify_signal_received (inferior_thread ()->stop_signal ());
+    {
+      gdb_assert (inferior_ptid != null_ptid);
+
+      /* Calling update_thread_list pulls information from the target.  For
+        native targets we can be (reasonably) sure that the information we
+        get back is sane, but for remote targets, we cannot reply on the
+        returned thread list to be correct.
+
+        Specifically, a remote target (not gdbserver), has been seen to
+        prematurely remove threads from the thread list after sending a
+        signal stop event.  The consequence of this, is that the thread
+        might now be exited.  This is bad as, trying to calling
+        notify_signal_received will cause GDB to read registers for the
+        current thread, but requesting the regcache for an exited thread
+        will trigger an assertion.
+
+        Check for the exited thread case here, and convert the stop reason
+        to a spurious stop event.  The thread exiting will have already
+        been reported (when the thread list was parsed), so making this a
+        spurious stop will cause GDB to drop back to the prompt.  */
+      if (inferior_thread ()->state != THREAD_EXITED)
+       notify_signal_received (inferior_thread ()->stop_signal ());
+      else
+       {
+         warning (_("command aborted, %s unexpectedly exited after signal stop event"),
+                  target_pid_to_str (inferior_thread ()->ptid).c_str ());
+
+         /* Mark this as a spurious stop.  GDB will return to the
+            prompt.  The warning above tells the user why.  */
+         last.set_spurious ();
+       }
+    }
 
   /* As with the notification of thread events, we want to delay
      notifying the user that we've switched thread context until
diff --git a/gdb/testsuite/gdb.replay/missing-thread.c b/gdb/testsuite/gdb.replay/missing-thread.c
new file mode 100644 (file)
index 0000000..8edb240
--- /dev/null
@@ -0,0 +1,61 @@
+/* This testcase is part of GDB, the GNU debugger.
+
+   Copyright 2025 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 <pthread.h>
+#include <assert.h>
+#include <signal.h>
+#include <stdio.h>
+#include <unistd.h>
+#include <stdbool.h>
+
+pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER;
+pthread_cond_t g_condvar = PTHREAD_COND_INITIALIZER;
+
+void *
+worker_function (void *arg)
+{
+  printf ("In worker, about to notify\n");
+  pthread_cond_signal (&g_condvar);
+
+  while (true)
+    sleep(1);
+
+  return NULL;
+}
+
+int
+main()
+{
+  pthread_t my_thread;
+
+  int result = pthread_create (&my_thread, NULL, worker_function, NULL);
+  assert (result == 0);
+
+  pthread_mutex_lock (&g_mutex);
+  pthread_cond_wait (&g_condvar, &g_mutex);
+
+  printf ("In main, have been woken.\n");
+  pthread_mutex_unlock (&g_mutex);
+
+  result = pthread_kill (my_thread, SIGTRAP);
+  assert (result == 0);
+
+  result = pthread_join (my_thread, NULL);
+  assert (result == 0);
+
+  return 0;
+}
diff --git a/gdb/testsuite/gdb.replay/missing-thread.exp b/gdb/testsuite/gdb.replay/missing-thread.exp
new file mode 100644 (file)
index 0000000..15a8aae
--- /dev/null
@@ -0,0 +1,237 @@
+# Copyright 2025 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/>.  */
+
+# This test confirms how GDB handles a badly behaving remote target.  The
+# remote target reports a stop event (signal delivery), then, as GDB is
+# processing the stop it syncs the thread list with the remote.
+#
+# The badly behaving remote target was dropping the signaled thread from the
+# thread list at this point, that is, the thread appeared to exit before an
+# exit event had been sent to (and seen by) GDB.
+#
+# At one point this was causing an assertion failed.  GDB would try to
+# process the signal stop event, and to do this would try to read some
+# registers.  Reading registers requires a regcache, and GDB will only
+# create a regcache for a non-exited thread.
+
+load_lib gdbserver-support.exp
+load_lib gdbreplay-support.exp
+
+require allow_gdbserver_tests
+require has_gdbreplay
+
+standard_testfile
+
+if { [build_executable "failed to build exec" $testfile $srcfile {debug pthreads}] } {
+    return -1
+}
+
+# Start the inferior and record a remote log for our interaction with it.
+# All we do is start the inferior and wait for thread 2 to receive a signal.
+# Check that GDB correctly shows the signal as received.  LOG_FILENAME is
+# where we should write the remote log.
+proc_with_prefix record_initial_logfile { log_filename } {
+    clean_restart $::testfile
+
+    # Make sure we're disconnected, in case we're testing with an
+    # extended-remote board, therefore already connected.
+    gdb_test "disconnect" ".*"
+
+    gdb_test_no_output "set sysroot" \
+       "setting sysroot before starting gdbserver"
+
+    # Start gdbserver like:
+    #   gdbserver :PORT ....
+    set res [gdbserver_start "" $::binfile]
+    set gdbserver_protocol [lindex $res 0]
+    set gdbserver_gdbport [lindex $res 1]
+
+    gdb_test_no_output "set remotelogfile $log_filename" \
+       "setup remotelogfile"
+
+    # Connect to gdbserver.
+    if {![gdb_target_cmd $gdbserver_protocol $gdbserver_gdbport] == 0} {
+       unsupported "$testfile (couldn't start gdbserver)"
+       return
+    }
+
+    gdb_breakpoint main
+    gdb_continue_to_breakpoint "continuing to main"
+
+    gdb_test "continue" \
+       "Thread $::decimal \[^\r\n\]+ received signal SIGTRAP, .*"
+
+    gdb_test "disconnect" ".*" \
+       "disconnect after seeing signal"
+}
+
+# Copy the remote log from IN_FILENAME to OUT_FILENAME, but modify one
+# particular line.
+#
+# The line to be modified is the last <threads>...</threads> line, this is
+# the reply from the remote that indicates the thread list.  It is expected
+# that the thread list will contain two threads.
+#
+# Whn DROP_BOTH is true then both threads will be removed from the modified
+# line.  Otherwise, only the second thread is removed.
+proc update_replay_log { in_filename out_filename drop_both } {
+    # Read IN_FILENAME into a list.
+    set fd [open $in_filename]
+    set data [read $fd]
+    close $fd
+    set lines [split $data "\n"]
+
+    # Find the last line in LINES that contains the <threads> list.
+    set idx -1
+    for { set i 0 } { $i < [llength $lines] } { incr i } {
+       if { [regexp "^r.*<threads>.*</threads>" [lindex $lines $i]] } {
+           set idx $i
+       }
+    }
+
+    # Modify the line by dropping the second thread.  This does assume
+    # the thread order as seen in the <threads>...</threads> list, but
+    # this seems stable for now.
+    set line [lindex $lines $idx]
+    set fixed_log false
+    if {[regexp "^(r .*<threads>\\\\n)(<thread id.*/>\\\\n)(<thread id.*/>\\\\n)(</threads>.*)$" $line \
+            match part1 part2 part3 part4]} {
+       if { $drop_both } {
+           set line $part1$part4
+       } else {
+           set line $part1$part2$part4
+       }
+       set lines [lreplace $lines $idx $idx $line]
+       set fixed_log true
+    }
+
+    # Write all the lines to OUT_FILENAME
+    set fd [open $out_filename "w"]
+    foreach l $lines {
+       puts $fd $l
+    }
+    close $fd
+
+    # Did we manage to update the log file?
+    return $fixed_log
+}
+
+# Replay the test process using REMOTE_LOG as the logfile to replay.  If
+# EXPECT_ERROR is true then after the final 'continue' we expect GDB to give
+# an error as the required thread is missing.  When EXPECT_ERROR is false
+# then we expect the test to complete as normal.  NON_STOP is eithe 'on' or
+# 'off' and indicates GDBs non-stop mode.
+proc_with_prefix replay_with_log { remote_log expect_error non_stop } {
+    clean_restart $::testfile
+
+    # Make sure we're disconnected, in case we're testing with an
+    # extended-remote board, therefore already connected.
+    gdb_test "disconnect" ".*"
+
+    gdb_test_no_output "set sysroot"
+
+    set res [gdbreplay_start $remote_log]
+    set gdbserver_protocol [lindex $res 0]
+    set gdbserver_gdbport [lindex $res 1]
+
+    # Connect to gdbserver.
+    if {![gdb_target_cmd $gdbserver_protocol $gdbserver_gdbport] == 0} {
+       fail "couldn't connect to gdbreplay"
+       return
+    }
+
+    gdb_breakpoint main
+    gdb_continue_to_breakpoint "continuing to main"
+
+    if { $expect_error } {
+       set expected_output \
+           [list \
+                "\\\[Thread \[^\r\n\]+ exited\\\]" \
+                "warning: command aborted, Thread \[^\r\n\]+ unexpectedly exited after signal stop event"]
+
+       if { !$non_stop } {
+           lappend expected_output "\\\[Switching to Thread \[^\r\n\]+\\\]"
+       }
+
+       gdb_test "continue" [multi_line {*}$expected_output]
+    } else {
+       # This is the original behaviour, we see this when running
+       # with the unmodified log.
+       gdb_test "continue" \
+           "Thread ${::decimal}(?: \[^\r\n\]+)? received signal SIGTRAP, .*"
+    }
+
+    gdb_test "disconnect" ".*" \
+       "disconnect after seeing signal"
+}
+
+# Run the complete test cycle; generate an initial log file, modify the log
+# file, then check that GDB correctly handles replaying the modified log
+# file.
+#
+# NON_STOP is either 'on' or 'off' and indicates GDB's non-stop mode.
+proc run_test { non_stop } {
+    if { $non_stop } {
+       set suffix "-ns"
+    } else {
+       set suffix ""
+    }
+
+    # The replay log is placed in 'replay.log'.
+    set remote_log [standard_output_file replay${suffix}.log]
+    set missing_1_log [standard_output_file replay-missing-1${suffix}.log]
+    set missing_2_log [standard_output_file replay-missing-2${suffix}.log]
+
+    record_initial_logfile $remote_log
+
+    if { ![update_replay_log $remote_log $missing_1_log false] } {
+       fail "couldn't update remote replay log (drop 1 case)"
+    }
+
+    if { ![update_replay_log $remote_log $missing_2_log true] } {
+       fail "couldn't update remote replay log (drop 2 case)"
+    }
+
+    with_test_prefix "with unmodified log" {
+       # Replay with the unmodified log.  This confirms that we can replay this
+       # scenario correctly.
+       replay_with_log $remote_log false $non_stop
+    }
+
+    with_test_prefix "missing 1 thread log" {
+       # Now replay with the modified log, this time the thread that receives
+       # the event should be missing from the thread list, GDB will give an
+       # error when the inferior stops.
+       replay_with_log $missing_1_log true $non_stop
+    }
+
+    with_test_prefix "missing 2 threads log" {
+       # When we drop both threads from the <threads> reply, GDB doesn't
+       # actually remove both threads from the inferior; an inferior must
+       # always have at least one thread.  So in this case, as the primary
+       # thread is first, GDB drops this, then retains the second thread, which
+       # is the one we're stopping in, and so, we don't expect to see the error
+       # in this case.
+       replay_with_log $missing_2_log false $non_stop
+    }
+}
+
+# Run the test twice, with non-stop on and off.
+foreach_with_prefix non_stop { on off } {
+    save_vars { ::GDBFLAGS } {
+       append ::GDBFLAGS " -ex \"set non-stop $non_stop\""
+       run_test $non_stop
+    }
+}