diff --git a/src/MICore/CommandFactories/MICommandFactory.cs b/src/MICore/CommandFactories/MICommandFactory.cs
index 54923e820..b63e2cd06 100644
--- a/src/MICore/CommandFactories/MICommandFactory.cs
+++ b/src/MICore/CommandFactories/MICommandFactory.cs
@@ -111,6 +111,23 @@ public virtual void DefineCurrentThread(int threadId)
public virtual int CurrentThread { get; }
+ ///
+ /// True if the debugger can debug more than one process ('inferior' in gdb terms) in a single
+ /// session, and can be asked to switch between them.
+ ///
+ public virtual bool SupportsMultipleInferiors => false;
+
+ ///
+ /// Makes 'threadId' the debugger's current thread. Commands issued by the engine normally name the
+ /// thread they apply to, so this is only needed to repair the debugger's notion of the current
+ /// thread after it has been left pointing at a thread which no longer exists.
+ ///
+ /// True if the current thread was changed, false if this debugger does not support it.
+ public virtual Task SelectThread(int threadId)
+ {
+ return Task.FromResult(false);
+ }
+
public virtual async Task ThreadInfo(uint? threadid = null)
{
string command = "-thread-info";
diff --git a/src/MICore/CommandFactories/gdb.cs b/src/MICore/CommandFactories/gdb.cs
index a268df1c2..b35a35687 100644
--- a/src/MICore/CommandFactories/gdb.cs
+++ b/src/MICore/CommandFactories/gdb.cs
@@ -47,6 +47,26 @@ public override bool SupportsChildProcessDebugging()
return true;
}
+ public override bool SupportsMultipleInferiors => true;
+
+ public override async Task SelectThread(int threadId)
+ {
+ ExclusiveLockToken lockToken = await _debugger.CommandLock.AquireExclusive();
+ try
+ {
+ // Force '-thread-select' to be sent even if 'threadId' is the thread we believe is already
+ // current: this is called precisely when gdb's current thread has changed underneath us.
+ _currentThreadId = 0;
+ await ThreadSelect(threadId, lockToken);
+ }
+ finally
+ {
+ lockToken.Close();
+ }
+
+ return true;
+ }
+
public override bool AllowCommandsWhileRunning()
{
return false;
diff --git a/src/MICore/Debugger.cs b/src/MICore/Debugger.cs
index e77fd8e76..fde858938 100755
--- a/src/MICore/Debugger.cs
+++ b/src/MICore/Debugger.cs
@@ -43,6 +43,8 @@ public class Debugger : ITransportCallback
public event EventHandler? ThreadCreatedEvent;
public event EventHandler? ThreadExitedEvent;
public event EventHandler? ThreadGroupExitedEvent;
+ // An inferior (a debuggee process) exited, but other inferiors are still being debugged
+ public event EventHandler? InferiorExitedEvent;
public event EventHandler? TelemetryEvent;
private int _exiting;
public ProcessState ProcessState { get; private set; }
@@ -110,6 +112,21 @@ public ResultEventArgs(Results results)
public uint Id { get; private set; }
};
+ public class InferiorExitedEventArgs : ResultEventArgs
+ {
+ ///
+ /// Id of a thread which is still alive after the inferior exited. The debugger's current
+ /// thread has already been set to this thread.
+ ///
+ public readonly int SurvivingThreadId;
+
+ public InferiorExitedEventArgs(Results results, int survivingThreadId)
+ : base(results)
+ {
+ SurvivingThreadId = survivingThreadId;
+ }
+ }
+
public class StoppingEventArgs : ResultEventArgs
{
public readonly BreakRequest AsyncRequest;
@@ -254,12 +271,124 @@ public Task AddInternalBreakAction(Func func)
}
}
+ ///
+ /// Handles the '*stopped,reason="exited*"' record which gdb sends when an inferior exits. gdb sends this
+ /// record for *any* inferior, and it contains nothing which identifies the inferior that exited, so on
+ /// its own it cannot be distinguished from the end of the debug session. When more than one inferior is
+ /// being debugged (e.g. 'set detach-on-fork off' plus 'set schedule-multiple on') the session has to
+ /// survive the exit of an inferior which isn't the last one.
+ ///
+ /// gdb sends '=thread-group-exited' for the inferior which is going away before this record, and
+ /// HandleThreadGroupExited removes that thread group from '_debuggeePids'. So a non-empty '_debuggeePids'
+ /// here means other inferiors are still being debugged. Once the last thread group goes away,
+ /// HandleThreadGroupExited schedules its own '*stopped,reason="exited"' record, and that is what ends the
+ /// debug session.
+ ///
+ /// True if the debug session should continue, false if this record ends the session.
+ private async Task TryHandleInferiorExit(Results results)
+ {
+ if (this.ProcessState == ProcessState.Exited || _terminating || _exiting != 0)
+ {
+ return false;
+ }
+
+ lock (_debuggeePids)
+ {
+ if (_debuggeePids.Count == 0)
+ {
+ // The single-inferior case: the only thread group is already gone, so the debuggee has exited.
+ return false;
+ }
+ }
+
+ // Only gdb can debug more than one inferior in a single session.
+ if (!MICommandFactory.SupportsMultipleInferiors)
+ {
+ return false;
+ }
+
+ // gdb stops every inferior when one of them exits (all-stop mode), so record that we are stopped
+ // before sending any commands.
+ this.ProcessState = ProcessState.Stopped;
+ FlushBreakStateData();
+
+ // gdb leaves the inferior which exited as the current one, so commands which don't name a thread --
+ // notably the plain '-exec-continue' sent by CmdContinueAsync -- would fail with
+ // "The program is not being run.". Point the debugger at a thread which is still alive.
+ int survivingThreadId;
+ try
+ {
+ Results threadsInfo = await MICommandFactory.ThreadInfo();
+ if (threadsInfo.ResultClass != ResultClass.done ||
+ !TryFindFirstThreadId(threadsInfo, out survivingThreadId) ||
+ !await MICommandFactory.SelectThread(survivingThreadId))
+ {
+ return false;
+ }
+ }
+ catch (MIException)
+ {
+ return false;
+ }
+ catch (ObjectDisposedException)
+ {
+ return false;
+ }
+
+ MICommandFactory.DefineCurrentThread(survivingThreadId);
+
+ // Run anything which was queued up while the debuggee was running -- those actions need a stopped
+ // target. Passing false for fIsAsyncBreak keeps DoInternalBreakActions from resuming the debuggee,
+ // since this stop wasn't caused by an internal async break.
+ if (await DoInternalBreakActions(false))
+ {
+ return true;
+ }
+
+ InferiorExitedEvent?.Invoke(this, new InferiorExitedEventArgs(results, survivingThreadId));
+
+ return true;
+ }
+
+ ///
+ /// Finds the id of the first thread in the result of '-thread-info'. Note that '-thread-info' does not
+ /// report a 'current-thread-id' when the current thread has gone away, which is the case after an
+ /// inferior exits.
+ ///
+ private static bool TryFindFirstThreadId(Results threadsInfo, out int threadId)
+ {
+ threadId = 0;
+
+ ValueListValue? threads = threadsInfo.TryFind("threads");
+ if (threads == null)
+ {
+ return false;
+ }
+
+ foreach (ResultValue thread in threads.Content)
+ {
+ if (thread.Contains("id"))
+ {
+ threadId = thread.FindInt("id");
+ return true;
+ }
+ }
+
+ return false;
+ }
+
private async void OnStopped(Results results)
{
string reason = results.TryFindString("reason");
if (reason.StartsWith("exited", StringComparison.Ordinal) || reason.StartsWith("disconnected", StringComparison.Ordinal))
{
+ if (await TryHandleInferiorExit(results))
+ {
+ // One of several inferiors exited. The debug session continues with the others.
+ return;
+ }
+
if (this.ProcessState != ProcessState.Exited)
{
this.ProcessState = ProcessState.Exited;
diff --git a/src/MICoreUnitTests/MultiInferiorExitTests.cs b/src/MICoreUnitTests/MultiInferiorExitTests.cs
new file mode 100644
index 000000000..dedf1d297
--- /dev/null
+++ b/src/MICoreUnitTests/MultiInferiorExitTests.cs
@@ -0,0 +1,254 @@
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using MICore;
+using Microsoft.DebugEngineHost;
+using Xunit;
+
+namespace MICoreUnitTests
+{
+ ///
+ /// Tests for how the engine reacts to '*stopped,reason="exited-normally"'. gdb sends this record whenever
+ /// *any* inferior exits and the record does not say which one, so the record must only end the debug
+ /// session when the inferior which exited was the last one.
+ /// See https://github.com/microsoft/vscode-cpptools/issues/13893.
+ ///
+ public class MultiInferiorExitTests
+ {
+ [Fact]
+ public void TestSingleInferiorExitEndsSession()
+ {
+ using (TestHarness harness = new TestHarness())
+ {
+ harness.Send("=thread-group-added,id=\"i1\"");
+ harness.Send("=thread-group-started,id=\"i1\",pid=\"1000\"");
+ harness.Send("=thread-created,id=\"1\",group-id=\"i1\"");
+
+ // gdb sends '=thread-group-exited' before the '*stopped' record, so by the time the '*stopped'
+ // record is processed there are no thread groups left.
+ harness.Send("=thread-exited,id=\"1\",group-id=\"i1\"");
+ harness.Send("=thread-group-exited,id=\"i1\",exit-code=\"0\"");
+ harness.Send("*stopped,reason=\"exited-normally\"");
+
+ Assert.True(harness.WaitForProcessExit(), "The debug session should end when the only inferior exits.");
+ Assert.Equal(0, harness.InferiorExitedCount);
+ Assert.Equal(ProcessState.Exited, harness.Debugger.ProcessState);
+ }
+ }
+
+ [Fact]
+ public void TestNonFinalInferiorExitKeepsSessionAlive()
+ {
+ using (TestHarness harness = new TestHarness())
+ {
+ harness.Send("=thread-group-added,id=\"i1\"");
+ harness.Send("=thread-group-started,id=\"i1\",pid=\"1000\"");
+ harness.Send("=thread-created,id=\"1\",group-id=\"i1\"");
+
+ // The debuggee forked with 'set detach-on-fork off', so gdb added a second inferior.
+ harness.Send("=thread-group-added,id=\"i2\"");
+ harness.Send("=thread-group-started,id=\"i2\",pid=\"1001\"");
+ harness.Send("=thread-created,id=\"2\",group-id=\"i2\"");
+
+ // The child exits. Thread group 'i1' is still alive.
+ harness.Send("=thread-exited,id=\"2\",group-id=\"i2\"");
+ harness.Send("=thread-group-exited,id=\"i2\",exit-code=\"0\"");
+ harness.Send("*stopped,reason=\"exited-normally\"");
+
+ Assert.True(harness.WaitForInferiorExited(), "The exit of one of two inferiors should be reported as an inferior exit.");
+ Assert.Equal(0, harness.ProcessExitCount);
+ Assert.Equal(ProcessState.Stopped, harness.Debugger.ProcessState);
+ Assert.Equal(1, harness.SurvivingThreadId);
+
+ // gdb leaves the exited inferior current, so the engine must select a live thread to make
+ // subsequent commands (e.g. a plain '-exec-continue') work.
+ Assert.Contains("-thread-select 1", harness.CommandsSent);
+ }
+ }
+
+ [Fact]
+ public void TestLastInferiorExitEndsSession()
+ {
+ using (TestHarness harness = new TestHarness())
+ {
+ harness.Send("=thread-group-added,id=\"i1\"");
+ harness.Send("=thread-group-started,id=\"i1\",pid=\"1000\"");
+ harness.Send("=thread-group-added,id=\"i2\"");
+ harness.Send("=thread-group-started,id=\"i2\",pid=\"1001\"");
+
+ harness.Send("=thread-group-exited,id=\"i2\",exit-code=\"0\"");
+ harness.Send("*stopped,reason=\"exited-normally\"");
+ Assert.True(harness.WaitForInferiorExited(), "The first of two inferiors to exit should not end the session.");
+ Assert.Equal(0, harness.ProcessExitCount);
+
+ // Now the remaining inferior exits. HandleThreadGroupExited schedules the synthetic
+ // '*stopped,reason="exited"' record which ends the session.
+ harness.Send("=thread-group-exited,id=\"i1\",exit-code=\"0\"");
+
+ Assert.True(harness.WaitForProcessExit(), "The debug session should end when the last inferior exits.");
+ }
+ }
+
+ private sealed class TestHarness : IDisposable
+ {
+ private const int TimeoutMs = 10000;
+
+ private readonly ScriptedTransport _transport;
+
+ public MICore.Debugger Debugger { get; }
+ public int ProcessExitCount { get; private set; }
+ public int InferiorExitedCount { get; private set; }
+ public int SurvivingThreadId { get; private set; }
+ public IReadOnlyList CommandsSent => _transport.Commands;
+
+ public TestHarness()
+ {
+ LaunchOptions launchOptions = LaunchOptions.GetInstance(
+ null,
+ "bogus-exe-path",
+ null,
+ null,
+ GetLaunchOptionsXml(),
+ false,
+ null,
+ TargetEngine.Native,
+ null);
+
+ this.Debugger = new MICore.Debugger(launchOptions, Logger.EnsureInitialized());
+ this.Debugger.ProcessExitEvent += (o, args) => this.ProcessExitCount++;
+ this.Debugger.InferiorExitedEvent += (o, args) =>
+ {
+ this.SurvivingThreadId = ((MICore.Debugger.InferiorExitedEventArgs)args).SurvivingThreadId;
+ this.InferiorExitedCount++;
+ };
+
+ _transport = new ScriptedTransport();
+ this.Debugger.Init(_transport, launchOptions);
+ }
+
+ public void Send(string line)
+ {
+ ((ITransportCallback)this.Debugger).OnStdOutLine(line);
+ }
+
+ public bool WaitForProcessExit()
+ {
+ return Wait(() => this.ProcessExitCount > 0);
+ }
+
+ public bool WaitForInferiorExited()
+ {
+ return Wait(() => this.InferiorExitedCount > 0);
+ }
+
+ // The MI records are processed by 'async void' methods, so the effects may not be visible by the
+ // time Send returns.
+ private static bool Wait(Func condition)
+ {
+ System.Diagnostics.Stopwatch stopwatch = System.Diagnostics.Stopwatch.StartNew();
+ while (stopwatch.ElapsedMilliseconds < TimeoutMs)
+ {
+ if (condition())
+ {
+ return true;
+ }
+
+ Task.Delay(10).Wait();
+ }
+
+ return condition();
+ }
+
+ private static string GetLaunchOptionsXml()
+ {
+ string fakeFilePath = typeof(MultiInferiorExitTests).Assembly.Location;
+ return string.Concat("");
+ }
+
+ public void Dispose()
+ {
+ _transport.Close();
+ }
+ }
+
+ ///
+ /// A transport which answers the handful of commands this test's code path sends, instead of replaying
+ /// a captured log the way MockTransport does.
+ ///
+ private sealed class ScriptedTransport : ITransport
+ {
+ private ITransportCallback? _callback;
+ private readonly List _commands = new List();
+
+ ///
+ /// Commands sent by the engine, with the MI token stripped off.
+ ///
+ public IReadOnlyList Commands => _commands;
+
+ public bool IsClosed { get; private set; }
+
+ public int DebuggerPid => 0;
+
+ public void Init(ITransportCallback transportCallback, LaunchOptions options, Logger logger, HostWaitLoop? waitLoop = null)
+ {
+ _callback = transportCallback;
+ }
+
+ public void Send(string cmd)
+ {
+ if (string.IsNullOrEmpty(cmd))
+ {
+ return;
+ }
+
+ int tokenLength = 0;
+ while (tokenLength < cmd.Length && char.IsDigit(cmd[tokenLength]))
+ {
+ tokenLength++;
+ }
+
+ string token = cmd.Substring(0, tokenLength);
+ string command = cmd.Substring(tokenLength);
+ _commands.Add(command);
+
+ string response;
+ if (command.StartsWith("-thread-info", StringComparison.Ordinal))
+ {
+ // Note that gdb reports no 'current-thread-id' when the current thread has exited.
+ response = "^done,threads=[{id=\"1\",target-id=\"Thread 1\",state=\"stopped\"}]";
+ }
+ else
+ {
+ response = "^done";
+ }
+
+ _callback?.OnStdOutLine(string.Concat(token, response));
+ }
+
+ public void Close()
+ {
+ this.IsClosed = true;
+ }
+
+ public int ExecuteSyncCommand(string commandDescription, string commandText, int timeout, out string output, out string error)
+ {
+ throw new NotImplementedException();
+ }
+
+ public bool CanExecuteCommand()
+ {
+ return false;
+ }
+ }
+ }
+}
diff --git a/src/MIDebugEngine/Engine.Impl/DebuggedProcess.cs b/src/MIDebugEngine/Engine.Impl/DebuggedProcess.cs
index 27edfad98..0462334ba 100755
--- a/src/MIDebugEngine/Engine.Impl/DebuggedProcess.cs
+++ b/src/MIDebugEngine/Engine.Impl/DebuggedProcess.cs
@@ -433,6 +433,35 @@ public DebuggedProcess(bool bLaunched, LaunchOptions launchOptions, ISampleEngin
ThreadCache.ThreadGroupExitedEvent(result.Results.FindString("id"));
};
+ // One of several inferiors exited, so the debug session continues with the remaining ones. gdb has
+ // stopped everything, so report a break on a thread which is still alive, the same way the pause
+ // button does. Without this the UI would show the debuggee as running while gdb is stopped.
+ InferiorExitedEvent += async delegate (object o, EventArgs args)
+ {
+ // NOTE: This is an async void method, so make sure exceptions are caught and somehow reported
+ MICore.Debugger.InferiorExitedEventArgs inferiorExited = (MICore.Debugger.InferiorExitedEventArgs)args;
+
+ try
+ {
+ ThreadCache.MarkDirty();
+ MICommandFactory.DefineCurrentThread(inferiorExited.SurvivingThreadId);
+
+ DebuggedThread thread = await ThreadCache.GetThread(inferiorExited.SurvivingThreadId);
+ if (thread == null)
+ {
+ return;
+ }
+
+ await ThreadCache.StackFrames(thread); // prepopulate the break thread in the thread cache
+ ThreadCache.SendThreadEvents(this, null); // push the exited inferior's thread removals to the UI
+
+ _callback.OnAsyncBreakComplete(thread);
+ }
+ catch (Exception e) when (ExceptionHelper.BeforeCatch(e, Logger, reportOnlyCorrupting: true))
+ {
+ }
+ };
+
TelemetryEvent += (object o, ResultEventArgs args) =>
{
string eventName;